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

@@ -64,6 +64,9 @@
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
"buildTarget": "frontend:build:production"

View File

@@ -17,7 +17,6 @@
"@angular/platform-browser": "^21.2.0",
"@angular/router": "^21.2.0",
"@angular/service-worker": "^21.2.0",
"oidc-client-ts": "^3.5.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},

4
frontend/proxy.conf.json Normal file
View File

@@ -0,0 +1,4 @@
{
"/api": { "target": "http://localhost:3000", "secure": false },
"/health": { "target": "http://localhost:3000", "secure": false }
}

View File

@@ -1,11 +1,87 @@
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from './auth.service';
describe('AuthService', () => {
it('starts unauthenticated when no OIDC user is stored', async () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('starts unauthenticated when no session is stored', () => {
const service = TestBed.inject(AuthService);
await Promise.resolve();
expect(service.isAuthenticated()).toBe(false);
});
it('starts authenticated when a non-expired access token is already stored', () => {
sessionStorage.setItem('auth.accessToken', 'stored-token');
sessionStorage.setItem('auth.expiresAt', String(Date.now() + 60_000));
const service = TestBed.inject(AuthService);
expect(service.isAuthenticated()).toBe(true);
});
it('login() persists a PKCE verifier and state before redirecting', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: () => Promise.resolve({ authorization_endpoint: 'https://idp.example.test/oidc/auth' }),
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('location', { ...window.location, href: '' });
const service = TestBed.inject(AuthService);
await service.login();
expect(sessionStorage.getItem('auth.codeVerifier')).toBeTruthy();
expect(sessionStorage.getItem('auth.state')).toBeTruthy();
expect(window.location.href).toContain('https://idp.example.test/oidc/auth?');
expect(window.location.href).toContain('code_challenge_method=S256');
});
it('completeLogin() rejects a state that does not match the one stored before redirecting', async () => {
sessionStorage.setItem('auth.codeVerifier', 'verifier-1');
sessionStorage.setItem('auth.state', 'expected-state');
vi.stubGlobal('location', { ...window.location, search: '?code=abc&state=wrong-state' });
const service = TestBed.inject(AuthService);
await expect(service.completeLogin()).rejects.toThrow();
});
it('completeLogin() exchanges the code via the backend and stores the resulting access token', async () => {
sessionStorage.setItem('auth.codeVerifier', 'verifier-1');
sessionStorage.setItem('auth.state', 'state-1');
vi.stubGlobal('location', { ...window.location, search: '?code=abc&state=state-1' });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ accessToken: 'at-1', expiresIn: 3600 }),
});
vi.stubGlobal('fetch', fetchMock);
const service = TestBed.inject(AuthService);
await service.completeLogin();
expect(service.isAuthenticated()).toBe(true);
await expect(service.getAccessToken()).resolves.toBe('at-1');
expect(sessionStorage.getItem('auth.codeVerifier')).toBeNull();
});
it('logout() clears the stored session', () => {
sessionStorage.setItem('auth.accessToken', 'at-1');
sessionStorage.setItem('auth.expiresAt', String(Date.now() + 60_000));
const service = TestBed.inject(AuthService);
service.logout();
expect(service.isAuthenticated()).toBe(false);
});
it('getAccessToken() returns undefined once the token has expired', async () => {
sessionStorage.setItem('auth.accessToken', 'at-1');
sessionStorage.setItem('auth.expiresAt', String(Date.now() - 1000));
const service = TestBed.inject(AuthService);
await expect(service.getAccessToken()).resolves.toBeUndefined();
});
});

View File

@@ -1,39 +1,103 @@
import { Injectable, signal } from '@angular/core';
import { UserManager } from 'oidc-client-ts';
import { environment } from '../../environments/environment';
import { generateCodeChallenge, generateRandomString } from './pkce';
const ACCESS_TOKEN_KEY = 'auth.accessToken';
const EXPIRES_AT_KEY = 'auth.expiresAt';
const CODE_VERIFIER_KEY = 'auth.codeVerifier';
const STATE_KEY = 'auth.state';
interface DiscoveryDocument {
authorization_endpoint: string;
}
interface SessionResponse {
accessToken: string;
expiresIn: number;
}
/**
* The IdP client backing this app is confidential (holds a client secret), so
* the authorization-code-for-tokens exchange must happen server-side — see
* `POST /api/v1/auth/session`. This service only performs the browser-side
* Authorization Code + PKCE redirect and hands the resulting code + PKCE
* verifier to the backend; it never sees or stores the client secret.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly userManager = new UserManager({
authority: environment.oidc.issuer,
client_id: environment.oidc.clientId,
redirect_uri: environment.oidc.redirectUri,
scope: environment.oidc.scope,
response_type: 'code',
});
private discoveryPromise: Promise<DiscoveryDocument> | undefined;
readonly isAuthenticated = signal(false);
readonly isAuthenticated = signal(this.hasValidAccessToken());
constructor() {
this.userManager.events.addUserLoaded(() => this.isAuthenticated.set(true));
this.userManager.events.addUserUnloaded(() => this.isAuthenticated.set(false));
void this.userManager.getUser().then((user) => this.isAuthenticated.set(!!user && !user.expired));
private hasValidAccessToken(): boolean {
const expiresAt = Number(sessionStorage.getItem(EXPIRES_AT_KEY) ?? 0);
return !!sessionStorage.getItem(ACCESS_TOKEN_KEY) && Date.now() < expiresAt;
}
login(): Promise<void> {
return this.userManager.signinRedirect();
private discover(): Promise<DiscoveryDocument> {
if (!this.discoveryPromise) {
const issuer = environment.oidc.issuer.replace(/\/$/, '');
this.discoveryPromise = fetch(`${issuer}/.well-known/openid-configuration`).then((response) => response.json());
}
return this.discoveryPromise;
}
async login(): Promise<void> {
const codeVerifier = generateRandomString();
const state = generateRandomString();
const codeChallenge = await generateCodeChallenge(codeVerifier);
sessionStorage.setItem(CODE_VERIFIER_KEY, codeVerifier);
sessionStorage.setItem(STATE_KEY, state);
const discovery = await this.discover();
const url = new URL(discovery.authorization_endpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', environment.oidc.clientId);
url.searchParams.set('redirect_uri', environment.oidc.redirectUri);
url.searchParams.set('scope', environment.oidc.scope);
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
window.location.href = url.toString();
}
async completeLogin(): Promise<void> {
await this.userManager.signinRedirectCallback();
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const codeVerifier = sessionStorage.getItem(CODE_VERIFIER_KEY);
const expectedState = sessionStorage.getItem(STATE_KEY);
if (!code || !state || !codeVerifier || state !== expectedState) {
throw new Error('Invalid or missing OIDC callback parameters');
}
const response = await fetch(`${environment.apiBaseUrl}/auth/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, codeVerifier, redirectUri: environment.oidc.redirectUri }),
});
if (!response.ok) {
throw new Error('Failed to exchange the authorization code for a session');
}
const session = (await response.json()) as SessionResponse;
sessionStorage.setItem(ACCESS_TOKEN_KEY, session.accessToken);
sessionStorage.setItem(EXPIRES_AT_KEY, String(Date.now() + session.expiresIn * 1000));
sessionStorage.removeItem(CODE_VERIFIER_KEY);
sessionStorage.removeItem(STATE_KEY);
this.isAuthenticated.set(true);
}
logout(): Promise<void> {
return this.userManager.signoutRedirect();
logout(): void {
sessionStorage.removeItem(ACCESS_TOKEN_KEY);
sessionStorage.removeItem(EXPIRES_AT_KEY);
this.isAuthenticated.set(false);
}
async getAccessToken(): Promise<string | undefined> {
const user = await this.userManager.getUser();
return user && !user.expired ? user.access_token : undefined;
return this.hasValidAccessToken() ? (sessionStorage.getItem(ACCESS_TOKEN_KEY) ?? undefined) : undefined;
}
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { generateCodeChallenge, generateRandomString } from './pkce';
describe('pkce', () => {
it('computes the RFC 7636 Appendix B S256 test vector', async () => {
// https://datatracker.ietf.org/doc/html/rfc7636#appendix-B
const codeVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
const challenge = await generateCodeChallenge(codeVerifier);
expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
});
it('generates a URL-safe random string of the requested length family', () => {
const value = generateRandomString();
expect(value).toMatch(/^[A-Za-z0-9_-]+$/);
expect(value.length).toBeGreaterThanOrEqual(43);
});
it('generates different values on each call', () => {
expect(generateRandomString()).not.toBe(generateRandomString());
});
});

View File

@@ -0,0 +1,16 @@
function base64UrlEncode(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function generateRandomString(byteLength = 32): string {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
export async function generateCodeChallenge(codeVerifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
return base64UrlEncode(new Uint8Array(digest));
}

View File

@@ -2,8 +2,8 @@ export const environment = {
production: false,
apiBaseUrl: '/api/v1',
oidc: {
issuer: 'https://idp.example.invalid/realms/travel-planner',
clientId: 'travel-planner-web',
issuer: 'https://auth.forgecore.work',
clientId: 'client_a297fd8d9c1f47a79d3600ea0c96984',
redirectUri: `${window.location.origin}/auth/callback`,
scope: 'openid profile email',
},