diff --git a/apps/isa-app/src/app/app.module.ts b/apps/isa-app/src/app/app.module.ts index a21507874..4d13e4ab4 100644 --- a/apps/isa-app/src/app/app.module.ts +++ b/apps/isa-app/src/app/app.module.ts @@ -117,7 +117,9 @@ export function _appInitializerFactory(config: Config, injector: Injector) { statusElement.innerHTML = 'Authentifizierung wird durchgeführt...'; const strategy = injector.get(LoginStrategy); await strategy.login(); + return; } + statusElement.innerHTML = 'Native Container wird initialisiert...'; const nativeContainer = injector.get(NativeContainerService); await nativeContainer.init(); @@ -141,7 +143,7 @@ export function _appInitializerFactory(config: Config, injector: Injector) { }); // Inject tab navigation service to initialize it - injector.get(TabNavigationService); + injector.get(TabNavigationService).init(); } catch (error) { console.error('Error during app initialization', error); laoderElement.remove(); diff --git a/apps/isa-app/src/app/interceptors/http-error.interceptor.ts b/apps/isa-app/src/app/interceptors/http-error.interceptor.ts index 71ef22f2c..8c2b40cf8 100644 --- a/apps/isa-app/src/app/interceptors/http-error.interceptor.ts +++ b/apps/isa-app/src/app/interceptors/http-error.interceptor.ts @@ -1,42 +1,50 @@ -import { inject, Injectable, Injector } from '@angular/core'; -import { HttpInterceptor, HttpEvent, HttpHandler, HttpRequest, HttpErrorResponse } from '@angular/common/http'; -import { from, NEVER, Observable, throwError } from 'rxjs'; -import { UiMessageModalComponent, UiModalService } from '@ui/modal'; -import { catchError, filter, mergeMap, takeUntil, tap } from 'rxjs/operators'; -import { AuthService, LoginStrategy } from '@core/auth'; -import { IsaLogProvider } from '../providers'; -import { LogLevel } from '@core/logger'; -import { injectOnline$ } from '../services/network-status.service'; - -@Injectable() -export class HttpErrorInterceptor implements HttpInterceptor { - readonly offline$ = injectOnline$().pipe(filter((online) => !online)); - readonly injector = inject(Injector); - - constructor( - private _modal: UiModalService, - private _auth: AuthService, - private _isaLogProvider: IsaLogProvider, - ) {} - - intercept(req: HttpRequest, next: HttpHandler): Observable> { - return next.handle(req).pipe( - takeUntil(this.offline$), - catchError((error: HttpErrorResponse, caught: any) => this.handleError(error)), - ); - } - - handleError(error: HttpErrorResponse): Observable { - if (error.status === 401) { - const strategy = this.injector.get(LoginStrategy); - - return from(strategy.login('Sie sind nicht mehr angemeldet')).pipe(mergeMap(() => NEVER)); - } - - if (!error.url.endsWith('/isa/logging')) { - this._isaLogProvider.log(LogLevel.ERROR, 'Http Error', error); - } - - return throwError(error); - } -} +import { inject, Injectable, Injector } from '@angular/core'; +import { + HttpInterceptor, + HttpEvent, + HttpHandler, + HttpRequest, + HttpErrorResponse, +} from '@angular/common/http'; +import { from, NEVER, Observable, throwError } from 'rxjs'; +import { catchError, filter, mergeMap, takeUntil } from 'rxjs/operators'; +import { LoginStrategy } from '@core/auth'; +import { IsaLogProvider } from '../providers'; +import { LogLevel } from '@core/logger'; +import { injectOnline$ } from '../services/network-status.service'; + +@Injectable() +export class HttpErrorInterceptor implements HttpInterceptor { + readonly offline$ = injectOnline$().pipe(filter((online) => !online)); + readonly injector = inject(Injector); + + constructor(private _isaLogProvider: IsaLogProvider) {} + + intercept( + req: HttpRequest, + next: HttpHandler, + ): Observable> { + return next.handle(req).pipe( + takeUntil(this.offline$), + catchError((error: HttpErrorResponse, caught: any) => + this.handleError(error), + ), + ); + } + + handleError(error: HttpErrorResponse): Observable { + if (error.status === 401) { + const strategy = this.injector.get(LoginStrategy); + + return from(strategy.login('Sie sind nicht mehr angemeldet')).pipe( + mergeMap(() => NEVER), + ); + } + + if (!error.url.endsWith('/isa/logging')) { + this._isaLogProvider.log(LogLevel.ERROR, 'Http Error', error); + } + + return throwError(error); + } +} diff --git a/apps/isa-app/src/core/auth/auth.service.ts b/apps/isa-app/src/core/auth/auth.service.ts index c801b8b49..f8a1f7291 100644 --- a/apps/isa-app/src/core/auth/auth.service.ts +++ b/apps/isa-app/src/core/auth/auth.service.ts @@ -1,174 +1,178 @@ -import { coerceArray } from "@angular/cdk/coercion"; -import { inject, Injectable } from "@angular/core"; -import { Config } from "@core/config"; -import { isNullOrUndefined } from "@utils/common"; -import { AuthConfig, OAuthService } from "angular-oauth2-oidc"; -import { JwksValidationHandler } from "angular-oauth2-oidc-jwks"; -import { BehaviorSubject } from "rxjs"; - -/** - * Storage key for the URL to redirect to after login - */ -const REDIRECT_URL_KEY = "auth_redirect_url"; - -@Injectable({ - providedIn: "root", -}) -export class AuthService { - private readonly _initialized = new BehaviorSubject(false); - get initialized$() { - return this._initialized.asObservable(); - } - - private _authConfig: AuthConfig; - constructor( - private _config: Config, - private readonly _oAuthService: OAuthService, - ) { - this._oAuthService.events?.subscribe((event) => { - if (event.type === "token_received") { - console.log( - "SSO Token Expiration:", - new Date(this._oAuthService.getAccessTokenExpiration()), - ); - - // Handle redirect after successful authentication - setTimeout(() => { - const redirectUrl = this._getAndClearRedirectUrl(); - if (redirectUrl) { - window.location.href = redirectUrl; - } - }, 100); - } - }); - } - - async init() { - if (this._initialized.getValue()) { - throw new Error("AuthService is already initialized"); - } - - this._authConfig = this._config.get("@core/auth"); - - this._authConfig.redirectUri = window.location.origin; - - this._authConfig.silentRefreshRedirectUri = - window.location.origin + "/silent-refresh.html"; - this._authConfig.useSilentRefresh = true; - - this._oAuthService.configure(this._authConfig); - this._oAuthService.tokenValidationHandler = new JwksValidationHandler(); - - this._oAuthService.setupAutomaticSilentRefresh(); - - await this._oAuthService.loadDiscoveryDocumentAndTryLogin(); - - this._initialized.next(true); - } - - isAuthenticated() { - return this.isIdTokenValid(); - } - - isIdTokenValid() { - console.log( - "ID Token Expiration:", - new Date(this._oAuthService.getIdTokenExpiration()), - ); - return this._oAuthService.hasValidIdToken(); - } - - isAccessTokenValid() { - console.log( - "ACCESS Token Expiration:", - new Date(this._oAuthService.getAccessTokenExpiration()), - ); - return this._oAuthService.hasValidAccessToken(); - } - - getToken() { - return this._oAuthService.getAccessToken(); - } - - getClaims() { - const token = this._oAuthService.getAccessToken(); - return this.parseJwt(token); - } - - getClaimByKey(key: string) { - const claims = this.getClaims(); - if (isNullOrUndefined(claims)) { - return null; - } - return claims[key]; - } - - parseJwt(token: string) { - if (isNullOrUndefined(token)) { - return null; - } - const base64Url = token.split(".")[1]; - const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); - - const encoded = window.atob(base64); - return JSON.parse(encoded); - } - - /** - * Saves the URL to redirect to after successful login - */ - _saveRedirectUrl(): void { - localStorage.setItem(REDIRECT_URL_KEY, window.location.href); - } - - /** - * Gets and clears the saved redirect URL - */ - _getAndClearRedirectUrl(): string | null { - const url = localStorage.getItem(REDIRECT_URL_KEY); - localStorage.removeItem(REDIRECT_URL_KEY); - return url; - } - - login() { - this._saveRedirectUrl(); - this._oAuthService.initLoginFlow(); - } - - setKeyCardToken(token: string) { - this._oAuthService.customQueryParams = { - temp_token: token, - }; - } - - async logout() { - await this._oAuthService.revokeTokenAndLogout(); - } - - hasRole(role: string | string[]) { - const roles = coerceArray(role); - - const userRoles = this.getClaimByKey("role"); - - if (isNullOrUndefined(userRoles)) { - return false; - } - - return roles.every((r) => userRoles.includes(r)); - } - - async refresh() { - try { - if ( - this._authConfig.responseType.includes("code") && - this._authConfig.scope.includes("offline_access") - ) { - await this._oAuthService.refreshToken(); - } else { - await this._oAuthService.silentRefresh(); - } - } catch (error) { - console.error(error); - } - } -} +import { coerceArray } from '@angular/cdk/coercion'; +import { inject, Injectable } from '@angular/core'; +import { Config } from '@core/config'; +import { isNullOrUndefined } from '@utils/common'; +import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; +import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks'; +import { BehaviorSubject } from 'rxjs'; + +/** + * Storage key for the URL to redirect to after login + */ +const REDIRECT_URL_KEY = 'auth_redirect_url'; + +@Injectable({ + providedIn: 'root', +}) +export class AuthService { + private readonly _initialized = new BehaviorSubject(false); + get initialized$() { + return this._initialized.asObservable(); + } + + private _authConfig: AuthConfig; + constructor( + private _config: Config, + private readonly _oAuthService: OAuthService, + ) { + this._oAuthService.events?.subscribe((event) => { + if (event.type === 'token_received') { + console.log( + 'SSO Token Expiration:', + new Date(this._oAuthService.getAccessTokenExpiration()), + ); + + // Handle redirect after successful authentication + setTimeout(() => { + const redirectUrl = this._getAndClearRedirectUrl(); + if (redirectUrl) { + window.location.href = redirectUrl; + } + }, 100); + } + }); + } + + async init() { + if (this._initialized.getValue()) { + throw new Error('AuthService is already initialized'); + } + + this._authConfig = this._config.get('@core/auth'); + + this._authConfig.redirectUri = window.location.origin; + + this._authConfig.silentRefreshRedirectUri = + window.location.origin + '/silent-refresh.html'; + this._authConfig.useSilentRefresh = true; + + this._oAuthService.configure(this._authConfig); + this._oAuthService.tokenValidationHandler = new JwksValidationHandler(); + + this._oAuthService.setupAutomaticSilentRefresh(); + + await this._oAuthService.loadDiscoveryDocumentAndTryLogin(); + + if (!this._oAuthService.getAccessToken()) { + throw new Error('No access token. User is not authenticated.'); + } + + this._initialized.next(true); + } + + isAuthenticated() { + return this.isIdTokenValid(); + } + + isIdTokenValid() { + console.log( + 'ID Token Expiration:', + new Date(this._oAuthService.getIdTokenExpiration()), + ); + return this._oAuthService.hasValidIdToken(); + } + + isAccessTokenValid() { + console.log( + 'ACCESS Token Expiration:', + new Date(this._oAuthService.getAccessTokenExpiration()), + ); + return this._oAuthService.hasValidAccessToken(); + } + + getToken() { + return this._oAuthService.getAccessToken(); + } + + getClaims() { + const token = this._oAuthService.getAccessToken(); + return this.parseJwt(token); + } + + getClaimByKey(key: string) { + const claims = this.getClaims(); + if (isNullOrUndefined(claims)) { + return null; + } + return claims[key]; + } + + parseJwt(token: string) { + if (isNullOrUndefined(token)) { + return null; + } + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + + const encoded = window.atob(base64); + return JSON.parse(encoded); + } + + /** + * Saves the URL to redirect to after successful login + */ + _saveRedirectUrl(): void { + localStorage.setItem(REDIRECT_URL_KEY, window.location.href); + } + + /** + * Gets and clears the saved redirect URL + */ + _getAndClearRedirectUrl(): string | null { + const url = localStorage.getItem(REDIRECT_URL_KEY); + localStorage.removeItem(REDIRECT_URL_KEY); + return url; + } + + login() { + this._saveRedirectUrl(); + this._oAuthService.initLoginFlow(); + } + + setKeyCardToken(token: string) { + this._oAuthService.customQueryParams = { + temp_token: token, + }; + } + + async logout() { + await this._oAuthService.revokeTokenAndLogout(); + } + + hasRole(role: string | string[]) { + const roles = coerceArray(role); + + const userRoles = this.getClaimByKey('role'); + + if (isNullOrUndefined(userRoles)) { + return false; + } + + return roles.every((r) => userRoles.includes(r)); + } + + async refresh() { + try { + if ( + this._authConfig.responseType.includes('code') && + this._authConfig.scope.includes('offline_access') + ) { + await this._oAuthService.refreshToken(); + } else { + await this._oAuthService.silentRefresh(); + } + } catch (error) { + console.error(error); + } + } +} diff --git a/libs/core/storage/src/lib/tokens.ts b/libs/core/storage/src/lib/tokens.ts index 0c487ebcf..b143b855e 100644 --- a/libs/core/storage/src/lib/tokens.ts +++ b/libs/core/storage/src/lib/tokens.ts @@ -1,12 +1,36 @@ import { inject, InjectionToken, signal, Signal } from '@angular/core'; +import { logger } from '@isa/core/logging'; import { OAuthService } from 'angular-oauth2-oidc'; +import z from 'zod'; export const USER_SUB = new InjectionToken>( 'core.storage.user-sub', { factory: () => { - const auth = inject(OAuthService, { optional: true }); - return signal(auth?.getIdentityClaims()?.['sub'] ?? 'anonymous'); + const _logger = logger(() => ({ + context: 'USER_SUB', + })); + const auth = inject(OAuthService); + + const claims = auth.getIdentityClaims(); + + if (!claims || typeof claims !== 'object' || !('sub' in claims)) { + const err = new Error( + 'No valid identity claims found. User is anonymous.', + ); + _logger.error(err.message); + throw err; + } + + const validation = z.string().safeParse(claims['sub']); + + if (!validation.success) { + const err = new Error('Invalid "sub" claim in identity claims.'); + _logger.error(err.message, { claims }); + throw err; + } + + return signal(validation.data); }, }, ); diff --git a/libs/core/tabs/src/lib/tab-navigation.service.ts b/libs/core/tabs/src/lib/tab-navigation.service.ts index c294c4325..7af9a5ec6 100644 --- a/libs/core/tabs/src/lib/tab-navigation.service.ts +++ b/libs/core/tabs/src/lib/tab-navigation.service.ts @@ -30,11 +30,7 @@ export class TabNavigationService { #tabService = inject(TabService); #title = inject(Title); - constructor() { - this.#initializeNavigationSync(); - } - - #initializeNavigationSync() { + init() { this.#router.events .pipe(filter((event) => event instanceof NavigationEnd)) .subscribe((event: NavigationEnd) => { diff --git a/package.json b/package.json index e81f65e0b..1f6d8b5d1 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,8 @@ "@ngrx/signals": "^20.0.0", "@ngrx/store": "^20.0.0", "@ngrx/store-devtools": "^20.0.0", - "angular-oauth2-oidc": "20.0.0", - "angular-oauth2-oidc-jwks": "20.0.0", + "angular-oauth2-oidc": "^20.0.2", + "angular-oauth2-oidc-jwks": "^20.0.0", "date-fns": "^4.1.0", "lodash": "^4.17.21", "moment": "^2.30.1",