feat: add oidc authorization code with pkce login flow
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }];
|
||||
|
||||
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',
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user