Skip to content

Commit

Permalink
Merge pull request #1236 from AzureAD/authorization-code-flow-cleanup…
Browse files Browse the repository at this point in the history
…Popup-flows

Authorization Code Flow for Single Page Applications: Cleanup Popup Flows and Window Nav
  • Loading branch information
Prithvi Kanherkar committed Jan 29, 2020
2 parents 42867dd + 126953f commit db98b9f
Show file tree
Hide file tree
Showing 7 changed files with 134 additions and 47 deletions.
81 changes: 58 additions & 23 deletions lib/msal-browser/src/app/PublicClientApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ export class PublicClientApplication {

/**
* Set the callback functions for the redirect flow to send back the success or error object.
* IMPORTANT: Please do not use this function when using the popup APIs, as it will break the response handling
* in the main window.
*
* @param {@link (AuthCallback:type)} authCallback - Callback which contains
* an AuthError object, containing error data from either the server
* or the library, depending on the origin of the error, or the AuthResponse object
Expand All @@ -110,12 +113,14 @@ export class PublicClientApplication {
const { location: { hash } } = window;
const cachedHash = this.browserStorage.getItem(TemporaryCacheKeys.URL_HASH);
try {
// If hash exists, handle in window. Otherwise, continue execution.
// If hash exists, handle in window. Otherwise, cancel any current requests and continue.
const interactionHandler = new RedirectHandler(this.authModule, this.browserStorage, this.config.auth.navigateToLoginRequestUrl);
const responseHash = UrlString.hashContainsKnownProperties(hash) ? hash : cachedHash;
if (responseHash) {
const tokenResponse = await interactionHandler.handleCodeResponse(responseHash);
this.authCallback(null, tokenResponse);
} else {
this.cleanRequest();
}
} catch (err) {
this.authCallback(err);
Expand All @@ -139,14 +144,19 @@ export class PublicClientApplication {
return;
}

// Create redirect interaction handler.
const interactionHandler = new RedirectHandler(this.authModule, this.browserStorage, this.config.auth.navigateToLoginRequestUrl);
try {
// Create redirect interaction handler.
const interactionHandler = new RedirectHandler(this.authModule, this.browserStorage, this.config.auth.navigateToLoginRequestUrl);

// Create login url, which will by default append the client id scope to the call.
this.authModule.createLoginUrl(request).then((navigateUrl) => {
// Show the UI once the url has been created. Response will come back in the hash, which will be handled in the handleRedirectCallback function.
interactionHandler.showUI(navigateUrl);
});
// Create login url, which will by default append the client id scope to the call.
this.authModule.createLoginUrl(request).then((navigateUrl) => {
// Show the UI once the url has been created. Response will come back in the hash, which will be handled in the handleRedirectCallback function.
interactionHandler.showUI(navigateUrl);
});
} catch (e) {
this.cleanRequest();
throw e;
}
}

/**
Expand All @@ -168,13 +178,19 @@ export class PublicClientApplication {
return;
}

// Create redirect interaction handler.
const interactionHandler = new RedirectHandler(this.authModule, this.browserStorage, this.config.auth.navigateToLoginRequestUrl);
// Create acquire token url.
this.authModule.createAcquireTokenUrl(request).then((navigateUrl) => {
// Show the UI once the url has been created. Response will come back in the hash, which will be handled in the handleRedirectCallback function.
interactionHandler.showUI(navigateUrl);
});
try {
// Create redirect interaction handler.
const interactionHandler = new RedirectHandler(this.authModule, this.browserStorage, this.config.auth.navigateToLoginRequestUrl);

// Create acquire token url.
this.authModule.createAcquireTokenUrl(request).then((navigateUrl) => {
// Show the UI once the url has been created. Response will come back in the hash, which will be handled in the handleRedirectCallback function.
interactionHandler.showUI(navigateUrl);
});
} catch (e) {
this.cleanRequest();
throw e;
}
}

// #endregion
Expand Down Expand Up @@ -226,16 +242,23 @@ export class PublicClientApplication {
* @param navigateUrl
*/
private async popupTokenHelper(navigateUrl: string): Promise<TokenResponse> {
// Create popup interaction handler.
const interactionHandler = new PopupHandler(this.authModule, this.browserStorage);
// Show the UI once the url has been created. Get the window handle for the popup.
const popupWindow = interactionHandler.showUI(navigateUrl);
// Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds.
const hash = await interactionHandler.monitorWindowForHash(popupWindow, this.config.system.windowHashTimeout, navigateUrl);
// Handle response from hash string.
return interactionHandler.handleCodeResponse(hash);
try {
// Create popup interaction handler.
const interactionHandler = new PopupHandler(this.authModule, this.browserStorage);
// Show the UI once the url has been created. Get the window handle for the popup.
const popupWindow = interactionHandler.showUI(navigateUrl);
// Monitor the window for the hash. Return the string value and close the popup when the hash is received. Default timeout is 60 seconds.
const hash = await interactionHandler.monitorWindowForHash(popupWindow, this.config.system.windowHashTimeout, navigateUrl);
// Handle response from hash string.
return interactionHandler.handleCodeResponse(hash);
} catch (e) {
this.cleanRequest();
throw e;
}
}

// #endregion

// #region Silent Flow

/**
Expand Down Expand Up @@ -309,10 +332,22 @@ export class PublicClientApplication {

// #region Helpers

/**
* Helper to check whether interaction is in progress
*/
private interactionInProgress(): boolean {
// Check whether value in cache is present and equal to expected value
return this.browserStorage.getItem(BrowserConstants.INTERACTION_STATUS_KEY) === BrowserConstants.INTERACTION_IN_PROGRESS_VALUE;
}

/**
* Helper to remove interaction status and remove tempoarary request data.
*/
private cleanRequest(): void {
// Interaction is completed - remove interaction status.
this.browserStorage.removeItem(BrowserConstants.INTERACTION_STATUS_KEY);
this.authModule.cancelRequest();
}

// #endregion
}
67 changes: 52 additions & 15 deletions lib/msal-browser/src/interaction_handler/PopupHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,27 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { UrlString, StringUtils, Constants, TokenResponse } from "@azure/msal-common";
import { UrlString, StringUtils, Constants, TokenResponse, AuthorizationCodeModule } from "@azure/msal-common";
import { InteractionHandler } from "./InteractionHandler";
import { BrowserAuthError } from "../error/BrowserAuthError";
import { BrowserConstants } from "../utils/BrowserConstants";
import { BrowserStorage } from "../cache/BrowserStorage";

/**
* This class implements the interaction handler base class for browsers. It is written specifically for handling
* popup window scenarios. It includes functions for monitoring the popup window for a hash.
*/
export class PopupHandler extends InteractionHandler {

// Currently opened window handle.
private currentWindow: Window;

constructor(authCodeModule: AuthorizationCodeModule, storageImpl: BrowserStorage) {
super(authCodeModule, storageImpl);

// Properly sets this reference for the unload event.
this.unloadWindow = this.unloadWindow.bind(this);
}

/**
* Opens a popup window with given request Url.
* @param requestUrl
Expand All @@ -25,13 +32,9 @@ export class PopupHandler extends InteractionHandler {
if (!StringUtils.isEmpty(requestUrl)) {
// Set interaction status in the library.
this.browserStorage.setItem(BrowserConstants.INTERACTION_STATUS_KEY, BrowserConstants.INTERACTION_IN_PROGRESS_VALUE);
// Open the popup window to requestUrl.
const popupWindow = this.openPopup(requestUrl, Constants.LIBRARY_NAME, BrowserConstants.POPUP_WIDTH, BrowserConstants.POPUP_HEIGHT);
// Save the window handle.
this.currentWindow = popupWindow;
this.authModule.logger.infoPii("Navigate to:" + requestUrl);
// Return popup window handle.
return popupWindow;
// Open the popup window to requestUrl.
return this.openPopup(requestUrl, Constants.LIBRARY_NAME, BrowserConstants.POPUP_WIDTH, BrowserConstants.POPUP_HEIGHT);
} else {
// Throw error if request URL is empty.
this.authModule.logger.error("Navigate url is empty");
Expand All @@ -49,12 +52,9 @@ export class PopupHandler extends InteractionHandler {
throw BrowserAuthError.createEmptyHashError(locationHash);
}

// Interaction is completed - remove interaction status.
this.browserStorage.removeItem(BrowserConstants.INTERACTION_STATUS_KEY);
// Handle code response.
const codeResponse = this.authModule.handleFragmentResponse(locationHash);
// Close window.
this.currentWindow.close();

// Acquire token with retrieved code.
return this.authModule.acquireToken(codeResponse);
}
Expand All @@ -72,6 +72,8 @@ export class PopupHandler extends InteractionHandler {

const intervalId = setInterval(() => {
if (contentWindow.closed) {
// Window is closed
this.cleanPopup();
clearInterval(intervalId);
reject(BrowserAuthError.createUserCancelledError());
return;
Expand All @@ -96,12 +98,18 @@ export class PopupHandler extends InteractionHandler {
ticks++;

if (UrlString.hashContainsKnownProperties(href)) {
// Success case
const contentHash = contentWindow.location.hash;
this.cleanPopup(contentWindow);
clearInterval(intervalId);
resolve(contentWindow.location.hash);
resolve(contentHash);
return;
} else if (ticks > maxTicks) {
// Timeout error
this.cleanPopup(contentWindow);
clearInterval(intervalId);
contentWindow.close();
reject(BrowserAuthError.createPopupWindowTimeoutError(urlNavigate)); // better error?
reject(BrowserAuthError.createPopupWindowTimeoutError(urlNavigate));
return;
}
}, BrowserConstants.POPUP_POLL_INTERVAL_MS);
});
Expand Down Expand Up @@ -144,6 +152,8 @@ export class PopupHandler extends InteractionHandler {
if (popupWindow.focus) {
popupWindow.focus();
}
this.currentWindow = popupWindow;
window.addEventListener("beforeunload", this.unloadWindow);

return popupWindow;
} catch (e) {
Expand All @@ -152,4 +162,31 @@ export class PopupHandler extends InteractionHandler {
throw BrowserAuthError.createPopupWindowError(e.toString());
}
}

/**
* Event callback to unload main window.
*/
unloadWindow(e: Event): void {
this.authModule.cancelRequest();
this.browserStorage.removeItem(BrowserConstants.INTERACTION_STATUS_KEY);
this.currentWindow.close();
// Guarantees browser unload will happen, so no other errors will be thrown.
delete e["returnValue"];
}

/**
* Closes popup, removes any state vars created during popup calls.
* @param popupWindow
*/
private cleanPopup(popupWindow?: Window): void {
if (popupWindow) {
// Close window.
popupWindow.close();
}
// Remove window unload function
window.removeEventListener("beforeunload", this.unloadWindow);

// Interaction is completed - remove interaction status.
this.browserStorage.removeItem(BrowserConstants.INTERACTION_STATUS_KEY);
}
}
8 changes: 8 additions & 0 deletions lib/msal-common/src/app/module/AuthorizationCodeModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,14 @@ export class AuthorizationCodeModule extends AuthModule {
}
}

/**
* Clears cache of items related to current request.
*/
public cancelRequest(): void {
const cachedState = this.cacheStorage.getItem(TemporaryCacheKeys.REQUEST_STATE);
this.cacheManager.resetTempCacheItems(cachedState || "");
}

// #region Logout

/**
Expand Down
1 change: 1 addition & 0 deletions lib/msal-common/src/cache/CacheHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export class CacheHelpers {
});
// delete generic interactive request parameters
this.cacheStorage.removeItem(TemporaryCacheKeys.REQUEST_STATE);
this.cacheStorage.removeItem(TemporaryCacheKeys.REQUEST_PARAMS);
this.cacheStorage.removeItem(TemporaryCacheKeys.ORIGIN_URI);
this.cacheStorage.removeItem(TemporaryCacheKeys.REDIRECT_REQUEST);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { LogLevel } from "../../../src/logger/Logger";

describe("PublicClientSPAConfiguration.ts Class Unit Tests", () => {

it("buildConfiguration assigns default functions", async () => {
it("buildPublicClientSPAConfiguration assigns default functions", async () => {
let emptyConfig: PublicClientSPAConfiguration = buildPublicClientSPAConfiguration({auth: null});
// Auth config checks
expect(emptyConfig.auth).to.be.not.null;
Expand Down Expand Up @@ -86,7 +86,7 @@ describe("PublicClientSPAConfiguration.ts Class Unit Tests", () => {
};

const testKeySet = ["testKey1", "testKey2"];
it("buildConfiguration correctly assigns new values", () => {
it("buildPublicClientSPAConfiguration correctly assigns new values", () => {
let newConfig: PublicClientSPAConfiguration = buildPublicClientSPAConfiguration({
auth: {
clientId: TEST_CONFIG.MSAL_CLIENT_ID,
Expand Down
10 changes: 6 additions & 4 deletions lib/msal-common/test/app/module/AuthModule.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ chai.use(chaiAsPromised);
import { AuthModule } from "../../../src/app/module/AuthModule";
import { ModuleConfiguration } from "../../../src/app/config/ModuleConfiguration";
import { AuthenticationParameters } from "../../../src/request/AuthenticationParameters";
import { TEST_HASHES } from "../../utils/StringConstants";
import { TokenResponse } from "../../../src/response/TokenResponse";
import { CodeResponse } from "../../../src/response/CodeResponse";
import { TokenRenewParameters } from "../../../src/request/TokenRenewParameters";
Expand Down Expand Up @@ -59,9 +58,12 @@ describe("AuthModule.ts Class Unit Tests", () => {
expect(authModule).to.be.not.null;
expect(authModule instanceof AuthModule).to.be.true;
});
});

describe("getAccount()", () => {

it("Handles the authentication response - currently return null", () => {
expect(() => authModule.handleFragmentResponse(TEST_HASHES.TEST_SUCCESS_ID_TOKEN_HASH)).to.throw("Method not implemented");
})
it("returns null if nothing is in the cache", () => {

});
});
});
10 changes: 7 additions & 3 deletions samples/VanillaJSTestApp/index_authCode.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
};

const tokenRequest = {
scopes: ["Mail.Read"],
scopes: ["User.Read"],
forceRefresh: true
};

Expand All @@ -53,11 +53,15 @@
// instantiate MSAL
const myMSALObj = new msal.PublicClientApplication(msalConfig);
// register callback for redirect usecases
myMSALObj.handleRedirectCallback(authRedirectCallBack);
// myMSALObj.handleRedirectCallback(authRedirectCallBack);
// signin and acquire a token silently with POPUP flow. Fall back in case of failure with silent acquisition to popup
function signIn() {
try {
myMSALObj.loginRedirect(loginRequest);
myMSALObj.loginPopup(loginRequest).then(tokenResponse => {
console.log("Response: " + JSON.stringify(tokenResponse));
}).catch(error => {
console.error("Auth Error: " + error);
});
} catch (e) {
console.log(e);
}
Expand Down

0 comments on commit db98b9f

Please sign in to comment.