migrate ResourceAuthenticator into here from separate package
[squeep-authentication-module] / lib / resource-authenticator.js
1 'use strict';
2
3 const { createHmac } = require('node:crypto');
4 const uuid = require('uuid');
5 const common = require('./common');
6 const Enum = require('./enum');
7 const { ResourceAuthenticatorError, ResponseError } = require('./errors');
8
9 const _fileScope = common.fileScope(__filename);
10
11 /**
12 * @typedef {import('node:http')} http
13 */
14
15 /**
16 * @typedef {object} ResourceRecord
17 * @property {string} secret secret
18 */
19
20 /**
21 * @typedef {object} ConsoleLike
22 * @property {Function} debug log debug
23 * @property {Function} error log error
24 * @property {Function} info log info
25 */
26
27 /**
28 * @callback DBContextExec
29 * @param {object} dbCtx db context
30 * @returns {Promise<any>} result
31 */
32
33 /**
34 * @typedef {object} ResourceDBInterface
35 * @property {(DBContextExec) => Promise<any>} context db context runner
36 * @property {(dbCtx: any, identifier: string) => Promise<ResourceRecord>} resourceGet get a resource entry
37 */
38
39 /**
40 * @typedef {object} ResourceAuthenticatorOptions
41 * @property {string=} digestAlgorithm digest algorithm
42 * @property {number=} graceSeconds grace seconds, give or take
43 * @property {number=} saltBytes salt bytes
44 */
45
46 /**
47 * @type {ResourceAuthenticatorOptions}
48 */
49 const defaultResourceAuthenticatorOptions = {
50 digestAlgorithm: 'sha256',
51 graceSeconds: 60,
52 saltBytes: 21,
53 };
54
55 class ResourceAuthenticator {
56 /**
57 *
58 * @param {ConsoleLike} logger logger instance
59 * @param {ResourceDBInterface} db db instance
60 * @param {object} options options
61 * @param {ResourceAuthenticatorOptions} options.resourceAuthenticator resource authenticator options
62 */
63 constructor(logger, db, options = {}) {
64 this.logger = logger;
65 this.db = db;
66 common.setOptions(this, defaultResourceAuthenticatorOptions, options?.resourceAuthenticator || {});
67 }
68
69
70 /**
71 * Get the current epoch.
72 * @returns {number} epoch seconds
73 */
74 static get currentEpoch() {
75 return Math.ceil(Date.now() / 1000);
76 }
77
78
79 /**
80 * Compact uuid identifier as 24 character url-safe string.
81 * @param {string} identifier uuid
82 * @returns {string} base64url encoded uuid
83 */
84 static ensmallenIdentifier(identifier) {
85 return Buffer.from(uuid.parse(identifier)).toString('base64url');
86 }
87
88
89 /**
90 * Expand compacted uuid identifier.
91 * @param {string} compact base64url encoded uuid
92 * @returns {string} uuid
93 */
94 static embiggenIdentifier(compact) {
95 const uuidBuffer = Buffer.from(compact, 'base64url');
96 return uuid.stringify(uuidBuffer);
97 }
98
99
100 /**
101 * Get an encoded salt string.
102 * @param {number} saltBytes salt bytes
103 * @returns {Promise<string>} base64url encoded
104 */
105 async getSalt(saltBytes = this.saltBytes) {
106 const saltBuffer = await common.randomBytesAsync(saltBytes);
107 return saltBuffer.toString('base64url');
108 }
109
110
111 /**
112 * Smoosh some contents through the configured HMAC.
113 * @param {Buffer|string} secret secret
114 * @param {...any} contents contents
115 * @returns {string} base64url encoded digest
116 */
117 createDigest(secret, ...contents) {
118 const hmac = createHmac(this.digestAlgorithm, secret);
119 contents.forEach((content) => hmac.update(Buffer.from(content)));
120 return hmac.digest('base64url');
121 }
122
123 /**
124 * Return an authorization header value for an identifier and secret.
125 * @param {string} identifier uuid
126 * @param {string} secret secret
127 * @returns {Promise<string>} authorization header
128 */
129 async authenticate(identifier, secret) {
130 const authenticationType = 'Bearer';
131 const currentEpoch = this.constructor.currentEpoch.toString();
132 const smallIdentifier = this.constructor.ensmallenIdentifier(identifier);
133 const salt = await this.getSalt();
134 const token = [
135 smallIdentifier,
136 currentEpoch,
137 salt,
138 this.createDigest(secret, smallIdentifier, currentEpoch, salt),
139 ].join(':');
140 return [authenticationType, token].join(' ');
141 }
142
143
144 /**
145 * Require a request to include a valid resource bearer token.
146 * Resource bearer tokens are of the form 'compact-identifier:epoch:salt:${hmac(identifier + epoch + salt)}'
147 * where the hmac using a pre-shared secret.
148 * Sets ctx.resourceIdentifier on success.
149 * @param {http.IncomingMessage} req request
150 * @param {http.ServerResponse} res response
151 * @param {object} ctx context
152 * @returns {Promise<void>}
153 */
154 async required(req, res, ctx) {
155 const _scope = _fileScope('required');
156 this.logger.debug(_scope, 'called');
157
158 try {
159 const authHeader = req.getHeader(Enum.Header.Authorization);
160 if (!authHeader) {
161 throw new ResourceAuthenticatorError('no authz header');
162 }
163
164 const [ authMethod, credentials ] = common.splitFirst(authHeader, ' ', '');
165 if (authMethod.toLowerCase() !== 'bearer' || !credentials) {
166 throw new ResourceAuthenticatorError('authz header not bearer type');
167 }
168
169 const [tIdentifier, tEpoch, tSalt, tDigest, ...tRest] = credentials.split(':');
170 if (!tIdentifier || !tEpoch || !tSalt || !tDigest) {
171 throw new ResourceAuthenticatorError('unparsable bearer token');
172 }
173 if (tRest.length) {
174 this.logger.debug(_scope, 'unexpected additional fields in bearer token', { tRest });
175 }
176
177 const identifier = this.constructor.embiggenIdentifier(tIdentifier);
178
179 const resource = await this.db.context(async (dbCtx) => {
180 return await this.db.resourceGet(dbCtx, identifier);
181 }); // dbCtx
182 if (!resource) {
183 throw new ResourceAuthenticatorError('invalid resource');
184 }
185
186 const resourceEpoch = parseInt(tEpoch, 10);
187 const epochDrift = Math.abs(resourceEpoch - this.constructor.currentEpoch);
188 if (epochDrift > this.graceSeconds) {
189 throw new ResourceAuthenticatorError('timestamp out of grace period');
190 }
191
192 const digest = this.createDigest(resource.secret, tIdentifier, tEpoch, tSalt);
193 if (digest !== tDigest) {
194 throw new ResourceAuthenticatorError('invalid digest');
195 }
196
197 // Success
198 ctx.resourceIdentifier = identifier;
199 this.logger.debug(_scope, 'success');
200 } catch (e) {
201 if (e instanceof ResourceAuthenticatorError) {
202 this.logger.debug(_scope, 'invalid', { error: e });
203 return this.constructor.requestBearer(res);
204 }
205 this.logger.error(_scope, 'failed', { error: e });
206 throw e;
207 }
208 }
209
210
211 /**
212 * Return a status code requesting bearer authentication.
213 * @param {http.ServerResponse} res response
214 * @param {object} response defaults to Unauthorized status
215 */
216 static requestBearer(res, response = Enum.ErrorResponse.Unauthorized) {
217 res.setHeader(Enum.Header.WWWAuthenticate, 'Bearer');
218 throw new ResponseError(response);
219 }
220
221 }
222
223 module.exports = ResourceAuthenticator;