feat: rework OIDC to BFF (Backend-For-Frontend) cookie-session pattern
- backend: switch from OAuth2 Resource Server to OAuth2 Client (spring-boot-starter-oauth2-client) - backend: configure Spring Security oauth2Login() with session cookie (JSESSIONID HttpOnly) - backend: add UserController exposing /api/user to report authenticated user info - backend: configure GET /logout with RP-initiated end-session support - backend: update HelloController to use @AuthenticationPrincipal OAuth2User - backend: update all tests to use .with(oidcLogin()) mock - backend: explicitly configure OIDC provider endpoints to bypass discovery at test time - frontend: remove angular-oauth2-oidc and @ngx-env/builder dependencies - frontend: revert angular.json builders to standard @angular/build:* - frontend: replace client-side OIDC flow with /api/user check + window.location redirects - frontend: update app.config.ts and config.service.ts to remove OIDC configuration - frontend: rewrite all unit tests to mock /api/user endpoint instead of OAuthService - compose: move OIDC_CLIENT_ID and OIDC_CLIENT_SECRET to backend environment variables - compose/dockerfile: remove frontend build-time OIDC ARG/ENV secrets
This commit is contained in:
1 parent
fce17a1c31
commit
bbe8916e82
19 files changed
+198
-194
No files matched your search
@@ -1,7 +1,6 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideAppInitializer, inject } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideOAuthClient, OAuthService } from 'angular-oauth2-oidc';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { ConfigService } from './config.service';
|
||||
@@ -11,23 +10,9 @@ export const appConfig: ApplicationConfig = {
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(),
|
||||
provideOAuthClient(),
|
||||
provideAppInitializer(() => {
|
||||
const configService = inject(ConfigService);
|
||||
const oauthService = inject(OAuthService);
|
||||
return configService.loadConfig().then(() => {
|
||||
oauthService.configure({
|
||||
issuer: configService.oidcIssuer,
|
||||
redirectUri: window.location.origin + '/',
|
||||
clientId: configService.clientId,
|
||||
dummyClientSecret: configService.clientSecret,
|
||||
responseType: 'code',
|
||||
scope: 'openid profile email',
|
||||
showDebugInformation: true,
|
||||
requireHttps: false
|
||||
});
|
||||
return oauthService.loadDiscoveryDocumentAndTryLogin();
|
||||
});
|
||||
return configService.loadConfig();
|
||||
})
|
||||
]
|
||||
};
|
||||
@@ -1,27 +1,27 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { ApartmentService } from './apartment.service';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('App', () => {
|
||||
let mockOAuthService: any;
|
||||
let mockHttpClient: any;
|
||||
let mockApartmentService: any;
|
||||
let mockUserResponse: { authenticated: boolean; username: string; email: string };
|
||||
let originalLocation: any;
|
||||
let locationMock: { href: string; origin: string };
|
||||
|
||||
beforeEach(async () => {
|
||||
mockOAuthService = {
|
||||
hasValidAccessToken: () => false,
|
||||
getIdentityClaims: () => null,
|
||||
getAccessToken: () => 'mock-token',
|
||||
initCodeFlow: vi.fn(),
|
||||
revokeTokenAndLogout: vi.fn()
|
||||
};
|
||||
mockUserResponse = { authenticated: false, username: '', email: '' };
|
||||
|
||||
mockHttpClient = {
|
||||
get: () => of('Hello from mocked API!')
|
||||
get: (url: string) => {
|
||||
if (url === '/api/user') {
|
||||
return of(mockUserResponse);
|
||||
}
|
||||
return of('Hello from mocked API!');
|
||||
}
|
||||
};
|
||||
|
||||
mockApartmentService = {
|
||||
@@ -34,16 +34,27 @@ describe('App', () => {
|
||||
deleteApartment: () => of(undefined)
|
||||
};
|
||||
|
||||
originalLocation = window.location;
|
||||
delete (window as any).location;
|
||||
locationMock = {
|
||||
href: '',
|
||||
origin: 'http://localhost'
|
||||
};
|
||||
(window as any).location = locationMock;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [
|
||||
{ provide: OAuthService, useValue: mockOAuthService },
|
||||
{ provide: HttpClient, useValue: mockHttpClient },
|
||||
{ provide: ApartmentService, useValue: mockApartmentService }
|
||||
]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(window as any).location = originalLocation;
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
@@ -52,51 +63,38 @@ describe('App', () => {
|
||||
|
||||
it('should render title', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain('LMNP Organizer');
|
||||
});
|
||||
|
||||
it('should return login status based on OAuthService', () => {
|
||||
it('should return login status based on user endpoint response', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
|
||||
mockOAuthService.hasValidAccessToken = () => true;
|
||||
expect(app.isLoggedIn).toBe(true);
|
||||
|
||||
mockOAuthService.hasValidAccessToken = () => false;
|
||||
// Initially unauthenticated
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(app.isLoggedIn).toBe(false);
|
||||
});
|
||||
|
||||
it('should return username and email from identity claims', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
|
||||
// Default username when no claims
|
||||
expect(app.username).toBe('');
|
||||
|
||||
// Custom claims
|
||||
mockOAuthService.getIdentityClaims = () => ({
|
||||
preferred_username: 'Alice',
|
||||
email: 'alice@example.com'
|
||||
});
|
||||
|
||||
// Activating authenticated response and calling ngOnInit manually
|
||||
mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' };
|
||||
app.ngOnInit();
|
||||
expect(app.isLoggedIn).toBe(true);
|
||||
expect(app.username).toBe('Alice');
|
||||
expect(app.email).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
it('should invoke OIDC login and logout flows', () => {
|
||||
it('should invoke OIDC login and logout flows by redirecting the browser window', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
|
||||
const loginSpy = vi.spyOn(mockOAuthService, 'initCodeFlow');
|
||||
const logoutSpy = vi.spyOn(mockOAuthService, 'revokeTokenAndLogout');
|
||||
|
||||
app.login();
|
||||
expect(loginSpy).toHaveBeenCalled();
|
||||
expect(locationMock.href).toBe('/oauth2/authorization/oidc');
|
||||
|
||||
app.logout();
|
||||
expect(logoutSpy).toHaveBeenCalled();
|
||||
expect(locationMock.href).toBe('/logout');
|
||||
});
|
||||
|
||||
it('should call hello API and set message on success', () => {
|
||||
@@ -111,22 +109,21 @@ describe('App', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
|
||||
mockHttpClient.get = () => throwError(() => ({ statusText: 'Unauthorized' }));
|
||||
mockHttpClient.get = (url: string) => {
|
||||
if (url === '/api/user') {
|
||||
return of(mockUserResponse);
|
||||
}
|
||||
return throwError(() => ({ statusText: 'Unauthorized' }));
|
||||
};
|
||||
|
||||
app.callHelloApi();
|
||||
expect(app.helloMessage()).toBe('Failed to connect to backend: Unauthorized');
|
||||
});
|
||||
|
||||
it('should render logged in state when user is authenticated', async () => {
|
||||
mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' };
|
||||
const fixture = TestBed.createComponent(App);
|
||||
|
||||
// Log in
|
||||
mockOAuthService.hasValidAccessToken = () => true;
|
||||
mockOAuthService.getIdentityClaims = () => ({
|
||||
preferred_username: 'Alice',
|
||||
email: 'alice@example.com'
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
@@ -136,16 +133,10 @@ describe('App', () => {
|
||||
});
|
||||
|
||||
it('should render hello API response when API button is clicked', async () => {
|
||||
mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' };
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
|
||||
// Log in
|
||||
mockOAuthService.hasValidAccessToken = () => true;
|
||||
mockOAuthService.getIdentityClaims = () => ({
|
||||
preferred_username: 'Alice',
|
||||
email: 'alice@example.com'
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
@@ -165,7 +156,7 @@ describe('App', () => {
|
||||
|
||||
// Apartment CRUD tests
|
||||
it('should load apartments on init if authenticated', async () => {
|
||||
mockOAuthService.hasValidAccessToken = () => true;
|
||||
mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' };
|
||||
const loadSpy = vi.spyOn(mockApartmentService, 'getApartments');
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
|
||||
+30
-19
@@ -1,6 +1,5 @@
|
||||
import { Component, signal, inject, OnInit, effect } from '@angular/core';
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { ApartmentService, Apartment } from './apartment.service';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
@@ -27,7 +26,10 @@ export class App implements OnInit {
|
||||
formDescription = '';
|
||||
formError = '';
|
||||
|
||||
private readonly oauthService = inject(OAuthService);
|
||||
private readonly _isLoggedIn = signal<boolean>(false);
|
||||
private readonly _username = signal<string>('');
|
||||
private readonly _email = signal<string>('');
|
||||
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly apartmentService = inject(ApartmentService);
|
||||
|
||||
@@ -40,40 +42,49 @@ export class App implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.isLoggedIn) {
|
||||
this.loadApartments();
|
||||
}
|
||||
this.http.get<{authenticated: boolean, username?: string, email?: string}>('/api/user').subscribe({
|
||||
next: (user) => {
|
||||
if (user.authenticated) {
|
||||
this._isLoggedIn.set(true);
|
||||
this._username.set(user.username || 'User');
|
||||
this._email.set(user.email || '');
|
||||
} else {
|
||||
this._isLoggedIn.set(false);
|
||||
this._username.set('');
|
||||
this._email.set('');
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error fetching user authentication status', err);
|
||||
this._isLoggedIn.set(false);
|
||||
this._username.set('');
|
||||
this._email.set('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get isLoggedIn(): boolean {
|
||||
return this.oauthService.hasValidAccessToken();
|
||||
return this._isLoggedIn();
|
||||
}
|
||||
|
||||
get username(): string {
|
||||
const claims = this.oauthService.getIdentityClaims() as Record<string, string> | null;
|
||||
if (!claims) return '';
|
||||
return claims['preferred_username'] || claims['given_name'] || claims['name'] || 'User';
|
||||
return this._username();
|
||||
}
|
||||
|
||||
get email(): string {
|
||||
const claims = this.oauthService.getIdentityClaims() as Record<string, string> | null;
|
||||
return claims ? claims['email'] : '';
|
||||
return this._email();
|
||||
}
|
||||
|
||||
login() {
|
||||
this.oauthService.initCodeFlow();
|
||||
window.location.href = '/oauth2/authorization/oidc';
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.oauthService.revokeTokenAndLogout();
|
||||
window.location.href = '/logout';
|
||||
}
|
||||
|
||||
callHelloApi() {
|
||||
const headers = new HttpHeaders({
|
||||
'Authorization': 'Bearer ' + this.oauthService.getAccessToken()
|
||||
});
|
||||
|
||||
this.http.get('/api/hello', { headers, responseType: 'text' }).subscribe({
|
||||
this.http.get('/api/hello', { responseType: 'text' }).subscribe({
|
||||
next: (msg) => this.helloMessage.set(msg),
|
||||
error: (err) => {
|
||||
console.error(err);
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ConfigService } from './config.service';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
describe('ConfigService', () => {
|
||||
let service: ConfigService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset env variables before each test
|
||||
vi.stubEnv('OIDC_ISSUER_URI', '');
|
||||
vi.stubEnv('OIDC_CLIENT_ID', '');
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ConfigService]
|
||||
});
|
||||
@@ -21,19 +16,6 @@ describe('ConfigService', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return default values when env variables are not set', () => {
|
||||
expect(service.oidcIssuer).toBe('http://localhost:8090/realms/voidAuth');
|
||||
expect(service.clientId).toBe('lmnp-client');
|
||||
});
|
||||
|
||||
it('should return env values when env variables are set', () => {
|
||||
vi.stubEnv('OIDC_ISSUER_URI', 'http://env-issuer');
|
||||
vi.stubEnv('OIDC_CLIENT_ID', 'env-client');
|
||||
|
||||
expect(service.oidcIssuer).toBe('http://env-issuer');
|
||||
expect(service.clientId).toBe('env-client');
|
||||
});
|
||||
|
||||
it('should resolve loadConfig successfully', async () => {
|
||||
await expect(service.loadConfig()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -7,16 +7,4 @@ export class ConfigService {
|
||||
async loadConfig(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
get oidcIssuer(): string {
|
||||
return import.meta.env['OIDC_ISSUER_URI'] || 'http://localhost:8090/realms/voidAuth';
|
||||
}
|
||||
|
||||
get clientId(): string {
|
||||
return import.meta.env['OIDC_CLIENT_ID'] || 'lmnp-client';
|
||||
}
|
||||
|
||||
get clientSecret(): string {
|
||||
return import.meta.env['OIDC_CLIENT_SECRET'] || 'lmnp-secret';
|
||||
}
|
||||
}
|
||||
Vendored
-22
@@ -1,22 +0,0 @@
|
||||
// Define the type of the environment variables.
|
||||
declare interface Env {
|
||||
readonly NODE_ENV: string;
|
||||
readonly OIDC_ISSUER_URI?: string;
|
||||
readonly OIDC_CLIENT_ID?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
// Choose how to access the environment variables.
|
||||
// Remove the unused options.
|
||||
|
||||
// 1. Use import.meta.env.YOUR_ENV_VAR in your code. (conventional)
|
||||
declare interface ImportMeta {
|
||||
readonly env: Env;
|
||||
}
|
||||
|
||||
// 2. Use _NGX_ENV_.YOUR_ENV_VAR in your code. (customizable)
|
||||
// You can modify the name of the variable in angular.json.
|
||||
// ngxEnv: {
|
||||
// define: '_NGX_ENV_',
|
||||
// }
|
||||
declare const _NGX_ENV_: Env;
|
||||
Reference in new issue
Block a user