import { TalyRoutingUtilsService } from '@allianz/taly-core';
import {
HashLocationStrategy,
Location as LocationService,
LocationStrategy,
PlatformLocation
} from '@angular/common';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Event, EventType, Router, RoutesRecognized } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { filter, mapTo, take } from 'rxjs/operators';
import {
LOCATION,
OAUTH_CONFIGURATIONS,
OauthConfig,
OauthConfigWithAppTokenPageIdOrInternalPath,
OauthConfigWithAppTokenUrl,
STORAGE
} from './symbols';
function extractAngularPathFromParentAppIfAny(angularPath: string, possibleJourneyPaths: string[]) {
const matchingIndex = possibleJourneyPaths.findIndex((element) => angularPath.endsWith(element));
if (matchingIndex !== -1) {
const matchingElement = possibleJourneyPaths[matchingIndex];
return angularPath.slice(0, angularPath.length - matchingElement.length);
}
return angularPath;
}
interface OauthTokenResponse {
access_token: string;
id_token: string;
refresh_token: string;
expires_in: number;
}
@Injectable()
export class OauthService {
private static RESPONSE_TYPE = 'code';
private static ACCESS_TOKEN_GRANT_TYPE = 'authorization_code';
private static REFRESH_TOKEN_GRANT_TYPE = 'refresh_token';
private static CLIENT_STATE_MARKER = 'CLIENT_STATE';
private static STORAGE_KEY_VERIFIER = 'storedCodeVerifier';
private static STORAGE_KEY_ACCESS_TOKEN = 'accessToken';
private static STORAGE_KEY_ID_TOKEN = 'idToken';
private static STORAGE_KEY_REFRESH_TOKEN = 'refreshToken';
private static STORAGE_KEY_EXPIRES_AT = 'expiresAt';
private static STORAGE_KEY_CURRENT_CONFIG = 'currentOauthConfig';
/**
* Default value that will be used when {@link TalyOauthConfig.refreshBeforeExpiryThreshold} is undefined.
*/
private static DEFAULT_REFRESH_BEFORE_EXPIRY_THRESHOLD = 300000;
// The angular compiler is not able to properly resolve these "ambient" types (Browser API)
// during a production build if they are injected.
// We use this workaround to be able to inject them without a type whilst keeping type safety
// see this issue for details: https://github.com/angular/angular/issues/20351
private _storage: Storage;
private _location: Location;
private oauthConfigurations!: OauthConfigWithAppTokenUrl[];
private performingTokenRefresh$ = new BehaviorSubject<boolean>(false);
constructor(
@Inject(OAUTH_CONFIGURATIONS) private initialOauthConfigurations: OauthConfig[],
@Inject(STORAGE) storage: Storage,
@Inject(LOCATION) location: Location,
private locationService: LocationService,
private http: HttpClient,
private router: Router,
private locationStrategy: LocationStrategy,
private platformLocation: PlatformLocation,
private talyRoutingUtilsService: TalyRoutingUtilsService
) {
this._storage = storage;
this._location = location;
this.assertConfigurationValidity();
this.setOauthConfigurations();
}
private setOauthConfigurations() {
const hasInternalPathConfig = this.initialOauthConfigurations.some(
(config) => 'appTokenPageIdOrInternalPath' in config
);
if (!hasInternalPathConfig) {
this.oauthConfigurations = this.initialOauthConfigurations as OauthConfigWithAppTokenUrl[];
return;
}
this.router.events
.pipe(
filter((event: Event) => event.type === EventType.RoutesRecognized),
take(1)
)
.subscribe((event: Event) => {
this.oauthConfigurations = this.initialOauthConfigurations.map((config) => {
if ('appTokenPageIdOrInternalPath' in config) {
const { appTokenPageIdOrInternalPath, ...rest } =
config as OauthConfigWithAppTokenPageIdOrInternalPath;
const configCopy: OauthConfigWithAppTokenUrl = {
...rest,
appTokenUrl: this.getAppTokenUrl(
appTokenPageIdOrInternalPath,
(event as RoutesRecognized).url
)
};
return configCopy;
}
return config;
});
});
}
// "routerUrl" is needed to extract the Angular routing path from a parent app in the module integration
private getAppTokenUrl(appTokenPageIdOrInternalPath: string, routerUrl: string) {
function removeSlashesIfAny(inputString: string) {
return inputString.replace(/^\/+|\/+$/g, '');
}
appTokenPageIdOrInternalPath = removeSlashesIfAny(appTokenPageIdOrInternalPath);
const routerUrlWithoutParams = routerUrl.split('?')[0];
const possibleJourneyPaths = this.talyRoutingUtilsService.getPossibleJourneyPaths();
const angularPathFromParent = extractAngularPathFromParentAppIfAny(
routerUrlWithoutParams,
possibleJourneyPaths
);
const appTokenRoutePath = LocationService.joinWithSlash(
angularPathFromParent,
appTokenPageIdOrInternalPath
);
let preparedExternalUrl = this.locationService.prepareExternalUrl(appTokenRoutePath);
// The `prepareExternalUrl` from PFE's `HashOnlyLocationStrategy` (used when the `useHashLocationStrategy` flag is set)
// doesn't add the base href to the URL. Therefore we do it manually
if (
this.locationStrategy instanceof HashLocationStrategy &&
!preparedExternalUrl.startsWith(this._location.pathname)
) {
preparedExternalUrl = LocationService.joinWithSlash(
this._location.pathname,
preparedExternalUrl
);
}
return `${this._location.origin}${preparedExternalUrl}`;
}
private assertConfigurationValidity() {
if (
new Set(this.initialOauthConfigurations.map((config) => config.name)).size !==
this.initialOauthConfigurations.length
) {
throw new Error(
'Duplicate OAuth configuration names. Ensure that each configuration name only appears once.'
);
}
if (this.initialOauthConfigurations.filter((config) => config.default).length > 1) {
throw new Error(
'Multiple OAuth configurations marked as default. Only one default configuration is allowed.'
);
}
}
/**
* @param oauthConfigName - if set, will use the given configuration. If left undefined, will use the default configuration.
*/
async authorize(oauthConfigName?: string, clientState?: Record<string, unknown>): Promise<void> {
const oauthConfig = this.getRelevantOauthConfigOrThrowError(oauthConfigName);
const authorizeURL = new URL(oauthConfig.authorizeUrl);
const state = this.generateEncodedState(clientState);
const codeVerifier = this.generateRandomString(128);
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
authorizeURL.searchParams.set('client_id', oauthConfig.clientId);
authorizeURL.searchParams.set('scope', oauthConfig.scope);
authorizeURL.searchParams.set('response_type', OauthService.RESPONSE_TYPE);
authorizeURL.searchParams.set('state', state);
authorizeURL.searchParams.set('redirect_uri', oauthConfig.appTokenUrl);
authorizeURL.searchParams.set('code_challenge', codeChallenge);
authorizeURL.searchParams.set('code_challenge_method', 'S256');
if (oauthConfig.acrValues) {
authorizeURL.searchParams.set('acr_values', oauthConfig.acrValues.join(' '));
}
this.setCodeVerifier(oauthConfig.name, codeVerifier);
this.setConfigurationNameForWhichLoginIsInProgress(oauthConfig.name);
this._location.replace(authorizeURL.toString());
}
/**
* @param oauthConfigName - if set, will use the given configuration. If left undefined, will use the default configuration.
* @returns a Promise that resolves whenever the refresh token request finished.
*/
refreshTokenIfRequired(oauthConfigName?: string): Promise<undefined> {
if (this.performingTokenRefresh$.value === true) {
return this.performingTokenRefresh$
.pipe(
filter((value) => value === false),
take(1),
mapTo(undefined)
)
.toPromise();
}
const oauthConfig = this.getRelevantOauthConfigOrThrowError(oauthConfigName);
const expiryThreshold =
oauthConfig.refreshBeforeExpiryThreshold ??
OauthService.DEFAULT_REFRESH_BEFORE_EXPIRY_THRESHOLD;
const needsTokenRenewal = this.getExpiresAt(oauthConfig.name) - expiryThreshold <= Date.now();
if (!needsTokenRenewal) {
return Promise.resolve(undefined);
}
this.performingTokenRefresh$.next(true);
return this.retrieveToken(oauthConfig.name, true)
.then(() => undefined)
.finally(() => this.performingTokenRefresh$.next(false));
}
/**
* @param oauthConfigName - if set, will use the given configuration. If left undefined, will use the default configuration.
* @param refresh - if set to true, the refresh flow will be performed
*/
async retrieveToken(
oauthConfigName?: string | undefined,
refresh = false
): Promise<OauthTokenResponse> {
const oauthConfig = this.getRelevantOauthConfigOrThrowError(
oauthConfigName ?? this.getConfigurationNameForWhichLoginIsInProgress() ?? undefined
);
const requestBody = refresh
? this.getRequestBodyForRefreshToken(oauthConfig)
: this.getRequestBodyForInitialAccessToken(oauthConfig);
const tokenResponse: OauthTokenResponse | undefined = await this.http
.post<OauthTokenResponse>(oauthConfig.tokenUrl, requestBody.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
.toPromise();
if (!tokenResponse) {
throw Error(`Can't retrieve Oauth token from ${oauthConfig.tokenUrl}`);
}
this.setAccessToken(oauthConfig.name, tokenResponse.access_token);
this.setIdentityToken(oauthConfig.name, tokenResponse.id_token);
if (tokenResponse.refresh_token) {
this.setRefreshToken(oauthConfig.name, tokenResponse.refresh_token);
}
this.setExpiresAt(oauthConfig.name, this.getExpiryTime(tokenResponse.expires_in));
this.clearConfigurationNameForWhichLoginIsInProgress();
return tokenResponse;
}
decodeClientState(stateString: string): Record<string, unknown> {
const decodedState = atob(decodeURIComponent(stateString));
let userState: Record<string, unknown> = {};
if (decodedState.startsWith(OauthService.CLIENT_STATE_MARKER)) {
userState = JSON.parse(decodedState.substr(OauthService.CLIENT_STATE_MARKER.length));
}
return userState;
}
private generateEncodedState(clientState?: Record<string, unknown>): string {
let state: string;
if (clientState) {
state = OauthService.CLIENT_STATE_MARKER + JSON.stringify(clientState);
} else {
state = this.generateRandomString(10);
}
return encodeURIComponent(btoa(state));
}
private getRequestBodyForInitialAccessToken(oauthConfig: OauthConfigWithAppTokenUrl): HttpParams {
const code = this.getQueryParameter('code');
if (!code) {
throw new Error(
'Error while creating the request body to fetch an access token. The query parameter "code" is missing from the URL.'
);
}
return new HttpParams()
.set('grant_type', OauthService.ACCESS_TOKEN_GRANT_TYPE)
.set('code', code)
.set('redirect_uri', oauthConfig.appTokenUrl)
.set('client_id', oauthConfig.clientId)
.set('code_verifier', this.getCodeVerifier(oauthConfig.name));
}
private getRequestBodyForRefreshToken(oauthConfig: OauthConfigWithAppTokenUrl): HttpParams {
return new HttpParams()
.set('grant_type', OauthService.REFRESH_TOKEN_GRANT_TYPE)
.set('redirect_uri', oauthConfig.appTokenUrl)
.set('client_id', oauthConfig.clientId)
.set('refresh_token', this.getRefreshToken(oauthConfig.name));
}
private getExpiryTime(expiresIn: number): number {
return Date.now() + expiresIn * 1000; // expiresIn comes in seconds, Date.now in milliseconds
}
/**
* @param oauthConfigName - if set, will use the given configuration. If left undefined, will use the default configuration.
*/
getIdentityToken(oauthConfigName?: string): string {
const oauthConfig = this.getRelevantOauthConfigOrThrowError(oauthConfigName);
const idToken = this._storage.getItem(
this.getPrefixedStorageKeyForOauthConfigName(
OauthService.STORAGE_KEY_ID_TOKEN,
oauthConfig.name
)
);
if (!idToken) {
throw new Error(`ID token is missing for configuration ${oauthConfig.name}!`);
}
return idToken;
}
private setIdentityToken(oauthConfigName: string, identityToken: string) {
this._storage.setItem(
this.getPrefixedStorageKeyForOauthConfigName(
OauthService.STORAGE_KEY_ID_TOKEN,
oauthConfigName
),
identityToken
);
}
/**
* @returns the access token from the first configuration that has one set
*/
getFirstAvailableAccessTokenForUrl(url: string): string | undefined {
const config = this.getFirstConfigWithAccessTokenResponsibleForUrl(url);
return this.getAccessToken(config?.name) ?? undefined;
}
getFirstConfigWithAccessTokenResponsibleForUrl(
url: string
): OauthConfigWithAppTokenUrl | undefined {
for (const oauthConfig of this.oauthConfigurations) {
if (!url.startsWith(oauthConfig.bffBaseUrl)) {
continue;
}
const tokenForConfig = this._storage.getItem(
this.getPrefixedStorageKeyForOauthConfigName(
OauthService.STORAGE_KEY_ACCESS_TOKEN,
oauthConfig.name
)
);
if (tokenForConfig) {
return oauthConfig;
}
}
return undefined;
}
getAccessToken(oauthConfigName?: string): string | null {
const oauthConfig = this.getRelevantOauthConfigOrThrowError(oauthConfigName);
const accessToken = this._storage.getItem(
this.getPrefixedStorageKeyForOauthConfigName(
OauthService.STORAGE_KEY_ACCESS_TOKEN,
oauthConfig.name
)
);
return accessToken;
}
private setAccessToken(oauthConfigName: string, accessToken: string) {
this._storage.setItem(
this.getPrefixedStorageKeyForOauthConfigName(
OauthService.STORAGE_KEY_ACCESS_TOKEN,
oauthConfigName
),
accessToken
);
}
private getRefreshToken(oauthConfigName?: string): string {
const oauthConfig = this.getRelevantOauthConfigOrThrowError(oauthConfigName);
const refreshToken = this._storage.getItem(
this.getPrefixedStorageKeyForOauthConfigName(
OauthService.STORAGE_KEY_REFRESH_TOKEN,
oauthConfig.name
)
);
if (!refreshToken) {
throw new Error(`Refresh token is missing for configuration ${oauthConfig.name}!`);
}
return refreshToken;
}
private setRefreshToken(oauthConfigName: string, refreshToken: string) {
this._storage.setItem(
this.getPrefixedStorageKeyForOauthConfigName(
OauthService.STORAGE_KEY_REFRESH_TOKEN,
oauthConfigName
),
refreshToken
);
}
private getCodeVerifier(oauthConfigName: string): string {
const codeVerifier = this._storage.getItem(
this.getPrefixedStorageKeyForOauthConfigName(
OauthService.STORAGE_KEY_VERIFIER,
oauthConfigName
)
);
if (!codeVerifier) {
throw new Error(`Code verifier is missing for configuration ${oauthConfigName}!`);
}
return codeVerifier;
}
private setCodeVerifier(oauthConfigName: string, verifier: string) {
this._storage.setItem(
this.getPrefixedStorageKeyForOauthConfigName(
OauthService.STORAGE_KEY_VERIFIER,
oauthConfigName
),
verifier
);
}
private getConfigurationNameForWhichLoginIsInProgress(): string | null {
return this._storage.getItem(OauthService.STORAGE_KEY_CURRENT_CONFIG);
}
private setConfigurationNameForWhichLoginIsInProgress(configName: string): void {
this._storage.setItem(OauthService.STORAGE_KEY_CURRENT_CONFIG, configName);
}
private clearConfigurationNameForWhichLoginIsInProgress(): void {
this._storage.removeItem(OauthService.STORAGE_KEY_CURRENT_CONFIG);
}
private getExpiresAt(oauthConfigName: string): number {
const storageEntry = this._storage.getItem(
this.getPrefixedStorageKeyForOauthConfigName(
OauthService.STORAGE_KEY_EXPIRES_AT,
oauthConfigName
)
);
if (!storageEntry) {
return 0;
}
return Number(storageEntry);
}
private setExpiresAt(oauthConfigName: string, expiresAt: number) {
this._storage.setItem(
this.getPrefixedStorageKeyForOauthConfigName(
OauthService.STORAGE_KEY_EXPIRES_AT,
oauthConfigName
),
expiresAt.toString()
);
}
private getPrefixedStorageKeyForOauthConfigName(storageKey: string, oauthConfigName: string) {
return `${oauthConfigName}.${storageKey}`;
}
/**
* Logs the user out of the session with one IdP configuration.
* @param oauthConfigName - if set, user will be logged out of this specific IdP's config. If not set, default IdP config will be used.
*/
async logout(oauthConfigName?: string): Promise<void> {
const oauthConfig = this.getRelevantOauthConfigOrThrowError(oauthConfigName);
try {
await this.endSession(oauthConfig);
} catch (err) {
console.error(`End session endpoint resulted in an error: ${err}`);
}
try {
await this.revokeToken(oauthConfig, 'access_token');
} catch (err) {
console.error(`Revoking the access token resulted in an error: ${err}`);
}
try {
await this.revokeToken(oauthConfig, 'refresh_token');
} catch (err) {
console.error(`Revoking refresh token resulted in an error: ${err}`);
}
for (const key of [
OauthService.STORAGE_KEY_VERIFIER,
OauthService.STORAGE_KEY_ACCESS_TOKEN,
OauthService.STORAGE_KEY_ID_TOKEN,
OauthService.STORAGE_KEY_REFRESH_TOKEN,
OauthService.STORAGE_KEY_EXPIRES_AT
]) {
this._storage.removeItem(this.getPrefixedStorageKeyForOauthConfigName(key, oauthConfig.name));
}
}
private async endSession(oauthConfig: OauthConfigWithAppTokenUrl): Promise<void> {
if (!oauthConfig.endSessionUrl) {
return;
}
await this.http
.get(
`${oauthConfig.endSessionUrl}?client_id=${
oauthConfig.clientId
}&id_token_hint=${this.getIdentityToken(oauthConfig.name)}`
)
.toPromise();
}
private async revokeToken(
oauthConfig: OauthConfigWithAppTokenUrl,
tokenType: 'access_token' | 'refresh_token'
): Promise<void> {
let token: string | null = null;
try {
token =
tokenType === 'access_token'
? this.getAccessToken(oauthConfig.name)
: this.getRefreshToken(oauthConfig.name);
} catch (error) {
// no token was set, so nothing to revoke
return;
}
if (!oauthConfig.revokeUrl || !token) {
return;
}
const requestBody = new HttpParams()
.set('token_type_hint', tokenType)
.set('token', token)
.set('client_id', oauthConfig.clientId);
await this.http
.post(oauthConfig.revokeUrl, requestBody.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
.toPromise();
}
private getRelevantOauthConfigOrThrowError(configName?: string): OauthConfigWithAppTokenUrl {
let relevantConfig: OauthConfigWithAppTokenUrl | undefined;
if (configName) {
relevantConfig = this.oauthConfigurations.find((config) => config.name === configName);
if (!relevantConfig) {
throw new Error(`No OAuth configuration found with name "${configName}"!`);
}
return relevantConfig;
}
if (this.oauthConfigurations.length === 1) {
return this.oauthConfigurations[0];
}
relevantConfig = this.oauthConfigurations.find((config) => config.default);
if (!relevantConfig) {
throw new Error(
'No default OAuth configuration found. Please either call the method with an explicit configuration name or define one OAuth configuration as default = true.'
);
}
return relevantConfig;
}
private generateRandomString(length: number): string {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < length; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
private async generateCodeChallenge(codeVerifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
/*
* We're first checking if there is an ongoing navigation that has query parameters.
* If there is no current navigation or if it is missing the query parameter
* we look at the current location's search parameters.
*/
private getQueryParameter(key: string): string | null {
let queryParameter: string | null = null;
const currentNavigation = this.router.getCurrentNavigation();
if (currentNavigation?.extractedUrl.queryParamMap) {
queryParameter = currentNavigation.extractedUrl.queryParamMap.get(key);
}
if (queryParameter === null) {
queryParameter = new URLSearchParams(this._location.search).get(key);
}
return queryParameter;
}
clearQueryParams() {
if (this.locationStrategy instanceof HashLocationStrategy) {
// The query params `code` and `state` are added in front of the hash
// and the PFE's HashOnlyLocationStrategy can't remove them automatically
// so we need to remove them manually
const queryParams = new URLSearchParams(this._location.search);
queryParams.delete('code');
queryParams.delete('state');
let newPath = '';
if (new Set(queryParams.keys()).size === 0) {
newPath = this._location.pathname + this._location.hash;
} else {
newPath = this._location.pathname + '?' + queryParams.toString() + this._location.hash;
}
// ATTENTION: HACK AHEAD!
// this next line will replace the current URL of the browser with the one
// without the query params but the LocationStrategy internally still has the
// old URL with the query params so we call the `getBaseHref()` method to update
// the internal state of the HashOnlyLocationStrategy in the next next line.
// This works just because of the specific way the HashOnlyLocationStrategy is built.
this.platformLocation.replaceState(null, '', newPath);
this.locationStrategy.getBaseHref();
} else {
// Why the setTimeout? We need to wait for the current navigation to finish
// before we can remove the query params
setTimeout(() => {
// stackoverflow reference for this solution: https://stackoverflow.com/a/52193044
this.router.navigate([], {
queryParamsHandling: 'merge',
queryParams: { code: null, state: null }
});
}, 0);
}
}
}