Skip to content

Commit

Permalink
[Skills] Add SkillValidation class (#1461)
Browse files Browse the repository at this point in the history
* add SkillValidation class

* add SkillValidation.isSkillToken() tests

* add more tests for SkillValidation

* cleanup bf-connector, duplicate necessary auth code in bb-dialogs

* add oAuthScope param to MicrosoftAppCredentials ctor & test

* SkillValidation changes & tests
  * export validateIdentity for testing
  * validateIdentity is not documented as to be "internal"
  * change signature on isSkillClaim to take Claim[]
  * isSkillClaim returns false if audience value is GovernmentConstants.ToBotFromChannelTokenIssuer
  * JwtTokenValidation.getAppIdFromClaims() now takes Claim[]
  • Loading branch information
stevengum authored Dec 4, 2019
1 parent 0535ebb commit a074f35
Show file tree
Hide file tree
Showing 11 changed files with 731 additions and 49 deletions.
93 changes: 93 additions & 0 deletions libraries/botbuilder-dialogs/src/prompts/skillsHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* @module botbuilder-dialogs
*/
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

// These internally exported constants and methods are duplicates of the AuthenticationConstants, JwtTokenValidation
// and SkillValidation exports from the Node.js-reliant botframework-connector library.
// The contents of this file should NOT be exported as this is a temporary patch for supporting Skills in
// Node.js bots without making botbuilder-dialogs not browser-compatible.
// isSkillClaim() is the only method directly called by the OAuthPrompt, but the other contents of this file are exported to facilitate the usage of the same tests as in botframework-connector.

export const AuthConstants = {
AppIdClaim: 'appid',
AudienceClaim: 'aud',
AuthorizedParty: 'azp',
ToBotFromChannelTokenIssuer: 'https://api.botframework.com',
VersionClaim: 'ver'
};

/**
* @ignore
* Checks if the given object of claims represents a skill.
* @remarks
* A skill claim should contain:
* An "AuthenticationConstants.VersionClaim" claim.
* An "AuthenticationConstants.AudienceClaim" claim.
* An "AuthenticationConstants.AppIdClaim" claim (v1) or an a "AuthenticationConstants.AuthorizedParty" claim (v2).
* And the appId claim should be different than the audience claim.
* The audience claim should be a guid, indicating that it is from another bot/skill.
* @param claims An object of claims.
* @returns {boolean} True if the object of claims is a skill claim, false if is not.
*/
export function isSkillClaim(claims: { [key: string]: any }): boolean {
if (!claims) {
throw new TypeError(`isSkillClaim(): missing claims.`);
}

const versionClaim = claims[AuthConstants.VersionClaim];
if (!versionClaim) {
// Must have a version claim.
return false;
}

const audClaim = claims[AuthConstants.AudienceClaim];
if (!audClaim || AuthConstants.ToBotFromChannelTokenIssuer === audClaim) {
// The audience is https://api.botframework.com and not an appId.
return false;
}

const appId = getAppIdFromClaims(claims);
if (!appId) {
return false;
}

// Skill claims must contain and app ID and the AppID must be different than the audience.
return appId !== audClaim;
}

/**
* @ignore
* Gets the AppId from a claims list.
* @remarks
* In v1 tokens the AppId is in the "ver" AuthenticationConstants.AppIdClaim claim.
* In v2 tokens the AppId is in the "azp" AuthenticationConstants.AuthorizedParty claim.
* If the AuthenticationConstants.VersionClaim is not present, this method will attempt to
* obtain the attribute from the AuthenticationConstants.AppIdClaim or if present.
*
* Throws a TypeError if claims is falsy.
* @param claims An object containing claims types and their values.
*/
export function getAppIdFromClaims(claims: { [key: string]: any }): string {
if (!claims) {
throw new TypeError(`getAppIdFromClaims(): missing claims.`);
}
let appId: string;

// Depending on Version, the AppId is either in the
// appid claim (Version 1) or the 'azp' claim (Version 2).
const tokenClaim = claims[AuthConstants.VersionClaim];
if (!tokenClaim || tokenClaim === '1.0') {
// No version or a version of '1.0' means we should look for
// the claim in the 'appid' claim.
appId = claims[AuthConstants.AppIdClaim];
} else if (tokenClaim === '2.0') {
// Version '2.0' puts the AppId in the 'azp' claim.
appId = claims[AuthConstants.AuthorizedParty];
}

return appId;
}
97 changes: 97 additions & 0 deletions libraries/botbuilder-dialogs/tests/internalSkillHelpers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
const assert = require('assert');
const { AuthConstants, isSkillClaim, getAppIdFromClaims } = require('../lib/prompts/skillsHelpers');

describe('Internal Skills-related methods', function() {
this.timeout(5000);
describe('isSkillClaim()', () => {
it('should return false for invalid claims and true for valid claims', () => {
const claims = {};
const audience = uuid();
const appId = uuid();

// No claims (falsey value)
try {
assert(!isSkillClaim());
throw new Error('isSkillClaim() should have failed with undefined parameter');
} catch (e) {
assert.strictEqual(e.message, 'isSkillClaim(): missing claims.');
}

// Empty list of claims
assert(!isSkillClaim(claims));

// No Audience claim
claims[AuthConstants.VersionClaim] = '1.0';
assert(!isSkillClaim(claims));

// Emulator Audience claim
claims[AuthConstants.AudienceClaim] = AuthConstants.ToBotFromChannelTokenIssuer;
assert(!isSkillClaim(claims));

// No AppId claim
claims[AuthConstants.AudienceClaim] = audience;
assert(!isSkillClaim(claims));

// AppId != Audience
claims[AuthConstants.AppIdClaim] = audience;
assert(!isSkillClaim(claims));

// All checks pass, should be good now
claims[AuthConstants.AudienceClaim] = appId;
assert(isSkillClaim(claims));
});
});

describe('getAppIdFromClaims()', () => {
it('should get appId from claims', () => {
const appId = 'uuid.uuid4()';
const v1Claims = {};
const v2Claims = { [AuthConstants.VersionClaim]: '2.0' };

// Empty array of Claims should yield undefined
assert.strictEqual(getAppIdFromClaims(v1Claims), undefined);

// AppId exists, but there is no version (assumes v1)
v1Claims[AuthConstants.AppIdClaim] = appId;
assert.strictEqual(getAppIdFromClaims(v1Claims), appId);

// AppId exists with v1 version
v1Claims[AuthConstants.VersionClaim] = '1.0';
assert.strictEqual(getAppIdFromClaims(v1Claims), appId);

// v2 version should yield undefined with no "azp" claim
v2Claims[AuthConstants.VersionClaim] = '2.0';
assert.strictEqual(getAppIdFromClaims(v2Claims), undefined);

// v2 version with azp
v2Claims[AuthConstants.AuthorizedParty] = appId;
assert.strictEqual(getAppIdFromClaims(v2Claims), appId);
});

it('should throw an error if claims is falsey', () => {
try {
getAppIdFromClaims();
} catch (e) {
assert.strictEqual(e.message, 'getAppIdFromClaims(): missing claims.');
}
});
});

describe('AuthConstants', () => {
it('should have correct values', () => {
// For reference see botframework-connector's AuthenticationConstants
assert.strictEqual(AuthConstants.AppIdClaim, 'appid');
assert.strictEqual(AuthConstants.AudienceClaim, 'aud');
assert.strictEqual(AuthConstants.AuthorizedParty, 'azp');
assert.strictEqual(AuthConstants.ToBotFromChannelTokenIssuer, 'https://api.botframework.com');
assert.strictEqual(AuthConstants.VersionClaim, 'ver');
});
});
});

function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import * as jwt from 'jsonwebtoken';
import { decode, VerifyOptions } from 'jsonwebtoken';
import { ClaimsIdentity } from './claimsIdentity';
import { AuthenticationConstants } from './authenticationConstants';
import { GovernmentConstants } from './governmentConstants';
Expand All @@ -21,7 +21,7 @@ export namespace EmulatorValidation {
/**
* TO BOT FROM EMULATOR: Token validation parameters when connecting to a channel.
*/
export const ToBotFromEmulatorTokenValidationParameters: jwt.VerifyOptions = {
export const ToBotFromEmulatorTokenValidationParameters: VerifyOptions = {
issuer: [
'https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/', // Auth v3.1, 1.0 token
'https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0', // Auth v3.1, 2.0 token
Expand Down Expand Up @@ -67,7 +67,7 @@ export namespace EmulatorValidation {
}

// Parse the Big Long String into an actual token.
const token: any = <any>jwt.decode(bearerToken, { complete: true });
const token: any = decode(bearerToken, { complete: true });
if (!token) {
return false;
}
Expand Down
1 change: 1 addition & 0 deletions libraries/botframework-connector/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export * from './endorsementsValidator';
export * from './claimsIdentity';
export * from './authenticationConfiguration';
export * from './authenticationConstants';
export * from './skillValidation';
33 changes: 22 additions & 11 deletions libraries/botframework-connector/src/auth/jwtTokenExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import * as jwt from 'jsonwebtoken';
import { decode, verify, VerifyOptions } from 'jsonwebtoken';
import { Claim, ClaimsIdentity } from './claimsIdentity';
import { EndorsementsValidator } from './endorsementsValidator';
import { OpenIdMetadata } from './openIdMetadata';
Expand All @@ -16,12 +16,12 @@ export class JwtTokenExtractor {
private static openIdMetadataCache: Map<string, OpenIdMetadata> = new Map<string, OpenIdMetadata>();

// Token validation parameters for this instance
public readonly tokenValidationParameters: jwt.VerifyOptions;
public readonly tokenValidationParameters: VerifyOptions;

// OpenIdMetadata for this instance
public readonly openIdMetadata: OpenIdMetadata;

constructor(tokenValidationParameters: jwt.VerifyOptions, metadataUrl: string, allowedSigningAlgorithms: string[]) {
constructor(tokenValidationParameters: VerifyOptions, metadataUrl: string, allowedSigningAlgorithms: string[]) {
this.tokenValidationParameters = { ...tokenValidationParameters };
this.tokenValidationParameters.algorithms = allowedSigningAlgorithms;
this.openIdMetadata = JwtTokenExtractor.getOrAddOpenIdMetadata(metadataUrl);
Expand All @@ -37,20 +37,24 @@ export class JwtTokenExtractor {
return metadata;
}

public async getIdentityFromAuthHeader(authorizationHeader: string, channelId: string): Promise<ClaimsIdentity | null> {
public async getIdentityFromAuthHeader(authorizationHeader: string, channelId: string, requiredEndorsements?: string[]): Promise<ClaimsIdentity | null> {
if (!authorizationHeader) {
return null;
}

const parts: string[] = authorizationHeader.split(' ');
if (parts.length === 2) {
return await this.getIdentity(parts[0], parts[1], channelId);
return await this.getIdentity(parts[0], parts[1], channelId, requiredEndorsements || []);
}

return null;
}

public async getIdentity(scheme: string, parameter: string, channelId: string): Promise<ClaimsIdentity | null> {
public async getIdentity(scheme: string, parameter: string, channelId: string, requiredEndorsements: string[]): Promise<ClaimsIdentity | null> {
if (!requiredEndorsements) {
throw new Error('JwtTokenExtractor.getIdentity() must be called valid a requiredEndorsements parameter');
}

// No header in correct scheme or no token
if (scheme !== 'Bearer' || !parameter) {
return null;
Expand All @@ -62,7 +66,7 @@ export class JwtTokenExtractor {
}

try {
return await this.validateToken(parameter, channelId);
return await this.validateToken(parameter, channelId, requiredEndorsements);
} catch (err) {
// tslint:disable-next-line:no-console
console.error('JwtTokenExtractor.getIdentity:err!', err);
Expand All @@ -71,7 +75,7 @@ export class JwtTokenExtractor {
}

private hasAllowedIssuer(jwtToken: string): boolean {
const decoded: any = <any>jwt.decode(jwtToken, { complete: true });
const decoded: any = decode(jwtToken, { complete: true });
const issuer: string = decoded.payload.iss;

if (Array.isArray(this.tokenValidationParameters.issuer)) {
Expand All @@ -85,9 +89,9 @@ export class JwtTokenExtractor {
return false;
}

private async validateToken(jwtToken: string, channelId: string): Promise<ClaimsIdentity> {
private async validateToken(jwtToken: string, channelId: string, requiredEndorsements: string[]): Promise<ClaimsIdentity> {

const decodedToken: any = <any>jwt.decode(jwtToken, { complete: true });
const decodedToken: any = decode(jwtToken, { complete: true });

// Update the signing tokens from the last refresh
const keyId: string = decodedToken.header.kid;
Expand All @@ -97,7 +101,7 @@ export class JwtTokenExtractor {
}

try {
const decodedPayload: any = <any>jwt.verify(jwtToken, metadata.key, this.tokenValidationParameters);
const decodedPayload: any = verify(jwtToken, metadata.key, this.tokenValidationParameters);

// enforce endorsements in openIdMetadadata if there is any endorsements associated with the key
const endorsements: any = metadata.endorsements;
Expand All @@ -107,6 +111,13 @@ export class JwtTokenExtractor {
if (!isEndorsed) {
throw new Error(`Could not validate endorsement for key: ${ keyId } with endorsements: ${ endorsements.join(',') }`);
}

// Verify that additional endorsements are satisfied. If no additional endorsements are expected, the requirement is satisfied as well
const additionalEndorsementsSatisfied = requiredEndorsements.every(endorsement => EndorsementsValidator.validate(endorsement, endorsements));

if (!additionalEndorsementsSatisfied) {
throw new Error(`Could not validate additional endorsement for key: ${keyId} with endorsements: ${requiredEndorsements.join(',')}. Expected endorsements: ${requiredEndorsements.join(',')}`);
}
}

if (this.tokenValidationParameters.algorithms) {
Expand Down
Loading

0 comments on commit a074f35

Please sign in to comment.