Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Skills] Add SkillValidation class #1461

Merged
merged 7 commits into from
Dec 4, 2019
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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