diff --git a/src/backend/database/routes/user-oidc-utils.ts b/src/backend/database/routes/user-oidc-utils.ts index 3b3e66e5..6eb5fda1 100644 --- a/src/backend/database/routes/user-oidc-utils.ts +++ b/src/backend/database/routes/user-oidc-utils.ts @@ -215,9 +215,8 @@ export async function verifyOIDCToken( ); } - const header = JSON.parse( - Buffer.from(idToken.split(".")[0], "base64").toString(), - ); + const { decodeProtectedHeader, importJWK, jwtVerify } = await import("jose"); + const header = decodeProtectedHeader(idToken); const keyId = header.kid; const publicKey = jwks.keys.find( @@ -229,8 +228,9 @@ export async function verifyOIDCToken( ); } - const { importJWK, jwtVerify } = await import("jose"); - const key = await importJWK(publicKey); + const algorithm = + typeof publicKey.alg === "string" ? publicKey.alg : header.alg; + const key = await importJWK(publicKey, algorithm); const { payload } = await jwtVerify(idToken, key, { issuer: possibleIssuers, diff --git a/src/backend/tests/database/routes/user-oidc-utils.test.ts b/src/backend/tests/database/routes/user-oidc-utils.test.ts index 96dcc723..7f1cd34a 100644 --- a/src/backend/tests/database/routes/user-oidc-utils.test.ts +++ b/src/backend/tests/database/routes/user-oidc-utils.test.ts @@ -16,11 +16,50 @@ const { getOIDCConfigFromEnv, extractOidcGroups, validateLogoutTokenClaims, + verifyOIDCToken, } = await import("../../../database/routes/user-oidc-utils.js"); const BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"; +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("verifyOIDCToken", () => { + it("uses the protected-header algorithm when the provider JWK omits alg", async () => { + const { exportJWK, generateKeyPair, SignJWT } = await import("jose"); + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + jwk.kid = "entra-key"; + + const issuer = "https://login.microsoftonline.com/example/v2.0"; + const clientId = "termix-client"; + const token = await new SignJWT({ sub: "user-1" }) + .setProtectedHeader({ alg: "RS256", kid: jwk.kid }) + .setIssuer(issuer) + .setAudience(clientId) + .setExpirationTime("5m") + .sign(privateKey); + + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), { + status: 200, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }), + ); + + const payload = await verifyOIDCToken(token, issuer, clientId); + + expect(payload.sub).toBe("user-1"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + describe("isOIDCUserAllowed", () => { it("allows everyone when the allow-list is empty", () => { expect(isOIDCUserAllowed("", "alice", "alice@x.com")).toBe(true);