UNPKG

@brionmario-experimental/asgardeo-auth-spa

Version:

Asgardeo Auth SPA SDK to be used in Single-Page Applications.

867 lines (758 loc) 29.5 kB
/** * Copyright (c) 2020, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. * * WSO2 Inc. licenses this file to you 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. */ import { AUTHORIZATION_CODE, AsgardeoAuthClient, AsgardeoAuthException, AuthClientConfig, AuthenticationUtils, BasicUserInfo, CryptoHelper, CustomGrantConfig, DecodedIDTokenPayload, FetchResponse, GetAuthURLConfig, OIDCEndpoints, ResponseMode, SESSION_STATE, STATE, Store } from "@asgardeo/auth-js"; import { DISABLE_HTTP_HANDLER, ENABLE_HTTP_HANDLER, GET_AUTH_URL, GET_BASIC_USER_INFO, GET_CONFIG_DATA, GET_CRYPTO_HELPER, GET_DECODED_IDP_ID_TOKEN, GET_DECODED_ID_TOKEN, GET_ID_TOKEN, GET_OIDC_SERVICE_ENDPOINTS, GET_SIGN_OUT_URL, HTTP_REQUEST, HTTP_REQUEST_ALL, INIT, IS_AUTHENTICATED, REFRESH_ACCESS_TOKEN, REQUEST_ACCESS_TOKEN, REQUEST_CUSTOM_GRANT, REQUEST_FINISH, REQUEST_START, REQUEST_SUCCESS, REVOKE_ACCESS_TOKEN, SET_SESSION_STATE, SIGN_OUT, SILENT_SIGN_IN_STATE, START_AUTO_REFRESH_TOKEN, Storage, UPDATE_CONFIG } from "../constants"; import { AuthenticationHelper, SPAHelper, SessionManagementHelper } from "../helpers"; import { AuthorizationInfo, AuthorizationResponse, HttpClient, HttpError, HttpRequestConfig, HttpResponse, Message, ResponseMessage, WebWorkerClientConfig, WebWorkerClientInterface } from "../models"; import { SPACustomGrantConfig } from "../models/request-custom-grant"; import { LocalStore, MemoryStore, SessionStore } from "../stores"; import { SPAUtils } from "../utils"; import { SPACryptoUtils } from "../utils/crypto-utils"; const initiateStore = (store: Storage | undefined): Store => { switch (store) { case Storage.LocalStorage: return new LocalStore(); case Storage.SessionStorage: return new SessionStore(); case Storage.BrowserMemory: return new MemoryStore(); default: return new SessionStore(); } }; export const WebWorkerClient = async ( instanceID: number, config: AuthClientConfig<WebWorkerClientConfig>, webWorker: new () => Worker, getAuthHelper: ( authClient: AsgardeoAuthClient<WebWorkerClientConfig>, spaHelper: SPAHelper<WebWorkerClientConfig> ) => AuthenticationHelper<WebWorkerClientConfig> ): Promise<WebWorkerClientInterface> => { /** * HttpClient handlers */ let httpClientHandlers: HttpClient; /** * API request time out. */ const _requestTimeout: number = config?.requestTimeout ?? 60000; let _isHttpHandlerEnabled: boolean = true; let _getSignOutURLFromSessionStorage: boolean = false; const _store: Store = initiateStore(config.storage); const _cryptoUtils: SPACryptoUtils = new SPACryptoUtils(); const _authenticationClient = new AsgardeoAuthClient<WebWorkerClientConfig>(); await _authenticationClient.initialize(config, _store, _cryptoUtils, instanceID); const _spaHelper = new SPAHelper<WebWorkerClientConfig>(_authenticationClient); const _sessionManagementHelper = await SessionManagementHelper( async () => { const message: Message<string> = { type: SIGN_OUT }; try { const signOutURL = await communicate<string, string>(message); return signOutURL; } catch { return SPAUtils.getSignOutURL(config.clientID, instanceID); } }, config.storage, (sessionState: string) => setSessionState(sessionState) ); const _authenticationHelper: AuthenticationHelper<WebWorkerClientConfig> = getAuthHelper(_authenticationClient, _spaHelper); const worker: Worker = new webWorker(); const communicate = <T, R>(message: Message<T>): Promise<R> => { const channel = new MessageChannel(); worker.postMessage(message, [ channel.port2 ]); return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject( new AsgardeoAuthException( "SPA-WEB_WORKER_CLIENT-COM-TO01", "Operation timed out.", "No response was received from the web worker for " + _requestTimeout / 1000 + " since dispatching the request" ) ); }, _requestTimeout); return (channel.port1.onmessage = ({ data }: { data: ResponseMessage<string>; }) => { clearTimeout(timer); channel.port1.close(); channel.port2.close(); if (data?.success) { const responseData = data?.data ? JSON.parse(data?.data) : null; if (data?.blob) { responseData.data = data?.blob; } resolve(responseData); } else { reject(data.error ? JSON.parse(data.error) : null); } }); }); }; /** * Allows using custom grant types. * * @param {CustomGrantRequestParams} requestParams Request Parameters. * * @returns {Promise<HttpResponse|boolean>} A promise that resolves with a boolean value or the request * response if the the `returnResponse` attribute in the `requestParams` object is set to `true`. */ const requestCustomGrant = (requestParams: SPACustomGrantConfig): Promise<FetchResponse | BasicUserInfo> => { const message: Message<CustomGrantConfig> = { data: requestParams, type: REQUEST_CUSTOM_GRANT }; return communicate<CustomGrantConfig, FetchResponse | BasicUserInfo>(message) .then((response) => { if (requestParams.preventSignOutURLUpdate) { _getSignOutURLFromSessionStorage = true; } return Promise.resolve(response); }) .catch((error) => { return Promise.reject(error); }); }; /** * * Send the API request to the web worker and returns the response. * * @param {HttpRequestConfig} config The Http Request Config object * * @returns {Promise<HttpResponse>} A promise that resolves with the response data. */ const httpRequest = <T = any>(config: HttpRequestConfig): Promise<HttpResponse<T>> => { /** * * Currently FormData is not supported to send to a web worker * * Below workaround will represent FormData object as a JSON. * This workaround will not be needed once FormData object is made cloneable * Reference: https://github.com/whatwg/xhr/issues/55 */ if(config?.data && config?.data instanceof FormData) { config.data = { ...Object.fromEntries(config?.data.entries()), formData: true }; } const message: Message<HttpRequestConfig> = { data: config, type: HTTP_REQUEST }; return communicate<HttpRequestConfig, HttpResponse<T>>(message) .then((response) => { return Promise.resolve(response); }) .catch(async (error) => { if (_isHttpHandlerEnabled) { if (typeof httpClientHandlers.requestErrorCallback === "function") { await httpClientHandlers.requestErrorCallback(error); } if (typeof httpClientHandlers.requestFinishCallback === "function") { httpClientHandlers.requestFinishCallback(); } } return Promise.reject(error); }); }; /** * * Send multiple API requests to the web worker and returns the response. * Similar `axios.spread` in functionality. * * @param {HttpRequestConfig[]} configs - The Http Request Config object * * @returns {Promise<HttpResponse<T>[]>} A promise that resolves with the response data. */ const httpRequestAll = <T = any>(configs: HttpRequestConfig[]): Promise<HttpResponse<T>[]> => { const message: Message<HttpRequestConfig[]> = { data: configs, type: HTTP_REQUEST_ALL }; return communicate<HttpRequestConfig[], HttpResponse<T>[]>(message) .then((response) => { return Promise.resolve(response); }) .catch(async (error) => { if (_isHttpHandlerEnabled) { if (typeof httpClientHandlers.requestErrorCallback === "function") { await httpClientHandlers.requestErrorCallback(error); } if (typeof httpClientHandlers.requestFinishCallback === "function") { httpClientHandlers.requestFinishCallback(); } } return Promise.reject(error); }); }; const enableHttpHandler = (): Promise<boolean> => { const message: Message<null> = { type: ENABLE_HTTP_HANDLER }; return communicate<null, null>(message) .then(() => { _isHttpHandlerEnabled = true; return Promise.resolve(true); }) .catch((error) => { return Promise.reject(error); }); }; const disableHttpHandler = (): Promise<boolean> => { const message: Message<null> = { type: DISABLE_HTTP_HANDLER }; return communicate<null, null>(message) .then(() => { _isHttpHandlerEnabled = false; return Promise.resolve(true); }) .catch((error) => { return Promise.reject(error); }); }; /** * Initializes the object with authentication parameters. * * @param {ConfigInterface} config The configuration object. * * @returns {Promise<boolean>} Promise that resolves when initialization is successful. * */ const initialize = (): Promise<boolean> => { if (!httpClientHandlers) { httpClientHandlers = { requestErrorCallback: () => Promise.resolve(), requestFinishCallback: () => null, requestStartCallback: () => null, requestSuccessCallback: () => null }; } worker.onmessage = ({ data }) => { switch (data.type) { case REQUEST_FINISH: httpClientHandlers?.requestFinishCallback && httpClientHandlers?.requestFinishCallback(); break; case REQUEST_START: httpClientHandlers?.requestStartCallback && httpClientHandlers?.requestStartCallback(); break; case REQUEST_SUCCESS: httpClientHandlers?.requestSuccessCallback && httpClientHandlers?.requestSuccessCallback(data.data ? JSON.parse(data.data) : null); break; } }; const message: Message<AuthClientConfig<WebWorkerClientConfig>> = { data: config, type: INIT }; return communicate<AuthClientConfig<WebWorkerClientConfig>, null>(message) .then(() => { return Promise.resolve(true); }) .catch((error) => { return Promise.reject(error); }); }; const setSessionState = (sessionState: string | null): Promise<void> => { const message: Message<string | null> = { data: sessionState, type: SET_SESSION_STATE }; return communicate<string | null, void>(message); }; const startAutoRefreshToken = (): Promise<void> => { const message: Message<null> = { type: START_AUTO_REFRESH_TOKEN }; return communicate<null, void>(message); }; const checkSession = async (): Promise<void> => { const oidcEndpoints: OIDCEndpoints = await getOIDCServiceEndpoints(); const config: AuthClientConfig<WebWorkerClientConfig> = await getConfigData(); _authenticationHelper.initializeSessionManger( config, oidcEndpoints, async () => (await getBasicUserInfo()).sessionState, async (params?: GetAuthURLConfig): Promise<string> => (await getAuthorizationURL(params)).authorizationURL, _sessionManagementHelper ); }; const constructSilentSignInUrl = async ( additionalParams: Record<string, string | boolean> = {} ): Promise<string> => { const config: AuthClientConfig<WebWorkerClientConfig> = await getConfigData(); const message: Message<GetAuthURLConfig> = { data: { prompt: "none", state: SILENT_SIGN_IN_STATE, ...additionalParams }, type: GET_AUTH_URL }; const response: AuthorizationResponse = await communicate<GetAuthURLConfig, AuthorizationResponse>(message); const pkceKey: string = AuthenticationUtils.extractPKCEKeyFromStateParam( new URL(response.authorizationURL).searchParams.get(STATE) ?? "" ); response.pkce && config.enablePKCE && SPAUtils.setPKCE(pkceKey, response.pkce); const urlString: string = response.authorizationURL; // Replace form_post with query const urlObject = new URL(urlString); urlObject.searchParams.set("response_mode", "query"); const url: string = urlObject.toString(); return url; } /** * This method checks if there is an active user session in the server by sending a prompt none request. * If the user is signed in, this method sends a token request. Returns false otherwise. * * @return {Promise<BasicUserInfo|boolean} Returns a Promise that resolves with the BasicUserInfo * if the user is signed in or with `false` if there is no active user session in the server. */ const trySignInSilently = async ( additionalParams?: Record<string, string | boolean>, tokenRequestConfig?: { params: Record<string, unknown> } ): Promise<BasicUserInfo | boolean> => { return await _authenticationHelper.trySignInSilently( constructSilentSignInUrl, requestAccessToken, _sessionManagementHelper, additionalParams, tokenRequestConfig ); }; /** * Generates an authorization URL. * * @param {GetAuthURLConfig} params Authorization URL params. * @returns {Promise<string>} Authorization URL. */ const getAuthorizationURL = async (params?: GetAuthURLConfig): Promise<AuthorizationResponse> => { const config: AuthClientConfig<WebWorkerClientConfig> = await getConfigData(); const message: Message<GetAuthURLConfig> = { data: params, type: GET_AUTH_URL }; return communicate<GetAuthURLConfig, AuthorizationResponse>(message).then( async (response: AuthorizationResponse) => { if (response.pkce && config.enablePKCE) { const pkceKey: string = AuthenticationUtils.extractPKCEKeyFromStateParam( new URL(response.authorizationURL).searchParams.get(STATE) ?? "" ); SPAUtils.setPKCE(pkceKey, response.pkce); } return Promise.resolve(response); } ); }; const requestAccessToken = async ( resolvedAuthorizationCode: string, resolvedSessionState: string, resolvedState: string, tokenRequestConfig?: { params: Record<string, unknown> } ): Promise<BasicUserInfo> => { const config: AuthClientConfig<WebWorkerClientConfig> = await getConfigData(); const pkceKey: string = AuthenticationUtils.extractPKCEKeyFromStateParam(resolvedState); const message: Message<AuthorizationInfo> = { data: { code: resolvedAuthorizationCode, pkce: config.enablePKCE ? SPAUtils.getPKCE(pkceKey) : undefined, sessionState: resolvedSessionState, state: resolvedState, tokenRequestConfig }, type: REQUEST_ACCESS_TOKEN }; config.enablePKCE && SPAUtils.removePKCE(pkceKey); return communicate<AuthorizationInfo, BasicUserInfo>(message) .then((response) => { const message: Message<null> = { type: GET_SIGN_OUT_URL }; return communicate<null, string>(message) .then((url: string) => { SPAUtils.setSignOutURL(url, config.clientID, instanceID); // Enable OIDC Sessions Management only if it is set to true in the config. if (config.enableOIDCSessionManagement) { checkSession(); } startAutoRefreshToken(); return Promise.resolve(response); }) .catch((error) => { return Promise.reject(error); }); }) .catch((error) => { return Promise.reject(error); }); }; const shouldStopAuthn = async (): Promise<boolean> => { return await _sessionManagementHelper.receivePromptNoneResponse( async (sessionState: string | null) => { return setSessionState(sessionState); } ); } const tryRetrievingUserInfo = async (): Promise<BasicUserInfo | undefined> => { if (await isAuthenticated()) { await startAutoRefreshToken(); // Enable OIDC Sessions Management only if it is set to true in the config. if (config.enableOIDCSessionManagement) { checkSession(); } return getBasicUserInfo(); } } /** * Initiates the authentication flow. * * @returns {Promise<UserInfo>} A promise that resolves when authentication is successful. */ const signIn = async ( params?: GetAuthURLConfig, authorizationCode?: string, sessionState?: string, state?: string, tokenRequestConfig?: { params: Record<string, unknown> } ): Promise<BasicUserInfo> => { const basicUserInfo = await _authenticationHelper.handleSignIn( shouldStopAuthn, checkSession, tryRetrievingUserInfo ); if(basicUserInfo) { return basicUserInfo; } else { let resolvedAuthorizationCode: string; let resolvedSessionState: string; let resolvedState: string; if (config?.responseMode === ResponseMode.formPost && authorizationCode) { resolvedAuthorizationCode = authorizationCode; resolvedSessionState = sessionState ?? ""; resolvedState = state ?? ""; } else { resolvedAuthorizationCode = new URL(window.location.href).searchParams.get(AUTHORIZATION_CODE) ?? ""; resolvedSessionState = new URL(window.location.href).searchParams.get(SESSION_STATE) ?? ""; resolvedState = new URL(window.location.href).searchParams.get(STATE) ?? ""; SPAUtils.removeAuthorizationCode(); } if (resolvedAuthorizationCode && resolvedState) { return requestAccessToken(resolvedAuthorizationCode, resolvedSessionState, resolvedState, tokenRequestConfig); } return getAuthorizationURL(params) .then(async (response: AuthorizationResponse) => { location.href = response.authorizationURL; await SPAUtils.waitTillPageRedirect(); return Promise.resolve({ allowedScopes: "", displayName: "", email: "", sessionState: "", sub: "", tenantDomain: "", username: "" }); }) .catch((error) => { return Promise.reject(error); }); } }; /** * Initiates the sign out flow. * * @returns {Promise<boolean>} A promise that resolves when sign out is completed. */ const signOut = (): Promise<boolean> => { return new Promise((resolve, reject) => { if (!_getSignOutURLFromSessionStorage) { const message: Message<null> = { type: SIGN_OUT }; return communicate<null, string>(message) .then(async (response) => { window.location.href = response; await SPAUtils.waitTillPageRedirect(); return resolve(true); }) .catch((error) => { return reject(error); }); } else { window.location.href = SPAUtils.getSignOutURL(config.clientID, instanceID); SPAUtils.waitTillPageRedirect().then(() => { return Promise.resolve(true); }); } }) }; /** * Revokes token. * * @returns {Promise<boolean>} A promise that resolves when revoking is completed. */ const revokeAccessToken = (): Promise<boolean> => { const message: Message<null> = { type: REVOKE_ACCESS_TOKEN }; return communicate<null, boolean>(message) .then((response) => { _sessionManagementHelper.reset(); return Promise.resolve(response); }) .catch((error) => { return Promise.reject(error); }); }; const getOIDCServiceEndpoints = (): Promise<OIDCEndpoints> => { const message: Message<null> = { type: GET_OIDC_SERVICE_ENDPOINTS }; return communicate<null, OIDCEndpoints>(message) .then((response) => { return Promise.resolve(response); }) .catch((error) => { return Promise.reject(error); }); }; const getConfigData = (): Promise<AuthClientConfig<WebWorkerClientConfig>> => { const message: Message<null> = { type: GET_CONFIG_DATA }; return communicate<null, AuthClientConfig<WebWorkerClientConfig>>(message) .then((response) => { return Promise.resolve(response); }) .catch((error) => { return Promise.reject(error); }); }; const getBasicUserInfo = (): Promise<BasicUserInfo> => { const message: Message<null> = { type: GET_BASIC_USER_INFO }; return communicate<null, BasicUserInfo>(message) .then((response) => { return Promise.resolve(response); }) .catch((error) => { return Promise.reject(error); }); }; const getDecodedIDToken = (): Promise<DecodedIDTokenPayload> => { const message: Message<null> = { type: GET_DECODED_ID_TOKEN }; return communicate<null, DecodedIDTokenPayload>(message) .then((response) => { return Promise.resolve(response); }) .catch((error) => { return Promise.reject(error); }); }; const getDecodedIDPIDToken = (): Promise<DecodedIDTokenPayload> => { const message: Message<null> = { type: GET_DECODED_IDP_ID_TOKEN }; return communicate<null, DecodedIDTokenPayload>(message) .then((response) => { return Promise.resolve(response); }) .catch((error) => { return Promise.reject(error); }); }; const getCryptoHelper = (): Promise<CryptoHelper> => { const message: Message<null> = { type: GET_CRYPTO_HELPER }; return communicate<null, CryptoHelper>(message) .then((response) => { return Promise.resolve(response); }) .catch((error) => { return Promise.reject(error); }); }; const getIDToken = (): Promise<string> => { const message: Message<null> = { type: GET_ID_TOKEN }; return communicate<null, string>(message) .then((response) => { return Promise.resolve(response); }) .catch((error) => { return Promise.reject(error); }); }; const isAuthenticated = (): Promise<boolean> => { const message: Message<null> = { type: IS_AUTHENTICATED }; return communicate<null, boolean>(message) .then((response) => { return Promise.resolve(response); }) .catch((error) => { return Promise.reject(error); }); }; const refreshAccessToken = (): Promise<BasicUserInfo> => { const message: Message<null> = { type: REFRESH_ACCESS_TOKEN }; return communicate<null, BasicUserInfo>(message); }; const setHttpRequestSuccessCallback = (callback: (response: HttpResponse) => void): void => { if (callback && typeof callback === "function") { httpClientHandlers.requestSuccessCallback = callback; } }; const setHttpRequestErrorCallback = (callback: (response: HttpError) => void | Promise<void>): void => { if (callback && typeof callback === "function") { httpClientHandlers.requestErrorCallback = callback; } }; const setHttpRequestStartCallback = (callback: () => void): void => { if (callback && typeof callback === "function") { httpClientHandlers.requestStartCallback = callback; } }; const setHttpRequestFinishCallback = (callback: () => void): void => { if (callback && typeof callback === "function") { httpClientHandlers.requestFinishCallback = callback; } }; const updateConfig = async (newConfig: Partial<AuthClientConfig<WebWorkerClientConfig>>): Promise<void> => { const existingConfig = await getConfigData(); const isCheckSessionIframeDifferent: boolean = !( existingConfig && existingConfig.endpoints && existingConfig.endpoints.checkSessionIframe && newConfig && newConfig.endpoints && newConfig.endpoints.checkSessionIframe && existingConfig.endpoints.checkSessionIframe === newConfig.endpoints.checkSessionIframe ); const config = { ...existingConfig, ...newConfig }; const message: Message<Partial<AuthClientConfig<WebWorkerClientConfig>>> = { data: config, type: UPDATE_CONFIG }; await communicate<Partial<AuthClientConfig<WebWorkerClientConfig>>, void>(message); // Re-initiates check session if the check session endpoint is updated. if (config.enableOIDCSessionManagement && isCheckSessionIframeDifferent) { _sessionManagementHelper.reset(); checkSession(); } }; return { disableHttpHandler, enableHttpHandler, getBasicUserInfo, getConfigData, getCryptoHelper, getDecodedIDPIDToken, getDecodedIDToken, getIDToken, getOIDCServiceEndpoints, httpRequest, httpRequestAll, initialize, isAuthenticated, refreshAccessToken, requestCustomGrant, revokeAccessToken, setHttpRequestErrorCallback, setHttpRequestFinishCallback, setHttpRequestStartCallback, setHttpRequestSuccessCallback, signIn, signOut, trySignInSilently, updateConfig }; };