import { HttpErrorResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Applicant } from '@core/typings/applicant.typing'; import { User } from '@core/typings/client-user.typing'; import { BlackbaudSsoError, SSOToken, TokenContent, TokenResponse } from '@core/typings/token.typing'; import { environment } from '@environment'; import { ImpersonationService } from '@features/impersonation/impersonation.service'; import { UserService } from '@features/users/user.service'; import { LogService } from '@yourcause/common/logging'; import { ModalFactory } from '@yourcause/common/modals'; import { AppInsightsService } from '@yourcause/common/utils'; import { DeepLinkingService } from '../deep-linking.service'; import { MixpanelService } from '../mixpanel.service'; import { PortalDeterminationService } from '../portal-determination.service'; import { SSOService } from '../sso.service'; import { TokenRefreshResources } from './token-refresh.resources'; import { TokenRetrievalResources } from './token-retrieval.resources'; import { TokenRevocationResources } from './token-revocation.resources'; import { TokenStorageService } from './token-storage.service'; import { TokenTimeoutService } from './token-timeout.service'; @Injectable({ providedIn: 'root' }) export class TokenService { private permittedPlatformTransferRedirects = [ 'localhost:51851', 'dev-grantsconnect-ui.azurewebsites.net' ]; private logoutTriggered = false; private latestProm: Promise; private refreshOffset = 1 /* minute(s) */ * 60 /* seconds */ * 1000 /* milliseconds */; constructor ( private mixpanel: MixpanelService, private logger: LogService, private userService: UserService, private ssoService: SSOService, private modalFactory: ModalFactory, private deepLinkingService: DeepLinkingService, private portal: PortalDeterminationService, private revocation: TokenRevocationResources, private refresh: TokenRefreshResources, private retrieval: TokenRetrievalResources, private storage: TokenStorageService, private timeout: TokenTimeoutService, private appInsights: AppInsightsService, private impersonationService: ImpersonationService ) { } hasToken () { return !!this.storage.jwt; } setAttemptedRoute () { if ( location.pathname !== '/' && location.pathname !== `/${this.portal.routeBase}/` && location.pathname !== '/' + this.portal.routeBase && !location.pathname.includes('apply/applications') && !location.pathname.includes('management/home/my-workspace') && !location.pathname.endsWith('/sso_redirect') && !location.pathname.endsWith('/ssologout') ) { this.deepLinkingService.setAttemptedRoute( location.href.split(new RegExp(location.hostname + '\:?\\d*')).pop() ); return true; } return false; } hasCurrentValidToken () { const currentToken = this.storage.jwt; // apply an offset to ensure we always have an up to date token const now = new Date(Date.now() + this.refreshOffset); // make sure the JWT is intact and that the token's expiration is in the future return currentToken && !!this.parseJwt() && (new Date(currentToken.expiration) > now); } hasFutureValidToken () { const currentToken = this.storage.jwt; const now = new Date(); // we have an intact JWT return currentToken && !!this.parseJwt() && // and will be valid in the future if the token is expired !this.hasCurrentValidToken() && // but the refreshToken is not (new Date(currentToken.refreshTokenExpiration) > now); } hasImpersonationToken () { const parsed = this.parseJwt(); return !!parsed?.impersonated_by_user_id ?? false; } getIsLoggedIn () { return this.hasCurrentValidToken() || this.hasFutureValidToken(); } getLatestToken (returnFullToken = false) { if (!this.latestProm || returnFullToken) { this.latestProm = new Promise(async (resolve) => { // pull the current token const currentToken = this.storage.jwt; // if we need to refresh, kick that off if (this.hasFutureValidToken()) { await this.doRefresh(); } // if we have a valid token, return that if (this.hasCurrentValidToken()) { return resolve(returnFullToken ? this.storage.jwt : this.storage.jwt.token); } else if (currentToken) { // if current token but it's not valid, we can remove it this.storage.revoke(); } // otherwise we assume they never tried to log in and don't have a token resolve(null); }).then((val) => { this.latestProm = null; return val; }).catch(e => { this.logger.error(e, { subMessage: 'Error logging out' }); this.logout(true); throw e; }); } return this.latestProm; } handleExpiredSession () { setTimeout(() => { return this.logout(true); }, 1000); } getIdentifier () { return this.storage.clientIdentifier; } async ssoExchange ( getUserFunc: () => Promise ) { // unpick the embedded URL content from the sso exchange or *subdomain redirect* const token = this.extractTokenFromLocationAttribute(location.hash); const { id_token, access_token } = token; const clientIdentifier = this.storage.clientIdentifier; // do the exchange const response = await this.retrieval.getTokenFromSSO( id_token, access_token, clientIdentifier ); const prefix = this.portal.getCurrentPrefix(); if ( prefix === 'barings' || prefix === 'qa-chs' ) { this.appInsights.trackEvent('sso:debug', { event: 'SSO_REDIRECT', prefix, clientIdentifier, response: JSON.stringify(response) }); } // set the client identifier this.storage.overrideClientIdentifier(clientIdentifier); // store the sso token for when we sign out this.ssoService.setIdToken(token.id_token); // using the exchanged token, store that for use in the app this.tokenSignin(response); await getUserFunc(); } extractTokenFromLocationAttribute (value: string) { return (value || '') .slice(1) .split('&') .reduce((obj, key) => ({ ...obj, [key.split('=')[0]]: key.split('=')[1] }), {} as SSOToken); } async isPasswordCorrect ( email: string, password: string ): Promise { try { await this.login(email, password); return true; } catch (err) { const e = err as HttpErrorResponse; this.logger.error(e); if (e?.error?.message === 'Incorrect email or password') { return false; } return null; } } async login ( email: string, password: string, multifactorAuthenticationCode?: string, rememberMe?: boolean ) { const tokenResponse = await this.retrieval.getToken({ email, password, clientIdentifier: this.storage.clientIdentifier, multifactorAuthenticationCode, rememberMe }); this.tokenSignin(tokenResponse); } tokenSignin (token: TokenResponse) { this.storage.jwt = token; const jwt = this.parseJwt(); if (jwt) { if (!environment.isLocalhost) { this.appInsights.setAuthenticatedUserContext(jwt.UserId, jwt.client_id, true); } this.mixpanel.track('Login', {}); } } logout = async ( doRedirect = true, attemptRevoke = true ) => { if (!this.logoutTriggered) { this.logoutTriggered = true; this.mixpanel.track('Logout', {}); this.mixpanel.reset(); this.timeout.stop(); const isImpersonating = this.hasImpersonationToken(); if (attemptRevoke && this.hasCurrentValidToken()) { await this.revocation.revokeToken(); } else { this.storage.revoke(); } this.userService.setUser(null); this.modalFactory.dismissAllOpen(); let doHrefChange = false; let url = `/${this.portal.routeBase}/auth/signin`; if (this.ssoService.getIdToken()) { url = this.ssoService.logout() || url; doRedirect = true; doHrefChange = true; } this.appInsights.clearAuthenticatedUserContext(); if (isImpersonating) { this.impersonationService.handleEndImpersonationSession(); } else if (doRedirect) { location[doHrefChange ? 'href' : 'pathname'] = url; } } return ''; }; parseJwt () { try { return JSON.parse(atob(this.storage.jwt.token.split('.')[1] .replace(/-/g, '+') .replace(/_/g, '/')) ) as TokenContent; } catch (e) { console.warn('Failed to parse JWT', e, 'got:', this.storage.jwt); } return null; } castTokenContentToUser (content: TokenContent): User { return { firstName: content.given_name, lastName: content.family_name, email: content.email, id: +content.sub, active: true, culture: null, isNewUser: null, isRootUser: null, jobTitle: null, profileImageUrl: null, requirePasswordReset: null, roles: null, workFlowLevels: null, acceptedTermsOfService: null, clientHasNominations: null, isInNominationWorkFlow: null, isIntegratedWithCsrZone: null, workflows: [], isSso: false }; } async doRefresh () { const parsedToken = this.parseJwt(); const identifier = this.storage.clientIdentifier; let result: TokenResponse; try { result = await this.refresh.refreshToken( this.storage.jwt.refreshToken, +parsedToken.UserId, identifier ); if (result) { // we are good to go this.storage.jwt = result; } else { // our session has been terminated by another tab return this.handleExpiredSession(); } } catch (e) { this.appInsights.trackEvent('sso:debug', { event: 'REFRESH_FAILED_CATCH', identifier, userId: parsedToken.UserId, token: this.storage.jwt.refreshToken }); this.handleExpiredSession(); throw e; } } handlePlatformDomainTransfer () { const platformHostRename = sessionStorage.getItem('platformHostRename'); // Check to see if returning to local environment if (!!platformHostRename) { sessionStorage.removeItem('platformHostRename'); const newHost = decodeURIComponent(platformHostRename); if (!this.permittedPlatformTransferRedirects.includes(newHost)) { return false; } location.hostname = newHost; return true; } return false; } async platformAdminSsoExchange (): Promise { const token = this.extractTokenFromLocationAttribute(location.search); try { const response = await this.retrieval.getPlatformAdminToken( token.code, this.getIdentifier() ); const clientIdentifier = this.storage.clientIdentifier; this.storage.overrideClientIdentifier(clientIdentifier); this.tokenSignin(response); return null; } catch (err) { const e = err as HttpErrorResponse; this.logger.error(e); if (e?.error?.message === 'User does not have an account') { return BlackbaudSsoError.NoPlatformAccount; } else { return BlackbaudSsoError.Unknown; } } } }