Skip to content

Commit

Permalink
Merge pull request #2112 from AzureAD/msal-node/client-credential-grant
Browse files Browse the repository at this point in the history
[msal-node][msal-common] Adds support for client credential grant
  • Loading branch information
sangonzal committed Aug 19, 2020
2 parents 92e7f3d + c10c920 commit 3ec753d
Show file tree
Hide file tree
Showing 19 changed files with 14,077 additions and 58 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"type": "minor",
"comment": "Add support for acquiring tokens with client credentials grant",
"packageName": "@azure/msal-common",
"email": "sagonzal@microsoft.com",
"dependentChangeType": "patch",
"date": "2020-08-15T00:00:57.341Z"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"packageName": "@azure/msal-node",
"email": "sagonzal@microsoft.com",
"dependentChangeType": "patch",
"date": "2020-08-15T00:01:24.544Z"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"type": "none",
"comment": "Add client credential sample",
"packageName": "msal-node-client-credentials",
"email": "sagonzal@microsoft.com",
"dependentChangeType": "patch",
"date": "2020-08-15T00:01:57.173Z"
}
129 changes: 129 additions & 0 deletions lib/msal-common/src/client/ClientCredentialClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import { ClientConfiguration } from "../config/ClientConfiguration";
import { BaseClient } from "./BaseClient";
import { Authority } from "../authority/Authority";
import { RequestParameterBuilder } from "../request/RequestParameterBuilder";
import { ScopeSet } from "../request/ScopeSet";
import { GrantType } from "../utils/Constants";
import { ResponseHandler } from "../response/ResponseHandler";
import { AuthenticationResult } from "../response/AuthenticationResult";
import { ClientCredentialRequest } from "../request/ClientCredentialRequest";
import { CredentialFilter, CredentialCache } from "../cache/utils/CacheTypes";
import { CredentialType } from "../utils/Constants";
import { AccessTokenEntity } from "../cache/entities/AccessTokenEntity";
import { TimeUtils } from "../utils/TimeUtils";

/**
* OAuth2.0 client credential grant
*/
export class ClientCredentialClient extends BaseClient {

private scopeSet: ScopeSet;

constructor(configuration: ClientConfiguration) {
super(configuration);
}

public async acquireToken(request: ClientCredentialRequest): Promise<AuthenticationResult> {

this.scopeSet = new ScopeSet(request.scopes || []);

if (request.skipCache) {
return await this.executeTokenRequest(request, this.authority);
}

const cachedAuthenticationResult = this.getCachedAuthenticationResult();
if (cachedAuthenticationResult != null) {
return cachedAuthenticationResult;
} else {
return await this.executeTokenRequest(request, this.authority);
}
}

private getCachedAuthenticationResult(): AuthenticationResult {
const cachedAccessToken = this.readAccessTokenFromCache();
if (!cachedAccessToken ||
TimeUtils.isTokenExpired(cachedAccessToken.expiresOn, this.config.systemOptions.tokenRenewalOffsetSeconds)) {
return null;
}
return ResponseHandler.generateAuthenticationResult({
account: null,
accessToken: cachedAccessToken,
idToken: null,
refreshToken: null
}, null, true);
}

private readAccessTokenFromCache(): AccessTokenEntity {
const accessTokenFilter: CredentialFilter = {
homeAccountId: "",
environment: this.authority.canonicalAuthorityUrlComponents.HostNameAndPort,
credentialType: CredentialType.ACCESS_TOKEN,
clientId: this.config.authOptions.clientId,
realm: this.authority.tenant,
target: this.scopeSet.printScopesLowerCase()
};
const credentialCache: CredentialCache = this.cacheManager.getCredentialsFilteredBy(accessTokenFilter);
const accessTokens = Object.keys(credentialCache.accessTokens).map(key => credentialCache.accessTokens[key]);
if (accessTokens.length < 1) {
return null;
}
return accessTokens[0] as AccessTokenEntity;
}

private async executeTokenRequest(request: ClientCredentialRequest, authority: Authority)
: Promise<AuthenticationResult> {

const requestBody = this.createTokenRequestBody(request);
const headers: Record<string, string> = this.createDefaultTokenRequestHeaders();

const response = await this.executePostToTokenEndpoint(authority.tokenEndpoint, requestBody, headers);

const responseHandler = new ResponseHandler(
this.config.authOptions.clientId,
this.cacheManager,
this.cryptoUtils,
this.logger
);

responseHandler.validateTokenResponse(response.body);
const tokenResponse = responseHandler.handleServerTokenResponse(
response.body,
this.authority,
null,
null,
request.scopes
);

return tokenResponse;
}

private createTokenRequestBody(request: ClientCredentialRequest): string {
const parameterBuilder = new RequestParameterBuilder();

parameterBuilder.addClientId(this.config.authOptions.clientId);

parameterBuilder.addScopes(this.scopeSet);

parameterBuilder.addGrantType(GrantType.CLIENT_CREDENTIALS_GRANT);

const correlationId = request.correlationId || this.config.cryptoInterface.createNewGuid();
parameterBuilder.addCorrelationId(correlationId);

if (this.config.clientCredentials.clientSecret) {
parameterBuilder.addClientSecret(this.config.clientCredentials.clientSecret);
}

if (this.config.clientCredentials.clientAssertion) {
const clientAssertion = this.config.clientCredentials.clientAssertion;
parameterBuilder.addClientAssertion(clientAssertion.assertion);
parameterBuilder.addClientAssertionType(clientAssertion.assertionType);
}

return parameterBuilder.createQueryString();
}
}
14 changes: 1 addition & 13 deletions lib/msal-common/src/client/SilentFlowClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class SilentFlowClient extends BaseClient {
const cachedRefreshToken = this.readRefreshTokenFromCache(homeAccountId, environment);

// Check if refresh is forced, or if tokens are expired. If neither are true, return a token response with the found token entry.
if (request.forceRefresh || !cachedAccessToken || this.isTokenExpired(cachedAccessToken.expiresOn)) {
if (request.forceRefresh || !cachedAccessToken || TimeUtils.isTokenExpired(cachedAccessToken.expiresOn, this.config.systemOptions.tokenRenewalOffsetSeconds)) {
// no refresh Token
if (!cachedRefreshToken) {
throw ClientAuthError.createNoTokensFoundError();
Expand Down Expand Up @@ -140,16 +140,4 @@ export class SilentFlowClient extends BaseClient {
return this.cacheManager.getCredential(refreshTokenKey) as RefreshTokenEntity;
}

/**
* check if a token is expired based on given UTC time in seconds.
* @param expiresOn
*/
private isTokenExpired(expiresOn: string): boolean {
// check for access token expiry
const expirationSec = Number(expiresOn) || 0;
const offsetCurrentTimeSec = TimeUtils.nowSeconds() + this.config.systemOptions.tokenRenewalOffsetSeconds;

// If current time + offset is greater than token expiration time, then token is expired.
return (offsetCurrentTimeSec > expirationSec);
}
}
2 changes: 2 additions & 0 deletions lib/msal-common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
export { AuthorizationCodeClient} from "./client/AuthorizationCodeClient";
export { DeviceCodeClient } from "./client/DeviceCodeClient";
export { RefreshTokenClient } from "./client/RefreshTokenClient";
export { ClientCredentialClient } from "./client/ClientCredentialClient";
export { SilentFlowClient } from "./client/SilentFlowClient";
export { AuthOptions, SystemOptions, LoggerOptions, DEFAULT_SYSTEM_OPTIONS } from "./config/ClientConfiguration";
export { ClientConfiguration } from "./config/ClientConfiguration";
Expand Down Expand Up @@ -37,6 +38,7 @@ export { BaseAuthRequest } from "./request/BaseAuthRequest";
export { AuthorizationUrlRequest } from "./request/AuthorizationUrlRequest";
export { AuthorizationCodeRequest } from "./request/AuthorizationCodeRequest";
export { RefreshTokenRequest } from "./request/RefreshTokenRequest";
export { ClientCredentialRequest } from "./request/ClientCredentialRequest";
export { SilentFlowRequest } from "./request/SilentFlowRequest";
export { DeviceCodeRequest } from "./request/DeviceCodeRequest";
export { EndSessionRequest } from "./request/EndSessionRequest";
Expand Down
17 changes: 17 additions & 0 deletions lib/msal-common/src/request/ClientCredentialRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import { BaseAuthRequest } from "./BaseAuthRequest";

/**
* RefreshTokenRequest
* - scopes - Array of scopes the application is requesting access to.
* - authority - URL of the authority, the security token service (STS) from which MSAL will acquire tokens.
* - correlationId - Unique GUID set per request to trace a request end-to-end for telemetry purposes.
* - skipCache - Skip token cache lookup and force request to authority to get a a new token. Defaults to false.
*/
export type ClientCredentialRequest = BaseAuthRequest & {
skipCache?: boolean;
};
82 changes: 48 additions & 34 deletions lib/msal-common/src/response/ResponseHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,29 +83,35 @@ export class ResponseHandler {
const errString = `${serverResponse.error_codes} - [${serverResponse.timestamp}]: ${serverResponse.error_description} - Correlation ID: ${serverResponse.correlation_id} - Trace ID: ${serverResponse.trace_id}`;
throw new ServerError(serverResponse.error, errString);
}

// generate homeAccountId
if (serverResponse.client_info) {
this.clientInfo = buildClientInfo(serverResponse.client_info, this.cryptoObj);
if (!StringUtils.isEmpty(this.clientInfo.uid) && !StringUtils.isEmpty(this.clientInfo.utid)) {
this.homeAccountIdentifier = `${this.clientInfo.uid}.${this.clientInfo.utid}`;
}
}
}

/**
* Returns a constructed token response based on given string. Also manages the cache updates and cleanups.
* @param serverTokenResponse
* @param authority
*/
handleServerTokenResponse(serverTokenResponse: ServerAuthorizationTokenResponse, authority: Authority, cachedNonce?: string, cachedState?: string): AuthenticationResult {
// create an idToken object (not entity)
const idTokenObj = new IdToken(serverTokenResponse.id_token, this.cryptoObj);

// token nonce check (TODO: Add a warning if no nonce is given?)
if (!StringUtils.isEmpty(cachedNonce)) {
if (idTokenObj.claims.nonce !== cachedNonce) {
throw ClientAuthError.createNonceMismatchError();
handleServerTokenResponse(serverTokenResponse: ServerAuthorizationTokenResponse, authority: Authority, cachedNonce?: string, cachedState?: string, requestScopes?: string[]): AuthenticationResult {

// generate homeAccountId
if (serverTokenResponse.client_info) {
this.clientInfo = buildClientInfo(serverTokenResponse.client_info, this.cryptoObj);
if (!StringUtils.isEmpty(this.clientInfo.uid) && !StringUtils.isEmpty(this.clientInfo.utid)) {
this.homeAccountIdentifier = `${this.clientInfo.uid}.${this.clientInfo.utid}`;
}
} else {
this.homeAccountIdentifier = "";
}

let idTokenObj: IdToken = null;
if (!StringUtils.isEmpty(serverTokenResponse.id_token)) {
// create an idToken object (not entity)
idTokenObj = new IdToken(serverTokenResponse.id_token, this.cryptoObj);

// token nonce check (TODO: Add a warning if no nonce is given?)
if (!StringUtils.isEmpty(cachedNonce)) {
if (idTokenObj.claims.nonce !== cachedNonce) {
throw ClientAuthError.createNonceMismatchError();
}
}
}

Expand All @@ -115,7 +121,7 @@ export class ResponseHandler {
requestStateObj = ProtocolUtils.parseRequestState(this.cryptoObj, cachedState);
}

const cacheRecord = this.generateCacheRecord(serverTokenResponse, idTokenObj, authority, requestStateObj && requestStateObj.libraryState);
const cacheRecord = this.generateCacheRecord(serverTokenResponse, idTokenObj, authority, requestStateObj && requestStateObj.libraryState, requestScopes);
this.cacheStorage.saveCacheRecord(cacheRecord);

return ResponseHandler.generateAuthenticationResult(cacheRecord, idTokenObj, false, requestStateObj);
Expand All @@ -127,13 +133,7 @@ export class ResponseHandler {
* @param idTokenObj
* @param authority
*/
private generateCacheRecord(serverTokenResponse: ServerAuthorizationTokenResponse, idTokenObj: IdToken, authority: Authority, libraryState?: LibraryStateObject): CacheRecord {
// Account
const cachedAccount = this.generateAccountEntity(
serverTokenResponse,
idTokenObj,
authority
);
private generateCacheRecord(serverTokenResponse: ServerAuthorizationTokenResponse, idTokenObj: IdToken, authority: Authority, libraryState?: LibraryStateObject, requestScopes?: string[]): CacheRecord {

const reqEnvironment = authority.canonicalAuthorityUrlComponents.HostNameAndPort;
const env = TrustedAuthority.getCloudDiscoveryMetadata(reqEnvironment) ? TrustedAuthority.getCloudDiscoveryMetadata(reqEnvironment).preferred_cache : "";
Expand All @@ -144,6 +144,7 @@ export class ResponseHandler {

// IdToken
let cachedIdToken: IdTokenEntity = null;
let cachedAccount: AccountEntity = null;
if (!StringUtils.isEmpty(serverTokenResponse.id_token)) {
cachedIdToken = IdTokenEntity.createIdTokenEntity(
this.homeAccountIdentifier,
Expand All @@ -152,12 +153,20 @@ export class ResponseHandler {
this.clientId,
idTokenObj.claims.tid
);

cachedAccount = this.generateAccountEntity(
serverTokenResponse,
idTokenObj,
authority
);
}

// AccessToken
let cachedAccessToken: AccessTokenEntity = null;
if (!StringUtils.isEmpty(serverTokenResponse.access_token)) {
const responseScopes = ScopeSet.fromString(serverTokenResponse.scope);

// If scopes not returned in server response, use request scopes
const responseScopes = serverTokenResponse.scope ? ScopeSet.fromString(serverTokenResponse.scope) : new ScopeSet(requestScopes || []);

// Expiration calculation
const currentTime = TimeUtils.nowSeconds();
Expand All @@ -172,7 +181,7 @@ export class ResponseHandler {
env,
serverTokenResponse.access_token,
this.clientId,
idTokenObj.claims.tid,
idTokenObj ? idTokenObj.claims.tid : authority.tenant,
responseScopes.printScopesLowerCase(),
tokenExpirationSeconds,
extendedTokenExpirationSeconds
Expand Down Expand Up @@ -203,13 +212,16 @@ export class ResponseHandler {
private generateAccountEntity(serverTokenResponse: ServerAuthorizationTokenResponse, idToken: IdToken, authority: Authority): AccountEntity {
const authorityType = authority.authorityType;

// ADFS does not require client_info in the response
if(authorityType === AuthorityType.Adfs){
return AccountEntity.createADFSAccount(authority, idToken);
}

if (StringUtils.isEmpty(serverTokenResponse.client_info)) {
throw ClientAuthError.createClientInfoEmptyError(serverTokenResponse.client_info);
}

return (authorityType === AuthorityType.Adfs)?
AccountEntity.createADFSAccount(authority, idToken):
AccountEntity.createAccount(serverTokenResponse.client_info, authority, idToken, this.cryptoObj);
return AccountEntity.createAccount(serverTokenResponse.client_info, authority, idToken, this.cryptoObj);
}

/**
Expand Down Expand Up @@ -237,13 +249,15 @@ export class ResponseHandler {
if (cacheRecord.refreshToken) {
familyId = cacheRecord.refreshToken.familyId || null;
}
const uid = idTokenObj ? idTokenObj.claims.oid || idTokenObj.claims.sub : "";
const tid = idTokenObj ? idTokenObj.claims.tid : "";
return {
uniqueId: idTokenObj.claims.oid || idTokenObj.claims.sub,
tenantId: idTokenObj.claims.tid,
uniqueId: uid,
tenantId: tid,
scopes: responseScopes,
account: cacheRecord.account.getAccountInfo(),
idToken: idTokenObj.rawIdToken,
idTokenClaims: idTokenObj.claims,
account: cacheRecord.account ? cacheRecord.account.getAccountInfo() : null,
idToken: idTokenObj ? idTokenObj.rawIdToken : "",
idTokenClaims: idTokenObj ? idTokenObj.claims : null,
accessToken: accessToken,
fromCache: fromTokenCache,
expiresOn: expiresOn,
Expand Down
13 changes: 13 additions & 0 deletions lib/msal-common/src/utils/TimeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,17 @@ export class TimeUtils {
// Date.getTime() returns in milliseconds.
return Math.round(new Date().getTime() / 1000.0);
}

/**
* check if a token is expired based on given UTC time in seconds.
* @param expiresOn
*/
static isTokenExpired(expiresOn: string, offset: number): boolean {
// check for access token expiry
const expirationSec = Number(expiresOn) || 0;
const offsetCurrentTimeSec = TimeUtils.nowSeconds() + offset;

// If current time + offset is greater than token expiration time, then token is expired.
return (offsetCurrentTimeSec > expirationSec);
}
}
Loading

0 comments on commit 3ec753d

Please sign in to comment.