From 55513cd59f74a48aecce7ac73c252009d6ab81da Mon Sep 17 00:00:00 2001 From: Belnadifia Date: Fri, 13 Mar 2026 22:14:45 +0100 Subject: [PATCH] feat(server): support IDPs that only send the userinfo in the ID token (#26717) Co-authored-by: irouply Co-authored-by: Daniel Dietzler --- e2e-auth-server/auth-server.ts | 29 ++++++++++++++++++--- e2e/src/specs/server/api/oauth.e2e-spec.ts | 19 ++++++++++++++ server/src/repositories/oauth.repository.ts | 11 +++++++- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/e2e-auth-server/auth-server.ts b/e2e-auth-server/auth-server.ts index a190ecd023..9aef56510d 100644 --- a/e2e-auth-server/auth-server.ts +++ b/e2e-auth-server/auth-server.ts @@ -10,6 +10,7 @@ export enum OAuthClient { export enum OAuthUser { NO_EMAIL = 'no-email', NO_NAME = 'no-name', + ID_TOKEN_CLAIMS = 'id-token-claims', WITH_QUOTA = 'with-quota', WITH_USERNAME = 'with-username', WITH_ROLE = 'with-role', @@ -52,12 +53,25 @@ const withDefaultClaims = (sub: string) => ({ email_verified: true, }); -const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub); +const getClaims = (sub: string, use?: string) => { + if (sub === OAuthUser.ID_TOKEN_CLAIMS) { + return { + sub, + email: `oauth-${sub}@immich.app`, + email_verified: true, + name: use === 'id_token' ? 'ID Token User' : 'Userinfo User', + }; + } + return claims.find((user) => user.sub === sub) || withDefaultClaims(sub); +}; const setup = async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); - const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect']; + const redirectUris = [ + 'http://127.0.0.1:2285/auth/login', + 'https://photos.immich.app/oauth/mobile-redirect', + ]; const port = 2286; const host = '0.0.0.0'; const oidc = new Provider(`http://${host}:${port}`, { @@ -66,7 +80,10 @@ const setup = async () => { console.error(error); ctx.body = 'Internal Server Error'; }, - findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }), + findAccount: (ctx, sub) => ({ + accountId: sub, + claims: (use) => getClaims(sub, use), + }), scopes: ['openid', 'email', 'profile'], claims: { openid: ['sub'], @@ -94,6 +111,7 @@ const setup = async () => { state: 'oidc.state', }, }, + conformIdTokenClaims: false, pkce: { required: () => false, }, @@ -125,7 +143,10 @@ const setup = async () => { ], }); - const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`); + const onStart = () => + console.log( + `[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`, + ); const app = oidc.listen(port, host, onStart); return () => app.close(); }; diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index cbd68c003a..ae9064375f 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -380,4 +380,23 @@ describe(`/oauth`, () => { }); }); }); + + describe('idTokenClaims', () => { + it('should use claims from the ID token if IDP includes them', async () => { + await setupOAuth(admin.accessToken, { + enabled: true, + clientId: OAuthClient.DEFAULT, + clientSecret: OAuthClient.DEFAULT, + }); + const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS); + const { status, body } = await request(app).post('/oauth/callback').send(callbackParams); + expect(status).toBe(201); + expect(body).toMatchObject({ + accessToken: expect.any(String), + name: 'ID Token User', + userEmail: 'oauth-id-token-claims@immich.app', + userId: expect.any(String), + }); + }); + }); }); diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index a42955ba10..5af5163f8f 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -70,7 +70,16 @@ export class OAuthRepository { try { const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier }); - const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck); + + let profile: OAuthProfile; + const tokenClaims = tokens.claims(); + if (tokenClaims && 'email' in tokenClaims) { + this.logger.debug('Using ID token claims instead of userinfo endpoint'); + profile = tokenClaims as OAuthProfile; + } else { + profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck); + } + if (!profile.sub) { throw new Error('Unexpected profile response, no `sub`'); }