Skip to content

Commit

Permalink
feat(authentication-jwt): implementation of refresh token
Browse files Browse the repository at this point in the history
feature refresh token implemented through interceptors
  • Loading branch information
madaky committed May 28, 2020
1 parent 28681dd commit d65d227
Show file tree
Hide file tree
Showing 17 changed files with 465 additions and 10 deletions.
3 changes: 2 additions & 1 deletion extensions/authentication-jwt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"@loopback/service-proxy": "^2.3.0",
"@types/bcryptjs": "2.4.2",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^8.5.1"
"jsonwebtoken": "^8.5.1",
"@loopback/context": "^3.8.1"
},
"devDependencies": {
"@loopback/boot": "^2.3.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ import {UserServiceBindings} from '../..';
import {OPERATION_SECURITY_SPEC, SECURITY_SCHEME_SPEC} from '../../';
import {UserRepository} from '../../repositories';
import {TestApplication} from '../fixtures/application';
import {RefreshTokenBindings} from '../../keys';

describe('jwt authentication', () => {
let app: TestApplication;
let client: Client;
let token: string;
let userRepo: UserRepository;

let refreshToken: string;
let tokenAuth: string;
before(givenRunningApplication);
before(() => {
client = createRestAppClient(app);
Expand Down Expand Up @@ -50,6 +52,29 @@ describe('jwt authentication', () => {
expect(spec.components?.securitySchemes).to.eql(SECURITY_SCHEME_SPEC);
});

it(`user login and token granted successfully`, async () => {
const credentials = {email: '[email protected]', password: 'opensesame'};
const res = await client
.post('/users/refresh/login')
.send(credentials)
.expect(200);
refreshToken = res.body.refreshToken;
});

it(`user sends refresh token and new access token issued`, async () => {
const tokenArg = {refreshToken: refreshToken};
const res = await client.post('/refresh/').send(tokenArg).expect(200);
tokenAuth = res.body.accessToken;
});

it('whoAmI returns the login user id using token generated from refresh', async () => {
const res = await client
.get('/whoAmI')
.set('Authorization', 'Bearer ' + tokenAuth)
.expect(200);
expect(res.text).to.equal('f48b7167-8d95-451c-bbfc-8a12cd49e763');
});

/*
============================================================================
TEST HELPERS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {RestApplication} from '@loopback/rest';
import {RestExplorerComponent} from '@loopback/rest-explorer';
import {ServiceMixin} from '@loopback/service-proxy';
import path from 'path';
import {JWTAuthenticationComponent, UserServiceBindings} from '../../';
import {
JWTAuthenticationComponent,
UserServiceBindings,
RefreshTokenBindings,
} from '../../';
import {DbDataSource} from './datasources/db.datasource';
import {MySequence} from './sequence';

Expand All @@ -34,6 +38,8 @@ export class TestApplication extends BootMixin(
this.component(JWTAuthenticationComponent);
// Bind datasource
this.dataSource(DbDataSource, UserServiceBindings.DATASOURCE_NAME);
//Bind datasource for refreshtoken table
this.dataSource(DbDataSource, RefreshTokenBindings.DATASOURCE_NAME);

this.component(RestExplorerComponent);
this.projectRoot = __dirname;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@ import {
TokenService,
UserService,
} from '@loopback/authentication';
import {inject} from '@loopback/core';
import {inject, intercept} from '@loopback/core';
import {get, post, requestBody} from '@loopback/rest';
import {SecurityBindings, securityId, UserProfile} from '@loopback/security';
import {TokenServiceBindings, User, UserServiceBindings} from '../../../';
import {
TokenServiceBindings,
User,
UserServiceBindings,
TokenObject,
RefreshGrantRequestBody,
RefreshGrant,
} from '../../../';
import {Credentials} from '../../../services/user.service';

const CredentialsSchema = {
Expand Down Expand Up @@ -95,4 +102,70 @@ export class UserController {
async whoAmI(): Promise<string> {
return this.user[securityId];
}
// Routes using refreshtoken
@post('/users/refresh/login', {
responses: {
'200': {
description: 'Token',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
accessToken: {
type: 'string',
},
refreshToken: {
type: 'string',
},
},
},
},
},
},
},
})
@intercept('refresh-token-generate')
async refreshLogin(
@requestBody(CredentialsRequestBody) credentials: Credentials,
): Promise<TokenObject> {
// ensure the user exists, and the password is correct
const user = await this.userService.verifyCredentials(credentials);
// convert a User object into a UserProfile object (reduced set of properties)
const userProfile: UserProfile = this.userService.convertToUserProfile(
user,
);
// create a JSON Web Token based on the user profile
const token = {
accessToken: await this.jwtService.generateToken(userProfile),
};
return token;
}

@post('/refresh', {
responses: {
'200': {
description: 'Token',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
refreshToken: {
type: 'string',
},
},
},
},
},
},
},
})
@intercept('refresh-token-grant')
async refresh(
@requestBody(RefreshGrantRequestBody) refreshGrant: RefreshGrant,
): Promise<{token: string}> {
const token = '';
return {token};
}
}
12 changes: 10 additions & 2 deletions extensions/authentication-jwt/src/__tests__/unit/jwt.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// License text available at https://opensource.org/licenses/MIT

import {HttpErrors} from '@loopback/rest';
import {securityId} from '@loopback/security';
import {securityId, UserProfile} from '@loopback/security';
import {expect} from '@loopback/testlab';
import {JWTService} from '../../';

Expand All @@ -19,9 +19,17 @@ describe('token service', () => {
id: '1',
name: 'test',
};
type Setter<T> = (value?: T) => void;
const TOKEN_SECRET_VALUE = 'myjwts3cr3t';
const TOKEN_EXPIRES_IN_VALUE = '60';
const jwtService = new JWTService(TOKEN_SECRET_VALUE, TOKEN_EXPIRES_IN_VALUE);
const setCurrentUser: Setter<UserProfile> = userProfile => {
return userProfile;
};
const jwtService = new JWTService(
TOKEN_SECRET_VALUE,
TOKEN_EXPIRES_IN_VALUE,
setCurrentUser,
);

it('token service generateToken() succeeds', async () => {
const token = await jwtService.generateToken(USER_PROFILE);
Expand Down
3 changes: 3 additions & 0 deletions extensions/authentication-jwt/src/interceptors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './refresh-token-generate.interceptor';

export * from './refresh-token-grant.interceptor';
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
Getter,
inject,
Interceptor,
InvocationContext,
InvocationResult,
Provider,
uuid,
ValueOrPromise,
} from '@loopback/context';
import {repository} from '@loopback/repository';
import {SecurityBindings, UserProfile} from '@loopback/security';
import {promisify} from 'util';
import {RefreshTokenRepository} from '../repositories';
import {RefreshTokenInterceptorBindings} from '../keys';
import {HttpErrors} from '@loopback/rest';

/**
* This class will be bound to the application as an `Interceptor` during
* `boot`
*/
const jwt = require('jsonwebtoken');
const signAsync = promisify(jwt.sign);
export class RefreshTokenGenerateInterceptor implements Provider<Interceptor> {
constructor(
@inject(RefreshTokenInterceptorBindings.REFRESH_SECRET)
private refreshSecret: string,
@inject(RefreshTokenInterceptorBindings.REFRESH_EXPIRES_IN)
private refreshExpiresIn: string,
@inject(RefreshTokenInterceptorBindings.REFRESH_ISSURE)
private refreshIssure: string,
@repository(RefreshTokenRepository)
public refreshTokenRepository: RefreshTokenRepository,
@inject.getter(SecurityBindings.USER, {optional: true})
private getCurrentUser: Getter<UserProfile>,
) {}

/**
* This method is used by LoopBack context to produce an interceptor function
* for the binding.
*
* @returns An interceptor function
*/
value() {
return this.intercept.bind(this);
}

/**
* The logic to intercept an invocation
* @param invocationCtx - Invocation context
* @param next - A function to invoke next interceptor or the target method
*/
async intercept(
invocationCtx: InvocationContext,
next: () => ValueOrPromise<InvocationResult>,
) {
try {
// Add pre-invocation logic here
let result = await next();
// Add post-invocation logic here
const currentUser = await this.getCurrentUser();
const data = {
token: uuid(),
};
const refreshToken = await signAsync(data, this.refreshSecret, {
expiresIn: Number(this.refreshExpiresIn),
issuer: this.refreshIssure,
});
result = Object.assign(result, {
refreshToken: refreshToken,
});
await this.refreshTokenRepository.create({
userId: currentUser.id,
refreshToken: result.refreshToken,
});
return result;
} catch (error) {
// Add error handling logic here
throw new HttpErrors.Unauthorized(
`Error verifying token : ${error.message}`,
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {TokenService} from '@loopback/authentication';
import {
inject,
Interceptor,
InvocationContext,
InvocationResult,
Provider,
ValueOrPromise,
} from '@loopback/context';
import {repository} from '@loopback/repository/';
import {HttpErrors} from '@loopback/rest';
import {UserProfile} from '@loopback/security';
import {promisify} from 'util';
import {
RefreshTokenInterceptorBindings,
UserServiceBindings,
TokenServiceBindings,
} from '../keys';
import {MyUserService} from '../services';
import {RefreshTokenRepository} from '../repositories';

const jwt = require('jsonwebtoken');
const verifyAsync = promisify(jwt.verify);
/**
* This class will be bound to the application as an `Interceptor` during
* `boot`
*/
export class RefreshTokenGrantInterceptor implements Provider<Interceptor> {
constructor(
@inject(RefreshTokenInterceptorBindings.REFRESH_SECRET)
private refreshSecret: string,
@inject(UserServiceBindings.USER_SERVICE) public userService: MyUserService,
@repository(RefreshTokenRepository)
public refreshTokenRepository: RefreshTokenRepository,
@inject(TokenServiceBindings.TOKEN_SERVICE) public jwtService: TokenService,
) {}
/**
* This method is used by LoopBack context to produce an interceptor function
* for the binding.
*
* @returns An interceptor function
*/
value() {
return this.intercept.bind(this);
}

/**
* The logic to intercept an invocation
* @param invocationCtx - Invocation context
* @param next - A function to invoke next interceptor or the target method
*/
async intercept(
invocationCtx: InvocationContext,
next: () => ValueOrPromise<InvocationResult>,
) {
try {
// Add pre-invocation logic here
let result = await next();
// Add post-invocation logic here
const refreshToken = invocationCtx.args[0].refreshToken;

if (!refreshToken) {
throw new HttpErrors.Unauthorized(
`Error verifying token : 'refresh token' is null`,
);
}

await verifyAsync(refreshToken, this.refreshSecret);
const userRefreshData = await this.refreshTokenRepository.findOne({
where: {refreshToken: refreshToken},
});

if (!userRefreshData) {
throw new HttpErrors.Unauthorized(
`Error verifying token : Invalid Token`,
);
}
const user = await this.userService.findUserById(
userRefreshData.userId.toString(),
);
const userProfile: UserProfile = this.userService.convertToUserProfile(
user,
);
// create a JSON Web Token based on the user profile
const token = await this.jwtService.generateToken(userProfile);
result = {
accessToken: token,
};
return result;
} catch (error) {
// Add error handling logic here
throw new HttpErrors.Unauthorized(
`Error verifying token : ${error.message}`,
);
}
}
}
Loading

0 comments on commit d65d227

Please sign in to comment.