mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
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:
@@ -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();
|
||||
|
||||
@@ -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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
return next.handle(req).pipe(
|
||||
takeUntil(this.offline$),
|
||||
catchError((error: HttpErrorResponse, caught: any) => this.handleError(error)),
|
||||
);
|
||||
}
|
||||
|
||||
handleError(error: HttpErrorResponse): Observable<any> {
|
||||
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<any>,
|
||||
next: HttpHandler,
|
||||
): Observable<HttpEvent<any>> {
|
||||
return next.handle(req).pipe(
|
||||
takeUntil(this.offline$),
|
||||
catchError((error: HttpErrorResponse, caught: any) =>
|
||||
this.handleError(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
handleError(error: HttpErrorResponse): Observable<any> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean>(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<boolean>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user