@@ -7,18 +7,22 @@ const discoverMock = vi.fn();
77const callbackMock = vi . fn ( ) ;
88const clientConfigs : Record < string , unknown > [ ] = [ ] ;
99const issuerMetadata = {
10+ issuer : "https://issuer.example" ,
1011 token_endpoint_auth_methods_supported : [ "client_secret_basic" ] ,
1112 id_token_signing_alg_values_supported : [ "HS256" , "RS256" ] ,
1213} ;
14+ const discoveredIssuer = {
15+ metadata : issuerMetadata ,
16+ } as any ;
1317
14- vi . mock ( "openid-client" , ( ) => {
15- const issuer = {
16- issuer : "https://issuer.example" ,
17- metadata : issuerMetadata ,
18- } as any ;
18+ Object . defineProperty ( discoveredIssuer , "issuer" , {
19+ get : ( ) => issuerMetadata . issuer ,
20+ enumerable : true ,
21+ } ) ;
1922
23+ vi . mock ( "openid-client" , ( ) => {
2024 class MockClient {
21- issuer = issuer ;
25+ issuer = discoveredIssuer ;
2226
2327 constructor ( config : Record < string , unknown > ) {
2428 clientConfigs . push ( { ...config } ) ;
@@ -37,8 +41,8 @@ vi.mock("openid-client", () => {
3741 }
3842 }
3943
40- issuer . Client = MockClient ;
41- discoverMock . mockResolvedValue ( issuer ) ;
44+ discoveredIssuer . Client = MockClient ;
45+ discoverMock . mockResolvedValue ( discoveredIssuer ) ;
4246
4347 return {
4448 Issuer : {
@@ -64,7 +68,7 @@ const base64UrlEncode = (value: Buffer | string): string => {
6468
6569const signFlowPayload = ( encodedPayload : string , secret : string ) : string =>
6670 base64UrlEncode (
67- crypto . createHmac ( "sha256" , secret ) . update ( encodedPayload , "utf8" ) . digest ( )
71+ crypto . createHmac ( "sha256" , secret ) . update ( encodedPayload , "utf8" ) . digest ( ) ,
6872 ) ;
6973
7074const makeFlowCookie = (
@@ -75,7 +79,7 @@ const makeFlowCookie = (
7579 codeVerifier : string ;
7680 returnTo : string ;
7781 expiresAt : number ;
78- } > = { }
82+ } > = { } ,
7983) => {
8084 const payload = {
8185 state : "state-fixed" ,
@@ -90,7 +94,9 @@ const makeFlowCookie = (
9094 return `${ encodedPayload } .${ signature } ` ;
9195} ;
9296
93- const createPrismaMock = ( ) => {
97+ const createPrismaMock = ( options ?: {
98+ providerIdentity ?: { id : string ; issuer : string ; subject : string } | null ;
99+ } ) => {
94100 const user = {
95101 id : "user-1" ,
96102 username : null ,
@@ -101,9 +107,20 @@ const createPrismaMock = () => {
101107 isActive : true ,
102108 } ;
103109
110+ const providerIdentity = options ?. providerIdentity ?? null ;
111+
104112 const tx = {
105113 authIdentity : {
106- findUnique : vi . fn ( async ( ) => null ) ,
114+ findUnique : vi . fn ( async ( args ?: Record < string , unknown > ) => {
115+ const where = ( args ?. where || { } ) as Record < string , unknown > ;
116+ if ( where . issuer_subject ) {
117+ return null ;
118+ }
119+ if ( where . provider_userId ) {
120+ return providerIdentity ;
121+ }
122+ return null ;
123+ } ) ,
107124 update : vi . fn ( async ( ) => ( { } ) ) ,
108125 create : vi . fn ( async ( ) => ( { } ) ) ,
109126 } ,
@@ -120,22 +137,27 @@ const createPrismaMock = () => {
120137
121138 return {
122139 $transaction : vi . fn ( async ( runner : ( arg : typeof tx ) => Promise < unknown > ) =>
123- runner ( tx )
140+ runner ( tx ) ,
124141 ) ,
125142 refreshToken : {
126143 create : vi . fn ( async ( ) => ( { } ) ) ,
127144 } ,
145+ __tx : tx ,
128146 } ;
129147} ;
130148
131- const createApp = async ( idTokenAlgOverride : string | null ) => {
149+ const createApp = async (
150+ idTokenAlgOverride : string | null ,
151+ issuerUrlOverride : string | null = null ,
152+ prismaOverride ?: Record < string , unknown > ,
153+ ) => {
132154 const { registerOidcRoutes } = await import ( "./oidcRoutes" ) ;
133155 const app = express ( ) ;
134156 const router = express . Router ( ) ;
135157 app . use ( router ) ;
136158 registerOidcRoutes ( {
137159 router,
138- prisma : createPrismaMock ( ) as any ,
160+ prisma : ( prismaOverride || createPrismaMock ( ) ) as any ,
139161 ensureAuthEnabled : vi . fn ( async ( ) => true ) ,
140162 ensureSystemConfig : vi . fn ( async ( ) => ( {
141163 id : "default" ,
@@ -158,7 +180,7 @@ const createApp = async (idTokenAlgOverride: string | null) => {
158180 enabled : true ,
159181 enforced : true ,
160182 providerName : "Test OIDC" ,
161- issuerUrl : "https://issuer.example" ,
183+ issuerUrl : issuerUrlOverride || "https://issuer.example" ,
162184 discoveryUrl : null ,
163185 clientId : "client-id" ,
164186 clientSecret : "client-secret" ,
@@ -183,15 +205,38 @@ describe("OIDC callback alg mismatch fallback", () => {
183205 beforeEach ( ( ) => {
184206 vi . clearAllMocks ( ) ;
185207 clientConfigs . length = 0 ;
208+ issuerMetadata . issuer = "https://issuer.example" ;
186209 issuerMetadata . id_token_signing_alg_values_supported = [ "HS256" , "RS256" ] ;
187210 } ) ;
188211
212+ it ( "uses configured issuer when discovery issuer differs in split-horizon setups" , async ( ) => {
213+ issuerMetadata . issuer = "http://keycloak:8080/realms/excalidash" ;
214+
215+ const app = await createApp ( null ) ;
216+ const response = await request ( app ) . get ( "/oidc/start" ) ;
217+
218+ expect ( response . status ) . toBe ( 302 ) ;
219+ expect ( discoveredIssuer . metadata . issuer ) . toBe ( "https://issuer.example" ) ;
220+ } ) ;
221+
222+ it ( "treats trailing slash issuer differences as equivalent" , async ( ) => {
223+ issuerMetadata . issuer = "https://issuer.example" ;
224+
225+ const app = await createApp ( null , "https://issuer.example/" ) ;
226+ const response = await request ( app ) . get ( "/oidc/start" ) ;
227+
228+ expect ( response . status ) . toBe ( 302 ) ;
229+ expect ( discoveredIssuer . metadata . issuer ) . toBe ( "https://issuer.example" ) ;
230+ } ) ;
231+
189232 it ( "retries once with observed HS alg when default expected alg mismatches" , async ( ) => {
190233 let callCount = 0 ;
191234 callbackMock . mockImplementation ( async ( ) => {
192235 callCount += 1 ;
193236 if ( callCount === 1 ) {
194- throw new Error ( "unexpected JWT alg received, expected RS256, got: HS256" ) ;
237+ throw new Error (
238+ "unexpected JWT alg received, expected RS256, got: HS256" ,
239+ ) ;
195240 }
196241 return {
197242 claims : ( ) => ( {
@@ -205,9 +250,7 @@ describe("OIDC callback alg mismatch fallback", () => {
205250 const app = await createApp ( null ) ;
206251 const response = await request ( app )
207252 . get ( "/oidc/callback?code=test-code&state=state-fixed" )
208- . set ( "Cookie" , [
209- `excalidash-oidc-flow=${ makeFlowCookie ( "test-secret" ) } ` ,
210- ] ) ;
253+ . set ( "Cookie" , [ `excalidash-oidc-flow=${ makeFlowCookie ( "test-secret" ) } ` ] ) ;
211254
212255 expect ( response . status ) . toBe ( 302 ) ;
213256 expect ( response . headers . location ) . toBe ( "/" ) ;
@@ -218,19 +261,45 @@ describe("OIDC callback alg mismatch fallback", () => {
218261
219262 it ( "does not retry when id token alg is explicitly configured" , async ( ) => {
220263 callbackMock . mockRejectedValue (
221- new Error ( "unexpected JWT alg received, expected RS256, got: HS256" )
264+ new Error ( "unexpected JWT alg received, expected RS256, got: HS256" ) ,
222265 ) ;
223266
224267 const app = await createApp ( "RS256" ) ;
225268 const response = await request ( app )
226269 . get ( "/oidc/callback?code=test-code&state=state-fixed" )
227- . set ( "Cookie" , [
228- `excalidash-oidc-flow=${ makeFlowCookie ( "test-secret" ) } ` ,
229- ] ) ;
270+ . set ( "Cookie" , [ `excalidash-oidc-flow=${ makeFlowCookie ( "test-secret" ) } ` ] ) ;
230271
231272 expect ( response . status ) . toBe ( 302 ) ;
232273 expect ( response . headers . location ) . toContain ( "oidcError=callback_failed" ) ;
233274 expect ( callbackMock ) . toHaveBeenCalledTimes ( 1 ) ;
234275 expect ( clientConfigs ) . toHaveLength ( 1 ) ;
235276 } ) ;
277+
278+ it ( "relinks existing provider identity when same email logs in with a new subject" , async ( ) => {
279+ const prisma = createPrismaMock ( {
280+ providerIdentity : {
281+ id : "identity-1" ,
282+ issuer : "https://issuer.example" ,
283+ subject : "subject-old" ,
284+ } ,
285+ } ) ;
286+
287+ callbackMock . mockResolvedValue ( {
288+ claims : ( ) => ( {
289+ sub : "subject-new" ,
290+ email : "alice@example.com" ,
291+ email_verified : true ,
292+ } ) ,
293+ } ) ;
294+
295+ const app = await createApp ( null , null , prisma as any ) ;
296+ const response = await request ( app )
297+ . get ( "/oidc/callback?code=test-code&state=state-fixed" )
298+ . set ( "Cookie" , [ `excalidash-oidc-flow=${ makeFlowCookie ( "test-secret" ) } ` ] ) ;
299+
300+ expect ( response . status ) . toBe ( 302 ) ;
301+ expect ( response . headers . location ) . toBe ( "/" ) ;
302+ expect ( prisma . __tx . authIdentity . update ) . toHaveBeenCalledTimes ( 1 ) ;
303+ expect ( prisma . __tx . authIdentity . create ) . toHaveBeenCalledTimes ( 0 ) ;
304+ } ) ;
236305} ) ;
0 commit comments