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');
9 const _fileScope
= common
.fileScope(__filename
);
12 * @typedef {import('node:http')} http
16 * @typedef {object} ResourceRecord
17 * @property {string} secret secret
21 * @typedef {object} ConsoleLike
22 * @property {Function} debug log debug
23 * @property {Function} error log error
24 * @property {Function} info log info
28 * @callback DBContextExec
29 * @param {object} dbCtx db context
30 * @returns {Promise<any>} result
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
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
47 * @type {ResourceAuthenticatorOptions}
49 const defaultResourceAuthenticatorOptions
= {
50 digestAlgorithm: 'sha256',
55 class ResourceAuthenticator
{
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
63 constructor(logger
, db
, options
= {}) {
66 common
.setOptions(this, defaultResourceAuthenticatorOptions
, options
?.resourceAuthenticator
|| {});
71 * Get the current epoch.
72 * @returns {number} epoch seconds
74 static get currentEpoch() {
75 return Math
.ceil(Date
.now() / 1000);
80 * Compact uuid identifier as 24 character url-safe string.
81 * @param {string} identifier uuid
82 * @returns {string} base64url encoded uuid
84 static ensmallenIdentifier(identifier
) {
85 return Buffer
.from(uuid
.parse(identifier
)).toString('base64url');
90 * Expand compacted uuid identifier.
91 * @param {string} compact base64url encoded uuid
92 * @returns {string} uuid
94 static embiggenIdentifier(compact
) {
95 const uuidBuffer
= Buffer
.from(compact
, 'base64url');
96 return uuid
.stringify(uuidBuffer
);
101 * Get an encoded salt string.
102 * @param {number} saltBytes salt bytes
103 * @returns {Promise<string>} base64url encoded
105 async
getSalt(saltBytes
= this.saltBytes
) {
106 const saltBuffer
= await common
.randomBytesAsync(saltBytes
);
107 return saltBuffer
.toString('base64url');
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
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');
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
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();
138 this.createDigest(secret
, smallIdentifier
, currentEpoch
, salt
),
140 return [authenticationType
, token
].join(' ');
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>}
154 async
required(req
, res
, ctx
) {
155 const _scope
= _fileScope('required');
156 this.logger
.debug(_scope
, 'called');
159 const authHeader
= req
.getHeader(Enum
.Header
.Authorization
);
161 throw new ResourceAuthenticatorError('no authz header');
164 const [ authMethod
, credentials
] = common
.splitFirst(authHeader
, ' ', '');
165 if (authMethod
.toLowerCase() !== 'bearer' || !credentials
) {
166 throw new ResourceAuthenticatorError('authz header not bearer type');
169 const [tIdentifier
, tEpoch
, tSalt
, tDigest
, ...tRest
] = credentials
.split(':');
170 if (!tIdentifier
|| !tEpoch
|| !tSalt
|| !tDigest
) {
171 throw new ResourceAuthenticatorError('unparsable bearer token');
174 this.logger
.debug(_scope
, 'unexpected additional fields in bearer token', { tRest
});
177 const identifier
= this.constructor.embiggenIdentifier(tIdentifier
);
179 const resource
= await
this.db
.context(async (dbCtx
) => {
180 return await
this.db
.resourceGet(dbCtx
, identifier
);
183 throw new ResourceAuthenticatorError('invalid resource');
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');
192 const digest
= this.createDigest(resource
.secret
, tIdentifier
, tEpoch
, tSalt
);
193 if (digest
!== tDigest
) {
194 throw new ResourceAuthenticatorError('invalid digest');
198 ctx
.resourceIdentifier
= identifier
;
199 this.logger
.debug(_scope
, 'success');
201 if (e
instanceof ResourceAuthenticatorError
) {
202 this.logger
.debug(_scope
, 'invalid', { error: e
});
203 return this.constructor.requestBearer(res
);
205 this.logger
.error(_scope
, 'failed', { error: e
});
212 * Return a status code requesting bearer authentication.
213 * @param {http.ServerResponse} res response
214 * @param {object} response defaults to Unauthorized status
216 static requestBearer(res
, response
= Enum
.ErrorResponse
.Unauthorized
) {
217 res
.setHeader(Enum
.Header
.WWWAuthenticate
, 'Bearer');
218 throw new ResponseError(response
);
223 module
.exports
= ResourceAuthenticator
;