Skip to content

Commit 1e2ed47

Browse files
authored
Merge pull request #4 from afteracademy/authorization
Authorization Bearer Scheme
2 parents ed3e883 + 8168dbf commit 1e2ed47

File tree

18 files changed

+174
-185
lines changed

18 files changed

+174
-185
lines changed

.env.example

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,4 @@ DB_ADMIN_PWD=changeit
3939
ACCESS_TOKEN_VALIDITY_DAYS=30
4040
REFRESH_TOKEN_VALIDITY_DAYS=120
4141
TOKEN_ISSUER=afteracademy.com
42-
TOKEN_AUDIENCE=afteracademy_users
42+
TOKEN_AUDIENCE=afteracademy.com

README.md

+1-2
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,7 @@ Following are the features of this project:
258258
Host: localhost:3000
259259
x-api-key: GCMUDiuY5a7WvyUNt9n3QztToSHzK7Uj
260260
Content-Type: application/json
261-
x-access-token: your_token_received_from_signup_or_login
262-
x-user-id: your_user_id
261+
Authorization: Bearer <your_token_received_from_signup_or_login>
263262
```
264263
* Response Body: 200
265264
```json

src/auth/authUtils.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@ import { Types } from 'mongoose';
55
import User from '../database/model/User';
66
import { tokenInfo } from '../config';
77

8-
export const validateTokenData = async (payload: JwtPayload, userId: Types.ObjectId): Promise<JwtPayload> => {
8+
export const getAccessToken = (authorization: string) => {
9+
if (!authorization) throw new AuthFailureError('Invalid Authorization');
10+
if (!authorization.startsWith('Bearer ')) throw new AuthFailureError('Invalid Authorization');
11+
return authorization.split(' ')[1];
12+
};
13+
14+
export const validateTokenData = (payload: JwtPayload): boolean => {
915
if (!payload || !payload.iss || !payload.sub || !payload.aud || !payload.prm
1016
|| payload.iss !== tokenInfo.issuer
1117
|| payload.aud !== tokenInfo.audience
12-
|| payload.sub !== userId.toHexString())
18+
|| !Types.ObjectId.isValid(payload.sub))
1319
throw new AuthFailureError('Invalid Access Token');
14-
return payload;
20+
return true;
1521
};
1622

1723
export const createTokens = async (user: User, accessTokenKey: string, refreshTokenKey: string)

src/auth/authentication.ts

+10-16
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ import express from 'express';
22
import { ProtectedRequest, Tokens } from 'app-request';
33
import UserRepo from '../database/repository/UserRepo';
44
import { AuthFailureError, AccessTokenError, TokenExpiredError } from '../core/ApiError';
5-
import JWT, { ValidationParams } from '../core/JWT';
5+
import JWT from '../core/JWT';
66
import KeystoreRepo from '../database/repository/KeystoreRepo';
77
import { Types } from 'mongoose';
8-
import { validateTokenData } from './authUtils';
9-
import { tokenInfo } from '../config';
8+
import { getAccessToken, validateTokenData } from './authUtils';
109
import validator, { ValidationSource } from '../helpers/validator';
1110
import schema from './schema';
1211
import asyncHandler from '../helpers/asyncHandler';
@@ -15,23 +14,18 @@ const router = express.Router();
1514

1615
export default router.use(validator(schema.auth, ValidationSource.HEADER),
1716
asyncHandler(async (req: ProtectedRequest, res, next) => {
18-
req.accessToken = req.headers['x-access-token'].toString();
19-
20-
const user = await UserRepo.findById(new Types.ObjectId(req.headers['x-user-id'].toString()));
21-
if (!user) throw new AuthFailureError('User not registered');
22-
req.user = user;
17+
req.accessToken = getAccessToken(req.headers.authorization); // Express headers are auto converted to lowercase
2318

2419
try {
25-
const payload = await JWT.validate(
26-
req.accessToken,
27-
new ValidationParams(tokenInfo.issuer, tokenInfo.audience, user._id.toHexString()));
28-
29-
const jwtPayload = await validateTokenData(payload, req.user._id);
30-
const keystore = await KeystoreRepo.findforKey(req.user._id, payload.prm);
20+
const payload = await JWT.validate(req.accessToken);
21+
validateTokenData(payload);
3122

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

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

3731
return next();

src/auth/schema.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import Joi from '@hapi/joi';
2-
import { JoiObjectId } from '../helpers/validator';
2+
import { JoiAuthBearer } from '../helpers/validator';
33

44
export default {
55
apiKey: Joi.object().keys({
66
'x-api-key': Joi.string().required()
77
}).unknown(true),
88
auth: Joi.object().keys({
9-
'x-access-token': Joi.string().required(),
10-
'x-user-id': JoiObjectId().required(),
9+
'authorization': JoiAuthBearer().required(),
1110
}).unknown(true)
1211
};

src/core/JWT.ts

+7-29
Original file line numberDiff line numberDiff line change
@@ -34,56 +34,34 @@ export default class JWT {
3434
/**
3535
* This method checks the token and returns the decoded data when token is valid in all respect
3636
*/
37-
public static async validate(token: string, validations: ValidationParams): Promise<JwtPayload> {
37+
public static async validate(token: string): Promise<JwtPayload> {
3838
const cert = await this.readPublicKey();
3939
try {
4040
// @ts-ignore
41-
return <JwtPayload>await promisify(verify)(token, cert, validations);
41+
return <JwtPayload>await promisify(verify)(token, cert);
4242
} catch (e) {
4343
Logger.debug(e);
4444
if (e && e.name === 'TokenExpiredError') throw new TokenExpiredError();
45+
// throws error if the token has not been encrypted by the private key
4546
throw new BadTokenError();
4647
}
4748
}
4849

4950
/**
50-
* This method checks the token and returns the decoded data even when the token is expired
51+
* Returns the decoded payload if the signature is valid even if it is expired
5152
*/
52-
public static async decode(token: string, validations: ValidationParams): Promise<JwtPayload> {
53+
public static async decode(token: string): Promise<JwtPayload> {
5354
const cert = await this.readPublicKey();
5455
try {
55-
// token is verified if it was encrypted by the private key
56-
// and if is still not expired then get the payload
5756
// @ts-ignore
58-
return <JwtPayload>await promisify(verify)(token, cert, validations);
57+
return <JwtPayload>await promisify(verify)(token, cert, { ignoreExpiration: true });
5958
} catch (e) {
6059
Logger.debug(e);
61-
if (e && e.name === 'TokenExpiredError') {
62-
// if the token has expired but was encryped by the private key
63-
// then decode it to get the payload
64-
// @ts-ignore
65-
return <JwtPayload>decode(token);
66-
}
67-
else {
68-
// throws error if the token has not been encrypted by the private key
69-
// or has not been issued for the user
70-
throw new BadTokenError();
71-
}
60+
throw new BadTokenError();
7261
}
7362
}
7463
}
7564

76-
export class ValidationParams {
77-
issuer: string;
78-
audience: string;
79-
subject: string;
80-
constructor(issuer: string, audience: string, subject: string) {
81-
this.issuer = issuer;
82-
this.audience = audience;
83-
this.subject = subject;
84-
}
85-
}
86-
8765
export class JwtPayload {
8866
aud: string;
8967
sub: string;

src/helpers/validator.ts

+5
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export const JoiUrlEndpoint = () => Joi.string().custom((value: string, helpers)
2121
return value;
2222
}, 'Url Endpoint Validation');
2323

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

2530
export default (schema: Joi.ObjectSchema, source: ValidationSource = ValidationSource.BODY) =>
2631
(req: Request, res: Response, next: NextFunction) => {

src/routes/v1/access/schema.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Joi from '@hapi/joi';
2-
import { JoiObjectId } from '../../../helpers/validator';
2+
import { JoiAuthBearer } from '../../../helpers/validator';
33

44
export default {
55
userCredential: Joi.object().keys({
@@ -10,8 +10,7 @@ export default {
1010
refreshToken: Joi.string().required().min(1),
1111
}),
1212
auth: Joi.object().keys({
13-
'x-access-token': Joi.string().required().min(1),
14-
'x-user-id': JoiObjectId().required(),
13+
'authorization': JoiAuthBearer().required()
1514
}).unknown(true),
1615
signup: Joi.object().keys({
1716
name: Joi.string().required().min(3),

src/routes/v1/access/token.ts

+11-21
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,33 @@ import { ProtectedRequest } from 'app-request';
44
import { Types } from 'mongoose';
55
import UserRepo from '../../../database/repository/UserRepo';
66
import { AuthFailureError, } from '../../../core/ApiError';
7-
import JWT, { ValidationParams } from '../../../core/JWT';
7+
import JWT from '../../../core/JWT';
88
import KeystoreRepo from '../../../database/repository/KeystoreRepo';
99
import crypto from 'crypto';
10-
import { validateTokenData, createTokens } from '../../../auth/authUtils';
10+
import { validateTokenData, createTokens, getAccessToken } from '../../../auth/authUtils';
1111
import validator, { ValidationSource } from '../../../helpers/validator';
1212
import schema from './schema';
1313
import asyncHandler from '../../../helpers/asyncHandler';
14-
import { tokenInfo } from '../../../config';
1514

1615
const router = express.Router();
1716

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

23-
const user = await UserRepo.findById(new Types.ObjectId(req.headers['x-user-id'].toString()));
22+
const accessTokenPayload = await JWT.decode(req.accessToken);
23+
validateTokenData(accessTokenPayload);
24+
25+
const user = await UserRepo.findById(new Types.ObjectId(accessTokenPayload.sub));
2426
if (!user) throw new AuthFailureError('User not registered');
2527
req.user = user;
2628

27-
const accessTokenPayload = await validateTokenData(
28-
await JWT.decode(req.accessToken,
29-
new ValidationParams(
30-
tokenInfo.issuer,
31-
tokenInfo.audience,
32-
req.user._id.toHexString())),
33-
req.user._id
34-
);
29+
const refreshTokenPayload = await JWT.validate(req.body.refreshToken);
30+
validateTokenData(refreshTokenPayload);
3531

36-
const refreshTokenPayload = await validateTokenData(
37-
await JWT.validate(req.body.refreshToken,
38-
new ValidationParams(
39-
tokenInfo.issuer,
40-
tokenInfo.audience,
41-
req.user._id.toHexString())),
42-
req.user._id
43-
);
32+
if (accessTokenPayload.sub !== refreshTokenPayload.sub)
33+
throw new AuthFailureError('Invalid access token');
4434

4535
const keystore = await KeystoreRepo.find(
4636
req.user._id,

tests/.env.test.example

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ DB_USER_PWD=changeit
3232
ACCESS_TOKEN_VALIDITY_DAYS=30
3333
REFRESH_TOKEN_VALIDITY_DAYS=120
3434
TOKEN_ISSUER=test.afteracademy.com
35-
TOKEN_AUDIENCE=test.afteracademy_users
35+
TOKEN_AUDIENCE=test.afteracademy.com

tests/auth/apikey/unit.test.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ describe('apikey validation', () => {
1111
mockFindApiKey.mockClear();
1212
});
1313

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

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

30-
it('Should response with 404 if correct api-key header is passed and when route is not handelled', async () => {
30+
it('Should response with 404 if correct x-api-key header is passed and when route is not handelled', async () => {
3131
const response = await request
3232
.get(endpoint)
3333
.set('x-api-key', API_KEY);

tests/auth/authUtils/unit.test.ts

+10-16
Original file line numberDiff line numberDiff line change
@@ -12,59 +12,53 @@ describe('authUtils validateTokenData tests', () => {
1212
jest.resetAllMocks();
1313
});
1414

15-
it('Should throw error when user is different', async () => {
16-
17-
const userId = new Types.ObjectId(); // Random Key
15+
it('Should throw error when subject in not user id format', async () => {
1816

1917
const payload = new JwtPayload(
2018
tokenInfo.issuer,
2119
tokenInfo.audience,
22-
new Types.ObjectId().toHexString(), // Random Key
20+
'abc',
2321
ACCESS_TOKEN_KEY,
2422
tokenInfo.accessTokenValidityDays
2523
);
2624

2725
try {
28-
await validateTokenData(payload, userId);
26+
validateTokenData(payload);
2927
} catch (e) {
3028
expect(e).toBeInstanceOf(AuthFailureError);
3129
}
3230
});
3331

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

36-
const userId = new Types.ObjectId(); // Random Key
37-
3834
const payload = new JwtPayload(
3935
tokenInfo.issuer,
4036
tokenInfo.audience,
41-
userId.toHexString(),
37+
new Types.ObjectId().toHexString(),
4238
'123',
4339
tokenInfo.accessTokenValidityDays
4440
);
4541

4642
try {
47-
await validateTokenData(payload, userId);
43+
validateTokenData(payload);
4844
} catch (e) {
4945
expect(e).toBeInstanceOf(AuthFailureError);
5046
}
5147
});
5248

53-
it('Should return same payload if all data is correct', async () => {
54-
55-
const userId = new Types.ObjectId('553f8a4286f5c759f36f8e5b'); // Random Key
49+
it('Should return true if all data is correct', async () => {
5650

5751
const payload = new JwtPayload(
5852
tokenInfo.issuer,
5953
tokenInfo.audience,
60-
userId.toHexString(),
54+
new Types.ObjectId().toHexString(), // Random Key
6155
ACCESS_TOKEN_KEY,
6256
tokenInfo.accessTokenValidityDays
6357
);
6458

65-
const validatedPayload = await validateTokenData(payload, userId);
59+
const validatedPayload = validateTokenData(payload);
6660

67-
expect(validatedPayload).toMatchObject(payload);
61+
expect(validatedPayload).toBeTruthy();
6862
});
6963
});
7064

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

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

80-
const userId = new Types.ObjectId('553f8a4286f5c759f36f8e5b'); // Random Key
74+
const userId = new Types.ObjectId(); // Random Key
8175

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

0 commit comments

Comments
 (0)