26867e45f35ca8b50b0c15729e7421f9c5ad1648
[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 * Query the current schema version.
43 * This is a standalone query function, as it is called before statements are loaded.
44 * @returns {Object} version
45 * @returns {Number} version.major
46 * @returns {Number} version.minor
47 * @returns {Number} version.patch
48 */
49 async _currentSchema() {
50 this._notImplemented('_currentSchema', arguments);
51 }
52
53
54 /**
55 * Perform db connection health-check, if applicable.
56 * Throw something if a database situation should pull us out of a load-balancer.
57 */
58 async healthCheck() {
59 this._notImplemented('healthCheck', arguments);
60 }
61
62
63 /**
64 * Wrap a function call in a database context.
65 * @param {Function} fn fn(ctx)
66 */
67 async context(fn) {
68 this._notImplemented('context', arguments);
69 }
70
71 /**
72 * Wrap a function call in a transaction context.
73 * @param {*} dbCtx
74 * @param {Function} fn fn(txCtx)
75 */
76 async transaction(dbCtx, fn) {
77 this._notImplemented('transaction', arguments);
78 }
79
80
81 /**
82 * @param {*} x
83 * @returns {Boolean}
84 */
85 static _isUUID(x) {
86 try {
87 uuid.parse(x);
88 return true;
89 } catch (e) {
90 return false;
91 }
92 }
93
94
95 /**
96 * @param {*} x
97 * @returns {Boolean}
98 */
99 static _isInfinites(x) {
100 return typeof(x) === 'number'
101 && Math.abs(x) === Infinity;
102 }
103
104 /**
105 * Basic type checking of object properties.
106 * @param {Object} object
107 * @param {String[]} properties
108 * @param {String[]} types
109 */
110 _ensureTypes(object, properties, types) {
111 const _scope = _fileScope('_ensureTypes');
112
113 if (!(object && properties && types)) {
114 this.logger.error(_scope, 'undefined argument', { object, properties, types });
115 throw new DatabaseErrors.DataValidation();
116 }
117 properties.forEach((p) => {
118 // eslint-disable-next-line security/detect-object-injection
119 const pObj = object[p];
120 const pType = typeof pObj;
121 if (!types.includes(pType)
122 && !(types.includes('array') && Array.isArray(pObj))
123 && !(types.includes('buffer') && pObj instanceof Buffer)
124 && !(types.includes('date') && pObj instanceof Date)
125 && !(types.includes('infinites'))
126 && !(types.includes('null') && pObj === null)
127 && !(types.includes('number') && pType === 'bigint')
128 && !(types.includes('uuid') && Database._isUUID(pObj))) {
129 const reason = `'${p}' is '${pType}', but must be ${types.length > 1 ? 'one of ' : ''}'${types}'`;
130 this.logger.error(_scope, reason, {});
131 throw new DatabaseErrors.DataValidation(reason);
132 }
133 });
134 }
135
136
137 /**
138 * @typedef {Object} Authentication
139 * @property {String} identifier
140 * @property {String=} credential
141 * @property {Date} created
142 * @property {Date=} lastAuthenticated
143 */
144 /**
145 * @param {Authentication} authentication
146 */
147 _validateAuthentication(authentication) {
148 [
149 [['identifier'], ['string']],
150 [['credential'], ['string', 'null']],
151 [['created'], ['date']],
152 [['lastAuthenticated'], ['date', 'infinites']],
153 ].forEach(([properties, types]) => this._ensureTypes(authentication, properties, types));
154 }
155
156
157 /**
158 * @typedef {Object} Resource
159 * @property {String} resourceId - uuid
160 * @property {String} secret
161 * @property {String} description
162 * @property {Date} created
163 */
164 /**
165 * @param {Resource} resource
166 */
167 _validateResource(resource) {
168 [
169 [['resourceId', 'secret', 'description'], ['string']],
170 [['resourceId'], ['uuid']],
171 [['created'], ['date']],
172 ].forEach(([properties, types]) => this._ensureTypes(resource, properties, types));
173 }
174
175
176 /**
177 * @typedef {Object} Token
178 * @property {String} codeId - uuid
179 * @property {String} profile
180 * @property {Date} created
181 * @property {Date=} expires
182 * @property {Date=} refreshExpires
183 * @property {Date=} refreshed
184 * @property {*=} duration
185 * @property {*=} refreshDuration
186 * @property {Number|BigInt=} refresh_count
187 * @property {Boolean} is_revoked
188 * @property {Boolean} is_token
189 * @property {String} client_id
190 * @property {String[]} scopes
191 * @property {Object=} profileData
192 */
193 /**
194 * @param {Token} token
195 */
196 _validateToken(token) {
197 [
198 [['codeId', 'profile', 'clientId'], ['string']],
199 [['codeId'], ['uuid']],
200 [['created'], ['date']],
201 [['expires', 'refreshExpires', 'refreshed'], ['date', 'null']],
202 [['isToken', 'isRevoked'], ['boolean']],
203 [['scopes'], ['array']],
204 [['profileData'], ['object', 'null']],
205 ].forEach(([properties, types]) => this._ensureTypes(token, properties, types));
206 this._ensureTypes(token.scopes, Object.keys(token.scopes), ['string']);
207 }
208
209
210 /**
211 * Interface methods need implementations. Ensure the db-interaction
212 * methods on the base class call this, so they may be overridden by
213 * implementation classes.
214 * @param {String} method
215 * @param {arguments} args
216 */
217 _notImplemented(method, args) {
218 this.logger.error(_fileScope(method), 'abstract method called', Array.from(args));
219 throw new DatabaseErrors.NotImplemented(method);
220 }
221
222
223 /**
224 * Get all the almanac entries.
225 * @param {*} dbCtx
226 */
227 async almanacGetAll(dbCtx) {
228 this._notImplemented('almanacGetAll', arguments);
229 }
230
231
232 /**
233 * Fetch the authentication record for an identifier.
234 * @param {*} dbCtx
235 * @param {String} identifier
236 * @returns {Promise<Authentication>}
237 */
238 async authenticationGet(dbCtx, identifier) {
239 this._notImplemented('authenticationGet', arguments);
240 }
241
242
243 /**
244 * Update the authentication record for the identifier that
245 * correct credentials have been supplied.
246 * @param {*} dbCtx
247 * @param {String} identifier
248 * @returns {Promise<void>}
249 */
250 async authenticationSuccess(dbCtx, identifier) {
251 this._notImplemented('authenticationSuccess', arguments);
252 }
253
254
255 /**
256 * Insert or update the credential for an identifier.
257 * @param {*} dbCtx
258 * @param {String} identifier
259 * @param {String} credential
260 * @returns {Promise<void>}
261 */
262 async authenticationUpsert(dbCtx, identifier, credential) {
263 this._notImplemented('authenticationUpsert', arguments);
264 }
265
266
267 /**
268 * Determine if profile url is known to this service.
269 * @param {*} dbCtx
270 * @param {String} profile
271 * @returns {Promise<Boolean>}
272 */
273 async profileIsValid(dbCtx, profile) {
274 this._notImplemented('profileGet', arguments);
275 }
276
277
278 /**
279 * Insert a new relationship between a profile endpoint and
280 * an authenticated identifier.
281 * @param {*} dbCtx
282 * @param {String} profile
283 * @param {String} identifier
284 * @returns {Promise<void>}
285 */
286 async profileIdentifierInsert(dbCtx, profile, identifier) {
287 this._notImplemented('profileIdentifierInsert', arguments);
288 }
289
290
291 /**
292 * Adds a scope to be available for a profile to include on any authorization request.
293 * @param {*} dbCtx
294 * @param {String} profile
295 * @param {String} scope
296 * @returns {Promise<void>}
297 */
298 async profileScopeInsert(dbCtx, profile, scope) {
299 this._notImplemented('profileScopeInsert', arguments);
300 }
301
302
303 /**
304 * @typedef {Object} ScopeDetails
305 * @property {String} description
306 * @property {String[]=} profiles
307 */
308 /**
309 * @typedef {Object.<String, Object>} ProfileScopes
310 * @property {Object.<String, Object>} profile
311 * @property {Object.<String, ScopeDetails>} profile.scope
312 */
313 /**
314 * @typedef {Object.<String, Object>} ScopeIndex
315 * @property {ScopeDetails} scope
316 */
317 /**
318 * @typedef {Object} ProfilesScopesReturn
319 * @property {ProfileScopes} profileScopes
320 * @property {ScopeIndex} scopeIndex
321 * @property {String[]} profiles
322 */
323 /**
324 * Returns an object containing:
325 * - an object with profiles as keys to objects with scopes as keys to scope objects,
326 * which each contain a description of the scope and a list of profiles offering it
327 * - an object with scopes as keys to the same scope objects
328 * - a list of profiles
329 * @param {*} dbCtx
330 * @param {String} identifier
331 * @returns {Promise<ProfileScopesReturn>}
332 */
333 async profilesScopesByIdentifier(dbCtx, identifier) {
334 this._notImplemented('profilesScopesByIdentifier', arguments);
335 }
336
337
338 /**
339 * @typedef ProfileScopesRow
340 * @property profile
341 * @property scope
342 * @property description
343 * @property application
344 * @property isPermanent
345 * @property isManuallyAdded
346 */
347 /**
348 * Convert db row data into associative structures.
349 * Same behavior is shared by multiple engines.
350 * @param {ProfileScopesRow[]} profileScopesRows
351 * @returns {ProfileScopesReturn}
352 */
353 static _profilesScopesBuilder(profileScopesRows) {
354 const scopeIndex = {};
355 const profileScopes = {};
356 const profileSet = new Set();
357
358 (profileScopesRows || []).forEach(({ profile, scope, description, application, isPermanent, isManuallyAdded }) => {
359 if (scope && !(scope in scopeIndex)) {
360 scopeIndex[scope] = { // eslint-disable-line security/detect-object-injection
361 description,
362 application,
363 isPermanent,
364 isManuallyAdded,
365 profiles: [],
366 };
367 }
368 if (profile) {
369 profileSet.add(profile);
370 if (!(profile in profileScopes)) {
371 profileScopes[profile] = {}; // eslint-disable-line security/detect-object-injection
372 }
373 }
374 if (profile && scope) {
375 scopeIndex[scope].profiles.push(profile); // eslint-disable-line security/detect-object-injection
376 profileScopes[profile][scope] = scopeIndex[scope]; // eslint-disable-line security/detect-object-injection
377 }
378 });
379
380 return {
381 profiles: [...profileSet],
382 profileScopes,
383 scopeIndex,
384 };
385 }
386
387
388 /**
389 * Sets list of additional scopes available to profile.
390 * @param {*} dbCtx
391 * @param {String} profile
392 * @param {String[]} scopes
393 * @returns {Promise<void>}
394 */
395 async profileScopesSetAll(dbCtx, profile, scopes) {
396 this._notImplemented('profileScopesSetAll', arguments);
397 }
398
399
400 /**
401 * Create (or revoke a duplicate) code as a token entry.
402 * @param {*} dbCtx
403 * @param {Object} data
404 * @param {String} data.codeId
405 * @param {Date} data.created
406 * @param {Boolean} data.isToken
407 * @param {String} data.clientId
408 * @param {String} data.profile - profile uri
409 * @param {String} data.identifier
410 * @param {String[]} data.scopes
411 * @param {Number|Null} data.lifespanSeconds - null sets expiration to Infinity
412 * @param {Number|Null} data.refreshLifespanSeconds - null sets refresh to none
413 * @param {String|Null} data.resource
414 * @param {Object|Null} data.profileData - profile data from profile uri
415 * @returns {Promise<Boolean>} whether redemption was successful
416 */
417 async redeemCode(dbCtx, { codeId, created, isToken, clientId, profile, identifier, scopes, lifespanSeconds, refreshLifespanSeconds, profileData } = {}) {
418 this._notImplemented('redeemCode', arguments);
419 }
420
421
422 /**
423 * @typedef {Object} RefreshedToken
424 * @property {Date} expires
425 * @property {Date} refreshExpires
426 * @property {String[]=} scopes if scopes were reduced
427 */
428 /**
429 * Redeem a refresh token to renew token codeId.
430 * @param {*} dbCtx
431 * @param {String} codeId
432 * @param {Date} refreshed
433 * @param {String[]} removeScopes
434 * @returns {Promise<RefreshedToken>}
435 */
436 async refreshCode(dbCtx, codeId, refreshed, removeScopes) {
437 this._notImplemented('refreshCode', arguments);
438 }
439
440
441 /**
442 * Fetch a resource server record.
443 * @param {*} dbCtx
444 * @param {String} identifier uuid
445 * @returns {Promise<Resource>}
446 */
447 async resourceGet(dbCtx, resourceId) {
448 this._notImplemented('resourceGet', arguments);
449 }
450
451
452 /**
453 * Create, or update description of, a resourceId.
454 * @param {*} dbCtx
455 * @param {String=} resourceId uuid
456 * @param {String=} secret
457 * @param {String=} description
458 * @returns {Promise<void>}
459 */
460 async resourceUpsert(dbCtx, resourceId, secret, description) {
461 this._notImplemented('resourceUpsert', arguments);
462 }
463
464
465 /**
466 * Register a scope and its description.
467 * @param {*} dbCtx
468 * @param {String} scope
469 * @param {String} application
470 * @param {String} description
471 * @returns {Promise<void>}
472 */
473 async scopeUpsert(dbCtx, scope, application, description, manuallyAdded = false) {
474 this._notImplemented('scopeUpsert', arguments);
475 }
476
477
478 /**
479 * Remove a non-permanent scope if it is not currently in use.
480 * @param {*} dbCtx
481 * @param {String} scope
482 * @returns {Promise<Boolean>}
483 */
484 async scopeDelete(dbCtx, scope) {
485 this._notImplemented('scopeDelete', arguments);
486 }
487
488
489 /**
490 * @typedef {Number|BigInt} CleanupResult
491 */
492 /**
493 * @typedef {Object} CleanupResult
494 */
495 /**
496 * Remove any non-permanent and non-manually-created scopes not currently in use.
497 * @param {*} dbCtx
498 * @param {Number} atLeastMsSinceLast skip cleanup if already executed this recently
499 * @returns {Promise<CleanupResult>}
500 */
501 async scopeCleanup(dbCtx, atLeastMsSinceLast) {
502 this._notImplemented('scopeClean', arguments);
503 }
504
505
506 /**
507 * Forget tokens after they have expired, and redeemed codes after they have expired.
508 * @param {*} dbCtx
509 * @param {Number} codeLifespanSeconds
510 * @param {Number} atLeastMsSinceLast skip cleanup if already executed this recently
511 * @returns {Promise<CleanupResult>}
512 */
513 async tokenCleanup(dbCtx, codeLifespanSeconds, atLeastMsSinceLast) {
514 this._notImplemented('tokenCleanup', arguments);
515 }
516
517
518 /**
519 * Look up a redeemed token by code_id.
520 * @param {*} dbCtx
521 * @param {String} codeId
522 * @returns {Promise<Token>}
523 */
524 async tokenGetByCodeId(dbCtx, codeId) {
525 this._notImplemented('tokenGetByCodeId', arguments);
526 }
527
528
529 /**
530 * Sets a redeemed token as revoked.
531 * @param {*} dbCtx
532 * @param {String} codeId - uuid
533 * @returns {Promise<void>}
534 */
535 async tokenRevokeByCodeId(dbCtx, codeId) {
536 this._notImplemented('tokenRevokeByCodeId', arguments);
537 }
538
539
540 /**
541 * Revoke the refreshability of a codeId.
542 * @param {*} dbCtx
543 * @param {String} codeId - uuid
544 * @returns {Promise<void>}
545 */
546 async tokenRefreshRevokeByCodeId(dbCtx, codeId) {
547 this._notImplemented('tokenRefreshRevokeByCodeId', arguments);
548 }
549
550
551 /**
552 * Get all tokens assigned to identifier.
553 * @param {*} dbCtx
554 * @param {String} identifier
555 * @returns {Promise<Tokens[]>}
556 */
557 async tokensGetByIdentifier(dbCtx, identifier) {
558 this._notImplemented('tokensGetByIdentifier', arguments);
559 }
560
561 }
562
563 module.exports = Database;