@@ -92,6 +92,7 @@ interface CapturedResponse {
9292 redirectUrl ?: string ;
9393 headers : Record < string , string > ;
9494 cookies : Array < { name : string ; value : string ; opts ?: unknown } > ;
95+ clearedCookies : Array < { name : string ; opts ?: unknown } > ;
9596 contentType ?: string ;
9697}
9798
@@ -100,13 +101,15 @@ const makeReq = (init: {
100101 query ?: Record < string , unknown > ;
101102 params ?: Record < string , unknown > ;
102103 headers ?: Record < string , string > ;
104+ cookies ?: Record < string , string > ;
103105 method ?: string ;
104106} ) : Request => {
105107 return {
106108 body : init . body ?? { } ,
107109 query : init . query ?? { } ,
108110 params : init . params ?? { } ,
109111 headers : init . headers ?? { } ,
112+ cookies : init . cookies ?? { } ,
110113 method : init . method ?? 'GET' ,
111114 } as unknown as Request ;
112115} ;
@@ -117,6 +120,7 @@ const makeRes = () => {
117120 body : undefined ,
118121 headers : { } ,
119122 cookies : [ ] ,
123+ clearedCookies : [ ] ,
120124 } ;
121125 const res = {
122126 json : vi . fn ( ( value : unknown ) => {
@@ -155,6 +159,10 @@ const makeRes = () => {
155159 captured . cookies . push ( { name, value, opts } ) ;
156160 return res ;
157161 } ) ,
162+ clearCookie : vi . fn ( ( name : string , opts ?: unknown ) => {
163+ captured . clearedCookies . push ( { name, opts } ) ;
164+ return res ;
165+ } ) ,
158166 type : vi . fn ( ( ) => res ) ,
159167 } ;
160168 return { res : res as unknown as Response , captured } ;
@@ -767,6 +775,177 @@ describe('OIDCController login callback', () => {
767775 } ) ;
768776} ) ;
769777
778+ // -- Browser binding / login-CSRF --------------------------------------
779+
780+ describe ( 'OIDCController browser binding' , ( ) => {
781+ const NONCE_COOKIE = 'puter_oidc_nonce' ;
782+
783+ const stubIdP = ( sub : string , email : string ) => {
784+ vi . spyOn ( oidc ( ) , 'exchangeCodeForTokens' ) . mockResolvedValue ( {
785+ access_token : 'access' ,
786+ id_token : 'id' ,
787+ } as never ) ;
788+ vi . spyOn ( oidc ( ) , 'getUserInfo' ) . mockResolvedValue ( {
789+ sub,
790+ email,
791+ email_verified : true ,
792+ } as never ) ;
793+ } ;
794+
795+ it ( '/start sets an HttpOnly nonce cookie matching the nonce embedded in state' , async ( ) => {
796+ const { res, captured } = makeRes ( ) ;
797+ await callRoute (
798+ 'get' ,
799+ '/auth/oidc/:provider/start' ,
800+ makeReq ( { params : { provider : 'custom' } } ) ,
801+ res ,
802+ ) ;
803+
804+ const nonceCookie = captured . cookies . find (
805+ ( c ) => c . name === NONCE_COOKIE ,
806+ ) ;
807+ expect ( nonceCookie ) . toBeTruthy ( ) ;
808+ expect ( nonceCookie ?. value ) . toBeTruthy ( ) ;
809+ expect ( ( nonceCookie ?. opts as { httpOnly ?: boolean } ) ?. httpOnly ) . toBe (
810+ true ,
811+ ) ;
812+
813+ // The cookie value must equal the nonce baked into the signed state.
814+ const state = new URL ( captured . redirectUrl ?? '' ) . searchParams . get (
815+ 'state' ,
816+ ) ;
817+ const decoded = oidc ( ) . verifyState ( state ! ) ;
818+ expect ( decoded ?. nonce ) . toBe ( nonceCookie ?. value ) ;
819+ } ) ;
820+
821+ it ( 'completes login when the nonce cookie matches the state nonce' , async ( ) => {
822+ const sub = `sub-${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ;
823+ const email = `bind-${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } @test.local` ;
824+ const nonce = 'browser-nonce-match' ;
825+ const state = oidc ( ) . signState ( {
826+ provider : 'custom' ,
827+ redirect_uri : TEST_ORIGIN + '/' ,
828+ nonce,
829+ } ) ;
830+ stubIdP ( sub , email ) ;
831+
832+ const { res, captured } = makeRes ( ) ;
833+ await callRoute (
834+ 'get' ,
835+ '/auth/oidc/callback/login' ,
836+ makeReq ( {
837+ query : { code : 'c' , state } ,
838+ cookies : { [ NONCE_COOKIE ] : nonce } ,
839+ } ) ,
840+ res ,
841+ ) ;
842+
843+ // Session cookie issued; single-use nonce cookie cleared.
844+ expect ( captured . cookies ) . toHaveLength ( 1 ) ;
845+ expect ( captured . redirectUrl ) . toBe ( TEST_ORIGIN + '/' ) ;
846+ expect (
847+ captured . clearedCookies . some ( ( c ) => c . name === NONCE_COOKIE ) ,
848+ ) . toBe ( true ) ;
849+ } ) ;
850+
851+ it ( 'rejects login (no session cookie) when the nonce cookie is absent — the login-CSRF case' , async ( ) => {
852+ const state = oidc ( ) . signState ( {
853+ provider : 'custom' ,
854+ redirect_uri : TEST_ORIGIN + '/' ,
855+ nonce : 'attacker-flow-nonce' ,
856+ } ) ;
857+ // If enforcement were missing, this would resolve a user and set a
858+ // session cookie for the victim's browser. It must not get that far.
859+ const exchangeSpy = vi . spyOn ( oidc ( ) , 'exchangeCodeForTokens' ) ;
860+
861+ const { res, captured } = makeRes ( ) ;
862+ await callRoute (
863+ 'get' ,
864+ '/auth/oidc/callback/login' ,
865+ // Victim's browser has no nonce cookie for the attacker's flow.
866+ makeReq ( { query : { code : 'c' , state } , cookies : { } } ) ,
867+ res ,
868+ ) ;
869+
870+ expect ( captured . redirectStatus ) . toBe ( 302 ) ;
871+ expect ( captured . redirectUrl ) . toContain ( 'auth_error=1' ) ;
872+ expect ( captured . cookies ) . toHaveLength ( 0 ) ;
873+ // We bail before ever exchanging the code.
874+ expect ( exchangeSpy ) . not . toHaveBeenCalled ( ) ;
875+ } ) ;
876+
877+ it ( 'rejects login when the nonce cookie does not match the state nonce' , async ( ) => {
878+ const state = oidc ( ) . signState ( {
879+ provider : 'custom' ,
880+ redirect_uri : TEST_ORIGIN + '/' ,
881+ nonce : 'expected-nonce' ,
882+ } ) ;
883+ const exchangeSpy = vi . spyOn ( oidc ( ) , 'exchangeCodeForTokens' ) ;
884+
885+ const { res, captured } = makeRes ( ) ;
886+ await callRoute (
887+ 'get' ,
888+ '/auth/oidc/callback/login' ,
889+ makeReq ( {
890+ query : { code : 'c' , state } ,
891+ cookies : { [ NONCE_COOKIE ] : 'a-different-nonce' } ,
892+ } ) ,
893+ res ,
894+ ) ;
895+
896+ expect ( captured . redirectUrl ) . toContain ( 'auth_error=1' ) ;
897+ expect ( captured . cookies ) . toHaveLength ( 0 ) ;
898+ expect ( exchangeSpy ) . not . toHaveBeenCalled ( ) ;
899+ } ) ;
900+
901+ it ( 'rejects the revalidate callback (400) when the nonce cookie is missing' , async ( ) => {
902+ const state = oidc ( ) . signState ( {
903+ provider : 'custom' ,
904+ flow : 'revalidate' ,
905+ user_uuid : uuidv4 ( ) ,
906+ nonce : 'reval-nonce' ,
907+ } ) ;
908+ const exchangeSpy = vi . spyOn ( oidc ( ) , 'exchangeCodeForTokens' ) ;
909+
910+ const { res, captured } = makeRes ( ) ;
911+ await callRoute (
912+ 'get' ,
913+ '/auth/oidc/callback/revalidate' ,
914+ makeReq ( { query : { code : 'c' , state } , cookies : { } } ) ,
915+ res ,
916+ ) ;
917+
918+ expect ( captured . statusCode ) . toBe ( 400 ) ;
919+ expect ( exchangeSpy ) . not . toHaveBeenCalled ( ) ;
920+ } ) ;
921+
922+ it ( 'lets legacy nonce-less state through (deploy grace) without touching the nonce cookie' , async ( ) => {
923+ const sub = `sub-${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ;
924+ const email = `legacy-${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } @test.local` ;
925+ // No `nonce` field — mimics a state signed before this shipped.
926+ const state = oidc ( ) . signState ( {
927+ provider : 'custom' ,
928+ redirect_uri : TEST_ORIGIN + '/' ,
929+ } ) ;
930+ stubIdP ( sub , email ) ;
931+
932+ const { res, captured } = makeRes ( ) ;
933+ await callRoute (
934+ 'get' ,
935+ '/auth/oidc/callback/login' ,
936+ makeReq ( { query : { code : 'c' , state } , cookies : { } } ) ,
937+ res ,
938+ ) ;
939+
940+ // Proceeds as before; no nonce cookie is cleared for legacy states.
941+ expect ( captured . cookies ) . toHaveLength ( 1 ) ;
942+ expect ( captured . redirectUrl ) . toBe ( TEST_ORIGIN + '/' ) ;
943+ expect (
944+ captured . clearedCookies . some ( ( c ) => c . name === NONCE_COOKIE ) ,
945+ ) . toBe ( false ) ;
946+ } ) ;
947+ } ) ;
948+
770949// ── /auth/oidc/callback/signup ──────────────────────────────────────
771950
772951describe ( 'OIDCController signup callback' , ( ) => {
0 commit comments