diff --git a/LICENSE b/LICENSE index d170b9cf537e..262c9c581fa1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) IBM Corp. 2017,2019. All Rights Reserved. +Copyright (c) IBM Corp. 2019. All Rights Reserved. Node module: loopback-next This project is licensed under the MIT License, full text below. diff --git a/labs/authentication-passport/.npmrc b/labs/authentication-passport/.npmrc new file mode 100644 index 000000000000..cafe685a112d --- /dev/null +++ b/labs/authentication-passport/.npmrc @@ -0,0 +1 @@ +package-lock=true diff --git a/labs/authentication-passport/LICENSE b/labs/authentication-passport/LICENSE new file mode 100644 index 000000000000..c70a3d6427b6 --- /dev/null +++ b/labs/authentication-passport/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2017,2019. All Rights Reserved. +Node module: @loopback/authentication-passport +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/labs/authentication-passport/README.md b/labs/authentication-passport/README.md new file mode 100644 index 000000000000..0848286cc23d --- /dev/null +++ b/labs/authentication-passport/README.md @@ -0,0 +1,189 @@ +# Passport Strategy Adapter + +_Important: We strongly suggest that users understand LoopBack's +[authentication system](https://loopback.io/doc/en/lb4/Loopback-component-authentication.html) +before using this module_ + +This is an adapter module created for plugging in +[`passport`](https://www.npmjs.com/package/passport) based strategies to the +authentication system in `@loopback/authentication@2.x`. + +## Installation + +```sh +npm i @loopback/authentication-passport --save +``` + +## Background + +`@loopback/authentication@2.x` allows users to register authentication +strategies that implement the interface +[`AuthenticationStrategy`](https://apidocs.strongloop.com/@loopback%2fdocs/authentication.html#AuthenticationStrategy) + +Since `AuthenticationStrategy` describes a strategy with different contracts +than the passport +[`Strategy`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/passport/index.d.ts#L79), +and we'd like to support the existing 500+ community passport strategies, an +**adapter class** is created in this package to convert a passport strategy to +the one that LoopBack 4 authentication system wants. + +## Usage + +### Simple Usage + +1. Create an instance of the passport strategy + +Taking the basic strategy exported from +[`passport-http`](https://github.com/jaredhanson/passport-http) as an example, +first create an instance of the basic strategy with your `verify` function. + +```ts +import {BasicStrategy} from 'passport-http'; + +function verify(username: string, password: string, cb: Function) { + users.find(username, password, cb); +} +const basicStrategy = new BasicStrategy(verify); +``` + +It's a similar configuration as you do when adding a strategy to a `passport` by +calling `passport.use()`. + +2. Apply the adapter to the strategy + +```ts +const AUTH_STRATEGY_NAME = 'basic'; + +const basicAuthStrategy = new StrategyAdapter( + // The configured basic strategy instance + basicStrategy, + // Give the strategy a name + // You'd better define your strategy name as a constant, like + // `const AUTH_STRATEGY_NAME = 'basic'`. + // You will need to decorate the APIs later with the same name. + AUTH_STRATEGY_NAME, +); +``` + +3. Register(bind) the strategy to app + +```ts +import {Application, CoreTags} from '@loopback/core'; +import {AuthenticationBindings} from '@loopback/authentication'; + +app + .bind('authentication.strategies.basicAuthStrategy') + .to(basicAuthStrategy) + .tag({ + [CoreTags.EXTENSION_FOR]: + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + }); +``` + +### With Provider + +If you need to inject stuff (e.g. the verify function) when configuring the +strategy, you may want to provide your strategy as a provider. + +_Note: If you are not familiar with LoopBack providers, check the documentation +in +[Extending LoopBack 4](https://loopback.io/doc/en/lb4/Extending-LoopBack-4.html)_ + +1. Create a provider for the strategy + +Use `passport-http` as the example again: + +```ts +class PassportBasicAuthProvider implements Provider { + value(): AuthenticationStrategy { + // The code that returns the converted strategy + } +} +``` + +The Provider should have two functions: + +- A function that takes in the verify callback function and returns a configured + basic strategy. To know more about the configuration, please check + [the configuration guide in module `passport-http`](https://github.com/jaredhanson/passport-http#usage-of-http-basic). + +- A function that applies the `StrategyAdapter` to the configured basic strategy + instance. Then in the `value()` function, you return the converted strategy. + +So a full implementation of the provider is: + +```ts +import {BasicStrategy, BasicVerifyFunction} from 'passport-http'; +import {StrategyAdapter} from `@loopback/passport-adapter`; +import {AuthenticationStrategy} from '@loopback/authentication'; + +class PassportBasicAuthProvider implements Provider { + constructor( + @inject('authentication.basic.verify') verifyFn: BasicVerifyFunction, + ); + value(): AuthenticationStrategy { + const basicStrategy = this.configuredBasicStrategy(verify); + return this.convertToAuthStrategy(basicStrategy); + } + + // Takes in the verify callback function and returns a configured basic strategy. + configuredBasicStrategy(verifyFn: BasicVerifyFunction): BasicStrategy { + return new BasicStrategy(verifyFn); + } + + // Applies the `StrategyAdapter` to the configured basic strategy instance. + // You'd better define your strategy name as a constant, like + // `const AUTH_STRATEGY_NAME = 'basic'` + // You will need to decorate the APIs later with the same name + convertToAuthStrategy(basic: BasicStrategy): AuthenticationStrategy { + return new StrategyAdapter(basic, AUTH_STRATEGY_NAME); + } +} +``` + +2. Register the strategy provider + +Register the strategy provider in your LoopBack application so that the +authentication system can look for your strategy by name and invoke it: + +```ts +// In the main file + +import {addExtension} from '@loopback/core'; +import {MyApplication} from ''; +import {PassportBasicAuthProvider} from ''; +import { + AuthenticationBindings, + registerAuthenticationStrategy, +} from '@loopback/authentication'; + +const app = new MyApplication(); + +// In a real app the function would be imported from a community module +function verify(username: string, password: string, cb: Function) { + users.find(username, password, cb); +} + +app.bind('authentication.basic.verify').to(verify); +registerAuthenticationStrategy(app, PassportBasicAuthProvider); +``` + +3. Decorate your endpoint + +To authenticate your request with the basic strategy, decorate your controller +function like: + +```ts +class MyController { + constructor( + @inject(AuthenticationBindings.CURRENT_USER) private user: UserProfile, + ) {} + + // Define your strategy name as a constant so that + // it is consistent with the name you provide in the adapter + @authenticate(AUTH_STRATEGY_NAME) + async whoAmI(): Promise { + return this.user.id; + } +} +``` diff --git a/labs/authentication-passport/docs.json b/labs/authentication-passport/docs.json new file mode 100644 index 000000000000..30b1a4b8d345 --- /dev/null +++ b/labs/authentication-passport/docs.json @@ -0,0 +1,8 @@ +{ + "content": [ + "index.ts", + "src/index.ts", + "src/strategy-adapter.ts" + ], + "codeSectionDepth": 4 +} diff --git a/labs/authentication-passport/index.d.ts b/labs/authentication-passport/index.d.ts new file mode 100644 index 000000000000..d677863d1138 --- /dev/null +++ b/labs/authentication-passport/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/authentication-passport +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/labs/authentication-passport/index.js b/labs/authentication-passport/index.js new file mode 100644 index 000000000000..e855a80461eb --- /dev/null +++ b/labs/authentication-passport/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/authentication-passport +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +module.exports = require('./dist'); diff --git a/labs/authentication-passport/index.ts b/labs/authentication-passport/index.ts new file mode 100644 index 000000000000..a3222e0b2e29 --- /dev/null +++ b/labs/authentication-passport/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/authentication-passport +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// DO NOT EDIT THIS FILE +// Add any additional (re)exports to src/index.ts instead. +export * from './src'; diff --git a/labs/authentication-passport/package-lock.json b/labs/authentication-passport/package-lock.json new file mode 100644 index 000000000000..98912a02037a --- /dev/null +++ b/labs/authentication-passport/package-lock.json @@ -0,0 +1,128 @@ +{ + "name": "@loopback/authentication-passport", + "version": "0.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/body-parser": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.0.tgz", + "integrity": "sha512-a2+YeUjPkztKJu5aIF2yArYFQQp8d51wZ7DavSHjFuY1mqVgidGyzEQ41JIVNy82fXj8yPgy2vJmfIywgESW6w==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.32", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz", + "integrity": "sha512-4r8qa0quOvh7lGD0pre62CAb1oni1OO6ecJLGCezTmhQ8Fz50Arx9RUszryR8KlgK6avuSXvviL6yWyViQABOg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/express": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.0.tgz", + "integrity": "sha512-CjaMu57cjgjuZbh9DpkloeGxV45CnMGlVd+XpG7Gm9QgVrd7KFq+X4HY0vM+2v0bczS48Wg7bvnMY5TN+Xmcfw==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.16.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.16.7.tgz", + "integrity": "sha512-847KvL8Q1y3TtFLRTXcVakErLJQgdpFSaq+k043xefz9raEf0C7HalpSY7OW5PyjCnY8P7bPW5t/Co9qqp+USg==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/range-parser": "*" + } + }, + "@types/mime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", + "integrity": "sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==", + "dev": true + }, + "@types/node": { + "version": "10.14.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.9.tgz", + "integrity": "sha512-NelG/dSahlXYtSoVPErrp06tYFrvzj8XLWmKA+X8x0W//4MqbUyZu++giUG/v0bjAT6/Qxa8IjodrfdACyb0Fg==", + "dev": true + }, + "@types/passport": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.0.tgz", + "integrity": "sha512-R2FXqM+AgsMIym0PuKj08Ybx+GR6d2rU3b1/8OcHolJ+4ga2pRPX105wboV6hq1AJvMo2frQzYKdqXS5+4cyMw==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, + "@types/passport-http": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@types/passport-http/-/passport-http-0.3.8.tgz", + "integrity": "sha512-PRjqOljk7pkOqXNTEL0SWS1wTxwuMGPkAjLJjg4wKpH2NPdSo0Z1rGndawMbqbB4/WaR6odwO3G8XEmR2FBk4g==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*" + } + }, + "@types/range-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", + "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==", + "dev": true + }, + "@types/serve-static": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-/BZ4QRLpH/bNYgZgwhKEh+5AsboDBcUdlBYgzoLX0fpj3Y2gp6EApyOlM3bK53wQS/OE1SrdSYBAbux2D1528Q==", + "dev": true, + "requires": { + "@types/express-serve-static-core": "*", + "@types/mime": "*" + } + }, + "passport": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.4.0.tgz", + "integrity": "sha1-xQlWkTR71a07XhgCOMORTRbwWBE=", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1" + } + }, + "passport-http": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/passport-http/-/passport-http-0.3.0.tgz", + "integrity": "sha1-juU9Q4C+nGDfIVGSUCmCb3cRVgM=", + "dev": true, + "requires": { + "passport-strategy": "1.x.x" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=" + }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" + }, + "util-promisifyall": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/util-promisifyall/-/util-promisifyall-1.0.6.tgz", + "integrity": "sha512-l+o62sbaqStC1xt7oEhlafC4jWBgkOjBXvlPwxkvOYmNqpY8dNXuKdOa+VHjkYz2Fw98e0HvJtNKUg0+6hfP2w==" + } + } +} diff --git a/labs/authentication-passport/package.json b/labs/authentication-passport/package.json new file mode 100644 index 000000000000..22c37f0132a0 --- /dev/null +++ b/labs/authentication-passport/package.json @@ -0,0 +1,61 @@ +{ + "name": "@loopback/authentication-passport", + "version": "0.1.0", + "description": "A package creating adapters between the passport module and @loopback/authentication", + "engines": { + "node": ">=8.9" + }, + "scripts": { + "acceptance": "lb-mocha \"dist/__tests__/acceptance/**/*.js\"", + "build:apidocs": "lb-apidocs", + "build": "lb-tsc", + "clean": "lb-clean loopback-authentication-passport*.tgz dist tsconfig.build.tsbuildinfo package", + "pretest": "npm run build", + "test": "lb-mocha \"dist/__tests__/**/*.js\"", + "unit": "lb-mocha \"dist/__tests__/unit/**/*.js\"", + "verify": "npm pack && tar xf loopback-authentication-passport*.tgz && tree package && npm run clean" + }, + "author": "IBM Corp.", + "copyright.owner": "IBM Corp.", + "license": "MIT", + "keywords": [ + "Passport", + "Authentication", + "TypeScript" + ], + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist", + "src", + "!*/__tests__" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + }, + "dependencies": { + "@loopback/authentication": "^2.0.0", + "@loopback/context": "^1.16.0", + "@loopback/core": "^1.7.1", + "@loopback/metadata": "^1.1.5", + "@loopback/openapi-v3": "^1.4.0", + "@loopback/rest": "^1.11.2", + "passport": "^0.4.0", + "util-promisifyall": "^1.0.6" + }, + "devDependencies": { + "@loopback/build": "^1.5.5", + "@loopback/openapi-spec-builder": "^1.1.11", + "@loopback/testlab": "^1.2.10", + "@loopback/eslint-config": "^1.1.1", + "@types/node": "^10.14.8", + "@types/passport": "^1.0.0", + "@types/passport-http": "^0.3.8", + "passport-http": "^0.3.0" + } +} diff --git a/labs/authentication-passport/src/__tests__/acceptance/authentication-with-passport-strategy-adapter.acceptance.ts b/labs/authentication-passport/src/__tests__/acceptance/authentication-with-passport-strategy-adapter.acceptance.ts new file mode 100644 index 000000000000..33910b4d056d --- /dev/null +++ b/labs/authentication-passport/src/__tests__/acceptance/authentication-with-passport-strategy-adapter.acceptance.ts @@ -0,0 +1,201 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/authentication-passport +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + authenticate, + AuthenticateFn, + AuthenticationBindings, + AuthenticationComponent, + UserProfile, +} from '@loopback/authentication'; +import {inject} from '@loopback/context'; +import {Application, CoreTags} from '@loopback/core'; +import {anOpenApiSpec} from '@loopback/openapi-spec-builder'; +import {api, get} from '@loopback/openapi-v3'; +import { + FindRoute, + InvokeMethod, + ParseParams, + Reject, + RequestContext, + RestBindings, + RestComponent, + RestServer, + Send, + SequenceHandler, +} from '@loopback/rest'; +import {Client, createClientForHandler} from '@loopback/testlab'; +import {BasicStrategy} from 'passport-http'; +import {StrategyAdapter} from '../../strategy-adapter'; +const SequenceActions = RestBindings.SequenceActions; +const AUTH_STRATEGY_NAME = 'basic'; + +describe('Basic Authentication', () => { + let app: Application; + let server: RestServer; + let users: UserRepository; + beforeEach(givenAServer); + beforeEach(givenUserRepository); + beforeEach(givenControllerInApp); + beforeEach(givenAuthenticatedSequence); + + it('authenticates successfully for correct credentials', async () => { + const client = whenIMakeRequestTo(server); + const credential = + users.list.joe.profile.id + ':' + users.list.joe.password; + const hash = Buffer.from(credential).toString('base64'); + await client + .get('/whoAmI') + .set('Authorization', 'Basic ' + hash) + .expect(users.list.joe.profile.id); + }); + + it('returns error for invalid credentials', async () => { + const client = whenIMakeRequestTo(server); + const credential = users.list.Simpson.profile.id + ':' + 'invalid'; + const hash = Buffer.from(credential).toString('base64'); + await client + .get('/whoAmI') + .set('Authorization', 'Basic ' + hash) + .expect(401); + }); + + 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}); + }); + + function givenUserRepository() { + users = new UserRepository({ + joe: {profile: {id: 'joe'}, password: '12345'}, + Simpson: {profile: {id: 'sim123'}, password: 'alpha'}, + Flinstone: {profile: {id: 'Flint'}, password: 'beta'}, + George: {profile: {id: 'Curious'}, password: 'gamma'}, + }); + } + + // Since it has to be user's job to provide the `verify` function and + // instantiate the passport strategy, we cannot add the imported `BasicStrategy` + // class as extension directly. + // We need to either wrap it as a strategy provider, and add the provider + // class as the extension. (When having something like the verify function to inject) + // Or just wrap the basic strategy instance and bind it to the app. (When nothing to inject) + + function verify(username: string, password: string, cb: Function) { + users.find(username, password, cb); + } + const basicStrategy = new BasicStrategy(verify); + const basicAuthStrategy = new StrategyAdapter( + basicStrategy, + AUTH_STRATEGY_NAME, + ); + + async function givenAServer() { + app = new Application(); + app.component(AuthenticationComponent); + app.component(RestComponent); + app + .bind('authentication.strategies.basicAuthStrategy') + .to(basicAuthStrategy) + .tag({ + [CoreTags.EXTENSION_FOR]: + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + }); + server = await app.getServer(RestServer); + } + + function givenControllerInApp() { + const apispec = anOpenApiSpec() + .withOperation('get', '/whoAmI', { + 'x-operation-name': 'whoAmI', + responses: { + '200': { + description: '', + schema: { + type: 'string', + }, + }, + }, + }) + .build(); + + @api(apispec) + class MyController { + constructor( + @inject(AuthenticationBindings.CURRENT_USER) private user: UserProfile, + ) {} + + @authenticate(AUTH_STRATEGY_NAME) + async whoAmI(): Promise { + return this.user.id; + } + } + app.controller(MyController); + } + + function givenAuthenticatedSequence() { + class MySequence implements SequenceHandler { + constructor( + @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, + @inject(SequenceActions.PARSE_PARAMS) + protected parseParams: ParseParams, + @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, + @inject(SequenceActions.SEND) protected send: Send, + @inject(SequenceActions.REJECT) protected reject: Reject, + @inject(AuthenticationBindings.AUTH_ACTION) + protected authenticateRequest: AuthenticateFn, + ) {} + + async handle(context: RequestContext) { + try { + const {request, response} = context; + const route = this.findRoute(request); + + // Authenticate + await this.authenticateRequest(request); + + // Authentication successful, proceed to invoke controller + const args = await this.parseParams(request, route); + const result = await this.invoke(route, args); + this.send(response, result); + } catch (error) { + this.reject(context, error); + return; + } + } + } + // bind user defined sequence + server.sequence(MySequence); + } + + function whenIMakeRequestTo(restServer: RestServer): Client { + return createClientForHandler(restServer.requestHandler); + } +}); + +class UserRepository { + constructor( + readonly list: {[key: string]: {profile: UserProfile; password: string}}, + ) {} + find(username: string, password: string, cb: Function): void { + const userList = this.list; + function search(key: string) { + return userList[key].profile.id === username; + } + const found = Object.keys(userList).find(search); + if (!found) return cb(null, false); + if (userList[found].password !== password) return cb(null, false); + cb(null, userList[found].profile); + } +} diff --git a/labs/authentication-passport/src/__tests__/unit/fixtures/mock-passport-strategy.ts b/labs/authentication-passport/src/__tests__/unit/fixtures/mock-passport-strategy.ts new file mode 100644 index 000000000000..8b0ac12ade14 --- /dev/null +++ b/labs/authentication-passport/src/__tests__/unit/fixtures/mock-passport-strategy.ts @@ -0,0 +1,71 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/authentication-passport +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// Should it be imported from 'express'? +// The `Request` type from 'express' is not compatible +// with the one from `@loopback/rest` now. +import {UserProfile} from '@loopback/authentication'; +import {Request} from '@loopback/rest'; +import {AuthenticateOptions, Strategy} from 'passport'; + +/** + * Test fixture for a mock asynchronous passport-strategy + */ +export class MockPassportStrategy extends Strategy { + // user to return for successful authentication + private mockUser: UserProfile; + public name: string = 'mock-strategy'; + + setMockUser(userObj: UserProfile) { + this.mockUser = userObj; + } + + /** + * authenticate() function similar to passport-strategy packages + * @param req + */ + async authenticate(req: Request, options?: AuthenticateOptions) { + await this.verify(req); + } + /** + * @param req + * mock verification function; usually passed in as constructor argument for + * passport-strategy + * + * For the purpose of mock tests we have this here + * pass req.query.testState = 'fail' to mock failed authorization + * pass req.query.testState = 'error' to mock unexpected error + */ + async verify(request: Request) { + if ( + request.headers && + request.headers.testState && + request.headers.testState === 'fail' + ) { + this.returnUnauthorized('authorization failed'); + return; + } else if ( + request.headers && + request.headers.testState && + request.headers.testState === 'error' + ) { + this.returnError('unexpected error'); + return; + } + process.nextTick(this.returnMockUser.bind(this)); + } + + returnMockUser() { + this.success(this.mockUser); + } + + returnUnauthorized(challenge?: string | number, status?: number) { + this.fail(challenge, status); + } + + returnError(err: string) { + this.error(err); + } +} diff --git a/labs/authentication-passport/src/__tests__/unit/passport-strategy-adapter.unit.ts b/labs/authentication-passport/src/__tests__/unit/passport-strategy-adapter.unit.ts new file mode 100644 index 000000000000..ab316cabd704 --- /dev/null +++ b/labs/authentication-passport/src/__tests__/unit/passport-strategy-adapter.unit.ts @@ -0,0 +1,73 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/authentication-passport +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {UserProfile} from '@loopback/authentication'; +import {HttpErrors, Request} from '@loopback/rest'; +import {expect} from '@loopback/testlab'; +import {AuthenticateOptions} from 'passport'; +import {StrategyAdapter} from '../..'; +import {MockPassportStrategy} from './fixtures/mock-passport-strategy'; + +describe('Strategy Adapter', () => { + const mockUser: UserProfile = {name: 'user-name', id: 'mock-id'}; + + describe('authenticate()', () => { + it('calls the authenticate method of the strategy', async () => { + let calledFlag = false; + // TODO: (as suggested by @bajtos) use sinon spy + class MyStrategy extends MockPassportStrategy { + // override authenticate method to set calledFlag + async authenticate(req: Request, options?: AuthenticateOptions) { + calledFlag = true; + await super.authenticate(req, options); + } + } + const strategy = new MyStrategy(); + const adapter = new StrategyAdapter(strategy, 'mock-strategy'); + const request = {}; + await adapter.authenticate(request); + expect(calledFlag).to.be.true(); + }); + + it('returns a promise which resolves to an object', async () => { + const strategy = new MockPassportStrategy(); + strategy.setMockUser(mockUser); + const adapter = new StrategyAdapter(strategy, 'mock-strategy'); + const request = {}; + const user: Object = await adapter.authenticate(request); + expect(user).to.be.eql(mockUser); + }); + + it('throws Unauthorized error when authentication fails', async () => { + const strategy = new MockPassportStrategy(); + strategy.setMockUser(mockUser); + const adapter = new StrategyAdapter(strategy, 'mock-strategy'); + const request = {}; + request.headers = {testState: 'fail'}; + let error; + try { + await adapter.authenticate(request); + } catch (err) { + error = err; + } + expect(error).to.be.instanceof(HttpErrors.Unauthorized); + }); + + it('throws InternalServerError when strategy returns error', async () => { + const strategy = new MockPassportStrategy(); + strategy.setMockUser(mockUser); + const adapter = new StrategyAdapter(strategy, 'mock-strategy'); + const request = {}; + request.headers = {testState: 'error'}; + let error; + try { + await adapter.authenticate(request); + } catch (err) { + error = err; + } + expect(error).to.be.instanceof(HttpErrors.InternalServerError); + }); + }); +}); diff --git a/labs/authentication-passport/src/index.ts b/labs/authentication-passport/src/index.ts new file mode 100644 index 000000000000..a7aaa74a7146 --- /dev/null +++ b/labs/authentication-passport/src/index.ts @@ -0,0 +1 @@ +export * from './strategy-adapter'; diff --git a/labs/authentication-passport/src/strategy-adapter.ts b/labs/authentication-passport/src/strategy-adapter.ts new file mode 100644 index 000000000000..aca83c663bfe --- /dev/null +++ b/labs/authentication-passport/src/strategy-adapter.ts @@ -0,0 +1,64 @@ +// Copyright IBM Corp. 2017,2019. All Rights Reserved. +// Node module: @loopback/authentication-passport +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {AuthenticationStrategy, UserProfile} from '@loopback/authentication'; +import {HttpErrors, Request} from '@loopback/rest'; +import {Strategy} from 'passport'; + +const passportRequestMixin = require('passport/lib/http/request'); + +/** + * Adapter class to invoke passport-strategy + * 1. provides express dependencies to the passport strategies + * 2. provides shimming of requests for passport authentication + * 3. provides lifecycle similar to express to the passport-strategy + * 4. provides state methods to the strategy instance + * see: https://github.com/jaredhanson/passport + */ +export class StrategyAdapter implements AuthenticationStrategy { + /** + * @param strategy instance of a class which implements a passport-strategy; + * @description http://passportjs.org/ + */ + constructor(private readonly strategy: Strategy, readonly name: string) {} + + /** + * The function to invoke the contained passport strategy. + * 1. Create an instance of the strategy + * 2. add success and failure state handlers + * 3. authenticate using the strategy + * @param request The incoming request. + */ + authenticate(request: Request): Promise { + return new Promise((resolve, reject) => { + // mix-in passport additions like req.logIn and req.logOut + for (const key in passportRequestMixin) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (request as any)[key] = passportRequestMixin[key]; + } + + // create a prototype chain of an instance of a passport strategy + const strategy = Object.create(this.strategy); + + // add success state handler to strategy instance + strategy.success = function(user: UserProfile) { + resolve(user); + }; + + // add failure state handler to strategy instance + strategy.fail = function(challenge: string) { + reject(new HttpErrors.Unauthorized(challenge)); + }; + + // add error state handler to strategy instance + strategy.error = function(error: string) { + reject(new HttpErrors.InternalServerError(error)); + }; + + // authenticate + strategy.authenticate(request); + }); + } +} diff --git a/labs/authentication-passport/tsconfig.build.json b/labs/authentication-passport/tsconfig.build.json new file mode 100644 index 000000000000..4435a00cd154 --- /dev/null +++ b/labs/authentication-passport/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@loopback/build/config/tsconfig.common.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src" + ] +} diff --git a/package.json b/package.json index a9c696ca0215..a91263dc587d 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "test:ci": "node packages/build/bin/run-nyc npm run mocha --scripts-prepend-node-path", "verify:docs": "npm run build:site -- --verify", "build:site": "./bin/build-docs-site.sh", - "mocha": "node packages/build/bin/run-mocha \"packages/*/dist/__tests__/**/*.js\" \"examples/*/dist/__tests__/**/*.js\" \"packages/cli/test/**/*.js\" \"packages/build/test/*/*.js\"", + "mocha": "node packages/build/bin/run-mocha \"labs/*/dist/__tests__/**/*.js\"", "posttest": "npm run lint" }, "config": { diff --git a/packages/authentication/src/__tests__/acceptance/basic-auth-extension.acceptance.ts b/packages/authentication/src/__tests__/acceptance/basic-auth-extension.acceptance.ts index bb85b776d6e8..30d5c51fa437 100644 --- a/packages/authentication/src/__tests__/acceptance/basic-auth-extension.acceptance.ts +++ b/packages/authentication/src/__tests__/acceptance/basic-auth-extension.acceptance.ts @@ -3,30 +3,21 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {inject} from '@loopback/context'; -import {Application} from '@loopback/core'; -import {anOpenApiSpec} from '@loopback/openapi-spec-builder'; -import {api, get} from '@loopback/openapi-v3'; -import {Request, RestServer} from '@loopback/rest'; -import {Client, createClientForHandler} from '@loopback/testlab'; -import { - authenticate, - AuthenticationBindings, - registerAuthenticationStrategy, - UserProfile, -} from '../..'; -import {AuthenticationStrategy} from '../../types'; -import { - createBasicAuthorizationHeaderValue, - getApp, - getUserRepository, -} from '../fixtures/helper'; -import {BasicAuthenticationStrategyBindings, USER_REPO} from '../fixtures/keys'; -import {MyAuthenticationSequence} from '../fixtures/sequences/authentication.sequence'; -import {BasicAuthenticationUserService} from '../fixtures/services/basic-auth-user-service'; -import {BasicAuthenticationStrategy} from '../fixtures/strategies/basic-strategy'; -import {User} from '../fixtures/users/user'; -import {UserRepository} from '../fixtures/users/user.repository'; +import { inject } from '@loopback/context'; +import { Application } from '@loopback/core'; +import { anOpenApiSpec } from '@loopback/openapi-spec-builder'; +import { api, get } from '@loopback/openapi-v3'; +import { Request, RestServer } from '@loopback/rest'; +import { Client, createClientForHandler } from '@loopback/testlab'; +import { authenticate, AuthenticationBindings, registerAuthenticationStrategy, UserProfile } from '../..'; +import { AuthenticationStrategy } from '../../types'; +import { createBasicAuthorizationHeaderValue, getApp, getUserRepository } from '../fixtures/helper'; +import { BasicAuthenticationStrategyBindings, USER_REPO } from '../fixtures/keys'; +import { MyAuthenticationSequence } from '../fixtures/sequences/authentication.sequence'; +import { BasicAuthenticationUserService } from '../fixtures/services/basic-auth-user-service'; +import { BasicAuthenticationStrategy } from '../fixtures/strategies/basic-strategy'; +import { User } from '../fixtures/users/user'; +import { UserRepository } from '../fixtures/users/user.repository'; describe('Basic Authentication', () => { let app: Application; @@ -64,7 +55,7 @@ describe('Basic Authentication', () => { .get('/whoAmI') .set( 'Authorization', - createBasicAuthorizationHeaderValue(joeUser, {prefix: 'NotB@sic '}), + createBasicAuthorizationHeaderValue(joeUser, { prefix: 'NotB@sic ' }), ) .expect({ error: { @@ -98,7 +89,7 @@ describe('Basic Authentication', () => { .get('/whoAmI') .set( 'Authorization', - createBasicAuthorizationHeaderValue(joeUser, {separator: '|'}), + createBasicAuthorizationHeaderValue(joeUser, { separator: '|' }), ) .expect({ error: { @@ -132,14 +123,14 @@ describe('Basic Authentication', () => { class InfoController { @get('/status') status() { - return {running: true}; + return { running: true }; } } app.controller(InfoController); await whenIMakeRequestTo(server) .get('/status') - .expect(200, {running: true}); + .expect(200, { running: true }); }); it('returns error for unknown authentication strategy', async () => { @@ -147,7 +138,7 @@ describe('Basic Authentication', () => { @get('/status') @authenticate('doesnotexist') status() { - return {running: true}; + return { running: true }; } } @@ -176,7 +167,7 @@ describe('Basic Authentication', () => { @get('/status') @authenticate('badbasic') status() { - return {running: true}; + return { running: true }; } } @@ -214,7 +205,7 @@ describe('Basic Authentication', () => { @api(apispec) class MyController { - constructor() {} + constructor() { } @authenticate('basic') async whoAmI(