@openpass/openpass-js-sdk
Version:
OpenPass SSO JavaScript SDK
274 lines • 13.9 kB
JavaScript
import { config, getOpenPassApiBaseUrl } from "../config";
import { PARAM_CODE_CHALLENGE_METHOD_VALUE, PARAM_CODE_RESPONSE_MODE_MESSAGE, POPUP_ADVANCE_MESSAGE_SOURCE, POPUP_MESSAGE_SOURCE, } from "./constants";
import { generateCodeChallenge, generateCodeVerifier } from "./utils/pkce";
import { generateStateValue } from "./utils/state";
import { buildAuthorizeUrl, matchesEventOrigin } from "./url";
import { isPostMessageSupported } from "./utils/browser";
import { isLargeBreakpoint, windowOpenPopupWithPercentage, windowOpenPopup } from "./utils/window";
import { ERROR_CODE_INVALID_AUTH_CODE } from "./error/codes";
import { AuthCancelledError, AuthError, SdkError } from "./error/errors";
import AbortablePromise from "./utils/abortablePromise";
import { sendSdkTelemetryErrorEvent, sendSdkTelemetryInfoEvent } from "./utils/sdkTelemetry";
import { isAuthCodeValid } from "./utils/authCode";
import { reportAnalyticsEvent, addAnalyticsDimension } from "./utils/userAnalytics";
const POPUP_HEIGHT = 586;
const POPUP_WIDTH = 428;
const POPUP_NAME = "openpass:popup:login";
// Used to close the popup in the event the set authenticated method requires a remote API call to complete the authentication
// process, and that call suffers from increased latency.
const SET_AUTHENTICATED_TIMEOUT_IN_MS = 10000;
/**
* Class which handles the Popup authorization flow.
* This class redirects to the authorization server and waits for a response from the authorization server.
* The response is sent via the window.postMessage API to support secure cross-origin communication.
*/
export default class PopupAuth {
constructor(openPassOptions, redirectApi, openPassApiClient) {
this.openPassOptions = openPassOptions;
this.openPassApiClient = openPassApiClient;
this.redirectApi = redirectApi;
}
refocusIfPopupExists() {
if (!this.popupWindow || this.popupWindow.window.closed) {
return false;
}
this.popupWindow.window.focus();
return true;
}
async signInWithPopup(options) {
// Report analytics event for popup sign-in started
reportAnalyticsEvent("sign-in-with-pop-up-started", { source: options.source });
// If an existing popup flow is in progress, close that and start a new one
this.closePopupIfExists(this.popupWindow);
let popup;
let popupErrorMessage = null;
try {
popup = this.openPopup();
}
catch (error) {
popup = null;
popupErrorMessage = error === null || error === void 0 ? void 0 : error.message;
}
if (!popup) {
// If the popup was blocked, and a fallback redirectUrl was supplied, try to redirect instead
if (options.redirectUrl) {
sendSdkTelemetryInfoEvent("PopupAuth.signInWithPopup", "Popup failed to open, falling back to redirect", this.openPassApiClient);
this.redirectApi.signIn({
...options,
redirectUrl: options.redirectUrl,
});
throw new SdkError("Using redirect instead of popup. This error should not be thrown because the redirect happens first.");
}
let exceptionMessage = "Popup window did not open correctly.";
if (popupErrorMessage) {
exceptionMessage += ` Error: ${popupErrorMessage}`;
}
throw new SdkError(exceptionMessage);
}
const authWindow = { window: popup };
this.popupWindow = authWindow;
return this.doLogin(authWindow, options);
}
async doLogin(authWindow, options) {
var _a, _b;
const popupCloseHandler = () => {
this.closePopupIfExists(authWindow);
};
window.addEventListener("beforeunload", popupCloseHandler);
// Ping the SDK popup every 500ms to provide it with a reference to this parent window.
// If Chrome discards a page to recover resources, the window.opener reference is lost in the popup.
// On receiving the ping message, the popup is provided with the message.source which is this window,
// which it can use to send the authentication complete message.
const pingIntervalId = setInterval(() => {
if (authWindow.window && !authWindow.window.closed) {
const pingMessage = {
source: "openpass-popup-ping",
};
authWindow.window.postMessage(pingMessage, getOpenPassApiBaseUrl(this.openPassOptions.baseUrl));
}
}, 500);
try {
const verifier = generateCodeVerifier();
const authSession = {
clientState: options === null || options === void 0 ? void 0 : options.clientState,
clientId: this.openPassOptions.clientId,
redirectUrl: options === null || options === void 0 ? void 0 : options.redirectUrl,
codeVerifier: verifier,
codeChallenge: await generateCodeChallenge(verifier),
codeChallengeMethod: PARAM_CODE_CHALLENGE_METHOD_VALUE,
state: generateStateValue(),
responseMode: PARAM_CODE_RESPONSE_MODE_MESSAGE,
loginHint: options === null || options === void 0 ? void 0 : options.loginHint,
disableLoginHintEditing: options === null || options === void 0 ? void 0 : options.disableLoginHintEditing,
originatingUri: (_a = window === null || window === void 0 ? void 0 : window.location) === null || _a === void 0 ? void 0 : _a.href,
allowUnverifiedEmail: (_b = options === null || options === void 0 ? void 0 : options.allowUnverifiedEmail) !== null && _b !== void 0 ? _b : false,
};
const source = options ? options.source : "Custom";
const loginUri = buildAuthorizeUrl(getOpenPassApiBaseUrl(this.openPassOptions.baseUrl), config.SSO_AUTHORIZE_PATH, authSession, source, options === null || options === void 0 ? void 0 : options.customQueryParameters);
try {
authWindow.window.location.replace(loginUri);
}
catch (_c) {
// If the window location replace fails, try to set the href directly
authWindow.window.location.href = loginUri;
}
return await this.waitForPopupResponse(authWindow, authSession);
}
catch (e) {
if (!(e instanceof AuthCancelledError)) {
// Only send telemetry events if the error is not due to the user closing the popup
sendSdkTelemetryErrorEvent("PopupAuth.doLogin", e, this.openPassApiClient);
// Close the popup if an error occurs
this.closePopupIfExists(authWindow);
}
throw e;
}
finally {
window.removeEventListener("beforeunload", popupCloseHandler);
clearInterval(pingIntervalId);
}
}
async waitForPopupResponse(authWindow, authSession) {
const authCodeResponse = await this.listenForPopupResponse(authWindow);
if (!isAuthCodeValid(authCodeResponse, authSession) || !authCodeResponse.code) {
const authError = new AuthError(authCodeResponse.error ? authCodeResponse.error : ERROR_CODE_INVALID_AUTH_CODE, authCodeResponse.errorDescription ? authCodeResponse.errorDescription : "Error, invalid authorization code response", authCodeResponse.errorUri ? authCodeResponse.errorUri : "", authSession.clientState);
sendSdkTelemetryErrorEvent("PopupAuth.waitForPopupResponse", authError, this.openPassApiClient);
throw authError;
}
const openPassTokens = await this.openPassApiClient.exchangeAuthCodeForTokens(authCodeResponse.code, authSession);
return await this.completeAuthentication(authWindow, openPassTokens, authSession);
}
async completeAuthentication(authWindow, tokens, authSession) {
const { idToken, rawIdToken, rawAccessToken, refreshToken, expiresIn, tokenType } = tokens;
return new Promise((resolve, reject) => {
//wait for identity to be set before closing the popup
setTimeout(() => {
reject(new SdkError("No Response received from popup"));
}, SET_AUTHENTICATED_TIMEOUT_IN_MS);
(async () => {
try {
this.closePopupIfExists(authWindow);
const response = {
clientState: authSession.clientState,
originatingUri: authSession.originatingUri,
idToken: idToken,
rawIdToken: rawIdToken,
accessToken: rawAccessToken,
rawAccessToken: rawAccessToken,
refreshToken: refreshToken,
expiresIn: expiresIn,
tokenType: tokenType,
};
// Report analytics for successful authentication
if (response.idToken) {
reportAnalyticsEvent("authentication-completed", { method: "popup" });
addAnalyticsDimension("is_authenticated", "true");
addAnalyticsDimension("op_user_id", response.idToken.sub || "");
}
resolve(response);
}
catch (e) {
this.closePopupIfExists(authWindow);
reject(e);
}
})();
});
}
async listenForPopupResponse(authWindow) {
let closeTimeout;
let messageTimeout;
let messageHandler;
const popupPromise = new AbortablePromise((resolve, reject, onAbort) => {
let advanceAuthMessage = null;
closeTimeout = setInterval(() => {
if (authWindow.window && authWindow.window.closed) {
if (advanceAuthMessage) {
resolve(advanceAuthMessage);
}
else {
clearInterval(closeTimeout);
window.removeEventListener("message", messageHandler);
this.closePopupIfExists(authWindow);
sendSdkTelemetryInfoEvent("PopupAuth.listenForPopupResponse", "Popup window closed, aborting sign-in", this.openPassApiClient);
reject(new SdkError("Popup closed, authentication response not available"));
}
}
}, 100);
messageHandler = (event) => {
//Check that the message is from the authorization server
if (!matchesEventOrigin(event.origin, getOpenPassApiBaseUrl(this.openPassOptions.baseUrl))) {
return;
}
sendSdkTelemetryInfoEvent("PopupAuth.listenForPopupResponse", `Popup message received. Has Data: ${!!event.data}`, this.openPassApiClient);
if (!event.data) {
return;
}
const { data } = event;
if (!data.source || data.source !== POPUP_MESSAGE_SOURCE) {
// cache advance auth message but don't resolve with it yet
if (data.source == POPUP_ADVANCE_MESSAGE_SOURCE) {
advanceAuthMessage = data;
}
return;
}
resolve(data);
};
window.addEventListener("message", messageHandler, false);
messageTimeout = setInterval(() => {
clearInterval(messageTimeout);
reject(new SdkError("No Response received from popup"));
sendSdkTelemetryInfoEvent("PopupAuth.listenForPopupResponse", "No Response received from popup after maximum timeout", this.openPassApiClient);
}, config.POPUP_RESPONSE_TIMEOUT_MS);
onAbort(() => {
clearInterval(closeTimeout);
clearTimeout(messageTimeout);
window.removeEventListener("message", messageHandler);
reject(new AuthCancelledError("Popup window was closed"));
});
});
authWindow.listener = popupPromise;
return popupPromise.finally(() => {
clearInterval(closeTimeout);
clearTimeout(messageTimeout);
window.removeEventListener("message", messageHandler);
});
}
openPopup() {
if (!isPostMessageSupported()) {
return null;
}
if (isLargeBreakpoint()) {
return windowOpenPopup("", POPUP_NAME, POPUP_WIDTH, POPUP_HEIGHT);
}
else {
return windowOpenPopupWithPercentage("", POPUP_NAME, 100);
}
}
closePopupIfExists(popupWindowToClose) {
if (!popupWindowToClose) {
return;
}
if (popupWindowToClose.window && !popupWindowToClose.window.closed) {
try {
popupWindowToClose.window.close();
}
catch (e) {
console.warn("Error closing the openpass popup window", e);
}
}
if (popupWindowToClose.listener) {
try {
popupWindowToClose.listener.abort();
popupWindowToClose.listener = undefined;
}
catch (e) {
console.warn("Error aborting the openpass popup listener", e);
}
}
// Only set the popup to null if the closed window was the current popup
if (popupWindowToClose == this.popupWindow) {
this.popupWindow = undefined;
}
}
}
//# sourceMappingURL=popup.js.map