Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Authorization Code Flow for Single Page Applications: Popup Handler and AcquireToken Implementation #1190

Merged
52 changes: 40 additions & 12 deletions lib/msal-browser/src/app/PublicClientApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { AuthError, AuthResponse, AuthorizationCodeModule, AuthenticationParamet
import { BrowserStorage } from "../cache/BrowserStorage";
import { Configuration, buildConfiguration } from "./Configuration";
import { CryptoOps } from "../crypto/CryptoOps";
import { IInteractionHandler } from "../interaction_handler/IInteractionHandler";
import { RedirectHandler } from "../interaction_handler/RedirectHandler";
import { PopupHandler } from "../interaction_handler/PopupHandler";
import { BrowserConfigurationAuthError } from "../error/BrowserConfigurationAuthError";
import { BrowserConstants } from "../utils/BrowserConstants";
import { BrowserAuthError } from "../error/BrowserAuthError";
Expand Down Expand Up @@ -106,15 +106,21 @@ export class PublicClientApplication {
* containing data from the server (returned with a null or non-blocking error).
*/
async handleRedirectCallback(authCallback: AuthCallback): Promise<void> {
if(!authCallback) {
if (!authCallback) {
throw BrowserConfigurationAuthError.createInvalidCallbackObjectError(authCallback);
}

this.authCallback = authCallback;
const { location: { hash } } = window;
if (UrlString.hashContainsKnownProperties(hash)) {
const interactionHandler = new RedirectHandler(this.authModule, this.browserStorage, this.authCallback);
interactionHandler.handleCodeResponse(hash);
const cachedHash = this.browserStorage.getItem(TemporaryCacheKeys.URL_HASH);
try {
const interactionHandler = new RedirectHandler(this.authModule, this.browserStorage, this.authCallback, this.config.auth.navigateToLoginRequestUrl);
const responseHash = UrlString.hashContainsKnownProperties(hash) ? hash : cachedHash;
if (responseHash) {
this.authCallback(null, await interactionHandler.handleCodeResponse(responseHash));
pkanher617 marked this conversation as resolved.
Show resolved Hide resolved
}
} catch (err) {
pkanher617 marked this conversation as resolved.
Show resolved Hide resolved
this.authCallback(err);
}
}

Expand All @@ -127,8 +133,10 @@ export class PublicClientApplication {
if (this.interactionInProgress()) {
throw BrowserAuthError.createInteractionInProgressError();
}
const interactionHandler = new RedirectHandler(this.authModule, this.browserStorage, this.authCallback);
interactionHandler.showUI(request);
const interactionHandler = new RedirectHandler(this.authModule, this.browserStorage, this.authCallback, this.config.auth.navigateToLoginRequestUrl);
this.authModule.createLoginUrl(request).then((navigateUrl) => {
interactionHandler.showUI(navigateUrl);
});
}

/**
Expand All @@ -139,7 +147,13 @@ export class PublicClientApplication {
* To acquire only idToken, please pass clientId as the only scope in the Authentication Parameters
*/
acquireTokenRedirect(request: AuthenticationParameters): void {
throw new Error("Method not implemented.");
if (this.interactionInProgress()) {
throw BrowserAuthError.createInteractionInProgressError();
}
const interactionHandler = new RedirectHandler(this.authModule, this.browserStorage, this.authCallback, this.config.auth.navigateToLoginRequestUrl);
this.authModule.createAcquireTokenUrl(request).then((navigateUrl) => {
interactionHandler.showUI(navigateUrl);
});
}

// #endregion
Expand All @@ -153,8 +167,15 @@ export class PublicClientApplication {
*
* @returns {Promise.<TokenResponse>} - a promise that is fulfilled when this function has completed, or rejected if an error was raised. Returns the {@link AuthResponse} object
*/
loginPopup(request: AuthenticationParameters): Promise<TokenResponse> {
throw new Error("Method not implemented.");
async loginPopup(request: AuthenticationParameters): Promise<TokenResponse> {
if (this.interactionInProgress()) {
throw BrowserAuthError.createInteractionInProgressError();
}
const interactionHandler = new PopupHandler(this.authModule, this.browserStorage);
const navigateUrl = await this.authModule.createLoginUrl(request);
const popupWindow = interactionHandler.showUI(navigateUrl);
const hash = await interactionHandler.monitorWindowForHash(popupWindow, this.config.system.loadFrameTimeout, navigateUrl);
return interactionHandler.handleCodeResponse(hash);
pkanher617 marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand All @@ -164,8 +185,15 @@ export class PublicClientApplication {
* To acquire only idToken, please pass clientId as the only scope in the Authentication Parameters
* @returns {Promise.<TokenResponse>} - a promise that is fulfilled when this function has completed, or rejected if an error was raised. Returns the {@link AuthResponse} object
*/
acquireTokenPopup(request: AuthenticationParameters): Promise<TokenResponse> {
throw new Error("Method not implemented.");
async acquireTokenPopup(request: AuthenticationParameters): Promise<TokenResponse> {
if (this.interactionInProgress()) {
throw BrowserAuthError.createInteractionInProgressError();
}
const interactionHandler = new PopupHandler(this.authModule, this.browserStorage);
const navigateUrl = await this.authModule.createAcquireTokenUrl(request);
const popupWindow = interactionHandler.showUI(navigateUrl);
const hash = await interactionHandler.monitorWindowForHash(popupWindow, this.config.system.loadFrameTimeout, navigateUrl);
return interactionHandler.handleCodeResponse(hash);
sameerag marked this conversation as resolved.
Show resolved Hide resolved
}

// #region Silent Flow
Expand Down
33 changes: 31 additions & 2 deletions lib/msal-browser/src/error/BrowserAuthError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { AuthError } from "msal-common";
import { AuthError, StringUtils } from "msal-common";

/**
* BrowserAuthErrorMessage class containing string constants used by error codes and messages.
Expand Down Expand Up @@ -35,7 +35,19 @@ export const BrowserAuthErrorMessage = {
interactionInProgress: {
code: "interaction_in_progress",
desc: "Interaction is currently in progress. Please ensure that this interaction has been completed before calling an interactive API."
}
},
popUpWindowError: {
code: "popup_window_error",
desc: "Error opening popup window. This can happen if you are using IE or if popups are blocked in the browser."
},
userCancelledError: {
code: "user_cancelled",
desc: "User cancelled the flow."
},
tokenRenewalError: {
code: "token_renewal_error",
desc: "Token renewal operation failed due to timeout."
},
};

/**
Expand Down Expand Up @@ -80,4 +92,21 @@ export class BrowserAuthError extends AuthError {
static createInteractionInProgressError(): BrowserAuthError {
return new BrowserAuthError(BrowserAuthErrorMessage.interactionInProgress.code, BrowserAuthErrorMessage.interactionInProgress.desc);
}

static createPopupWindowError(errDetail?: string): BrowserAuthError {
let errorMessage = BrowserAuthErrorMessage.popUpWindowError.desc;
errorMessage = !StringUtils.isEmpty(errDetail) ? `${errorMessage} Details: ${errDetail}` : errorMessage;
return new BrowserAuthError(BrowserAuthErrorMessage.popUpWindowError.code, errorMessage);
}

static createUserCancelledError(): BrowserAuthError {
return new BrowserAuthError(BrowserAuthErrorMessage.userCancelledError.code,
BrowserAuthErrorMessage.userCancelledError.desc);
}

static createTokenRenewalTimeoutError(urlNavigate: string): BrowserAuthError {
const errorMessage = `URL navigated to is ${urlNavigate}, ${BrowserAuthErrorMessage.tokenRenewalError.desc}`;
return new BrowserAuthError(BrowserAuthErrorMessage.tokenRenewalError.code,
errorMessage);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { AuthorizationCodeModule, AuthenticationParameters } from "msal-common";
import { AuthorizationCodeModule, TokenResponse } from "msal-common";
import { BrowserStorage } from "../cache/BrowserStorage";

export abstract class IInteractionHandler {
export abstract class InteractionHandler {

protected authModule: AuthorizationCodeModule;
protected browserStorage: BrowserStorage;
Expand All @@ -19,11 +19,11 @@ export abstract class IInteractionHandler {
* Function to enable user interaction.
* @param urlNavigate
*/
abstract showUI(authRequest: AuthenticationParameters): void;
abstract showUI(requestUrl: string): Window;

/**
* Function to handle response parameters from hash.
* @param hash
*/
abstract async handleCodeResponse(hash: string): Promise<void>;
abstract async handleCodeResponse(locationHash: string): Promise<TokenResponse>;
}
131 changes: 131 additions & 0 deletions lib/msal-browser/src/interaction_handler/PopupHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { InteractionHandler } from "./InteractionHandler";
import { BrowserAuthError } from "../error/BrowserAuthError";
import { TemporaryCacheKeys, UrlString, StringUtils, Constants, AuthorizationCodeModule, TokenResponse } from "msal-common";
import { BrowserConstants } from "../utils/BrowserConstants";
import { BrowserStorage } from "../cache/BrowserStorage";

export class PopupHandler extends InteractionHandler {

private currentWindow: Window;

showUI(requestUrl: string): Window {
this.browserStorage.setItem(BrowserConstants.INTERACTION_STATUS_KEY, BrowserConstants.INTERACTION_IN_PROGRESS);
const popupWindow = this.openPopup(requestUrl, Constants.LIBRARY_NAME, BrowserConstants.POPUP_WIDTH, BrowserConstants.POPUP_HEIGHT);
this.currentWindow = popupWindow;
if (!StringUtils.isEmpty(requestUrl)) {
this.authModule.logger.infoPii("Navigate to:" + requestUrl);
return popupWindow;
} else {
this.authModule.logger.info("Navigate url is empty");
pkanher617 marked this conversation as resolved.
Show resolved Hide resolved
throw BrowserAuthError.createEmptyRedirectUriError();
}
}

async handleCodeResponse(locationHash: string): Promise<TokenResponse> {
if (StringUtils.isEmpty(locationHash)) {
throw BrowserAuthError.createEmptyHashError(locationHash);
}

this.browserStorage.removeItem(BrowserConstants.INTERACTION_STATUS_KEY);
const codeResponse = this.authModule.handleFragmentResponse(locationHash);
this.currentWindow.close();
return this.authModule.acquireToken(null, codeResponse);
}

/**
* Monitors a window until it loads a url with a hash
* @param contentWindow
* @param timeout
* @param urlNavigate
*/
monitorWindowForHash(contentWindow: Window, timeout: number, urlNavigate: string): Promise<string> {
return new Promise((resolve, reject) => {
const maxTicks = timeout / BrowserConstants.POPUP_POLL_INTERVAL_MS;
let ticks = 0;

const intervalId = setInterval(() => {
if (contentWindow.closed) {
clearInterval(intervalId);
reject(BrowserAuthError.createUserCancelledError());
return;
}

let href;
try {
/*
* Will throw if cross origin,
* which should be caught and ignored
* since we need the interval to keep running while on STS UI.
*/
href = contentWindow.location.href;
} catch (e) {}

// Don't process blank pages or cross domain
if (!href || href === "about:blank") {
return;
}

// Only run clock when we are on same domain
ticks++;

if (UrlString.hashContainsKnownProperties(href)) {
clearInterval(intervalId);
resolve(contentWindow.location.hash);
} else if (ticks > maxTicks) {
clearInterval(intervalId);
reject(BrowserAuthError.createTokenRenewalTimeoutError(urlNavigate)); // better error?
}
}, BrowserConstants.POPUP_POLL_INTERVAL_MS);
});
}

/**
* @hidden
*
* Configures popup window for login.
*
* @param urlNavigate
* @param title
* @param popUpWidth
* @param popUpHeight
* @ignore
* @hidden
*/
private openPopup(urlNavigate: string, title: string, popUpWidth: number, popUpHeight: number) {
try {
/**
* adding winLeft and winTop to account for dual monitor
* using screenLeft and screenTop for IE8 and earlier
*/
const winLeft = window.screenLeft ? window.screenLeft : window.screenX;
const winTop = window.screenTop ? window.screenTop : window.screenY;
/**
* window.innerWidth displays browser window"s height and width excluding toolbars
* using document.documentElement.clientWidth for IE8 and earlier
*/
const width = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
const height = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
const left = ((width / 2) - (popUpWidth / 2)) + winLeft;
const top = ((height / 2) - (popUpHeight / 2)) + winTop;

// open the window
const popupWindow = window.open(urlNavigate, title, "width=" + popUpWidth + ", height=" + popUpHeight + ", top=" + top + ", left=" + left);
if (!popupWindow) {
throw BrowserAuthError.createPopupWindowError();
}
if (popupWindow.focus) {
popupWindow.focus();
}

return popupWindow;
} catch (e) {
this.authModule.logger.error("error opening popup " + e.message);
this.browserStorage.removeItem(BrowserConstants.INTERACTION_STATUS_KEY);
throw BrowserAuthError.createPopupWindowError(e.toString());
}
}
}