@brionmario-experimental/asgardeo-auth-spa
Version:
Asgardeo Auth SPA SDK to be used in Single-Page Applications.
727 lines (638 loc) • 28.5 kB
text/typescript
/**
* Copyright (c) 2022-2024, 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 {
AsgardeoAuthClient,
AsgardeoAuthException,
AuthClientConfig,
AuthenticationUtils,
BasicUserInfo,
CryptoHelper,
CustomGrantConfig,
DataLayer,
DecodedIDTokenPayload,
FetchResponse,
GetAuthURLConfig,
OIDCEndpoints,
TokenResponse
} from "@asgardeo/auth-js";
import { SPAHelper } from "./spa-helper";
import {
ACCESS_TOKEN_INVALID,
CHECK_SESSION_SIGNED_IN,
CHECK_SESSION_SIGNED_OUT,
CUSTOM_GRANT_CONFIG,
ERROR,
ERROR_DESCRIPTION,
PROMPT_NONE_IFRAME,
REFRESH_ACCESS_TOKEN_ERR0R,
RP_IFRAME,
Storage
} from "../constants";
import {
AuthorizationInfo,
HttpClientInstance,
HttpError,
HttpRequestConfig,
HttpRequestInterface,
HttpResponse,
MainThreadClientConfig,
Message,
SessionManagementHelperInterface,
WebWorkerClientConfig
} from "../models";
import { SPACustomGrantConfig } from "../models/request-custom-grant";
import { SPAUtils } from "../utils";
export class AuthenticationHelper<
T extends MainThreadClientConfig | WebWorkerClientConfig
> {
protected _authenticationClient: AsgardeoAuthClient<T>;
protected _dataLayer: DataLayer<T>;
protected _spaHelper: SPAHelper<T>;
protected _instanceID: number;
protected _isTokenRefreshing: boolean;
public constructor(
authClient: AsgardeoAuthClient<T>,
spaHelper: SPAHelper<T>
) {
this._authenticationClient = authClient;
this._dataLayer = this._authenticationClient.getDataLayer();
this._spaHelper = spaHelper;
this._instanceID = this._authenticationClient.getInstanceID();
this._isTokenRefreshing = false;
}
public enableHttpHandler(httpClient: HttpClientInstance): void {
httpClient?.enableHandler && httpClient.enableHandler();
}
public disableHttpHandler (httpClient: HttpClientInstance): void {
httpClient?.disableHandler && httpClient.disableHandler();
}
public initializeSessionManger(
config: AuthClientConfig<T>,
oidcEndpoints: OIDCEndpoints,
getSessionState: () => Promise<string>,
getAuthzURL: (params?: GetAuthURLConfig) => Promise<string>,
sessionManagementHelper: SessionManagementHelperInterface
): void {
sessionManagementHelper.initialize(
config.clientID,
oidcEndpoints.checkSessionIframe ?? "",
getSessionState,
config.checkSessionInterval ?? 3,
config.sessionRefreshInterval ?? 300,
config.signInRedirectURL,
getAuthzURL
);
}
public async requestCustomGrant(
config: SPACustomGrantConfig,
enableRetrievingSignOutURLFromSession?: (config: SPACustomGrantConfig) => void
): Promise<BasicUserInfo | FetchResponse> {
let useDefaultEndpoint = true;
let matches = false;
// If the config does not contains a token endpoint, default token endpoint will be used.
if (config?.tokenEndpoint) {
useDefaultEndpoint = false;
for (const baseUrl of [
...((await this._dataLayer.getConfigData())?.resourceServerURLs ?? []),
(config as any).baseUrl
]) {
if (baseUrl && config.tokenEndpoint?.startsWith(baseUrl)) {
matches = true;
break;
}
}
}
if (config.shouldReplayAfterRefresh) {
this._dataLayer.setTemporaryDataParameter(
CUSTOM_GRANT_CONFIG,
JSON.stringify(config)
);
}
if (useDefaultEndpoint || matches) {
return this._authenticationClient
.requestCustomGrant(config)
.then(async (response: FetchResponse | TokenResponse) => {
if (enableRetrievingSignOutURLFromSession &&
typeof enableRetrievingSignOutURLFromSession === "function") {
enableRetrievingSignOutURLFromSession(config);
}
if (config.returnsSession) {
this._spaHelper.refreshAccessTokenAutomatically(this);
return this._authenticationClient.getBasicUserInfo();
} else {
return response as FetchResponse;
}
})
.catch((error) => {
return Promise.reject(error);
});
} else {
return Promise.reject(
new AsgardeoAuthException(
"SPA-MAIN_THREAD_CLIENT-RCG-IV01",
"Request to the provided endpoint is prohibited.",
"Requests can only be sent to resource servers specified by the `resourceServerURLs`" +
" attribute while initializing the SDK. The specified token endpoint in this request " +
"cannot be found among the `resourceServerURLs`"
)
);
}
}
public async getCustomGrantConfigData(): Promise<AuthClientConfig<CustomGrantConfig> | null> {
const configString = await this._dataLayer.getTemporaryDataParameter(
CUSTOM_GRANT_CONFIG
);
if (configString) {
return JSON.parse(configString as string);
} else {
return null;
}
}
public async refreshAccessToken(
enableRetrievingSignOutURLFromSession?: (config: SPACustomGrantConfig) => void
): Promise<BasicUserInfo> {
try {
await this._authenticationClient.refreshAccessToken();
const customGrantConfig = await this.getCustomGrantConfigData();
if (customGrantConfig) {
await this.requestCustomGrant(
customGrantConfig,
enableRetrievingSignOutURLFromSession
);
}
this._spaHelper.refreshAccessTokenAutomatically(this);
return this._authenticationClient.getBasicUserInfo();
} catch (error) {
const refreshTokenError: Message<string> = {
type: REFRESH_ACCESS_TOKEN_ERR0R
}
window.postMessage(refreshTokenError);
return Promise.reject(error);
}
}
protected async retryFailedRequests (failedRequest: HttpRequestInterface): Promise<HttpResponse> {
const httpClient = failedRequest.httpClient;
const requestConfig = failedRequest.requestConfig;
const isHttpHandlerEnabled = failedRequest.isHttpHandlerEnabled;
const httpErrorCallback = failedRequest.httpErrorCallback;
const httpFinishCallback = failedRequest.httpFinishCallback;
// Wait until the token is refreshed.
await SPAUtils.until(() => !this._isTokenRefreshing);
try {
const httpResponse = await httpClient.request(requestConfig);
return Promise.resolve(httpResponse);
} catch (error: any) {
if (isHttpHandlerEnabled) {
if (typeof httpErrorCallback === "function") {
await httpErrorCallback(error);
}
if (typeof httpFinishCallback === "function") {
httpFinishCallback();
}
}
return Promise.reject(error);
}
}
public async httpRequest(
httpClient: HttpClientInstance,
requestConfig: HttpRequestConfig,
isHttpHandlerEnabled?: boolean,
httpErrorCallback?: (error: HttpError) => void | Promise<void>,
httpFinishCallback?: () => void,
enableRetrievingSignOutURLFromSession?: (config: SPACustomGrantConfig) => void
): Promise<HttpResponse> {
let matches = false;
const config = await this._dataLayer.getConfigData();
for (const baseUrl of [
...((await config?.resourceServerURLs) ?? []),
(config as any).baseUrl
]) {
if (baseUrl && requestConfig?.url?.startsWith(baseUrl)) {
matches = true;
break;
}
}
if (matches) {
return httpClient
.request(requestConfig)
.then((response: HttpResponse) => {
return Promise.resolve(response);
})
.catch(async (error: HttpError) => {
if (error?.response?.status === 401 || !error?.response) {
if (this._isTokenRefreshing) {
return this.retryFailedRequests({
enableRetrievingSignOutURLFromSession,
httpClient,
httpErrorCallback,
httpFinishCallback,
isHttpHandlerEnabled,
requestConfig
});
}
this._isTokenRefreshing = true;
// Try to refresh the token
let refreshAccessTokenResponse: BasicUserInfo;
try {
refreshAccessTokenResponse = await this.refreshAccessToken(
enableRetrievingSignOutURLFromSession
);
this._isTokenRefreshing = false;
} catch (refreshError: any) {
this._isTokenRefreshing = false;
if (isHttpHandlerEnabled) {
if (typeof httpErrorCallback === "function") {
await httpErrorCallback({
...error,
code: ACCESS_TOKEN_INVALID
});
}
if (typeof httpFinishCallback === "function") {
httpFinishCallback();
}
}
throw new AsgardeoAuthException(
"SPA-AUTH_HELPER-HR-SE01",
refreshError?.name ?? "Refresh token request failed.",
refreshError?.message ??
"An error occurred while trying to refresh the " +
"access token following a 401 response from the server."
);
}
// Retry the request after refreshing the token
if (refreshAccessTokenResponse) {
try {
const httpResponse = await httpClient.request(requestConfig);
return Promise.resolve(httpResponse);
} catch (error: any) {
if (isHttpHandlerEnabled) {
if (typeof httpErrorCallback === "function") {
await httpErrorCallback(error);
}
if (typeof httpFinishCallback === "function") {
httpFinishCallback();
}
}
return Promise.reject(error);
}
}
}
if (isHttpHandlerEnabled) {
if (typeof httpErrorCallback === "function") {
await httpErrorCallback(error);
}
if (typeof httpFinishCallback === "function") {
httpFinishCallback();
}
}
return Promise.reject(error);
});
} else {
return Promise.reject(
new AsgardeoAuthException(
"SPA-AUTH_HELPER-HR-IV02",
"Request to the provided endpoint is prohibited.",
"Requests can only be sent to resource servers specified by the `resourceServerURLs`" +
" attribute while initializing the SDK. The specified endpoint in this request " +
"cannot be found among the `resourceServerURLs`"
)
);
}
}
public async httpRequestAll(
requestConfigs: HttpRequestConfig[],
httpClient: HttpClientInstance,
isHttpHandlerEnabled?: boolean,
httpErrorCallback?: (error: HttpError) => void | Promise<void>,
httpFinishCallback?: () => void
): Promise<HttpResponse[] | undefined> {
let matches = true;
const config = await this._dataLayer.getConfigData();
for (const requestConfig of requestConfigs) {
let urlMatches = false;
for (const baseUrl of [
...((await config)?.resourceServerURLs ?? []),
(config as any).baseUrl
]) {
if (baseUrl && requestConfig.url?.startsWith(baseUrl)) {
urlMatches = true;
break;
}
}
if (!urlMatches) {
matches = false;
break;
}
}
const requests: Promise<HttpResponse<any>>[] = [];
if (matches) {
requestConfigs.forEach((request) => {
requests.push(httpClient.request(request));
});
return (
httpClient?.all &&
httpClient
.all(requests)
.then((responses: HttpResponse[]) => {
return Promise.resolve(responses);
})
.catch(async (error: HttpError) => {
if (error?.response?.status === 401 || !error?.response) {
let refreshTokenResponse: TokenResponse | BasicUserInfo;
try {
refreshTokenResponse = await this._authenticationClient.refreshAccessToken();
} catch (refreshError: any) {
if (isHttpHandlerEnabled) {
if (typeof httpErrorCallback === "function") {
await httpErrorCallback({
...error,
code: ACCESS_TOKEN_INVALID
});
}
if (typeof httpFinishCallback === "function") {
httpFinishCallback();
}
}
throw new AsgardeoAuthException(
"SPA-AUTH_HELPER-HRA-SE01",
refreshError?.name ?? "Refresh token request failed.",
refreshError?.message ??
"An error occurred while trying to refresh the " +
"access token following a 401 response from the server."
);
}
if (refreshTokenResponse) {
return (
httpClient.all &&
httpClient
.all(requests)
.then((response) => {
return Promise.resolve(response);
})
.catch(async (error) => {
if (isHttpHandlerEnabled) {
if (typeof httpErrorCallback === "function") {
await httpErrorCallback(error);
}
if (typeof httpFinishCallback === "function") {
httpFinishCallback();
}
}
return Promise.reject(error);
})
);
}
}
if (isHttpHandlerEnabled) {
if (typeof httpErrorCallback === "function") {
await httpErrorCallback(error);
}
if (typeof httpFinishCallback === "function") {
httpFinishCallback();
}
}
return Promise.reject(error);
})
);
} else {
throw new AsgardeoAuthException(
"SPA-AUTH_HELPER-HRA-IV02",
"Request to the provided endpoint is prohibited.",
"Requests can only be sent to resource servers specified by the `resourceServerURLs`" +
" attribute while initializing the SDK. The specified endpoint in this request " +
"cannot be found among the `resourceServerURLs`"
);
}
}
public async requestAccessToken(
authorizationCode?: string,
sessionState?: string,
checkSession?: () => Promise<void>,
pkce?: string,
state?: string,
tokenRequestConfig?: {
params: Record<string, unknown>
}
): Promise<BasicUserInfo> {
const config = await this._dataLayer.getConfigData();
if (config.storage === Storage.BrowserMemory && config.enablePKCE && sessionState) {
const pkce = SPAUtils.getPKCE(
AuthenticationUtils.extractPKCEKeyFromStateParam(sessionState)
);
await this._authenticationClient.setPKCECode(
AuthenticationUtils.extractPKCEKeyFromStateParam(sessionState),
pkce
);
} else if (config.storage === Storage.WebWorker && pkce) {
await this._authenticationClient.setPKCECode(pkce, state ?? "");
}
if (authorizationCode) {
return this._authenticationClient
.requestAccessToken(authorizationCode, sessionState ?? "", state ?? "", undefined, tokenRequestConfig)
.then(async () => {
// Disable this temporarily
/* if (config.storage === Storage.BrowserMemory) {
SPAUtils.setSignOutURL(await _authenticationClient.getSignOutURL());
} */
if (config.storage !== Storage.WebWorker) {
SPAUtils.setSignOutURL(
await this._authenticationClient.getSignOutURL(), config.clientID, this._instanceID);
if (this._spaHelper) {
this._spaHelper.clearRefreshTokenTimeout();
this._spaHelper.refreshAccessTokenAutomatically(this);
}
// Enable OIDC Sessions Management only if it is set to true in the config.
if (
checkSession &&
typeof checkSession === "function" &&
config.enableOIDCSessionManagement
) {
checkSession();
}
} else {
if (this._spaHelper) {
this._spaHelper.refreshAccessTokenAutomatically(this);
}
}
return this._authenticationClient.getBasicUserInfo();
})
.catch((error) => {
return Promise.reject(error);
});
}
return Promise.reject(
new AsgardeoAuthException(
"SPA-AUTH_HELPER-RAT1-NF01",
"No authorization code.",
"No authorization code was found."
)
);
}
public async trySignInSilently(
constructSilentSignInUrl: (additionalParams?: Record<string, string | boolean>) => Promise<string>,
requestAccessToken: (authzCode: string, sessionState: string, state: string,
tokenRequestConfig?: { params: Record<string, unknown> }) => Promise<BasicUserInfo>,
sessionManagementHelper: SessionManagementHelperInterface,
additionalParams?: Record<string, string | boolean>,
tokenRequestConfig?: { params: Record<string, unknown> }
): Promise<BasicUserInfo | boolean> {
// This block is executed by the iFrame when the server redirects with the authorization code.
if (SPAUtils.isInitializedSilentSignIn()) {
await sessionManagementHelper.receivePromptNoneResponse();
return Promise.resolve({
allowedScopes: "",
displayName: "",
email: "",
sessionState: "",
sub: "",
tenantDomain: "",
username: ""
});
}
// This gets executed in the main thread and sends the prompt none request.
const rpIFrame = document.getElementById(RP_IFRAME) as HTMLIFrameElement;
const promptNoneIFrame: HTMLIFrameElement = rpIFrame?.contentDocument?.getElementById(
PROMPT_NONE_IFRAME
) as HTMLIFrameElement;
try {
const url = await constructSilentSignInUrl(additionalParams);
promptNoneIFrame.src = url;
} catch (error) {
return Promise.reject(error);
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
resolve(false);
}, 10000);
const listenToPromptNoneIFrame = async (e: MessageEvent) => {
const data: Message<AuthorizationInfo | null> = e.data;
if (data?.type == CHECK_SESSION_SIGNED_OUT) {
window.removeEventListener("message", listenToPromptNoneIFrame);
clearTimeout(timer);
resolve(false);
}
if (data?.type == CHECK_SESSION_SIGNED_IN && data?.data?.code) {
requestAccessToken(data?.data?.code, data?.data?.sessionState,
data?.data?.state, tokenRequestConfig)
.then((response: BasicUserInfo) => {
window.removeEventListener("message", listenToPromptNoneIFrame);
resolve(response);
})
.catch((error) => {
window.removeEventListener("message", listenToPromptNoneIFrame);
reject(error);
})
.finally(() => {
clearTimeout(timer);
});
}
};
window.addEventListener("message", listenToPromptNoneIFrame);
});
}
public async handleSignIn (
shouldStopAuthn: () => Promise<boolean>,
checkSession: () => Promise<void>,
tryRetrievingUserInfo?: () => Promise<BasicUserInfo | undefined>
): Promise<BasicUserInfo | undefined> {
const config = await this._dataLayer.getConfigData();
if (await shouldStopAuthn()) {
return Promise.resolve({
allowedScopes: "",
displayName: "",
email: "",
sessionState: "",
sub: "",
tenantDomain: "",
username: ""
});
}
if (config.storage !== Storage.WebWorker) {
if (await this._authenticationClient.isAuthenticated()) {
this._spaHelper.clearRefreshTokenTimeout();
this._spaHelper.refreshAccessTokenAutomatically(this);
// Enable OIDC Sessions Management only if it is set to true in the config.
if (config.enableOIDCSessionManagement) {
checkSession();
}
return Promise.resolve(await this._authenticationClient.getBasicUserInfo());
}
}
const error = new URL(window.location.href).searchParams.get(ERROR);
const errorDescription = new URL(window.location.href).searchParams.get(ERROR_DESCRIPTION);
if (error) {
const url = new URL(window.location.href);
url.searchParams.delete(ERROR);
url.searchParams.delete(ERROR_DESCRIPTION);
history.pushState(null, document.title, url.toString());
throw new AsgardeoAuthException("SPA-AUTH_HELPER-SI-SE01", error, errorDescription ?? "");
}
if (config.storage === Storage.WebWorker && tryRetrievingUserInfo) {
const basicUserInfo = await tryRetrievingUserInfo();
if (basicUserInfo) {
return basicUserInfo;
}
}
}
public async attachTokenToRequestConfig(request : HttpRequestConfig): Promise<void> {
const requestConfig = { attachToken: true, ...request };
if (requestConfig.attachToken) {
if(requestConfig.shouldAttachIDPAccessToken) {
request.headers = {
...request.headers,
Authorization: `Bearer ${ await this.getIDPAccessToken() }`
};
} else {
request.headers = {
...request.headers,
Authorization: `Bearer ${ await this.getAccessToken() }`
};
}
}
}
public async getBasicUserInfo(): Promise<BasicUserInfo> {
return this._authenticationClient.getBasicUserInfo();
}
public async getDecodedIDToken(): Promise<DecodedIDTokenPayload> {
return this._authenticationClient.getDecodedIDToken();
}
public async getDecodedIDPIDToken(): Promise<DecodedIDTokenPayload> {
return this._authenticationClient.getDecodedIDToken();
}
public async getCryptoHelper(): Promise<CryptoHelper> {
return this._authenticationClient.getCryptoHelper();
}
public async getIDToken(): Promise<string> {
return this._authenticationClient.getIDToken();
}
public async getOIDCServiceEndpoints(): Promise<OIDCEndpoints> {
return this._authenticationClient.getOIDCServiceEndpoints();
}
public async getAccessToken(): Promise<string> {
return this._authenticationClient.getAccessToken();
}
public async getIDPAccessToken(): Promise<string> {
return (await this._dataLayer.getSessionData())?.access_token;
}
public getDataLayer(): DataLayer<T> {
return this._dataLayer;
}
public async isAuthenticated(): Promise<boolean> {
return this._authenticationClient.isAuthenticated();
}
}