diff --git a/apps/holder/projects/browser-extension/src/app/app.component.html b/apps/holder/projects/browser-extension/src/app/app.component.html index ac88b78f..24006282 100644 --- a/apps/holder/projects/browser-extension/src/app/app.component.html +++ b/apps/holder/projects/browser-extension/src/app/app.component.html @@ -1,7 +1,11 @@
- +
qr_code_scanner { + this.isAuthenticated = isAuthenticated; + }); + } } diff --git a/apps/holder/projects/browser-extension/src/app/app.config.ts b/apps/holder/projects/browser-extension/src/app/app.config.ts index 93d9f7be..a6cddd23 100644 --- a/apps/holder/projects/browser-extension/src/app/app.config.ts +++ b/apps/holder/projects/browser-extension/src/app/app.config.ts @@ -10,14 +10,18 @@ import { ApiModule, Configuration } from '../../../shared/api/kms'; import { AuthServiceInterface } from '../../../shared/settings/settings.component'; import { AuthService } from './auth/auth.service'; +// eslint-disable-next-line @typescript-eslint/no-namespace +export declare namespace globalThis { + let token: string; +} + function getConfiguration() { return new Configuration({ //TODO: the basepath is static, therefore we can not set it during the login process. basePath: environment.backendUrl, credentials: { - oauth2: () => { - return localStorage.getItem('accessToken') as string; - }, + // we fetch the token via globalThis since we can not access it via the chrome.storage API since it's async. + oauth2: () => globalThis.token, }, }); } diff --git a/apps/holder/projects/browser-extension/src/app/auth/auth.guard.ts b/apps/holder/projects/browser-extension/src/app/auth/auth.guard.ts index 7f6807b2..0dc06d9b 100644 --- a/apps/holder/projects/browser-extension/src/app/auth/auth.guard.ts +++ b/apps/holder/projects/browser-extension/src/app/auth/auth.guard.ts @@ -2,10 +2,21 @@ import { inject } from '@angular/core'; import { CanActivateFn } from '@angular/router'; import { AuthService } from './auth.service'; +// eslint-disable-next-line @typescript-eslint/no-namespace +export declare namespace globalThis { + let token: string; +} + export const authGuard: CanActivateFn = async () => { const authService: AuthService = inject(AuthService); - if (!authService.isAuthenticated()) { - authService.login(); - } - return true; + return authService + .isAuthenticated() + .then(async () => { + globalThis.token = await authService.getToken(); + return true; + }) + .catch(() => { + authService.login(); + return false; + }); }; diff --git a/apps/holder/projects/browser-extension/src/app/auth/auth.service.ts b/apps/holder/projects/browser-extension/src/app/auth/auth.service.ts index 60e85357..7f3befe3 100644 --- a/apps/holder/projects/browser-extension/src/app/auth/auth.service.ts +++ b/apps/holder/projects/browser-extension/src/app/auth/auth.service.ts @@ -1,23 +1,52 @@ import { Injectable } from '@angular/core'; import { environment } from '../../environments/environment'; import { decodeJwt } from 'jose'; +import { Router } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; + +interface Storage { + access_token: string; + expiration_time: number; + id_token: string; +} @Injectable({ providedIn: 'root', }) export class AuthService { - constructor() {} + changed: BehaviorSubject = new BehaviorSubject(false); + + constructor(private router: Router) {} /** * Checks if the access token exists and if it is expired * @returns */ - isAuthenticated() { - const token = localStorage.getItem('accessToken'); - if (!token) return false; - const jwt = decodeJwt(token as string); - if (jwt.exp && new Date(jwt.exp * 1000) < new Date()) return false; - return true; + isAuthenticated(): Promise { + return new Promise((resolve, reject) => { + chrome.storage.local.get(['access_token'], (values) => { + const token = (values as Storage).access_token; + if (!token) return reject(false); + const jwt = decodeJwt(token as string); + if (jwt.exp && new Date(jwt.exp * 1000) < new Date()) + return reject(false); + this.changed.next(true); + resolve(true); + }); + }); + } + + /** + * Gets the access token from the storage + * @returns + */ + getToken(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get('access_token', (values) => { + const token = (values as Storage).access_token; + resolve(token); + }); + }); } /** @@ -33,28 +62,53 @@ export class AuthService { .then( (redirectUri: string | undefined) => { if (!redirectUri) return; - const accessToken = this.extractTokenFromRedirectUri(redirectUri); - localStorage.setItem('accessToken', accessToken); + const { accessToken, expiresIn, idToken } = + this.parseUrl(redirectUri); + return chrome.storage.local.set({ + access_token: accessToken, + id_token: idToken, + expiration_time: + new Date().getTime() / 1000 + Number.parseInt(expiresIn), + }); }, (err) => console.log(err) ); } } + /** + * Generates a random string + * @returns + */ + private generateRandomString() { + const array = new Uint32Array(28); + crypto.getRandomValues(array); + return Array.from(array, (dec) => `0${dec.toString(16)}`.substr(-2)).join( + '' + ); + } + /** * Generates the auth url that will be used for login. * @returns */ private getAuthUrl() { - //TODO: it's not just the endpoint that needs to be passed, it's the host, client id and also the backend endpoints. We should provide this via an url that will be loaded and saved locally so it can be used on demand. const redirectURL = chrome.identity.getRedirectURL(); const scopes = ['openid']; - let authURL = `${environment.keycloakHost}/realms/${environment.keycloakRealm}/protocol/openid-connect/auth`; - authURL += `?client_id=${environment.keycloakClient}`; - authURL += '&response_type=token'; - authURL += `&redirect_uri=${encodeURIComponent(redirectURL)}`; - authURL += `&scope=${encodeURIComponent(scopes.join(' '))}`; - return authURL; + const nonce = this.generateRandomString(); + + const url = new URL( + `${environment.keycloakHost}/realms/${environment.keycloakRealm}/protocol/openid-connect/auth` + ); + const params = { + client_id: environment.keycloakClient, + response_type: 'id_token token', + redirect_uri: redirectURL, + nonce: nonce, + scope: scopes.join(' '), + }; + url.search = new URLSearchParams(params).toString(); + return url.toString(); } /** @@ -62,11 +116,16 @@ export class AuthService { * @param redirectUri * @returns */ - private extractTokenFromRedirectUri(redirectUri: string): string { + private parseUrl(redirectUri: string) { // Assuming redirectUri is the URL you provided const fragmentString = redirectUri.split('#')[1]; const params = new URLSearchParams(fragmentString); const accessToken = params.get('access_token') as string; + const idToken = params.get('id_token') as string; + const expiresIn = params.get('expires_in') as string; + if (!idToken || !accessToken || !expiresIn) { + throw new Error('Missing required parameters in redirect URL.'); + } //TODO parse the access_token to get the expiration time const payload = JSON.parse(window.atob(accessToken.split('.')[1])); const refreshTimer = @@ -76,6 +135,43 @@ export class AuthService { // Refresh the token 10 seconds before it expires setTimeout(() => this.login(), refreshTimer - 1000 * 10); // Returning the extracted values - return accessToken; + return { idToken, accessToken, expiresIn }; + } + + /** + * Logs out the user. It will close the window after the user is logged out. + */ + async logout() { + await chrome.identity + .launchWebAuthFlow({ + interactive: false, + url: await this.getLogoutUrl(), + }) + .then( + () => + chrome.storage.local.remove( + ['access_token', 'expiration_time', 'id_token'], + () => window.close() + ), + (err) => console.log(err) + ); + } + + /** + * Generates the logout URL that will be used for logging out. + * @returns {string} The logout URL. + */ + private getLogoutUrl(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(['id_token'], (values) => { + const idToken = (values as Storage).id_token; + const logoutUrl = + `${environment.keycloakHost}/realms/${environment.keycloakRealm}/protocol/openid-connect/logout` + + `?client_id=${environment.keycloakClient}` + + `&id_token_hint=${idToken}` + + `&post_logout_redirect_uri=${encodeURIComponent(chrome.identity.getRedirectURL(''))}`; + resolve(logoutUrl); + }); + }); } } diff --git a/apps/holder/projects/browser-extension/src/app/login/login.component.ts b/apps/holder/projects/browser-extension/src/app/login/login.component.ts index 34aa1b98..e25701de 100644 --- a/apps/holder/projects/browser-extension/src/app/login/login.component.ts +++ b/apps/holder/projects/browser-extension/src/app/login/login.component.ts @@ -36,9 +36,11 @@ export class LoginComponent implements OnInit { ) {} ngOnInit(): void { - if (this.authService.isAuthenticated()) { - this.router.navigate(['/credentials']); - } + this.authService.isAuthenticated().then((isAuthenticated) => { + if (isAuthenticated) { + this.router.navigate(['/credentials']); + } + }); } async login() { diff --git a/apps/holder/projects/browser-extension/src/environments/environment.development.ts b/apps/holder/projects/browser-extension/src/environments/environment.development.ts index 0ad5d87e..5b0388aa 100644 --- a/apps/holder/projects/browser-extension/src/environments/environment.development.ts +++ b/apps/holder/projects/browser-extension/src/environments/environment.development.ts @@ -1,3 +1,15 @@ +// eslint-disable-next-line @typescript-eslint/no-namespace +export declare namespace globalThis { + let environment: { + backendUrl: string; + keycloakHost: string; + keycloakClient: string; + keycloakRealm: string; + demoIssuer: string; + demoVerifier: string; + }; +} + export const environment = { backendUrl: 'http://localhost:3000', keycloakHost: 'http://localhost:8080', @@ -6,3 +18,5 @@ export const environment = { demoIssuer: 'http://localhost:3001', demoVerifier: 'http://localhost:3002', }; + +globalThis.environment = environment;