@@ -5,6 +5,7 @@ import type { AddressInfo } from "node:net";
55import test from "node:test" ;
66import type { AgentIdentity , SponsorProof } from "@relayauth/types" ;
77import { observerBus , type ObserverEvent } from "../lib/events.js" ;
8+ import { FixedWindowRateLimiter } from "../lib/rate-limit.js" ;
89import {
910 assertJsonResponse ,
1011 createTestApp ,
@@ -31,8 +32,8 @@ type CreatedIdentity = AgentIdentity & {
3132 sponsorChain : string [ ] ;
3233} ;
3334
34- function signIdToken ( claims : Record < string , unknown > ) : string {
35- const header = Buffer . from ( JSON . stringify ( { alg : "RS256" , typ : "JWT" , kid : OIDC_KID } ) )
35+ function signIdToken ( claims : Record < string , unknown > , kid = OIDC_KID ) : string {
36+ const header = Buffer . from ( JSON . stringify ( { alg : "RS256" , typ : "JWT" , kid } ) )
3637 . toString ( "base64url" ) ;
3738 const payload = Buffer . from ( JSON . stringify ( claims ) ) . toString ( "base64url" ) ;
3839 const signingInput = `${ header } .${ payload } ` ;
@@ -41,11 +42,17 @@ function signIdToken(claims: Record<string, unknown>): string {
4142 return `${ signingInput } .${ signature } ` ;
4243}
4344
44- async function startOidcFixture ( t : test . TestContext ) : Promise < { issuer : string } > {
45+ async function startOidcFixture (
46+ t : test . TestContext ,
47+ cacheControl = "public, max-age=60" ,
48+ ) : Promise < { issuer : string ; requestCount ( path : string ) : number } > {
4549 let issuer = "" ;
50+ const requestCounts = new Map < string , number > ( ) ;
4651 const server = createServer ( ( request , response ) => {
52+ const path = request . url ?? "" ;
53+ requestCounts . set ( path , ( requestCounts . get ( path ) ?? 0 ) + 1 ) ;
4754 response . setHeader ( "content-type" , "application/json" ) ;
48- response . setHeader ( "cache-control" , "public, max-age=60" ) ;
55+ response . setHeader ( "cache-control" , cacheControl ) ;
4956 if ( request . url === "/.well-known/openid-configuration" ) {
5057 response . end ( JSON . stringify ( { issuer, jwks_uri : `${ issuer } /jwks` } ) ) ;
5158 return ;
@@ -67,7 +74,10 @@ async function startOidcFixture(t: test.TestContext): Promise<{ issuer: string }
6774 t . after ( ( ) => new Promise < void > ( ( resolve , reject ) => {
6875 server . close ( ( error ) => error ? reject ( error ) : resolve ( ) ) ;
6976 } ) ) ;
70- return { issuer } ;
77+ return {
78+ issuer,
79+ requestCount : ( path : string ) => requestCounts . get ( path ) ?? 0 ,
80+ } ;
7181}
7282
7383function adminAuthorization ( org : string ) : HeadersInit {
@@ -404,6 +414,83 @@ test("sponsor proof requires a valid intent", async (t) => {
404414 }
405415} ) ;
406416
417+ test ( "sponsor proof is rate limited per organization and API key" , async ( t ) => {
418+ const { issuer } = await startOidcFixture ( t ) ;
419+ const org = "org_oidc_proof_rate_limit" ;
420+ const app = createTestApp (
421+ {
422+ RELAYAUTH_SPONSOR_FEDERATIONS : JSON . stringify ( {
423+ [ org ] : { sponsorBinding : "oidc" , issuer, clientId : "chief-fixture" } ,
424+ } ) ,
425+ } ,
426+ { identityCreateRateLimiter : new FixedWindowRateLimiter ( 1 , 60_000 ) } ,
427+ ) ;
428+ const apiKey = await createWorkspaceApiKey ( app , org ) ;
429+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
430+ const request = ( ) => app . request (
431+ createTestRequest (
432+ "POST" ,
433+ "/v1/sponsors/proof" ,
434+ {
435+ idToken : signIdToken ( {
436+ iss : issuer ,
437+ sub : "alice" ,
438+ aud : "chief-fixture" ,
439+ iat : now ,
440+ exp : now + 300 ,
441+ } ) ,
442+ intent : "approval" ,
443+ } ,
444+ { "x-api-key" : apiKey } ,
445+ ) ,
446+ undefined ,
447+ app . bindings ,
448+ ) ;
449+
450+ await assertJsonResponse < SponsorProof > ( await request ( ) , 201 ) ;
451+ const refused = await request ( ) ;
452+ const body = await assertJsonResponse < { code : string } > ( refused , 429 ) ;
453+ assert . equal ( body . code , "rate_limited" ) ;
454+ assert . equal ( refused . headers . get ( "retry-after" ) , "60" ) ;
455+ } ) ;
456+
457+ test ( "unknown OIDC kids cannot bypass JWKS cache or forced-refresh cooldown" , async ( t ) => {
458+ const { issuer, requestCount } = await startOidcFixture ( t , "public, max-age=0" ) ;
459+ const org = "org_oidc_jwks_refresh_limit" ;
460+ const app = createTestApp ( {
461+ RELAYAUTH_SPONSOR_FEDERATIONS : JSON . stringify ( {
462+ [ org ] : { sponsorBinding : "oidc" , issuer, clientId : "chief-fixture" } ,
463+ } ) ,
464+ } ) ;
465+ const apiKey = await createWorkspaceApiKey ( app , org ) ;
466+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
467+ const proofRequest = ( kid : string ) => app . request (
468+ createTestRequest (
469+ "POST" ,
470+ "/v1/sponsors/proof" ,
471+ {
472+ idToken : signIdToken ( {
473+ iss : issuer ,
474+ sub : "alice" ,
475+ aud : "chief-fixture" ,
476+ iat : now ,
477+ exp : now + 300 ,
478+ } , kid ) ,
479+ intent : "approval" ,
480+ } ,
481+ { "x-api-key" : apiKey } ,
482+ ) ,
483+ undefined ,
484+ app . bindings ,
485+ ) ;
486+
487+ await assertJsonResponse < SponsorProof > ( await proofRequest ( OIDC_KID ) , 201 ) ;
488+ await assertJsonResponse < { code : string } > ( await proofRequest ( "attacker-kid-1" ) , 403 ) ;
489+ await assertJsonResponse < { code : string } > ( await proofRequest ( "attacker-kid-2" ) , 403 ) ;
490+ assert . equal ( requestCount ( "/.well-known/openid-configuration" ) , 1 ) ;
491+ assert . equal ( requestCount ( "/jwks" ) , 2 ) ;
492+ } ) ;
493+
407494test ( "OIDC subject mapping is collision-free for raw and encoded-looking values" , async ( t ) => {
408495 const { issuer } = await startOidcFixture ( t ) ;
409496 const org = "org_oidc_subject_encoding" ;
0 commit comments