Skip to content

Commit

Permalink
feat: add extension point for passport
Browse files Browse the repository at this point in the history
  • Loading branch information
jannyHou committed May 7, 2019
1 parent 519e0d1 commit dbc51ce
Show file tree
Hide file tree
Showing 8 changed files with 167 additions and 88 deletions.
Expand Up @@ -3,8 +3,8 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {inject, Provider, ValueOrPromise} from '@loopback/context';
import {Application} from '@loopback/core';
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 {
Expand All @@ -20,28 +20,26 @@ import {
SequenceHandler,
} from '@loopback/rest';
import {Client, createClientForHandler} from '@loopback/testlab';
import {Strategy} from 'passport';
import {BasicStrategy} from 'passport-http';
import {BasicStrategy, BasicVerifyFunction} from 'passport-http';
import {
authenticate,
AuthenticateFn,
AuthenticationBindings,
AuthenticationComponent,
AuthenticationMetadata,
UserProfile,
} from '../..';

import {StrategyAdapter} from '../../strategy-adapter';
import {AuthenticationStrategy} from '../../types';
const SequenceActions = RestBindings.SequenceActions;

describe.skip('Basic Authentication', () => {
describe('Basic Authentication', () => {
let app: Application;
let server: RestServer;
let users: UserRepository;
beforeEach(givenAServer);
beforeEach(givenUserRepository);
beforeEach(givenControllerInApp);
beforeEach(givenAuthenticatedSequence);
beforeEach(givenProviders);

it('authenticates successfully for correct credentials', async () => {
const client = whenIMakeRequestTo(server);
Expand Down Expand Up @@ -87,10 +85,45 @@ describe.skip('Basic Authentication', () => {
});
}

// 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 wrap it as a strategy provider,
// then add the provider class as the extension.
// See Line 89 in the function `givenAServer`
class PassportBasicAuthProvider implements Provider<AuthenticationStrategy> {
value(): AuthenticationStrategy {
const basicStrategy = this.configuratedBasicStrategy(verify);
return this.convertToAuthStrategy(basicStrategy);
}

configuratedBasicStrategy(verifyFn: BasicVerifyFunction): BasicStrategy {
return new BasicStrategy(verifyFn);
}

convertToAuthStrategy(basic: BasicStrategy): AuthenticationStrategy {
return new StrategyAdapter(basic, 'basic');
}
}

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);
}

Expand All @@ -115,7 +148,7 @@ describe.skip('Basic Authentication', () => {
@inject(AuthenticationBindings.CURRENT_USER) private user: UserProfile,
) {}

@authenticate('BasicStrategy')
@authenticate('basic')
async whoAmI(): Promise<string> {
return this.user.id;
}
Expand Down Expand Up @@ -158,35 +191,6 @@ describe.skip('Basic Authentication', () => {
server.sequence(MySequence);
}

function givenProviders() {
class MyPassportStrategyProvider implements Provider<Strategy | undefined> {
constructor(
@inject(AuthenticationBindings.METADATA)
private metadata: AuthenticationMetadata,
) {}
value(): ValueOrPromise<Strategy | undefined> {
if (!this.metadata) {
return undefined;
}
const name = this.metadata.strategy;
if (name === 'BasicStrategy') {
return new BasicStrategy(this.verify);
} else {
return Promise.reject(`The strategy ${name} is not available.`);
}
}
// callback method for BasicStrategy
verify(username: string, password: string, cb: Function) {
process.nextTick(() => {
users.find(username, password, cb);
});
}
}
server
.bind(AuthenticationBindings.STRATEGY)
.toProvider(MyPassportStrategyProvider);
}

function whenIMakeRequestTo(restServer: RestServer): Client {
return createClientForHandler(restServer.requestHandler);
}
Expand Down
@@ -0,0 +1,67 @@
// 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

import {Request} from 'express';
import {AuthenticateOptions, Strategy} from 'passport';
import {UserProfile} from '../../../types';

/**
* Test fixture for a mock asynchronous passport-strategy
*/
export class MockPassportStrategy extends Strategy {
// user to return for successful authentication
private mockUser: UserProfile;

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);
}
}
Expand Up @@ -3,26 +3,35 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Strategy, AuthenticateOptions} from 'passport';
import {Request} from 'express';
import {Request} from '@loopback/rest';
import {AuthenticationStrategy, UserProfile} from '../../../types';

class AuthenticationError extends Error {
statusCode?: number;
}

/**
* Test fixture for a mock asynchronous passport-strategy
*/
export class MockStrategy extends Strategy {
export class MockStrategy implements AuthenticationStrategy {
name: 'MockStrategy';
// user to return for successful authentication
private mockUser: Object;
private mockUser: UserProfile;

setMockUser(userObj: Object) {
setMockUser(userObj: UserProfile) {
this.mockUser = userObj;
}

returnMockUser(): UserProfile {
return this.mockUser;
}

/**
* authenticate() function similar to passport-strategy packages
* @param req
*/
async authenticate(req: Request, options?: AuthenticateOptions) {
await this.verify(req);
async authenticate(req: Request): Promise<UserProfile> {
return await this.verify(req);
}
/**
* @param req
Expand All @@ -39,28 +48,16 @@ export class MockStrategy extends Strategy {
request.headers.testState &&
request.headers.testState === 'fail'
) {
this.returnUnauthorized('authorization failed');
return;
const err = new AuthenticationError('authorization failed');
err.statusCode = 401;
throw err;
} else if (
request.headers &&
request.headers.testState &&
request.headers.testState === 'error'
) {
this.returnError('unexpected error');
return;
throw new Error('unexpected error');
}
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);
return this.returnMockUser();
}
}
Expand Up @@ -6,12 +6,12 @@
import {Context, instantiateClass} from '@loopback/context';
import {Request} from '@loopback/rest';
import {expect} from '@loopback/testlab';
import {Strategy} from 'passport';
import {AuthenticateFn, AuthenticationBindings, UserProfile} from '../../..';
import {AuthenticateActionProvider} from '../../../providers';
import {AuthenticationStrategy} from '../../../types';
import {MockStrategy} from '../fixtures/mock-strategy';

describe.skip('AuthenticateActionProvider', () => {
describe('AuthenticateActionProvider', () => {
describe('constructor()', () => {
it('instantiateClass injects authentication.strategy in the constructor', async () => {
const context = new Context();
Expand Down Expand Up @@ -65,9 +65,12 @@ describe.skip('AuthenticateActionProvider', () => {
expect(user).to.be.equal(mockUser);
});

it('throws an error if the injected passport strategy is not valid', async () => {
// This PoC is in progress, will recover the test asap
it.skip('throws an error if the injected passport strategy is not valid', async () => {
const context: Context = new Context();
context.bind(AuthenticationBindings.STRATEGY).to({} as Strategy);
context
.bind(AuthenticationBindings.STRATEGY)
.to({} as AuthenticationStrategy);
context
.bind(AuthenticationBindings.AUTH_ACTION)
.toProvider(AuthenticateActionProvider);
Expand Down Expand Up @@ -108,10 +111,10 @@ describe.skip('AuthenticateActionProvider', () => {
function givenAuthenticateActionProvider() {
strategy = new MockStrategy();
strategy.setMockUser(mockUser);
// provider = new AuthenticateActionProvider(
// () => Promise.resolve(strategy),
// u => (currentUser = u),
// );
provider = new AuthenticateActionProvider(
() => Promise.resolve(strategy),
u => (currentUser = u),
);
currentUser = undefined;
}
});
Expand Down
28 changes: 16 additions & 12 deletions packages/authentication/src/__tests__/unit/strategy-adapter.unit.ts
Expand Up @@ -3,11 +3,11 @@
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {HttpErrors, Request} from '@loopback/rest';
import {expect} from '@loopback/testlab';
import {StrategyAdapter, UserProfile} from '../..';
import {Request, HttpErrors} from '@loopback/rest';
import {MockStrategy} from './fixtures/mock-strategy';
import {AuthenticateOptions} from 'passport';
import {StrategyAdapter, UserProfile} from '../..';
import {MockPassportStrategy} from './fixtures/mock-strategy-passport';

describe('Strategy Adapter', () => {
const mockUser: UserProfile = {name: 'user-name', id: 'mock-id'};
Expand All @@ -16,33 +16,37 @@ describe('Strategy Adapter', () => {
it('calls the authenticate method of the strategy', async () => {
let calledFlag = false;
// TODO: (as suggested by @bajtos) use sinon spy
class Strategy extends MockStrategy {
class Strategy extends MockPassportStrategy {
// override authenticate method to set calledFlag
async authenticate(req: Request, options?: AuthenticateOptions) {
calledFlag = true;
await MockStrategy.prototype.authenticate.call(this, req, options);
await MockPassportStrategy.prototype.authenticate.call(
this,
req,
options,
);
}
}
const strategy = new Strategy();
const adapter = new StrategyAdapter(strategy);
const adapter = new StrategyAdapter(strategy, 'mock-strategy');
const request = <Request>{};
await adapter.authenticate(request);
expect(calledFlag).to.be.true();
});

it('returns a promise which resolves to an object', async () => {
const strategy = new MockStrategy();
const strategy = new MockPassportStrategy();
strategy.setMockUser(mockUser);
const adapter = new StrategyAdapter(strategy);
const adapter = new StrategyAdapter(strategy, 'mock-strategy');
const request = <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 MockStrategy();
const strategy = new MockPassportStrategy();
strategy.setMockUser(mockUser);
const adapter = new StrategyAdapter(strategy);
const adapter = new StrategyAdapter(strategy, 'mock-strategy');
const request = <Request>{};
request.headers = {testState: 'fail'};
let error;
Expand All @@ -55,9 +59,9 @@ describe('Strategy Adapter', () => {
});

it('throws InternalServerError when strategy returns error', async () => {
const strategy = new MockStrategy();
const strategy = new MockPassportStrategy();
strategy.setMockUser(mockUser);
const adapter = new StrategyAdapter(strategy);
const adapter = new StrategyAdapter(strategy, 'mock-strategy');
const request = <Request>{};
request.headers = {testState: 'error'};
let error;
Expand Down

0 comments on commit dbc51ce

Please sign in to comment.