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
@@ -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
};
}