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:
@@ -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 {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './oidc-discovery.service';
|
||||
export * from './oidc-auth.guard';
|
||||
export * from './token-exchange.service';
|
||||
export * from './auth.module';
|
||||
|
||||
47
backend/libs/auth/src/oidc-discovery.service.spec.ts
Normal file
47
backend/libs/auth/src/oidc-discovery.service.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
68
backend/libs/auth/src/token-exchange.service.ts
Normal file
68
backend/libs/auth/src/token-exchange.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user