pathology/viewer/src/services/auth.service.ts (284 lines of code) (raw):
/**
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference types="google.accounts" />
import {isPlatformBrowser} from '@angular/common';
import {Inject, Injectable, NgZone, PLATFORM_ID} from '@angular/core';
import {MatSnackBar, MatSnackBarConfig} from '@angular/material/snack-bar';
import {Router} from '@angular/router';
import {EMPTY, from, Observable, of} from 'rxjs';
import {map} from 'rxjs/operators';
import {environment} from '../environments/environment';
import {LogService} from './log.service';
import {UserService} from './user.service';
import {CREDENTIAL_STORAGE_KEY, WindowService} from './window.service';
/// <reference types="google" />
/** Token for valid user */
export declare interface AppToken {
email: string;
oauthTokenInfo: {
error: string,
expirationTime: string,
token: string,
};
}
interface DecodedToken {
email?: string;
name?: string;
exp?: number;
[key: string]: unknown;
}
declare global {
interface Window {
onGoogleLibraryLoad: () => Promise<void>;
}
}
/** Key for Dpas Token */
export const ACCESS_TOKEN_CACHE_KEY = 'DPAS_ACCESS_TOKEN';
const TIME_CONVERSION_MULTIPLIER = 1000;
// Buffer time of 4 seconds. The cached OAuth token needs to be valid at least
// until Now() + BUFFER_TIME. The buffer time will be useful to ensure that we
// don't return soon to expire tokens.
const BUFFER_TIME = 4000;
/**
* Service to handle Auth.
*/
@Injectable({
providedIn: 'root',
})
export class AuthService {
scope = environment.OAUTH_SCOPES;
appToken?: AppToken;
snackBarConfig = new MatSnackBarConfig();
constructor(
@Inject(NgZone) private readonly ngZone: NgZone,
@Inject(PLATFORM_ID) private readonly platformId: object,
private readonly logService: LogService,
private readonly router: Router,
private readonly snackBar: MatSnackBar,
private readonly userService: UserService,
private readonly windowService: WindowService,
) {
this.snackBarConfig.duration = 4000;
this.snackBarConfig.horizontalPosition = 'start';
}
setupGoogleLogin() {
if (!isPlatformBrowser(this.platformId) || !window ||
!environment.OAUTH_CLIENT_ID) {
return;
}
window.onGoogleLibraryLoad = async () => {
google.accounts.id.initialize({
// Ref:
// https://developers.google.com/identity/gsi/web/reference/js-reference#IdConfiguration
client_id: environment.OAUTH_CLIENT_ID,
callback: this.handleCredentialResponse.bind(this),
auto_select: false,
cancel_on_tap_outside: false
});
google.accounts!.id.renderButton(
document!.getElementById('loginBtn')!,
{theme: 'outline', size: 'large', width: 200, type: 'standard'});
if (this.windowService.getLocalStorageItem(CREDENTIAL_STORAGE_KEY)) {
let accessToken = this.getCachedAccessToken();
if (accessToken && this.isTokenExpired(accessToken)) {
await this.fetchAccessToken();
accessToken = this.getCachedAccessToken();
}
if (accessToken && !this.isTokenExpired(accessToken)) {
this.navigateToSearchIfAuth();
}
return;
}
google.accounts.id.prompt();
};
}
private async fetchAccessToken(): Promise<AppToken> {
if (!environment.OAUTH_CLIENT_ID) {
return Promise.resolve({
email: '',
oauthTokenInfo: {
error: '',
expirationTime: '',
token: '',
}
});
}
const decodedToken = this.handleCredentialToken();
if (decodedToken?.email) {
this.userService.setCurrentUser(decodedToken.email);
} else {
return Promise.resolve({
email: '',
oauthTokenInfo: {
error: '',
expirationTime: '',
token: '',
}
});
}
const tokenPromise = new Promise<AppToken>((resolve, reject) => {
const currentUserEmailAddress =
this.userService.getCurrentUser() ?? undefined;
const tokenClient = google.accounts.oauth2.initTokenClient({
client_id: environment.OAUTH_CLIENT_ID,
scope: this.scope,
hint: currentUserEmailAddress,
prompt: '',
callback: (response: google.accounts.oauth2.TokenResponse) => {
if (response.error) {
// User clicked cancel on the concent screen.
const errorMessage = 'Consent needed. Access denied.';
const error = new Error(errorMessage);
this.logService.error(error);
this.snackBar.open(errorMessage, 'Dismiss', this.snackBarConfig);
this.logout();
reject(errorMessage);
return;
}
const scopes = this.scope.split(' ') as [string, ...string[]];
if (!google.accounts.oauth2.hasGrantedAllScopes(
response, ...scopes)) {
const errorMessage = 'Not all scopes are granted. Try again.';
const error = new Error();
this.logService.error(error);
this.snackBar.open(errorMessage, 'Dismiss', this.snackBarConfig);
this.logout();
reject(errorMessage);
return;
}
const token = this.convertResponseToToken(response);
if (!token) {
const errorMessage = 'Access denied.';
this.logService.error(new Error(response.error));
this.snackBar.open(errorMessage, 'Dismiss', this.snackBarConfig);
this.logout();
reject(response.error);
return;
}
resolve(token);
},
});
if (!tokenClient) {
const errorMessage = 'No oauth client initialized.';
this.logService.error(new Error(errorMessage));
reject(errorMessage);
return;
}
tokenClient.requestAccessToken();
});
const token = await tokenPromise;
this.setCachedAccessToken(token);
this.navigateToSearchIfAuth();
return token;
}
private navigateToSearchIfAuth() {
const appToken = this.getCachedAccessToken();
if (this.router.url === '/auth' && appToken?.oauthTokenInfo.token) {
this.ngZone.run(() => {
this.router.navigate(['search'], {replaceUrl: true});
});
}
}
private convertResponseToToken(res: google.accounts.oauth2.TokenResponse):
AppToken|undefined {
if (!res.access_token || !res.expires_in) {
return;
}
const currentUserEmailAddress = this.userService.getCurrentUser() ?? '';
// milliseconds
const expiresAt =
Date.now() + this.secondsToMilliseconds(Number(res.expires_in));
const token = {
// Email is set in fetchTokenAndEmail() instead of here.
email: currentUserEmailAddress,
oauthTokenInfo: {
error: res.error ?? '',
expirationTime: String(expiresAt),
token: res.access_token,
}
};
return token;
}
private handleCredentialResponse(response:
google.accounts.id.CredentialResponse) {
try {
if (response?.credential) {
this.windowService.setLocalStorageItem(
CREDENTIAL_STORAGE_KEY, response?.credential);
}
this.fetchAccessToken();
} catch (e) {
console.error('Error while trying to decode token', e); // CONSOLE LOG OK
}
}
private handleCredentialToken() {
const credential =
this.windowService.getLocalStorageItem(CREDENTIAL_STORAGE_KEY);
if (!credential) {
console.error( // CONSOLE LOG OK
'Error while trying to decode token, no credentials');
return;
}
let decodedToken: DecodedToken|null = null;
decodedToken = JSON.parse(atob(credential.split('.')[1]));
return decodedToken;
}
getOAuthToken(retry = 1): Observable<string> {
if (!environment.OAUTH_CLIENT_ID) {
return of('');
}
const cachedToken = this.getCachedAccessToken();
if (cachedToken && !this.isTokenExpired(cachedToken)) {
return of(cachedToken.oauthTokenInfo.token);
}
if (retry) {
return this.fetchAndMapToken();
}
return EMPTY;
}
private fetchAndMapToken(): Observable<string> {
return from(this.fetchAccessToken())
.pipe(map((appToken) => appToken.oauthTokenInfo.token));
}
private getCachedAccessToken(): AppToken|undefined {
const cachedToken = this.getCachedAccessTokenFromLocalStorage();
if (!cachedToken) {
return;
}
if (this.isTokenExpired(cachedToken)) {
// The token has expired or will expire soon. Clear the old item.
this.clearCachedAccessToken();
return;
}
return cachedToken;
}
private isTokenExpired(token: AppToken) {
try {
if (!token.oauthTokenInfo.expirationTime) {
return false;
}
const expirationTimeMillis = Number(token.oauthTokenInfo.expirationTime);
if (Number.isNaN(expirationTimeMillis)) {
return true;
}
return expirationTimeMillis < Date.now() + BUFFER_TIME;
} catch (error: unknown) {
this.logout();
this.logService.error(error as Error);
return false;
}
}
logout() {
this.clearCurrentUser();
this.clearCachedAccessToken();
this.router.navigate(['/auth']);
google.accounts.id.disableAutoSelect();
google.accounts.id.prompt();
}
private getCachedAccessTokenFromLocalStorage(): AppToken|undefined {
const item = this.windowService.getLocalStorageItem(ACCESS_TOKEN_CACHE_KEY);
if (!item) {
return;
}
const token = JSON.parse(item) as AppToken;
if (!this.userService.getCurrentUser()) {
this.userService.setCurrentUser(token.email);
}
return token;
}
private setCachedAccessToken(token: AppToken): void {
this.userService.setCurrentUser(token.email);
this.windowService.setLocalStorageItem(ACCESS_TOKEN_CACHE_KEY, token);
}
private clearCurrentUser() {
this.userService.setCurrentUser(null);
}
private clearCachedAccessToken() {
this.windowService.removeLocalStorageItem(ACCESS_TOKEN_CACHE_KEY);
}
private secondsToMilliseconds(seconds: number) {
return seconds * TIME_CONVERSION_MULTIPLIER;
}
}