/*
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
* Licensed under the MIT License.
|
*/
|
|
import {
|
Authority,
|
AuthorizeProtocol,
|
ClientConfigurationErrorCodes,
|
CommonAuthorizationUrlRequest,
|
createClientConfigurationError,
|
invokeAsync,
|
IPerformanceClient,
|
Logger,
|
PerformanceEvents,
|
PopTokenGenerator,
|
ProtocolMode,
|
RequestParameterBuilder,
|
CommonAuthorizationCodeRequest,
|
AuthorizationCodeClient,
|
ProtocolUtils,
|
ThrottlingUtils,
|
AuthorizeResponse,
|
ResponseHandler,
|
TimeUtils,
|
AuthorizationCodePayload,
|
ServerAuthorizationTokenResponse,
|
Constants,
|
} from "@azure/msal-common/browser";
|
import * as BrowserPerformanceEvents from "../telemetry/BrowserPerformanceEvents.js";
|
import { BrowserConfiguration } from "../config/Configuration.js";
|
import { ApiId, BrowserConstants } from "../utils/BrowserConstants.js";
|
import { version } from "../packageMetadata.js";
|
import { CryptoOps } from "../crypto/CryptoOps.js";
|
import {
|
BrowserAuthErrorCodes,
|
createBrowserAuthError,
|
} from "../error/BrowserAuthError.js";
|
import { AuthenticationResult } from "../response/AuthenticationResult.js";
|
import { InteractionHandler } from "../interaction_handler/InteractionHandler.js";
|
import { BrowserCacheManager } from "../cache/BrowserCacheManager.js";
|
import { PlatformAuthInteractionClient } from "../interaction_client/PlatformAuthInteractionClient.js";
|
import { EventHandler } from "../event/EventHandler.js";
|
import { decryptEarResponse } from "../crypto/BrowserCrypto.js";
|
import { IPlatformAuthHandler } from "../broker/nativeBroker/IPlatformAuthHandler.js";
|
|
/**
|
* Parsed representation of the clientdata response parameter from the /authorize endpoint.
|
*
|
* Format: urlencoded(account_type|error|sub_error|cloud_instance|caller_data_boundary)
|
*/
|
type ClientData = {
|
/** Account type: MSA, AAD */
|
accountType: string;
|
/** Error code string (e.g. "0x8004345C" for MSA) */
|
error: string;
|
/** Sub-error code string (e.g. "0x80043588" for MSA) */
|
subError: string;
|
/** Cloud instance hostname (e.g. "login.microsoftonline.com") */
|
cloudInstance: string;
|
/** Caller data boundary (e.g. "none" for MSA) */
|
callerDataBoundary: string;
|
};
|
|
const clientDataAccountTypeMapping = new Map([
|
["e", "AAD"],
|
["m", "MSA"],
|
]);
|
|
/**
|
* Parses the clientdata response parameter from the /authorize endpoint.
|
*
|
* Logically, the clientdata value is URL-encoded and pipe-delimited:
|
* urlencoded(account_type | error | sub_error | cloud_instance | caller_data_boundary)
|
*
|
* In normal browser flows, this value may already have been URL-decoded
|
* (e.g. by URLSearchParams). This function will only apply decodeURIComponent
|
* when the string appears to contain percent-encoded sequences to avoid
|
* double-decoding.
|
*
|
* @param clientdata - The raw clientdata string from the authorize response
|
* @returns Parsed ClientData object, or null if the input is empty/invalid
|
*/
|
export function parseClientData(clientdata?: string): ClientData | null {
|
if (!clientdata) {
|
return null;
|
}
|
|
try {
|
// Only decode when the string appears to contain percent-encoded sequences
|
const shouldDecode = /%(?:[0-9A-Fa-f]{2})/.test(clientdata);
|
const decoded = shouldDecode
|
? decodeURIComponent(clientdata)
|
: clientdata;
|
const parts = decoded.split("|");
|
|
if (parts.length < 5) {
|
return null;
|
}
|
|
return {
|
accountType:
|
clientDataAccountTypeMapping.get(parts[0]?.trim() || "") || "",
|
error: parts[1]?.trim() || "",
|
subError: parts[2]?.trim() || "",
|
cloudInstance: parts[3]?.trim() || "",
|
callerDataBoundary: parts[4]?.trim() || "",
|
};
|
} catch {
|
return null;
|
}
|
}
|
|
/**
|
* Instruments account type, error, and suberror from clientdata
|
*/
|
function instrumentClientData(
|
response: AuthorizeResponse,
|
correlationId: string,
|
performanceClient: IPerformanceClient
|
): void {
|
const parsed = parseClientData(response.clientdata);
|
parsed?.accountType &&
|
performanceClient.addFields(
|
{ accountType: parsed.accountType },
|
correlationId
|
);
|
parsed?.error &&
|
performanceClient.addFields(
|
{ serverErrorNo: parsed.error },
|
correlationId
|
);
|
parsed?.subError &&
|
performanceClient.addFields(
|
{ serverSubErrorNo: parsed.subError },
|
correlationId
|
);
|
}
|
|
/**
|
* Returns map of parameters that are applicable to all calls to /authorize whether using PKCE or EAR
|
* @param config
|
* @param authority
|
* @param request
|
* @param logger
|
* @param performanceClient
|
* @returns
|
*/
|
async function getStandardParameters(
|
config: BrowserConfiguration,
|
authority: Authority,
|
request: CommonAuthorizationUrlRequest,
|
logger: Logger,
|
performanceClient: IPerformanceClient
|
): Promise<Map<string, string>> {
|
const parameters = AuthorizeProtocol.getStandardAuthorizeRequestParameters(
|
{ ...config.auth, authority: authority },
|
request,
|
logger,
|
performanceClient
|
);
|
RequestParameterBuilder.addLibraryInfo(parameters, {
|
sku: BrowserConstants.MSAL_SKU,
|
version: version,
|
os: "",
|
cpu: "",
|
});
|
if (config.system.protocolMode !== ProtocolMode.OIDC) {
|
RequestParameterBuilder.addApplicationTelemetry(
|
parameters,
|
config.telemetry.application
|
);
|
}
|
|
if (request.platformBroker) {
|
// signal ests that this is a WAM call
|
RequestParameterBuilder.addNativeBroker(parameters);
|
|
// instrument JS-platform bridge specific fields
|
performanceClient.addFields(
|
{
|
isPlatformAuthorizeRequest: true,
|
},
|
request.correlationId
|
);
|
|
// pass the req_cnf for POP
|
if (
|
request.authenticationScheme === Constants.AuthenticationScheme.POP
|
) {
|
const cryptoOps = new CryptoOps(logger, performanceClient);
|
const popTokenGenerator = new PopTokenGenerator(
|
cryptoOps,
|
performanceClient
|
);
|
|
// req_cnf is always sent as a string for SPAs
|
let reqCnfData;
|
if (!request.popKid) {
|
const generatedReqCnfData = await invokeAsync(
|
popTokenGenerator.generateCnf.bind(popTokenGenerator),
|
PerformanceEvents.PopTokenGenerateCnf,
|
logger,
|
performanceClient,
|
request.correlationId
|
)(request, logger);
|
reqCnfData = generatedReqCnfData.reqCnfString;
|
} else {
|
reqCnfData = cryptoOps.encodeKid(request.popKid);
|
}
|
RequestParameterBuilder.addPopToken(parameters, reqCnfData);
|
}
|
}
|
|
RequestParameterBuilder.instrumentBrokerParams(
|
parameters,
|
request.correlationId,
|
performanceClient
|
);
|
|
return parameters;
|
}
|
|
/**
|
* Gets the full /authorize URL with request parameters when using Auth Code + PKCE
|
* @param config
|
* @param authority
|
* @param request
|
* @param logger
|
* @param performanceClient
|
* @returns
|
*/
|
export async function getAuthCodeRequestUrl(
|
config: BrowserConfiguration,
|
authority: Authority,
|
request: CommonAuthorizationUrlRequest,
|
logger: Logger,
|
performanceClient: IPerformanceClient
|
): Promise<string> {
|
if (!request.codeChallenge) {
|
throw createClientConfigurationError(
|
ClientConfigurationErrorCodes.pkceParamsMissing
|
);
|
}
|
|
const parameters = await invokeAsync(
|
getStandardParameters,
|
BrowserPerformanceEvents.GetStandardParams,
|
logger,
|
performanceClient,
|
request.correlationId
|
)(config, authority, request, logger, performanceClient);
|
RequestParameterBuilder.addResponseType(
|
parameters,
|
Constants.OAuthResponseType.CODE
|
);
|
|
RequestParameterBuilder.addCodeChallengeParams(
|
parameters,
|
request.codeChallenge,
|
Constants.S256_CODE_CHALLENGE_METHOD
|
);
|
|
// Merge extraQueryParameters and extraParameters to be appended to request URL
|
RequestParameterBuilder.addExtraParameters(parameters, {
|
...request.extraQueryParameters,
|
...request.extraParameters,
|
});
|
|
return AuthorizeProtocol.getAuthorizeUrl(authority, parameters);
|
}
|
|
/**
|
* Gets the form that will be posted to /authorize with request parameters when using EAR
|
*/
|
export async function getEARForm(
|
frame: Document,
|
config: BrowserConfiguration,
|
authority: Authority,
|
request: CommonAuthorizationUrlRequest,
|
logger: Logger,
|
performanceClient: IPerformanceClient
|
): Promise<HTMLFormElement> {
|
if (!request.earJwk) {
|
throw createBrowserAuthError(BrowserAuthErrorCodes.earJwkEmpty);
|
}
|
|
const parameters = await getStandardParameters(
|
config,
|
authority,
|
request,
|
logger,
|
performanceClient
|
);
|
|
RequestParameterBuilder.addResponseType(
|
parameters,
|
Constants.OAuthResponseType.IDTOKEN_TOKEN_REFRESHTOKEN
|
);
|
RequestParameterBuilder.addEARParameters(parameters, request.earJwk);
|
|
// Also add codeChallenge as backup in case EAR is not supported
|
RequestParameterBuilder.addCodeChallengeParams(
|
parameters,
|
request.codeChallenge,
|
Constants.S256_CODE_CHALLENGE_METHOD
|
);
|
|
RequestParameterBuilder.addExtraParameters(parameters, {
|
...request.extraParameters,
|
});
|
|
const queryParams = new Map<string, string>();
|
RequestParameterBuilder.addExtraParameters(
|
queryParams,
|
request.extraQueryParameters || {}
|
);
|
|
// Add correlationId to query params so gateway can propagate it to IDPs
|
RequestParameterBuilder.addCorrelationId(
|
queryParams,
|
request.correlationId
|
);
|
|
const url = AuthorizeProtocol.getAuthorizeUrl(authority, queryParams);
|
|
return createForm(frame, url, parameters);
|
}
|
|
/**
|
* Gets the form that will be posted to /authorize with request parameters when using POST method
|
*/
|
export async function getCodeForm(
|
frame: Document,
|
config: BrowserConfiguration,
|
authority: Authority,
|
request: CommonAuthorizationUrlRequest,
|
logger: Logger,
|
performanceClient: IPerformanceClient
|
): Promise<HTMLFormElement> {
|
const parameters = await getStandardParameters(
|
config,
|
authority,
|
request,
|
logger,
|
performanceClient
|
);
|
|
RequestParameterBuilder.addResponseType(
|
parameters,
|
Constants.OAuthResponseType.CODE
|
);
|
|
RequestParameterBuilder.addCodeChallengeParams(
|
parameters,
|
request.codeChallenge,
|
request.codeChallengeMethod || Constants.S256_CODE_CHALLENGE_METHOD
|
);
|
|
// Add extraParameters to the request body
|
RequestParameterBuilder.addExtraParameters(parameters, {
|
...request.extraParameters,
|
});
|
|
// Add extraQueryParameters to be appended to request URL
|
const queryParams = new Map<string, string>();
|
RequestParameterBuilder.addExtraParameters(
|
queryParams,
|
request.extraQueryParameters || {}
|
);
|
|
// Add correlationId to query params so gateway can propagate it to IDPs
|
RequestParameterBuilder.addCorrelationId(
|
queryParams,
|
request.correlationId
|
);
|
|
const url = AuthorizeProtocol.getAuthorizeUrl(authority, queryParams);
|
|
return createForm(frame, url, parameters);
|
}
|
|
/**
|
* Creates form element in the provided document with auth parameters in the post body
|
* @param frame
|
* @param authorizeUrl
|
* @param parameters
|
* @returns
|
*/
|
function createForm(
|
frame: Document,
|
authorizeUrl: string,
|
parameters: Map<string, string>
|
): HTMLFormElement {
|
const form = frame.createElement("form");
|
form.method = "post";
|
form.action = authorizeUrl;
|
|
parameters.forEach((value: string, key: string) => {
|
const param = frame.createElement("input");
|
param.hidden = true;
|
param.name = key;
|
param.value = value;
|
|
form.appendChild(param);
|
});
|
|
frame.body.appendChild(form);
|
return form;
|
}
|
|
/**
|
* Response handler when server returns accountId on the /authorize request
|
* @param request
|
* @param accountId
|
* @param apiId
|
* @param config
|
* @param browserStorage
|
* @param nativeStorage
|
* @param eventHandler
|
* @param logger
|
* @param performanceClient
|
* @param nativeMessageHandler
|
* @returns
|
*/
|
export async function handleResponsePlatformBroker(
|
request: CommonAuthorizationUrlRequest,
|
accountId: string,
|
apiId: ApiId,
|
config: BrowserConfiguration,
|
browserStorage: BrowserCacheManager,
|
nativeStorage: BrowserCacheManager,
|
eventHandler: EventHandler,
|
logger: Logger,
|
performanceClient: IPerformanceClient,
|
platformAuthProvider?: IPlatformAuthHandler
|
): Promise<AuthenticationResult> {
|
logger.verbose(
|
"Account id found, calling WAM for token",
|
request.correlationId
|
);
|
|
if (!platformAuthProvider) {
|
throw createBrowserAuthError(
|
BrowserAuthErrorCodes.nativeConnectionNotEstablished
|
);
|
}
|
const browserCrypto = new CryptoOps(logger, performanceClient);
|
const nativeInteractionClient = new PlatformAuthInteractionClient(
|
config,
|
browserStorage,
|
browserCrypto,
|
logger,
|
eventHandler,
|
config.system.navigationClient,
|
apiId,
|
performanceClient,
|
platformAuthProvider,
|
accountId,
|
nativeStorage,
|
request.correlationId
|
);
|
const { userRequestState } = ProtocolUtils.parseRequestState(
|
browserCrypto.base64Decode,
|
request.state
|
);
|
return invokeAsync(
|
nativeInteractionClient.acquireToken.bind(nativeInteractionClient),
|
BrowserPerformanceEvents.NativeInteractionClientAcquireToken,
|
logger,
|
performanceClient,
|
request.correlationId
|
)({
|
...request,
|
state: userRequestState,
|
prompt: undefined, // Server should handle the prompt, ideally native broker can do this part silently
|
});
|
}
|
|
/**
|
* Response handler when server returns code on the /authorize request
|
* @param request
|
* @param response
|
* @param codeVerifier
|
* @param authClient
|
* @param browserStorage
|
* @param logger
|
* @param performanceClient
|
* @returns
|
*/
|
export async function handleResponseCode(
|
request: CommonAuthorizationUrlRequest,
|
response: AuthorizeResponse,
|
codeVerifier: string,
|
apiId: ApiId,
|
config: BrowserConfiguration,
|
authClient: AuthorizationCodeClient,
|
browserStorage: BrowserCacheManager,
|
nativeStorage: BrowserCacheManager,
|
eventHandler: EventHandler,
|
logger: Logger,
|
performanceClient: IPerformanceClient,
|
platformAuthProvider?: IPlatformAuthHandler
|
): Promise<AuthenticationResult> {
|
// Remove throttle if it exists
|
ThrottlingUtils.removeThrottle(
|
browserStorage,
|
config.auth.clientId,
|
request
|
);
|
|
// Instrument clientdata telemetry fields from the authorize response
|
instrumentClientData(response, request.correlationId, performanceClient);
|
|
if (response.accountId) {
|
return invokeAsync(
|
handleResponsePlatformBroker,
|
BrowserPerformanceEvents.HandleResponsePlatformBroker,
|
logger,
|
performanceClient,
|
request.correlationId
|
)(
|
request,
|
response.accountId,
|
apiId,
|
config,
|
browserStorage,
|
nativeStorage,
|
eventHandler,
|
logger,
|
performanceClient,
|
platformAuthProvider
|
);
|
}
|
const authCodeRequest: CommonAuthorizationCodeRequest = {
|
...request,
|
code: response.code || "",
|
codeVerifier: codeVerifier,
|
};
|
// Create popup interaction handler.
|
const interactionHandler = new InteractionHandler(
|
authClient,
|
browserStorage,
|
authCodeRequest,
|
logger,
|
performanceClient
|
);
|
// Handle response from hash string.
|
const result = await invokeAsync(
|
interactionHandler.handleCodeResponse.bind(interactionHandler),
|
BrowserPerformanceEvents.HandleCodeResponse,
|
logger,
|
performanceClient,
|
request.correlationId
|
)(response, request, apiId);
|
|
return result;
|
}
|
|
/**
|
* Response handler when server returns ear_jwe on the /authorize request
|
* @param request
|
* @param response
|
* @param apiId
|
* @param config
|
* @param authority
|
* @param browserStorage
|
* @param nativeStorage
|
* @param eventHandler
|
* @param logger
|
* @param performanceClient
|
* @param nativeMessageHandler
|
* @returns
|
*/
|
export async function handleResponseEAR(
|
request: CommonAuthorizationUrlRequest,
|
response: AuthorizeResponse,
|
apiId: ApiId,
|
config: BrowserConfiguration,
|
authority: Authority,
|
browserStorage: BrowserCacheManager,
|
nativeStorage: BrowserCacheManager,
|
eventHandler: EventHandler,
|
logger: Logger,
|
performanceClient: IPerformanceClient,
|
platformAuthProvider?: IPlatformAuthHandler
|
): Promise<AuthenticationResult> {
|
// Remove throttle if it exists
|
ThrottlingUtils.removeThrottle(
|
browserStorage,
|
config.auth.clientId,
|
request
|
);
|
|
// Instrument clientdata telemetry fields from the authorize response
|
instrumentClientData(response, request.correlationId, performanceClient);
|
|
// Validate state & check response for errors
|
AuthorizeProtocol.validateAuthorizationResponse(response, request.state);
|
|
if (!response.ear_jwe) {
|
throw createBrowserAuthError(BrowserAuthErrorCodes.earJweEmpty);
|
}
|
|
if (!request.earJwk) {
|
throw createBrowserAuthError(BrowserAuthErrorCodes.earJwkEmpty);
|
}
|
|
const decryptedData = JSON.parse(
|
await invokeAsync(
|
decryptEarResponse,
|
BrowserPerformanceEvents.DecryptEarResponse,
|
logger,
|
performanceClient,
|
request.correlationId
|
)(request.earJwk, response.ear_jwe)
|
) as AuthorizeResponse & ServerAuthorizationTokenResponse;
|
|
if (decryptedData.accountId) {
|
return invokeAsync(
|
handleResponsePlatformBroker,
|
BrowserPerformanceEvents.HandleResponsePlatformBroker,
|
logger,
|
performanceClient,
|
request.correlationId
|
)(
|
request,
|
decryptedData.accountId,
|
apiId,
|
config,
|
browserStorage,
|
nativeStorage,
|
eventHandler,
|
logger,
|
performanceClient,
|
platformAuthProvider
|
);
|
}
|
|
const responseHandler = new ResponseHandler(
|
config.auth.clientId,
|
browserStorage,
|
new CryptoOps(logger, performanceClient),
|
logger,
|
performanceClient,
|
null,
|
null
|
);
|
|
// Validate response. This function throws a server error if an error is returned by the server.
|
responseHandler.validateTokenResponse(decryptedData, request.correlationId);
|
|
// Temporary until response handler is refactored to be more flow agnostic.
|
const additionalData: AuthorizationCodePayload = {
|
code: "",
|
state: request.state,
|
nonce: request.nonce,
|
client_info: decryptedData.client_info,
|
cloud_graph_host_name: decryptedData.cloud_graph_host_name,
|
cloud_instance_host_name: decryptedData.cloud_instance_host_name,
|
cloud_instance_name: decryptedData.cloud_instance_name,
|
msgraph_host: decryptedData.msgraph_host,
|
};
|
|
return (await invokeAsync(
|
responseHandler.handleServerTokenResponse.bind(responseHandler),
|
PerformanceEvents.HandleServerTokenResponse,
|
logger,
|
performanceClient,
|
request.correlationId
|
)(
|
decryptedData,
|
authority,
|
TimeUtils.nowSeconds(),
|
request,
|
apiId,
|
additionalData,
|
undefined,
|
undefined,
|
undefined,
|
undefined
|
)) as AuthenticationResult;
|
}
|