diff --git a/.tests/auth/oidc-auth.test.js b/.tests/auth/oidc-auth.test.js index 197ce866..03c95452 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,75 @@ 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 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 dd766549..a129ad80 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) { + effectiveClaims[groupsClaim] = claims[groupsClaim]; + } + return effectiveClaims; + } catch { + return claims; + } +} + async function getDiscoveryConfig() { const config = getRequiredConfig(); if (!config) { @@ -228,7 +244,8 @@ export async function handleOidcCallback(req) { idTokenExpected: true, }); - const claims = tokens.claims() || {}; + const claims = await fetchEffectiveClaims(oidc, tokens, tokens.claims() || {}); + const username = resolveOidcUsername(claims); if (!username) { throw Object.assign(new Error("OIDC identity did not include a usable username"), {