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
+6 -278
View File
@@ -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;
}
}
}
+20 -16
View File
@@ -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']);
}
}