diff --git a/packages/authentication/src/authentication.ts b/packages/authentication/src/authentication.ts new file mode 100644 index 000000000000..01a281899d86 --- /dev/null +++ b/packages/authentication/src/authentication.ts @@ -0,0 +1,65 @@ +// Copyright IBM Corp. 2018. 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 {ParsedRequest} from '@loopback/rest'; + +/** + * interface definition of a function which accepts a request + * and returns an authenticated user + */ +export interface AuthenticateFn { + (request: ParsedRequest): Promise; +} + +/** + * interface definition of a user profile + * http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + */ +export interface UserProfile { + id: string; + name?: string; + email?: string; +} + +/** + * Authentication metadata stored via Reflection API + */ +export interface AuthenticationMetadata { + /** + * Name of the authentication strategy + */ + strategy: string; + /** + * Options for authentication + */ + options?: Object; +} + +/** + * Interface for authentication providers + */ +export interface Authenticator { + /** + * Check if the given strategy is supported by the authenticator + * @param stragety Name of the authentication strategy + */ + isSupported(strategy: string): boolean; + + /** + * Authenticate a request with given options + * @param request HTTP request + * @param metadata Authentication metadata + */ + authenticate( + request: ParsedRequest, + metadata?: AuthenticationMetadata, + ): Promise; +} + +/** + * Passport monkey-patches Node.js' IncomingMessage prototype + * and adds extra methods like "login" and "isAuthenticated" + */ +export type PassportRequest = ParsedRequest & Express.Request; diff --git a/packages/authentication/src/decorators/authenticate.ts b/packages/authentication/src/decorators/authenticate.ts index 576193fa90e4..4a3f511fc118 100644 --- a/packages/authentication/src/decorators/authenticate.ts +++ b/packages/authentication/src/decorators/authenticate.ts @@ -9,14 +9,7 @@ import { MethodDecoratorFactory, } from '@loopback/context'; import {AuthenticationBindings} from '../keys'; - -/** - * Authentication metadata stored via Reflection API - */ -export interface AuthenticationMetadata { - strategy: string; - options?: Object; -} +import {AuthenticationMetadata} from '../authentication'; /** * Mark a controller method as requiring authenticated user. diff --git a/packages/authentication/src/index.ts b/packages/authentication/src/index.ts index 05ae07356a08..192b8edce52d 100644 --- a/packages/authentication/src/index.ts +++ b/packages/authentication/src/index.ts @@ -3,6 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +export * from './authentication'; export * from './auth-component'; export * from './decorators/authenticate'; export * from './keys'; diff --git a/packages/authentication/src/providers/auth-extension-point.ts b/packages/authentication/src/providers/auth-extension-point.ts new file mode 100644 index 000000000000..700081add927 --- /dev/null +++ b/packages/authentication/src/providers/auth-extension-point.ts @@ -0,0 +1,40 @@ +// Copyright IBM Corp. 2018. 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 {ExtensionPoint, Context} from '@loopback/core'; +import {ParsedRequest} from '@loopback/rest'; +import { + UserProfile, + AuthenticationMetadata, + Authenticator, +} from '../authentication'; +import {AuthenticationBindings} from '../keys'; + +export class AuthenticationExtensionPoint extends ExtensionPoint< + Authenticator +> { + static extensionPointName = 'authenticators'; + + async authenticate( + ctx: Context, + request: ParsedRequest, + ): Promise { + const meta: AuthenticationMetadata | undefined = await ctx.get( + AuthenticationBindings.METADATA, + ); + if (meta == undefined) { + return undefined; + } + const authenticators = await this.getAllExtensions(ctx); + let user: UserProfile | undefined = undefined; + for (const authenticator of authenticators) { + if (authenticator.isSupported(meta.strategy)) { + user = await authenticator.authenticate(request, meta); + if (user === undefined) continue; + } + } + return user; + } +} diff --git a/packages/authentication/src/providers/auth-metadata.ts b/packages/authentication/src/providers/auth-metadata.ts index 2026eb0fd196..b1272ffe223d 100644 --- a/packages/authentication/src/providers/auth-metadata.ts +++ b/packages/authentication/src/providers/auth-metadata.ts @@ -5,10 +5,8 @@ import {CoreBindings} from '@loopback/core'; import {Constructor, Provider, inject} from '@loopback/context'; -import { - AuthenticationMetadata, - getAuthenticateMetadata, -} from '../decorators/authenticate'; +import {getAuthenticateMetadata} from '../decorators/authenticate'; +import {AuthenticationMetadata} from '../authentication'; /** * @description Provides authentication metadata of a controller method diff --git a/packages/authentication/src/providers/authenticate.ts b/packages/authentication/src/providers/authenticate.ts index a749d6d0c389..fd7b77038766 100644 --- a/packages/authentication/src/providers/authenticate.ts +++ b/packages/authentication/src/providers/authenticate.ts @@ -9,30 +9,7 @@ import {Provider, Getter, Setter} from '@loopback/context'; import {Strategy} from 'passport'; import {StrategyAdapter} from '../strategy-adapter'; import {AuthenticationBindings} from '../keys'; - -/** - * Passport monkey-patches Node.js' IncomingMessage prototype - * and adds extra methods like "login" and "isAuthenticated" - */ -export type PassportRequest = ParsedRequest & Express.Request; - -/** - * interface definition of a function which accepts a request - * and returns an authenticated user - */ -export interface AuthenticateFn { - (request: ParsedRequest): Promise; -} - -/** - * interface definition of a user profile - * http://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - */ -export interface UserProfile { - id: string; - name?: string; - email?: string; -} +import {AuthenticateFn, UserProfile} from '../authentication'; /** * @description Provider of a function which authenticates diff --git a/packages/authentication/src/strategy-adapter.ts b/packages/authentication/src/strategy-adapter.ts index b415a586b30e..9bb0822cb7e6 100644 --- a/packages/authentication/src/strategy-adapter.ts +++ b/packages/authentication/src/strategy-adapter.ts @@ -5,7 +5,8 @@ import {HttpErrors, ParsedRequest} from '@loopback/rest'; import {Strategy} from 'passport'; -import {UserProfile} from './providers/authenticate'; +import {UserProfile, Authenticator} from './authentication'; +import {AuthenticationMetadata} from '../index'; const PassportRequestExtras: Express.Request = require('passport/lib/http/request'); @@ -65,7 +66,7 @@ export class ShimRequest implements Express.Request { * 3. provides state methods to the strategy instance * see: https://github.com/jaredhanson/passport */ -export class StrategyAdapter { +export class StrategyAdapter implements Authenticator { /** * @param strategy instance of a class which implements a passport-strategy; * @description http://passportjs.org/ @@ -79,7 +80,7 @@ export class StrategyAdapter { * 3. authenticate using the strategy * @param req {http.ServerRequest} The incoming request. */ - authenticate(req: ParsedRequest) { + authenticate(req: ParsedRequest, metadata?: AuthenticationMetadata) { const shimReq = new ShimRequest(req); return new Promise((resolve, reject) => { // create a prototype chain of an instance of a passport strategy @@ -101,7 +102,11 @@ export class StrategyAdapter { }; // authenticate - strategy.authenticate(shimReq); + strategy.authenticate(shimReq, metadata && metadata.options); }); } + + isSupported(strategy: string) { + return strategy.startsWith('passport:'); + } } diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index 24f11013053f..4a1307d46056 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -20,7 +20,7 @@ export { export {Binding, BindingScope, BindingType} from './binding'; export {Context} from './context'; -export {ResolutionSession} from './resolution-session'; +export {ResolutionSession, ResolutionOptions} from './resolution-session'; export {inject, Setter, Getter, Injection, InjectionMetadata} from './inject'; export {Provider} from './provider'; diff --git a/packages/core/src/application.ts b/packages/core/src/application.ts index 9c437d45f57f..6bb56166d730 100644 --- a/packages/core/src/application.ts +++ b/packages/core/src/application.ts @@ -7,6 +7,7 @@ import {Context, Binding, BindingScope, Constructor} from '@loopback/context'; import {Server} from './server'; import {Component, mountComponent} from './component'; import {CoreBindings} from './keys'; +import {ExtensionPoint} from './extension-point'; /** * Application is the container for various types of artifacts, such as @@ -194,10 +195,50 @@ export class Application extends Context { const instance = this.getSync(componentKey); mountComponent(this, instance); } + + /** + * Register an extension point + * @param extensionPointClass Extension point class + * @param extensionPointName Name of the extension point, if not present, + * default to extensionPoints. + */ + public extensionPoint( + // tslint:disable-next-line:no-any + extensionPointClass: Constructor>, + extensionPointName?: string, + ): Binding { + extensionPointName = + extensionPointName || `extensionPoints.${extensionPointClass.name}`; + return this.bind(extensionPointName) + .toClass(extensionPointClass) + .inScope(BindingScope.CONTEXT) + .tag('extensionPoint') + .tag(`name:${extensionPointName}`); + } + + /** + * Register an extension of the given extension point + * @param extensionPointName Name of the extension point + * @param extensionClass Extension class + * @param extensionName Name of the extension. If not present, default to + * the name of extension class + */ + public extension( + extensionPointName: string, + // tslint:disable-next-line:no-any + extensionClass: Constructor, + extensionName?: string, + ): Binding { + extensionName = extensionName || extensionClass.name; + return this.bind(`${extensionPointName}.${extensionName}`) + .toClass(extensionClass) + .tag(`extensionPoint:${extensionPointName}`) + .tag(`name:${extensionName}`); + } } /** - * Configuration for application + * Configuration for an application */ export interface ApplicationConfig { /** diff --git a/packages/core/src/extension-point.ts b/packages/core/src/extension-point.ts new file mode 100644 index 000000000000..8ca1d7816104 --- /dev/null +++ b/packages/core/src/extension-point.ts @@ -0,0 +1,178 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/core +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Context, + Binding, + inject, + resolveList, + Injection, + ResolutionSession, +} from '@loopback/context'; + +// tslint:disable:no-any +/** + * Interface for the extension point configuration + */ +export interface ExtensionPointConfig { + // Configuration properties for the extension point itself + [property: string]: any; +} + +/** + * Base class for extension points + */ +export abstract class ExtensionPoint { + /** + * Configuration (typically to be injected) + */ + @inject.config() protected config: ExtensionPointConfig = {}; + + /** + * Name of the extension point. The subclass must set the value. + */ + static extensionPointName: string; + + /** + * The unique name of this extension point. It also serves as the binding + * key prefix for bound extensions + */ + public readonly name: string; + + constructor() { + const ctor = this.constructor as typeof ExtensionPoint; + this.name = ctor.extensionPointName; + if (!this.name) { + throw new Error(`${ctor.name}.extensionPointName must be set`); + } + } + + /** + * Find an array of bindings for extensions + */ + getAllExtensionBindings(ctx: Context): Binding[] { + return ctx.findByTag(this.getTagForExtensions()); + } + + /** + * Get the binding tag for extensions of this extension point + */ + protected getTagForExtensions(): string { + return `extensionPoint:${this.name}`; + } + + /** + * Get a map of extension bindings by the keys + */ + getExtensionBindingMap(ctx: Context): {[name: string]: Binding} { + const extensionBindingMap: {[name: string]: Binding} = {}; + const bindings = this.getAllExtensionBindings(ctx); + bindings.forEach(binding => { + extensionBindingMap[binding.key] = binding; + }); + return extensionBindingMap; + } + + /** + * Look up an extension binding by name + * @param extensionName Name of the extension + */ + getExtensionBinding(ctx: Context, extensionName: string): Binding { + const bindings = this.getAllExtensionBindings(ctx); + const binding = bindings.find(b => + b.tags.has(this.getTagForName(extensionName)), + ); + if (binding == null) + throw new Error( + `Extension ${extensionName} does not exist for extension point ${ + this.name + }`, + ); + return binding; + } + + /** + * Get the binding tag for an extension name + * @param extensionName Name of the extension + */ + protected getTagForName(extensionName: string): string { + return `name:${extensionName}`; + } + + /** + * Get configuration for this extension point + */ + getConfiguration() { + return this.config; + } + + /** + * Get configuration for an extension of this extension point + * @param extensionName Name of the extension + */ + async getExtensionConfiguration(ctx: Context, extensionName: string) { + return (await ctx.getConfig(`${this.name}.${extensionName}`)) || {}; + } + + /** + * Get an instance of an extension by name + * @param extensionName Name of the extension + */ + async getExtension(ctx: Context, extensionName: string): Promise { + const binding = this.getExtensionBinding(ctx, extensionName); + return binding.getValue(ctx); + } + + /** + * Get an array of registered extension instances + */ + async getAllExtensions(ctx: Context): Promise { + const bindings = this.getAllExtensionBindings(ctx); + return resolveList(bindings, async binding => { + return await binding.getValue(ctx); + }); + } + + /** + * Get the name tag (name:extension-name) associated with the binding + * @param binding + */ + static getExtensionName(binding: Binding) { + for (const tag of binding.tags) { + if (tag.startsWith('name:')) { + return tag.substr('name:'.length); + } + } + return undefined; + } +} + +/** + * @extensions() - decorator to inject extensions + */ +export function extensions() { + return inject( + '', + {decorator: '@extensions'}, + (ctx: Context, injection: Injection, session?: ResolutionSession) => { + const target = injection.target; + const ctor: any = + target instanceof Function ? target : target.constructor; + if (ctor.extensionPointName) { + const bindings = ctx.findByTag( + `extensionPoint:${ctor.extensionPointName}`, + ); + return resolveList(bindings, b => { + // We need to clone the session so that resolution of multiple + // bindings can be tracked in parallel + return b.getValue(ctx, ResolutionSession.fork(session)); + }); + } + throw new Error( + '@extensions must be used within a extension point class', + ); + }, + ); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cf9d5f64358a..faa5b624fe03 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,3 +12,4 @@ export {Server} from './server'; export * from './application'; export * from './component'; export * from './keys'; +export * from './extension-point'; diff --git a/packages/core/test/acceptance/extension-point.feature.md b/packages/core/test/acceptance/extension-point.feature.md new file mode 100644 index 000000000000..9bd9446b9fb5 --- /dev/null +++ b/packages/core/test/acceptance/extension-point.feature.md @@ -0,0 +1,78 @@ +# Feature: Context bindings - organizing services as extension point/extensions by naming convention + +- In order to manage services that follow the extension point/extension pattern +- As a developer +- I would like to bind a extension point and its extensions to the context following certain conventions +- So that an extension point can find all of its extensions or one of them by name + +See https://wiki.eclipse.org/FAQ_What_are_extensions_and_extension_points%3F for the pattern. + +# Scenario - register an extension to a given extension point + +- Given a context +- Given a class `AuthenticationManager` as the extension point for extensible authentication strategies +- Given a class `LocalStrategy` that implements the `local` authentication strategy +- Given a class `LDAPStrategy` that implements the `ldap` authentication strategy +- Given a class `OAuth2Strategy` that implements the `oauth2` authentication strategy +- We should be able to add `LocalStrategy` by binding it to `authentication.strategies.local` +- We should be able to add `LDAPStrategy` by binding it to `authentication.strategies.ldap` +- We should be able to add `OAuth2Strategy` by binding it to `authentication.strategies.oauth2` + +# Scenario - find all of its extensions for a given extension point + +- Given a context +- Given a class `AuthenticationManager` as the extension point for extensible authentication strategies +- Given a class `LocalStrategy` that implements the `local` authentication strategy +- Given a class `LDAPStrategy` that implements the `ldap` authentication strategy +- Given a class `OAuth2Strategy` that implements the `oauth2` authentication strategy +- When LocalStrategy is bound to `authentication.strategies.local` +- When LDAPStrategy is bound to `authentication.strategies.ldap` +- When OAuth2Strategy is bound to `authentication.strategies.oauth2` +- AuthenticationManager should be able to list all extensions bound to `authentication.strategies.*` + +# Scenario - find one of its extensions by name for a given extension point + +- Given a context +- Given a class `AuthenticationManager` as the extension point for extensible authentication strategies +- Given a class `LocalStrategy` that implements the `local` authentication strategy +- Given a class `LDAPStrategy` that implements the `ldap` authentication strategy +- Given a class `OAuth2Strategy` that implements the `oauth2` authentication strategy +- When LocalStrategy is bound to `authentication.strategies.local` +- When LDAPStrategy is bound to `authentication.strategies.ldap` +- When OAuth2Strategy is bound to `authentication.strategies.oauth2` +- AuthenticationManager should be able to find/get LocalStrategy by `local` from the context +- AuthenticationManager should be able to find/get LDAPStrategy by `ldap` from the context +- AuthenticationManager should be able to find/get OAuth2Strategy by `oauth2` from the context + +# Scenario - populate configuration for extension points and extension + +- Given a context +- Given a class `AuthenticationManager` as the extension point for extensible authentication strategies +- Given a class `LocalStrategy` that implements the `local` authentication strategy +- Given a class `LDAPStrategy` that implements the `ldap` authentication strategy +- Given a class `OAuth2Strategy` that implements the `oauth2` authentication strategy +- When LocalStrategy is bound to `authentication.strategies.local` +- When LDAPStrategy is bound to `authentication.strategies.ldap` +- When OAuth2Strategy is bound to `authentication.strategies.oauth2` +- Given a configuration object +```js +{ + 'authentication.strategies': { + local: { /* config for local strategy */}, + ldap: { /* config for ldap strategy */} + oauth2: { /* config for oauth2 strategy */} + } +} +``` +- Each of the strategy class should be able to receive its configuration via dependency injection. For example, + +```ts +class LocalStrategy implements AuthenticationStrategy { + constructor(@inject('config') private config: LocalConfig) {} +} +``` + +# TBD + +- Should we go beyond naming conventions to make ExtensionPoint/Extension first class entity of the context? +- Should we introduce decorators such as `@extensionPoint` and `@extension`? diff --git a/packages/core/test/acceptance/extension-point.ts b/packages/core/test/acceptance/extension-point.ts new file mode 100644 index 000000000000..e9f85016dca5 --- /dev/null +++ b/packages/core/test/acceptance/extension-point.ts @@ -0,0 +1,267 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: loopback +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + Context, + BindingScope, + inject, + Constructor, + invokeMethod, +} from '@loopback/context'; +import {Application, ExtensionPoint, extensions} from '../..'; + +// tslint:disable:no-any +interface AuthenticationStrategy { + authenticate(credentials: any): Promise; + config: any; +} + +class LocalStrategy implements AuthenticationStrategy { + constructor(@inject.config() public config: any) {} + + authenticate(credentials: any) { + return Promise.resolve(true); + } +} + +class LDAPStrategy implements AuthenticationStrategy { + constructor(@inject.config() public config: any) {} + + authenticate(credentials: any) { + return Promise.resolve(true); + } +} + +class OAuth2Strategy implements AuthenticationStrategy { + constructor(@inject.config() public config: any) {} + + authenticate(credentials: any) { + return Promise.resolve(true); + } +} + +const AUTH_EXTENSION_POINT = 'authentication.strategies'; + +class AuthenticationManager extends ExtensionPoint { + static extensionPointName = AUTH_EXTENSION_POINT; + + async authenticate( + ctx: Context, + strategy: string, + credentials: any, + ): Promise { + const ext: AuthenticationStrategy = await this.getExtension(ctx, strategy); + return ext.authenticate(credentials); + } + + async getStrategies( + // Use method injection to ensure we pick up all available extensions within + // the current context + @extensions() authenticators: AuthenticationStrategy[], + ) { + return authenticators; + } +} + +const configs: {[name: string]: object} = { + local: { + url: 'https://localhost:3000/users/login', + }, + ldap: { + url: 'ldap://localhost:1389', + }, + oauth2: { + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + tokenInspectUrl: 'https://localhost:3000/oauth2/inspect', + }, +}; + +// Register multiple extensions +const strategies: {[name: string]: Constructor} = { + local: LocalStrategy, + ldap: LDAPStrategy, + oauth2: OAuth2Strategy, +}; + +describe('Extension point', () => { + let ctx: Context; + beforeEach('given a context', createContext); + + it('lists all extensions', async () => { + const authManager: AuthenticationManager = await ctx.get( + AUTH_EXTENSION_POINT, + ); + const extBindings = authManager.getAllExtensionBindings(ctx); + expect(extBindings.length).to.eql(3); + }); + + it('gets an extension by name', async () => { + const authManager: AuthenticationManager = await ctx.get( + AUTH_EXTENSION_POINT, + ); + const binding = authManager.getExtensionBinding(ctx, 'ldap'); + expect(binding.key).to.eql(`${AUTH_EXTENSION_POINT}.ldap`); + expect(binding.valueConstructor).to.exactly(LDAPStrategy); + }); + + it('gets an extension instance by name', async () => { + const authManager: AuthenticationManager = await ctx.get( + AUTH_EXTENSION_POINT, + ); + const ext = await authManager.getExtension(ctx, 'ldap'); + expect(ext.config).to.eql({ + url: 'ldap://localhost:1389', + }); + const result = await ext.authenticate({}); + expect(result).to.be.true(); + }); + + it('delegates to an extension', async () => { + const authManager: AuthenticationManager = await ctx.get( + AUTH_EXTENSION_POINT, + ); + const result = await authManager.authenticate(ctx, 'local', { + username: 'my-user', + password: 'my-pass', + }); + expect(result).to.be.true(); + }); + + it('injects extensions', async () => { + const authManager: AuthenticationManager = await ctx.get( + AUTH_EXTENSION_POINT, + ); + const authenticators: AuthenticationStrategy[] = await invokeMethod( + authManager, + 'getStrategies', + ctx, + ); + expect(authenticators).have.length(3); + }); + + function createContext() { + ctx = new Context(); + + // Register the extension point + ctx + .bind(AUTH_EXTENSION_POINT) + .toClass(AuthenticationManager) + .inScope(BindingScope.SINGLETON) + .tag('extensionPoint') + .tag(`name:${AUTH_EXTENSION_POINT}`); + + for (const e in strategies) { + ctx + .bind(`authentication.strategies.${e}`) + .toClass(strategies[e]) + .inScope(BindingScope.SINGLETON) + .tag(`extensionPoint:${AUTH_EXTENSION_POINT}`) + .tag(`name:${e}`); + ctx.configure(`authentication.strategies.${e}`).to(configs[e]); + } + } +}); + +describe('Application support for extension points', () => { + let app: Application; + + beforeEach(givenApplication); + + it('registers an extension point by class name', () => { + app.extensionPoint(AuthenticationManager); + const binding = app.getBinding('extensionPoints.AuthenticationManager'); + expect(binding.valueConstructor).to.be.exactly(AuthenticationManager); + expect(binding.scope === BindingScope.SINGLETON); + expect( + binding.tags.has('name:extensionPoints.AuthenticationManager'), + ).to.be.true(); + expect(binding.tags.has('extensionPoint')).to.be.true(); + }); + + it('registers an extension point by name', () => { + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + const binding = app.getBinding(AUTH_EXTENSION_POINT); + expect(binding.valueConstructor).to.be.exactly(AuthenticationManager); + expect(binding.scope === BindingScope.SINGLETON); + expect(binding.tags.has(`name:${AUTH_EXTENSION_POINT}`)).to.be.true(); + expect(binding.tags.has('extensionPoint')).to.be.true(); + }); + + it('registers an extension by class name', () => { + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + app.extension(AUTH_EXTENSION_POINT, LocalStrategy); + const binding = app.getBinding(`${AUTH_EXTENSION_POINT}.LocalStrategy`); + expect(binding.valueConstructor).to.be.exactly(LocalStrategy); + expect(binding.tags.has('name:LocalStrategy')).to.be.true(); + expect( + binding.tags.has(`extensionPoint:${AUTH_EXTENSION_POINT}`), + ).to.be.true(); + }); + + it('registers an extension by class name', () => { + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + app.extension(AUTH_EXTENSION_POINT, LocalStrategy); + const binding = app.getBinding(`${AUTH_EXTENSION_POINT}.LocalStrategy`); + expect(binding.valueConstructor).to.be.exactly(LocalStrategy); + expect(binding.tags.has('name:LocalStrategy')).to.be.true(); + expect( + binding.tags.has(`extensionPoint:${AUTH_EXTENSION_POINT}`), + ).to.be.true(); + }); + + it('registers an extension by name', () => { + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + app.extension(AUTH_EXTENSION_POINT, LocalStrategy, 'local'); + const binding = app.getBinding(`${AUTH_EXTENSION_POINT}.local`); + expect(binding.valueConstructor).to.be.exactly(LocalStrategy); + expect(binding.tags.has('name:local')).to.be.true(); + expect( + binding.tags.has(`extensionPoint:${AUTH_EXTENSION_POINT}`), + ).to.be.true(); + }); + + it('configures an extension point', async () => { + const config = {auth: true}; + app.configure(AUTH_EXTENSION_POINT).to(config); + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + const auth = await app.get(AUTH_EXTENSION_POINT); + expect(auth.config).to.eql(config); + }); + + it('binds an extension point in context scope', async () => { + const config = {auth: true}; + app.configure(AUTH_EXTENSION_POINT).to(config); + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + const auth1 = await app.get(AUTH_EXTENSION_POINT); + const auth2 = await app.get(AUTH_EXTENSION_POINT); + expect(auth1).to.be.exactly(auth2); + + const reqCtx = new Context(app); + const auth3 = await reqCtx.get(AUTH_EXTENSION_POINT); + const auth4 = await reqCtx.get(AUTH_EXTENSION_POINT); + expect(auth3).to.be.exactly(auth4); + expect(auth3).to.be.not.exactly(auth1); + }); + + it('configures extensions', async () => { + app.extensionPoint(AuthenticationManager, AUTH_EXTENSION_POINT); + app.extension(AUTH_EXTENSION_POINT, LocalStrategy, 'local'); + app.configure(`${AUTH_EXTENSION_POINT}.local`).to(configs.local); + const extensionPoint: ExtensionPoint< + AuthenticationStrategy + > = await app.get(AUTH_EXTENSION_POINT); + const extension: LocalStrategy = await extensionPoint.getExtension( + app, + 'local', + ); + expect(extension.config).to.eql(configs.local); + }); + + function givenApplication() { + app = new Application(); + } +});