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: Token Caching and Cleanup #1185

Merged
13 changes: 13 additions & 0 deletions lib/msal-browser/src/app/PublicClientApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { CryptoOps } from "../crypto/CryptoOps";
import { IInteractionHandler } from "../interaction_handler/IInteractionHandler";
import { RedirectHandler } from "../interaction_handler/RedirectHandler";
import { BrowserConfigurationAuthError } from "../error/BrowserConfigurationAuthError";
import { BrowserConstants } from "../utils/BrowserConstants";
import { BrowserAuthError } from "../error/BrowserAuthError";

/**
* A type alias for an authResponseCallback function.
Expand Down Expand Up @@ -122,6 +124,9 @@ export class PublicClientApplication {
* @param {@link (AuthenticationParameters:type)}
*/
loginRedirect(request: AuthenticationParameters): void {
if (this.interactionInProgress()) {
throw BrowserAuthError.createInteractionInProgressError();
}
const interactionHandler = new RedirectHandler(this.authModule, this.browserStorage, this.authCallback);
interactionHandler.showUI(request);
}
Expand Down Expand Up @@ -207,4 +212,12 @@ export class PublicClientApplication {
}

// #endregion

// #region Helpers

private interactionInProgress() {
return this.browserStorage.getItem(BrowserConstants.INTERACTION_STATUS_KEY) === BrowserConstants.INTERACTION_IN_PROGRESS;
}

// #endregion
}
23 changes: 6 additions & 17 deletions lib/msal-browser/src/cache/BrowserStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export class BrowserStorage implements ICacheStorage {
private windowStorage: Storage;

private clientId: string;
private rollbackEnabled: boolean;

constructor(clientId: string, cacheConfig: CacheOptions) {
this.validateWindowStorage(cacheConfig.cacheLocation);
Expand All @@ -25,9 +24,6 @@ export class BrowserStorage implements ICacheStorage {
this.windowStorage = window[this.cacheConfig.cacheLocation];
this.clientId = clientId;

// This is hardcoded to true for now, we will roll this out as a configurable option in the future.
this.rollbackEnabled = true;

this.migrateCacheEntries();
}

Expand All @@ -51,7 +47,6 @@ export class BrowserStorage implements ICacheStorage {
* @param storeAuthStateInCookie
*/
private migrateCacheEntries() {

const idTokenKey = `${Constants.CACHE_PREFIX}.${PersistentCacheKeys.ID_TOKEN}`;
const clientInfoKey = `${Constants.CACHE_PREFIX}.${PersistentCacheKeys.CLIENT_INFO}`;
const errorKey = `${Constants.CACHE_PREFIX}.${ErrorCacheKeys.ERROR}`;
Expand Down Expand Up @@ -85,7 +80,7 @@ export class BrowserStorage implements ICacheStorage {
* @param key
* @param addInstanceId
*/
private generateCacheKey(key: string, addInstanceId: boolean): string {
private generateCacheKey(key: string): string {
try {
// Defined schemas do not need the key appended
this.validateObjectKey(key);
Expand All @@ -94,7 +89,7 @@ export class BrowserStorage implements ICacheStorage {
if (key.startsWith(`${Constants.CACHE_PREFIX}`) || key.startsWith(PersistentCacheKeys.ADAL_ID_TOKEN)) {
return key;
}
return addInstanceId ? `${Constants.CACHE_PREFIX}.${this.clientId}.${key}` : `${Constants.CACHE_PREFIX}.${key}`;
return `${Constants.CACHE_PREFIX}.${this.clientId}.${key}`;
sameerag marked this conversation as resolved.
Show resolved Hide resolved
}
}

Expand All @@ -107,18 +102,15 @@ export class BrowserStorage implements ICacheStorage {
}

setItem(key: string, value: string): void {
const msalKey = this.generateCacheKey(key, true);
const msalKey = this.generateCacheKey(key);
this.windowStorage.setItem(msalKey, value);
if (this.rollbackEnabled) {
this.windowStorage.setItem(this.generateCacheKey(key, false), value);
}
if (this.cacheConfig.storeAuthStateInCookie) {
this.setItemCookie(msalKey, value);
}
}

getItem(key: string): string {
const msalKey = this.generateCacheKey(key, true);
const msalKey = this.generateCacheKey(key);
const itemCookie = this.getItemCookie(msalKey);
if (this.cacheConfig.storeAuthStateInCookie && itemCookie) {
return itemCookie;
Expand All @@ -127,18 +119,15 @@ export class BrowserStorage implements ICacheStorage {
}

removeItem(key: string): void {
const msalKey = this.generateCacheKey(key, true);
const msalKey = this.generateCacheKey(key);
this.windowStorage.removeItem(msalKey);
if (this.rollbackEnabled) {
this.windowStorage.removeItem(this.generateCacheKey(key, false));
}
if (this.cacheConfig.storeAuthStateInCookie) {
this.clearItemCookie(msalKey);
}
}

containsKey(key: string): boolean {
const msalKey = this.generateCacheKey(key, true);
const msalKey = this.generateCacheKey(key);
return this.windowStorage.hasOwnProperty(msalKey) || this.windowStorage.hasOwnProperty(key);
}

Expand Down
8 changes: 8 additions & 0 deletions lib/msal-browser/src/error/BrowserAuthError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export const BrowserAuthErrorMessage = {
hashEmptyError: {
code: "hash_empty_error",
desc: "Hash value cannot be processed because it is empty."
},
interactionInProgress: {
code: "interaction_in_progress",
desc: "Interaction is currently in progress. Please ensure that this interaction has been completed before calling an interactive API."
}
};

Expand Down Expand Up @@ -72,4 +76,8 @@ export class BrowserAuthError extends AuthError {
static createEmptyHashError(hashValue: string): BrowserAuthError {
return new BrowserAuthError(BrowserAuthErrorMessage.hashEmptyError.code, `${BrowserAuthErrorMessage.hashEmptyError.desc} Given Url: ${hashValue}`);
}

static createInteractionInProgressError(): BrowserAuthError {
return new BrowserAuthError(BrowserAuthErrorMessage.interactionInProgress.code, BrowserAuthErrorMessage.interactionInProgress.desc);
}
}
5 changes: 4 additions & 1 deletion lib/msal-browser/src/interaction_handler/RedirectHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
*/
import { IInteractionHandler } from "./IInteractionHandler";
import { BrowserAuthError } from "../error/BrowserAuthError";
import { StringUtils, AuthorizationCodeModule, TemporaryCacheKeys, AuthenticationParameters, AuthError, TokenResponse } from "msal-common";
import { StringUtils, AuthorizationCodeModule, TemporaryCacheKeys, AuthenticationParameters, AuthError, TokenResponse, Constants } from "msal-common";
import { AuthCallback } from "../app/PublicClientApplication";
import { BrowserConfigurationAuthError } from "../error/BrowserConfigurationAuthError";
import { BrowserStorage } from "../cache/BrowserStorage";
import { BrowserConstants } from "../utils/BrowserConstants";

export class RedirectHandler extends IInteractionHandler {

Expand All @@ -28,6 +29,7 @@ export class RedirectHandler extends IInteractionHandler {
showUI(authRequest: AuthenticationParameters): void {
this.browserStorage.setItem(TemporaryCacheKeys.ORIGIN_URI, window.location.href);
this.authModule.createLoginUrl(authRequest).then((urlNavigate) => {
this.browserStorage.setItem(BrowserConstants.INTERACTION_STATUS_KEY, BrowserConstants.INTERACTION_IN_PROGRESS);
// Navigate if valid URL
if (urlNavigate && !StringUtils.isEmpty(urlNavigate)) {
this.authModule.logger.infoPii("Navigate to:" + urlNavigate);
Expand Down Expand Up @@ -67,6 +69,7 @@ export class RedirectHandler extends IInteractionHandler {
}

try {
this.browserStorage.removeItem(BrowserConstants.INTERACTION_STATUS_KEY);
const codeResponse = this.authModule.handleFragmentResponse(locationHash);
const tokenResponse: TokenResponse = await this.authModule.acquireToken(null, codeResponse);
this.authCallback(null, tokenResponse);
Expand Down
4 changes: 3 additions & 1 deletion lib/msal-browser/src/utils/BrowserConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
*/
export const BrowserConstants = {
CACHE_LOCATION_LOCAL: "localStorage",
CACHE_LOCATION_SESSION: "sessionStorage"
CACHE_LOCATION_SESSION: "sessionStorage",
INTERACTION_STATUS_KEY: "interaction.status",
INTERACTION_IN_PROGRESS: "interaction_in_progress"
sameerag marked this conversation as resolved.
Show resolved Hide resolved
};

export enum HTTP_REQUEST_TYPE {
Expand Down
15 changes: 12 additions & 3 deletions lib/msal-common/src/app/module/AuthorizationCodeModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,13 @@ export class AuthorizationCodeModule extends AuthModule {
// Update required cache entries for request
this.cacheManager.updateCacheEntries(requestParameters, request.account);

// Populate query parameters (sid/login_hint/domain_hint) and any other extraQueryParameters set by the developer
requestParameters.populateQueryParams();
try {
// Populate query parameters (sid/login_hint/domain_hint) and any other extraQueryParameters set by the developer
requestParameters.populateQueryParams();
} catch (e) {
this.cacheManager.resetTempCacheItems(requestParameters.state);
throw e;
}

const urlNavigate = await requestParameters.createNavigateUrl();

Expand All @@ -99,6 +104,7 @@ export class AuthorizationCodeModule extends AuthModule {

async acquireToken(request: TokenExchangeParameters, codeResponse: CodeResponse): Promise<TokenResponse> {
if (!codeResponse || !codeResponse.code) {
this.cacheManager.resetTempCacheItems(codeResponse.userRequestState);
throw ClientAuthError.createAuthCodeNullOrEmptyError();
}

Expand All @@ -110,6 +116,7 @@ export class AuthorizationCodeModule extends AuthModule {
try {
await acquireTokenAuthority.resolveEndpointsAsync();
} catch (e) {
this.cacheManager.resetTempCacheItems(codeResponse.userRequestState);
throw ClientAuthError.createEndpointDiscoveryIncompleteError(e);
}
}
Expand All @@ -134,7 +141,9 @@ export class AuthorizationCodeModule extends AuthModule {
try {
validateServerAuthorizationTokenResponse(acquiredTokenResponse);
const responseHandler = new ResponseHandler(this.clientConfig.auth.clientId, this.cacheStorage, this.cacheManager, this.cryptoObj);
return responseHandler.createTokenResponse(acquiredTokenResponse, tokenReqParams.state);
const tokenResponse = responseHandler.createTokenResponse(acquiredTokenResponse, tokenReqParams.state);
this.account = tokenResponse.account;
return tokenResponse;
} catch (e) {
this.cacheManager.resetTempCacheItems(tokenReqParams.state);
this.account = null;
Expand Down
21 changes: 21 additions & 0 deletions lib/msal-common/src/cache/AccessTokenCacheItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import { AccessTokenKey } from "./AccessTokenKey";
import { AccessTokenValue } from "./AccessTokenValue";

/**
* @hidden
*/
export class AccessTokenCacheItem {

key: AccessTokenKey;
value: AccessTokenValue;

constructor(key: AccessTokenKey, value: AccessTokenValue) {
this.key = key;
this.value = value;
}
}
26 changes: 26 additions & 0 deletions lib/msal-common/src/cache/AccessTokenKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import { ICrypto } from "../crypto/ICrypto";
import { UrlString } from "../url/UrlString";

/**
* @hidden
*/
export class AccessTokenKey {

authority: string;
clientId: string;
scopes: string;
homeAccountIdentifier: string;

constructor(authority: string, clientId: string, scopes: string, uid: string, utid: string, cryptoObj: ICrypto) {
const authorityUri = new UrlString(authority);
this.authority = authorityUri.urlString;
this.clientId = clientId;
this.scopes = scopes;
this.homeAccountIdentifier = `${cryptoObj.base64Encode(uid)}.${cryptoObj.base64Encode(utid)}`;
}
}
24 changes: 24 additions & 0 deletions lib/msal-common/src/cache/AccessTokenValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

/**
* @hidden
*/
export class AccessTokenValue {

accessToken: string;
idToken: string;
refreshToken: string;
expiresIn: string;
homeAccountIdentifier: string;

constructor(accessToken: string, idToken: string, refreshToken: string, expiresIn: string, homeAccountIdentifier: string) {
this.accessToken = accessToken;
this.idToken = idToken;
this.refreshToken = refreshToken;
this.expiresIn = expiresIn;
this.homeAccountIdentifier = homeAccountIdentifier;
}
}
47 changes: 35 additions & 12 deletions lib/msal-common/src/cache/CacheHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { ICacheStorage } from "./ICacheStorage";
import { Account } from "../auth/Account";
import { Authority } from "../auth/authority/Authority";
import { ServerCodeRequestParameters } from "../server/ServerCodeRequestParameters";
import { StringUtils } from "../utils/StringUtils";
import { ClientAuthError } from "../error/ClientAuthError";
import { AccessTokenCacheItem } from "./AccessTokenCacheItem";
import { AccessTokenKey } from "./AccessTokenKey";
import { AccessTokenValue } from "./AccessTokenValue";

export class CacheHelpers {

Expand Down Expand Up @@ -103,27 +108,45 @@ export class CacheHelpers {
*/
resetTempCacheItems(state: string): void {
let key: string;
// check state and remove associated cache
for (key in this.cacheStorage.getKeys()) {
if (!state || key.indexOf(state) !== -1) {
// check state and remove associated cache items
this.cacheStorage.getKeys().forEach((key) => {
if (StringUtils.isEmpty(state) || key.indexOf(state) !== -1) {
const splitKey = key.split(Constants.RESOURCE_DELIM);
const keyState = splitKey.length > 1 ? splitKey[splitKey.length-1]: null;
if (keyState === state && !this.tokenRenewalInProgress(keyState)) {
if (keyState === state) {
this.cacheStorage.removeItem(key);
}
}
}
// delete the interaction status cache
this.cacheStorage.removeItem(TemporaryCacheKeys.INTERACTION_STATUS);
});
// delete generic interactive request parameters
this.cacheStorage.removeItem(TemporaryCacheKeys.REQUEST_STATE);
this.cacheStorage.removeItem(TemporaryCacheKeys.ORIGIN_URI);
this.cacheStorage.removeItem(TemporaryCacheKeys.REDIRECT_REQUEST);
}

/**
* Return if the token renewal is still in progress
* @param stateValue
* Get all access tokens in the cache
* @param clientId
* @param homeAccountIdentifier
*/
tokenRenewalInProgress(stateValue: string): boolean {
const renewStatus = this.cacheStorage.getItem(TemporaryCacheKeys.RENEW_STATUS + stateValue);
return !!(renewStatus && renewStatus === Constants.INTERACTION_IN_PROGRESS);
getAllAccessTokens(clientId: string, authority: string): Array<AccessTokenCacheItem> {
const results = this.cacheStorage.getKeys().reduce((tokens, key) => {
const keyMatches = key.match(clientId) && key.match(authority) && key.match(TemporaryCacheKeys.SCOPES);
if (keyMatches) {
const value = this.cacheStorage.getItem(key);
if (value) {
try {
const parseAtKey = JSON.parse(key) as AccessTokenKey;
const newAccessTokenCacheItem = new AccessTokenCacheItem(parseAtKey, JSON.parse(value) as AccessTokenValue);
return tokens.concat([ newAccessTokenCacheItem ]);
} catch (e) {
throw ClientAuthError.createCacheParseError(key);
}
}
}
return tokens;
}, []);

return results;
}
}