From f123621a4f65819f89e161ec3fbba1ac46301f41 Mon Sep 17 00:00:00 2001 From: Robert Smayda Date: Thu, 22 Jul 2021 15:58:01 -0400 Subject: [PATCH] feat: allow token introspection as an authz option (#44) * feat: allow token introspection as an authz option * chore: minor updates * chore: address PR comments --- src/smartAuthorizationHelper.test.ts | 238 +++++++++++++++++++++++---- src/smartAuthorizationHelper.ts | 96 +++++++++-- src/smartConfig.ts | 22 ++- src/smartHandler.test.ts | 48 ++++++ src/smartHandler.ts | 33 +++- 5 files changed, 383 insertions(+), 54 deletions(-) diff --git a/src/smartAuthorizationHelper.test.ts b/src/smartAuthorizationHelper.test.ts index 1014c89..9597814 100644 --- a/src/smartAuthorizationHelper.test.ts +++ b/src/smartAuthorizationHelper.test.ts @@ -7,13 +7,21 @@ import jwksClient, { JwksClient } from 'jwks-rsa'; import { KeyObject } from 'crypto'; // eslint-disable-next-line import/no-unresolved -import fromKeyLike from 'jose/jwk/from_key_like'; +import fromKeyLike, { KeyLike } from 'jose/jwk/from_key_like'; // eslint-disable-next-line import/no-unresolved import SignJWT from 'jose/jwt/sign'; // eslint-disable-next-line import/no-unresolved import generateKeyPair from 'jose/util/generate_key_pair'; -import { hasReferenceToResource, getFhirResource, getFhirUser, verifyJwtToken } from './smartAuthorizationHelper'; -import { FhirResource } from './smartConfig'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { + hasReferenceToResource, + getFhirResource, + getFhirUser, + verifyJwtToken, + introspectJwtToken, +} from './smartAuthorizationHelper'; +import { FhirResource, IntrospectionOptions } from './smartConfig'; const apiUrl = 'https://fhirServer.com'; const id = '1234'; @@ -327,6 +335,34 @@ describe('hasReferenceToResource', () => { }); }); +function getDefaultPayload(iat: number, exp: number, aud: string | string[], iss: string | string[]) { + return { + ver: 1, + jti: 'AT.6a7kncTCpu1X9eo2QhH1z_WLUK4TyV43n_9I6kZNwPY', + iss, + aud, + iat, + exp, + cid: '0oa8muazKSyk9gP5y5d5', + uid: '00u85ozwjjWRd17PB5d5', + scp: ['fhirUser', 'openid', 'profile', 'launch/encounter', 'patient/Patient.read'], + sub: 'test@test.com', + fhirUser: 'Practitioner/1234', + }; +} +async function getSignedJwt( + payload: any, + kid: string, + privateKey: KeyLike, + headerContainsKidAttribute: boolean = true, +) { + let header: any = { alg: 'RS256', type: 'JWT' }; + if (headerContainsKidAttribute) { + header = { ...header, kid }; + } + return new SignJWT(payload).setProtectedHeader(header).sign(privateKey); +} + describe('verifyJwt', () => { const kid = 'abcd1234'; @@ -350,30 +386,6 @@ describe('verifyJwt', () => { const expectedAudValue = 'api://default'; const expectedIssValue = 'https://exampleAuthServer.com/oauth2'; - function getDefaultPayload(iat: number, exp: number, aud: string | string[], iss: string | string[]) { - return { - ver: 1, - jti: 'AT.6a7kncTCpu1X9eo2QhH1z_WLUK4TyV43n_9I6kZNwPY', - iss, - aud, - iat, - exp, - cid: '0oa8muazKSyk9gP5y5d5', - uid: '00u85ozwjjWRd17PB5d5', - scp: ['fhirUser', 'openid', 'profile', 'launch/encounter', 'patient/Patient.read'], - sub: 'test@test.com', - fhirUser: 'Practitioner/1234', - }; - } - - async function getSignedJwt(payload: any, headerContainsKidAttribute: boolean = true) { - let header: any = { alg: 'RS256', type: 'JWT' }; - if (headerContainsKidAttribute) { - header = { ...header, kid }; - } - return new SignJWT(payload).setProtectedHeader(header).sign(privateKey); - } - test('JWT is valid and verified', async () => { const payload = getDefaultPayload( Math.floor(Date.now() / 1000), @@ -381,7 +393,7 @@ describe('verifyJwt', () => { expectedAudValue, expectedIssValue, ); - const jwt = await getSignedJwt(payload); + const jwt = await getSignedJwt(payload, kid, privateKey); return expect(verifyJwtToken(jwt, expectedAudValue, expectedIssValue, client)).resolves.toEqual(payload); }); @@ -392,7 +404,7 @@ describe('verifyJwt', () => { expectedAudValue, expectedIssValue, ); - const jwt = await getSignedJwt(payload, false); + const jwt = await getSignedJwt(payload, kid, privateKey, false); return expect(verifyJwtToken(jwt, expectedAudValue, expectedIssValue, client)).rejects.toThrowError( new UnauthorizedError('Invalid access token'), ); @@ -405,7 +417,7 @@ describe('verifyJwt', () => { expectedAudValue, expectedIssValue, ); - const jwt = await getSignedJwt(payload); + const jwt = await getSignedJwt(payload, kid, privateKey); return expect(verifyJwtToken(jwt, expectedAudValue, expectedIssValue, client)).rejects.toThrowError( new UnauthorizedError('Invalid access token'), @@ -432,7 +444,7 @@ describe('verifyJwt', () => { aud, expectedIssValue, ); - const jwt = await getSignedJwt(payload); + const jwt = await getSignedJwt(payload, kid, privateKey); return expect( verifyJwtToken(jwt, expectedAudValue, 'https://exampleAuthServer.com/oauth2', client), ).rejects.toThrowError(new UnauthorizedError('Invalid access token')); @@ -451,7 +463,7 @@ describe('verifyJwt', () => { aud, expectedIssValue, ); - const jwt = await getSignedJwt(payload); + const jwt = await getSignedJwt(payload, kid, privateKey); return expect( verifyJwtToken(jwt, expectedAudValue, 'https://exampleAuthServer.com/oauth2', client), ).resolves.toEqual(payload); @@ -467,7 +479,7 @@ describe('verifyJwt', () => { aud, expectedIssValue, ); - const jwt = await getSignedJwt(payload); + const jwt = await getSignedJwt(payload, kid, privateKey); return expect(verifyJwtToken(jwt, audRegExp, expectedIssValue, client)).resolves.toEqual(payload); }); }); @@ -479,9 +491,167 @@ describe('verifyJwt', () => { expectedAudValue, expectedIssValue, ); - const jwt = await getSignedJwt(payload); + const jwt = await getSignedJwt(payload, kid, privateKey); return expect(verifyJwtToken(jwt, expectedAudValue, 'fakeIss', client)).rejects.toThrowError( new UnauthorizedError('Invalid access token'), ); }); }); + +describe('introspectJwtToken', () => { + const expectedAudValue = 'api://default'; + const expectedIssValue = 'https://exampleAuthServer.com/oauth2'; + const introspectUrl = `${expectedIssValue}/v1/introspect`; + const introspectionOptions: IntrospectionOptions = { + clientId: '123', + clientSecret: '1234', + introspectUrl, + }; + const kid = 'abcd1234'; + + let privateKey: KeyObject; + + beforeAll(async () => { + const keyPair = await generateKeyPair('RS256'); + privateKey = keyPair.privateKey; + }); + + test('valid and verified', async () => { + const payload = getDefaultPayload( + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000) + 10, + expectedAudValue, + expectedIssValue, + ); + const mock = new MockAdapter(axios); + mock.onPost(introspectUrl).reply(200, { + ...payload, + active: true, + }); + + const jwt = await getSignedJwt(payload, kid, privateKey); + return expect( + introspectJwtToken(jwt, expectedAudValue, expectedIssValue, introspectionOptions), + ).resolves.toEqual({ + ...payload, + active: true, + }); + }); + + test('Introspection returns 200 with active set to false', async () => { + const payload = getDefaultPayload( + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000) + 10, + expectedAudValue, + `${expectedIssValue}/`, + ); + const mock = new MockAdapter(axios); + mock.onPost(introspectUrl).reply(200, { + active: false, + }); + + const jwt = await getSignedJwt(payload, kid, privateKey); + return expect( + introspectJwtToken(jwt, expectedAudValue, expectedIssValue, introspectionOptions), + ).rejects.toThrowError(new UnauthorizedError('Invalid access token')); + }); + + test('Introspection returns 400', async () => { + const payload = getDefaultPayload( + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000) + 10, + expectedAudValue, + expectedIssValue, + ); + const mock = new MockAdapter(axios); + mock.onPost(introspectUrl).reply(400, { + active: false, + }); + + const jwt = await getSignedJwt(payload, kid, privateKey); + return expect( + introspectJwtToken(jwt, expectedAudValue, expectedIssValue, introspectionOptions), + ).rejects.toThrowError(new UnauthorizedError('Invalid access token')); + }); + test('Introspection returns 401', async () => { + const payload = getDefaultPayload( + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000) + 10, + expectedAudValue, + expectedIssValue, + ); + const mock = new MockAdapter(axios); + mock.onPost(introspectUrl).reply(401, { + active: false, + }); + + const jwt = await getSignedJwt(payload, kid, privateKey); + return expect( + introspectJwtToken(jwt, expectedAudValue, expectedIssValue, introspectionOptions), + ).rejects.toThrowError(new UnauthorizedError('Invalid access token')); + }); + test('Introspection returns 403', async () => { + const payload = getDefaultPayload( + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000) + 10, + expectedAudValue, + expectedIssValue, + ); + const mock = new MockAdapter(axios); + mock.onPost(introspectUrl).reply(403, { + active: false, + }); + + const jwt = await getSignedJwt(payload, kid, privateKey); + return expect( + introspectJwtToken(jwt, expectedAudValue, expectedIssValue, introspectionOptions), + ).rejects.toThrowError(new UnauthorizedError('Invalid access token')); + }); + test('Introspection returns 500', async () => { + const payload = getDefaultPayload( + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000) + 10, + expectedAudValue, + expectedIssValue, + ); + const mock = new MockAdapter(axios); + mock.onPost(introspectUrl).reply(500, { + active: false, + }); + + const jwt = await getSignedJwt(payload, kid, privateKey); + return expect( + introspectJwtToken(jwt, expectedAudValue, expectedIssValue, introspectionOptions), + ).rejects.toThrowError(new UnauthorizedError('Invalid access token')); + }); + test('Introspection returns network error', async () => { + const payload = getDefaultPayload( + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000) + 10, + expectedAudValue, + expectedIssValue, + ); + const mock = new MockAdapter(axios); + mock.onPost(introspectUrl).networkError(); + + const jwt = await getSignedJwt(payload, kid, privateKey); + return expect( + introspectJwtToken(jwt, expectedAudValue, expectedIssValue, introspectionOptions), + ).rejects.toThrowError(new UnauthorizedError('Invalid access token')); + }); + test('Introspection returns timeout', async () => { + const payload = getDefaultPayload( + Math.floor(Date.now() / 1000), + Math.floor(Date.now() / 1000) + 10, + expectedAudValue, + expectedIssValue, + ); + const mock = new MockAdapter(axios); + mock.onPost(introspectUrl).timeout(); + + const jwt = await getSignedJwt(payload, kid, privateKey); + return expect( + introspectJwtToken(jwt, expectedAudValue, expectedIssValue, introspectionOptions), + ).rejects.toThrowError(new UnauthorizedError('Invalid access token')); + }); +}); diff --git a/src/smartAuthorizationHelper.ts b/src/smartAuthorizationHelper.ts index 7c02ef5..abcd8d7 100644 --- a/src/smartAuthorizationHelper.ts +++ b/src/smartAuthorizationHelper.ts @@ -5,14 +5,17 @@ import { FhirVersion, UnauthorizedError } from 'fhir-works-on-aws-interface'; import jwksClient, { JwksClient } from 'jwks-rsa'; import { decode, verify } from 'jsonwebtoken'; +import axios from 'axios'; import resourceReferencesMatrixV4 from './schema/fhirResourceReferencesMatrix.v4.0.1.json'; import resourceReferencesMatrixV3 from './schema/fhirResourceReferencesMatrix.v3.0.1.json'; -import { FhirResource } from './smartConfig'; +import { FhirResource, IntrospectionOptions } from './smartConfig'; import getComponentLogger from './loggerBuilder'; export const FHIR_USER_REGEX = /^(?(http|https):\/\/([A-Za-z0-9\-\\.:%$_/])+)\/(?Person|Practitioner|RelatedPerson|Patient)\/(?[A-Za-z0-9\-.]+)$/; export const FHIR_RESOURCE_REGEX = /^((?(http|https):\/\/([A-Za-z0-9\-\\.:%$_/])+)\/)?(?[A-Z][a-zA-Z]+)\/(?[A-Za-z0-9\-.]+)$/; +const GENERIC_ERR_MESSAGE = 'Invalid access token'; + export function getFhirUser(fhirUserValue: string): FhirResource { const match = fhirUserValue.match(FHIR_USER_REGEX); if (match) { @@ -117,29 +120,100 @@ export function getJwksClient(jwksUri: string): JwksClient { }); } +export function decodeJwtToken(token: string, expectedAudValue: string | RegExp, expectedIssValue: string) { + const decodedAccessToken = decode(token, { complete: true }); + if (decodedAccessToken === null || typeof decodedAccessToken === 'string') { + logger.warn('access_token could not be decoded into an object'); + throw new UnauthorizedError(GENERIC_ERR_MESSAGE); + } + + const { aud, iss } = decodedAccessToken.payload; + + if (expectedIssValue !== iss) { + logger.warn('access_token has unexpected `iss`'); + throw new UnauthorizedError(GENERIC_ERR_MESSAGE); + } + + let audArray: string[] = aud; + if (typeof audArray === 'string') { + audArray = [aud]; + } + const audMatch: boolean = audArray.some( + (audience: string) => + (typeof expectedAudValue === 'string' && expectedAudValue === audience) || + (expectedAudValue instanceof RegExp && expectedAudValue.test(audience)), + ); + if (!audMatch) { + logger.warn('access_token has unexpected `aud`'); + throw new UnauthorizedError(GENERIC_ERR_MESSAGE); + } + + return decodedAccessToken; +} + export async function verifyJwtToken( token: string, expectedAudValue: string | RegExp, expectedIssValue: string, client: JwksClient, ) { - const genericErrorMessage = 'Invalid access token'; - const decodedAccessToken = decode(token, { complete: true }); - if (decodedAccessToken === null || typeof decodedAccessToken === 'string') { - logger.error('access_token could not be decoded into an object'); - throw new UnauthorizedError(genericErrorMessage); - } + const decodedAccessToken = decodeJwtToken(token, expectedAudValue, expectedIssValue); const { kid } = decodedAccessToken.header; if (!kid) { - logger.error('JWT verification failed. JWT "kid" attribute is required in the header'); - throw new UnauthorizedError(genericErrorMessage); + logger.warn('JWT verification failed. JWT "kid" attribute is required in the header'); + throw new UnauthorizedError(GENERIC_ERR_MESSAGE); } try { const key = await client.getSigningKeyAsync(kid); return verify(token, key.getPublicKey(), { audience: expectedAudValue, issuer: expectedIssValue }); } catch (e) { - logger.error(e.message); - throw new UnauthorizedError(genericErrorMessage); + logger.warn(e.message); + throw new UnauthorizedError(GENERIC_ERR_MESSAGE); + } +} + +export async function introspectJwtToken( + token: string, + expectedAudValue: string | RegExp, + expectedIssValue: string, + introspectionOptions: IntrospectionOptions, +) { + // used to verify if `iss` or `aud` is valid + decodeJwtToken(token, expectedAudValue, expectedIssValue); + const { introspectUrl, clientId, clientSecret } = introspectionOptions; + + // setup basic authentication + const username = clientId; + const password = clientSecret; + const auth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + + try { + const response = await axios.post( + introspectUrl, + { token }, + { + headers: { + 'content-type': 'application/x-www-form-urlencoded', + accept: 'application/json', + authorization: auth, + 'cache-control': 'no-cache', + }, + }, + ); + if (!response.data.active) { + throw new UnauthorizedError(GENERIC_ERR_MESSAGE); + } + return response.data; + } catch (e) { + if (axios.isAxiosError(e)) { + if (e.response) { + logger.warn(`Status received from introspection call: ${e.response.status}`); + logger.warn(e.response.data); + } + } else { + logger.warn(e.message); + } + throw new UnauthorizedError(GENERIC_ERR_MESSAGE); } } diff --git a/src/smartConfig.ts b/src/smartConfig.ts index a457836..548d8d0 100644 --- a/src/smartConfig.ts +++ b/src/smartConfig.ts @@ -62,6 +62,22 @@ export interface UserIdentity extends KeyValueMap { patientLaunchContext?: FhirResource; } +export interface IntrospectionOptions { + /** + * Your FHIR server's ID, typically generated by your idp. + */ + clientId: string; + /** + * Your FHIR server's password, typically generated by your idp. + */ + clientSecret: string; + /** + * The introspection url where we will send the access_token to get verified + * @example http://www.authzserver.com/v1/introspect/ + */ + introspectUrl: string; +} + export interface SMARTConfig { version: number; /** @@ -98,5 +114,9 @@ export interface SMARTConfig { /** * Json Web Key Set endpoint used to get the key for verifying access_token */ - jwksEndpoint: string; + jwksEndpoint?: string; + /** + * Token introspection settings; if both introspection and jwksEndpoint are provided tokenIntrospection will be defaulted to. + */ + tokenIntrospection?: IntrospectionOptions; } diff --git a/src/smartHandler.test.ts b/src/smartHandler.test.ts index 8cd924b..8c55caf 100644 --- a/src/smartHandler.test.ts +++ b/src/smartHandler.test.ts @@ -445,6 +445,54 @@ describe('verifyAccessToken', () => { expectedUserIdentity, ); }); + + test('Use introspection', async () => { + // BUILD + const config: SMARTConfig = { + ...authZConfig, + tokenIntrospection: { + clientId: '123', + clientSecret: '456', + introspectUrl: `${authZConfig.expectedIssValue}/v1/introspect`, + }, + }; + const handler: SMARTHandler = new SMARTHandler(config, apiUrl, '4.0.1'); + const introspectionBody = { + ...baseAccessNoScopes, + scp: 'patient/Observation.read', + ...patientContext, + active: true, + }; + jest.spyOn(smartAuthorizationHelper, 'introspectJwtToken').mockImplementation(() => + Promise.resolve(introspectionBody), + ); + const expectedUserIdentity = getExpectedUserIdentity(introspectionBody); + + // CHECK + expect( + handler.verifyAccessToken({ + accessToken: 'fake', + operation: 'vread', + resourceType: 'Observation', + id: '1', + vid: '1', + }), + ).resolves.toMatchObject(expectedUserIdentity); + }); + test('Invalid configuration', async () => { + // BUILD + const config: SMARTConfig = { ...authZConfig, jwksEndpoint: '' }; + const handler: SMARTHandler = new SMARTHandler(config, apiUrl, '4.0.1'); + + // CHECK + expect( + handler.verifyAccessToken({ + accessToken: 'fake', + operation: 'create', + resourceType: 'Patient', + }), + ).rejects.toThrowError(Error); + }); }); describe('verifyAccessToken; System level export requests', () => { diff --git a/src/smartHandler.ts b/src/smartHandler.ts index 83b3db7..31a00eb 100644 --- a/src/smartHandler.ts +++ b/src/smartHandler.ts @@ -36,6 +36,7 @@ import { getFhirUser, getJwksClient, verifyJwtToken, + introspectJwtToken, } from './smartAuthorizationHelper'; import getComponentLogger from './loggerBuilder'; @@ -61,7 +62,7 @@ export class SMARTHandler implements Authorization { private readonly fhirVersion: FhirVersion; - private readonly jwksClient: JwksClient; + private readonly jwksClient?: JwksClient; /** * @param apiUrl URL of this FHIR service. Will be used to determine if a requestor is from this FHIR server or not @@ -81,18 +82,34 @@ export class SMARTHandler implements Authorization { this.config = config; this.apiUrl = apiUrl; this.fhirVersion = fhirVersion; - this.jwksClient = getJwksClient(this.config.jwksEndpoint); this.adminAccessTypes = adminAccessTypes; this.bulkDataAccessTypes = bulkDataAccessTypes; + if (this.config.jwksEndpoint && !this.config.tokenIntrospection) { + this.jwksClient = getJwksClient(this.config.jwksEndpoint); + } } async verifyAccessToken(request: VerifyAccessTokenRequest): Promise { - const decodedToken: any = await verifyJwtToken( - request.accessToken, - this.config.expectedAudValue, - this.config.expectedIssValue, - this.jwksClient, - ); + let decodedToken: any; + if (this.config.tokenIntrospection) { + decodedToken = await introspectJwtToken( + request.accessToken, + this.config.expectedAudValue, + this.config.expectedIssValue, + this.config.tokenIntrospection, + ); + } else if (this.jwksClient) { + decodedToken = await verifyJwtToken( + request.accessToken, + this.config.expectedAudValue, + this.config.expectedIssValue, + this.jwksClient, + ); + } else { + throw Error( + `Authorization configuration not properly set up. Either 'tokenIntrospection' or 'jwksEndpoint' must be present`, + ); + } const fhirUserClaim = get(decodedToken, this.config.fhirUserClaimPath); const patientContextClaim = get(decodedToken, `${this.config.launchContextPathPrefix}patient`);