diff --git a/labs/passport-adapter/README.md b/labs/passport-adapter/README.md index f92edbb0cd7d..8c7ad9783dad 100644 --- a/labs/passport-adapter/README.md +++ b/labs/passport-adapter/README.md @@ -1,4 +1,4 @@ -## Passport Adapter +# Passport Strategy Adapter _Important: We suggest users understand LoopBack's authentication system[Link TBD](some loopback.io link) before using this module_ @@ -127,3 +127,79 @@ class MyController { } } ``` + +# Passport Adapter + +This adapter is different than the "Passport strategy adapter" described above in the way that it wraps the **passport middleware** itself instead of a passport strategy. + +`PassportAdapter` has an `authenticate` function, which creates a passport instance, uses the injected strategy and invokes `passport.authenticate()` to perform the authentication. + +## Usage + +1. Create a provider for the strategy + +It returns a configured `BasicStrategy` that's wrapped with the `PassportAdapter` class. + +```ts + class PassportBasicAuthProvider implements Provider { + value(): AuthenticationStrategy { + const basicStrategy = this.configuredBasicStrategy(verify); + cosnt passport = new Passport(); + passport.use(basicStrategy); + return new PassportAdapter(passport); + } + + configuredBasicStrategy(verifyFn: BasicVerifyFunction): BasicStrategy { + return new BasicStrategy(verifyFn); + } + } +``` + +2. Register the strategy provider + +*Exactly same as the strategy adapter* + +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 {AuthenticationBindings} from '@loopback/authentication'; + +const app = new MyApplication(); + +addExtension( + app, + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + PassportBasicAuthProvider, + { + namespace: + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + }, +); +``` + +3. Decorate your endpoint + +*Exactly same as the strategy adapter* + +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/passport-adapter/src/__tests__/acceptance/authentication-with-passport-adapter.acceptance.ts b/labs/passport-adapter/src/__tests__/acceptance/authentication-with-passport-adapter.acceptance.ts new file mode 100644 index 000000000000..5fa6eb065ee1 --- /dev/null +++ b/labs/passport-adapter/src/__tests__/acceptance/authentication-with-passport-adapter.acceptance.ts @@ -0,0 +1,201 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/authentication +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// FOR REVIWERS: THIS ACCEPTANCE TEST IS FOR `PassportAdapter` + +import { authenticate, AuthenticateFn, AuthenticationBindings, AuthenticationComponent, AuthenticationStrategy, UserProfile } from '@loopback/authentication'; +import { inject } from '@loopback/context'; +import { addExtension, Application, Provider } 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, BasicVerifyFunction } from 'passport-http'; +import { PassportAdapter } from '../../'; +const SequenceActions = RestBindings.SequenceActions; +const AUTH_STRATEGY_NAME = 'basic'; + +describe('Basic Authentication - passport', () => { + 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' }, + }); + } + + class PassportBasicAuthProvider implements Provider { + value(): AuthenticationStrategy { + const basicStrategy = this.configuredBasicStrategy(verify); + // Ideally the passport adapter should accept a configured + // passport instance like: + + // const passport = new Passport(); + // passport.use(basicStrategy); + // return new PassportAdapter(passport); + + // The workaround still takes a strategy because the types exported from + // `passport` module gives error when using it as a type: + // afunction(passport: Passport){} + // the code above has type error. I've tried all the possible types exported + // from `passport` module and none of them work. + return new PassportAdapter(basicStrategy, 'basic'); + } + + configuredBasicStrategy(verifyFn: BasicVerifyFunction): BasicStrategy { + return new BasicStrategy(verifyFn); + } + } + + function verify(username: string, password: string, cb: Function) { + process.nextTick(() => { + users.find(username, password, cb); + }); + } + + async function givenAServer() { + app = new Application(); + app.component(AuthenticationComponent); + app.component(RestComponent); + addExtension( + app, + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + PassportBasicAuthProvider, + { + namespace: + 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/passport-adapter/src/__tests__/acceptance/authentication-with-passport-strategy.acceptance.ts b/labs/passport-adapter/src/__tests__/acceptance/authentication-with-passport-strategy-adapter.acceptance.ts similarity index 81% rename from labs/passport-adapter/src/__tests__/acceptance/authentication-with-passport-strategy.acceptance.ts rename to labs/passport-adapter/src/__tests__/acceptance/authentication-with-passport-strategy-adapter.acceptance.ts index 57e3b830e0cf..70524db94284 100644 --- a/labs/passport-adapter/src/__tests__/acceptance/authentication-with-passport-strategy.acceptance.ts +++ b/labs/passport-adapter/src/__tests__/acceptance/authentication-with-passport-strategy-adapter.acceptance.ts @@ -3,33 +3,17 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {inject} from '@loopback/context'; -import {addExtension, Application, Provider} 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, BasicVerifyFunction} from 'passport-http'; -import { - authenticate, - AuthenticationStrategy, - AuthenticateFn, - AuthenticationBindings, - AuthenticationComponent, - UserProfile, -} from '@loopback/authentication'; -import {StrategyAdapter} from '../../'; +// FOR REVIWERS: THIS ACCEPTANCE TEST IS FOR `StrategyAdapter` + +import { authenticate, AuthenticateFn, AuthenticationBindings, AuthenticationComponent, AuthenticationStrategy, UserProfile } from '@loopback/authentication'; +import { inject } from '@loopback/context'; +import { addExtension, Application, Provider } 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, BasicVerifyFunction } from 'passport-http'; +import { StrategyAdapter } from '../../'; const SequenceActions = RestBindings.SequenceActions; const AUTH_STRATEGY_NAME = 'basic'; @@ -67,22 +51,22 @@ 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 }); }); 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'}, + joe: { profile: { id: 'joe' }, password: '12345' }, + Simpson: { profile: { id: 'sim123' }, password: 'alpha' }, + Flinstone: { profile: { id: 'Flint' }, password: 'beta' }, + George: { profile: { id: 'Curious' }, password: 'gamma' }, }); } @@ -147,7 +131,7 @@ describe('Basic Authentication', () => { class MyController { constructor( @inject(AuthenticationBindings.CURRENT_USER) private user: UserProfile, - ) {} + ) { } @authenticate(AUTH_STRATEGY_NAME) async whoAmI(): Promise { @@ -168,11 +152,11 @@ describe('Basic Authentication', () => { @inject(SequenceActions.REJECT) protected reject: Reject, @inject(AuthenticationBindings.AUTH_ACTION) protected authenticateRequest: AuthenticateFn, - ) {} + ) { } async handle(context: RequestContext) { try { - const {request, response} = context; + const { request, response } = context; const route = this.findRoute(request); // Authenticate @@ -199,8 +183,8 @@ describe('Basic Authentication', () => { class UserRepository { constructor( - readonly list: {[key: string]: {profile: UserProfile; password: string}}, - ) {} + readonly list: { [key: string]: { profile: UserProfile; password: string } }, + ) { } find(username: string, password: string, cb: Function): void { const userList = this.list; function search(key: string) { diff --git a/labs/passport-adapter/src/__tests__/unit/fixtures/mock-passport-strategy.ts b/labs/passport-adapter/src/__tests__/unit/fixtures/mock-passport-strategy.ts index e5f9761ec24b..acd685457b7e 100644 --- a/labs/passport-adapter/src/__tests__/unit/fixtures/mock-passport-strategy.ts +++ b/labs/passport-adapter/src/__tests__/unit/fixtures/mock-passport-strategy.ts @@ -6,9 +6,9 @@ // 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'; -import {UserProfile} from '@loopback/authentication'; /** * Test fixture for a mock asynchronous passport-strategy @@ -16,6 +16,7 @@ import {UserProfile} from '@loopback/authentication'; 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; diff --git a/labs/passport-adapter/src/__tests__/unit/passport-adapter.unit.ts b/labs/passport-adapter/src/__tests__/unit/passport-adapter.unit.ts new file mode 100644 index 000000000000..f3494355439a --- /dev/null +++ b/labs/passport-adapter/src/__tests__/unit/passport-adapter.unit.ts @@ -0,0 +1,75 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/authentication +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// FOR REVIWERS: THIS UNIT TEST IS FOR `PassportAdapter` + +import { UserProfile } from '@loopback/authentication'; +import { HttpErrors, Request } from '@loopback/rest'; +import { expect } from '@loopback/testlab'; +import { AuthenticateOptions } from 'passport'; +import { PassportAdapter } from '../..'; +import { MockPassportStrategy } from './fixtures/mock-passport-strategy'; + +describe('Passport 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 PassportAdapter(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 PassportAdapter(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 PassportAdapter(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 PassportAdapter(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/passport-adapter/src/__tests__/unit/strategy-adapter.unit.ts b/labs/passport-adapter/src/__tests__/unit/passport-strategy-adapter.unit.ts similarity index 80% rename from labs/passport-adapter/src/__tests__/unit/strategy-adapter.unit.ts rename to labs/passport-adapter/src/__tests__/unit/passport-strategy-adapter.unit.ts index 98a25f2dc1c8..7c1aa0b906e7 100644 --- a/labs/passport-adapter/src/__tests__/unit/strategy-adapter.unit.ts +++ b/labs/passport-adapter/src/__tests__/unit/passport-strategy-adapter.unit.ts @@ -3,15 +3,17 @@ // 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 {MockPassportStrategy} from './fixtures/mock-passport-strategy'; -import {StrategyAdapter} from '../..'; +// FOR REVIWERS: THIS UNIT TEST IS FOR `StrategyAdapter` + +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'}; + const mockUser: UserProfile = { name: 'user-name', id: 'mock-id' }; describe('authenticate()', () => { it('calls the authenticate method of the strategy', async () => { @@ -45,7 +47,7 @@ describe('Strategy Adapter', () => { strategy.setMockUser(mockUser); const adapter = new StrategyAdapter(strategy, 'mock-strategy'); const request = {}; - request.headers = {testState: 'fail'}; + request.headers = { testState: 'fail' }; let error; try { await adapter.authenticate(request); @@ -60,7 +62,7 @@ describe('Strategy Adapter', () => { strategy.setMockUser(mockUser); const adapter = new StrategyAdapter(strategy, 'mock-strategy'); const request = {}; - request.headers = {testState: 'error'}; + request.headers = { testState: 'error' }; let error; try { await adapter.authenticate(request); diff --git a/labs/passport-adapter/src/index.ts b/labs/passport-adapter/src/index.ts index a7aaa74a7146..e35a7f9dfaeb 100644 --- a/labs/passport-adapter/src/index.ts +++ b/labs/passport-adapter/src/index.ts @@ -1 +1,2 @@ +export * from './passport-adapter'; export * from './strategy-adapter'; diff --git a/labs/passport-adapter/src/passport-adapter.ts b/labs/passport-adapter/src/passport-adapter.ts new file mode 100644 index 000000000000..4bd0dd6c8b47 --- /dev/null +++ b/labs/passport-adapter/src/passport-adapter.ts @@ -0,0 +1,78 @@ +// Copyright IBM Corp. 2017,2019. 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 { AuthenticationStrategy, UserProfile } from '@loopback/authentication'; +import { HttpErrors, Request, Response } from '@loopback/rest'; +import { Passport, Strategy } from 'passport'; + +const passportRequestMixin = require('passport/lib/http/request'); + +/** + * Adapter class that implements `AuthenticationStrategy` from `@loopback/authentication` + * and invokes the authenticate function from `passport` under the hood. + * see: https://github.com/jaredhanson/passport + */ +export class PassportAdapter implements AuthenticationStrategy { + _passportStrategy: Strategy; + + + // Ideally the constructor should be + // constructor(private passport: Passport) { + // this._passport = passport; + // } + // The reason why temporarily it takes in a strategys is explained in + // authentication-with-passport-adapter.acceptance Line 76 + /** + * @param strategy instance of a class which implements a passport-strategy; + * @description http://passportjs.org/ + */ + constructor(private strategy: Strategy, readonly name: string) { + this._passportStrategy = strategy; + this.name = name; + } + + /** + * 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. + */ + async authenticate(request: Request): Promise { + return new Promise((resolve, reject) => { + // The following 2 lines should be created in the strategy provider + // and call `new PassportAdapter(passport)` to instantiate the adapter class + // The reason why temporarily put it here is explained in + // authentication-with-passport-adapter.acceptance Line 76 + const passport = new Passport(); + passport.use(this._passportStrategy); + for (const key in passportRequestMixin) { + // tslint:disable-next-line:no-any + (request as any)[key] = passportRequestMixin[key]; + } + + const response = ({} as any) as Response; + const cb = (err: Error, user: UserProfile, challenge: string) => { + // TBD: support multiple strategies + // reject internal server errors + if (err) return reject(new HttpErrors.InternalServerError(err.message)); + + // reject credential validation errors + if (challenge) return reject(new HttpErrors.Unauthorized(challenge)); + + resolve(user); + }; + + const authFn = passport.authenticate(this.strategy.name || this.name, cb); + try { + authFn(request, response); + } catch { + (err: Error) => { + reject(new HttpErrors.Unauthorized(err.message)); + }; + } + }); + } +} diff --git a/labs/passport-adapter/src/strategy-adapter.ts b/labs/passport-adapter/src/strategy-adapter.ts index d2d3b3421105..d08514823523 100644 --- a/labs/passport-adapter/src/strategy-adapter.ts +++ b/labs/passport-adapter/src/strategy-adapter.ts @@ -14,7 +14,7 @@ const passportRequestMixin = require('passport/lib/http/request'); * 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 - * 3. provides state methods to the strategy instance + * 4. provides state methods to the strategy instance * see: https://github.com/jaredhanson/passport */ export class StrategyAdapter implements AuthenticationStrategy {