update depedencies, changes to support updated authentication-module
[squeep-indie-auther] / src / db / abstract.js
1 /* eslint-disable no-unused-vars */
2 'use strict';
3
4 const common = require('../common');
5 const DatabaseErrors = require('./errors');
6 const svh = require('./schema-version-helper');
7 const uuid = require('uuid');
8
9 const _fileScope = common.fileScope(__filename);
10
11 class Database {
12 constructor(logger, options) {
13 this.logger = logger;
14 this.options = options;
15 }
16
17
18 /**
19 * Perform tasks needed to prepare database for use. Ensure this is called
20 * after construction, and before any other database activity.
21 * At the minimum, this will validate a compatible schema is present and usable.
22 * Some engines will also perform other initializations or async actions which
23 * are easier handled outside the constructor.
24 */
25 async initialize() {
26 const _scope = _fileScope('initialize');
27
28 const currentSchema = await this._currentSchema();
29 const current = svh.schemaVersionObjectToNumber(currentSchema);
30 const min = svh.schemaVersionObjectToNumber(this.schemaVersionsSupported.min);
31 const max = svh.schemaVersionObjectToNumber(this.schemaVersionsSupported.max);
32 if (current >= min && current <= max) {
33 this.logger.debug(_scope, 'schema supported', { currentSchema, schemaVersionsSupported: this.schemaVersionsSupported });
34 } else {
35 this.logger.error(_scope, 'schema not supported', { currentSchema, schemaVersionsSupported: this.schemaVersionsSupported });
36 throw new DatabaseErrors.MigrationNeeded();
37 }
38 }
39
40
41 /**
42 * @typedef {Object} SchemaVersionObject
43 * @property {Number} major
44 * @property {Number} minor
45 * @property {Number} patch
46 */
47 /**
48 * Query the current schema version.
49 * This is a standalone query function, as it is called before statements are loaded.
50 * @returns {Promise<SchemaVersionObject>}
51 */
52 async _currentSchema() {
53 this._notImplemented('_currentSchema', arguments);
54 }
55
56
57 /**
58 * Perform db connection health-check, if applicable.
59 * Throw something if a database situation should pull us out of a load-balancer.
60 */
61 async healthCheck() {
62 this._notImplemented('healthCheck', arguments);
63 }
64
65
66 /**
67 * Wrap a function call in a database context.
68 * @param {Function} fn fn(ctx)
69 */
70 async context(fn) {
71 this._notImplemented('context', arguments);
72 }
73
74 /**
75 * Wrap a function call in a transaction context.
76 * @param {*} dbCtx
77 * @param {Function} fn fn(txCtx)
78 */
79 async transaction(dbCtx, fn) {
80 this._notImplemented('transaction', arguments);
81 }
82
83
84 /**
85 * Basic type checking of object properties.
86 *
87 * Types may be any of the built-in types:
88 * - boolean
89 * - bigint (also allowed with 'number')
90 * - function
91 * - number (this will also allow 'bigint')
92 * - object
93 * - string
94 * - symbol
95 * - undefined
96 *
97 * Types may also be any of the following:
98 * - array
99 * - buffer
100 * - date
101 * - infinites
102 * - null
103 * - uuid
104 * @param {Object} object
105 * @param {String[]} properties
106 * @param {String[]} types
107 */
108 _ensureTypes(object, properties, types) {
109 const _scope = _fileScope('_ensureTypes');
110
111 if (!(object && properties && types)) {
112 this.logger.error(_scope, 'undefined argument', { object, properties, types });
113 throw new DatabaseErrors.DataValidation();
114 }
115
116 const supportedTypes = [
117 'array',
118 'bigint',
119 'boolean',
120 'buffer',
121 'date',
122 'function',
123 'infinites',
124 'null',
125 'number',
126 'object',
127 'string',
128 'symbol',
129 'undefined',
130 'uuid',
131 ];
132 types.forEach((t) => {
133 if (!supportedTypes.includes(t)) {
134 this.logger.error(_scope, 'unsupported type', { object, properties, types, unsupportedType: t });
135 throw new DatabaseErrors.DataValidation();
136 }
137 });
138
139 properties.forEach((p) => {
140 // eslint-disable-next-line security/detect-object-injection
141 const pObj = object[p];
142 const pType = typeof pObj;
143 if (!types.includes(pType)
144 && !(types.includes('array') && Array.isArray(pObj))
145 && !(types.includes('buffer') && pObj instanceof Buffer)
146 && !(types.includes('date') && pObj instanceof Date)
147 && !(types.includes('infinites') && Math.abs(pObj) === Infinity)
148 && !(types.includes('null') && pObj === null)
149 && !(types.includes('number') && pType === 'bigint')
150 && !(types.includes('uuid') && uuid.validate(pObj))) {
151 const reason = `'${p}' is '${pType}', but must be ${types.length > 1 ? 'one of ' : ''}'${types}'`;
152 this.logger.error(_scope, reason, {});
153 throw new DatabaseErrors.DataValidation(reason);
154 }
155 });
156 }
157
158
159 /**
160 * @typedef {Object} Authentication
161 * @property {String} identifier
162 * @property {String=} credential
163 * @property {Date} created
164 * @property {Date=} lastAuthentication
165 */
166 /**
167 * @param {Authentication} authentication
168 */
169 _validateAuthentication(authentication) {
170 [
171 [['identifier'], ['string']],
172 [['credential'], ['string', 'null']],
173 [['created'], ['date']],
174 [['lastAuthentication'], ['date', 'infinites']],
175 ].forEach(([properties, types]) => this._ensureTypes(authentication, properties, types));
176 }
177
178
179 /**
180 * @typedef {Object} Resource
181 * @property {String} resourceId - uuid
182 * @property {String} secret
183 * @property {String} description
184 * @property {Date} created
185 */
186 /**
187 * @param {Resource} resource
188 */
189 _validateResource(resource) {
190 [
191 [['resourceId', 'secret', 'description'], ['string']],
192 [['resourceId'], ['uuid']],
193 [['created'], ['date']],
194 ].forEach(([properties, types]) => this._ensureTypes(resource, properties, types));
195 }
196
197
198 /**
199 * @typedef {Object} Token
200 * @property {String} codeId - uuid
201 * @property {String} profile
202 * @property {Date} created
203 * @property {Date=} expires
204 * @property {Date=} refreshExpires
205 * @property {Date=} refreshed
206 * @property {*=} duration
207 * @property {*=} refreshDuration
208 * @property {Number|BigInt=} refresh_count
209 * @property {Boolean} is_revoked
210 * @property {Boolean} is_token
211 * @property {String} client_id
212 * @property {String[]} scopes
213 * @property {Object=} profileData
214 */
215 /**
216 * @param {Token} token
217 */
218 _validateToken(token) {
219 [
220 [['codeId', 'profile', 'clientId'], ['string']],
221 [['codeId'], ['uuid']],
222 [['created'], ['date']],
223 [['expires', 'refreshExpires', 'refreshed'], ['date', 'null']],
224 [['isToken', 'isRevoked'], ['boolean']],
225 [['scopes'], ['array']],
226 [['profileData'], ['object', 'null']],
227 ].forEach(([properties, types]) => this._ensureTypes(token, properties, types));
228 this._ensureTypes(token.scopes, Object.keys(token.scopes), ['string']);
229 }
230
231
232 /**
233 * Interface methods need implementations. Ensure the db-interaction
234 * methods on the base class call this, so they may be overridden by
235 * implementation classes.
236 * @param {String} method
237 * @param {arguments} args
238 */
239 _notImplemented(method, args) {
240 this.logger.error(_fileScope(method), 'abstract method called', Array.from(args));
241 throw new DatabaseErrors.NotImplemented(method);
242 }
243
244
245 /**
246 * Get all the almanac entries.
247 * @param {*} dbCtx
248 */
249 async almanacGetAll(dbCtx) {
250 this._notImplemented('almanacGetAll', arguments);
251 }
252
253
254 /**
255 * Insert or update an almanac entry.
256 * @param {*} dbCtx
257 * @param {String} event
258 * @param {Date=} date
259 */
260 async almanacUpsert(dbCtx, event, date) {
261 this._notImplemented('almanacUpsert', arguments);
262 }
263
264
265 /**
266 * Fetch the authentication record for an identifier.
267 * @param {*} dbCtx
268 * @param {String} identifier
269 * @returns {Promise<Authentication>}
270 */
271 async authenticationGet(dbCtx, identifier) {
272 this._notImplemented('authenticationGet', arguments);
273 }
274
275
276 /**
277 * Update the authentication record for the identifier that
278 * correct credentials have been supplied.
279 * @param {*} dbCtx
280 * @param {String} identifier
281 * @returns {Promise<void>}
282 */
283 async authenticationSuccess(dbCtx, identifier) {
284 this._notImplemented('authenticationSuccess', arguments);
285 }
286
287
288 /**
289 * Insert or update the credential for an identifier.
290 * @param {*} dbCtx
291 * @param {String} identifier
292 * @param {String} credential
293 * @param {String=} otpKey
294 * @returns {Promise<void>}
295 */
296 async authenticationUpsert(dbCtx, identifier, credential, otpKey) {
297 this._notImplemented('authenticationUpsert', arguments);
298 }
299
300
301 /**
302 * Update the otpKey for an identifier.
303 * @param {*} dbCtx
304 * @param {String} identifier
305 * @param {String=} otpKey
306 * @returns {Promise<void>}
307 */
308 async authenticationUpdateOTPKey(dbCtx, identifier, otpKey) {
309 this._notImplemented('authenticationUpdateOTPKey', arguments);
310 }
311
312
313 /**
314 * Update the credential for an identifier.
315 * @param {*} dbCtx
316 * @param {String} identifier
317 * @param {String} credential
318 * @returns {Promise<void>}
319 */
320 async authenticationUpdateCredential(dbCtx, identifier, credential) {
321 this._notImplemented('authenticationUpdateCredentials', arguments);
322 }
323
324
325 /**
326 * Determine if profile url is known to this service.
327 * @param {*} dbCtx
328 * @param {String} profile
329 * @returns {Promise<Boolean>}
330 */
331 async profileIsValid(dbCtx, profile) {
332 this._notImplemented('profileGet', arguments);
333 }
334
335
336 /**
337 * Insert a new relationship between a profile endpoint and
338 * an authenticated identifier.
339 * @param {*} dbCtx
340 * @param {String} profile
341 * @param {String} identifier
342 * @returns {Promise<void>}
343 */
344 async profileIdentifierInsert(dbCtx, profile, identifier) {
345 this._notImplemented('profileIdentifierInsert', arguments);
346 }
347
348
349 /**
350 * Adds a scope to be available for a profile to include on any authorization request.
351 * @param {*} dbCtx
352 * @param {String} profile
353 * @param {String} scope
354 * @returns {Promise<void>}
355 */
356 async profileScopeInsert(dbCtx, profile, scope) {
357 this._notImplemented('profileScopeInsert', arguments);
358 }
359
360
361 /**
362 * @typedef {Object} ScopeDetails
363 * @property {String} description
364 * @property {String[]=} profiles
365 */
366 /**
367 * @typedef {Object.<String, Object>} ProfileScopes
368 * @property {Object.<String, Object>} profile
369 * @property {Object.<String, ScopeDetails>} profile.scope
370 */
371 /**
372 * @typedef {Object.<String, Object>} ScopeIndex
373 * @property {ScopeDetails} scope
374 */
375 /**
376 * @typedef {Object} ProfilesScopesReturn
377 * @property {ProfileScopes} profileScopes
378 * @property {ScopeIndex} scopeIndex
379 * @property {String[]} profiles
380 */
381 /**
382 * Returns an object containing:
383 * - an object with profiles as keys to objects with scopes as keys to scope objects,
384 * which each contain a description of the scope and a list of profiles offering it
385 * - an object with scopes as keys to the same scope objects
386 * - a list of profiles
387 * @param {*} dbCtx
388 * @param {String} identifier
389 * @returns {Promise<ProfileScopesReturn>}
390 */
391 async profilesScopesByIdentifier(dbCtx, identifier) {
392 this._notImplemented('profilesScopesByIdentifier', arguments);
393 }
394
395
396 /**
397 * @typedef ProfileScopesRow
398 * @property profile
399 * @property scope
400 * @property description
401 * @property application
402 * @property isPermanent
403 * @property isManuallyAdded
404 */
405 /**
406 * Convert db row data into associative structures.
407 * Same behavior is shared by multiple engines.
408 * @param {ProfileScopesRow[]} profileScopesRows
409 * @returns {ProfileScopesReturn}
410 */
411 static _profilesScopesBuilder(profileScopesRows) {
412 const scopeIndex = {};
413 const profileScopes = {};
414 const profileSet = new Set();
415
416 (profileScopesRows || []).forEach(({ profile, scope, description, application, isPermanent, isManuallyAdded }) => {
417 if (scope && !(scope in scopeIndex)) {
418 scopeIndex[scope] = { // eslint-disable-line security/detect-object-injection
419 description,
420 application,
421 isPermanent,
422 isManuallyAdded,
423 profiles: [],
424 };
425 }
426 if (profile) {
427 profileSet.add(profile);
428 if (!(profile in profileScopes)) {
429 profileScopes[profile] = {}; // eslint-disable-line security/detect-object-injection
430 }
431 }
432 if (profile && scope) {
433 scopeIndex[scope].profiles.push(profile); // eslint-disable-line security/detect-object-injection
434 profileScopes[profile][scope] = scopeIndex[scope]; // eslint-disable-line security/detect-object-injection
435 }
436 });
437
438 return {
439 profiles: [...profileSet],
440 profileScopes,
441 scopeIndex,
442 };
443 }
444
445
446 /**
447 * Sets list of additional scopes available to profile.
448 * @param {*} dbCtx
449 * @param {String} profile
450 * @param {String[]} scopes
451 * @returns {Promise<void>}
452 */
453 async profileScopesSetAll(dbCtx, profile, scopes) {
454 this._notImplemented('profileScopesSetAll', arguments);
455 }
456
457
458 /**
459 * Create (or revoke a duplicate) code as a token entry.
460 * @param {*} dbCtx
461 * @param {Object} data
462 * @param {String} data.codeId
463 * @param {Date} data.created
464 * @param {Boolean} data.isToken
465 * @param {String} data.clientId
466 * @param {String} data.profile - profile uri
467 * @param {String} data.identifier
468 * @param {String[]} data.scopes
469 * @param {Number|Null} data.lifespanSeconds - null sets expiration to Infinity
470 * @param {Number|Null} data.refreshLifespanSeconds - null sets refresh to none
471 * @param {String|Null} data.resource
472 * @param {Object|Null} data.profileData - profile data from profile uri
473 * @returns {Promise<Boolean>} whether redemption was successful
474 */
475 async redeemCode(dbCtx, { codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshLifespanSeconds, profileData } = {}) {
476 this._notImplemented('redeemCode', arguments);
477 }
478
479
480 /**
481 * @typedef {Object} RefreshedToken
482 * @property {Date} expires
483 * @property {Date} refreshExpires
484 * @property {String[]=} scopes if scopes were reduced
485 */
486 /**
487 * Redeem a refresh token to renew token codeId.
488 * @param {*} dbCtx
489 * @param {String} codeId
490 * @param {Date} refreshed
491 * @param {String[]} removeScopes
492 * @returns {Promise<RefreshedToken>}
493 */
494 async refreshCode(dbCtx, codeId, refreshed, removeScopes) {
495 this._notImplemented('refreshCode', arguments);
496 }
497
498
499 /**
500 * Fetch a resource server record.
501 * @param {*} dbCtx
502 * @param {String} identifier uuid
503 * @returns {Promise<Resource>}
504 */
505 async resourceGet(dbCtx, resourceId) {
506 this._notImplemented('resourceGet', arguments);
507 }
508
509
510 /**
511 * Create, or update description of, a resourceId.
512 * @param {*} dbCtx
513 * @param {String=} resourceId uuid
514 * @param {String=} secret
515 * @param {String=} description
516 * @returns {Promise<void>}
517 */
518 async resourceUpsert(dbCtx, resourceId, secret, description) {
519 this._notImplemented('resourceUpsert', arguments);
520 }
521
522
523 /**
524 * Register a scope and its description.
525 * @param {*} dbCtx
526 * @param {String} scope
527 * @param {String} application
528 * @param {String} description
529 * @returns {Promise<void>}
530 */
531 async scopeUpsert(dbCtx, scope, application, description, manuallyAdded = false) {
532 this._notImplemented('scopeUpsert', arguments);
533 }
534
535
536 /**
537 * Remove a non-permanent scope if it is not currently in use.
538 * @param {*} dbCtx
539 * @param {String} scope
540 * @returns {Promise<Boolean>}
541 */
542 async scopeDelete(dbCtx, scope) {
543 this._notImplemented('scopeDelete', arguments);
544 }
545
546
547 /**
548 * @typedef {Number|BigInt} CleanupResult
549 */
550 /**
551 * @typedef {Object} CleanupResult
552 */
553 /**
554 * Remove any non-permanent and non-manually-created scopes not currently in use.
555 * @param {*} dbCtx
556 * @param {Number} atLeastMsSinceLast skip cleanup if already executed this recently
557 * @returns {Promise<CleanupResult>}
558 */
559 async scopeCleanup(dbCtx, atLeastMsSinceLast) {
560 this._notImplemented('scopeClean', arguments);
561 }
562
563
564 /**
565 * Forget tokens after they have expired, and redeemed codes after they have expired.
566 * @param {*} dbCtx
567 * @param {Number} codeLifespanSeconds
568 * @param {Number} atLeastMsSinceLast skip cleanup if already executed this recently
569 * @returns {Promise<CleanupResult>}
570 */
571 async tokenCleanup(dbCtx, codeLifespanSeconds, atLeastMsSinceLast) {
572 this._notImplemented('tokenCleanup', arguments);
573 }
574
575
576 /**
577 * Look up a redeemed token by code_id.
578 * @param {*} dbCtx
579 * @param {String} codeId
580 * @returns {Promise<Token>}
581 */
582 async tokenGetByCodeId(dbCtx, codeId) {
583 this._notImplemented('tokenGetByCodeId', arguments);
584 }
585
586
587 /**
588 * Sets a redeemed token as revoked.
589 * @param {*} dbCtx
590 * @param {String} codeId - uuid
591 * @returns {Promise<void>}
592 */
593 async tokenRevokeByCodeId(dbCtx, codeId) {
594 this._notImplemented('tokenRevokeByCodeId', arguments);
595 }
596
597
598 /**
599 * Revoke the refreshability of a codeId.
600 * @param {*} dbCtx
601 * @param {String} codeId - uuid
602 * @returns {Promise<void>}
603 */
604 async tokenRefreshRevokeByCodeId(dbCtx, codeId) {
605 this._notImplemented('tokenRefreshRevokeByCodeId', arguments);
606 }
607
608
609 /**
610 * Get all tokens assigned to identifier.
611 * @param {*} dbCtx
612 * @param {String} identifier
613 * @returns {Promise<Tokens[]>}
614 */
615 async tokensGetByIdentifier(dbCtx, identifier) {
616 this._notImplemented('tokensGetByIdentifier', arguments);
617 }
618
619
620 /** @typedef {Object} RedeemedTicketData
621 * @property {String} subject
622 * @property {String} resource
623 * @property {String=} iss
624 * @property {String} ticket
625 * @property {String} token
626 */
627 /**
628 * Persist details of a redeemed ticket.
629 * @param {*} dbCtx
630 * @param {RedeemedTicketData} redeemedData
631 * @returns {Promise<void>}
632 */
633 async ticketRedeemed(dbCtx, redeemedData) {
634 this._notImplemented('ticketRedeemed', arguments);
635 }
636
637
638 /**
639 * Update details of a redeemed ticket that it has been published.
640 * @param {*} dbCtx
641 * @param {RedeemedTicketData} redeemedData
642 * @returns {Promise<void>}
643 */
644 async ticketTokenPublished(dbCtx, redeemedData) {
645 this._notImplemented('ticketTokenPublished', arguments);
646 }
647
648 /**
649 * Retrieve redeemed tokens which have not yet been published to queue.
650 * @param {Number} limit
651 * @returns {Promise<RedeemedData[]>}
652 */
653 async ticketTokenGetUnpublished(dbCtx, limit) {
654 this._notImplemented('ticketTokenGetUnpublished', arguments);
655 }
656
657 }
658
659 module.exports = Database;