@azure/msal-browser
Version:
Microsoft Authentication Library for js
811 lines (753 loc) • 30.1 kB
text/typescript
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import {
AuthorizationCodeClient,
UrlString,
AuthError,
ServerTelemetryManager,
Constants,
AuthorizeResponse,
ICrypto,
Logger,
IPerformanceClient,
PerformanceEvents,
ProtocolMode,
invokeAsync,
ServerResponseType,
UrlUtils,
InProgressPerformanceEvent,
CommonAuthorizationUrlRequest,
} from "@azure/msal-common/browser";
import { StandardInteractionClient } from "./StandardInteractionClient.js";
import {
ApiId,
INTERACTION_TYPE,
InteractionType,
TemporaryCacheKeys,
} from "../utils/BrowserConstants.js";
import * as BrowserUtils from "../utils/BrowserUtils.js";
import { EndSessionRequest } from "../request/EndSessionRequest.js";
import { EventType } from "../event/EventType.js";
import { NavigationOptions } from "../navigation/NavigationOptions.js";
import {
createBrowserAuthError,
BrowserAuthErrorCodes,
} from "../error/BrowserAuthError.js";
import { RedirectRequest } from "../request/RedirectRequest.js";
import { NativeMessageHandler } from "../broker/nativeBroker/NativeMessageHandler.js";
import { BrowserConfiguration } from "../config/Configuration.js";
import { BrowserCacheManager } from "../cache/BrowserCacheManager.js";
import { EventHandler } from "../event/EventHandler.js";
import { INavigationClient } from "../navigation/INavigationClient.js";
import { EventError } from "../event/EventMessage.js";
import { AuthenticationResult } from "../response/AuthenticationResult.js";
import * as ResponseHandler from "../response/ResponseHandler.js";
import * as Authorize from "../protocol/Authorize.js";
import { generatePkceCodes } from "../crypto/PkceGenerator.js";
import { generateEarKey } from "../crypto/BrowserCrypto.js";
function getNavigationType(): NavigationTimingType | undefined {
if (
typeof window === "undefined" ||
typeof window.performance === "undefined" ||
typeof window.performance.getEntriesByType !== "function"
) {
return undefined;
}
const navigationEntries = window.performance.getEntriesByType("navigation");
const navigation = navigationEntries.length
? (navigationEntries[0] as PerformanceNavigationTiming)
: undefined;
return navigation?.type;
}
export class RedirectClient extends StandardInteractionClient {
protected nativeStorage: BrowserCacheManager;
constructor(
config: BrowserConfiguration,
storageImpl: BrowserCacheManager,
browserCrypto: ICrypto,
logger: Logger,
eventHandler: EventHandler,
navigationClient: INavigationClient,
performanceClient: IPerformanceClient,
nativeStorageImpl: BrowserCacheManager,
nativeMessageHandler?: NativeMessageHandler,
correlationId?: string
) {
super(
config,
storageImpl,
browserCrypto,
logger,
eventHandler,
navigationClient,
performanceClient,
nativeMessageHandler,
correlationId
);
this.nativeStorage = nativeStorageImpl;
}
/**
* Redirects the page to the /authorize endpoint of the IDP
* @param request
*/
async acquireToken(request: RedirectRequest): Promise<void> {
const validRequest = await invokeAsync(
this.initializeAuthorizationRequest.bind(this),
PerformanceEvents.StandardInteractionClientInitializeAuthorizationRequest,
this.logger,
this.performanceClient,
this.correlationId
)(request, InteractionType.Redirect);
validRequest.platformBroker =
NativeMessageHandler.isPlatformBrokerAvailable(
this.config,
this.logger,
this.nativeMessageHandler,
request.authenticationScheme
);
const handleBackButton = (event: PageTransitionEvent) => {
// Clear temporary cache if the back button is clicked during the redirect flow.
if (event.persisted) {
this.logger.verbose(
"Page was restored from back/forward cache. Clearing temporary cache."
);
this.browserStorage.resetRequestCache();
this.eventHandler.emitEvent(
EventType.RESTORE_FROM_BFCACHE,
InteractionType.Redirect
);
}
};
const redirectStartPage = this.getRedirectStartPage(
request.redirectStartPage
);
this.logger.verbosePii(`Redirect start page: ${redirectStartPage}`);
// Cache start page, returns to this page after redirectUri if navigateToLoginRequestUrl is true
this.browserStorage.setTemporaryCache(
TemporaryCacheKeys.ORIGIN_URI,
redirectStartPage,
true
);
// Clear temporary cache if the back button is clicked during the redirect flow.
window.addEventListener("pageshow", handleBackButton);
try {
if (this.config.auth.protocolMode === ProtocolMode.EAR) {
await this.executeEarFlow(validRequest);
} else {
await this.executeCodeFlow(
validRequest,
request.onRedirectNavigate
);
}
} catch (e) {
if (e instanceof AuthError) {
e.setCorrelationId(this.correlationId);
}
window.removeEventListener("pageshow", handleBackButton);
throw e;
}
}
/**
* Executes auth code + PKCE flow
* @param request
* @returns
*/
async executeCodeFlow(
request: CommonAuthorizationUrlRequest,
onRedirectNavigate?: (url: string) => boolean | void
): Promise<void> {
const correlationId = request.correlationId;
const serverTelemetryManager = this.initializeServerTelemetryManager(
ApiId.acquireTokenRedirect
);
const pkceCodes = await invokeAsync(
generatePkceCodes,
PerformanceEvents.GeneratePkceCodes,
this.logger,
this.performanceClient,
correlationId
)(this.performanceClient, this.logger, correlationId);
const redirectRequest = {
...request,
codeChallenge: pkceCodes.challenge,
};
this.browserStorage.cacheAuthorizeRequest(
redirectRequest,
pkceCodes.verifier
);
try {
// Initialize the client
const authClient: AuthorizationCodeClient = await invokeAsync(
this.createAuthCodeClient.bind(this),
PerformanceEvents.StandardInteractionClientCreateAuthCodeClient,
this.logger,
this.performanceClient,
this.correlationId
)({
serverTelemetryManager,
requestAuthority: redirectRequest.authority,
requestAzureCloudOptions: redirectRequest.azureCloudOptions,
requestExtraQueryParameters:
redirectRequest.extraQueryParameters,
account: redirectRequest.account,
});
// Create acquire token url.
const navigateUrl = await invokeAsync(
Authorize.getAuthCodeRequestUrl,
PerformanceEvents.GetAuthCodeUrl,
this.logger,
this.performanceClient,
request.correlationId
)(
this.config,
authClient.authority,
redirectRequest,
this.logger,
this.performanceClient
);
// Show the UI once the url has been created. Response will come back in the hash, which will be handled in the handleRedirectCallback function.
return await this.initiateAuthRequest(
navigateUrl,
onRedirectNavigate
);
} catch (e) {
if (e instanceof AuthError) {
e.setCorrelationId(this.correlationId);
serverTelemetryManager.cacheFailedRequest(e);
}
throw e;
}
}
/**
* Executes EAR flow
* @param request
*/
async executeEarFlow(
request: CommonAuthorizationUrlRequest
): Promise<void> {
const correlationId = request.correlationId;
// Get the frame handle for the silent request
const discoveredAuthority = await invokeAsync(
this.getDiscoveredAuthority.bind(this),
PerformanceEvents.StandardInteractionClientGetDiscoveredAuthority,
this.logger,
this.performanceClient,
correlationId
)({
requestAuthority: request.authority,
requestAzureCloudOptions: request.azureCloudOptions,
requestExtraQueryParameters: request.extraQueryParameters,
account: request.account,
});
const earJwk = await invokeAsync(
generateEarKey,
PerformanceEvents.GenerateEarKey,
this.logger,
this.performanceClient,
correlationId
)();
const redirectRequest = {
...request,
earJwk: earJwk,
};
this.browserStorage.cacheAuthorizeRequest(redirectRequest);
const form = await Authorize.getEARForm(
document,
this.config,
discoveredAuthority,
redirectRequest,
this.logger,
this.performanceClient
);
form.submit();
}
/**
* Checks if navigateToLoginRequestUrl is set, and:
* - if true, performs logic to cache and navigate
* - if false, handles hash string and parses response
* @param hash {string} url hash
* @param parentMeasurement {InProgressPerformanceEvent} parent measurement
*/
async handleRedirectPromise(
hash: string = "",
request: CommonAuthorizationUrlRequest,
pkceVerifier: string,
parentMeasurement: InProgressPerformanceEvent
): Promise<AuthenticationResult | null> {
const serverTelemetryManager = this.initializeServerTelemetryManager(
ApiId.handleRedirectPromise
);
try {
const [serverParams, responseString] = this.getRedirectResponse(
hash || ""
);
if (!serverParams) {
// Not a recognized server response hash or hash not associated with a redirect request
this.logger.info(
"handleRedirectPromise did not detect a response as a result of a redirect. Cleaning temporary cache."
);
this.browserStorage.resetRequestCache();
// Do not instrument "no_server_response" if user clicked back button
if (getNavigationType() !== "back_forward") {
parentMeasurement.event.errorCode = "no_server_response";
} else {
this.logger.verbose(
"Back navigation event detected. Muting no_server_response error"
);
}
return null;
}
// If navigateToLoginRequestUrl is true, get the url where the redirect request was initiated
const loginRequestUrl =
this.browserStorage.getTemporaryCache(
TemporaryCacheKeys.ORIGIN_URI,
true
) || Constants.EMPTY_STRING;
const loginRequestUrlNormalized =
UrlString.removeHashFromUrl(loginRequestUrl);
const currentUrlNormalized = UrlString.removeHashFromUrl(
window.location.href
);
if (
loginRequestUrlNormalized === currentUrlNormalized &&
this.config.auth.navigateToLoginRequestUrl
) {
// We are on the page we need to navigate to - handle hash
this.logger.verbose(
"Current page is loginRequestUrl, handling response"
);
if (loginRequestUrl.indexOf("#") > -1) {
// Replace current hash with non-msal hash, if present
BrowserUtils.replaceHash(loginRequestUrl);
}
const handleHashResult = await this.handleResponse(
serverParams,
request,
pkceVerifier,
serverTelemetryManager
);
return handleHashResult;
} else if (!this.config.auth.navigateToLoginRequestUrl) {
this.logger.verbose(
"NavigateToLoginRequestUrl set to false, handling response"
);
return await this.handleResponse(
serverParams,
request,
pkceVerifier,
serverTelemetryManager
);
} else if (
!BrowserUtils.isInIframe() ||
this.config.system.allowRedirectInIframe
) {
/*
* Returned from authority using redirect - need to perform navigation before processing response
* Cache the hash to be retrieved after the next redirect
*/
this.browserStorage.setTemporaryCache(
TemporaryCacheKeys.URL_HASH,
responseString,
true
);
const navigationOptions: NavigationOptions = {
apiId: ApiId.handleRedirectPromise,
timeout: this.config.system.redirectNavigationTimeout,
noHistory: true,
};
/**
* Default behavior is to redirect to the start page and not process the hash now.
* The start page is expected to also call handleRedirectPromise which will process the hash in one of the checks above.
*/
let processHashOnRedirect: boolean = true;
if (!loginRequestUrl || loginRequestUrl === "null") {
// Redirect to home page if login request url is null (real null or the string null)
const homepage = BrowserUtils.getHomepage();
// Cache the homepage under ORIGIN_URI to ensure cached hash is processed on homepage
this.browserStorage.setTemporaryCache(
TemporaryCacheKeys.ORIGIN_URI,
homepage,
true
);
this.logger.warning(
"Unable to get valid login request url from cache, redirecting to home page"
);
processHashOnRedirect =
await this.navigationClient.navigateInternal(
homepage,
navigationOptions
);
} else {
// Navigate to page that initiated the redirect request
this.logger.verbose(
`Navigating to loginRequestUrl: ${loginRequestUrl}`
);
processHashOnRedirect =
await this.navigationClient.navigateInternal(
loginRequestUrl,
navigationOptions
);
}
// If navigateInternal implementation returns false, handle the hash now
if (!processHashOnRedirect) {
return await this.handleResponse(
serverParams,
request,
pkceVerifier,
serverTelemetryManager
);
}
}
return null;
} catch (e) {
if (e instanceof AuthError) {
(e as AuthError).setCorrelationId(this.correlationId);
serverTelemetryManager.cacheFailedRequest(e);
}
throw e;
}
}
/**
* Gets the response hash for a redirect request
* Returns null if interactionType in the state value is not "redirect" or the hash does not contain known properties
* @param hash
*/
protected getRedirectResponse(
userProvidedResponse: string
): [AuthorizeResponse | null, string] {
this.logger.verbose("getRedirectResponseHash called");
// Get current location hash from window or cache.
let responseString = userProvidedResponse;
if (!responseString) {
if (
this.config.auth.OIDCOptions.serverResponseType ===
ServerResponseType.QUERY
) {
responseString = window.location.search;
} else {
responseString = window.location.hash;
}
}
let response = UrlUtils.getDeserializedResponse(responseString);
if (response) {
try {
ResponseHandler.validateInteractionType(
response,
this.browserCrypto,
InteractionType.Redirect
);
} catch (e) {
if (e instanceof AuthError) {
this.logger.error(
`Interaction type validation failed due to ${e.errorCode}: ${e.errorMessage}`
);
}
return [null, ""];
}
BrowserUtils.clearHash(window);
this.logger.verbose(
"Hash contains known properties, returning response hash"
);
return [response, responseString];
}
const cachedHash = this.browserStorage.getTemporaryCache(
TemporaryCacheKeys.URL_HASH,
true
);
this.browserStorage.removeItem(
this.browserStorage.generateCacheKey(TemporaryCacheKeys.URL_HASH)
);
if (cachedHash) {
response = UrlUtils.getDeserializedResponse(cachedHash);
if (response) {
this.logger.verbose(
"Hash does not contain known properties, returning cached hash"
);
return [response, cachedHash];
}
}
return [null, ""];
}
/**
* Checks if hash exists and handles in window.
* @param hash
* @param state
*/
protected async handleResponse(
serverParams: AuthorizeResponse,
request: CommonAuthorizationUrlRequest,
codeVerifier: string,
serverTelemetryManager: ServerTelemetryManager
): Promise<AuthenticationResult> {
const state = serverParams.state;
if (!state) {
throw createBrowserAuthError(BrowserAuthErrorCodes.noStateInHash);
}
if (serverParams.ear_jwe) {
const discoveredAuthority = await invokeAsync(
this.getDiscoveredAuthority.bind(this),
PerformanceEvents.StandardInteractionClientGetDiscoveredAuthority,
this.logger,
this.performanceClient,
request.correlationId
)({
requestAuthority: request.authority,
requestAzureCloudOptions: request.azureCloudOptions,
requestExtraQueryParameters: request.extraQueryParameters,
account: request.account,
});
return invokeAsync(
Authorize.handleResponseEAR,
PerformanceEvents.HandleResponseEar,
this.logger,
this.performanceClient,
request.correlationId
)(
request,
serverParams,
ApiId.acquireTokenRedirect,
this.config,
discoveredAuthority,
this.browserStorage,
this.nativeStorage,
this.eventHandler,
this.logger,
this.performanceClient,
this.nativeMessageHandler
);
}
const authClient = await invokeAsync(
this.createAuthCodeClient.bind(this),
PerformanceEvents.StandardInteractionClientCreateAuthCodeClient,
this.logger,
this.performanceClient,
this.correlationId
)({ serverTelemetryManager, requestAuthority: request.authority });
return invokeAsync(
Authorize.handleResponseCode,
PerformanceEvents.HandleResponseCode,
this.logger,
this.performanceClient,
request.correlationId
)(
request,
serverParams,
codeVerifier,
ApiId.acquireTokenRedirect,
this.config,
authClient,
this.browserStorage,
this.nativeStorage,
this.eventHandler,
this.logger,
this.performanceClient,
this.nativeMessageHandler
);
}
/**
* Redirects window to given URL.
* @param urlNavigate
* @param onRedirectNavigateRequest - onRedirectNavigate callback provided on the request
*/
async initiateAuthRequest(
requestUrl: string,
onRedirectNavigateRequest?: (url: string) => boolean | void
): Promise<void> {
this.logger.verbose("RedirectHandler.initiateAuthRequest called");
// Navigate if valid URL
if (requestUrl) {
this.logger.infoPii(
`RedirectHandler.initiateAuthRequest: Navigate to: ${requestUrl}`
);
const navigationOptions: NavigationOptions = {
apiId: ApiId.acquireTokenRedirect,
timeout: this.config.system.redirectNavigationTimeout,
noHistory: false,
};
const onRedirectNavigate =
onRedirectNavigateRequest ||
this.config.auth.onRedirectNavigate;
// If onRedirectNavigate is implemented, invoke it and provide requestUrl
if (typeof onRedirectNavigate === "function") {
this.logger.verbose(
"RedirectHandler.initiateAuthRequest: Invoking onRedirectNavigate callback"
);
const navigate = onRedirectNavigate(requestUrl);
// Returning false from onRedirectNavigate will stop navigation
if (navigate !== false) {
this.logger.verbose(
"RedirectHandler.initiateAuthRequest: onRedirectNavigate did not return false, navigating"
);
await this.navigationClient.navigateExternal(
requestUrl,
navigationOptions
);
return;
} else {
this.logger.verbose(
"RedirectHandler.initiateAuthRequest: onRedirectNavigate returned false, stopping navigation"
);
return;
}
} else {
// Navigate window to request URL
this.logger.verbose(
"RedirectHandler.initiateAuthRequest: Navigating window to navigate url"
);
await this.navigationClient.navigateExternal(
requestUrl,
navigationOptions
);
return;
}
} else {
// Throw error if request URL is empty.
this.logger.info(
"RedirectHandler.initiateAuthRequest: Navigate url is empty"
);
throw createBrowserAuthError(
BrowserAuthErrorCodes.emptyNavigateUri
);
}
}
/**
* Use to log out the current user, and redirect the user to the postLogoutRedirectUri.
* Default behaviour is to redirect the user to `window.location.href`.
* @param logoutRequest
*/
async logout(logoutRequest?: EndSessionRequest): Promise<void> {
this.logger.verbose("logoutRedirect called");
const validLogoutRequest = this.initializeLogoutRequest(logoutRequest);
const serverTelemetryManager = this.initializeServerTelemetryManager(
ApiId.logout
);
try {
this.eventHandler.emitEvent(
EventType.LOGOUT_START,
InteractionType.Redirect,
logoutRequest
);
// Clear cache on logout
await this.clearCacheOnLogout(validLogoutRequest.account);
const navigationOptions: NavigationOptions = {
apiId: ApiId.logout,
timeout: this.config.system.redirectNavigationTimeout,
noHistory: false,
};
const authClient = await invokeAsync(
this.createAuthCodeClient.bind(this),
PerformanceEvents.StandardInteractionClientCreateAuthCodeClient,
this.logger,
this.performanceClient,
this.correlationId
)({
serverTelemetryManager,
requestAuthority: logoutRequest && logoutRequest.authority,
requestExtraQueryParameters:
logoutRequest?.extraQueryParameters,
account: (logoutRequest && logoutRequest.account) || undefined,
});
if (authClient.authority.protocolMode === ProtocolMode.OIDC) {
try {
authClient.authority.endSessionEndpoint;
} catch {
if (validLogoutRequest.account?.homeAccountId) {
void this.browserStorage.removeAccount(
validLogoutRequest.account?.homeAccountId
);
this.eventHandler.emitEvent(
EventType.LOGOUT_SUCCESS,
InteractionType.Redirect,
validLogoutRequest
);
return;
}
}
}
// Create logout string and navigate user window to logout.
const logoutUri: string =
authClient.getLogoutUri(validLogoutRequest);
this.eventHandler.emitEvent(
EventType.LOGOUT_SUCCESS,
InteractionType.Redirect,
validLogoutRequest
);
// Check if onRedirectNavigate is implemented, and invoke it if so
if (
logoutRequest &&
typeof logoutRequest.onRedirectNavigate === "function"
) {
const navigate = logoutRequest.onRedirectNavigate(logoutUri);
if (navigate !== false) {
this.logger.verbose(
"Logout onRedirectNavigate did not return false, navigating"
);
// Ensure interaction is in progress
if (!this.browserStorage.getInteractionInProgress()) {
this.browserStorage.setInteractionInProgress(
true,
INTERACTION_TYPE.SIGNOUT
);
}
await this.navigationClient.navigateExternal(
logoutUri,
navigationOptions
);
return;
} else {
// Ensure interaction is not in progress
this.browserStorage.setInteractionInProgress(false);
this.logger.verbose(
"Logout onRedirectNavigate returned false, stopping navigation"
);
}
} else {
// Ensure interaction is in progress
if (!this.browserStorage.getInteractionInProgress()) {
this.browserStorage.setInteractionInProgress(
true,
INTERACTION_TYPE.SIGNOUT
);
}
await this.navigationClient.navigateExternal(
logoutUri,
navigationOptions
);
return;
}
} catch (e) {
if (e instanceof AuthError) {
(e as AuthError).setCorrelationId(this.correlationId);
serverTelemetryManager.cacheFailedRequest(e);
}
this.eventHandler.emitEvent(
EventType.LOGOUT_FAILURE,
InteractionType.Redirect,
null,
e as EventError
);
this.eventHandler.emitEvent(
EventType.LOGOUT_END,
InteractionType.Redirect
);
throw e;
}
this.eventHandler.emitEvent(
EventType.LOGOUT_END,
InteractionType.Redirect
);
}
/**
* Use to get the redirectStartPage either from request or use current window
* @param requestStartPage
*/
protected getRedirectStartPage(requestStartPage?: string): string {
const redirectStartPage = requestStartPage || window.location.href;
return UrlString.getAbsoluteUrl(
redirectStartPage,
BrowserUtils.getCurrentUri()
);
}
}