From bd91a3da4a796a6cd9e02b5d010a9c7712727896 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 17 Aug 2026 15:43:17 +0200 Subject: [PATCH] feat: add oidc authorization code with pkce login flow --- frontend/angular.json | 8 +++- frontend/package.json | 1 + frontend/src/app/app.config.ts | 3 ++ frontend/src/app/app.routes.ts | 3 +- frontend/src/app/auth/auth.guard.ts | 12 ++++++ .../src/app/auth/auth.interceptor.spec.ts | 36 +++++++++++++++++ frontend/src/app/auth/auth.interceptor.ts | 19 +++++++++ frontend/src/app/auth/auth.service.spec.ts | 11 ++++++ frontend/src/app/auth/auth.service.ts | 39 +++++++++++++++++++ frontend/src/app/auth/callback/callback.html | 1 + .../src/app/auth/callback/callback.spec.ts | 27 +++++++++++++ frontend/src/app/auth/callback/callback.ts | 17 ++++++++ .../environments/environment.production.ts | 13 +++++++ frontend/src/environments/environment.ts | 10 +++++ pnpm-lock.yaml | 17 ++++++++ 15 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/auth/auth.guard.ts create mode 100644 frontend/src/app/auth/auth.interceptor.spec.ts create mode 100644 frontend/src/app/auth/auth.interceptor.ts create mode 100644 frontend/src/app/auth/auth.service.spec.ts create mode 100644 frontend/src/app/auth/auth.service.ts create mode 100644 frontend/src/app/auth/callback/callback.html create mode 100644 frontend/src/app/auth/callback/callback.spec.ts create mode 100644 frontend/src/app/auth/callback/callback.ts create mode 100644 frontend/src/environments/environment.production.ts create mode 100644 frontend/src/environments/environment.ts diff --git a/frontend/angular.json b/frontend/angular.json index bb140e7..286f0bc 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -46,7 +46,13 @@ } ], "outputHashing": "all", - "serviceWorker": "ngsw-config.json" + "serviceWorker": "ngsw-config.json", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.production.ts" + } + ] }, "development": { "optimization": false, diff --git a/frontend/package.json b/frontend/package.json index 5344ef4..b4716ea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@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" }, diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 655d9ae..3535203 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,13 +1,16 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core'; import { provideRouter } from '@angular/router'; +import { provideHttpClient, withInterceptors } 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])), provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000', diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index dc39edb..40bd1a7 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,3 +1,4 @@ import { Routes } from '@angular/router'; +import { Callback } from './auth/callback/callback'; -export const routes: Routes = []; +export const routes: Routes = [{ path: 'auth/callback', component: Callback }]; diff --git a/frontend/src/app/auth/auth.guard.ts b/frontend/src/app/auth/auth.guard.ts new file mode 100644 index 0000000..33cf68c --- /dev/null +++ b/frontend/src/app/auth/auth.guard.ts @@ -0,0 +1,12 @@ +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; +import { AuthService } from './auth.service'; + +export const authGuard: CanActivateFn = () => { + const authService = inject(AuthService); + if (authService.isAuthenticated()) { + return true; + } + void authService.login(); + return false; +}; diff --git a/frontend/src/app/auth/auth.interceptor.spec.ts b/frontend/src/app/auth/auth.interceptor.spec.ts new file mode 100644 index 0000000..bf0a6e4 --- /dev/null +++ b/frontend/src/app/auth/auth.interceptor.spec.ts @@ -0,0 +1,36 @@ +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; + 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; + expect(forwarded.headers.get('Authorization')).toBeNull(); + }); +}); diff --git a/frontend/src/app/auth/auth.interceptor.ts b/frontend/src/app/auth/auth.interceptor.ts new file mode 100644 index 0000000..65290fc --- /dev/null +++ b/frontend/src/app/auth/auth.interceptor.ts @@ -0,0 +1,19 @@ +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, 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); + }), + ); +} diff --git a/frontend/src/app/auth/auth.service.spec.ts b/frontend/src/app/auth/auth.service.spec.ts new file mode 100644 index 0000000..40afd2e --- /dev/null +++ b/frontend/src/app/auth/auth.service.spec.ts @@ -0,0 +1,11 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + it('starts unauthenticated when no OIDC user is stored', async () => { + const service = TestBed.inject(AuthService); + await Promise.resolve(); + expect(service.isAuthenticated()).toBe(false); + }); +}); diff --git a/frontend/src/app/auth/auth.service.ts b/frontend/src/app/auth/auth.service.ts new file mode 100644 index 0000000..2a39293 --- /dev/null +++ b/frontend/src/app/auth/auth.service.ts @@ -0,0 +1,39 @@ +import { Injectable, signal } from '@angular/core'; +import { UserManager } from 'oidc-client-ts'; +import { environment } from '../../environments/environment'; + +@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', + }); + + readonly isAuthenticated = signal(false); + + 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)); + } + + login(): Promise { + return this.userManager.signinRedirect(); + } + + async completeLogin(): Promise { + await this.userManager.signinRedirectCallback(); + } + + logout(): Promise { + return this.userManager.signoutRedirect(); + } + + async getAccessToken(): Promise { + const user = await this.userManager.getUser(); + return user && !user.expired ? user.access_token : undefined; + } +} diff --git a/frontend/src/app/auth/callback/callback.html b/frontend/src/app/auth/callback/callback.html new file mode 100644 index 0000000..2098280 --- /dev/null +++ b/frontend/src/app/auth/callback/callback.html @@ -0,0 +1 @@ +

Signing you in…

diff --git a/frontend/src/app/auth/callback/callback.spec.ts b/frontend/src/app/auth/callback/callback.spec.ts new file mode 100644 index 0000000..399ba43 --- /dev/null +++ b/frontend/src/app/auth/callback/callback.spec.ts @@ -0,0 +1,27 @@ +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'); + }); +}); diff --git a/frontend/src/app/auth/callback/callback.ts b/frontend/src/app/auth/callback/callback.ts new file mode 100644 index 0000000..43041a7 --- /dev/null +++ b/frontend/src/app/auth/callback/callback.ts @@ -0,0 +1,17 @@ +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 { + await this.authService.completeLogin(); + await this.router.navigateByUrl('/trips'); + } +} diff --git a/frontend/src/environments/environment.production.ts b/frontend/src/environments/environment.production.ts new file mode 100644 index 0000000..7671ee8 --- /dev/null +++ b/frontend/src/environments/environment.production.ts @@ -0,0 +1,13 @@ +// Values here are placeholders. `docker/edge.Dockerfile` overwrites this file at +// image build time from the OIDC_ISSUER/OIDC_CLIENT_ID build args (see Task 12), +// so no secret ever needs to be baked into source control. +export const environment = { + production: true, + apiBaseUrl: '/api/v1', + oidc: { + issuer: 'https://idp.example.invalid/realms/travel-planner', + clientId: 'travel-planner-web', + redirectUri: `${window.location.origin}/auth/callback`, + scope: 'openid profile email', + }, +}; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts new file mode 100644 index 0000000..931e419 --- /dev/null +++ b/frontend/src/environments/environment.ts @@ -0,0 +1,10 @@ +export const environment = { + production: false, + apiBaseUrl: '/api/v1', + oidc: { + issuer: 'https://idp.example.invalid/realms/travel-planner', + clientId: 'travel-planner-web', + redirectUri: `${window.location.origin}/auth/callback`, + scope: 'openid profile email', + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69b7404..0293a9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: '@angular/service-worker': specifier: ^21.2.0 version: 21.2.20(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2))(rxjs@7.8.2) + oidc-client-ts: + specifier: ^3.5.0 + version: 3.5.0 rxjs: specifier: ~7.8.0 version: 7.8.2 @@ -3534,6 +3537,10 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3869,6 +3876,10 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + oidc-client-ts@3.5.0: + resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==} + engines: {node: '>=18'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -8679,6 +8690,8 @@ snapshots: jsonparse@1.3.1: {} + jwt-decode@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -9028,6 +9041,10 @@ snapshots: obug@2.1.4: {} + oidc-client-ts@3.5.0: + dependencies: + jwt-decode: 4.0.0 + on-finished@2.4.1: dependencies: ee-first: 1.1.1