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');
13 const _fileScope
= common
.fileScope(__filename
);
16 * @typedef {Object} ResourceRecord
17 * @property {String} secret
20 const defaultOptions
= {
21 digestAlgorithm: 'sha256',
26 class ResourceAuthenticator
{
29 * @param {Console} logger
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
39 constructor(logger
, db
, options
= { resourceAuthenticator: {} }) {
42 Object
.assign(this, defaultOptions
, options
.resourceAuthenticator
);
47 * Get the current epoch.
49 static get currentEpoch() {
50 return Math
.ceil(Date
.now() / 1000);
55 * Spice up the payload.
59 const saltBytes
= await common
.randomBytesAsync(this.saltBytes
);
60 return common
.base64ToBase64URL(saltBytes
.toString('base64'));
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.
70 * @param {http.IncomingMessage} req
71 * @param {http.ServerResponse} res
74 async
required(req
, res
, ctx
) {
75 const _scope
= _fileScope('required');
76 this.logger
.debug(_scope
, 'called', { req
, ctx
});
79 const authHeader
= req
.getHeader(Enum
.Header
.Authorization
);
81 throw new ResourceAuthenticatorError('auth header missing')
84 const [ authMethod
, credentials
] = common
.splitFirst(authHeader
, ' ', '');
85 if (authMethod
.toLowerCase() !== 'bearer' || !credentials
) {
86 throw new ResourceAuthenticatorError('auth header not bearer');
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');
95 const identifier
= ResourceAuthenticator
.embiggenIdentifier(tIdentifier
);
98 await
this.db
.context(async (dbCtx
) => {
99 resource
= await
this.db
.resourceGet(dbCtx
, identifier
);
102 throw new ResourceAuthenticatorError('invalid resource');
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');
112 const hmac
= this.createDigest(resource
.secret
, tIdentifier
, tEpoch
, tSalt
);
113 if (hmac
!== tHMAC
) {
114 throw new ResourceAuthenticatorError('mismatched HMAC');
117 ctx
.resourceIdentifier
= identifier
;
118 this.logger
.debug(_scope
, 'success', { ctx
});
121 if (e
instanceof ResourceAuthenticatorError
) {
122 this.logger
.debug(_scope
, 'invalid', { error: e
, ctx
});
123 return ResourceAuthenticator
.requestBearer(res
);
125 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
132 * Return the authentication header for an identifier.
133 * @param {String} identifier UUID
135 async
authenticate(identifier
, secret
) {
136 const currentEpoch
= ResourceAuthenticator
.currentEpoch
.toString();
137 const smallIdentifier
= ResourceAuthenticator
.ensmallenIdentifier(identifier
);
138 const salt
= await
this.getSalt();
143 this.createDigest(secret
, smallIdentifier
, currentEpoch
, salt
),
145 return parts
.join(':');
150 * Compact uuid identifier, as 24 url-safe characters.
151 * @param {String} identifier UUID
152 * @returns {String} b64u-encoded UUID
154 static ensmallenIdentifier(identifier
) {
155 const uuidBase64
= Buffer
.from(uuid
.parse(identifier
)).toString('base64');
156 return common
.base64ToBase64URL(uuidBase64
);
161 * Expand compacted uuid identifier.
162 * @param {String} identifier b64u-encoded UUID
163 * @returns {String} UUID
165 static embiggenIdentifier(small
) {
166 const uuidBase64
= common
.base64URLToBase64(small
);
167 const uuidBuffer
= Buffer
.from(uuidBase64
, 'base64');
168 return uuid
.stringify(uuidBuffer
);
173 * Smoosh some contents through the chosen HMAC.
174 * @param {Buffer} secret
175 * @param {...any} contents
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
);
187 * Abort and request bearer authentication.
188 * @param {http.ServerResponse} res
189 * @param {Object} response defaults to Unauthorized
191 static requestBearer(res
, response
= Enum
.ErrorResponse
.Unauthorized
) {
192 res
.setHeader(Enum
.Header
.WWWAuthenticate
, 'Bearer');
193 throw new ResponseError(response
);
199 ResourceAuthenticator
,
200 ResourceAuthenticatorError
,