feat: switch oidc client to confidential (backend token exchange)

The provisioned IdP client (https://auth.forgecore.work) is confidential
rather than public/PKCE-only, so a client secret must never reach the
browser. The frontend now only performs the Authorization Code + PKCE
redirect itself (hand-rolled PKCE, oidc-client-ts dependency removed)
and hands the resulting code + verifier to a new, intentionally
unauthenticated POST /api/v1/auth/session endpoint, which performs the
code-for-tokens exchange server-side using OIDC_CLIENT_SECRET and
returns only {accessToken, expiresIn} — refresh_token/id_token are
never forwarded to the client.

New required backend env vars: OIDC_CLIENT_ID, OIDC_CLIENT_SECRET.
Added frontend/proxy.conf.json so the Angular dev server forwards
/api and /health to the local API without needing CORS.
This commit is contained in:
Bastian Wagner
2026-08-17 16:36:49 +02:00
parent 8eb5f0a3ed
commit 981cecbcbd
26 changed files with 554 additions and 58 deletions

View File

@@ -0,0 +1,110 @@
import { BadRequestException } from '@nestjs/common';
import { TokenExchangeService } from './token-exchange.service';
import type { AppEnvironment } from '../../configuration/src';
describe('TokenExchangeService.exchangeAuthorizationCode', () => {
const environment: AppEnvironment = {
databaseUrl: 'postgresql://u:p@postgres:5432/db',
redisUrl: 'redis://redis:6379',
oidcIssuer: 'https://idp.example.test',
oidcAudience: 'client-1',
oidcClientId: 'client-1',
oidcClientSecret: 'super-secret',
appVersion: 'dev',
teamCityBuildNumber: 'local',
sourceRevision: 'local',
};
function fakeDiscovery(
tokenEndpoint = 'https://idp.example.test/oidc/token',
) {
return { getTokenEndpoint: jest.fn().mockReturnValue(tokenEndpoint) };
}
it('posts a client-secret-authenticated request and returns only the safe fields', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
access_token: 'at-1',
expires_in: 3600,
refresh_token: 'rt-1',
id_token: 'idt-1',
}),
});
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
const service = new TokenExchangeService(
environment,
fakeDiscovery() as never,
);
const result = await service.exchangeAuthorizationCode({
code: 'auth-code-1',
codeVerifier: 'verifier-1',
redirectUri: 'http://localhost:4200/auth/callback',
});
expect(result).toEqual({ accessToken: 'at-1', expiresIn: 3600 });
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://idp.example.test/oidc/token');
const body = new URLSearchParams(init.body as string);
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('client_id')).toBe('client-1');
expect(body.get('client_secret')).toBe('super-secret');
expect(body.get('code')).toBe('auth-code-1');
expect(body.get('code_verifier')).toBe('verifier-1');
expect(body.get('redirect_uri')).toBe(
'http://localhost:4200/auth/callback',
);
});
it('never leaks the refresh_token or id_token to the caller', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
access_token: 'at-1',
expires_in: 3600,
refresh_token: 'rt-1',
id_token: 'idt-1',
}),
});
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
const service = new TokenExchangeService(
environment,
fakeDiscovery() as never,
);
const result = await service.exchangeAuthorizationCode({
code: 'auth-code-1',
codeVerifier: 'verifier-1',
redirectUri: 'http://localhost:4200/auth/callback',
});
expect(result).not.toHaveProperty('refreshToken');
expect(result).not.toHaveProperty('idToken');
});
it('rejects with BadRequestException when the identity provider rejects the code', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ error: 'invalid_grant' }),
});
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
const service = new TokenExchangeService(
environment,
fakeDiscovery() as never,
);
await expect(
service.exchangeAuthorizationCode({
code: 'bad-code',
codeVerifier: 'verifier-1',
redirectUri: 'http://localhost:4200/auth/callback',
}),
).rejects.toThrow(BadRequestException);
});
});