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:
110
backend/libs/auth/src/token-exchange.service.spec.ts
Normal file
110
backend/libs/auth/src/token-exchange.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user