From 135babebc1d93aa0890fe3a3c819935694bab532 Mon Sep 17 00:00:00 2001 From: Kalle Virtaneva Date: Mon, 27 Jan 2020 09:44:24 -0500 Subject: [PATCH] feat(jwt): add option to configure audience using a provider --- README.md | 50 +++- src/keys.ts | 7 + src/providers/authentication.provider.ts | 7 +- test/acceptance/authenticate.acceptance.ts | 285 ++++++++++++--------- 4 files changed, 217 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index 8c7054c..61314b1 100644 --- a/README.md +++ b/README.md @@ -14,21 +14,22 @@ Register the component and register the configuration for the action by injectin ## Options -| Property | Type | Details | -| :------- | :---: | :--------------------------------------------------------------------------------------------------------- | -| tenant | string | The LabShare Auth Tenant the Resource Server (API) is registered to. Example: `ncats`. | -| authUrl | string | The full URL to the LabShare Auth API the Resource Server (API) is registered to. Example: `https://a.labshare.org` | +| Property | Type | Details | +| :------- | :----: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| tenant | string | The LabShare Auth Tenant the Resource Server (API) is registered to. Example: `ncats`. | +| authUrl | string | The full URL to the LabShare Auth API the Resource Server (API) is registered to. Example: `https://a.labshare.org` | | audience | string | The audience of the Resource Server. This is a unique identifier for the API registered on the LabShare Auth Service. It does not need match an actual API deployment host. This is required to check if a Client (application) is allowed to access the API. Example: `https://my.api.com/v2`. Optional. | -| issuer | string | The issuer of the Bearer Token. Use this to validate the source of the Bearer Token. Optional. Example: `https://a.labshare.org/_api/ls` | +| issuer | string | The issuer of the Bearer Token. Use this to validate the source of the Bearer Token. Optional. Example: `https://a.labshare.org/_api/ls` | ## Bindings (optional) To perform additional customization of token validation, you can bind Loopback [Providers](https://loopback.io/doc/en/lb4/Creating-components.html#providers) to the following keys: -| Binding | Details | -| :------- | :------:| -| AuthenticationBindings.SECRET_PROVIDER | Obtains the secret used to validate the JWT signature. Not required when using tokens signed by LabShare Auth. | -| AuthenticationBindings.IS_REVOKED_CALLBACK_PROVIDER | Used to check if the token has been revoked. For example, a request to the `introspection_endpoint` can check if the JWT is still valid. | +| Binding | Details | +| :---------------------------------------------------- | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| `AuthenticationBindings.SECRET_PROVIDER` | Obtains the secret used to validate the JWT signature. Not required when using tokens signed by LabShare Auth. | +| `AuthenticationBindings.IS_REVOKED_CALLBACK_PROVIDER` | Used to check if the token has been revoked. For example, a request to the `introspection_endpoint` can check if the JWT is still valid. | +| `AuthenticationBindings.AUDIENCE_PROVIDER` | Provides the value for the audience the JWT will be validated against instead of using the audience configuration assigned to `AuthenticationBindings.AUTH_CONFIG`. | ### Example IsRevokedCallbackProvider @@ -47,7 +48,7 @@ export class IsRevokedCallbackProvider { try { // ... request to introspection endpoint // ... check if token is valid - + callback(null, isTokenRevoked); } catch (error) { callback(error, false); @@ -57,7 +58,7 @@ export class IsRevokedCallbackProvider { } ``` -### Example RS256 SecretProvider +### Example SecretProvider ``` import { jwk2pem } from 'pem-jwk'; @@ -99,11 +100,29 @@ export class SecretProvider { } ``` -#### Example +### Example AudienceProvider + +``` +export class SecretProvider { + constructor( + // Constructor can inject services used to figure out which audience to use + @inject(RestBindings.Http.REQUEST) + public request: Request + ) {} + + public async value() { + // Determine the audience the JWT should be validated against, perhaps based on a value in the API request + return 'https://some.new.audience'; + } +} +``` + +#### Application Bootstrap Example ``` import { LbServicesAuthComponent } from '@labshare/lb-services-auth'; -import { CustomProvider } from 'my-custom.provider'; +import { SecretProvider } from 'secret.provider'; +import { AudienceProvider } from 'audience.provider'; import { IsRevokedCallbackProvider} from 'is-revoked-callback.provider'; app = new Application(); @@ -114,10 +133,13 @@ app.bind(AuthenticationBindings.AUTH_CONFIG).to({ }); // Assign a custom JWT secret provider (optional) -app.bind(AuthenticationBindings.SECRET_PROVIDER).toProvider(CustomProvider); +app.bind(AuthenticationBindings.SECRET_PROVIDER).toProvider(SecretProvider); // Assign a custom revoked JWT check (optional) app.bind(AuthenticationBindings.IS_REVOKED_CALLBACK_PROVIDER).toProvider(IsRevokedCallbackProvider); + +// Assign a custom audience provider (optional) +app.bind(AuthenticationBindings.IS_REVOKED_CALLBACK_PROVIDER).toProvider(AudienceProvider); ``` ## Actions diff --git a/src/keys.ts b/src/keys.ts index a4b91f3..b28cd0a 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -67,6 +67,13 @@ export namespace AuthenticationBindings { 'authentication.secretProvider', ); + /** + * The key used to get a custom JWT audience + */ + export const AUDIENCE_PROVIDER = BindingKey.create( + 'authentication.audienceProvider', + ); + /** * The key used to set the custom RS256/HS256 secret provider */ diff --git a/src/providers/authentication.provider.ts b/src/providers/authentication.provider.ts index 52d143d..c47c1d3 100644 --- a/src/providers/authentication.provider.ts +++ b/src/providers/authentication.provider.ts @@ -75,6 +75,10 @@ export class AuthenticateActionProvider implements Provider { optional: true, }) private readonly isRevokedCallbackProvider: Getter, + @inject.getter(AuthenticationBindings.AUDIENCE_PROVIDER, { + optional: true, + }) + private readonly audienceProvider: Getter, @inject(SequenceActions.PARSE_PARAMS) private readonly parseParams: ParseParams, @inject(SequenceActions.FIND_ROUTE) private readonly findRoute: FindRoute, @@ -123,6 +127,7 @@ export class AuthenticateActionProvider implements Provider { (await this.secretProvider()) || jwksClient.expressJwtSecret(jwksClientOptions); const isRevoked = await this.isRevokedCallbackProvider(); + const jwtAudience = (await this.audienceProvider()) || audience; // Validate JWT in Authorization Bearer header using RS256 await new Promise((resolve, reject) => { @@ -130,7 +135,7 @@ export class AuthenticateActionProvider implements Provider { getToken: parseToken, isRevoked, secret, - audience, // Optionally validate the audience and the issuer + audience: jwtAudience, // Optionally validate the audience and the issuer issuer, })(request, response, (error: any) => { if (error) { diff --git a/test/acceptance/authenticate.acceptance.ts b/test/acceptance/authenticate.acceptance.ts index f32a47e..10227cf 100644 --- a/test/acceptance/authenticate.acceptance.ts +++ b/test/acceptance/authenticate.acceptance.ts @@ -50,9 +50,6 @@ describe('Authentication Decorator', () => { /** * Helper for creating a bearer token for RS256 - * @param {string} sub - * @param {string} scope - * @param {string} audience */ function createToken( sub: string, @@ -77,117 +74,8 @@ describe('Authentication Decorator', () => { ); } - beforeEach(done => { - authApp = express(); - - // Create a JSON Web Key from the PEM - const jwk: any = pem2jwk(certificates.private); - - // Azure AD checks for the 'use' and 'kid' properties - jwk.kid = '1'; - jwk.use = 'sig'; - - authApp.get( - `/auth/${tenant}/.well-known/jwks.json`, - (req: any, res: any) => { - res.json({ - keys: [jwk], - }); - }, - ); - - portfinder.getPort((err, unusedPort) => { - if (err) { - done(err); - return; - } - - authServerPort = unusedPort; - authServerUrl = `http://localhost:${authServerPort}`; - authServer = http.createServer(authApp).listen(unusedPort); - - done(); - }); - }); - - afterEach(done => { - authServer.close(done); - }); - - beforeEach(givenAServer); - beforeEach(givenControllerInApp); - beforeEach(givenAuthenticatedSequence); - - it('authenticates successfully for correct credentials', async () => { - const token = createToken('abc', 'shared:scope'); - const client = whenIMakeRequestTo(server); - const res = await client - .get('/whoAmI') - .set('Authorization', 'Bearer ' + token); - expect(res.body.sub).to.equal('abc'); - }); - - it('authorizes an API with Resource Scope requirements', async () => { - const token = createToken('abc', 'shared:scope read:users'); - const client = whenIMakeRequestTo(server); - await client - .get('/users') - .set('Authorization', 'Bearer ' + token) - .expect('all users'); - }); - - it('authorizes an API by expanding dynamic resource scopes', async () => { - const token = createToken( - 'ls:read:users', - 'shared:scope ls:update:users:100', - ); - const client = whenIMakeRequestTo(server); - await client - .get('/ls/users?limit=100') - .set('Authorization', 'Bearer ' + token) - .expect(`received 100 users from tenant ls`); - }); - - it('returns an error for invalid bearer tokens', async () => { - const client = whenIMakeRequestTo(server); - await client - .get('/whoAmI') - .set('Authorization', 'Bearer ' + 'invalid-token') - .expect(401); - }); - - it('returns an error for bearer tokens missing required scope claims', async () => { - const token = createToken('abc'); - const client = whenIMakeRequestTo(server); - await client - .get('/users') - .set('Authorization', 'Bearer ' + token) - .expect(403); - }); - - it('allows anonymous requests to methods with no decorator', async () => { - class InfoController { - @get('/status') - status() { - return {running: true}; - } - } - - app.controller(InfoController); - await whenIMakeRequestTo(server) - .get('/status') - .expect(200, {running: true}); - }); - - async function givenAServer() { - app = new Application(); - app.component(LbServicesAuthComponent); - app.component(RestComponent); - app.bind(AuthenticationBindings.AUTH_CONFIG).to({ - authUrl: authServerUrl, - tenant, - }); - server = await app.getServer(RestServer); + function whenIMakeRequestTo(restServer: RestServer): Client { + return createClientForHandler(restServer.requestHandler); } function givenControllerInApp() { @@ -301,7 +189,170 @@ describe('Authentication Decorator', () => { server.sequence(MySequence); } - function whenIMakeRequestTo(restServer: RestServer): Client { - return createClientForHandler(restServer.requestHandler); - } + beforeEach(done => { + authApp = express(); + + // Create a JSON Web Key from the PEM + const jwk: any = pem2jwk(certificates.private); + + // Azure AD checks for the 'use' and 'kid' properties + jwk.kid = '1'; + jwk.use = 'sig'; + + authApp.get( + `/auth/${tenant}/.well-known/jwks.json`, + (req: any, res: any) => { + res.json({ + keys: [jwk], + }); + }, + ); + + portfinder.getPort((err, unusedPort) => { + if (err) { + done(err); + return; + } + + authServerPort = unusedPort; + authServerUrl = `http://localhost:${authServerPort}`; + authServer = http.createServer(authApp).listen(unusedPort); + + done(); + }); + }); + + afterEach(done => { + authServer.close(done); + }); + + describe('when configuring default options', () => { + beforeEach(async () => { + app = new Application(); + app.component(LbServicesAuthComponent); + app.component(RestComponent); + app.bind(AuthenticationBindings.AUTH_CONFIG).to({ + authUrl: authServerUrl, + tenant, + audience: defaultAudience, + }); + server = await app.getServer(RestServer); + }); + + beforeEach(givenControllerInApp); + beforeEach(givenAuthenticatedSequence); + + it('authenticates successfully for correct credentials', async () => { + const token = createToken('abc', 'shared:scope'); + const client = whenIMakeRequestTo(server); + const res = await client + .get('/whoAmI') + .set('Authorization', 'Bearer ' + token); + expect(res.body.sub).to.equal('abc'); + }); + + it('authorizes an API with Resource Scope requirements', async () => { + const token = createToken('abc', 'shared:scope read:users'); + const client = whenIMakeRequestTo(server); + await client + .get('/users') + .set('Authorization', 'Bearer ' + token) + .expect('all users'); + }); + + it('authorizes an API by expanding dynamic resource scopes', async () => { + const token = createToken( + 'ls:read:users', + 'shared:scope ls:update:users:100', + ); + const client = whenIMakeRequestTo(server); + await client + .get('/ls/users?limit=100') + .set('Authorization', 'Bearer ' + token) + .expect(`received 100 users from tenant ls`); + }); + + it('returns an error for invalid bearer tokens', async () => { + const client = whenIMakeRequestTo(server); + await client + .get('/whoAmI') + .set('Authorization', 'Bearer ' + 'invalid-token') + .expect(401); + }); + + it('returns an error for bearer tokens missing required scope claims', async () => { + const token = createToken('abc'); + const client = whenIMakeRequestTo(server); + await client + .get('/users') + .set('Authorization', 'Bearer ' + token) + .expect(403); + }); + + it('allows anonymous requests to methods with no decorator', async () => { + class InfoController { + @get('/status') + status() { + return {running: true}; + } + } + + app.controller(InfoController); + await whenIMakeRequestTo(server) + .get('/status') + .expect(200, {running: true}); + }); + }); + + describe('when configuring a custom audience provider', () => { + const newAudience = 'https://some.new.audience'; + + class AudienceProvider { + constructor() {} + + public async value() { + return newAudience; + } + } + + beforeEach(async () => { + app = new Application(); + app.component(LbServicesAuthComponent); + app.component(RestComponent); + app + .bind(AuthenticationBindings.AUDIENCE_PROVIDER) + .toProvider(AudienceProvider); + app.bind(AuthenticationBindings.AUTH_CONFIG).to({ + authUrl: authServerUrl, + tenant, + }); + + server = await app.getServer(RestServer); + }); + + beforeEach(givenControllerInApp); + beforeEach(givenAuthenticatedSequence); + + it('authenticates successfully with a valid audience', async () => { + const token = createToken('abc', 'shared:scope', newAudience); + const client = whenIMakeRequestTo(server); + const res = await client + .get('/whoAmI') + .set('Authorization', 'Bearer ' + token); + + expect(res.body.sub).to.equal('abc'); + }); + + it('fails to authenticate with an audience that does not match the one returned by the provider', async () => { + const token = createToken('abc', 'shared:scope', 'https://not.valid.com'); + const client = whenIMakeRequestTo(server); + const res = await client + .get('/whoAmI') + .set('Authorization', 'Bearer ' + token); + + expect(res.body.error.message).to.match( + `jwt audience invalid. expected: ${newAudience}`, + ); + }); + }); });