initial commit
[squeep-resource-authentication-module] / lib / resource-authenticator.js
1 'use strict';
2
3 /**
4 *
5 */
6
7 const crypto = require('crypto');
8 const uuid = require('uuid');
9 const common = require('./common');
10 const Enum = require('./enum');
11 const { ResponseError, ResourceAuthenticatorError } = require('./errors');
12
13 const _fileScope = common.fileScope(__filename);
14
15 /**
16 * @typedef {Object} ResourceRecord
17 * @property {String} secret
18 */
19
20 const defaultOptions = {
21 digestAlgorithm: 'sha256',
22 graceSeconds: 60,
23 saltBytes: 21,
24 };
25
26 class ResourceAuthenticator {
27 /**
28 *
29 * @param {Console} logger
30 * @param {*} db
31 * @param {(dbCtx: any, identifier: String) => Promise<ResourceRecord>} db.resourceGet
32 * @param {((dbCtx: any) => Promise<any>) => Promise<void>} db.context
33 * @param {Object=} options
34 * @param {Object=} options.resourceAuthenticator
35 * @param {String=} options.resourceAuthenticator.digestAlgorithm
36 * @param {Number=} options.resourceAuthenticator.graceSeconds
37 * @param {Number=} options.resourceAuthenticator.saltBytes
38 */
39 constructor(logger, db, options = { resourceAuthenticator: {} }) {
40 this.logger = logger;
41 this.db = db;
42 Object.assign(this, defaultOptions, options.resourceAuthenticator);
43 }
44
45
46 /**
47 * Get the current epoch.
48 */
49 static get currentEpoch() {
50 return Math.ceil(Date.now() / 1000);
51 }
52
53
54 /**
55 * Spice up the payload.
56 * @returns {String}
57 */
58 async getSalt() {
59 const saltBytes = await common.randomBytesAsync(this.saltBytes);
60 return common.base64ToBase64URL(saltBytes.toString('base64'));
61 }
62
63
64 /**
65 * Require a request includes a valid resource bearer token.
66 * Resource bearer tokens are of the form 'identifier:epoch:salt:${hmac(identifier + epoch + salt)}'
67 * with the hmac using a shared secret.
68 * Identifier is an alternate representation of the UUID referencing the resource.
69 *
70 * @param {http.IncomingMessage} req
71 * @param {http.ServerResponse} res
72 * @param {Object} ctx
73 */
74 async required(req, res, ctx) {
75 const _scope = _fileScope('required');
76 this.logger.debug(_scope, 'called', { req, ctx });
77
78 try {
79 const authHeader = req.getHeader(Enum.Header.Authorization);
80 if (!authHeader) {
81 throw new ResourceAuthenticatorError('auth header missing')
82 }
83
84 const [ authMethod, credentials ] = common.splitFirst(authHeader, ' ', '');
85 if (authMethod.toLowerCase() !== 'bearer' || !credentials) {
86 throw new ResourceAuthenticatorError('auth header not bearer');
87 }
88
89 const tokenParts = credentials.split(':');
90 const [ tIdentifier, tEpoch, tSalt, tHMAC ] = tokenParts;
91 if (tokenParts.length !== 4 || !tIdentifier || !tEpoch || !tSalt || !tHMAC) {
92 throw new ResourceAuthenticatorError('unparsable bearer token');
93 }
94
95 const identifier = ResourceAuthenticator.embiggenIdentifier(tIdentifier);
96
97 let resource;
98 await this.db.context(async (dbCtx) => {
99 resource = await this.db.resourceGet(dbCtx, identifier);
100 });
101 if (!resource) {
102 throw new ResourceAuthenticatorError('invalid resource');
103 }
104
105 const resourceEpoch = parseInt(tEpoch, 10);
106 const currentEpoch = ResourceAuthenticator.currentEpoch;
107 const epochDrift = Math.abs(currentEpoch - resourceEpoch);
108 if (epochDrift > this.graceSeconds) {
109 throw new ResourceAuthenticatorError('timestamp out of grace period');
110 }
111
112 const hmac = this.createDigest(resource.secret, tIdentifier, tEpoch, tSalt);
113 if (hmac !== tHMAC) {
114 throw new ResourceAuthenticatorError('mismatched HMAC');
115 }
116
117 ctx.resourceIdentifier = identifier;
118 this.logger.debug(_scope, 'success', { ctx });
119
120 } catch (e) {
121 if (e instanceof ResourceAuthenticatorError) {
122 this.logger.debug(_scope, 'invalid', { error: e, ctx });
123 return ResourceAuthenticator.requestBearer(res);
124 }
125 this.logger.error(_scope, 'failed', { error: e, ctx });
126 throw e;
127 }
128 }
129
130
131 /**
132 * Return the authentication header for an identifier.
133 * @param {String} identifier UUID
134 */
135 async authenticate(identifier, secret) {
136 const currentEpoch = ResourceAuthenticator.currentEpoch.toString();
137 const smallIdentifier = ResourceAuthenticator.ensmallenIdentifier(identifier);
138 const salt = await this.getSalt();
139 const parts = [
140 smallIdentifier,
141 currentEpoch,
142 salt,
143 this.createDigest(secret, smallIdentifier, currentEpoch, salt),
144 ];
145 return parts.join(':');
146 }
147
148
149 /**
150 * Compact uuid identifier, as 24 url-safe characters.
151 * @param {String} identifier UUID
152 * @returns {String} b64u-encoded UUID
153 */
154 static ensmallenIdentifier(identifier) {
155 const uuidBase64 = Buffer.from(uuid.parse(identifier)).toString('base64');
156 return common.base64ToBase64URL(uuidBase64);
157 }
158
159
160 /**
161 * Expand compacted uuid identifier.
162 * @param {String} identifier b64u-encoded UUID
163 * @returns {String} UUID
164 */
165 static embiggenIdentifier(small) {
166 const uuidBase64 = common.base64URLToBase64(small);
167 const uuidBuffer = Buffer.from(uuidBase64, 'base64');
168 return uuid.stringify(uuidBuffer);
169 }
170
171
172 /**
173 * Smoosh some contents through the chosen HMAC.
174 * @param {Buffer} secret
175 * @param {...any} contents
176 * @returns {String}
177 */
178 createDigest(secret, ...contents) {
179 const hmac = crypto.createHmac(this.digestAlgorithm, secret);
180 contents.forEach((content) => hmac.update(Buffer.from(content)));
181 const digestBase64 = hmac.digest('base64');
182 return common.base64ToBase64URL(digestBase64);
183 }
184
185
186 /**
187 * Abort and request bearer authentication.
188 * @param {http.ServerResponse} res
189 * @param {Object} response defaults to Unauthorized
190 */
191 static requestBearer(res, response = Enum.ErrorResponse.Unauthorized) {
192 res.setHeader(Enum.Header.WWWAuthenticate, 'Bearer');
193 throw new ResponseError(response);
194 }
195
196 }
197
198 module.exports = {
199 ResourceAuthenticator,
200 ResourceAuthenticatorError,
201 };