WEB-487: Add OAuth 2.1 PKCE flow (#2864)

This commit is contained in:
morvagergely
2025-12-18 12:40:25 -06:00
committed by GitHub
parent 3fe4b68ca4
commit 2295356f5f
19 changed files with 466 additions and 545 deletions
+14
View File
@@ -31,6 +31,7 @@
"@swimlane/ngx-graph": "^11.0.0", "@swimlane/ngx-graph": "^11.0.0",
"@tailwindcss/forms": "^0.5.4", "@tailwindcss/forms": "^0.5.4",
"@tinymce/tinymce-angular": "^9.1.1", "@tinymce/tinymce-angular": "^9.1.1",
"angular-oauth2-oidc": "^20.0.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"d3": "^7.9.0", "d3": "^7.9.0",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
@@ -9897,6 +9898,19 @@
"node": ">= 14.0.0" "node": ">= 14.0.0"
} }
}, },
"node_modules/angular-oauth2-oidc": {
"version": "20.0.2",
"resolved": "https://registry.npmjs.org/angular-oauth2-oidc/-/angular-oauth2-oidc-20.0.2.tgz",
"integrity": "sha512-bMSXEQIuvgq8yqnsIatZggAvCJvY+pm7G8MK0tWCHR93UpFuNN+L5B6pY9CzRg8Ys+VVhkLIBx4zEHbJnv9icg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.5.2"
},
"peerDependencies": {
"@angular/common": ">=20.0.0",
"@angular/core": ">=20.0.0"
}
},
"node_modules/ansi-colors": { "node_modules/ansi-colors": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
+1
View File
@@ -52,6 +52,7 @@
"@swimlane/ngx-graph": "^11.0.0", "@swimlane/ngx-graph": "^11.0.0",
"@tailwindcss/forms": "^0.5.4", "@tailwindcss/forms": "^0.5.4",
"@tinymce/tinymce-angular": "^9.1.1", "@tinymce/tinymce-angular": "^9.1.1",
"angular-oauth2-oidc": "^20.0.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"d3": "^7.9.0", "d3": "^7.9.0",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
+3 -1
View File
@@ -58,6 +58,7 @@ import { TokenInterceptor as ZitadelTokenInterceptor } from './zitadel/token.int
import { AuthService } from './zitadel/auth.service'; import { AuthService } from './zitadel/auth.service';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { CallbackComponent } from './zitadel/callback/callback.component'; import { CallbackComponent } from './zitadel/callback/callback.component';
import { OAuthModule } from 'angular-oauth2-oidc';
export class CustomMissingTranslationHandler implements MissingTranslationHandler { export class CustomMissingTranslationHandler implements MissingTranslationHandler {
handle(params: MissingTranslationHandlerParams): string { handle(params: MissingTranslationHandlerParams): string {
@@ -120,7 +121,8 @@ export function HttpLoaderFactory(http: HttpClient) {
ConfigurationWizardModule, ConfigurationWizardModule,
AppRoutingModule, AppRoutingModule,
NotFoundComponent, NotFoundComponent,
CallbackComponent CallbackComponent,
OAuthModule.forRoot()
], ],
providers: [ providers: [
DatePipe, DatePipe,
@@ -1,11 +1,12 @@
/** Angular Imports */ /** Angular Imports */
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http'; import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
/** rxjs Imports */ /** rxjs Imports */
import { BehaviorSubject, Observable, of } from 'rxjs'; import { BehaviorSubject, Observable, of } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
/** 3rd party Imports */
import { OAuthService } from 'angular-oauth2-oidc';
/** Custom Services */ /** Custom Services */
import { AlertService } from '../alert/alert.service'; import { AlertService } from '../alert/alert.service';
@@ -18,7 +19,7 @@ import { environment } from '../../../environments/environment';
/** Custom Models */ /** Custom Models */
import { LoginContext } from './login-context.model'; import { LoginContext } from './login-context.model';
import { Credentials } from './credentials.model'; import { Credentials } from './credentials.model';
import { OAuth2Token } from './o-auth2-token.model'; import { getOAuthConfig, getActiveAuthMode, AuthMode } from './oauth.config';
/** /**
* Authentication workflow. * Authentication workflow.
@@ -28,17 +29,24 @@ export class AuthenticationService {
private http = inject(HttpClient); private http = inject(HttpClient);
private alertService = inject(AlertService); private alertService = inject(AlertService);
private authenticationInterceptor = inject(AuthenticationInterceptor); private authenticationInterceptor = inject(AuthenticationInterceptor);
private oauthService = inject(OAuthService);
/**
* Updates the password for the specified user.
* @param {string} userId Target user identifier.
* @param {*} passwordObj Payload containing the new password fields.
* @returns Updated user response observable.
*/
changePassword(userId: string, passwordObj: any) { changePassword(userId: string, passwordObj: any) {
return this.http.put(`/users/${userId}`, passwordObj); return this.http.put(`/users/${userId}`, passwordObj);
} }
// User logged in boolean
private userLoggedIn: boolean;
private userLoggedIn$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false); private userLoggedIn$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/** Observable that emits authentication state changes. */
public readonly isAuthenticated$ = this.userLoggedIn$.asObservable(); public readonly isAuthenticated$ = this.userLoggedIn$.asObservable();
/** Denotes whether the user credentials should persist through sessions. */ /** Denotes whether the user credentials should persist through sessions. */
private rememberMe: boolean; private rememberMe = false;
/** /**
* Denotes the type of storage: * Denotes the type of storage:
* *
@@ -46,17 +54,15 @@ export class AuthenticationService {
* *
* Local Storage: User credentials should persist through sessions. * Local Storage: User credentials should persist through sessions.
*/ */
private storage: any; private storage: Storage = sessionStorage;
/** User credentials. */
private credentials: Credentials; private credentials: Credentials;
private dialogShown = false; private dialogShown = false;
private authMode: AuthMode = AuthMode.Basic;
/** Key to store credentials in storage. */ /** Key to store credentials in storage. */
private credentialsStorageKey = 'mifosXCredentials'; private readonly credentialsStorageKey = 'mifosXCredentials';
/** Key to store oauth token details in storage. */
private oAuthTokenDetailsStorageKey = 'mifosXOAuthTokenDetails';
/** Key to store two factor authentication token in storage. */ /** Key to store two factor authentication token in storage. */
private twoFactorAuthenticationTokenStorageKey = 'mifosXTwoFactorAuthenticationToken'; private readonly twoFactorAuthenticationTokenStorageKey = 'mifosXTwoFactorAuthenticationToken';
/** /**
* Initializes the type of storage and authorization headers depending on whether * Initializes the type of storage and authorization headers depending on whether
@@ -64,135 +70,173 @@ export class AuthenticationService {
* @param {HttpClient} http Http Client to send requests. * @param {HttpClient} http Http Client to send requests.
* @param {AlertService} alertService Alert Service. * @param {AlertService} alertService Alert Service.
* @param {AuthenticationInterceptor} authenticationInterceptor Authentication Interceptor. * @param {AuthenticationInterceptor} authenticationInterceptor Authentication Interceptor.
* @param {OAuthService} oauthService OAuth Service.
*/ */
constructor() { constructor() {
const authenticationInterceptor = this.authenticationInterceptor; this.authMode = getActiveAuthMode();
this.userLoggedIn = false; if (this.authMode !== AuthMode.Basic) {
this.rememberMe = false; this.initializeOAuthService();
this.storage = sessionStorage; }
const savedCredentials = JSON.parse(
sessionStorage.getItem(this.credentialsStorageKey) || localStorage.getItem(this.credentialsStorageKey) this.restoreSession();
); }
if (savedCredentials) {
if (savedCredentials.rememberMe) { /**
this.rememberMe = true; * Configures the OAuth service with runtime settings and hooks up token listeners.
this.storage = localStorage; */
private initializeOAuthService(): void {
this.oauthService.configure(getOAuthConfig());
const oauthStorage = environment.enableRememberMe ? localStorage : sessionStorage;
this.oauthService.setStorage(oauthStorage);
this.oauthService.setupAutomaticSilentRefresh();
this.oauthService.events.subscribe((event) => {
if (event.type === 'token_received' || event.type === 'token_refreshed') {
this.updateCredentialsToken();
} }
const twoFactorAccessToken = JSON.parse(this.storage.getItem(this.twoFactorAuthenticationTokenStorageKey)); });
if (environment.oauth.enabled) {
this.refreshOAuthAccessToken(); this.cleanupLegacyStorage();
} else { }
authenticationInterceptor.setAuthorizationToken(savedCredentials.base64EncodedAuthenticationKey);
/**
* Restores persisted credentials/tokens from storage and rehydrates the session state.
*/
private restoreSession(): void {
const savedCredentials = this.getSavedCredentials();
if (!savedCredentials) return;
if (savedCredentials.rememberMe) {
this.rememberMe = true;
this.storage = localStorage;
this.oauthService.setStorage(this.storage);
}
if (this.authMode !== AuthMode.Basic) {
// OAuth2/OIDC: Use angular-oauth2-oidc library for token management
if (this.oauthService.hasValidAccessToken()) {
this.authenticationInterceptor.setAuthorizationToken(this.oauthService.getAccessToken());
this.userLoggedIn$.next(true);
} else if (this.oauthService.getRefreshToken()) {
this.oauthService
.refreshToken()
.then(() => this.userLoggedIn$.next(true))
.catch(() => this.logout().subscribe());
} }
if (twoFactorAccessToken) { } else {
authenticationInterceptor.setTwoFactorAccessToken(twoFactorAccessToken.token); // Basic Auth
this.authenticationInterceptor.setAuthorizationToken(savedCredentials.base64EncodedAuthenticationKey);
const twoFactorToken = JSON.parse(this.storage.getItem(this.twoFactorAuthenticationTokenStorageKey));
if (twoFactorToken) {
this.authenticationInterceptor.setTwoFactorAccessToken(twoFactorToken.token);
} }
// Emit the correct initial state
this.userLoggedIn = true;
this.userLoggedIn$.next(true); this.userLoggedIn$.next(true);
} }
} }
/**
* Persists the latest OAuth access token in both the interceptor and stored credentials.
*/
private updateCredentialsToken(): void {
const accessToken = this.oauthService.getAccessToken();
if (!accessToken) return;
this.authenticationInterceptor.setAuthorizationToken(accessToken);
const credentials = this.getCredentials();
if (credentials) {
credentials.accessToken = accessToken;
this.storage.setItem(this.credentialsStorageKey, JSON.stringify(credentials));
}
}
/**
* Reads the cached credentials from session or local storage, if present.
* @returns {Credentials | null} Stored credentials or null when absent.
*/
private getSavedCredentials(): Credentials | null {
const stored =
sessionStorage.getItem(this.credentialsStorageKey) || localStorage.getItem(this.credentialsStorageKey);
return stored ? JSON.parse(stored) : null;
}
/** /**
* Authenticates the user. * Authenticates the user.
* @param {LoginContext} loginContext Login parameters. * @param {LoginContext} loginContext Login parameters.
* @returns {Observable<boolean>} True if authentication is successful. * @returns {Observable<boolean>} True if authentication is successful.
*/ */
login(loginContext: LoginContext) { login(loginContext?: LoginContext): Observable<boolean> {
this.alertService.alert({ type: 'Authentication Start', message: 'Please wait...' }); this.alertService.alert({ type: 'Authentication Start', message: 'Please wait...' });
// Only allow Remember Me if enabled in config
const rememberAllowed = environment.enableRememberMe === true; if (this.authMode !== AuthMode.Basic) {
this.rememberMe = rememberAllowed ? loginContext.remember : false; // OAuth2/OIDC: Redirect to authorization server with PKCE
this.oauthService.initCodeFlow();
return of(true);
}
if (!loginContext) {
throw new Error('loginContext is required when using Basic authentication');
}
this.rememberMe = environment.enableRememberMe ? (loginContext?.remember ?? false) : false;
this.storage = this.rememberMe ? localStorage : sessionStorage; this.storage = this.rememberMe ? localStorage : sessionStorage;
if (environment.oauth.enabled) { // Basic Auth: Direct authentication with Fineract
let httpParams = new HttpParams(); return this.http
httpParams = httpParams.set('username', loginContext.username); .post('/authentication', {
httpParams = httpParams.set('password', loginContext.password); username: loginContext.username,
httpParams = httpParams.set('client_id', `${environment.oauth.appId}`); password: loginContext.password,
httpParams = httpParams.set('grant_type', 'password'); remember: this.rememberMe
httpParams = httpParams.set('remember_me', this.rememberMe ? 'true' : 'false'); })
let headers = new HttpHeaders(); .pipe(
headers = headers.set('Content-Type', 'application/x-www-form-urlencoded'); map((credentials: Credentials) => {
return this.http.post(`${environment.oauth.serverUrl}/token`, httpParams.toString(), { headers: headers }).pipe( this.onLoginSuccess(credentials);
map((tokenResponse: OAuth2Token) => { return true;
this.getUserDetails(tokenResponse);
return of(true);
}) })
); );
} else { }
return this.http
.post('/authentication', { /**
username: loginContext.username, * Fetches user details from the server.
password: loginContext.password, * @returns {Promise<void>} Promise that resolves when user details are fetched.
remember: this.rememberMe */
}) private async getUserDetails(): Promise<void> {
.pipe( const accessToken = this.oauthService.getAccessToken();
map((credentials: Credentials) => {
return new Promise((resolve, reject) => {
if (this.authMode === AuthMode.OIDC) {
const url = `${environment.OIDC.oidcApiUrl}authentication/userdetails`;
this.http.post<{ object: Credentials }>(url, { token: accessToken }).subscribe({
next: (response) => {
const credentials = response.object;
credentials.accessToken = accessToken;
this.onLoginSuccess(credentials); this.onLoginSuccess(credentials);
return of(true); resolve();
}) },
); error: (error) => {
} console.error('Failed to fetch user details:', error);
} reject(error);
}
/** });
* Retrieves the user details after oauth2 authentication. } else if (this.authMode === AuthMode.OAuth2) {
* const headers = new HttpHeaders().set('Authorization', `Bearer ${accessToken}`);
* Sets the oauth2 token refresh time. const url = `${environment.oauth.serverUrl}/userdetails`;
* @param {OAuth2Token} tokenResponse OAuth2 Token details. this.http.get<Credentials>(url, { headers }).subscribe({
*/ next: (credentials) => {
private getUserDetails(tokenResponse: OAuth2Token) { credentials.accessToken = accessToken;
this.refreshTokenOnExpiry(tokenResponse.expires_in); this.onLoginSuccess(credentials);
let headers = new HttpHeaders(); resolve();
headers = headers.set('Authorization', 'bearer ' + tokenResponse.access_token); },
this.http error: (error) => {
.get(`${environment.serverUrl}/userdetails`, { headers: headers }) console.error('Failed to fetch user details:', error);
.subscribe((credentials: Credentials) => { reject(error);
this.onLoginSuccess(credentials); }
if (!credentials.shouldRenewPassword) { });
this.storage.setItem(this.oAuthTokenDetailsStorageKey, JSON.stringify(tokenResponse)); }
} });
});
}
/**
* Sets the oauth2 token to refresh on expiry.
* @param {number} expiresInTime OAuth2 token expiry time in seconds.
*/
private refreshTokenOnExpiry(expiresInTime: number) {
setTimeout(() => this.refreshOAuthAccessToken(), expiresInTime * 1000);
}
/**
* Refreshes the oauth2 authorization token.
*/
private refreshOAuthAccessToken() {
var oAuthRefreshToken = JSON.parse(this.storage.getItem(this.oAuthTokenDetailsStorageKey));
if (oAuthRefreshToken == null) {
return;
}
oAuthRefreshToken = JSON.parse(this.storage.getItem(this.oAuthTokenDetailsStorageKey)).refresh_token;
this.authenticationInterceptor.removeAuthorization();
const credentials = JSON.parse(this.storage.getItem(this.credentialsStorageKey));
let httpParams = new HttpParams();
httpParams = httpParams.set('username', credentials.username);
httpParams = httpParams.set('client_id', `${environment.oauth.appId}`);
httpParams = httpParams.set('refresh_token', oAuthRefreshToken);
httpParams = httpParams.set('grant_type', 'refresh_token');
let headers = new HttpHeaders();
headers = headers.set('Content-Type', 'application/x-www-form-urlencoded');
return this.http
.post(`${environment.oauth.serverUrl}/token`, httpParams.toString(), { headers: headers })
.subscribe((tokenResponse: OAuth2Token) => {
this.storage.setItem(this.oAuthTokenDetailsStorageKey, JSON.stringify(tokenResponse));
this.authenticationInterceptor.setAuthorizationToken(tokenResponse.access_token);
this.refreshTokenOnExpiry(tokenResponse.expires_in);
const credentials = JSON.parse(this.storage.getItem(this.credentialsStorageKey));
credentials.accessToken = tokenResponse.access_token;
this.storage.setItem(this.credentialsStorageKey, JSON.stringify(credentials));
});
} }
/** /**
@@ -205,13 +249,12 @@ export class AuthenticationService {
* Sends an alert on successful login. * Sends an alert on successful login.
* @param {Credentials} credentials Authenticated user credentials. * @param {Credentials} credentials Authenticated user credentials.
*/ */
private onLoginSuccess(credentials: Credentials) { private onLoginSuccess(credentials: Credentials): void {
this.userLoggedIn = true;
this.userLoggedIn$.next(true); // ✅ notify observers this.userLoggedIn$.next(true); // ✅ notify observers
// Ensure the rememberMe value is preserved in credentials // Ensure the rememberMe value is preserved in credentials
credentials.rememberMe = this.rememberMe; credentials.rememberMe = this.rememberMe;
if (environment.oauth.enabled) { if (this.authMode !== AuthMode.Basic) {
this.authenticationInterceptor.setAuthorizationToken(credentials.accessToken); this.authenticationInterceptor.setAuthorizationToken(credentials.accessToken);
} else { } else {
this.authenticationInterceptor.setAuthorizationToken(credentials.base64EncodedAuthenticationKey); this.authenticationInterceptor.setAuthorizationToken(credentials.base64EncodedAuthenticationKey);
@@ -241,21 +284,31 @@ export class AuthenticationService {
} }
/** /**
* Logout ongoing Oauth2 session. * Handles the OAuth callback.
* @returns {Promise<boolean>} True if the OAuth callback was successful.
*/ */
private logoutAuthSession() { async handleOAuthCallback(): Promise<boolean> {
const oAuthRefreshToken = JSON.parse(this.storage.getItem(this.oAuthTokenDetailsStorageKey)).refresh_token; try {
const credentials = JSON.parse(this.storage.getItem(this.credentialsStorageKey)); // index.html preserves the OAuth callback query string in sessionStorage before redirecting to /#/callback, since Angular routing consumes query params before the OAuth library can process them.
this.authenticationInterceptor.removeAuthorizationTenant(); let queryString = sessionStorage.getItem('oauth_callback_query');
let httpParams = new HttpParams();
httpParams = httpParams.set('username', credentials.username); if (queryString) {
httpParams = httpParams.set('client_id', `${environment.oauth.appId}`); sessionStorage.removeItem('oauth_callback_query');
httpParams = httpParams.set('refresh_token', oAuthRefreshToken); await this.oauthService.tryLoginCodeFlow({ customHashFragment: queryString });
let headers = new HttpHeaders(); } else {
headers = headers.set('Content-Type', 'application/x-www-form-urlencoded'); await this.oauthService.tryLoginCodeFlow();
return this.http }
.post(`${environment.oauth.serverUrl}/logout`, httpParams.toString(), { headers: headers })
.subscribe(); if (this.oauthService.hasValidAccessToken()) {
await this.getUserDetails();
return true;
}
return false;
} catch (error) {
console.error('OAuth callback failed:', error);
return false;
}
} }
/** /**
@@ -268,15 +321,31 @@ export class AuthenticationService {
this.http.post('/twofactor/invalidate', { token: twoFactorToken.token }).subscribe(); this.http.post('/twofactor/invalidate', { token: twoFactorToken.token }).subscribe();
this.authenticationInterceptor.removeTwoFactorAuthorization(); this.authenticationInterceptor.removeTwoFactorAuthorization();
} }
const oAuthRefreshToken = JSON.parse(this.storage.getItem(this.oAuthTokenDetailsStorageKey));
if (oAuthRefreshToken) { // Clear any pending OAuth callback data
this.logoutAuthSession(); sessionStorage.removeItem('oauth_callback_query');
}
this.authenticationInterceptor.removeAuthorization(); this.authenticationInterceptor.removeAuthorization();
this.setCredentials(); this.setCredentials();
this.resetDialog(); this.resetDialog();
this.userLoggedIn = false; this.userLoggedIn$.next(false);
this.userLoggedIn$.next(false); // ✅ notify observers
if (this.authMode === AuthMode.OIDC) {
// OIDC: Use library to handle logout (redirects to OIDC provider)
this.oauthService.logOut();
} else if (this.authMode === AuthMode.OAuth2) {
// OAuth2 (Fineract): Clear library tokens and server session
this.oauthService.logOut(true); // true = don't redirect
// Call Fineract logout endpoint in a popup to clear server session (includes JSESSIONID cookie)
// Then close the popup and navigate to login page
const logoutWindow = window.open(environment.oauth.logoutUrl, '_blank', 'width=100,height=100');
setTimeout(() => {
if (logoutWindow) {
logoutWindow.close();
}
window.location.href = `${window.location.origin}/#/login`;
}, 500);
}
return of(true); return of(true);
} }
@@ -297,11 +366,10 @@ export class AuthenticationService {
* @returns {boolean} True if the user is authenticated. * @returns {boolean} True if the user is authenticated.
*/ */
isAuthenticated(): boolean { isAuthenticated(): boolean {
return !!( if (this.authMode !== AuthMode.Basic) {
JSON.parse( return this.oauthService.hasValidAccessToken();
sessionStorage.getItem(this.credentialsStorageKey) || localStorage.getItem(this.credentialsStorageKey) }
) && this.twoFactorAccessTokenIsValid() return !!(this.getSavedCredentials() && this.twoFactorAccessTokenIsValid());
);
} }
/** /**
@@ -320,29 +388,40 @@ export class AuthenticationService {
* *
* @param {Credentials} credentials Authenticated user credentials. * @param {Credentials} credentials Authenticated user credentials.
*/ */
private setCredentials(credentials?: Credentials) { private setCredentials(credentials?: Credentials): void {
if (credentials) { if (credentials) {
credentials.rememberMe = this.rememberMe; credentials.rememberMe = this.rememberMe;
// Make sure we're using the correct storage based on rememberMe value // Make sure we're using the correct storage based on rememberMe value
this.storage = credentials.rememberMe ? localStorage : sessionStorage; this.storage = credentials.rememberMe ? localStorage : sessionStorage;
this.oauthService.setStorage(this.storage);
this.storage.setItem(this.credentialsStorageKey, JSON.stringify(credentials)); this.storage.setItem(this.credentialsStorageKey, JSON.stringify(credentials));
} else { } else {
// Clear credentials from both storage types to ensure complete logout // Clear credentials from both storage types to ensure complete logout
localStorage.removeItem(this.credentialsStorageKey); [
sessionStorage.removeItem(this.credentialsStorageKey); localStorage,
localStorage.removeItem(this.oAuthTokenDetailsStorageKey); sessionStorage
sessionStorage.removeItem(this.oAuthTokenDetailsStorageKey); ].forEach((store) => {
localStorage.removeItem(this.twoFactorAuthenticationTokenStorageKey); store.removeItem(this.credentialsStorageKey);
sessionStorage.removeItem(this.twoFactorAuthenticationTokenStorageKey); store.removeItem(this.twoFactorAuthenticationTokenStorageKey);
});
this.cleanupLegacyStorage();
} }
} }
public saveZitadelCredentials(credentials: Credentials): void { private cleanupLegacyStorage(): void {
this.setCredentials(credentials); const legacyKeys = [
} 'mifosXZitadelTokenDetails',
'mifosXOAuthTokenDetails',
public saveZitadeloAuthTokenDetailsStorageKey(tokenResponse: OAuth2Token): void { 'token_start_time',
this.storage.setItem(this.oAuthTokenDetailsStorageKey, JSON.stringify(tokenResponse)); 'refresh_expires_in',
'mifosXZitadel',
'auth_code'
// Note: Do NOT remove 'PKCE_verifier' here - it's needed by angular-oauth2-oidc for the callback
];
legacyKeys.forEach((key) => {
localStorage.removeItem(key);
sessionStorage.removeItem(key);
});
} }
/** /**
@@ -401,7 +480,7 @@ export class AuthenticationService {
* Sends an alert on successful login. * Sends an alert on successful login.
* @param {any} response Two factor authentication token details. * @param {any} response Two factor authentication token details.
*/ */
private onOTPValidateSuccess(response: any) { private onOTPValidateSuccess(response: any): void {
this.authenticationInterceptor.setTwoFactorAccessToken(response.token); this.authenticationInterceptor.setTwoFactorAccessToken(response.token);
if (this.credentials.shouldRenewPassword) { if (this.credentials.shouldRenewPassword) {
this.alertService.alert({ this.alertService.alert({
@@ -443,6 +522,6 @@ export class AuthenticationService {
* Get user logged in * Get user logged in
*/ */
getUserLoggedIn(): boolean { getUserLoggedIn(): boolean {
return this.userLoggedIn; return this.userLoggedIn$.value;
} }
} }
@@ -1,10 +1,12 @@
/** /**
* OAuth2 token model. * OAuth2/OIDC token response model.
*/ */
export interface OAuth2Token { export interface OAuth2Token {
access_token: string; access_token: string;
token_type: string; token_type: string;
refresh_token: string; refresh_token?: string;
expires_in: number; expires_in: number;
scope: string; scope?: string;
// ID token (only present in OIDC responses)
id_token?: string;
} }
@@ -0,0 +1,90 @@
import { AuthConfig } from 'angular-oauth2-oidc';
import { environment } from '../../../environments/environment';
export enum AuthMode {
OAuth2 = 'oauth2',
OIDC = 'oidc',
Basic = 'basic'
}
/**
* Determines which authentication mode should be enabled based on runtime configuration.
* @returns {AuthMode} The active authentication mode.
*/
export function getActiveAuthMode(): AuthMode {
if (environment.OIDC.oidcServerEnabled) {
return AuthMode.OIDC;
} else if (environment.oauth.enabled) {
return AuthMode.OAuth2;
}
return AuthMode.Basic;
}
/**
* Builds the Angular OAuth service configuration for the active authentication mode.
* @returns {AuthConfig} OAuth/OIDC configuration consumed by angular-oauth2-oidc.
*/
export function getOAuthConfig(): AuthConfig {
const authMode = getActiveAuthMode();
switch (authMode) {
case AuthMode.OIDC:
return getOIDCConfig();
case AuthMode.OAuth2:
return getOAuth2Config();
default:
return { clientId: '' };
}
}
/**
* Creates the configuration required for OIDC-compliant providers.
* @returns {AuthConfig} OIDC configuration block.
*/
function getOIDCConfig(): AuthConfig {
const frontUrl = environment.OIDC.oidcFrontUrl || window.location.origin;
return {
issuer: environment.OIDC.oidcBaseUrl,
clientId: environment.OIDC.oidcClientId,
redirectUri: `${frontUrl}/callback`,
postLogoutRedirectUri: `${frontUrl}/#/login`,
responseType: 'code',
scope: 'openid profile email offline_access',
oidc: true,
useSilentRefresh: false,
requireHttps: environment.production,
showDebugInformation: !environment.production,
sessionChecksEnabled: false,
clearHashAfterLogin: false
};
}
/**
* Creates the configuration required for classic OAuth2 providers (e.g., Fineract).
* @returns {AuthConfig} OAuth2 configuration block.
*/
function getOAuth2Config(): AuthConfig {
const frontendUrl = window.location.origin;
return {
issuer: environment.oauth.serverUrl,
loginUrl: environment.oauth.authorizeUrl,
tokenEndpoint: environment.oauth.tokenUrl,
redirectUri: environment.oauth.redirectUri,
postLogoutRedirectUri: `${frontendUrl}/#/login`,
clientId: environment.oauth.appId,
responseType: 'code',
scope: environment.oauth.scope,
useSilentRefresh: false,
oidc: false,
// Skip issuer validation for OAuth2 (non-OIDC) flows
skipIssuerCheck: true,
// Disable strict discovery validation for custom OAuth2 endpoints
strictDiscoveryDocumentValidation: false,
requireHttps: environment.production,
showDebugInformation: !environment.production,
sessionChecksEnabled: false,
clearHashAfterLogin: false
};
}
@@ -31,6 +31,7 @@ export class ApiPrefixInterceptor implements HttpInterceptor {
/** /**
* Ignore URLs that are complete for i18n * Ignore URLs that are complete for i18n
* **__NOTE__** OAuth2 endpoints are handled by angular-oauth2-oidc library
*/ */
if (!request.url.includes('http:') && !request.url.includes('https:')) { if (!request.url.includes('http:') && !request.url.includes('https:')) {
request = request.clone({ url: baseUrl + request.url }); request = request.clone({ url: baseUrl + request.url });
@@ -24,9 +24,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatLine } from '@angular/material/grid-list'; import { MatLine } from '@angular/material/grid-list';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Environment Configuration and Zitadel*/ import { catchError, finalize, of, take } from 'rxjs';
import { environment } from '../../../../environments/environment';
import { AuthService } from 'app/zitadel/auth.service';
/** /**
* Sidenav component. * Sidenav component.
@@ -56,7 +54,6 @@ export class SidenavComponent implements OnInit, AfterViewInit {
private settingsService = inject(SettingsService); private settingsService = inject(SettingsService);
private configurationWizardService = inject(ConfigurationWizardService); private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService); private popoverService = inject(PopoverService);
private authService = inject(AuthService);
/** True if sidenav is in collapsed state. */ /** True if sidenav is in collapsed state. */
@Input() sidenavCollapsed: boolean; @Input() sidenavCollapsed: boolean;
@@ -103,13 +100,17 @@ export class SidenavComponent implements OnInit, AfterViewInit {
/** /**
* Logs out the authenticated user and redirects to login page. * Logs out the authenticated user and redirects to login page.
* Uses unified AuthenticationService which handles both OAuth2 and OIDC logout.
*/ */
logout() { logout() {
if (!environment.OIDC.oidcServerEnabled) { this.authenticationService
this.authenticationService.logout().subscribe(() => this.router.navigate(['/login'], { replaceUrl: true })); .logout()
} else { .pipe(
this.authService.logout(); take(1),
} catchError(() => of(void 0)),
finalize(() => this.router.navigate(['/login'], { replaceUrl: true }))
)
.subscribe();
} }
/** /**
+11 -10
View File
@@ -19,8 +19,8 @@ import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
/** rxjs Imports */ /** rxjs Imports */
import { Observable } from 'rxjs'; import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators'; import { catchError, finalize, map, take } from 'rxjs/operators';
/** Custom Services */ /** Custom Services */
import { AuthenticationService } from '../../authentication/authentication.service'; import { AuthenticationService } from '../../authentication/authentication.service';
@@ -42,9 +42,7 @@ import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../.
import { ThemeToggleComponent } from '../../../shared/theme-toggle/theme-toggle.component'; import { ThemeToggleComponent } from '../../../shared/theme-toggle/theme-toggle.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Environment Configuration and Zitadel*/
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { AuthService } from 'app/zitadel/auth.service';
/** /**
* Toolbar component. * Toolbar component.
@@ -77,7 +75,6 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
private configurationWizardService = inject(ConfigurationWizardService); private configurationWizardService = inject(ConfigurationWizardService);
private dialog = inject(MatDialog); private dialog = inject(MatDialog);
private changeDetector = inject(ChangeDetectorRef); private changeDetector = inject(ChangeDetectorRef);
private authService = inject(AuthService);
/* Reference of institution */ /* Reference of institution */
@ViewChild('institution') institution: ElementRef<any>; @ViewChild('institution') institution: ElementRef<any>;
@@ -134,13 +131,17 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
/** /**
* Logs out the authenticated user and redirects to login page. * Logs out the authenticated user and redirects to login page.
* Uses unified AuthenticationService which handles both OAuth2 and OIDC logout.
*/ */
logout() { logout() {
if (!environment.OIDC.oidcServerEnabled) { this.authenticationService
this.authenticationService.logout().subscribe(() => this.router.navigate(['/login'], { replaceUrl: true })); .logout()
} else { .pipe(
this.authService.logout(); take(1),
} catchError(() => of(void 0)),
finalize(() => this.router.navigate(['/login'], { replaceUrl: true }))
)
.subscribe();
} }
/** /**
@@ -1,4 +1,5 @@
@if (!oidcServerEnabled) { <!-- Basic Auth: Username/password form -->
@if (!oauthEnabled) {
<div> <div>
<form class="layout-column" [formGroup]="loginForm" (ngSubmit)="login()" id="login-form"> <form class="layout-column" [formGroup]="loginForm" (ngSubmit)="login()" id="login-form">
<mat-form-field class="login-input flex-align-center"> <mat-form-field class="login-input flex-align-center">
@@ -62,25 +63,20 @@
</div> </div>
} }
@if (oidcServerEnabled) { <!-- OAuth/OIDC: Simple login button (no credentials needed) -->
<div class="container container-margin"> @if (oauthEnabled) {
@if (!isLoggedIn) { <div class="layout-column">
<div class="layout-column align-center-center"> <button
<button mat-raised-button color="primary" class="login-button-first flex-align-center" (click)="loginOIDC()"> mat-raised-button
{{ 'labels.buttons.Login' | translate }} color="primary"
@if (loading) { class="login-button-first flex-align-center"
<mat-spinner [diameter]="20"></mat-spinner> (click)="loginOAuth()"
} [disabled]="loading"
</button> >
</div> {{ 'labels.buttons.Login' | translate }}
} @if (loading) {
@if (isLoggedIn) { <mat-spinner [diameter]="20"></mat-spinner>
<div> }
<p>Welcome! You are logged in.</p> </button>
<button (click)="logout()">Logout</button>
<button (click)="loadProtectedData()">Load Protected Data</button>
<pre>{{ protectedData | json }}</pre>
</div>
}
</div> </div>
} }
@@ -1,22 +1,18 @@
/** Angular Imports */ /** Angular Imports */
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { FormGroup, FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { FormGroup, FormBuilder, Validators } from '@angular/forms';
/** rxjs Imports */ /** rxjs Imports */
import { finalize } from 'rxjs/operators'; import { finalize } from 'rxjs/operators';
/** Custom Services */ /** Custom Services */
import { AuthenticationService } from '../../core/authentication/authentication.service'; import { AuthenticationService } from '../../core/authentication/authentication.service';
import { MatFormField, MatPrefix, MatLabel, MatError, MatSuffix } from '@angular/material/form-field'; import { MatPrefix } from '@angular/material/form-field';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatIconButton, MatButton } from '@angular/material/button';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatProgressBar } from '@angular/material/progress-bar'; import { MatProgressBar } from '@angular/material/progress-bar';
import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Custom Service Zitadel */
import { AuthService } from '../../zitadel/auth.service';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
/** /**
@@ -30,7 +26,6 @@ import { environment } from '../../../environments/environment';
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatPrefix, MatPrefix,
FaIconComponent, FaIconComponent,
MatIconButton,
MatProgressBar, MatProgressBar,
MatProgressSpinner MatProgressSpinner
] ]
@@ -38,7 +33,6 @@ import { environment } from '../../../environments/environment';
export class LoginFormComponent implements OnInit { export class LoginFormComponent implements OnInit {
private formBuilder = inject(FormBuilder); private formBuilder = inject(FormBuilder);
private authenticationService = inject(AuthenticationService); private authenticationService = inject(AuthenticationService);
private authService = inject(AuthService);
/** Login form group. */ /** Login form group. */
loginForm: FormGroup; loginForm: FormGroup;
@@ -46,7 +40,8 @@ export class LoginFormComponent implements OnInit {
passwordInputType: string = 'password'; passwordInputType: string = 'password';
/** True if loading. */ /** True if loading. */
loading = false; loading = false;
oidcServerEnabled = environment.OIDC.oidcServerEnabled; /** Whether OAuth (OIDC or OAuth2) is enabled */
oauthEnabled = environment.OIDC.oidcServerEnabled || environment.oauth.enabled;
/** Whether remember me functionality is enabled */ /** Whether remember me functionality is enabled */
enableRememberMe = environment.enableRememberMe === true; enableRememberMe = environment.enableRememberMe === true;
@@ -80,19 +75,23 @@ export class LoginFormComponent implements OnInit {
} }
/** /**
* Authenticates the user if the credentials are valid to Zitadel. * Initiates OAuth/OIDC login flow.
* The unified AuthenticationService handles both Fineract OAuth2 and OIDC providers.
*/ */
loginOAuth() {
loginOIDC() { this.loading = true;
this.authService.login(); this.authenticationService
} .login()
.pipe(
getUsers() { finalize(() => {
this.authService.getUsers(); this.loading = false;
} })
)
logout() { .subscribe({
this.authService.logout(); error: () => {
// Error handling is managed by the authentication service
}
});
} }
/** /**
+4
View File
@@ -102,6 +102,10 @@ export class LoginComponent implements OnInit, OnDestroy {
} }
displayTenantSelector(): boolean { displayTenantSelector(): boolean {
// Hide tenant selector when OAuth2 is enabled (tenant is determined by OAuth server)
if (environment.oauth.enabled) {
return false;
}
return environment.displayTenantSelector === 'false' ? false : true; return environment.displayTenantSelector === 'false' ? false : true;
} }
+2 -13
View File
@@ -33,8 +33,6 @@ import { animate, style, transition, trigger } from '@angular/animations';
import { I18nService } from './core/i18n/i18n.service'; import { I18nService } from './core/i18n/i18n.service';
import { ThemingService } from './shared/theme-toggle/theming.service'; import { ThemingService } from './shared/theme-toggle/theming.service';
import { AuthService } from './zitadel/auth.service';
/** Initialize Logger */ /** Initialize Logger */
const log = new Logger('MifosX'); const log = new Logger('MifosX');
@@ -123,8 +121,7 @@ export class WebAppComponent implements OnInit, OnDestroy {
private themingService: ThemingService, private themingService: ThemingService,
private dateUtils: Dates, private dateUtils: Dates,
private idle: IdleTimeoutService, private idle: IdleTimeoutService,
private dialog: MatDialog, private dialog: MatDialog
private authService: AuthService
) {} ) {}
@HostBinding('class') public cssClass: string; @HostBinding('class') public cssClass: string;
@@ -141,10 +138,6 @@ export class WebAppComponent implements OnInit, OnDestroy {
* 4) Alerts * 4) Alerts
*/ */
login() {
this.authService.login();
}
ngOnInit() { ngOnInit() {
this.themingService.theme.subscribe((value: string) => { this.themingService.theme.subscribe((value: string) => {
this.cssClass = value; this.cssClass = value;
@@ -252,11 +245,7 @@ export class WebAppComponent implements OnInit, OnDestroy {
}); });
this.dialog.open(SessionTimeoutDialogComponent); this.dialog.open(SessionTimeoutDialogComponent);
setTimeout(() => { setTimeout(() => {
if (!environment.OIDC.oidcServerEnabled) { this.logout();
this.logout();
} else {
this.authService.logout();
}
}, 1000); }, 1000);
}); });
} }
+6 -278
View File
@@ -1,190 +1,19 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http'; import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { OAuthService } from 'angular-oauth2-oidc';
import { AlertService } from '../../app/core/alert/alert.service';
import { TranslateService } from '@ngx-translate/core';
/** Custom Services */
import { AuthenticationService } from '../core/authentication/authentication.service';
import { Credentials } from '../core/authentication/credentials.model';
import { OAuth2Token } from '../core/authentication/o-auth2-token.model';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthService { export class AuthService {
private authenticationService = inject(AuthenticationService);
private http = inject(HttpClient); private http = inject(HttpClient);
private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private alertService = inject(AlertService); private oauthService = inject(OAuthService);
private translateService = inject(TranslateService);
private baseUrl = environment.OIDC.oidcBaseUrl;
private authUrl = `${this.baseUrl}oauth/v2/authorize`;
private tokenUrl = `${this.baseUrl}oauth/v2/token`;
private clientId = environment.OIDC.oidcClientId;
private api = environment.OIDC.oidcApiUrl; private api = environment.OIDC.oidcApiUrl;
private frontUrl = environment.OIDC.oidcFrontUrl;
private redirectUri = `${this.frontUrl}#/callback`;
private refreshTimeoutId: any = null;
async login() {
const codeVerifier = this.generateRandomString();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
sessionStorage.setItem('code_verifier', codeVerifier);
const url =
`${this.authUrl}` +
`?client_id=${encodeURIComponent(this.clientId)}` +
`&redirect_uri=${encodeURIComponent(this.redirectUri)}` +
`&response_type=code` +
`&scope=openid profile email offline_access` +
`&code_challenge=${codeChallenge}` +
`&code_challenge_method=S256`;
window.location.href = url;
}
logout() {
const idToken = localStorage.getItem('id_token');
const postLogoutRedirectUri = this.frontUrl + '#/login';
if (this.refreshTimeoutId) {
clearTimeout(this.refreshTimeoutId);
this.refreshTimeoutId = null;
}
if (!idToken) {
window.location.href = postLogoutRedirectUri;
return;
}
sessionStorage.removeItem('mifosXCredentials');
sessionStorage.removeItem('mifosXZitadelTokenDetails');
localStorage.removeItem('access_token');
localStorage.removeItem('expires_in');
localStorage.removeItem('id_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('refresh_expires_in');
localStorage.removeItem('token_start_time');
localStorage.removeItem('code_verifier');
localStorage.removeItem('mifosXZitadel');
localStorage.removeItem('auth_code');
const logoutUrl = `${this.baseUrl}/oidc/v1/end_session?id_token_hint=${idToken}&post_logout_redirect_uri=${encodeURIComponent(postLogoutRedirectUri)}`;
window.location.href = logoutUrl;
}
getAccessToken(): string | null { getAccessToken(): string | null {
const rawToken = sessionStorage.getItem('mifosXZitadelTokenDetails'); return this.oauthService.getAccessToken();
if (rawToken) {
const parsedToken: OAuth2Token = JSON.parse(rawToken);
return parsedToken.access_token;
}
return null;
}
generateRandomString(length = 128) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
let result = '';
for (let i = 0; i < length; i++) {
result += charset.charAt(Math.floor(Math.random() * charset.length));
}
return result;
}
async generateCodeChallenge(verifier: string): Promise<string> {
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const base64 = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return base64;
}
async exchangeCodeForTokens(code: string, codeVerifier: string | null): Promise<void> {
const payload = {
code: code,
code_verifier: codeVerifier || ''
};
try {
const response = await fetch(this.api + 'authentication/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Error exchanging code: ${response.status} ${response.statusText}`);
}
const tokens: {
access_token: string;
id_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
} = await response.json();
const token: OAuth2Token = {
access_token: tokens.access_token,
token_type: tokens.token_type,
refresh_token: tokens.refresh_token,
expires_in: tokens.expires_in,
scope: 'Bearer'
};
sessionStorage.setItem('mifosXZitadelTokenDetails', JSON.stringify(token));
localStorage.setItem('id_token', tokens.id_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
localStorage.setItem('mifosXZitadel', 'true');
this.scheduleRefresh(tokens.expires_in);
await this.userdetails();
} catch (error) {
window.location.href = '/#/login';
throw error;
}
}
userdetails() {
const accessToken = this.getAccessToken();
if (!accessToken) {
console.error('No access token found');
return;
}
fetch(this.api + 'authentication/userdetails', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ token: accessToken })
})
.then((res) => {
if (!res.ok) {
this.alertService.alert({
type: 'User Details',
message: this.translateService.instant('errors.Username or password incorrect.')
});
this.sesionEnd();
return false;
}
return res.json();
})
.then((userInfo) => {
const user = userInfo.object;
const credentials: Credentials = user;
this.authenticationService.saveZitadelCredentials(credentials);
window.location.href = '/#/home';
})
.catch((error) => {
this.alertService.alert({ type: 'User Details', message: error });
});
} }
public deletUser(userId: string) { public deletUser(userId: string) {
@@ -315,112 +144,11 @@ export class AuthService {
}); });
} }
refreshToken(): Promise<void> { async refreshToken(): Promise<void> {
return new Promise((resolve, reject) => {
const rt = localStorage.getItem('refresh_token');
if (!rt) {
//this.logout();
return reject('Sin refresh_token');
}
const payload = new URLSearchParams();
payload.set('grant_type', 'refresh_token');
payload.set('refresh_token', rt);
payload.set('client_id', this.clientId);
fetch(this.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: payload.toString()
})
.then((res) => {
if (!res.ok) {
return res.text().then((text) => {
throw new Error(text);
});
}
return res.json();
})
.then((tokens) => {
if (!tokens || !tokens.access_token || !tokens.expires_in) {
throw new Error("The server's response does not contain the expected fields.");
}
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('id_token', tokens.id_token ?? '');
localStorage.setItem('refresh_token', tokens.refresh_token ?? '');
localStorage.setItem('expires_in', tokens.expires_in.toString());
localStorage.setItem('refresh_expires_in', tokens.refresh_expires_in?.toString() ?? '');
localStorage.setItem('token_start_time', Date.now().toString());
this.scheduleRefresh(tokens.expires_in);
resolve();
})
.catch((err) => {
setTimeout(() => {
//this.logout();
}, 300000);
reject(err);
});
});
}
private scheduleRefresh(expiresIn: number) {
const refreshInMs = (expiresIn - 3539) * 1000;
if (refreshInMs <= 0) {
this.refreshToken();
return;
}
if (this.refreshTimeoutId) {
clearTimeout(this.refreshTimeoutId);
}
this.refreshTimeoutId = setTimeout(() => {
this.refreshToken();
}, refreshInMs);
}
async sesionEnd() {
const idToken = localStorage.getItem('id_token');
const postLogoutRedirectUri = this.frontUrl + '#/login';
if (this.refreshTimeoutId) {
clearTimeout(this.refreshTimeoutId);
this.refreshTimeoutId = null;
}
if (!idToken) {
window.location.href = postLogoutRedirectUri;
return;
}
const logoutUrl = `${this.baseUrl}oidc/v1/end_session?id_token_hint=${idToken}&post_logout_redirect_uri=${encodeURIComponent(postLogoutRedirectUri)}`;
try { try {
const response = await fetch(logoutUrl, { await this.oauthService.refreshToken();
method: 'GET',
credentials: 'include'
});
if (!response.ok) {
console.error('Error en logout:', response.status, response.statusText);
}
} catch (error) { } catch (error) {
console.error('Error de red en logout:', error); throw error;
} finally {
sessionStorage.removeItem('mifosXCredentials');
sessionStorage.removeItem('mifosXZitadelTokenDetails');
localStorage.removeItem('access_token');
localStorage.removeItem('expires_in');
localStorage.removeItem('id_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('refresh_expires_in');
localStorage.removeItem('token_start_time');
localStorage.removeItem('code_verifier');
localStorage.removeItem('mifosXZitadel');
localStorage.removeItem('auth_code');
} }
} }
} }
+20 -16
View File
@@ -1,32 +1,36 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../auth.service'; import { AuthenticationService } from '../../core/authentication/authentication.service';
import { AlertService } from '../../core/alert/alert.service';
@Component({ @Component({
selector: 'mifosx-callback', selector: 'mifosx-callback',
templateUrl: './callback.component.html' templateUrl: './callback.component.html'
}) })
export class CallbackComponent implements OnInit { export class CallbackComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private authService = inject(AuthService); private alertService = inject(AlertService);
private authenticationService = inject(AuthenticationService);
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
try { try {
const code = sessionStorage.getItem('auth_code'); const success = await this.authenticationService.handleOAuthCallback();
if (code) {
const codeVerifier = sessionStorage.getItem('code_verifier'); if (success) {
try { this.router.navigate(['/home']);
await this.authService.exchangeCodeForTokens(code, codeVerifier); } else {
} finally { this.alertService.alert({
// Clean up sensitive data immediately after use type: 'Authentication Failed',
sessionStorage.removeItem('auth_code'); message: 'Unable to complete authentication. Please try again.'
sessionStorage.removeItem('code_verifier'); });
} this.router.navigate(['/login']);
} }
} catch (error) { } catch (error) {
console.error('Authentication failed:', error); console.error('Authentication callback failed:', error);
// Navigate to login page this.alertService.alert({
type: 'Authentication Error',
message: 'An error occurred during authentication. Please try again.'
});
this.router.navigate(['/login']); this.router.navigate(['/login']);
} }
} }
+2 -2
View File
@@ -2,8 +2,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '251204', 'version': '251211',
'hash': '925391ff' 'hash': 'g667887c5f'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };
+9 -4
View File
@@ -26,9 +26,14 @@ export const environment = {
apiActuator: loadedEnv.apiActuator || '/fineract-provider', apiActuator: loadedEnv.apiActuator || '/fineract-provider',
serverUrl: '', serverUrl: '',
oauth: { oauth: {
enabled: loadedEnv['oauthServerEnabled'] || false, // For connecting to Mifos X using OAuth2 Authentication change the value to true enabled: loadedEnv.oauthServerEnabled === true,
serverUrl: loadedEnv['oauthServerUrl'] || '', serverUrl: loadedEnv.oauthServerUrl || '',
appId: loadedEnv['oauthAppId'] || '' logoutUrl: loadedEnv.oauthServerLogoutUrl || '',
appId: loadedEnv.oauthAppId || '',
authorizeUrl: loadedEnv.oauthAuthorizeUrl || '',
tokenUrl: loadedEnv.oauthTokenUrl || '',
redirectUri: loadedEnv.oauthRedirectUri || '',
scope: loadedEnv.oauthScope || ''
}, },
/** Feature flag for Remember Me functionality */ /** Feature flag for Remember Me functionality */
enableRememberMe: false, enableRememberMe: false,
@@ -66,7 +71,7 @@ export const environment = {
minPasswordLength: loadedEnv['minPasswordLength'] || 12, minPasswordLength: loadedEnv['minPasswordLength'] || 12,
OIDC: { OIDC: {
oidcServerEnabled: window['env']['oidcServerEnabled'] || false, oidcServerEnabled: window['env']['oidcServerEnabled'] === true || window['env']['oidcServerEnabled'] === 'true',
oidcBaseUrl: window['env']['oidcBaseUrl'] || '', oidcBaseUrl: window['env']['oidcBaseUrl'] || '',
oidcClientId: window['env']['oidcClientId'] || '', oidcClientId: window['env']['oidcClientId'] || '',
oidcApiUrl: window['env']['oidcApiUrl'] || '', oidcApiUrl: window['env']['oidcApiUrl'] || '',
+8 -3
View File
@@ -32,9 +32,14 @@ export const environment = {
/** Feature flag for Remember Me functionality */ /** Feature flag for Remember Me functionality */
enableRememberMe: false, enableRememberMe: false,
oauth: { oauth: {
enabled: loadedEnv.oauthServerEnabled || false, // For connecting to Mifos X using OAuth2 Authentication change the value to true enabled: loadedEnv.oauthServerEnabled === true,
serverUrl: loadedEnv.oauthServerUrl || '', serverUrl: loadedEnv.oauthServerUrl || '',
appId: loadedEnv.oauthAppId || '' logoutUrl: loadedEnv.oauthServerLogoutUrl || '',
appId: loadedEnv.oauthAppId || '',
authorizeUrl: loadedEnv.oauthAuthorizeUrl || '',
tokenUrl: loadedEnv.oauthTokenUrl || '',
redirectUri: loadedEnv.oauthRedirectUri || '',
scope: loadedEnv.oauthScope || ''
}, },
warningDialog: { warningDialog: {
title: 'Warning', title: 'Warning',
@@ -70,7 +75,7 @@ export const environment = {
minPasswordLength: loadedEnv.minPasswordLength || 12, minPasswordLength: loadedEnv.minPasswordLength || 12,
OIDC: { OIDC: {
oidcServerEnabled: window['env']['oidcServerEnabled'] || false, oidcServerEnabled: window['env']['oidcServerEnabled'] === true || window['env']['oidcServerEnabled'] === 'true',
oidcBaseUrl: window['env']['oidcBaseUrl'] || '', oidcBaseUrl: window['env']['oidcBaseUrl'] || '',
oidcClientId: window['env']['oidcClientId'] || '', oidcClientId: window['env']['oidcClientId'] || '',
oidcApiUrl: window['env']['oidcApiUrl'] || '', oidcApiUrl: window['env']['oidcApiUrl'] || '',
+1 -1
View File
@@ -25,7 +25,7 @@
} }
const code = getQueryParam('code'); const code = getQueryParam('code');
if (code) { if (code) {
sessionStorage.setItem('auth_code', code); sessionStorage.setItem('oauth_callback_query', window.location.search);
window.location.href = '/#/callback'; window.location.href = '/#/callback';
} }
</script> </script>