Skip to content

Commit

Permalink
Merge pull request #4 from afteracademy/authorization
Browse files Browse the repository at this point in the history
Authorization Bearer Scheme
  • Loading branch information
janishar authored Apr 11, 2020
2 parents ed3e883 + 8168dbf commit 1e2ed47
Show file tree
Hide file tree
Showing 18 changed files with 174 additions and 185 deletions.
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ DB_ADMIN_PWD=changeit
ACCESS_TOKEN_VALIDITY_DAYS=30
REFRESH_TOKEN_VALIDITY_DAYS=120
TOKEN_ISSUER=afteracademy.com
TOKEN_AUDIENCE=afteracademy_users
TOKEN_AUDIENCE=afteracademy.com
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,7 @@ Following are the features of this project:
Host: localhost:3000
x-api-key: GCMUDiuY5a7WvyUNt9n3QztToSHzK7Uj
Content-Type: application/json
x-access-token: your_token_received_from_signup_or_login
x-user-id: your_user_id
Authorization: Bearer <your_token_received_from_signup_or_login>
```
* Response Body: 200
```json
Expand Down
12 changes: 9 additions & 3 deletions src/auth/authUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ import { Types } from 'mongoose';
import User from '../database/model/User';
import { tokenInfo } from '../config';

export const validateTokenData = async (payload: JwtPayload, userId: Types.ObjectId): Promise<JwtPayload> => {
export const getAccessToken = (authorization: string) => {
if (!authorization) throw new AuthFailureError('Invalid Authorization');
if (!authorization.startsWith('Bearer ')) throw new AuthFailureError('Invalid Authorization');
return authorization.split(' ')[1];
};

export const validateTokenData = (payload: JwtPayload): boolean => {
if (!payload || !payload.iss || !payload.sub || !payload.aud || !payload.prm
|| payload.iss !== tokenInfo.issuer
|| payload.aud !== tokenInfo.audience
|| payload.sub !== userId.toHexString())
|| !Types.ObjectId.isValid(payload.sub))
throw new AuthFailureError('Invalid Access Token');
return payload;
return true;
};

export const createTokens = async (user: User, accessTokenKey: string, refreshTokenKey: string)
Expand Down
26 changes: 10 additions & 16 deletions src/auth/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import express from 'express';
import { ProtectedRequest, Tokens } from 'app-request';
import UserRepo from '../database/repository/UserRepo';
import { AuthFailureError, AccessTokenError, TokenExpiredError } from '../core/ApiError';
import JWT, { ValidationParams } from '../core/JWT';
import JWT from '../core/JWT';
import KeystoreRepo from '../database/repository/KeystoreRepo';
import { Types } from 'mongoose';
import { validateTokenData } from './authUtils';
import { tokenInfo } from '../config';
import { getAccessToken, validateTokenData } from './authUtils';
import validator, { ValidationSource } from '../helpers/validator';
import schema from './schema';
import asyncHandler from '../helpers/asyncHandler';
Expand All @@ -15,23 +14,18 @@ const router = express.Router();

export default router.use(validator(schema.auth, ValidationSource.HEADER),
asyncHandler(async (req: ProtectedRequest, res, next) => {
req.accessToken = req.headers['x-access-token'].toString();

const user = await UserRepo.findById(new Types.ObjectId(req.headers['x-user-id'].toString()));
if (!user) throw new AuthFailureError('User not registered');
req.user = user;
req.accessToken = getAccessToken(req.headers.authorization); // Express headers are auto converted to lowercase

try {
const payload = await JWT.validate(
req.accessToken,
new ValidationParams(tokenInfo.issuer, tokenInfo.audience, user._id.toHexString()));

const jwtPayload = await validateTokenData(payload, req.user._id);
const keystore = await KeystoreRepo.findforKey(req.user._id, payload.prm);
const payload = await JWT.validate(req.accessToken);
validateTokenData(payload);

if (!keystore || keystore.primaryKey !== jwtPayload.prm)
throw new AuthFailureError('Invalid access token');
const user = await UserRepo.findById(new Types.ObjectId(payload.sub));
if (!user) throw new AuthFailureError('User not registered');
req.user = user;

const keystore = await KeystoreRepo.findforKey(req.user._id, payload.prm);
if (!keystore) throw new AuthFailureError('Invalid access token');
req.keystore = keystore;

return next();
Expand Down
5 changes: 2 additions & 3 deletions src/auth/schema.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import Joi from '@hapi/joi';
import { JoiObjectId } from '../helpers/validator';
import { JoiAuthBearer } from '../helpers/validator';

export default {
apiKey: Joi.object().keys({
'x-api-key': Joi.string().required()
}).unknown(true),
auth: Joi.object().keys({
'x-access-token': Joi.string().required(),
'x-user-id': JoiObjectId().required(),
'authorization': JoiAuthBearer().required(),
}).unknown(true)
};
36 changes: 7 additions & 29 deletions src/core/JWT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,56 +34,34 @@ export default class JWT {
/**
* This method checks the token and returns the decoded data when token is valid in all respect
*/
public static async validate(token: string, validations: ValidationParams): Promise<JwtPayload> {
public static async validate(token: string): Promise<JwtPayload> {
const cert = await this.readPublicKey();
try {
// @ts-ignore
return <JwtPayload>await promisify(verify)(token, cert, validations);
return <JwtPayload>await promisify(verify)(token, cert);
} catch (e) {
Logger.debug(e);
if (e && e.name === 'TokenExpiredError') throw new TokenExpiredError();
// throws error if the token has not been encrypted by the private key
throw new BadTokenError();
}
}

/**
* This method checks the token and returns the decoded data even when the token is expired
* Returns the decoded payload if the signature is valid even if it is expired
*/
public static async decode(token: string, validations: ValidationParams): Promise<JwtPayload> {
public static async decode(token: string): Promise<JwtPayload> {
const cert = await this.readPublicKey();
try {
// token is verified if it was encrypted by the private key
// and if is still not expired then get the payload
// @ts-ignore
return <JwtPayload>await promisify(verify)(token, cert, validations);
return <JwtPayload>await promisify(verify)(token, cert, { ignoreExpiration: true });
} catch (e) {
Logger.debug(e);
if (e && e.name === 'TokenExpiredError') {
// if the token has expired but was encryped by the private key
// then decode it to get the payload
// @ts-ignore
return <JwtPayload>decode(token);
}
else {
// throws error if the token has not been encrypted by the private key
// or has not been issued for the user
throw new BadTokenError();
}
throw new BadTokenError();
}
}
}

export class ValidationParams {
issuer: string;
audience: string;
subject: string;
constructor(issuer: string, audience: string, subject: string) {
this.issuer = issuer;
this.audience = audience;
this.subject = subject;
}
}

export class JwtPayload {
aud: string;
sub: string;
Expand Down
5 changes: 5 additions & 0 deletions src/helpers/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export const JoiUrlEndpoint = () => Joi.string().custom((value: string, helpers)
return value;
}, 'Url Endpoint Validation');

export const JoiAuthBearer = () => Joi.string().custom((value: string, helpers) => {
if (!value.startsWith('Bearer ')) return helpers.error('any.invalid');
if (!value.split(' ')[1]) return helpers.error('any.invalid');
return value;
}, 'Authorization Header Validation');

export default (schema: Joi.ObjectSchema, source: ValidationSource = ValidationSource.BODY) =>
(req: Request, res: Response, next: NextFunction) => {
Expand Down
5 changes: 2 additions & 3 deletions src/routes/v1/access/schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Joi from '@hapi/joi';
import { JoiObjectId } from '../../../helpers/validator';
import { JoiAuthBearer } from '../../../helpers/validator';

export default {
userCredential: Joi.object().keys({
Expand All @@ -10,8 +10,7 @@ export default {
refreshToken: Joi.string().required().min(1),
}),
auth: Joi.object().keys({
'x-access-token': Joi.string().required().min(1),
'x-user-id': JoiObjectId().required(),
'authorization': JoiAuthBearer().required()
}).unknown(true),
signup: Joi.object().keys({
name: Joi.string().required().min(3),
Expand Down
32 changes: 11 additions & 21 deletions src/routes/v1/access/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,33 @@ import { ProtectedRequest } from 'app-request';
import { Types } from 'mongoose';
import UserRepo from '../../../database/repository/UserRepo';
import { AuthFailureError, } from '../../../core/ApiError';
import JWT, { ValidationParams } from '../../../core/JWT';
import JWT from '../../../core/JWT';
import KeystoreRepo from '../../../database/repository/KeystoreRepo';
import crypto from 'crypto';
import { validateTokenData, createTokens } from '../../../auth/authUtils';
import { validateTokenData, createTokens, getAccessToken } from '../../../auth/authUtils';
import validator, { ValidationSource } from '../../../helpers/validator';
import schema from './schema';
import asyncHandler from '../../../helpers/asyncHandler';
import { tokenInfo } from '../../../config';

const router = express.Router();

router.post('/refresh',
validator(schema.auth, ValidationSource.HEADER), validator(schema.refreshToken),
asyncHandler(async (req: ProtectedRequest, res, next) => {
req.accessToken = req.headers['x-access-token'].toString();
req.accessToken = getAccessToken(req.headers.authorization); // Express headers are auto converted to lowercase

const user = await UserRepo.findById(new Types.ObjectId(req.headers['x-user-id'].toString()));
const accessTokenPayload = await JWT.decode(req.accessToken);
validateTokenData(accessTokenPayload);

const user = await UserRepo.findById(new Types.ObjectId(accessTokenPayload.sub));
if (!user) throw new AuthFailureError('User not registered');
req.user = user;

const accessTokenPayload = await validateTokenData(
await JWT.decode(req.accessToken,
new ValidationParams(
tokenInfo.issuer,
tokenInfo.audience,
req.user._id.toHexString())),
req.user._id
);
const refreshTokenPayload = await JWT.validate(req.body.refreshToken);
validateTokenData(refreshTokenPayload);

const refreshTokenPayload = await validateTokenData(
await JWT.validate(req.body.refreshToken,
new ValidationParams(
tokenInfo.issuer,
tokenInfo.audience,
req.user._id.toHexString())),
req.user._id
);
if (accessTokenPayload.sub !== refreshTokenPayload.sub)
throw new AuthFailureError('Invalid access token');

const keystore = await KeystoreRepo.find(
req.user._id,
Expand Down
2 changes: 1 addition & 1 deletion tests/.env.test.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ DB_USER_PWD=changeit
ACCESS_TOKEN_VALIDITY_DAYS=30
REFRESH_TOKEN_VALIDITY_DAYS=120
TOKEN_ISSUER=test.afteracademy.com
TOKEN_AUDIENCE=test.afteracademy_users
TOKEN_AUDIENCE=test.afteracademy.com
6 changes: 3 additions & 3 deletions tests/auth/apikey/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ describe('apikey validation', () => {
mockFindApiKey.mockClear();
});

it('Should response with 400 if api-key header is not passed', async () => {
it('Should response with 400 if x-api-key header is not passed', async () => {
const response = await request.get(endpoint);
expect(response.status).toBe(400);
expect(mockFindApiKey).not.toBeCalled();
});

it('Should response with 403 if wrong api-key header is passed', async () => {
it('Should response with 403 if wrong x-api-key header is passed', async () => {
const wrongApiKey = '123';
const response = await request
.get(endpoint)
Expand All @@ -27,7 +27,7 @@ describe('apikey validation', () => {
expect(mockFindApiKey).toBeCalledWith(wrongApiKey);
});

it('Should response with 404 if correct api-key header is passed and when route is not handelled', async () => {
it('Should response with 404 if correct x-api-key header is passed and when route is not handelled', async () => {
const response = await request
.get(endpoint)
.set('x-api-key', API_KEY);
Expand Down
26 changes: 10 additions & 16 deletions tests/auth/authUtils/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,59 +12,53 @@ describe('authUtils validateTokenData tests', () => {
jest.resetAllMocks();
});

it('Should throw error when user is different', async () => {

const userId = new Types.ObjectId(); // Random Key
it('Should throw error when subject in not user id format', async () => {

const payload = new JwtPayload(
tokenInfo.issuer,
tokenInfo.audience,
new Types.ObjectId().toHexString(), // Random Key
'abc',
ACCESS_TOKEN_KEY,
tokenInfo.accessTokenValidityDays
);

try {
await validateTokenData(payload, userId);
validateTokenData(payload);
} catch (e) {
expect(e).toBeInstanceOf(AuthFailureError);
}
});

it('Should throw error when access token key is different', async () => {

const userId = new Types.ObjectId(); // Random Key

const payload = new JwtPayload(
tokenInfo.issuer,
tokenInfo.audience,
userId.toHexString(),
new Types.ObjectId().toHexString(),
'123',
tokenInfo.accessTokenValidityDays
);

try {
await validateTokenData(payload, userId);
validateTokenData(payload);
} catch (e) {
expect(e).toBeInstanceOf(AuthFailureError);
}
});

it('Should return same payload if all data is correct', async () => {

const userId = new Types.ObjectId('553f8a4286f5c759f36f8e5b'); // Random Key
it('Should return true if all data is correct', async () => {

const payload = new JwtPayload(
tokenInfo.issuer,
tokenInfo.audience,
userId.toHexString(),
new Types.ObjectId().toHexString(), // Random Key
ACCESS_TOKEN_KEY,
tokenInfo.accessTokenValidityDays
);

const validatedPayload = await validateTokenData(payload, userId);
const validatedPayload = validateTokenData(payload);

expect(validatedPayload).toMatchObject(payload);
expect(validatedPayload).toBeTruthy();
});
});

Expand All @@ -77,7 +71,7 @@ describe('authUtils createTokens function', () => {

it('Should process and return accessToken and refreshToken', async () => {

const userId = new Types.ObjectId('553f8a4286f5c759f36f8e5b'); // Random Key
const userId = new Types.ObjectId(); // Random Key

const tokens = await createTokens(<User>{ _id: userId }, ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY);

Expand Down
Loading

0 comments on commit 1e2ed47

Please sign in to comment.