Skip to content

Commit

Permalink
Merge pull request #16 from cre8/7-bug-logout-out-of-browser-extension
Browse files Browse the repository at this point in the history
fix: trying launchWebauthflow to logout
  • Loading branch information
cre8 authored May 2, 2024
2 parents a67cde4 + 277226d commit 756a0f5
Show file tree
Hide file tree
Showing 7 changed files with 169 additions and 31 deletions.
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
<div class="content">
<router-outlet></router-outlet>
</div>
<mat-toolbar fxLayout="row" fxLayoutAlign="space-between center">
<mat-toolbar
*ngIf="isAuthenticated"
fxLayout="row"
fxLayoutAlign="space-between center"
>
<div fxLayout="column" fxLayoutAlign=" center">
<a mat-icon-button routerLink="/scan" routerLinkActive="active-link"
><mat-icon>qr_code_scanner</mat-icon></a
Expand Down
11 changes: 9 additions & 2 deletions apps/holder/projects/browser-extension/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
Expand All @@ -25,6 +25,13 @@ import { AuthService } from './auth/auth.service';
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent {
export class AppComponent implements OnInit {
isAuthenticated = false;
constructor(public authService: AuthService) {}

ngOnInit(): void {
this.authService.changed.subscribe((isAuthenticated) => {
this.isAuthenticated = isAuthenticated;
});
}
}
10 changes: 7 additions & 3 deletions apps/holder/projects/browser-extension/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
}
Expand Down
19 changes: 15 additions & 4 deletions apps/holder/projects/browser-extension/src/app/auth/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
};
132 changes: 114 additions & 18 deletions apps/holder/projects/browser-extension/src/app/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> = new BehaviorSubject<boolean>(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<boolean> {
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<string> {
return new Promise((resolve) => {
chrome.storage.local.get('access_token', (values) => {
const token = (values as Storage).access_token;
resolve(token);
});
});
}

/**
Expand All @@ -33,40 +62,70 @@ 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();
}

/**
* Extract the token from the redirectUri, refresh the token 10 seconds before it expires
* @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 =
Expand All @@ -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<string> {
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);
});
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -6,3 +18,5 @@ export const environment = {
demoIssuer: 'http://localhost:3001',
demoVerifier: 'http://localhost:3002',
};

globalThis.environment = environment;

0 comments on commit 756a0f5

Please sign in to comment.