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

@@ -2,11 +2,17 @@ import { Global, Module } from '@nestjs/common';
import { UsersLibModule } from '../../users/src';
import { OidcDiscoveryService } from './oidc-discovery.service';
import { OidcAuthGuard } from './oidc-auth.guard';
import { TokenExchangeService } from './token-exchange.service';
@Global()
@Module({
imports: [UsersLibModule],
providers: [OidcDiscoveryService, OidcAuthGuard],
exports: [OidcDiscoveryService, OidcAuthGuard, UsersLibModule],
providers: [OidcDiscoveryService, OidcAuthGuard, TokenExchangeService],
exports: [
OidcDiscoveryService,
OidcAuthGuard,
TokenExchangeService,
UsersLibModule,
],
})
export class AuthModule {}

View File

@@ -1,3 +1,4 @@
export * from './oidc-discovery.service';
export * from './oidc-auth.guard';
export * from './token-exchange.service';
export * from './auth.module';

View File

@@ -0,0 +1,47 @@
import { OidcDiscoveryService } from './oidc-discovery.service';
import type { AppEnvironment } from '../../configuration/src';
describe('OidcDiscoveryService', () => {
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: 'secret-1',
appVersion: 'dev',
teamCityBuildNumber: 'local',
sourceRevision: 'local',
};
it('fetches the discovery document once and exposes the token endpoint', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
jwks_uri: 'https://idp.example.test/oidc/jwks',
token_endpoint: 'https://idp.example.test/oidc/token',
}),
});
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
const service = new OidcDiscoveryService(environment);
await service.onModuleInit();
expect(fetchMock).toHaveBeenCalledWith(
'https://idp.example.test/.well-known/openid-configuration',
);
expect(service.getTokenEndpoint()).toBe(
'https://idp.example.test/oidc/token',
);
expect(service.getIssuer()).toBe('https://idp.example.test');
expect(service.getAudience()).toBe('client-1');
});
it('throws when asked for the token endpoint before discovery has completed', () => {
const service = new OidcDiscoveryService(environment);
expect(() => service.getTokenEndpoint()).toThrow(
'OIDC discovery has not completed yet',
);
});
});

View File

@@ -6,11 +6,13 @@ import type { AppEnvironment } from '../../configuration/src';
interface OidcDiscoveryDocument {
jwks_uri: string;
token_endpoint: string;
}
@Injectable()
export class OidcDiscoveryService implements OnModuleInit {
private verificationKeySet: JWTVerifyGetKey | undefined;
private tokenEndpoint: string | undefined;
constructor(
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
@@ -26,6 +28,7 @@ export class OidcDiscoveryService implements OnModuleInit {
}
const document = (await response.json()) as OidcDiscoveryDocument;
this.verificationKeySet = createRemoteJWKSet(new URL(document.jwks_uri));
this.tokenEndpoint = document.token_endpoint;
}
getIssuer(): string {
@@ -42,4 +45,11 @@ export class OidcDiscoveryService implements OnModuleInit {
}
return this.verificationKeySet;
}
getTokenEndpoint(): string {
if (!this.tokenEndpoint) {
throw new Error('OIDC discovery has not completed yet');
}
return this.tokenEndpoint;
}
}

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);
});
});

View File

@@ -0,0 +1,68 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { APP_ENVIRONMENT } from '../../configuration/src';
import type { AppEnvironment } from '../../configuration/src';
import { OidcDiscoveryService } from './oidc-discovery.service';
export interface AuthorizationCodeExchangeRequest {
code: string;
codeVerifier: string;
redirectUri: string;
}
export interface AuthorizationCodeExchangeResult {
accessToken: string;
expiresIn: number;
}
interface TokenEndpointResponse {
access_token: string;
expires_in: number;
refresh_token?: string;
id_token?: string;
}
/**
* Exchanges an OIDC authorization code for tokens on behalf of the (confidential,
* client-secret-bearing) SPA client. The secret never reaches the browser: the
* frontend performs the Authorization Code + PKCE redirect itself, then hands the
* resulting code + PKCE verifier to this service, which is the only place the
* client secret is used.
*/
@Injectable()
export class TokenExchangeService {
constructor(
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
private readonly discovery: OidcDiscoveryService,
) {}
async exchangeAuthorizationCode(
request: AuthorizationCodeExchangeRequest,
): Promise<AuthorizationCodeExchangeResult> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.environment.oidcClientId,
client_secret: this.environment.oidcClientSecret,
code: request.code,
code_verifier: request.codeVerifier,
redirect_uri: request.redirectUri,
});
const response = await fetch(this.discovery.getTokenEndpoint(), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
throw new BadRequestException(
'The identity provider rejected the authorization code',
);
}
const tokenResponse = (await response.json()) as TokenEndpointResponse;
return {
accessToken: tokenResponse.access_token,
expiresIn: tokenResponse.expires_in,
};
}
}

View File

@@ -12,6 +12,8 @@ describe('loadEnvironment', () => {
REDIS_URL: 'redis://redis:6379',
OIDC_ISSUER: 'https://idp.example.test/',
OIDC_AUDIENCE: 'travel-planner-api',
OIDC_CLIENT_ID: 'test-client',
OIDC_CLIENT_SECRET: 'test-secret',
APP_VERSION: '1.2.3',
TEAMCITY_BUILD_NUMBER: '42',
SOURCE_REVISION: 'abc123',
@@ -21,6 +23,8 @@ describe('loadEnvironment', () => {
redisUrl: 'redis://redis:6379',
oidcIssuer: 'https://idp.example.test/',
oidcAudience: 'travel-planner-api',
oidcClientId: 'test-client',
oidcClientSecret: 'test-secret',
appVersion: '1.2.3',
teamCityBuildNumber: '42',
sourceRevision: 'abc123',

View File

@@ -3,6 +3,8 @@ export interface AppEnvironment {
redisUrl: string;
oidcIssuer: string;
oidcAudience: string;
oidcClientId: string;
oidcClientSecret: string;
appVersion: string;
teamCityBuildNumber: string;
sourceRevision: string;
@@ -20,6 +22,8 @@ export function loadEnvironment(env: NodeJS.ProcessEnv): AppEnvironment {
redisUrl: required(env, 'REDIS_URL'),
oidcIssuer: required(env, 'OIDC_ISSUER'),
oidcAudience: required(env, 'OIDC_AUDIENCE'),
oidcClientId: required(env, 'OIDC_CLIENT_ID'),
oidcClientSecret: required(env, 'OIDC_CLIENT_SECRET'),
appVersion: env.APP_VERSION?.trim() || 'dev',
teamCityBuildNumber: env.TEAMCITY_BUILD_NUMBER?.trim() || 'local',
sourceRevision: env.SOURCE_REVISION?.trim() || 'local',