/*
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
* Licensed under the MIT License.
|
*/
|
|
import {
|
AADServerParamKeys,
|
AccessTokenEntity,
|
AccountEntity,
|
AccountInfo,
|
AuthenticationResult,
|
Authority,
|
AuthToken,
|
ClientAuthErrorCodes,
|
ClientConfiguration,
|
createClientAuthError,
|
CredentialFilter,
|
Constants,
|
IdTokenEntity,
|
RequestParameterBuilder,
|
RequestThumbprint,
|
ResponseHandler,
|
ScopeSet,
|
TimeUtils,
|
TokenClaims,
|
UrlString,
|
ClientAssertion,
|
getClientAssertion,
|
UrlUtils,
|
} from "@azure/msal-common/node";
|
import { ApiId } from "../utils/Constants.js";
|
import { EncodingUtils } from "../utils/EncodingUtils.js";
|
import { CommonOnBehalfOfRequest } from "../request/CommonOnBehalfOfRequest.js";
|
import { BaseClient } from "./BaseClient.js";
|
|
/**
|
* On-Behalf-Of client
|
* @public
|
*/
|
export class OnBehalfOfClient extends BaseClient {
|
private scopeSet: ScopeSet;
|
private userAssertionHash: string;
|
|
constructor(configuration: ClientConfiguration) {
|
super(configuration);
|
}
|
|
/**
|
* Public API to acquire tokens with on behalf of flow
|
* @param request - developer provided CommonOnBehalfOfRequest
|
*/
|
public async acquireToken(
|
request: CommonOnBehalfOfRequest
|
): Promise<AuthenticationResult | null> {
|
this.scopeSet = new ScopeSet(request.scopes || []);
|
|
// generate the user_assertion_hash for OBOAssertion
|
this.userAssertionHash = await this.cryptoUtils.hashString(
|
request.oboAssertion
|
);
|
|
if (request.skipCache || request.claims) {
|
return this.executeTokenRequest(
|
request,
|
this.authority,
|
this.userAssertionHash
|
);
|
}
|
|
try {
|
return await this.getCachedAuthenticationResult(request);
|
} catch (e) {
|
// Any failure falls back to interactive request, once we implement distributed cache, we plan to handle `createRefreshRequiredError` to refresh using the RT
|
return await this.executeTokenRequest(
|
request,
|
this.authority,
|
this.userAssertionHash
|
);
|
}
|
}
|
|
/**
|
* look up cache for tokens
|
* Find idtoken in the cache
|
* Find accessToken based on user assertion and account info in the cache
|
* Please note we are not yet supported OBO tokens refreshed with long lived RT. User will have to send a new assertion if the current access token expires
|
* This is to prevent security issues when the assertion changes over time, however, longlived RT helps retaining the session
|
* @param request - developer provided CommonOnBehalfOfRequest
|
*/
|
private async getCachedAuthenticationResult(
|
request: CommonOnBehalfOfRequest
|
): Promise<AuthenticationResult | null> {
|
// look in the cache for the access_token which matches the incoming_assertion
|
const cachedAccessToken = this.readAccessTokenFromCacheForOBO(
|
this.config.authOptions.clientId,
|
request
|
);
|
if (!cachedAccessToken) {
|
// Must refresh due to non-existent access_token.
|
this.serverTelemetryManager?.setCacheOutcome(
|
Constants.CacheOutcome.NO_CACHED_ACCESS_TOKEN
|
);
|
this.logger.info(
|
"SilentFlowClient:acquireCachedToken - No access token found in cache for the given properties.",
|
request.correlationId
|
);
|
throw createClientAuthError(
|
ClientAuthErrorCodes.tokenRefreshRequired
|
);
|
} else if (
|
TimeUtils.isTokenExpired(
|
cachedAccessToken.expiresOn,
|
this.config.systemOptions.tokenRenewalOffsetSeconds
|
)
|
) {
|
// Access token expired, will need to renewed
|
this.serverTelemetryManager?.setCacheOutcome(
|
Constants.CacheOutcome.CACHED_ACCESS_TOKEN_EXPIRED
|
);
|
this.logger.info(
|
`OnbehalfofFlow:getCachedAuthenticationResult - Cached access token is expired or will expire within ${this.config.systemOptions.tokenRenewalOffsetSeconds} seconds.`,
|
request.correlationId
|
);
|
throw createClientAuthError(
|
ClientAuthErrorCodes.tokenRefreshRequired
|
);
|
}
|
|
// fetch the idToken from cache
|
const cachedIdToken = this.readIdTokenFromCacheForOBO(
|
cachedAccessToken.homeAccountId,
|
request.correlationId
|
);
|
let idTokenClaims: TokenClaims | undefined;
|
let cachedAccount: AccountEntity | null = null;
|
if (cachedIdToken) {
|
idTokenClaims = AuthToken.extractTokenClaims(
|
cachedIdToken.secret,
|
EncodingUtils.base64Decode
|
);
|
const localAccountId = idTokenClaims.oid || idTokenClaims.sub;
|
const accountInfo: AccountInfo = {
|
homeAccountId: cachedIdToken.homeAccountId,
|
environment: cachedIdToken.environment,
|
tenantId: cachedIdToken.realm,
|
username: "",
|
localAccountId: localAccountId || "",
|
};
|
|
cachedAccount = this.cacheManager.getAccount(
|
this.cacheManager.generateAccountKey(accountInfo),
|
request.correlationId
|
);
|
}
|
|
// increment telemetry cache hit counter
|
if (this.config.serverTelemetryManager) {
|
this.config.serverTelemetryManager.incrementCacheHits();
|
}
|
|
return ResponseHandler.generateAuthenticationResult(
|
this.cryptoUtils,
|
this.authority,
|
{
|
account: cachedAccount,
|
accessToken: cachedAccessToken,
|
idToken: cachedIdToken,
|
refreshToken: null,
|
appMetadata: null,
|
},
|
true,
|
request,
|
this.performanceClient,
|
idTokenClaims
|
);
|
}
|
|
/**
|
* read idtoken from cache, this is a specific implementation for OBO as the requirements differ from a generic lookup in the cacheManager
|
* Certain use cases of OBO flow do not expect an idToken in the cache/or from the service
|
* @param atHomeAccountId - account id
|
*/
|
private readIdTokenFromCacheForOBO(
|
atHomeAccountId: string,
|
correlationId: string
|
): IdTokenEntity | null {
|
const idTokenFilter: CredentialFilter = {
|
homeAccountId: atHomeAccountId,
|
environment:
|
this.authority.canonicalAuthorityUrlComponents.HostNameAndPort,
|
credentialType: Constants.CredentialType.ID_TOKEN,
|
clientId: this.config.authOptions.clientId,
|
realm: this.authority.tenant,
|
};
|
|
const idTokenMap: Map<string, IdTokenEntity> =
|
this.cacheManager.getIdTokensByFilter(idTokenFilter, correlationId);
|
|
// When acquiring a token on behalf of an application, there might not be an id token in the cache
|
if (Object.values(idTokenMap).length < 1) {
|
return null;
|
}
|
return Object.values(idTokenMap)[0] as IdTokenEntity;
|
}
|
|
/**
|
* Fetches the cached access token based on incoming assertion
|
* @param clientId - client id
|
* @param request - developer provided CommonOnBehalfOfRequest
|
*/
|
private readAccessTokenFromCacheForOBO(
|
clientId: string,
|
request: CommonOnBehalfOfRequest
|
) {
|
const authScheme =
|
request.authenticationScheme ||
|
Constants.AuthenticationScheme.BEARER;
|
/*
|
* Distinguish between Bearer and PoP/SSH token cache types
|
* Cast to lowercase to handle "bearer" from ADFS
|
*/
|
const credentialType =
|
authScheme &&
|
authScheme.toLowerCase() !==
|
Constants.AuthenticationScheme.BEARER.toLowerCase()
|
? Constants.CredentialType.ACCESS_TOKEN_WITH_AUTH_SCHEME
|
: Constants.CredentialType.ACCESS_TOKEN;
|
|
const accessTokenFilter: CredentialFilter = {
|
credentialType: credentialType,
|
clientId,
|
target: ScopeSet.createSearchScopes(this.scopeSet.asArray()),
|
tokenType: authScheme,
|
keyId: request.sshKid,
|
userAssertionHash: this.userAssertionHash,
|
};
|
|
const accessTokens = this.cacheManager.getAccessTokensByFilter(
|
accessTokenFilter,
|
request.correlationId
|
);
|
|
const numAccessTokens = accessTokens.length;
|
if (numAccessTokens < 1) {
|
return null;
|
} else if (numAccessTokens > 1) {
|
throw createClientAuthError(
|
ClientAuthErrorCodes.multipleMatchingTokens
|
);
|
}
|
|
return accessTokens[0] as AccessTokenEntity;
|
}
|
|
/**
|
* Make a network call to the server requesting credentials
|
* @param request - developer provided CommonOnBehalfOfRequest
|
* @param authority - authority object
|
*/
|
private async executeTokenRequest(
|
request: CommonOnBehalfOfRequest,
|
authority: Authority,
|
userAssertionHash: string
|
): Promise<AuthenticationResult | null> {
|
const queryParametersString = this.createTokenQueryParameters(request);
|
const endpoint = UrlString.appendQueryString(
|
authority.tokenEndpoint,
|
queryParametersString
|
);
|
const requestBody = await this.createTokenRequestBody(request);
|
const headers: Record<string, string> =
|
this.createTokenRequestHeaders();
|
const thumbprint: RequestThumbprint = {
|
clientId: this.config.authOptions.clientId,
|
authority: request.authority,
|
scopes: request.scopes,
|
claims: request.claims,
|
authenticationScheme: request.authenticationScheme,
|
resourceRequestMethod: request.resourceRequestMethod,
|
resourceRequestUri: request.resourceRequestUri,
|
shrClaims: request.shrClaims,
|
sshKid: request.sshKid,
|
};
|
|
const reqTimestamp = TimeUtils.nowSeconds();
|
const response = await this.executePostToTokenEndpoint(
|
endpoint,
|
requestBody,
|
headers,
|
thumbprint,
|
request.correlationId
|
);
|
|
const responseHandler = new ResponseHandler(
|
this.config.authOptions.clientId,
|
this.cacheManager,
|
this.cryptoUtils,
|
this.logger,
|
this.performanceClient,
|
this.config.serializableCache,
|
this.config.persistencePlugin
|
);
|
|
responseHandler.validateTokenResponse(
|
response.body,
|
request.correlationId
|
);
|
const tokenResponse = await responseHandler.handleServerTokenResponse(
|
response.body,
|
this.authority,
|
reqTimestamp,
|
request,
|
ApiId.acquireTokenByOBO,
|
undefined,
|
userAssertionHash
|
);
|
|
return tokenResponse;
|
}
|
|
/**
|
* generate a server request in accepable format
|
* @param request - developer provided CommonOnBehalfOfRequest
|
*/
|
private async createTokenRequestBody(
|
request: CommonOnBehalfOfRequest
|
): Promise<string> {
|
const parameters = new Map<string, string>();
|
|
RequestParameterBuilder.addClientId(
|
parameters,
|
this.config.authOptions.clientId
|
);
|
|
RequestParameterBuilder.addScopes(parameters, request.scopes);
|
|
RequestParameterBuilder.addGrantType(
|
parameters,
|
Constants.GrantType.JWT_BEARER
|
);
|
|
RequestParameterBuilder.addClientInfo(parameters);
|
|
RequestParameterBuilder.addLibraryInfo(
|
parameters,
|
this.config.libraryInfo
|
);
|
RequestParameterBuilder.addApplicationTelemetry(
|
parameters,
|
this.config.telemetry.application
|
);
|
RequestParameterBuilder.addThrottling(parameters);
|
|
if (this.serverTelemetryManager) {
|
RequestParameterBuilder.addServerTelemetry(
|
parameters,
|
this.serverTelemetryManager
|
);
|
}
|
|
const correlationId =
|
request.correlationId ||
|
this.config.cryptoInterface.createNewGuid();
|
RequestParameterBuilder.addCorrelationId(parameters, correlationId);
|
|
RequestParameterBuilder.addRequestTokenUse(
|
parameters,
|
AADServerParamKeys.ON_BEHALF_OF
|
);
|
|
RequestParameterBuilder.addOboAssertion(
|
parameters,
|
request.oboAssertion
|
);
|
|
if (this.config.clientCredentials.clientSecret) {
|
RequestParameterBuilder.addClientSecret(
|
parameters,
|
this.config.clientCredentials.clientSecret
|
);
|
}
|
|
const clientAssertion: ClientAssertion | undefined =
|
this.config.clientCredentials.clientAssertion;
|
|
if (clientAssertion) {
|
RequestParameterBuilder.addClientAssertion(
|
parameters,
|
await getClientAssertion(
|
clientAssertion.assertion,
|
this.config.authOptions.clientId,
|
request.resourceRequestUri
|
)
|
);
|
RequestParameterBuilder.addClientAssertionType(
|
parameters,
|
clientAssertion.assertionType
|
);
|
}
|
|
if (
|
request.claims ||
|
(this.config.authOptions.clientCapabilities &&
|
this.config.authOptions.clientCapabilities.length > 0)
|
) {
|
RequestParameterBuilder.addClaims(
|
parameters,
|
request.claims,
|
this.config.authOptions.clientCapabilities
|
);
|
}
|
|
return UrlUtils.mapToQueryString(parameters);
|
}
|
}
|