UNPKG

@axa-fr/oidc-client

Version:

OpenID Connect & OAuth authentication using native javascript only, compatible with angular, react, vue, svelte, next, etc.

498 lines (453 loc) 17.4 kB
import { startCheckSessionAsync as defaultStartCheckSessionAsync } from './checkSession.js'; import { CheckSessionIFrame } from './checkSessionIFrame.js'; import { base64urlOfHashOfASCIIEncodingAsync } from './crypto'; import { eventNames } from './events.js'; import { initSession } from './initSession.js'; import { getTabId, initWorkerAsync } from './initWorker.js'; import { activateServiceWorker } from './initWorkerOption'; import { defaultDemonstratingProofOfPossessionConfiguration, generateJwtDemonstratingProofOfPossessionAsync, } from './jwt'; import { tryKeepSessionAsync } from './keepSession'; import { ILOidcLocation, OidcLocation } from './location'; import { defaultLoginAsync, loginCallbackAsync } from './login.js'; import { destroyAsync, logoutAsync } from './logout.js'; import { TokenRenewMode, Tokens } from './parseTokens.js'; import { autoRenewTokens, renewTokensAndStartTimerAsync } from './renewTokens.js'; import { fetchFromIssuer } from './requests.js'; import { getParseQueryStringFromLocation } from './route-utils.js'; import defaultSilentLoginAsync from './silentLogin.js'; import timer from './timer.js'; import { AuthorityConfiguration, Fetch, OidcConfiguration, StringMap, TokenAutomaticRenewMode, } from './types.js'; import { userInfoAsync } from './user.js'; export const getFetchDefault = () => { return fetch; }; export interface OidcAuthorizationServiceConfigurationJson { check_session_iframe?: string; issuer: string; } export class OidcAuthorizationServiceConfiguration { private checkSessionIframe: string; private issuer: string; private authorizationEndpoint: string; private tokenEndpoint: string; private revocationEndpoint: string; private userInfoEndpoint: string; private endSessionEndpoint: string; constructor(request: any) { this.authorizationEndpoint = request.authorization_endpoint; this.tokenEndpoint = request.token_endpoint; this.revocationEndpoint = request.revocation_endpoint; this.userInfoEndpoint = request.userinfo_endpoint; this.checkSessionIframe = request.check_session_iframe; this.issuer = request.issuer; this.endSessionEndpoint = request.end_session_endpoint; } } const oidcDatabase = {}; const oidcFactory = (getFetch: () => Fetch, location: ILOidcLocation = new OidcLocation()) => (configuration: OidcConfiguration, name = 'default') => { if (oidcDatabase[name]) { return oidcDatabase[name]; } oidcDatabase[name] = new Oidc(configuration, name, getFetch, location); return oidcDatabase[name]; }; export type LoginCallback = { callbackPath: string; }; export type InternalLoginCallback = { callbackPath: string; state: string; parsedTokens: Tokens; scope: string; extras: StringMap; }; const loginCallbackWithAutoTokensRenewAsync = async (oidc): Promise<LoginCallback> => { const { parsedTokens, callbackPath, extras, scope } = await oidc.loginCallbackAsync(); oidc.timeoutId = autoRenewTokens(oidc, parsedTokens.expiresAt, extras, scope); return { callbackPath }; }; const getRandomInt = max => { return Math.floor(Math.random() * max); }; export class Oidc { public configuration: OidcConfiguration; public userInfo: null; public tokens?: Tokens; public events: Array<any>; public timeoutId: NodeJS.Timeout | number; public configurationName: string; public checkSessionIFrame: CheckSessionIFrame; public getFetch: () => Fetch; public location: ILOidcLocation; constructor( configuration: OidcConfiguration, configurationName = 'default', getFetch: () => Fetch, location: ILOidcLocation = new OidcLocation(), ) { let silent_login_uri = configuration.silent_login_uri; if (configuration.silent_redirect_uri && !configuration.silent_login_uri) { silent_login_uri = `${configuration.silent_redirect_uri.replace('-callback', '').replace('callback', '')}-login`; } let refresh_time_before_tokens_expiration_in_second = configuration.refresh_time_before_tokens_expiration_in_second ?? 120; if (refresh_time_before_tokens_expiration_in_second > 60) { refresh_time_before_tokens_expiration_in_second = refresh_time_before_tokens_expiration_in_second - Math.floor(Math.random() * 40); } this.location = location ?? new OidcLocation(); this.configuration = { ...configuration, silent_login_uri, token_automatic_renew_mode: configuration.token_automatic_renew_mode ?? TokenAutomaticRenewMode.AutomaticBeforeTokenExpiration, monitor_session: configuration.monitor_session ?? false, refresh_time_before_tokens_expiration_in_second, silent_login_timeout: configuration.silent_login_timeout ?? 12000, token_renew_mode: configuration.token_renew_mode ?? TokenRenewMode.access_token_or_id_token_invalid, demonstrating_proof_of_possession: configuration.demonstrating_proof_of_possession ?? false, authority_timeout_wellknowurl_in_millisecond: configuration.authority_timeout_wellknowurl_in_millisecond ?? 10000, logout_tokens_to_invalidate: configuration.logout_tokens_to_invalidate ?? [ 'access_token', 'refresh_token', ], service_worker_activate: configuration.service_worker_activate ?? activateServiceWorker, demonstrating_proof_of_possession_configuration: configuration.demonstrating_proof_of_possession_configuration ?? defaultDemonstratingProofOfPossessionConfiguration, preload_user_info: configuration.preload_user_info ?? false, }; this.getFetch = getFetch ?? getFetchDefault; this.configurationName = configurationName; this.tokens = null; this.userInfo = null; this.events = []; this.timeoutId = null; this.loginCallbackWithAutoTokensRenewAsync.bind(this); this.initAsync.bind(this); this.loginCallbackAsync.bind(this); this.subscribeEvents.bind(this); this.removeEventSubscription.bind(this); this.publishEvent.bind(this); this.destroyAsync.bind(this); this.logoutAsync.bind(this); this.renewTokensAsync.bind(this); this.initAsync(this.configuration.authority, this.configuration.authority_configuration); } subscribeEvents(func): string { const id = getRandomInt(9999999999999).toString(); this.events.push({ id, func }); return id; } removeEventSubscription(id): void { const newEvents = this.events.filter(e => e.id !== id); this.events = newEvents; } publishEvent(eventName, data) { this.events.forEach(event => { event.func(eventName, data); }); } static getOrCreate = (getFetch: () => Fetch, location: ILOidcLocation) => (configuration, name = 'default') => { return oidcFactory(getFetch, location)(configuration, name); }; static get(name = 'default') { const isInsideBrowser = typeof process === 'undefined'; if (!Object.prototype.hasOwnProperty.call(oidcDatabase, name) && isInsideBrowser) { throw Error(`OIDC library does seem initialized. Please checkout that you are using OIDC hook inside a <OidcProvider configurationName="${name}"></OidcProvider> component.`); } return oidcDatabase[name]; } static eventNames = eventNames; _silentLoginCallbackFromIFrame() { if (this.configuration.silent_redirect_uri && this.configuration.silent_login_uri) { const location = this.location; const queryParams = getParseQueryStringFromLocation(location.getCurrentHref()); window.parent.postMessage( `${this.configurationName}_oidc_tokens:${JSON.stringify({ tokens: this.tokens, sessionState: queryParams.session_state })}`, location.getOrigin(), ); } } _silentLoginErrorCallbackFromIFrame(exception = null) { if (this.configuration.silent_redirect_uri && this.configuration.silent_login_uri) { const location = this.location; const queryParams = getParseQueryStringFromLocation(location.getCurrentHref()); if (queryParams.error) { window.parent.postMessage( `${this.configurationName}_oidc_error:${JSON.stringify({ error: queryParams.error })}`, location.getOrigin(), ); } else { window.parent.postMessage( `${this.configurationName}_oidc_exception:${JSON.stringify({ error: exception == null ? '' : exception.toString() })}`, location.getOrigin(), ); } } } async silentLoginCallbackAsync() { try { await this.loginCallbackAsync(true); this._silentLoginCallbackFromIFrame(); } catch (exception) { console.error(exception); this._silentLoginErrorCallbackFromIFrame(exception); } } initPromise = null; async initAsync(authority: string, authorityConfiguration: AuthorityConfiguration) { if (this.initPromise !== null) { return this.initPromise; } const localFuncAsync = async () => { if (authorityConfiguration != null) { return new OidcAuthorizationServiceConfiguration({ authorization_endpoint: authorityConfiguration.authorization_endpoint, end_session_endpoint: authorityConfiguration.end_session_endpoint, revocation_endpoint: authorityConfiguration.revocation_endpoint, token_endpoint: authorityConfiguration.token_endpoint, userinfo_endpoint: authorityConfiguration.userinfo_endpoint, check_session_iframe: authorityConfiguration.check_session_iframe, issuer: authorityConfiguration.issuer, }); } const serviceWorker = await initWorkerAsync(this.configuration, this.configurationName); const storage = serviceWorker ? window.sessionStorage : null; return await fetchFromIssuer(this.getFetch())( authority, this.configuration.authority_time_cache_wellknowurl_in_second ?? 60 * 60, storage, this.configuration.authority_timeout_wellknowurl_in_millisecond, ); }; this.initPromise = localFuncAsync(); return this.initPromise.finally(() => { // in case if anything went wrong with the promise, we should reset the initPromise to null too // otherwise client can't re-init the OIDC client // as the promise is already fulfilled with rejected state, so could not ever reach this point again, // so that leads to infinite loop of calls, when client tries to re-init the OIDC client after error this.initPromise = null; }); } tryKeepExistingSessionPromise = null; async tryKeepExistingSessionAsync(): Promise<boolean> { if (this.tryKeepExistingSessionPromise !== null) { return this.tryKeepExistingSessionPromise; } this.tryKeepExistingSessionPromise = tryKeepSessionAsync(this); return this.tryKeepExistingSessionPromise.finally(() => { this.tryKeepExistingSessionPromise = null; }); } async startCheckSessionAsync( checkSessionIFrameUri, clientId, sessionState, isSilentSignin = false, ) { await defaultStartCheckSessionAsync(this, oidcDatabase, this.configuration)( checkSessionIFrameUri, clientId, sessionState, isSilentSignin, ); } loginPromise: Promise<unknown> = null; async loginAsync( callbackPath: string = undefined, extras: StringMap = null, isSilentSignin = false, scope: string = undefined, silentLoginOnly = false, ) { if (this.logoutPromise) { await this.logoutPromise; } if (this.loginPromise !== null) { return this.loginPromise; } if (silentLoginOnly) { this.loginPromise = defaultSilentLoginAsync( window, this.configurationName, this.configuration, this.publishEvent.bind(this), this, )(extras, scope); } else { this.loginPromise = defaultLoginAsync( this.configurationName, this.configuration, this.publishEvent.bind(this), this.initAsync.bind(this), this.location, )(callbackPath, extras, isSilentSignin, scope); } return this.loginPromise.finally(() => { this.loginPromise = null; }); } loginCallbackPromise: Promise<any> = null; async loginCallbackAsync(isSilenSignin = false) { if (this.loginCallbackPromise !== null) { return this.loginCallbackPromise; } const loginCallbackLocalAsync = async (): Promise<InternalLoginCallback> => { const response = await loginCallbackAsync(this)(isSilenSignin); // @ts-ignore const parsedTokens = response.tokens; // @ts-ignore this.tokens = parsedTokens; const serviceWorker = await initWorkerAsync(this.configuration, this.configurationName); if (!serviceWorker) { const session = initSession(this.configurationName, this.configuration.storage); session.setTokens(parsedTokens); } this.publishEvent(Oidc.eventNames.token_acquired, parsedTokens); if (this.configuration.preload_user_info) { await this.userInfoAsync(); } // @ts-ignore return { parsedTokens, state: response.state, callbackPath: response.callbackPath, scope: response.scope, extras: response.extras, }; }; this.loginCallbackPromise = loginCallbackLocalAsync(); return this.loginCallbackPromise.finally(() => { this.loginCallbackPromise = null; }); } async generateDemonstrationOfProofOfPossessionAsync( accessToken: string, url: string, method: string, extras: StringMap = {}, ): Promise<string> { const configuration = this.configuration; const claimsExtras = { ath: await base64urlOfHashOfASCIIEncodingAsync(accessToken), ...extras, }; const serviceWorker = await initWorkerAsync(configuration, this.configurationName); if (serviceWorker) { return `DPOP_SECURED_BY_OIDC_SERVICE_WORKER_${this.configurationName}#tabId=${getTabId(this.configurationName)}`; } const session = initSession(this.configurationName, configuration.storage); const jwk = await session.getDemonstratingProofOfPossessionJwkAsync(); const demonstratingProofOfPossessionNonce = session.getDemonstratingProofOfPossessionNonce(); if (demonstratingProofOfPossessionNonce) { claimsExtras['nonce'] = demonstratingProofOfPossessionNonce; } return await generateJwtDemonstratingProofOfPossessionAsync(window)( configuration.demonstrating_proof_of_possession_configuration, )(jwk, method, url, claimsExtras); } loginCallbackWithAutoTokensRenewPromise: Promise<LoginCallback> = null; loginCallbackWithAutoTokensRenewAsync(): Promise<LoginCallback> { if (this.loginCallbackWithAutoTokensRenewPromise !== null) { return this.loginCallbackWithAutoTokensRenewPromise; } this.loginCallbackWithAutoTokensRenewPromise = loginCallbackWithAutoTokensRenewAsync(this); return this.loginCallbackWithAutoTokensRenewPromise.finally(() => { this.loginCallbackWithAutoTokensRenewPromise = null; }); } userInfoPromise: Promise<any> = null; userInfoAsync(noCache = false, demonstrating_proof_of_possession = false) { if (this.userInfoPromise !== null) { return this.userInfoPromise; } this.userInfoPromise = userInfoAsync(this)(noCache, demonstrating_proof_of_possession); return this.userInfoPromise.finally(() => { this.userInfoPromise = null; }); } renewTokensPromise: Promise<any> = null; async renewTokensAsync(extras: StringMap = null, scope: string = null) { if (this.renewTokensPromise !== null) { return this.renewTokensPromise; } if (!this.timeoutId) { return; } timer.clearTimeout(this.timeoutId); // @ts-ignore this.renewTokensPromise = renewTokensAndStartTimerAsync(this, true, extras, scope); return this.renewTokensPromise.finally(() => { this.renewTokensPromise = null; }); } async destroyAsync(status) { return await destroyAsync(this)(status); } async logoutSameTabAsync(clientId: string, sub: any) { // @ts-ignore if ( this.configuration.monitor_session && this.configuration.client_id === clientId && sub && this.tokens && this.tokens.idTokenPayload && this.tokens.idTokenPayload.sub === sub ) { await this.destroyAsync('LOGGED_OUT'); this.publishEvent(eventNames.logout_from_same_tab, { mmessage: 'SessionMonitor', sub }); } } async logoutOtherTabAsync(clientId: string, sub: any) { // @ts-ignore if ( this.configuration.monitor_session && this.configuration.client_id === clientId && sub && this.tokens && this.tokens.idTokenPayload && this.tokens.idTokenPayload.sub === sub ) { await this.destroyAsync('LOGGED_OUT'); this.publishEvent(eventNames.logout_from_another_tab, { message: 'SessionMonitor', sub }); } } logoutPromise: Promise<void> = null; async logoutAsync( callbackPathOrUrl: string | null | undefined = undefined, extras: StringMap = null, ) { if (this.logoutPromise) { return this.logoutPromise; } this.logoutPromise = logoutAsync( this, oidcDatabase, this.getFetch(), console, this.location, )(callbackPathOrUrl, extras); return this.logoutPromise.finally(() => { this.logoutPromise = null; }); } } export default Oidc;