diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index 786fd637..1a8b660f 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -313,7 +313,6 @@ export async function verifyIdToken( try { const verified = jwt.verify(idToken, publicKey, { algorithms: [alg as jwt.Algorithm], - issuer: expectedIssuer, audience: clientId, }); claims = typeof verified === 'string' ? {} : (verified as Record); @@ -322,6 +321,13 @@ export async function verifyIdToken( return { ok: false, error: `signature_or_claim_mismatch: ${msg}` }; } + // Normalize trailing slash before issuer comparison — some IdPs (e.g. Authentik) + // include a trailing slash in the id_token iss claim. + const tokenIssuer = typeof claims['iss'] === 'string' ? claims['iss'].replace(/\/+$/, '') : ''; + if (tokenIssuer !== expectedIssuer) { + return { ok: false, error: `signature_or_claim_mismatch: jwt issuer invalid. expected: ${expectedIssuer}` }; + } + return { ok: true, claims }; } diff --git a/server/tests/unit/services/oidcService.test.ts b/server/tests/unit/services/oidcService.test.ts index eca92065..5e27b5fb 100644 --- a/server/tests/unit/services/oidcService.test.ts +++ b/server/tests/unit/services/oidcService.test.ts @@ -4,6 +4,8 @@ * discover caching, and the ReDoS-sensitive issuer trailing-slash regex. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; +import { generateKeyPairSync } from 'crypto'; +import jwtLib from 'jsonwebtoken'; // ── DB setup ────────────────────────────────────────────────────────────────── @@ -50,6 +52,7 @@ import { frontendUrl, findOrCreateUser, discover, + verifyIdToken, } from '../../../src/services/oidcService'; const MOCK_CONFIG = { @@ -460,3 +463,66 @@ describe('getUserInfo', () => { expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123'); }); }); + +// ── verifyIdToken ───────────────────────────────────────────────────────────── + +describe('verifyIdToken', () => { + const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = publicKey.export({ format: 'jwk' }) as Record; + const ISSUER = 'https://auth.example.com/application/o/trek'; + const CLIENT_ID = 'trek-client'; + const JWKS_URI = 'https://auth.example.com/.well-known/jwks.json'; + + function mockJwks() { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ keys: [jwk] }), + })); + } + + function makeToken(iss: string, overrides: object = {}) { + return jwtLib.sign( + { sub: 'user-sub', email: 'user@example.com', ...overrides }, + privateKey, + { algorithm: 'RS256', audience: CLIENT_ID, issuer: iss, expiresIn: '1h' } + ); + } + + const doc = { jwks_uri: JWKS_URI } as any; + + afterEach(() => { vi.unstubAllGlobals(); }); + + it('OIDC-SVC-033: accepts token whose iss matches expectedIssuer exactly', async () => { + mockJwks(); + const token = makeToken(ISSUER); + const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER); + expect(result.ok).toBe(true); + }); + + it('OIDC-SVC-034: accepts token whose iss has a trailing slash (Authentik)', async () => { + mockJwks(); + const token = makeToken(ISSUER + '/'); + const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER); + expect(result.ok).toBe(true); + }); + + it('OIDC-SVC-035: rejects token with wrong issuer', async () => { + mockJwks(); + const token = makeToken('https://evil.example.com'); + const result = await verifyIdToken(token, doc, CLIENT_ID, ISSUER); + expect(result.ok).toBe(false); + expect((result as any).error).toMatch('jwt issuer invalid'); + }); + + it('OIDC-SVC-036: rejects token with wrong audience', async () => { + mockJwks(); + const token = makeToken(ISSUER, {}); + const wrongAudToken = jwtLib.sign( + { sub: 'user-sub', iss: ISSUER }, + privateKey, + { algorithm: 'RS256', audience: 'wrong-client', expiresIn: '1h' } + ); + const result = await verifyIdToken(wrongAudToken, doc, CLIENT_ID, ISSUER); + expect(result.ok).toBe(false); + }); +});