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

@@ -2,7 +2,8 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {

View File

@@ -1,16 +1,15 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideHttpClient } from '@angular/common/http';
import { routes } from './app.routes';
import { provideServiceWorker } from '@angular/service-worker';
import { authInterceptor } from './auth/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClient(),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',

View File

@@ -3,6 +3,9 @@
<p>Reisen planen, gemeinsam entscheiden.</p>
<nav>
<a routerLink="/trips">Reisen</a>
@if (authService.isAuthenticated()) {
<button type="button" (click)="logout()">Abmelden</button>
}
</nav>
<router-outlet />
</main>

View File

@@ -1,9 +1,7 @@
import { Routes } from '@angular/router';
import { Callback } from './auth/callback/callback';
import { authGuard } from './auth/auth.guard';
export const routes: Routes = [
{ path: 'auth/callback', component: Callback },
{
path: 'trips',
canActivate: [authGuard],

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { AuthService } from './auth/auth.service';
@Component({
selector: 'app-root',
@@ -7,4 +8,10 @@ import { RouterLink, RouterOutlet } from '@angular/router';
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {}
export class App {
protected readonly authService = inject(AuthService);
logout(): void {
void this.authService.logout();
}
}

View File

@@ -0,0 +1,26 @@
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { authGuard } from './auth.guard';
import { AuthService } from './auth.service';
describe('authGuard', () => {
it('allows activation when a session already exists', async () => {
const authService = { ensureSessionChecked: vi.fn().mockResolvedValue(true), login: vi.fn() };
TestBed.configureTestingModule({ providers: [{ provide: AuthService, useValue: authService }] });
const result = await TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));
expect(result).toBe(true);
expect(authService.login).not.toHaveBeenCalled();
});
it('triggers login and denies activation when no session exists', async () => {
const authService = { ensureSessionChecked: vi.fn().mockResolvedValue(false), login: vi.fn() };
TestBed.configureTestingModule({ providers: [{ provide: AuthService, useValue: authService }] });
const result = await TestBed.runInInjectionContext(() => authGuard({} as never, {} as never));
expect(result).toBe(false);
expect(authService.login).toHaveBeenCalledTimes(1);
});
});

View File

@@ -2,11 +2,12 @@ import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = () => {
export const authGuard: CanActivateFn = async () => {
const authService = inject(AuthService);
if (authService.isAuthenticated()) {
const authenticated = await authService.ensureSessionChecked();
if (authenticated) {
return true;
}
void authService.login();
authService.login();
return false;
};

View File

@@ -1,36 +0,0 @@
import { HttpHandlerFn, HttpRequest, HttpResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { firstValueFrom, of } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { authInterceptor } from './auth.interceptor';
import { AuthService } from './auth.service';
describe('authInterceptor', () => {
it('attaches a bearer token to API requests', async () => {
const authService = { getAccessToken: vi.fn().mockResolvedValue('token-123') };
TestBed.configureTestingModule({ providers: [{ provide: AuthService, useValue: authService }] });
const req = new HttpRequest('GET', '/api/v1/trips');
const next = vi.fn().mockReturnValue(of(new HttpResponse())) as unknown as HttpHandlerFn;
await firstValueFrom(TestBed.runInInjectionContext(() => authInterceptor(req, next)));
expect(next).toHaveBeenCalledTimes(1);
const forwarded = vi.mocked(next).mock.calls[0][0] as HttpRequest<unknown>;
expect(forwarded.headers.get('Authorization')).toBe('Bearer token-123');
});
it('does not attach a token to non-API requests', async () => {
const authService = { getAccessToken: vi.fn().mockResolvedValue('token-123') };
TestBed.configureTestingModule({ providers: [{ provide: AuthService, useValue: authService }] });
const req = new HttpRequest('GET', 'https://example.com/unrelated');
const next = vi.fn().mockReturnValue(of(new HttpResponse())) as unknown as HttpHandlerFn;
await firstValueFrom(TestBed.runInInjectionContext(() => authInterceptor(req, next)));
expect(authService.getAccessToken).not.toHaveBeenCalled();
const forwarded = vi.mocked(next).mock.calls[0][0] as HttpRequest<unknown>;
expect(forwarded.headers.get('Authorization')).toBeNull();
});
});

View File

@@ -1,19 +0,0 @@
import { HttpHandlerFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { from, switchMap } from 'rxjs';
import { environment } from '../../environments/environment';
import { AuthService } from './auth.service';
export function authInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) {
if (!req.url.startsWith(environment.apiBaseUrl)) {
return next(req);
}
const authService = inject(AuthService);
return from(authService.getAccessToken()).pipe(
switchMap((token) => {
const authorizedReq = token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
return next(authorizedReq);
}),
);
}

View File

@@ -1,87 +1,62 @@
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from './auth.service';
describe('AuthService', () => {
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('starts unauthenticated when no session is stored', () => {
it('starts with an unknown authentication state until checked', () => {
const service = TestBed.inject(AuthService);
expect(service.isAuthenticated()).toBe(false);
expect(service.isAuthenticated()).toBeUndefined();
});
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);
it('login() navigates to the backend login endpoint', () => {
vi.stubGlobal('location', { ...window.location, href: '' });
const service = TestBed.inject(AuthService);
await service.login();
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');
expect(window.location.href).toBe('/api/v1/auth/login');
});
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 }),
});
it('ensureSessionChecked() reports authenticated when /users/me succeeds, and only fetches once', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal('fetch', fetchMock);
const service = TestBed.inject(AuthService);
await service.completeLogin();
await expect(service.ensureSessionChecked()).resolves.toBe(true);
await expect(service.ensureSessionChecked()).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('/api/v1/users/me');
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));
it('ensureSessionChecked() reports unauthenticated when /users/me returns 401', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
const service = TestBed.inject(AuthService);
service.logout();
await expect(service.ensureSessionChecked()).resolves.toBe(false);
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);
it('ensureSessionChecked() reports unauthenticated when the request itself fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
await expect(service.getAccessToken()).resolves.toBeUndefined();
const service = TestBed.inject(AuthService);
await expect(service.ensureSessionChecked()).resolves.toBe(false);
});
it('logout() posts to the backend and clears the authenticated state', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal('fetch', fetchMock);
const service = TestBed.inject(AuthService);
await service.logout();
expect(fetchMock).toHaveBeenCalledWith('/api/v1/auth/logout', { method: 'POST' });
expect(service.isAuthenticated()).toBe(false);
});
});

View File

@@ -1,103 +1,46 @@
import { Injectable, signal } from '@angular/core';
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.
* the entire Authorization Code + PKCE dance — including the PKCE verifier and
* the resulting access token — is handled server-side (see
* `GET /api/v1/auth/login`, `GET /api/v1/auth/callback`). The backend sets an
* httpOnly session cookie; the browser never sees an access token at all.
* This service therefore only triggers navigation and asks the backend
* "is there a valid session?" — it holds no tokens or PKCE state itself.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
private discoveryPromise: Promise<DiscoveryDocument> | undefined;
readonly isAuthenticated = signal<boolean | undefined>(undefined);
readonly isAuthenticated = signal(this.hasValidAccessToken());
private sessionCheck: Promise<boolean> | undefined;
private hasValidAccessToken(): boolean {
const expiresAt = Number(sessionStorage.getItem(EXPIRES_AT_KEY) ?? 0);
return !!sessionStorage.getItem(ACCESS_TOKEN_KEY) && Date.now() < expiresAt;
login(): void {
window.location.href = `${environment.apiBaseUrl}/auth/login`;
}
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> {
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(): void {
sessionStorage.removeItem(ACCESS_TOKEN_KEY);
sessionStorage.removeItem(EXPIRES_AT_KEY);
async logout(): Promise<void> {
await fetch(`${environment.apiBaseUrl}/auth/logout`, { method: 'POST' });
this.sessionCheck = undefined;
this.isAuthenticated.set(false);
}
async getAccessToken(): Promise<string | undefined> {
return this.hasValidAccessToken() ? (sessionStorage.getItem(ACCESS_TOKEN_KEY) ?? undefined) : undefined;
ensureSessionChecked(): Promise<boolean> {
if (!this.sessionCheck) {
this.sessionCheck = this.checkSession();
}
return this.sessionCheck;
}
private async checkSession(): Promise<boolean> {
try {
const response = await fetch(`${environment.apiBaseUrl}/users/me`);
this.isAuthenticated.set(response.ok);
return response.ok;
} catch {
this.isAuthenticated.set(false);
return false;
}
}
}

View File

@@ -1 +0,0 @@
<p>Signing you in…</p>

View File

@@ -1,27 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { describe, expect, it, vi } from 'vitest';
import { Callback } from './callback';
import { AuthService } from '../auth.service';
describe('Callback', () => {
it('completes the OIDC login and navigates to /trips', async () => {
const authService = { completeLogin: vi.fn().mockResolvedValue(undefined) };
const router = { navigateByUrl: vi.fn().mockResolvedValue(true) };
await TestBed.configureTestingModule({
imports: [Callback],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: Router, useValue: router },
],
}).compileComponents();
const fixture = TestBed.createComponent(Callback);
fixture.detectChanges();
await fixture.whenStable();
expect(authService.completeLogin).toHaveBeenCalledTimes(1);
expect(router.navigateByUrl).toHaveBeenCalledWith('/trips');
});
});

View File

@@ -1,17 +0,0 @@
import { Component, inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../auth.service';
@Component({
selector: 'app-callback',
templateUrl: './callback.html',
})
export class Callback implements OnInit {
private readonly authService = inject(AuthService);
private readonly router = inject(Router);
async ngOnInit(): Promise<void> {
await this.authService.completeLogin();
await this.router.navigateByUrl('/trips');
}
}

View File

@@ -1,21 +0,0 @@
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

@@ -1,16 +0,0 @@
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));
}