feat: fully backend-driven OIDC session flow (session cookie, not bearer token)

Replace the hybrid flow (frontend PKCE + POST /auth/session token
exchange, access token in sessionStorage) with a classic backend-driven
BFF: the browser only ever navigates to GET /api/v1/auth/login and is
redirected straight to the IdP; PKCE verifier/state live server-side in
Redis (SessionStoreService); GET /api/v1/auth/callback (now the
registered IdP redirect URI, replacing the frontend's /auth/callback
route, which is deleted) verifies the id_token, JIT-provisions the
user, creates a Redis-backed session, and sets one httpOnly SameSite=Lax
cookie before redirecting into the app. No token material of any kind
ever reaches the browser.

OidcAuthGuard (per-request bearer JWT verification) is replaced by
SessionAuthGuard (cookie -> Redis session lookup) across every
controller that used it. cookie-parser is now wired into main.ts.

Frontend AuthService shrinks to login()/logout()/ensureSessionChecked();
pkce.ts, auth.interceptor.ts, and the callback component/route are all
removed as dead code under this model.

New required env var: APP_BASE_URL (source of truth for the OIDC
redirect_uri and the post-login redirect target).

Verified end-to-end against the real API, Redis, and a mocked IdP:
login redirect shape, callback cookie + redirect, state-replay
rejection, /users/me 401<->200 around the cookie, and logout.
This commit is contained in:
Bastian Wagner
2026-08-17 17:33:55 +02:00
parent 4aca6b64a0
commit 1d2467049d
58 changed files with 936 additions and 574 deletions

View File

@@ -10,6 +10,7 @@ describe('TokenExchangeService.exchangeAuthorizationCode', () => {
oidcAudience: 'client-1',
oidcClientId: 'client-1',
oidcClientSecret: 'super-secret',
appBaseUrl: 'http://localhost:4200',
appVersion: 'dev',
teamCityBuildNumber: 'local',
sourceRevision: 'local',
@@ -21,7 +22,7 @@ describe('TokenExchangeService.exchangeAuthorizationCode', () => {
return { getTokenEndpoint: jest.fn().mockReturnValue(tokenEndpoint) };
}
it('posts a client-secret-authenticated request and returns only the safe fields', async () => {
it('posts a client-secret-authenticated request and returns the access + id token', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: () =>
@@ -41,10 +42,14 @@ describe('TokenExchangeService.exchangeAuthorizationCode', () => {
const result = await service.exchangeAuthorizationCode({
code: 'auth-code-1',
codeVerifier: 'verifier-1',
redirectUri: 'http://localhost:4200/auth/callback',
redirectUri: 'http://localhost:4200/api/v1/auth/callback',
});
expect(result).toEqual({ accessToken: 'at-1', expiresIn: 3600 });
expect(result).toEqual({
accessToken: 'at-1',
expiresIn: 3600,
idToken: 'idt-1',
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://idp.example.test/oidc/token');
@@ -55,11 +60,11 @@ describe('TokenExchangeService.exchangeAuthorizationCode', () => {
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',
'http://localhost:4200/api/v1/auth/callback',
);
});
it('never leaks the refresh_token or id_token to the caller', async () => {
it('never exposes the refresh_token, which is not needed by this MVP (no silent refresh)', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: () =>
@@ -79,11 +84,10 @@ describe('TokenExchangeService.exchangeAuthorizationCode', () => {
const result = await service.exchangeAuthorizationCode({
code: 'auth-code-1',
codeVerifier: 'verifier-1',
redirectUri: 'http://localhost:4200/auth/callback',
redirectUri: 'http://localhost:4200/api/v1/auth/callback',
});
expect(result).not.toHaveProperty('refreshToken');
expect(result).not.toHaveProperty('idToken');
});
it('rejects with BadRequestException when the identity provider rejects the code', async () => {
@@ -103,7 +107,7 @@ describe('TokenExchangeService.exchangeAuthorizationCode', () => {
service.exchangeAuthorizationCode({
code: 'bad-code',
codeVerifier: 'verifier-1',
redirectUri: 'http://localhost:4200/auth/callback',
redirectUri: 'http://localhost:4200/api/v1/auth/callback',
}),
).rejects.toThrow(BadRequestException);
});