mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fix(oidc): normalize id_token iss claim before issuer comparison (#837)
jwt.verify does an exact string match on the issuer. Providers like Authentik include a trailing slash in the id_token iss claim while the configured issuer is already normalized (no trailing slash), causing every login attempt to fail with jwt issuer invalid. Move the issuer check out of jwt.verify options and apply the same trailing-slash normalization used in the discovery doc validation. Also adds OIDC-SVC-033–036 unit tests covering exact match, trailing slash, wrong issuer, and wrong audience cases. Closes #834
This commit is contained in:
@@ -313,7 +313,6 @@ export async function verifyIdToken(
|
|||||||
try {
|
try {
|
||||||
const verified = jwt.verify(idToken, publicKey, {
|
const verified = jwt.verify(idToken, publicKey, {
|
||||||
algorithms: [alg as jwt.Algorithm],
|
algorithms: [alg as jwt.Algorithm],
|
||||||
issuer: expectedIssuer,
|
|
||||||
audience: clientId,
|
audience: clientId,
|
||||||
});
|
});
|
||||||
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
|
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
|
||||||
@@ -322,6 +321,13 @@ export async function verifyIdToken(
|
|||||||
return { ok: false, error: `signature_or_claim_mismatch: ${msg}` };
|
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 };
|
return { ok: true, claims };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
|
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||||
|
import { generateKeyPairSync } from 'crypto';
|
||||||
|
import jwtLib from 'jsonwebtoken';
|
||||||
|
|
||||||
// ── DB setup ──────────────────────────────────────────────────────────────────
|
// ── DB setup ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ import {
|
|||||||
frontendUrl,
|
frontendUrl,
|
||||||
findOrCreateUser,
|
findOrCreateUser,
|
||||||
discover,
|
discover,
|
||||||
|
verifyIdToken,
|
||||||
} from '../../../src/services/oidcService';
|
} from '../../../src/services/oidcService';
|
||||||
|
|
||||||
const MOCK_CONFIG = {
|
const MOCK_CONFIG = {
|
||||||
@@ -460,3 +463,66 @@ describe('getUserInfo', () => {
|
|||||||
expect(fetchCall[1].headers.Authorization).toBe('Bearer access-token-123');
|
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<string, unknown>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user