bugfix(auth): enhance authentication flow and error handling

- Ensure access token is present during initialization.
- Improve error logging for identity claims validation.
- Update dependencies for better compatibility.
This commit is contained in:
Lorenz Hilpert
2025-10-01 14:52:10 +02:00
parent 47a051c214
commit 827828aee2
6 changed files with 260 additions and 226 deletions

View File

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

View File

@@ -1,9 +1,14 @@
import { inject, Injectable, Injector } from '@angular/core';
import { HttpInterceptor, HttpEvent, HttpHandler, HttpRequest, HttpErrorResponse } from '@angular/common/http';
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 { 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';
@@ -13,16 +18,17 @@ 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,
) {}
constructor(private _isaLogProvider: IsaLogProvider) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
intercept(
req: HttpRequest<any>,
next: HttpHandler,
): Observable<HttpEvent<any>> {
return next.handle(req).pipe(
takeUntil(this.offline$),
catchError((error: HttpErrorResponse, caught: any) => this.handleError(error)),
catchError((error: HttpErrorResponse, caught: any) =>
this.handleError(error),
),
);
}
@@ -30,7 +36,9 @@ export class HttpErrorInterceptor implements HttpInterceptor {
if (error.status === 401) {
const strategy = this.injector.get(LoginStrategy);
return from(strategy.login('Sie sind nicht mehr angemeldet')).pipe(mergeMap(() => NEVER));
return from(strategy.login('Sie sind nicht mehr angemeldet')).pipe(
mergeMap(() => NEVER),
);
}
if (!error.url.endsWith('/isa/logging')) {

View File

@@ -1,18 +1,18 @@
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";
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";
const REDIRECT_URL_KEY = 'auth_redirect_url';
@Injectable({
providedIn: "root",
providedIn: 'root',
})
export class AuthService {
private readonly _initialized = new BehaviorSubject<boolean>(false);
@@ -26,9 +26,9 @@ export class AuthService {
private readonly _oAuthService: OAuthService,
) {
this._oAuthService.events?.subscribe((event) => {
if (event.type === "token_received") {
if (event.type === 'token_received') {
console.log(
"SSO Token Expiration:",
'SSO Token Expiration:',
new Date(this._oAuthService.getAccessTokenExpiration()),
);
@@ -45,15 +45,15 @@ export class AuthService {
async init() {
if (this._initialized.getValue()) {
throw new Error("AuthService is already initialized");
throw new Error('AuthService is already initialized');
}
this._authConfig = this._config.get("@core/auth");
this._authConfig = this._config.get('@core/auth');
this._authConfig.redirectUri = window.location.origin;
this._authConfig.silentRefreshRedirectUri =
window.location.origin + "/silent-refresh.html";
window.location.origin + '/silent-refresh.html';
this._authConfig.useSilentRefresh = true;
this._oAuthService.configure(this._authConfig);
@@ -63,6 +63,10 @@ export class AuthService {
await this._oAuthService.loadDiscoveryDocumentAndTryLogin();
if (!this._oAuthService.getAccessToken()) {
throw new Error('No access token. User is not authenticated.');
}
this._initialized.next(true);
}
@@ -72,7 +76,7 @@ export class AuthService {
isIdTokenValid() {
console.log(
"ID Token Expiration:",
'ID Token Expiration:',
new Date(this._oAuthService.getIdTokenExpiration()),
);
return this._oAuthService.hasValidIdToken();
@@ -80,7 +84,7 @@ export class AuthService {
isAccessTokenValid() {
console.log(
"ACCESS Token Expiration:",
'ACCESS Token Expiration:',
new Date(this._oAuthService.getAccessTokenExpiration()),
);
return this._oAuthService.hasValidAccessToken();
@@ -107,8 +111,8 @@ export class AuthService {
if (isNullOrUndefined(token)) {
return null;
}
const base64Url = token.split(".")[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const encoded = window.atob(base64);
return JSON.parse(encoded);
@@ -148,7 +152,7 @@ export class AuthService {
hasRole(role: string | string[]) {
const roles = coerceArray(role);
const userRoles = this.getClaimByKey("role");
const userRoles = this.getClaimByKey('role');
if (isNullOrUndefined(userRoles)) {
return false;
@@ -160,8 +164,8 @@ export class AuthService {
async refresh() {
try {
if (
this._authConfig.responseType.includes("code") &&
this._authConfig.scope.includes("offline_access")
this._authConfig.responseType.includes('code') &&
this._authConfig.scope.includes('offline_access')
) {
await this._oAuthService.refreshToken();
} else {

View File

@@ -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<Signal<string>>(
'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);
},
},
);

View File

@@ -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) => {

View File

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