WEB-487: Add OAuth 2.1 PKCE flow (#2864)
This commit is contained in:
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user