WEB-487: Add OAuth 2.1 PKCE flow (#2864)
This commit is contained in:
Generated
+14
@@ -31,6 +31,7 @@
|
||||
"@swimlane/ngx-graph": "^11.0.0",
|
||||
"@tailwindcss/forms": "^0.5.4",
|
||||
"@tinymce/tinymce-angular": "^9.1.1",
|
||||
"angular-oauth2-oidc": "^20.0.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"d3": "^7.9.0",
|
||||
"exceljs": "^4.4.0",
|
||||
@@ -9897,6 +9898,19 @@
|
||||
"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": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@swimlane/ngx-graph": "^11.0.0",
|
||||
"@tailwindcss/forms": "^0.5.4",
|
||||
"@tinymce/tinymce-angular": "^9.1.1",
|
||||
"angular-oauth2-oidc": "^20.0.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"d3": "^7.9.0",
|
||||
"exceljs": "^4.4.0",
|
||||
|
||||
@@ -58,6 +58,7 @@ import { TokenInterceptor as ZitadelTokenInterceptor } from './zitadel/token.int
|
||||
import { AuthService } from './zitadel/auth.service';
|
||||
import { environment } from '../environments/environment';
|
||||
import { CallbackComponent } from './zitadel/callback/callback.component';
|
||||
import { OAuthModule } from 'angular-oauth2-oidc';
|
||||
|
||||
export class CustomMissingTranslationHandler implements MissingTranslationHandler {
|
||||
handle(params: MissingTranslationHandlerParams): string {
|
||||
@@ -120,7 +121,8 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
ConfigurationWizardModule,
|
||||
AppRoutingModule,
|
||||
NotFoundComponent,
|
||||
CallbackComponent
|
||||
CallbackComponent,
|
||||
OAuthModule.forRoot()
|
||||
],
|
||||
providers: [
|
||||
DatePipe,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/** Angular Imports */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/** 3rd party Imports */
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
/** Custom Services */
|
||||
import { AlertService } from '../alert/alert.service';
|
||||
|
||||
@@ -18,7 +19,7 @@ import { environment } from '../../../environments/environment';
|
||||
/** Custom Models */
|
||||
import { LoginContext } from './login-context.model';
|
||||
import { Credentials } from './credentials.model';
|
||||
import { OAuth2Token } from './o-auth2-token.model';
|
||||
import { getOAuthConfig, getActiveAuthMode, AuthMode } from './oauth.config';
|
||||
|
||||
/**
|
||||
* Authentication workflow.
|
||||
@@ -28,17 +29,24 @@ export class AuthenticationService {
|
||||
private http = inject(HttpClient);
|
||||
private alertService = inject(AlertService);
|
||||
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) {
|
||||
return this.http.put(`/users/${userId}`, passwordObj);
|
||||
}
|
||||
// User logged in boolean
|
||||
private userLoggedIn: boolean;
|
||||
|
||||
private userLoggedIn$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
/** Observable that emits authentication state changes. */
|
||||
public readonly isAuthenticated$ = this.userLoggedIn$.asObservable();
|
||||
|
||||
/** Denotes whether the user credentials should persist through sessions. */
|
||||
private rememberMe: boolean;
|
||||
private rememberMe = false;
|
||||
/**
|
||||
* Denotes the type of storage:
|
||||
*
|
||||
@@ -46,17 +54,15 @@ export class AuthenticationService {
|
||||
*
|
||||
* Local Storage: User credentials should persist through sessions.
|
||||
*/
|
||||
private storage: any;
|
||||
/** User credentials. */
|
||||
|
||||
private storage: Storage = sessionStorage;
|
||||
private credentials: Credentials;
|
||||
private dialogShown = false;
|
||||
private authMode: AuthMode = AuthMode.Basic;
|
||||
|
||||
/** Key to store credentials in storage. */
|
||||
private credentialsStorageKey = 'mifosXCredentials';
|
||||
/** Key to store oauth token details in storage. */
|
||||
private oAuthTokenDetailsStorageKey = 'mifosXOAuthTokenDetails';
|
||||
private readonly credentialsStorageKey = 'mifosXCredentials';
|
||||
/** 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
|
||||
@@ -64,135 +70,173 @@ export class AuthenticationService {
|
||||
* @param {HttpClient} http Http Client to send requests.
|
||||
* @param {AlertService} alertService Alert Service.
|
||||
* @param {AuthenticationInterceptor} authenticationInterceptor Authentication Interceptor.
|
||||
* @param {OAuthService} oauthService OAuth Service.
|
||||
*/
|
||||
constructor() {
|
||||
const authenticationInterceptor = this.authenticationInterceptor;
|
||||
this.authMode = getActiveAuthMode();
|
||||
|
||||
this.userLoggedIn = false;
|
||||
this.rememberMe = false;
|
||||
this.storage = sessionStorage;
|
||||
const savedCredentials = JSON.parse(
|
||||
sessionStorage.getItem(this.credentialsStorageKey) || localStorage.getItem(this.credentialsStorageKey)
|
||||
);
|
||||
if (savedCredentials) {
|
||||
if (savedCredentials.rememberMe) {
|
||||
this.rememberMe = true;
|
||||
this.storage = localStorage;
|
||||
if (this.authMode !== AuthMode.Basic) {
|
||||
this.initializeOAuthService();
|
||||
}
|
||||
|
||||
this.restoreSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the OAuth service with runtime settings and hooks up token listeners.
|
||||
*/
|
||||
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();
|
||||
} else {
|
||||
authenticationInterceptor.setAuthorizationToken(savedCredentials.base64EncodedAuthenticationKey);
|
||||
});
|
||||
|
||||
this.cleanupLegacyStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
authenticationInterceptor.setTwoFactorAccessToken(twoFactorAccessToken.token);
|
||||
} else {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {LoginContext} loginContext Login parameters.
|
||||
* @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...' });
|
||||
// Only allow Remember Me if enabled in config
|
||||
const rememberAllowed = environment.enableRememberMe === true;
|
||||
this.rememberMe = rememberAllowed ? loginContext.remember : false;
|
||||
|
||||
if (this.authMode !== AuthMode.Basic) {
|
||||
// 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;
|
||||
|
||||
if (environment.oauth.enabled) {
|
||||
let httpParams = new HttpParams();
|
||||
httpParams = httpParams.set('username', loginContext.username);
|
||||
httpParams = httpParams.set('password', loginContext.password);
|
||||
httpParams = httpParams.set('client_id', `${environment.oauth.appId}`);
|
||||
httpParams = httpParams.set('grant_type', 'password');
|
||||
httpParams = httpParams.set('remember_me', this.rememberMe ? 'true' : 'false');
|
||||
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 }).pipe(
|
||||
map((tokenResponse: OAuth2Token) => {
|
||||
this.getUserDetails(tokenResponse);
|
||||
return of(true);
|
||||
// Basic Auth: Direct authentication with Fineract
|
||||
return this.http
|
||||
.post('/authentication', {
|
||||
username: loginContext.username,
|
||||
password: loginContext.password,
|
||||
remember: this.rememberMe
|
||||
})
|
||||
.pipe(
|
||||
map((credentials: Credentials) => {
|
||||
this.onLoginSuccess(credentials);
|
||||
return true;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return this.http
|
||||
.post('/authentication', {
|
||||
username: loginContext.username,
|
||||
password: loginContext.password,
|
||||
remember: this.rememberMe
|
||||
})
|
||||
.pipe(
|
||||
map((credentials: Credentials) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user details from the server.
|
||||
* @returns {Promise<void>} Promise that resolves when user details are fetched.
|
||||
*/
|
||||
private async getUserDetails(): Promise<void> {
|
||||
const accessToken = this.oauthService.getAccessToken();
|
||||
|
||||
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);
|
||||
return of(true);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the user details after oauth2 authentication.
|
||||
*
|
||||
* Sets the oauth2 token refresh time.
|
||||
* @param {OAuth2Token} tokenResponse OAuth2 Token details.
|
||||
*/
|
||||
private getUserDetails(tokenResponse: OAuth2Token) {
|
||||
this.refreshTokenOnExpiry(tokenResponse.expires_in);
|
||||
let headers = new HttpHeaders();
|
||||
headers = headers.set('Authorization', 'bearer ' + tokenResponse.access_token);
|
||||
this.http
|
||||
.get(`${environment.serverUrl}/userdetails`, { headers: headers })
|
||||
.subscribe((credentials: Credentials) => {
|
||||
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));
|
||||
});
|
||||
resolve();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to fetch user details:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} else if (this.authMode === AuthMode.OAuth2) {
|
||||
const headers = new HttpHeaders().set('Authorization', `Bearer ${accessToken}`);
|
||||
const url = `${environment.oauth.serverUrl}/userdetails`;
|
||||
this.http.get<Credentials>(url, { headers }).subscribe({
|
||||
next: (credentials) => {
|
||||
credentials.accessToken = accessToken;
|
||||
this.onLoginSuccess(credentials);
|
||||
resolve();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Failed to fetch user details:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,13 +249,12 @@ export class AuthenticationService {
|
||||
* Sends an alert on successful login.
|
||||
* @param {Credentials} credentials Authenticated user credentials.
|
||||
*/
|
||||
private onLoginSuccess(credentials: Credentials) {
|
||||
this.userLoggedIn = true;
|
||||
private onLoginSuccess(credentials: Credentials): void {
|
||||
this.userLoggedIn$.next(true); // ✅ notify observers
|
||||
// Ensure the rememberMe value is preserved in credentials
|
||||
credentials.rememberMe = this.rememberMe;
|
||||
|
||||
if (environment.oauth.enabled) {
|
||||
if (this.authMode !== AuthMode.Basic) {
|
||||
this.authenticationInterceptor.setAuthorizationToken(credentials.accessToken);
|
||||
} else {
|
||||
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() {
|
||||
const oAuthRefreshToken = JSON.parse(this.storage.getItem(this.oAuthTokenDetailsStorageKey)).refresh_token;
|
||||
const credentials = JSON.parse(this.storage.getItem(this.credentialsStorageKey));
|
||||
this.authenticationInterceptor.removeAuthorizationTenant();
|
||||
let httpParams = new HttpParams();
|
||||
httpParams = httpParams.set('username', credentials.username);
|
||||
httpParams = httpParams.set('client_id', `${environment.oauth.appId}`);
|
||||
httpParams = httpParams.set('refresh_token', oAuthRefreshToken);
|
||||
let headers = new HttpHeaders();
|
||||
headers = headers.set('Content-Type', 'application/x-www-form-urlencoded');
|
||||
return this.http
|
||||
.post(`${environment.oauth.serverUrl}/logout`, httpParams.toString(), { headers: headers })
|
||||
.subscribe();
|
||||
async handleOAuthCallback(): Promise<boolean> {
|
||||
try {
|
||||
// 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.
|
||||
let queryString = sessionStorage.getItem('oauth_callback_query');
|
||||
|
||||
if (queryString) {
|
||||
sessionStorage.removeItem('oauth_callback_query');
|
||||
await this.oauthService.tryLoginCodeFlow({ customHashFragment: queryString });
|
||||
} else {
|
||||
await this.oauthService.tryLoginCodeFlow();
|
||||
}
|
||||
|
||||
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.authenticationInterceptor.removeTwoFactorAuthorization();
|
||||
}
|
||||
const oAuthRefreshToken = JSON.parse(this.storage.getItem(this.oAuthTokenDetailsStorageKey));
|
||||
if (oAuthRefreshToken) {
|
||||
this.logoutAuthSession();
|
||||
}
|
||||
|
||||
// Clear any pending OAuth callback data
|
||||
sessionStorage.removeItem('oauth_callback_query');
|
||||
|
||||
this.authenticationInterceptor.removeAuthorization();
|
||||
this.setCredentials();
|
||||
this.resetDialog();
|
||||
this.userLoggedIn = false;
|
||||
this.userLoggedIn$.next(false); // ✅ notify observers
|
||||
this.userLoggedIn$.next(false);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -297,11 +366,10 @@ export class AuthenticationService {
|
||||
* @returns {boolean} True if the user is authenticated.
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return !!(
|
||||
JSON.parse(
|
||||
sessionStorage.getItem(this.credentialsStorageKey) || localStorage.getItem(this.credentialsStorageKey)
|
||||
) && this.twoFactorAccessTokenIsValid()
|
||||
);
|
||||
if (this.authMode !== AuthMode.Basic) {
|
||||
return this.oauthService.hasValidAccessToken();
|
||||
}
|
||||
return !!(this.getSavedCredentials() && this.twoFactorAccessTokenIsValid());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,29 +388,40 @@ export class AuthenticationService {
|
||||
*
|
||||
* @param {Credentials} credentials Authenticated user credentials.
|
||||
*/
|
||||
private setCredentials(credentials?: Credentials) {
|
||||
private setCredentials(credentials?: Credentials): void {
|
||||
if (credentials) {
|
||||
credentials.rememberMe = this.rememberMe;
|
||||
// Make sure we're using the correct storage based on rememberMe value
|
||||
this.storage = credentials.rememberMe ? localStorage : sessionStorage;
|
||||
this.oauthService.setStorage(this.storage);
|
||||
this.storage.setItem(this.credentialsStorageKey, JSON.stringify(credentials));
|
||||
} else {
|
||||
// Clear credentials from both storage types to ensure complete logout
|
||||
localStorage.removeItem(this.credentialsStorageKey);
|
||||
sessionStorage.removeItem(this.credentialsStorageKey);
|
||||
localStorage.removeItem(this.oAuthTokenDetailsStorageKey);
|
||||
sessionStorage.removeItem(this.oAuthTokenDetailsStorageKey);
|
||||
localStorage.removeItem(this.twoFactorAuthenticationTokenStorageKey);
|
||||
sessionStorage.removeItem(this.twoFactorAuthenticationTokenStorageKey);
|
||||
[
|
||||
localStorage,
|
||||
sessionStorage
|
||||
].forEach((store) => {
|
||||
store.removeItem(this.credentialsStorageKey);
|
||||
store.removeItem(this.twoFactorAuthenticationTokenStorageKey);
|
||||
});
|
||||
this.cleanupLegacyStorage();
|
||||
}
|
||||
}
|
||||
|
||||
public saveZitadelCredentials(credentials: Credentials): void {
|
||||
this.setCredentials(credentials);
|
||||
}
|
||||
|
||||
public saveZitadeloAuthTokenDetailsStorageKey(tokenResponse: OAuth2Token): void {
|
||||
this.storage.setItem(this.oAuthTokenDetailsStorageKey, JSON.stringify(tokenResponse));
|
||||
private cleanupLegacyStorage(): void {
|
||||
const legacyKeys = [
|
||||
'mifosXZitadelTokenDetails',
|
||||
'mifosXOAuthTokenDetails',
|
||||
'token_start_time',
|
||||
'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.
|
||||
* @param {any} response Two factor authentication token details.
|
||||
*/
|
||||
private onOTPValidateSuccess(response: any) {
|
||||
private onOTPValidateSuccess(response: any): void {
|
||||
this.authenticationInterceptor.setTwoFactorAccessToken(response.token);
|
||||
if (this.credentials.shouldRenewPassword) {
|
||||
this.alertService.alert({
|
||||
@@ -443,6 +522,6 @@ export class AuthenticationService {
|
||||
* Get user logged in
|
||||
*/
|
||||
getUserLoggedIn(): boolean {
|
||||
return this.userLoggedIn;
|
||||
return this.userLoggedIn$.value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* OAuth2 token model.
|
||||
* OAuth2/OIDC token response model.
|
||||
*/
|
||||
export interface OAuth2Token {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
refresh_token: string;
|
||||
refresh_token?: string;
|
||||
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
|
||||
* **__NOTE__** OAuth2 endpoints are handled by angular-oauth2-oidc library
|
||||
*/
|
||||
if (!request.url.includes('http:') && !request.url.includes('https:')) {
|
||||
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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Environment Configuration and Zitadel*/
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { AuthService } from 'app/zitadel/auth.service';
|
||||
import { catchError, finalize, of, take } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Sidenav component.
|
||||
@@ -56,7 +54,6 @@ export class SidenavComponent implements OnInit, AfterViewInit {
|
||||
private settingsService = inject(SettingsService);
|
||||
private configurationWizardService = inject(ConfigurationWizardService);
|
||||
private popoverService = inject(PopoverService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
/** True if sidenav is in collapsed state. */
|
||||
@Input() sidenavCollapsed: boolean;
|
||||
@@ -103,13 +100,17 @@ export class SidenavComponent implements OnInit, AfterViewInit {
|
||||
|
||||
/**
|
||||
* Logs out the authenticated user and redirects to login page.
|
||||
* Uses unified AuthenticationService which handles both OAuth2 and OIDC logout.
|
||||
*/
|
||||
logout() {
|
||||
if (!environment.OIDC.oidcServerEnabled) {
|
||||
this.authenticationService.logout().subscribe(() => this.router.navigate(['/login'], { replaceUrl: true }));
|
||||
} else {
|
||||
this.authService.logout();
|
||||
}
|
||||
this.authenticationService
|
||||
.logout()
|
||||
.pipe(
|
||||
take(1),
|
||||
catchError(() => of(void 0)),
|
||||
finalize(() => this.router.navigate(['/login'], { replaceUrl: true }))
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,8 +19,8 @@ import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, finalize, map, take } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
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 { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Environment Configuration and Zitadel*/
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { AuthService } from 'app/zitadel/auth.service';
|
||||
|
||||
/**
|
||||
* Toolbar component.
|
||||
@@ -77,7 +75,6 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
|
||||
private configurationWizardService = inject(ConfigurationWizardService);
|
||||
private dialog = inject(MatDialog);
|
||||
private changeDetector = inject(ChangeDetectorRef);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
/* Reference of institution */
|
||||
@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.
|
||||
* Uses unified AuthenticationService which handles both OAuth2 and OIDC logout.
|
||||
*/
|
||||
logout() {
|
||||
if (!environment.OIDC.oidcServerEnabled) {
|
||||
this.authenticationService.logout().subscribe(() => this.router.navigate(['/login'], { replaceUrl: true }));
|
||||
} else {
|
||||
this.authService.logout();
|
||||
}
|
||||
this.authenticationService
|
||||
.logout()
|
||||
.pipe(
|
||||
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>
|
||||
<form class="layout-column" [formGroup]="loginForm" (ngSubmit)="login()" id="login-form">
|
||||
<mat-form-field class="login-input flex-align-center">
|
||||
@@ -62,25 +63,20 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (oidcServerEnabled) {
|
||||
<div class="container container-margin">
|
||||
@if (!isLoggedIn) {
|
||||
<div class="layout-column align-center-center">
|
||||
<button mat-raised-button color="primary" class="login-button-first flex-align-center" (click)="loginOIDC()">
|
||||
{{ 'labels.buttons.Login' | translate }}
|
||||
@if (loading) {
|
||||
<mat-spinner [diameter]="20"></mat-spinner>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (isLoggedIn) {
|
||||
<div>
|
||||
<p>Welcome! You are logged in.</p>
|
||||
<button (click)="logout()">Logout</button>
|
||||
<button (click)="loadProtectedData()">Load Protected Data</button>
|
||||
<pre>{{ protectedData | json }}</pre>
|
||||
</div>
|
||||
}
|
||||
<!-- OAuth/OIDC: Simple login button (no credentials needed) -->
|
||||
@if (oauthEnabled) {
|
||||
<div class="layout-column">
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
class="login-button-first flex-align-center"
|
||||
(click)="loginOAuth()"
|
||||
[disabled]="loading"
|
||||
>
|
||||
{{ 'labels.buttons.Login' | translate }}
|
||||
@if (loading) {
|
||||
<mat-spinner [diameter]="20"></mat-spinner>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormGroup, FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
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 { MatIconButton, MatButton } from '@angular/material/button';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatProgressBar } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/** Custom Service Zitadel */
|
||||
import { AuthService } from '../../zitadel/auth.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
/**
|
||||
@@ -30,7 +26,6 @@ import { environment } from '../../../environments/environment';
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatPrefix,
|
||||
FaIconComponent,
|
||||
MatIconButton,
|
||||
MatProgressBar,
|
||||
MatProgressSpinner
|
||||
]
|
||||
@@ -38,7 +33,6 @@ import { environment } from '../../../environments/environment';
|
||||
export class LoginFormComponent implements OnInit {
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
/** Login form group. */
|
||||
loginForm: FormGroup;
|
||||
@@ -46,7 +40,8 @@ export class LoginFormComponent implements OnInit {
|
||||
passwordInputType: string = 'password';
|
||||
/** True if loading. */
|
||||
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 */
|
||||
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.
|
||||
*/
|
||||
|
||||
loginOIDC() {
|
||||
this.authService.login();
|
||||
}
|
||||
|
||||
getUsers() {
|
||||
this.authService.getUsers();
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.authService.logout();
|
||||
loginOAuth() {
|
||||
this.loading = true;
|
||||
this.authenticationService
|
||||
.login()
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
this.loading = false;
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
error: () => {
|
||||
// Error handling is managed by the authentication service
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -102,6 +102,10 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,6 @@ import { animate, style, transition, trigger } from '@angular/animations';
|
||||
import { I18nService } from './core/i18n/i18n.service';
|
||||
import { ThemingService } from './shared/theme-toggle/theming.service';
|
||||
|
||||
import { AuthService } from './zitadel/auth.service';
|
||||
|
||||
/** Initialize Logger */
|
||||
const log = new Logger('MifosX');
|
||||
|
||||
@@ -123,8 +121,7 @@ export class WebAppComponent implements OnInit, OnDestroy {
|
||||
private themingService: ThemingService,
|
||||
private dateUtils: Dates,
|
||||
private idle: IdleTimeoutService,
|
||||
private dialog: MatDialog,
|
||||
private authService: AuthService
|
||||
private dialog: MatDialog
|
||||
) {}
|
||||
|
||||
@HostBinding('class') public cssClass: string;
|
||||
@@ -141,10 +138,6 @@ export class WebAppComponent implements OnInit, OnDestroy {
|
||||
* 4) Alerts
|
||||
*/
|
||||
|
||||
login() {
|
||||
this.authService.login();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.themingService.theme.subscribe((value: string) => {
|
||||
this.cssClass = value;
|
||||
@@ -252,11 +245,7 @@ export class WebAppComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
this.dialog.open(SessionTimeoutDialogComponent);
|
||||
setTimeout(() => {
|
||||
if (!environment.OIDC.oidcServerEnabled) {
|
||||
this.logout();
|
||||
} else {
|
||||
this.authService.logout();
|
||||
}
|
||||
this.logout();
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,190 +1,19 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams, HttpHeaders } from '@angular/common/http';
|
||||
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';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
private http = inject(HttpClient);
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private alertService = inject(AlertService);
|
||||
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 oauthService = inject(OAuthService);
|
||||
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 {
|
||||
const rawToken = sessionStorage.getItem('mifosXZitadelTokenDetails');
|
||||
|
||||
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 });
|
||||
});
|
||||
return this.oauthService.getAccessToken();
|
||||
}
|
||||
|
||||
public deletUser(userId: string) {
|
||||
@@ -315,112 +144,11 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
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)}`;
|
||||
|
||||
async refreshToken(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(logoutUrl, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Error en logout:', response.status, response.statusText);
|
||||
}
|
||||
await this.oauthService.refreshToken();
|
||||
} catch (error) {
|
||||
console.error('Error de red en logout:', 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');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { AlertService } from '../../core/alert/alert.service';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-callback',
|
||||
templateUrl: './callback.component.html'
|
||||
})
|
||||
export class CallbackComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private authService = inject(AuthService);
|
||||
private alertService = inject(AlertService);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
try {
|
||||
const code = sessionStorage.getItem('auth_code');
|
||||
if (code) {
|
||||
const codeVerifier = sessionStorage.getItem('code_verifier');
|
||||
try {
|
||||
await this.authService.exchangeCodeForTokens(code, codeVerifier);
|
||||
} finally {
|
||||
// Clean up sensitive data immediately after use
|
||||
sessionStorage.removeItem('auth_code');
|
||||
sessionStorage.removeItem('code_verifier');
|
||||
}
|
||||
const success = await this.authenticationService.handleOAuthCallback();
|
||||
|
||||
if (success) {
|
||||
this.router.navigate(['/home']);
|
||||
} else {
|
||||
this.alertService.alert({
|
||||
type: 'Authentication Failed',
|
||||
message: 'Unable to complete authentication. Please try again.'
|
||||
});
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication failed:', error);
|
||||
// Navigate to login page
|
||||
console.error('Authentication callback failed:', error);
|
||||
this.alertService.alert({
|
||||
type: 'Authentication Error',
|
||||
message: 'An error occurred during authentication. Please try again.'
|
||||
});
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
/* tslint:disable */
|
||||
export default {
|
||||
'mifos_x': {
|
||||
'version': '251204',
|
||||
'hash': '925391ff'
|
||||
'version': '251211',
|
||||
'hash': 'g667887c5f'
|
||||
},
|
||||
'allow_switching_backend_instance': true
|
||||
};
|
||||
|
||||
@@ -26,9 +26,14 @@ export const environment = {
|
||||
apiActuator: loadedEnv.apiActuator || '/fineract-provider',
|
||||
serverUrl: '',
|
||||
oauth: {
|
||||
enabled: loadedEnv['oauthServerEnabled'] || false, // For connecting to Mifos X using OAuth2 Authentication change the value to true
|
||||
serverUrl: loadedEnv['oauthServerUrl'] || '',
|
||||
appId: loadedEnv['oauthAppId'] || ''
|
||||
enabled: loadedEnv.oauthServerEnabled === true,
|
||||
serverUrl: loadedEnv.oauthServerUrl || '',
|
||||
logoutUrl: loadedEnv.oauthServerLogoutUrl || '',
|
||||
appId: loadedEnv.oauthAppId || '',
|
||||
authorizeUrl: loadedEnv.oauthAuthorizeUrl || '',
|
||||
tokenUrl: loadedEnv.oauthTokenUrl || '',
|
||||
redirectUri: loadedEnv.oauthRedirectUri || '',
|
||||
scope: loadedEnv.oauthScope || ''
|
||||
},
|
||||
/** Feature flag for Remember Me functionality */
|
||||
enableRememberMe: false,
|
||||
@@ -66,7 +71,7 @@ export const environment = {
|
||||
minPasswordLength: loadedEnv['minPasswordLength'] || 12,
|
||||
|
||||
OIDC: {
|
||||
oidcServerEnabled: window['env']['oidcServerEnabled'] || false,
|
||||
oidcServerEnabled: window['env']['oidcServerEnabled'] === true || window['env']['oidcServerEnabled'] === 'true',
|
||||
oidcBaseUrl: window['env']['oidcBaseUrl'] || '',
|
||||
oidcClientId: window['env']['oidcClientId'] || '',
|
||||
oidcApiUrl: window['env']['oidcApiUrl'] || '',
|
||||
|
||||
@@ -32,9 +32,14 @@ export const environment = {
|
||||
/** Feature flag for Remember Me functionality */
|
||||
enableRememberMe: false,
|
||||
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 || '',
|
||||
appId: loadedEnv.oauthAppId || ''
|
||||
logoutUrl: loadedEnv.oauthServerLogoutUrl || '',
|
||||
appId: loadedEnv.oauthAppId || '',
|
||||
authorizeUrl: loadedEnv.oauthAuthorizeUrl || '',
|
||||
tokenUrl: loadedEnv.oauthTokenUrl || '',
|
||||
redirectUri: loadedEnv.oauthRedirectUri || '',
|
||||
scope: loadedEnv.oauthScope || ''
|
||||
},
|
||||
warningDialog: {
|
||||
title: 'Warning',
|
||||
@@ -70,7 +75,7 @@ export const environment = {
|
||||
minPasswordLength: loadedEnv.minPasswordLength || 12,
|
||||
|
||||
OIDC: {
|
||||
oidcServerEnabled: window['env']['oidcServerEnabled'] || false,
|
||||
oidcServerEnabled: window['env']['oidcServerEnabled'] === true || window['env']['oidcServerEnabled'] === 'true',
|
||||
oidcBaseUrl: window['env']['oidcBaseUrl'] || '',
|
||||
oidcClientId: window['env']['oidcClientId'] || '',
|
||||
oidcApiUrl: window['env']['oidcApiUrl'] || '',
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
}
|
||||
const code = getQueryParam('code');
|
||||
if (code) {
|
||||
sessionStorage.setItem('auth_code', code);
|
||||
sessionStorage.setItem('oauth_callback_query', window.location.search);
|
||||
window.location.href = '/#/callback';
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user