feat: add oidc authorization code with pkce login flow

This commit is contained in:
Bastian Wagner
2026-08-17 15:43:17 +02:00
parent dedb3fff40
commit bd91a3da4a
15 changed files with 215 additions and 2 deletions

View File

@@ -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,

View File

@@ -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"
},

View File

@@ -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',

View File

@@ -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 }];

View File

@@ -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;
};

View File

@@ -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<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

@@ -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<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

@@ -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);
});
});

View File

@@ -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<void> {
return this.userManager.signinRedirect();
}
async completeLogin(): Promise<void> {
await this.userManager.signinRedirectCallback();
}
logout(): Promise<void> {
return this.userManager.signoutRedirect();
}
async getAccessToken(): Promise<string | undefined> {
const user = await this.userManager.getUser();
return user && !user.expired ? user.access_token : undefined;
}
}

View File

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

View File

@@ -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');
});
});

View File

@@ -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<void> {
await this.authService.completeLogin();
await this.router.navigateByUrl('/trips');
}
}

View File

@@ -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',
},
};

View File

@@ -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',
},
};

17
pnpm-lock.yaml generated
View File

@@ -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