From 2106d2beab889bf6caaf1a812ae435c19e80d4a7 Mon Sep 17 00:00:00 2001 From: Hugo Renard Date: Fri, 14 Aug 2026 00:07:34 +0200 Subject: [PATCH 1/3] fix(oidc): always fetch user info The ID Token isn't required to hold claims data. --- backend/services/oidcAuth.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/services/oidcAuth.js b/backend/services/oidcAuth.js index dd766549e..d46c63af8 100644 --- a/backend/services/oidcAuth.js +++ b/backend/services/oidcAuth.js @@ -229,14 +229,16 @@ export async function handleOidcCallback(req) { }); const claims = tokens.claims() || {}; - const username = resolveOidcUsername(claims); + const userInfo = await client.fetchUserInfo(oidc, tokens.access_token, claims.sub); + + const username = resolveOidcUsername(userInfo); if (!username) { throw Object.assign(new Error("OIDC identity did not include a usable username"), { status: 400, }); } - const role = resolveOidcRole(username, claims); + const role = resolveOidcRole(username, userInfo); const user = ensureExternalUser(username, role); if (!user?.id || user.id < 0) { throw Object.assign(new Error("Failed to provision OIDC user"), { status: 500 }); From aa4ec72fb786be75b61739a0d06ce4f29d8d8726 Mon Sep 17 00:00:00 2001 From: Lee Kelly Date: Tue, 18 Aug 2026 19:46:58 +0000 Subject: [PATCH 2/3] fix(oidc): preserve claims when fetching user info --- .tests/auth/oidc-auth.test.js | 61 ++++++++++++++++++++++++++++++++--- backend/services/oidcAuth.js | 23 ++++++++++--- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/.tests/auth/oidc-auth.test.js b/.tests/auth/oidc-auth.test.js index 197ce8660..88341ac3a 100644 --- a/.tests/auth/oidc-auth.test.js +++ b/.tests/auth/oidc-auth.test.js @@ -38,7 +38,7 @@ const completeOnboarding = () => dbOps.updateSettings({ onboardingComplete: true const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const oidcKey = { ...publicKey.export({ format: "jwk" }), kid: "test-key", use: "sig", alg: "RS256" }; -const createIdToken = (issuer, nonce) => { +const createIdToken = (issuer, nonce, claimOverrides = {}) => { const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url"); const header = encode({ alg: "RS256", kid: oidcKey.kid, typ: "JWT" }); const payload = encode({ @@ -49,6 +49,7 @@ const createIdToken = (issuer, nonce) => { nonce, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, + ...claimOverrides, }); const input = `${header}.${payload}`; const signature = createSign("RSA-SHA256").update(input).sign(privateKey).toString("base64url"); @@ -82,30 +83,38 @@ function enableOidcEnv(overrides = {}) { Object.assign(process.env, overrides); } -async function createPendingOidcLogin() { +async function createPendingOidcLogin({ idTokenClaims = {}, userInfo = null, userInfoError = false } = {}) { let issuer; let nonce; const discoveryServer = await createMockHttpServer((request, response) => { - response.writeHead(200, { "content-type": "application/json" }); if (request.url === "/jwks") { + response.writeHead(200, { "content-type": "application/json" }); response.end(JSON.stringify({ keys: [oidcKey] })); return; } if (request.method === "POST" && request.url === "/token") { + response.writeHead(200, { "content-type": "application/json" }); response.end( JSON.stringify({ access_token: "access-token", token_type: "Bearer", - id_token: createIdToken(issuer, nonce), + id_token: createIdToken(issuer, nonce, idTokenClaims), }), ); return; } + if (request.url === "/userinfo") { + response.writeHead(userInfoError ? 500 : 200, { "content-type": "application/json" }); + response.end(JSON.stringify(userInfoError ? { error: "userinfo_unavailable" } : userInfo)); + return; + } + response.writeHead(200, { "content-type": "application/json" }); response.end( JSON.stringify({ issuer, authorization_endpoint: `${issuer}authorize`, token_endpoint: `${issuer}token`, + ...(userInfo ? { userinfo_endpoint: `${issuer}userinfo` } : {}), jwks_uri: `${issuer}jwks`, }), ); @@ -259,6 +268,50 @@ test("OIDC callback issues a cookie-bound one-time session exchange", async () = } }); +test("OIDC callback combines UserInfo with ID-token claims", async () => { + process.env.OIDC_GROUPS_CLAIM = "groups"; + process.env.OIDC_ADMIN_GROUPS = "aurral-admins"; + const pending = await createPendingOidcLogin({ + idTokenClaims: { preferred_username: undefined, groups: ["aurral-admins"] }, + userInfo: { + sub: "oidc-subject", + preferred_username: "userinfo-user", + groups: ["regular-users"], + }, + }); + + try { + const callback = await handleOidcCallback({ + query: { state: pending.state, code: "authorization-code" }, + headers: { cookie: pending.cookie }, + ip: "127.0.0.1", + }); + assert.equal(callback.user.username, "userinfo-user"); + assert.equal(callback.user.role, "admin"); + } finally { + await pending.close(); + } +}); + +test("OIDC callback falls back to ID-token claims when UserInfo fails", async () => { + const pending = await createPendingOidcLogin({ + idTokenClaims: { preferred_username: "id-token-user" }, + userInfo: { sub: "oidc-subject" }, + userInfoError: true, + }); + + try { + const callback = await handleOidcCallback({ + query: { state: pending.state, code: "authorization-code" }, + headers: { cookie: pending.cookie }, + ip: "127.0.0.1", + }); + assert.equal(callback.user.username, "id-token-user"); + } finally { + await pending.close(); + } +}); + test("OIDC callback rejects an expired state without creating a session", async () => { const pending = await createPendingOidcLogin(); const now = Date.now(); diff --git a/backend/services/oidcAuth.js b/backend/services/oidcAuth.js index d46c63af8..036fb7530 100644 --- a/backend/services/oidcAuth.js +++ b/backend/services/oidcAuth.js @@ -135,6 +135,22 @@ export function resolveOidcUsername(claims = {}) { return ""; } +async function fetchEffectiveClaims(oidc, tokens, claims) { + if (!claims.sub || !tokens.access_token) return claims; + + try { + const userInfo = await client.fetchUserInfo(oidc, tokens.access_token, claims.sub); + const effectiveClaims = { ...claims, ...userInfo }; + const groupsClaim = getGroupsClaim(); + if (groupsClaim && claims[groupsClaim] !== undefined) { + effectiveClaims[groupsClaim] = claims[groupsClaim]; + } + return effectiveClaims; + } catch { + return claims; + } +} + async function getDiscoveryConfig() { const config = getRequiredConfig(); if (!config) { @@ -228,17 +244,16 @@ export async function handleOidcCallback(req) { idTokenExpected: true, }); - const claims = tokens.claims() || {}; - const userInfo = await client.fetchUserInfo(oidc, tokens.access_token, claims.sub); + const claims = await fetchEffectiveClaims(oidc, tokens, tokens.claims() || {}); - const username = resolveOidcUsername(userInfo); + const username = resolveOidcUsername(claims); if (!username) { throw Object.assign(new Error("OIDC identity did not include a usable username"), { status: 400, }); } - const role = resolveOidcRole(username, userInfo); + const role = resolveOidcRole(username, claims); const user = ensureExternalUser(username, role); if (!user?.id || user.id < 0) { throw Object.assign(new Error("Failed to provision OIDC user"), { status: 500 }); From aabd74276edecfc84282ffc6d77233fd4681f1eb Mon Sep 17 00:00:00 2001 From: Lee Kelly Date: Tue, 18 Aug 2026 19:57:43 +0000 Subject: [PATCH 3/3] fix(oidc): restrict groups claims to id tokens --- .tests/auth/oidc-auth.test.js | 25 +++++++++++++++++++++++++ backend/services/oidcAuth.js | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.tests/auth/oidc-auth.test.js b/.tests/auth/oidc-auth.test.js index 88341ac3a..03c95452d 100644 --- a/.tests/auth/oidc-auth.test.js +++ b/.tests/auth/oidc-auth.test.js @@ -312,6 +312,31 @@ test("OIDC callback falls back to ID-token claims when UserInfo fails", async () } }); +test("OIDC groups claim ignores UserInfo-only admin groups", async () => { + process.env.OIDC_GROUPS_CLAIM = "groups"; + process.env.OIDC_ADMIN_GROUPS = "aurral-admins"; + const pending = await createPendingOidcLogin({ + idTokenClaims: { preferred_username: "id-token-user" }, + userInfo: { + sub: "oidc-subject", + preferred_username: "userinfo-user", + groups: ["aurral-admins"], + }, + }); + + try { + const callback = await handleOidcCallback({ + query: { state: pending.state, code: "authorization-code" }, + headers: { cookie: pending.cookie }, + ip: "127.0.0.1", + }); + assert.equal(callback.user.username, "userinfo-user"); + assert.equal(callback.user.role, "user"); + } finally { + await pending.close(); + } +}); + test("OIDC callback rejects an expired state without creating a session", async () => { const pending = await createPendingOidcLogin(); const now = Date.now(); diff --git a/backend/services/oidcAuth.js b/backend/services/oidcAuth.js index 036fb7530..a129ad80d 100644 --- a/backend/services/oidcAuth.js +++ b/backend/services/oidcAuth.js @@ -142,7 +142,7 @@ async function fetchEffectiveClaims(oidc, tokens, claims) { const userInfo = await client.fetchUserInfo(oidc, tokens.access_token, claims.sub); const effectiveClaims = { ...claims, ...userInfo }; const groupsClaim = getGroupsClaim(); - if (groupsClaim && claims[groupsClaim] !== undefined) { + if (groupsClaim) { effectiveClaims[groupsClaim] = claims[groupsClaim]; } return effectiveClaims;