From 2917145d084137204944c2202573ac72f5003128 Mon Sep 17 00:00:00 2001 From: nflaig Date: Sun, 14 Jun 2020 15:11:11 +0200 Subject: [PATCH 1/2] feat(authentication): add support for multiple strategies on same method re #5310 Signed-off-by: nflaig --- .../decorators/authenticate.decorator.unit.ts | 13 +++ .../__tests__/unit/fixtures/mock-metadata.ts | 13 +++ .../__tests__/unit/fixtures/mock-strategy.ts | 15 ++- .../providers/auth-strategy.provider.unit.ts | 101 ++++++++++++++++++ .../providers/authentication.provider.unit.ts | 60 ++++++++++- .../src/decorators/authenticate.decorator.ts | 7 +- packages/authentication/src/keys.ts | 8 +- .../src/providers/auth-action.provider.ts | 73 ++++++++----- .../src/providers/auth-strategy.provider.ts | 52 +++++---- packages/authentication/src/types.ts | 2 +- 10 files changed, 287 insertions(+), 57 deletions(-) create mode 100644 packages/authentication/src/__tests__/unit/fixtures/mock-metadata.ts create mode 100644 packages/authentication/src/__tests__/unit/providers/auth-strategy.provider.unit.ts diff --git a/packages/authentication/src/__tests__/unit/decorators/authenticate.decorator.unit.ts b/packages/authentication/src/__tests__/unit/decorators/authenticate.decorator.unit.ts index df8beb161d21..5bdbcd871fa1 100644 --- a/packages/authentication/src/__tests__/unit/decorators/authenticate.decorator.unit.ts +++ b/packages/authentication/src/__tests__/unit/decorators/authenticate.decorator.unit.ts @@ -47,6 +47,19 @@ describe('Authentication', () => { expect(metaData).to.eql({strategy: 'my-strategy', options: {}}); }); + it('can add authenticate metadata to target method with strategies as array', () => { + class TestClass { + @authenticate(['my-strategy', 'my-strategy2']) + whoAmI() {} + } + + const metaData = getAuthenticateMetadata(TestClass, 'whoAmI'); + expect(metaData).to.eql({ + strategy: ['my-strategy', 'my-strategy2'], + options: {}, + }); + }); + it('adds authenticate metadata to target class', () => { @authenticate('my-strategy', {option1: 'value1', option2: 'value2'}) class TestClass { diff --git a/packages/authentication/src/__tests__/unit/fixtures/mock-metadata.ts b/packages/authentication/src/__tests__/unit/fixtures/mock-metadata.ts new file mode 100644 index 000000000000..c45dd186f625 --- /dev/null +++ b/packages/authentication/src/__tests__/unit/fixtures/mock-metadata.ts @@ -0,0 +1,13 @@ +import {AuthenticationMetadata} from '../../..'; + +const options = {option1: 'value1', option2: 'value2'}; + +export const mockAuthenticationMetadata: AuthenticationMetadata = { + strategy: 'MockStrategy', + options, +}; + +export const mockAuthenticationMetadata2: AuthenticationMetadata = { + strategy: ['MockStrategy', 'MockStrategy2'], + options, +}; diff --git a/packages/authentication/src/__tests__/unit/fixtures/mock-strategy.ts b/packages/authentication/src/__tests__/unit/fixtures/mock-strategy.ts index e9b2e02cbb62..7affcdd84ccf 100644 --- a/packages/authentication/src/__tests__/unit/fixtures/mock-strategy.ts +++ b/packages/authentication/src/__tests__/unit/fixtures/mock-strategy.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {Request} from '@loopback/rest'; -import {UserProfile} from '@loopback/security'; +import {securityId, UserProfile} from '@loopback/security'; import {AuthenticationStrategy} from '../../../types'; class AuthenticationError extends Error { @@ -15,7 +15,7 @@ class AuthenticationError extends Error { * Test fixture for a mock asynchronous authentication strategy */ export class MockStrategy implements AuthenticationStrategy { - name: 'MockStrategy'; + name = 'MockStrategy'; // user to return for successful authentication private mockUser: UserProfile; @@ -51,3 +51,14 @@ export class MockStrategy implements AuthenticationStrategy { return this.returnMockUser(); } } + +export class MockStrategy2 implements AuthenticationStrategy { + name = 'MockStrategy2'; + + async authenticate(request: Request): Promise { + if (request.headers?.testState2 === 'fail') { + throw new AuthenticationError(); + } + return {[securityId]: 'mock-id'}; + } +} diff --git a/packages/authentication/src/__tests__/unit/providers/auth-strategy.provider.unit.ts b/packages/authentication/src/__tests__/unit/providers/auth-strategy.provider.unit.ts new file mode 100644 index 000000000000..ef7cdf3fde99 --- /dev/null +++ b/packages/authentication/src/__tests__/unit/providers/auth-strategy.provider.unit.ts @@ -0,0 +1,101 @@ +import {Context} from '@loopback/core'; +import {expect} from '@loopback/testlab'; +import { + AuthenticationBindings, + AuthenticationStrategy, + AuthenticationStrategyProvider, +} from '../../..'; +import {AuthenticationMetadata} from '../../../types'; +import { + mockAuthenticationMetadata, + mockAuthenticationMetadata2, +} from '../fixtures/mock-metadata'; +import {MockStrategy, MockStrategy2} from '../fixtures/mock-strategy'; + +describe('AuthStrategyProvider', () => { + let strategyProvider: AuthenticationStrategyProvider; + const mockStrategy = new MockStrategy(); + const mockStrategy2 = new MockStrategy2(); + + beforeEach(() => { + givenAuthenticationStrategyProvider( + [mockStrategy, mockStrategy2], + mockAuthenticationMetadata2, + ); + }); + + describe('value()', () => { + it('should return the authentication strategies', async () => { + const strategies = await strategyProvider.value(); + + expect(strategies).to.not.be.undefined(); + expect(strategies![0]).to.be.equal(mockStrategy); + expect(strategies![1]).to.be.equal(mockStrategy2); + }); + + it('should only return the authentication strategy specified in the authentication metadata', async () => { + givenAuthenticationStrategyProvider( + [mockStrategy, mockStrategy2], + mockAuthenticationMetadata, + ); + + const strategies = await strategyProvider.value(); + + expect(strategies?.length).to.be.equal(1); + expect(strategies![0]).to.be.equal(mockStrategy); + }); + + it('should return undefined if the authentication metadata is not available', async () => { + givenAuthenticationStrategyProvider([mockStrategy], undefined); + + const strategies = await strategyProvider.value(); + + expect(strategies).to.be.undefined(); + }); + + it('should throw an error if the authentication strategy is not available', async () => { + givenAuthenticationStrategyProvider([], mockAuthenticationMetadata); + + await expect(strategyProvider.value()).to.be.rejected(); + + givenAuthenticationStrategyProvider([], mockAuthenticationMetadata2); + + await expect(strategyProvider.value()).to.be.rejected(); + }); + }); + + describe('context.get(bindingKey)', () => { + it('should return the authentication strategies', async () => { + const context = new Context(); + context + .bind(AuthenticationBindings.STRATEGY) + .to([mockStrategy, mockStrategy2]); + const strategies = await context.get( + AuthenticationBindings.STRATEGY, + ); + + expect(strategies[0]).to.be.equal(mockStrategy); + expect(strategies[1]).to.be.equal(mockStrategy2); + }); + + it('should return undefined if no authentication strategies are defined', async () => { + const context = new Context(); + context.bind(AuthenticationBindings.STRATEGY).to(undefined); + const strategies = await context.get( + AuthenticationBindings.STRATEGY, + ); + + expect(strategies).to.be.undefined(); + }); + }); + + function givenAuthenticationStrategyProvider( + strategies: AuthenticationStrategy[], + metadata: AuthenticationMetadata | undefined, + ) { + strategyProvider = new AuthenticationStrategyProvider( + () => Promise.resolve(strategies), + metadata, + ); + } +}); diff --git a/packages/authentication/src/__tests__/unit/providers/authentication.provider.unit.ts b/packages/authentication/src/__tests__/unit/providers/authentication.provider.unit.ts index 5114dd5d1154..aa7220e046f3 100644 --- a/packages/authentication/src/__tests__/unit/providers/authentication.provider.unit.ts +++ b/packages/authentication/src/__tests__/unit/providers/authentication.provider.unit.ts @@ -10,7 +10,7 @@ import {expect} from '@loopback/testlab'; import {AuthenticateFn, AuthenticationBindings} from '../../..'; import {AuthenticateActionProvider} from '../../../providers'; import {AuthenticationStrategy} from '../../../types'; -import {MockStrategy} from '../fixtures/mock-strategy'; +import {MockStrategy, MockStrategy2} from '../fixtures/mock-strategy'; describe('AuthenticateActionProvider', () => { describe('constructor()', () => { @@ -22,18 +22,32 @@ describe('AuthenticateActionProvider', () => { AuthenticateActionProvider, context, ); - expect(await provider.getStrategy()).to.be.equal(strategy); + expect(await provider.getStrategies()).to.be.equal(strategy); + }); + + it('should inject multiple strategies in the constructor on instantiation', async () => { + const context = new Context(); + const strategies = [new MockStrategy(), new MockStrategy2()]; + context.bind(AuthenticationBindings.STRATEGY).to(strategies); + const provider = await instantiateClass( + AuthenticateActionProvider, + context, + ); + expect(await provider.getStrategies()).to.deepEqual(strategies); }); }); describe('value()', () => { let provider: AuthenticateActionProvider; let strategy: MockStrategy; + let strategy2: MockStrategy2; let currentUser: UserProfile | undefined; const mockUser: UserProfile = {name: 'user-name', [securityId]: 'mock-id'}; - beforeEach(givenAuthenticateActionProvider); + beforeEach(() => { + givenAuthenticateActionProvider(); + }); it('returns a function which authenticates a request and returns a user', async () => { const authenticate: AuthenticateFn = await Promise.resolve( @@ -51,6 +65,39 @@ describe('AuthenticateActionProvider', () => { expect(currentUser).to.equal(mockUser); }); + it('should return a function that throws an error if authentication fails', async () => { + givenAuthenticateActionProvider([strategy]); + const authenticate = await Promise.resolve(provider.value()); + const request = {}; + request.headers = {testState: 'fail'}; + + await expect(authenticate(request)).to.be.rejected(); + }); + + it('should return a function that throws an error if both authentication strategies fail', async () => { + givenAuthenticateActionProvider([strategy, strategy2]); + const authenticate = await Promise.resolve(provider.value()); + const request = {}; + request.headers = {testState: 'fail', testState2: 'fail'}; + + await expect(authenticate(request)).to.be.rejected(); + }); + + it('should return a function that does not throw an error if one authentication strategy succeeds', async () => { + givenAuthenticateActionProvider([strategy, strategy2]); + let authenticate = await Promise.resolve(provider.value()); + const request = {}; + request.headers = {testState: 'fail'}; + + await expect(authenticate(request)).to.not.be.rejected(); + + givenAuthenticateActionProvider([strategy, strategy2]); + authenticate = await Promise.resolve(provider.value()); + request.headers = {testState2: 'fail'}; + + await expect(authenticate(request)).to.not.be.rejected(); + }); + describe('context.get(provider_key)', () => { it('returns a function which authenticates a request and returns a user', async () => { const context: Context = new Context(); @@ -131,11 +178,14 @@ describe('AuthenticateActionProvider', () => { }); }); - function givenAuthenticateActionProvider() { + function givenAuthenticateActionProvider( + strategies?: AuthenticationStrategy[], + ) { strategy = new MockStrategy(); strategy.setMockUser(mockUser); + strategy2 = new MockStrategy2(); provider = new AuthenticateActionProvider( - () => Promise.resolve(strategy), + () => Promise.resolve(strategies ?? strategy), u => (currentUser = u), url => url, status => status, diff --git a/packages/authentication/src/decorators/authenticate.decorator.ts b/packages/authentication/src/decorators/authenticate.decorator.ts index 82a789779dfb..ee22e518d1df 100644 --- a/packages/authentication/src/decorators/authenticate.decorator.ts +++ b/packages/authentication/src/decorators/authenticate.decorator.ts @@ -29,7 +29,7 @@ class AuthenticateClassDecoratorFactory extends ClassDecoratorFactory< * @param options - Additional options to configure the authentication. */ export function authenticate( - strategyNameOrMetadata: string | AuthenticationMetadata, + strategyNameOrMetadata: string | string[] | AuthenticationMetadata, options?: object, ) { return function authenticateDecoratorForClassOrMethod( @@ -43,7 +43,10 @@ export function authenticate( methodDescriptor?: TypedPropertyDescriptor, ) { let spec: AuthenticationMetadata; - if (typeof strategyNameOrMetadata === 'object') { + if ( + typeof strategyNameOrMetadata === 'object' && + !Array.isArray(strategyNameOrMetadata) + ) { spec = strategyNameOrMetadata; } else { spec = {strategy: strategyNameOrMetadata, options: options ?? {}}; diff --git a/packages/authentication/src/keys.ts b/packages/authentication/src/keys.ts index 615e0dfdb608..728d90c3abd8 100644 --- a/packages/authentication/src/keys.ts +++ b/packages/authentication/src/keys.ts @@ -47,12 +47,12 @@ export namespace AuthenticationBindings { * ```ts * server * .bind(AuthenticationBindings.STRATEGY) - * .toProvider(MyAuthenticationStrategy); + * .toProvider([MyAuthenticationStrategy]); * ``` */ - export const STRATEGY = BindingKey.create( - 'authentication.strategy', - ); + export const STRATEGY = BindingKey.create< + AuthenticationStrategy | AuthenticationStrategy[] | undefined + >('authentication.strategy'); /** * Key used to inject the authentication function into the sequence. diff --git a/packages/authentication/src/providers/auth-action.provider.ts b/packages/authentication/src/providers/auth-action.provider.ts index 601c1c789fbf..43ce099bc8f6 100644 --- a/packages/authentication/src/providers/auth-action.provider.ts +++ b/packages/authentication/src/providers/auth-action.provider.ts @@ -33,7 +33,9 @@ export class AuthenticateActionProvider implements Provider { // defer resolution of the strategy until authenticate() action // is executed. @inject.getter(AuthenticationBindings.STRATEGY) - readonly getStrategy: Getter, + readonly getStrategies: Getter< + AuthenticationStrategy | AuthenticationStrategy[] + >, @inject.setter(SecurityBindings.USER) readonly setCurrentUser: Setter, @inject.setter(AuthenticationBindings.AUTHENTICATION_REDIRECT_URL) @@ -54,36 +56,59 @@ export class AuthenticateActionProvider implements Provider { * @param request - The incoming request provided by the REST layer */ async action(request: Request): Promise { - const strategy = await this.getStrategy(); - if (!strategy) { + let strategies = await this.getStrategies(); + if (!strategies) { // The invoked operation does not require authentication. return undefined; } - const authResponse = await strategy.authenticate(request); - let userProfile: UserProfile; + strategies = Array.isArray(strategies) ? strategies : [strategies]; - // response from `strategy.authenticate()` could return an object of type UserProfile or RedirectRoute - if (RedirectRoute.isRedirectRoute(authResponse)) { - const redirectOptions = authResponse; - // bind redirection url and status to the context - // controller should handle actual redirection - this.setRedirectUrl(redirectOptions.targetLocation); - this.setRedirectStatus(redirectOptions.statusCode); - } else if (authResponse) { - // if `strategy.authenticate()` returns an object of type UserProfile, set it as current user - userProfile = authResponse as UserProfile; + let authenticated = false; + let redirected = false; + let authError: Error | undefined; + let authResponse: UserProfile | RedirectRoute | undefined; + let userProfile: UserProfile | undefined; + + for (const strategy of strategies) { + // the first strategy to succeed or redirect will halt the execution chain + if (authenticated || redirected) break; + + try { + authResponse = await strategy.authenticate(request); + + // response from `strategy.authenticate()` could return an object of type UserProfile or RedirectRoute + if (RedirectRoute.isRedirectRoute(authResponse)) { + redirected = true; + const redirectOptions = authResponse; + // bind redirection url and status to the context + // controller should handle actual redirection + this.setRedirectUrl(redirectOptions.targetLocation); + this.setRedirectStatus(redirectOptions.statusCode); + } else if (authResponse) { + authenticated = true; + // if `strategy.authenticate()` returns an object of type UserProfile, set it as current user + userProfile = authResponse as UserProfile; + } else if (!authResponse) { + // important to throw a non-protocol-specific error here + const error = new Error( + `User profile not returned from strategy's authenticate function`, + ); + Object.assign(error, { + code: USER_PROFILE_NOT_FOUND, + }); + throw error; + } + } catch (err) { + authError = authError ?? err; + } + } + + if (!authenticated && !redirected) throw authError; + + if (userProfile) { this.setCurrentUser(userProfile); return userProfile; - } else if (!authResponse) { - // important to throw a non-protocol-specific error here - const error = new Error( - `User profile not returned from strategy's authenticate function`, - ); - Object.assign(error, { - code: USER_PROFILE_NOT_FOUND, - }); - throw error; } } } diff --git a/packages/authentication/src/providers/auth-strategy.provider.ts b/packages/authentication/src/providers/auth-strategy.provider.ts index cad90ec45a23..3d23742d7fa8 100644 --- a/packages/authentication/src/providers/auth-strategy.provider.ts +++ b/packages/authentication/src/providers/auth-strategy.provider.ts @@ -5,10 +5,10 @@ import { BindingScope, - Getter, - inject, extensionPoint, extensions, + Getter, + inject, Provider, } from '@loopback/core'; import {AuthenticationBindings} from '../keys'; @@ -32,33 +32,47 @@ import { {scope: BindingScope.TRANSIENT}, ) //this needs to be transient, e.g. for request level context. export class AuthenticationStrategyProvider - implements Provider { + implements Provider { constructor( @extensions() protected authenticationStrategies: Getter, @inject(AuthenticationBindings.METADATA) protected metadata?: AuthenticationMetadata, ) {} - async value(): Promise { + async value(): Promise { if (!this.metadata) { return undefined; } - const name = this.metadata.strategy; - const strategy = await this.findAuthenticationStrategy(name); - if (!strategy) { - // important to throw a non-protocol-specific error here - const error = new Error(`The strategy '${name}' is not available.`); - Object.assign(error, { - code: AUTHENTICATION_STRATEGY_NOT_FOUND, - }); - throw error; - } - return strategy; + return this.findAuthenticationStrategies(this.metadata.strategy); } - async findAuthenticationStrategy(name: string) { - const strategies = await this.authenticationStrategies(); - const matchingAuthStrategy = strategies.find(a => a.name === name); - return matchingAuthStrategy; + private async findAuthenticationStrategies(names: string | string[]) { + const strategies: AuthenticationStrategy[] = []; + + const existingStrategies = await this.authenticationStrategies(); + + const findStrategy = (name: string) => { + const strategy = existingStrategies.find(a => a.name === name); + if (!strategy) { + const error = new Error(`The strategy '${name}' is not available.`); + Object.assign(error, { + code: AUTHENTICATION_STRATEGY_NOT_FOUND, + }); + throw error; + } + return strategy; + }; + + if (Array.isArray(names)) { + for (const name of names) { + const strategy = findStrategy(name); + strategies.push(strategy); + } + } else { + const strategy = findStrategy(names); + strategies.push(strategy); + } + + return strategies; } } diff --git a/packages/authentication/src/types.ts b/packages/authentication/src/types.ts index bd617616f935..46864797fc3e 100644 --- a/packages/authentication/src/types.ts +++ b/packages/authentication/src/types.ts @@ -22,7 +22,7 @@ export interface AuthenticationMetadata { /** * Name of the authentication strategy */ - strategy: string; + strategy: string | string[]; /** * Options for the authentication strategy */ From 04132862512c24dbbd12b5b8a66a34a6ac385366 Mon Sep 17 00:00:00 2001 From: nflaig Date: Sat, 11 Jul 2020 22:51:08 +0200 Subject: [PATCH 2/2] feat(authentication): update signature of authenticate decorator It is now possible to provide multiple strategy names and/or metadata objects BREAKING CHANGE: The `@authenticate` signature changed, options are no longer a separate input parameter but instead have to be provided in the metadata object. The metadata value is now `AuthenticationMetadata[]`. Signed-off-by: nflaig --- .../decorators/authenticate.decorator.unit.ts | 78 +++++++++++++------ .../__tests__/unit/fixtures/mock-metadata.ts | 2 +- .../providers/auth-metadata.provider.unit.ts | 64 ++++++++------- .../providers/auth-strategy.provider.unit.ts | 21 +++-- ...thentication-metadata-for-strategy.unit.ts | 45 +++++++++++ .../src/decorators/authenticate.decorator.ts | 49 ++++++------ packages/authentication/src/keys.ts | 17 ++-- .../src/providers/auth-action.provider.ts | 8 +- .../src/providers/auth-metadata.provider.ts | 8 +- .../src/providers/auth-strategy.provider.ts | 19 ++--- packages/authentication/src/types.ts | 19 ++++- 11 files changed, 210 insertions(+), 120 deletions(-) create mode 100644 packages/authentication/src/__tests__/unit/types/authentication-metadata-for-strategy.unit.ts diff --git a/packages/authentication/src/__tests__/unit/decorators/authenticate.decorator.unit.ts b/packages/authentication/src/__tests__/unit/decorators/authenticate.decorator.unit.ts index 5bdbcd871fa1..1a6bdd4c1878 100644 --- a/packages/authentication/src/__tests__/unit/decorators/authenticate.decorator.unit.ts +++ b/packages/authentication/src/__tests__/unit/decorators/authenticate.decorator.unit.ts @@ -8,16 +8,15 @@ import {authenticate, getAuthenticateMetadata} from '../../..'; describe('Authentication', () => { describe('@authenticate decorator', () => { - it('can add authenticate metadata to target method with options', () => { + it('can add authenticate metadata to target method', () => { class TestClass { - @authenticate('my-strategy', {option1: 'value1', option2: 'value2'}) + @authenticate('my-strategy') whoAmI() {} } const metaData = getAuthenticateMetadata(TestClass, 'whoAmI'); - expect(metaData).to.eql({ + expect(metaData?.[0]).to.eql({ strategy: 'my-strategy', - options: {option1: 'value1', option2: 'value2'}, }); }); @@ -31,7 +30,7 @@ describe('Authentication', () => { } const metaData = getAuthenticateMetadata(TestClass, 'whoAmI'); - expect(metaData).to.eql({ + expect(metaData?.[0]).to.eql({ strategy: 'my-strategy', options: {option1: 'value1', option2: 'value2'}, }); @@ -44,47 +43,82 @@ describe('Authentication', () => { } const metaData = getAuthenticateMetadata(TestClass, 'whoAmI'); - expect(metaData).to.eql({strategy: 'my-strategy', options: {}}); + expect(metaData?.[0]).to.eql({ + strategy: 'my-strategy', + }); }); - it('can add authenticate metadata to target method with strategies as array', () => { + it('can add authenticate metadata to target method with multiple strategies', () => { class TestClass { - @authenticate(['my-strategy', 'my-strategy2']) + @authenticate('my-strategy', 'my-strategy2') whoAmI() {} } const metaData = getAuthenticateMetadata(TestClass, 'whoAmI'); - expect(metaData).to.eql({ - strategy: ['my-strategy', 'my-strategy2'], - options: {}, + expect(metaData?.[0]).to.eql({ + strategy: 'my-strategy', + }); + expect(metaData?.[1]).to.eql({ + strategy: 'my-strategy2', }); }); - it('adds authenticate metadata to target class', () => { - @authenticate('my-strategy', {option1: 'value1', option2: 'value2'}) + it('can add authenticate metadata to target method with multiple objects', () => { class TestClass { + @authenticate( + { + strategy: 'my-strategy', + options: {option1: 'value1', option2: 'value2'}, + }, + { + strategy: 'my-strategy2', + options: {option1: 'value1', option2: 'value2'}, + }, + ) whoAmI() {} } const metaData = getAuthenticateMetadata(TestClass, 'whoAmI'); - expect(metaData).to.eql({ + expect(metaData?.[0]).to.eql({ strategy: 'my-strategy', options: {option1: 'value1', option2: 'value2'}, }); + expect(metaData?.[1]).to.eql({ + strategy: 'my-strategy2', + options: {option1: 'value1', option2: 'value2'}, + }); + }); + + it('adds authenticate metadata to target class', () => { + @authenticate('my-strategy') + class TestClass { + whoAmI() {} + } + + const metaData = getAuthenticateMetadata(TestClass, 'whoAmI'); + expect(metaData?.[0]).to.eql({ + strategy: 'my-strategy', + }); }); it('overrides class level metadata by method level', () => { - @authenticate('my-strategy', {option1: 'value1', option2: 'value2'}) + @authenticate({ + strategy: 'my-strategy', + options: {option1: 'value1', option2: 'value2'}, + }) class TestClass { - @authenticate('another-strategy', { - option1: 'valueA', - option2: 'value2', + @authenticate({ + strategy: 'another-strategy', + options: { + option1: 'valueA', + option2: 'value2', + }, }) whoAmI() {} } const metaData = getAuthenticateMetadata(TestClass, 'whoAmI'); - expect(metaData).to.eql({ + expect(metaData?.[0]).to.eql({ strategy: 'another-strategy', options: {option1: 'valueA', option2: 'value2'}, }); @@ -92,14 +126,14 @@ describe('Authentication', () => { }); it('can skip authentication', () => { - @authenticate('my-strategy', {option1: 'value1', option2: 'value2'}) + @authenticate('my-strategy') class TestClass { @authenticate.skip() whoAmI() {} } const metaData = getAuthenticateMetadata(TestClass, 'whoAmI'); - expect(metaData).to.containEql({skip: true}); + expect(metaData?.[0]).to.containEql({skip: true}); }); it('can skip authentication at class level', () => { @@ -109,6 +143,6 @@ describe('Authentication', () => { } const metaData = getAuthenticateMetadata(TestClass, 'whoAmI'); - expect(metaData).to.containEql({skip: true}); + expect(metaData?.[0]).to.containEql({skip: true}); }); }); diff --git a/packages/authentication/src/__tests__/unit/fixtures/mock-metadata.ts b/packages/authentication/src/__tests__/unit/fixtures/mock-metadata.ts index c45dd186f625..8e35a64e76f4 100644 --- a/packages/authentication/src/__tests__/unit/fixtures/mock-metadata.ts +++ b/packages/authentication/src/__tests__/unit/fixtures/mock-metadata.ts @@ -8,6 +8,6 @@ export const mockAuthenticationMetadata: AuthenticationMetadata = { }; export const mockAuthenticationMetadata2: AuthenticationMetadata = { - strategy: ['MockStrategy', 'MockStrategy2'], + strategy: 'MockStrategy2', options, }; diff --git a/packages/authentication/src/__tests__/unit/providers/auth-metadata.provider.unit.ts b/packages/authentication/src/__tests__/unit/providers/auth-metadata.provider.unit.ts index b56bedf037b2..3524396ae4db 100644 --- a/packages/authentication/src/__tests__/unit/providers/auth-metadata.provider.unit.ts +++ b/packages/authentication/src/__tests__/unit/providers/auth-metadata.provider.unit.ts @@ -3,17 +3,17 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Context, Provider, CoreBindings} from '@loopback/core'; +import {Context, CoreBindings, Provider} from '@loopback/core'; import {expect} from '@loopback/testlab'; import {authenticate, AuthenticationMetadata} from '../../..'; import {AuthenticationBindings} from '../../../keys'; import {AuthMetadataProvider} from '../../../providers'; describe('AuthMetadataProvider', () => { - let provider: Provider; + let provider: Provider; class TestController { - @authenticate('my-strategy', {option1: 'value1', option2: 'value2'}) + @authenticate('my-strategy') whoAmI() {} @authenticate.skip() @@ -29,11 +29,10 @@ describe('AuthMetadataProvider', () => { describe('value()', () => { it('returns the auth metadata of a controller method', async () => { const authMetadata: - | AuthenticationMetadata + | AuthenticationMetadata[] | undefined = await provider.value(); - expect(authMetadata).to.be.eql({ + expect(authMetadata?.[0]).to.be.eql({ strategy: 'my-strategy', - options: {option1: 'value1', option2: 'value2'}, }); }); @@ -45,12 +44,11 @@ describe('AuthMetadataProvider', () => { context .bind(CoreBindings.CONTROLLER_METHOD_META) .toProvider(AuthMetadataProvider); - const authMetadata = await context.get( - CoreBindings.CONTROLLER_METHOD_META, - ); - expect(authMetadata).to.be.eql({ + const authMetadata: + | AuthenticationMetadata[] + | undefined = await context.get(CoreBindings.CONTROLLER_METHOD_META); + expect(authMetadata?.[0]).to.be.eql({ strategy: 'my-strategy', - options: {option1: 'value1', option2: 'value2'}, }); }); @@ -61,10 +59,10 @@ describe('AuthMetadataProvider', () => { context .bind(CoreBindings.CONTROLLER_METHOD_META) .toProvider(AuthMetadataProvider); - const authMetadata = await context.get( - CoreBindings.CONTROLLER_METHOD_META, - ); - expect(authMetadata).to.be.undefined(); + const authMetadata: + | AuthenticationMetadata[] + | undefined = await context.get(CoreBindings.CONTROLLER_METHOD_META); + expect(authMetadata?.[0]).to.be.undefined(); }); it('returns undefined for a method decorated with @authenticate.skip even with default metadata', async () => { @@ -76,11 +74,11 @@ describe('AuthMetadataProvider', () => { .toProvider(AuthMetadataProvider); context .configure(AuthenticationBindings.COMPONENT) - .to({defaultMetadata: {strategy: 'xyz'}}); - const authMetadata = await context.get( - CoreBindings.CONTROLLER_METHOD_META, - ); - expect(authMetadata).to.be.undefined(); + .to({defaultMetadata: [{strategy: 'xyz'}]}); + const authMetadata: + | AuthenticationMetadata[] + | undefined = await context.get(CoreBindings.CONTROLLER_METHOD_META); + expect(authMetadata?.[0]).to.be.undefined(); }); it('returns undefined if no auth metadata is defined', async () => { @@ -92,10 +90,10 @@ describe('AuthMetadataProvider', () => { context .bind(CoreBindings.CONTROLLER_METHOD_META) .toProvider(AuthMetadataProvider); - const authMetadata = await context.get( - CoreBindings.CONTROLLER_METHOD_META, - ); - expect(authMetadata).to.be.undefined(); + const authMetadata: + | AuthenticationMetadata[] + | undefined = await context.get(CoreBindings.CONTROLLER_METHOD_META); + expect(authMetadata?.[0]).to.be.undefined(); }); it('returns default metadata if no auth metadata is defined', async () => { @@ -106,14 +104,14 @@ describe('AuthMetadataProvider', () => { context.bind(CoreBindings.CONTROLLER_METHOD_NAME).to('whoAmI'); context .configure(AuthenticationBindings.COMPONENT) - .to({defaultMetadata: {strategy: 'xyz'}}); + .to({defaultMetadata: [{strategy: 'xyz'}]}); context .bind(CoreBindings.CONTROLLER_METHOD_META) .toProvider(AuthMetadataProvider); - const authMetadata = await context.get( - CoreBindings.CONTROLLER_METHOD_META, - ); - expect(authMetadata).to.be.eql({strategy: 'xyz'}); + const authMetadata: + | AuthenticationMetadata[] + | undefined = await context.get(CoreBindings.CONTROLLER_METHOD_META); + expect(authMetadata?.[0]).to.be.eql({strategy: 'xyz'}); }); it('returns undefined when the class or method is missing', async () => { @@ -121,10 +119,10 @@ describe('AuthMetadataProvider', () => { context .bind(CoreBindings.CONTROLLER_METHOD_META) .toProvider(AuthMetadataProvider); - const authMetadata = await context.get( - CoreBindings.CONTROLLER_METHOD_META, - ); - expect(authMetadata).to.be.undefined(); + const authMetadata: + | AuthenticationMetadata[] + | undefined = await context.get(CoreBindings.CONTROLLER_METHOD_META); + expect(authMetadata?.[0]).to.be.undefined(); }); }); }); diff --git a/packages/authentication/src/__tests__/unit/providers/auth-strategy.provider.unit.ts b/packages/authentication/src/__tests__/unit/providers/auth-strategy.provider.unit.ts index ef7cdf3fde99..09ed3753e57e 100644 --- a/packages/authentication/src/__tests__/unit/providers/auth-strategy.provider.unit.ts +++ b/packages/authentication/src/__tests__/unit/providers/auth-strategy.provider.unit.ts @@ -1,3 +1,8 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/authentication +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + import {Context} from '@loopback/core'; import {expect} from '@loopback/testlab'; import { @@ -20,7 +25,7 @@ describe('AuthStrategyProvider', () => { beforeEach(() => { givenAuthenticationStrategyProvider( [mockStrategy, mockStrategy2], - mockAuthenticationMetadata2, + [mockAuthenticationMetadata, mockAuthenticationMetadata2], ); }); @@ -29,20 +34,20 @@ describe('AuthStrategyProvider', () => { const strategies = await strategyProvider.value(); expect(strategies).to.not.be.undefined(); - expect(strategies![0]).to.be.equal(mockStrategy); - expect(strategies![1]).to.be.equal(mockStrategy2); + expect(strategies?.[0]).to.be.equal(mockStrategy); + expect(strategies?.[1]).to.be.equal(mockStrategy2); }); it('should only return the authentication strategy specified in the authentication metadata', async () => { givenAuthenticationStrategyProvider( [mockStrategy, mockStrategy2], - mockAuthenticationMetadata, + [mockAuthenticationMetadata], ); const strategies = await strategyProvider.value(); expect(strategies?.length).to.be.equal(1); - expect(strategies![0]).to.be.equal(mockStrategy); + expect(strategies?.[0]).to.be.equal(mockStrategy); }); it('should return undefined if the authentication metadata is not available', async () => { @@ -54,11 +59,11 @@ describe('AuthStrategyProvider', () => { }); it('should throw an error if the authentication strategy is not available', async () => { - givenAuthenticationStrategyProvider([], mockAuthenticationMetadata); + givenAuthenticationStrategyProvider([], [mockAuthenticationMetadata]); await expect(strategyProvider.value()).to.be.rejected(); - givenAuthenticationStrategyProvider([], mockAuthenticationMetadata2); + givenAuthenticationStrategyProvider([], [mockAuthenticationMetadata2]); await expect(strategyProvider.value()).to.be.rejected(); }); @@ -91,7 +96,7 @@ describe('AuthStrategyProvider', () => { function givenAuthenticationStrategyProvider( strategies: AuthenticationStrategy[], - metadata: AuthenticationMetadata | undefined, + metadata: AuthenticationMetadata[] | undefined, ) { strategyProvider = new AuthenticationStrategyProvider( () => Promise.resolve(strategies), diff --git a/packages/authentication/src/__tests__/unit/types/authentication-metadata-for-strategy.unit.ts b/packages/authentication/src/__tests__/unit/types/authentication-metadata-for-strategy.unit.ts new file mode 100644 index 000000000000..0eceeed55534 --- /dev/null +++ b/packages/authentication/src/__tests__/unit/types/authentication-metadata-for-strategy.unit.ts @@ -0,0 +1,45 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/authentication +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + AuthenticationMetadata, + getAuthenticationMetadataForStrategy, +} from '../../../types'; +import { + mockAuthenticationMetadata, + mockAuthenticationMetadata2, +} from '../fixtures/mock-metadata'; + +describe('getAuthenticationMetadataForStrategy', () => { + let metadata: AuthenticationMetadata[]; + + beforeEach(givenAuthenticationMetadata); + + it('should return the authentication metadata for the specified strategy', () => { + const {strategy, options} = mockAuthenticationMetadata; + const strategyMetadata = getAuthenticationMetadataForStrategy( + metadata, + strategy, + ); + + expect(strategyMetadata).to.not.be.undefined(); + expect(strategyMetadata!.strategy).to.equal(strategy); + expect(strategyMetadata!.options).to.equal(options); + }); + + it('should return undefined if no metadata exists for the specified strategy', () => { + const strategyMetadata = getAuthenticationMetadataForStrategy( + metadata, + 'doesnotexist', + ); + + expect(strategyMetadata).to.be.undefined(); + }); + + function givenAuthenticationMetadata() { + metadata = [mockAuthenticationMetadata, mockAuthenticationMetadata2]; + } +}); diff --git a/packages/authentication/src/decorators/authenticate.decorator.ts b/packages/authentication/src/decorators/authenticate.decorator.ts index ee22e518d1df..2718d3ae60bf 100644 --- a/packages/authentication/src/decorators/authenticate.decorator.ts +++ b/packages/authentication/src/decorators/authenticate.decorator.ts @@ -18,19 +18,17 @@ import { import {AuthenticationMetadata} from '../types'; class AuthenticateClassDecoratorFactory extends ClassDecoratorFactory< - AuthenticationMetadata + AuthenticationMetadata[] > {} /** * Mark a controller method as requiring authenticated user. * - * @param strategyNameOrMetadata - The name of the authentication strategy to use - * or the authentication metadata object. - * @param options - Additional options to configure the authentication. + * @param strategies - The names of the authentication strategies to use + * or authentication metadata objects. */ export function authenticate( - strategyNameOrMetadata: string | string[] | AuthenticationMetadata, - options?: object, + ...strategies: (string | AuthenticationMetadata)[] ) { return function authenticateDecoratorForClassOrMethod( // Class or a prototype @@ -42,34 +40,35 @@ export function authenticate( // eslint-disable-next-line @typescript-eslint/no-explicit-any methodDescriptor?: TypedPropertyDescriptor, ) { - let spec: AuthenticationMetadata; - if ( - typeof strategyNameOrMetadata === 'object' && - !Array.isArray(strategyNameOrMetadata) - ) { - spec = strategyNameOrMetadata; - } else { - spec = {strategy: strategyNameOrMetadata, options: options ?? {}}; + const specs: AuthenticationMetadata[] = []; + + for (const strategy of strategies) { + if (typeof strategy === 'object') { + specs.push(strategy); + } else { + specs.push({strategy: strategy}); + } } + if (method && methodDescriptor) { // Method - return MethodDecoratorFactory.createDecorator( + return MethodDecoratorFactory.createDecorator( AUTHENTICATION_METADATA_KEY, - spec, + specs, {decoratorName: '@authenticate'}, )(target, method, methodDescriptor); } if (typeof target === 'function' && !method && !methodDescriptor) { // Class - return AuthenticateClassDecoratorFactory.createDecorator( - AUTHENTICATION_METADATA_CLASS_KEY, - spec, - {decoratorName: '@authenticate'}, - )(target); + return AuthenticateClassDecoratorFactory.createDecorator< + AuthenticationMetadata[] + >(AUTHENTICATION_METADATA_CLASS_KEY, specs, { + decoratorName: '@authenticate', + })(target); } // Not on a class or method throw new Error( - '@intercept cannot be used on a property: ' + + '@authenticate cannot be used on a property: ' + DecoratorFactory.getTargetName(target, method, methodDescriptor), ); }; @@ -91,16 +90,16 @@ export namespace authenticate { export function getAuthenticateMetadata( targetClass: Constructor<{}>, methodName: string, -): AuthenticationMetadata | undefined { +): AuthenticationMetadata[] | undefined { // First check method level - let metadata = MetadataInspector.getMethodMetadata( + let metadata = MetadataInspector.getMethodMetadata( AUTHENTICATION_METADATA_METHOD_KEY, targetClass.prototype, methodName, ); if (metadata) return metadata; // Check if the class level has `@authenticate` - metadata = MetadataInspector.getClassMetadata( + metadata = MetadataInspector.getClassMetadata( AUTHENTICATION_METADATA_CLASS_KEY, targetClass, ); diff --git a/packages/authentication/src/keys.ts b/packages/authentication/src/keys.ts index 728d90c3abd8..bdd6db3cf6e2 100644 --- a/packages/authentication/src/keys.ts +++ b/packages/authentication/src/keys.ts @@ -40,14 +40,14 @@ export namespace AuthenticationBindings { >('authentication.userProfileFactory'); /** - * Key used to bind an authentication strategy to the context for the - * authentication function to use. + * Key used to bind an authentication strategy or multiple strategies + * to the context for the authentication function to use. * * @example * ```ts * server * .bind(AuthenticationBindings.STRATEGY) - * .toProvider([MyAuthenticationStrategy]); + * .toProvider(MyAuthenticationStrategy); * ``` */ export const STRATEGY = BindingKey.create< @@ -105,20 +105,19 @@ export namespace AuthenticationBindings { * class MyPassportStrategyProvider implements Provider { * constructor( * @inject(AuthenticationBindings.METADATA) - * private metadata: AuthenticationMetadata, + * private metadata?: AuthenticationMetadata[], * ) {} * value(): ValueOrPromise { - * if (this.metadata) { - * const name = this.metadata.strategy; + * if (this.metadata?.length) { * // logic to determine which authentication strategy to return * } * } * } * ``` */ - export const METADATA = BindingKey.create( - 'authentication.operationMetadata', - ); + export const METADATA = BindingKey.create< + AuthenticationMetadata[] | undefined + >('authentication.operationMetadata'); export const AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME = 'authentication.strategies'; diff --git a/packages/authentication/src/providers/auth-action.provider.ts b/packages/authentication/src/providers/auth-action.provider.ts index 43ce099bc8f6..09e9b20344b9 100644 --- a/packages/authentication/src/providers/auth-action.provider.ts +++ b/packages/authentication/src/providers/auth-action.provider.ts @@ -34,7 +34,7 @@ export class AuthenticateActionProvider implements Provider { // is executed. @inject.getter(AuthenticationBindings.STRATEGY) readonly getStrategies: Getter< - AuthenticationStrategy | AuthenticationStrategy[] + AuthenticationStrategy | AuthenticationStrategy[] | undefined >, @inject.setter(SecurityBindings.USER) readonly setCurrentUser: Setter, @@ -61,7 +61,7 @@ export class AuthenticateActionProvider implements Provider { // The invoked operation does not require authentication. return undefined; } - + // convert to array if required strategies = Array.isArray(strategies) ? strategies : [strategies]; let authenticated = false; @@ -99,8 +99,8 @@ export class AuthenticateActionProvider implements Provider { }); throw error; } - } catch (err) { - authError = authError ?? err; + } catch (error) { + authError = authError ?? error; } } diff --git a/packages/authentication/src/providers/auth-metadata.provider.ts b/packages/authentication/src/providers/auth-metadata.provider.ts index 5df7d5d3d6cb..b8861f672996 100644 --- a/packages/authentication/src/providers/auth-metadata.provider.ts +++ b/packages/authentication/src/providers/auth-metadata.provider.ts @@ -6,9 +6,9 @@ import { config, Constructor, + CoreBindings, inject, Provider, - CoreBindings, } from '@loopback/core'; import {getAuthenticateMetadata} from '../decorators'; import {AuthenticationBindings} from '../keys'; @@ -19,7 +19,7 @@ import {AuthenticationMetadata, AuthenticationOptions} from '../types'; * @example `context.bind('authentication.operationMetadata').toProvider(AuthMetadataProvider)` */ export class AuthMetadataProvider - implements Provider { + implements Provider { constructor( @inject(CoreBindings.CONTROLLER_CLASS, {optional: true}) private readonly controllerClass: Constructor<{}>, @@ -32,14 +32,14 @@ export class AuthMetadataProvider /** * @returns AuthenticationMetadata */ - value(): AuthenticationMetadata | undefined { + value(): AuthenticationMetadata[] | undefined { if (!this.controllerClass || !this.methodName) return; const metadata = getAuthenticateMetadata( this.controllerClass, this.methodName, ); // Skip authentication if `skip` is `true` - if (metadata?.skip) return undefined; + if (metadata?.[0]?.skip) return undefined; if (metadata) return metadata; // Fall back to default metadata return this.options.defaultMetadata; diff --git a/packages/authentication/src/providers/auth-strategy.provider.ts b/packages/authentication/src/providers/auth-strategy.provider.ts index 3d23742d7fa8..143c6baab058 100644 --- a/packages/authentication/src/providers/auth-strategy.provider.ts +++ b/packages/authentication/src/providers/auth-strategy.provider.ts @@ -37,16 +37,18 @@ export class AuthenticationStrategyProvider @extensions() protected authenticationStrategies: Getter, @inject(AuthenticationBindings.METADATA) - protected metadata?: AuthenticationMetadata, + protected metadata?: AuthenticationMetadata[], ) {} async value(): Promise { - if (!this.metadata) { + if (!this.metadata?.length) { return undefined; } - return this.findAuthenticationStrategies(this.metadata.strategy); + return this.findAuthenticationStrategies(this.metadata); } - private async findAuthenticationStrategies(names: string | string[]) { + private async findAuthenticationStrategies( + metadata: AuthenticationMetadata[], + ): Promise { const strategies: AuthenticationStrategy[] = []; const existingStrategies = await this.authenticationStrategies(); @@ -63,13 +65,8 @@ export class AuthenticationStrategyProvider return strategy; }; - if (Array.isArray(names)) { - for (const name of names) { - const strategy = findStrategy(name); - strategies.push(strategy); - } - } else { - const strategy = findStrategy(names); + for (const data of metadata) { + const strategy = findStrategy(data.strategy); strategies.push(strategy); } diff --git a/packages/authentication/src/types.ts b/packages/authentication/src/types.ts index 46864797fc3e..6d2ddf62fa2a 100644 --- a/packages/authentication/src/types.ts +++ b/packages/authentication/src/types.ts @@ -11,7 +11,7 @@ import { Context, extensionFor, } from '@loopback/core'; -import {Request, RedirectRoute} from '@loopback/rest'; +import {RedirectRoute, Request} from '@loopback/rest'; import {UserProfile} from '@loopback/security'; import {AuthenticationBindings} from './keys'; @@ -22,7 +22,7 @@ export interface AuthenticationMetadata { /** * Name of the authentication strategy */ - strategy: string | string[]; + strategy: string; /** * Options for the authentication strategy */ @@ -59,7 +59,7 @@ export interface AuthenticationOptions { * `@authenticate`. If not set, no default authentication will be enforced for * those methods without authentication metadata. */ - defaultMetadata?: AuthenticationMetadata; + defaultMetadata?: AuthenticationMetadata[]; } /** @@ -133,3 +133,16 @@ export const asAuthStrategy: BindingTemplate = binding => { AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, }); }; + +/** + * Get the authentication metadata object for the specified strategy. + * + * @param metadata - Array of authentication metadata objects + * @param strategyName - Name of the authentication strategy + */ +export function getAuthenticationMetadataForStrategy( + metadata: AuthenticationMetadata[], + strategyName: string, +): AuthenticationMetadata | undefined { + return metadata.find(data => data.strategy === strategyName); +}