feat: add oidc authorization code with pkce login flow
This commit is contained in:
@@ -46,7 +46,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all",
|
"outputHashing": "all",
|
||||||
"serviceWorker": "ngsw-config.json"
|
"serviceWorker": "ngsw-config.json",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.production.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"@angular/service-worker": "^21.2.0",
|
"@angular/service-worker": "^21.2.0",
|
||||||
|
"oidc-client-ts": "^3.5.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { provideServiceWorker } from '@angular/service-worker';
|
import { provideServiceWorker } from '@angular/service-worker';
|
||||||
|
import { authInterceptor } from './auth/auth.interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
provideServiceWorker('ngsw-worker.js', {
|
provideServiceWorker('ngsw-worker.js', {
|
||||||
enabled: !isDevMode(),
|
enabled: !isDevMode(),
|
||||||
registrationStrategy: 'registerWhenStable:30000',
|
registrationStrategy: 'registerWhenStable:30000',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
|
import { Callback } from './auth/callback/callback';
|
||||||
|
|
||||||
export const routes: Routes = [];
|
export const routes: Routes = [{ path: 'auth/callback', component: Callback }];
|
||||||
|
|||||||
12
frontend/src/app/auth/auth.guard.ts
Normal file
12
frontend/src/app/auth/auth.guard.ts
Normal 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;
|
||||||
|
};
|
||||||
36
frontend/src/app/auth/auth.interceptor.spec.ts
Normal file
36
frontend/src/app/auth/auth.interceptor.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
frontend/src/app/auth/auth.interceptor.ts
Normal file
19
frontend/src/app/auth/auth.interceptor.ts
Normal 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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/src/app/auth/auth.service.spec.ts
Normal file
11
frontend/src/app/auth/auth.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
frontend/src/app/auth/auth.service.ts
Normal file
39
frontend/src/app/auth/auth.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/src/app/auth/callback/callback.html
Normal file
1
frontend/src/app/auth/callback/callback.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<p>Signing you in…</p>
|
||||||
27
frontend/src/app/auth/callback/callback.spec.ts
Normal file
27
frontend/src/app/auth/callback/callback.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
17
frontend/src/app/auth/callback/callback.ts
Normal file
17
frontend/src/app/auth/callback/callback.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/src/environments/environment.production.ts
Normal file
13
frontend/src/environments/environment.production.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
10
frontend/src/environments/environment.ts
Normal file
10
frontend/src/environments/environment.ts
Normal 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
17
pnpm-lock.yaml
generated
@@ -141,6 +141,9 @@ importers:
|
|||||||
'@angular/service-worker':
|
'@angular/service-worker':
|
||||||
specifier: ^21.2.0
|
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)
|
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:
|
rxjs:
|
||||||
specifier: ~7.8.0
|
specifier: ~7.8.0
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
@@ -3534,6 +3537,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
||||||
engines: {'0': node >= 0.2.0}
|
engines: {'0': node >= 0.2.0}
|
||||||
|
|
||||||
|
jwt-decode@4.0.0:
|
||||||
|
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -3869,6 +3876,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||||
engines: {node: '>=12.20.0'}
|
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:
|
on-finished@2.4.1:
|
||||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -8679,6 +8690,8 @@ snapshots:
|
|||||||
|
|
||||||
jsonparse@1.3.1: {}
|
jsonparse@1.3.1: {}
|
||||||
|
|
||||||
|
jwt-decode@4.0.0: {}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@@ -9028,6 +9041,10 @@ snapshots:
|
|||||||
|
|
||||||
obug@2.1.4: {}
|
obug@2.1.4: {}
|
||||||
|
|
||||||
|
oidc-client-ts@3.5.0:
|
||||||
|
dependencies:
|
||||||
|
jwt-decode: 4.0.0
|
||||||
|
|
||||||
on-finished@2.4.1:
|
on-finished@2.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ee-first: 1.1.1
|
ee-first: 1.1.1
|
||||||
|
|||||||
Reference in New Issue
Block a user