6c16414adc6853cea2e90a9089292689cb11cb5d
[squeep-indie-auther] / src / manager.js
1 'use strict';
2
3 const common = require('./common');
4 const { ResponseError, ValidationError } = require('./errors');
5 const Enum = require('./enum');
6 const { Communication, Errors: CommunicationErrors } = require('@squeep/indieauth-helper');
7 const Template = require('./template');
8 const { MysteryBox } = require('@squeep/mystery-box');
9 const DBErrors = require('./db/errors');
10 const Chores = require('./chores');
11 const { Publisher: QueuePublisher } = require('@squeep/amqp-helper');
12
13 const _fileScope = common.fileScope(__filename);
14
15 // These are used during request ingestion and validation
16 const validBase64URLRE = /^[-A-Za-z0-9_]+$/;
17 const scopeSplitRE = / +/;
18
19 const supportedCodeChallengeMethods = ['S256', 'SHA256'];
20
21 class Manager {
22 constructor(logger, db, options) {
23 this.options = options;
24 this.logger = logger;
25 this.db = db;
26 this.chores = new Chores(logger, db, options);
27 this.communication = new Communication(logger, options);
28 if (options.queues.amqp.url) {
29 this.queuePublisher = new QueuePublisher(logger, options.queues.amqp);
30 }
31 this.mysteryBox = new MysteryBox(logger, options);
32
33 // We need to know how the outside world sees us, to verify if a
34 // profile indicates us as the auth server.
35 // selfBaseUrl should already include proxy prefix and end with a /
36 this.selfAuthorizationEndpoint = options.dingus.selfBaseUrl + options.route.authorization;
37 }
38
39
40 /**
41 * Perform any async startup tasks.
42 */
43 async initialize() {
44 if (this.queuePublisher) {
45 await this._connectQueues();
46 }
47 }
48
49
50 async _connectQueues() {
51 await this.queuePublisher.connect();
52 await this.queuePublisher.establishAMQPPlumbing(this.options.queues.ticketPublishName);
53 }
54
55
56 /**
57 * Add an error to a session, keeping only the most-severe code, but all descriptions.
58 * This error is sent along on the redirection back to client endpoint.
59 * @param {Object} ctx
60 * @param {Object} ctx.session
61 * @param {String[]=} ctx.session.errorDescriptions
62 * @param {String=} ctx.session.error
63 * @param {String} error
64 * @param {String} errorDescription
65 */
66 static _setError(ctx, error, errorDescription) {
67 const errorPrecedence = [ // By increasing severity
68 'invalid_scope',
69 'unsupported_response_type',
70 'access_denied',
71 'unauthorized_client',
72 'invalid_grant',
73 'invalid_request',
74 'temporarily_unavailable',
75 'server_error',
76 ];
77 if (!(errorPrecedence.includes(error))) {
78 throw new RangeError(`invalid error value '${error}'`);
79 }
80 if (!ctx.session.errorDescriptions) {
81 ctx.session.errorDescriptions = [];
82 }
83 if (!common.validError(errorDescription)) {
84 throw new RangeError(`invalid error description '${errorDescription}'`);
85 }
86 const isHigherPrecedence = errorPrecedence.indexOf(error) > errorPrecedence.indexOf(ctx.session.error);
87 if (!ctx.session.error || isHigherPrecedence) {
88 ctx.session.error = error;
89 }
90 if (isHigherPrecedence) {
91 ctx.session.errorDescriptions.unshift(errorDescription);
92 } else {
93 ctx.session.errorDescriptions.push(errorDescription);
94 }
95 }
96
97
98 /**
99 * Discourage caching of a response.
100 * OAuth 2.1 ยง3.2.3
101 * The authorization server MUST include the HTTP Cache-Control response
102 * header field with a value of no-store in any response
103 * containing tokens, credentials, or other sensitive information.
104 * @param {http.ServerResponse} res
105 */
106 static _sensitiveResponse(res) {
107 Object.entries({
108 [Enum.Header.CacheControl]: 'no-store',
109 [Enum.Header.Pragma]: 'no-cache',
110 }).forEach(([k, v]) => res.setHeader(k, v));
111 }
112
113
114 /**
115 * Sets params entries as url search parameters.
116 * @param {URL} url
117 * @param {Object} params
118 */
119 static _setSearchParams(url, params) {
120 Object.entries(params).forEach((param) => url.searchParams.set(...param));
121 }
122
123
124 /**
125 * Serve the informational root page.
126 * @param {http.ClientRequest} req
127 * @param {http.ServerResponse} res
128 * @param {Object} ctx
129 */
130 async getRoot(res, ctx) {
131 const _scope = _fileScope('getRoot');
132 this.logger.debug(_scope, 'called', { ctx });
133
134 res.end(Template.rootHTML(ctx, this.options));
135 this.logger.info(_scope, 'finished', { ctx });
136 }
137
138
139 /**
140 * Serve the metadata for this service.
141 * @param {http.ServerResponse} res
142 * @param {Object} ctx
143 */
144 async getMeta(res, ctx) {
145 const _scope = _fileScope('getMeta');
146 this.logger.debug(_scope, 'called', { ctx });
147
148 const base = this.options.dingus.selfBaseUrl;
149 const endpoint = (r) => `${base}${this.options.route[r]}`; // eslint-disable-line security/detect-object-injection
150
151 const metadata = {
152 issuer: base,
153 'authorization_endpoint': endpoint('authorization'),
154 'token_endpoint': endpoint('token'),
155 ...(this.queuePublisher && { 'ticket_endpoint': endpoint('ticket') }),
156 'introspection_endpoint': endpoint('introspection'),
157 'introspection_endpoint_auth_methods_supported': ['Bearer'],
158 'revocation_endpoint': endpoint('revocation'),
159 'revocation_endpoint_auth_methods_supported': ['none'],
160 'scopes_supported': ['profile', 'email'], // only advertise minimum IA scopes
161 'response_types_supported': 'code',
162 'grant_types_supported': [
163 'authorization_code',
164 'refresh_token',
165 ...(this.queuePublisher && ['ticket'] || []),
166 ],
167 'service_documentation': 'https://indieauth.spec.indieweb.org/',
168 'code_challenge_methods_supported': supportedCodeChallengeMethods,
169 'authorization_response_iss_parameter_supported': true,
170 'userinfo_endpoint': endpoint('userinfo'),
171 };
172
173 res.end(JSON.stringify(metadata));
174 this.logger.info(_scope, 'finished', { ctx });
175 }
176
177
178 /**
179 * Process an authorization request from a client.
180 * User has authenticated, check if user matches profile,
181 * present user with consent form.
182 * @param {http.ServerResponse} res
183 * @param {Object} ctx
184 */
185 async getAuthorization(res, ctx) {
186 const _scope = _fileScope('getAuthorization');
187 this.logger.debug(_scope, 'called', { ctx });
188
189 ctx.session = Object.assign({}, ctx.session, {
190 errorDescriptions: [],
191 });
192
193 // Ingest and validate expected data, populating ctx.session.
194 await this._clientIdRequired(ctx);
195 Manager._redirectURIRequired(ctx);
196 Manager._responseTypeRequired(ctx);
197 Manager._stateRequired(ctx);
198 this._codeChallengeMethodRequired(ctx);
199 this._codeChallengeRequired(ctx);
200 this._scopeOptional(ctx);
201 await this._meOptional(ctx);
202
203 if (!ctx.session.clientIdentifier || !ctx.session.redirectUri) {
204 // Do not redirect if either of these fields were invalid, just report error.
205 this.logger.debug(_scope, 'invalid request, not redirecting', { ctx });
206
207 // Set error response for template to render.
208 ctx.errors.push('Cannot redirect to client application.');
209 ctx.errorContent = [
210 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
211 ];
212 res.statusCode = 400;
213 res.end(Template.authorizationErrorHTML(ctx, this.options));
214 this.logger.info(_scope, 'bad request', { ctx });
215 return;
216 }
217
218 await this.db.context(async (dbCtx) => {
219 const profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, ctx.authenticationId);
220 Object.assign(ctx.session, {
221 profiles: [],
222 profileScopes: {},
223 scopeIndex: {},
224 }, profilesScopes);
225 }); // dbCtx
226
227 if (!ctx.session.profiles.length) {
228 this.logger.error(_scope, 'identifier has no profiles', { ctx });
229 Manager._setError(ctx, 'access_denied', 'Profile not valid for the authenticated user.');
230 }
231
232 if (!this._profileValidForIdentifier(ctx)) {
233 // if the hinted profile supplied in me does not match any known
234 // profile mappings for the authenticated identifier, remove the
235 // hint. UI will prompt to choose from available profiles.
236 this.logger.debug(_scope, 'removing provided me hint, not valid for identifier', { ctx });
237 delete ctx.session.me;
238 }
239
240 // Ugly support logic for allowing legacy non-pkce requests, for the micropub.rocks site until it is updated.
241 // Require both be missing to qualify as a legacy request, otherwise still fail.
242 const isMissingBothPKCE = (!ctx.session.codeChallengeMethod) && (!ctx.session.codeChallenge);
243 if (isMissingBothPKCE && this.options.manager.allowLegacyNonPKCE) {
244 ctx.notifications.push('<div class="legacy-warning">This request was submitted using an unsupported legacy format, which does not include PKCE safeguards! This is a security issue! This request should not be accepted!</div>');
245 } else {
246 if (!ctx.session.codeChallenge) {
247 Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_challenge\'');
248 }
249 if (!ctx.session.codeChallengeMethod) {
250 Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
251 }
252 }
253
254 // If anything went wrong, redirect with error report.
255 if (ctx.session.error) {
256 // Valid redirect_url and client_id, errors hop back to them.
257 this.logger.debug(_scope, 'invalid request, redirecting', { ctx });
258
259 Manager._setSearchParams(ctx.session.redirectUri, {
260 'state': ctx.session.state,
261 'error': ctx.session.error,
262 'error_description': ctx.session.errorDescriptions.join(', '),
263 });
264 res.statusCode = 302; // Found
265 res.setHeader(Enum.Header.Location, ctx.session.redirectUri.href);
266 res.end();
267 this.logger.info(_scope, 'bad request', { ctx });
268 return;
269 }
270
271 // Store the current state of this session, to be forwarded on to consent processing.
272 // This blob will be passed on as a form field in consent response.
273 ctx.session.persist = await this.mysteryBox.pack({
274 id: common.requestId(), // codeId in database
275 clientId: ctx.session.clientId.href,
276 clientIdentifier: ctx.session.clientIdentifier,
277 redirectUri: ctx.session.redirectUri.href,
278 responseType: ctx.session.responseType,
279 state: ctx.session.state,
280 codeChallengeMethod: ctx.session.codeChallengeMethod,
281 codeChallenge: ctx.session.codeChallenge,
282 me: ctx.session.me,
283 profiles: ctx.session.profiles,
284 requestedScopes: ctx.session.scope,
285 authenticationId: ctx.authenticationId,
286 });
287
288 // Present authenticated user the option to submit consent
289 const content = Template.authorizationRequestHTML(ctx, this.options);
290 res.end(content);
291
292 this.logger.info(_scope, 'finished', { ctx });
293 }
294
295
296 /**
297 * Validates, fetches, and parses client_id url, populating clientIdentifier with client h-app data.
298 * @param {Object} ctx
299 */
300 async _clientIdRequired(ctx) {
301 if (ctx.queryParams['client_id']) {
302 try {
303 ctx.session.clientId = await this.communication.validateClientIdentifier(ctx.queryParams['client_id']);
304 ctx.session.clientIdentifier = await this.communication.fetchClientIdentifier(ctx.session.clientId);
305 if (!ctx.session.clientIdentifier) {
306 Manager._setError(ctx, 'invalid_request', 'invalid client_id: could not fetch');
307 throw new ValidationError('could not fetch');
308 }
309 } catch (e) {
310 ctx.session.clientId = undefined;
311 if (e instanceof CommunicationErrors.ValidationError) {
312 Manager._setError(ctx, 'invalid_request', e.message);
313 }
314 Manager._setError(ctx, 'invalid_request', 'invalid value for parameter \'client_id\'');
315 }
316 } else {
317 Manager._setError(ctx, 'invalid_request', 'missing required parameter \'client_id\'');
318 }
319 }
320
321
322 /**
323 * Ensure redirect_uri exists and is corroborated by clientIdentifier data.
324 * @param {Object} ctx
325 */
326 static _redirectURIRequired(ctx) {
327 if (ctx.queryParams['redirect_uri']) {
328 try {
329 ctx.session.redirectUri = new URL(ctx.queryParams['redirect_uri']);
330
331 if (ctx.session.clientId) {
332 // Either all these parts must match, or a specific alternative must be specified.
333 const redirectMatchesClientId = ['protocol', 'hostname', 'port']
334 .map((p) => ctx.session.redirectUri[p] == ctx.session.clientId[p]) // eslint-disable-line security/detect-object-injection
335 .reduce((acc, match) => acc && match, true);
336
337 // Check for alternate redirect_uri entries on client_id data if no initial match
338 if (!redirectMatchesClientId) {
339 const validRedirectUris = ctx.session?.clientIdentifier?.['rels']?.['redirect_uri'] || [];
340 if (!validRedirectUris.includes(ctx.session.redirectUri.href)) {
341 Manager._setError(ctx, 'invalid_request', 'redirect_uri not valid for that client_id');
342 // Remove invalid redirect_uri from session; doing this eases error routing.
343 ctx.session.redirectUri = undefined;
344 }
345 }
346 }
347 } catch (e) {
348 Manager._setError(ctx, 'invalid_request', 'invalid value for parameter \'redirect_uri\'');
349 }
350 } else {
351 Manager._setError(ctx, 'invalid_request', 'missing required parameter \'redirect_uri\'');
352 }
353 }
354
355
356 /**
357 * response_type must be valid
358 * @param {Object} ctx
359 */
360 static _responseTypeRequired(ctx) {
361 ctx.session.responseType = ctx.queryParams['response_type'];
362 if (ctx.session.responseType) {
363 // Must be one of these types
364 if (!['code'].includes(ctx.session.responseType)) {
365 Manager._setError(ctx, 'unsupported_response_type', 'invalid value for parameter \'response_type\'');
366 }
367 } else {
368 Manager._setError(ctx, 'invalid_request', 'missing required parameter \'response_type\'');
369 }
370 }
371
372
373 /**
374 * A state parameter must be present
375 * @param {Object} ctx
376 */
377 static _stateRequired(ctx) {
378 ctx.session.state = ctx.queryParams['state'];
379 if (ctx.session.state) {
380 // No restrictions on content of this
381 } else {
382 Manager._setError(ctx, 'invalid_request', 'missing required parameter \'state\'');
383 }
384 }
385
386
387 /**
388 * A code_challenge_method must be present and valid
389 * @param {Object} ctx
390 */
391 _codeChallengeMethodRequired(ctx) {
392 ctx.session.codeChallengeMethod = ctx.queryParams['code_challenge_method'];
393 if (ctx.session.codeChallengeMethod) {
394 if (!supportedCodeChallengeMethods.includes(ctx.session.codeChallengeMethod)) {
395 Manager._setError(ctx, 'invalid_request', 'unsupported code_challenge_method');
396 }
397 } else {
398 if (this.options.manager.allowLegacyNonPKCE) {
399 return;
400 }
401 Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
402 }
403 }
404
405
406 /**
407 * A code_challenge must be present
408 * @param {Object} ctx
409 */
410 _codeChallengeRequired(ctx) {
411 ctx.session.codeChallenge = ctx.queryParams['code_challenge'];
412 if (ctx.session.codeChallenge) {
413 if (!validBase64URLRE.test(ctx.session.codeChallenge)) {
414 Manager._setError(ctx, 'invalid_request', 'invalid value for parameter \'code_challenge\'');
415 }
416 } else {
417 if (this.options.manager.allowLegacyNonPKCE) {
418 return;
419 }
420 Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_challenge\'');
421 }
422 }
423
424
425 /**
426 * Scopes may be present, with one known combination limitation
427 * @param {Object} ctx
428 */
429 _scopeOptional(ctx) {
430 const _scope = _fileScope('_scopeOptional');
431 const scope = ctx.queryParams['scope'];
432 ctx.session.scope = [];
433 if (scope) {
434 const allScopes = scope.split(scopeSplitRE);
435 const validScopes = allScopes.filter((s) => common.validScope(s));
436 ctx.session.scope.push(...validScopes);
437 if (allScopes.length != validScopes.length) {
438 const invalidScopes = allScopes.filter((s) => !common.validScope(s));
439 this.logger.debug(_scope, 'client requested invalid scope', { ctx, invalidScopes });
440 }
441 }
442 // If email scope is requested, profile scope must also be explicitly requested.
443 if (ctx.session.scope.includes('email')
444 && !ctx.session.scope.includes('profile')) {
445 Manager._setError(ctx, 'invalid_scope', 'cannot provide \'email\' scope without \'profile\' scope');
446 }
447 }
448
449
450 /**
451 * Parses me, if provided
452 * @param {Object} ctx
453 */
454 async _meOptional(ctx) {
455 const me = ctx.queryParams['me'];
456 if (me) {
457 try {
458 ctx.session.me = await this.communication.validateProfile(me);
459 } catch (e) {
460 ctx.session.me = undefined;
461 }
462 }
463 }
464
465
466 /**
467 * Ensure authenticated identifier matches profile.
468 * @param {Object} ctx
469 * @returns {Boolean}
470 */
471 _profileValidForIdentifier(ctx) {
472 const _scope = _fileScope('_profileValidForIdentifier');
473
474 if (!ctx.session.me) {
475 this.logger.debug(_scope, 'no profile provided, cannot correlate', { ctx });
476 return false;
477 }
478
479 return ctx.session.profiles.includes(ctx.session.me.href);
480 }
481
482
483 /**
484 * Get numeric value from form field data.
485 * @param {*} ctx
486 * @param {String} field
487 * @param {String} customField
488 * @returns {Number=}
489 */
490 _parseLifespan(ctx, field, customField) {
491 const _scope = _fileScope('_parseLifespan');
492
493 const presetValues = {
494 'never': undefined,
495 '1d': 86400,
496 '1w': 86400 * 7,
497 '1m': 86400 * 31,
498 };
499 const fieldValue = ctx.parsedBody[field]; // eslint-disable-line security/detect-object-injection
500 if (fieldValue in presetValues) {
501 return presetValues[fieldValue]; // eslint-disable-line security/detect-object-injection
502 }
503
504 if (fieldValue === 'custom') {
505 const expiresSeconds = parseInt(ctx.parsedBody[customField], 10); // eslint-disable-line security/detect-object-injection
506 if (isFinite(expiresSeconds) && expiresSeconds > 0) {
507 return expiresSeconds;
508 } else {
509 this.logger.debug(_scope, 'invalid custom value', { ctx, field, customField });
510 }
511 }
512
513 this.logger.debug(_scope, 'invalid value', { ctx, field, customField });
514 return undefined;
515 }
516
517
518 /**
519 * Validate any accepted scopes, ensure uniqueness, return as array.
520 * @param {Object} ctx
521 * @returns {String=}
522 */
523 _parseConsentScopes(ctx) {
524 const _scope = _fileScope('_ingestConsentScopes');
525 const acceptedScopesSet = new Set();
526 const rejectedScopesSet = new Set();
527
528 const submittedScopes = common.ensureArray(ctx.parsedBody['accepted_scopes'])
529 .concat((ctx.parsedBody['ad_hoc_scopes'] || '').split(scopeSplitRE));
530 submittedScopes.forEach((scope) => {
531 if (scope) {
532 (common.validScope(scope) ? acceptedScopesSet : rejectedScopesSet).add(scope);
533 }
534 });
535
536 // If email scope was accepted but profile was not, elide email scope
537 if (acceptedScopesSet.has('email')
538 && !acceptedScopesSet.has('profile')) {
539 acceptedScopesSet.delete('email');
540 rejectedScopesSet.add('email (without profile)');
541 }
542
543 if (rejectedScopesSet.size) {
544 this.logger.debug(_scope, 'ignoring invalid scopes', { ctx, rejectedScopes: Array.from(rejectedScopesSet) });
545 }
546
547 return Array.from(acceptedScopesSet);
548 }
549
550
551 /**
552 * Parse and validate selected me is a valid profile option.
553 * @param {Object} ctx
554 * @returns {URL}
555 */
556 _parseConsentMe(ctx) {
557 const _scope = _fileScope('_parseConsentMe');
558 const selectedMe = ctx.parsedBody['me'];
559 try {
560 const me = new URL(selectedMe);
561 if (ctx.session.profiles.includes(me.href)) {
562 return me;
563 } else {
564 this.logger.debug(_scope, 'selected \'me\' profile not among available', { me, available: ctx.session.profiles, ctx });
565 Manager._setError(ctx, 'invalid_request', 'invalid profile url');
566 }
567 } catch (e) {
568 this.logger.debug(_scope, 'failed to parse selected \'me\' as url', { error: e, ctx });
569 Manager._setError(ctx, 'invalid_request', 'invalid profile url');
570 }
571 return undefined;
572 }
573
574
575 /**
576 * Get up-to-date profile data from selected profile endpoint.
577 * @param {Object} ctx
578 * @returns {Object}
579 */
580 async _fetchConsentProfileData(ctx) {
581 const _scope = _fileScope('_fetchConsentProfileData');
582 try {
583 const profile = await this.communication.fetchProfile(ctx.session.me);
584 if (!profile) {
585 this.logger.debug(_scope, 'no profile data at \'me\' endpoint', { ctx });
586 Manager._setError(ctx, 'temporarily_unavailable', 'unable to retrieve profile');
587 } else {
588 // Profile info gets persisted in code, only include known profile fields to help keep size down.
589 return common.pick(profile, [
590 'name',
591 'photo',
592 'url',
593 'email',
594 ]);
595 }
596 } catch (e) {
597 this.logger.debug(_scope, 'failed to fetch \'me\' endpoint', { error: e, ctx });
598 Manager._setError(ctx, 'temporarily_unavailable', 'could not reach profile endpoint');
599 }
600 return undefined;
601 }
602
603
604 /**
605 * Ingest user consent response details, redirect as needed.
606 * Receives POST request from consent page, expecting these form fields:
607 * session - encrypted data collected from initial auth call
608 * accept - 'true' if consent was granted
609 * accepted_scopes - list of scopes to grant
610 * ad_hoc_scopes - additional scopes specified by user
611 * me - selected profile to identify as
612 * expires - optional lifespan
613 * expires-seconds - optional custom lifespan
614 * refresh - optional refresh lifespan
615 * refresh-seconds - optional custom refresh lifespan
616 * @param {http.ServerResponse} res
617 * @param {Object} ctx
618 */
619 async postConsent(res, ctx) {
620 const _scope = _fileScope('postConsent');
621 this.logger.debug(_scope, 'called', { ctx });
622
623 // Ensure session exists, persisting any login session data.
624 ctx.session = Object.assign({}, ctx.session);
625 try {
626 // Recover the session established on initial auth request.
627 const oldSession = await this.mysteryBox.unpack(ctx.parsedBody['session']);
628 Object.assign(ctx.session, oldSession);
629 ctx.session.redirectUri = new URL(ctx.session.redirectUri);
630 ctx.session.clientId = new URL(ctx.session.clientId);
631 } catch (e) {
632 this.logger.debug(_scope, 'failed to unpack session', { error: e, ctx });
633 Manager._setError(ctx, 'invalid_request', 'un-parsable data in authorization consent');
634 }
635
636 // If these are missing, we cannot proceed.
637 if (!ctx.session.clientId || !ctx.session.redirectUri) {
638 // Set error response for html template to render.
639 ctx.errors = [
640 'Cannot redirect to client application.',
641 ];
642 ctx.errorContent = [
643 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
644 ];
645 res.statusCode = 400;
646 res.end(Template.authorizationErrorHTML(ctx, this.options));
647 this.logger.info(_scope, 'bad request, cannot redirect', { ctx });
648 return;
649 }
650
651 // TODO: Should probably re-validate more unpacked session values, even though those should be trustable.
652
653 // Check if we need to check anything else.
654 ctx.session.accept = (ctx.parsedBody['accept'] === 'true');
655 if (!ctx.session.accept) {
656 this.logger.debug(_scope, 'consent denied', { ctx });
657 Manager._setError(ctx, 'access_denied', 'authorization was not granted');
658 } else {
659 // Ingest form data.
660 ctx.session.acceptedScopes = this._parseConsentScopes(ctx);
661 ctx.session.me = this._parseConsentMe(ctx);
662 ctx.session.profile = await this._fetchConsentProfileData(ctx);
663 ctx.session.tokenLifespan = this._parseLifespan(ctx, 'expires', 'expires-seconds');
664 if (ctx.session.tokenLifespan) {
665 ctx.session.refreshLifespan = this._parseLifespan(ctx, 'refresh', 'refresh-seconds');
666 }
667 }
668
669 if (ctx.session.error) {
670 this.logger.debug(_scope, 'invalid request, redirecting', { ctx });
671
672 // Set all errors as parameters for client to interpret upon redirection.
673 Manager._setSearchParams(ctx.session.redirectUri, {
674 'state': ctx.session.state,
675 'error': ctx.session.error,
676 'error_description': ctx.session.errorDescriptions.join(', '),
677 });
678 res.statusCode = 302; // Found
679 res.setHeader(Enum.Header.Location, ctx.session.redirectUri.href);
680 res.end();
681 this.logger.info(_scope, 'bad request, redirected', { ctx });
682 return;
683 }
684
685 // Consented, off we go. Keep all this session state as the code.
686 const code = await this.mysteryBox.pack({
687 codeId: ctx.session.id,
688 codeChallengeMethod: ctx.session.codeChallengeMethod,
689 codeChallenge: ctx.session.codeChallenge,
690 clientId: ctx.session.clientId.href,
691 redirectUri: ctx.session.redirectUri.href,
692 acceptedScopes: ctx.session.acceptedScopes,
693 tokenLifespan: ctx.session.tokenLifespan,
694 refreshLifespan: ctx.session.refreshLifespan,
695 me: ctx.session.me.href,
696 profile: ctx.session.profile,
697 identifier: ctx.session.authenticatedIdentifier, // need this to pair with profile
698 minted: Date.now(),
699 });
700
701 Manager._setSearchParams(ctx.session.redirectUri, {
702 'code': code,
703 'state': ctx.session.state,
704 'iss': this.options.dingus.selfBaseUrl,
705 });
706 res.statusCode = 302;
707 res.setHeader(Enum.Header.Location, ctx.session.redirectUri.href);
708 res.end();
709
710 this.logger.info(_scope, 'finished', { ctx });
711 }
712
713
714 /**
715 * Redeem a code for a profile url, and maybe more profile info.
716 * @param {http.ServerResponse} res
717 * @param {Object} ctx
718 */
719 async postAuthorization(res, ctx) {
720 const _scope = _fileScope('postAuthorization');
721 this.logger.debug(_scope, 'called', { ctx });
722
723 await this._ingestPostAuthorizationRequest(ctx);
724
725 const response = {
726 me: ctx.session.me,
727 ...(ctx.session?.acceptedScopes?.includes('profile') && { profile: ctx.session.profile }),
728 scope: ctx.session.acceptedScopes,
729 };
730 if (response.profile && !ctx.session?.acceptedScopes?.includes('email')) {
731 delete response.profile.email;
732 }
733
734 if (!ctx.session.error) {
735 await this.db.context(async (dbCtx) => {
736 // Record code redemption without token.
737 const valid = await this.db.redeemCode(dbCtx, {
738 codeId: ctx.session.codeId,
739 created: new Date(),
740 isToken: false,
741 clientId: ctx.session.clientId.href,
742 profile: ctx.session.me,
743 identifier: ctx.session.identifier,
744 scopes: ctx.session.acceptedScopes,
745 lifespanSeconds: Math.ceil(this.options.manager.codeValidityTimeoutMs / 1000),
746 profileData: response.profile,
747 });
748 if (!valid) {
749 this.logger.debug(_scope, 'code already redeemed', { ctx });
750 Manager._setError(ctx, 'access_denied', 'code already redeemed');
751 }
752 }); // dbCtx
753 }
754
755 if (ctx.session.error) {
756 res.statusCode = 400;
757 res.end(JSON.stringify({
758 'error': ctx.session.error,
759 'error_description': ctx.session.errorDescriptions.join(', '),
760 }));
761 this.logger.info(_scope, 'invalid request', { ctx });
762 return;
763 }
764
765 res.end(JSON.stringify(response));
766
767 this.logger.info(_scope, 'finished', { ctx, response });
768 }
769
770
771 /**
772 * Ingest an incoming authorization redemption request, parsing fields
773 * onto a new session object on the context.
774 * @param {*} dbCtx
775 * @param {Object} ctx
776 */
777 async _ingestPostAuthorizationRequest(ctx) {
778 const _scope = _fileScope('_ingestPostAuthorizationRequest');
779
780 ctx.session = Object.assign({}, ctx.session, {
781 errorDescriptions: [],
782 });
783
784 if (!ctx.parsedBody) {
785 this.logger.debug(_scope, 'no body data', { ctx });
786 Manager._setError(ctx, 'invalid_request', 'missing data');
787 }
788
789 await this._restoreSessionFromCode(ctx);
790 this._checkSessionMatchingClientId(ctx);
791 this._checkSessionMatchingRedirectUri(ctx);
792 this._checkGrantType(ctx);
793 this._checkSessionMatchingCodeVerifier(ctx);
794
795 if (!ctx.session.me || !ctx.session.minted) {
796 this.logger.debug(_scope, 'session missing fields', { ctx });
797 Manager._setError(ctx, 'invalid_request', 'malformed code');
798 return;
799 }
800
801 const expires = new Date(ctx.session.minted + this.options.manager.codeValidityTimeoutMs);
802 const now = new Date();
803 if (expires < now) {
804 this.logger.debug(_scope, 'code expired', { ctx });
805 Manager._setError(ctx, 'invalid_request', 'code has expired');
806 }
807 }
808
809
810 /**
811 * Unpack the session data from provided code overtop of context session ..
812 * @param {Object} ctx
813 */
814 async _restoreSessionFromCode(ctx) {
815 const _scope = _fileScope('_restoreSessionFromCode');
816
817 const code = ctx.parsedBody['code'];
818 if (code) {
819 try {
820 const oldSession = await this.mysteryBox.unpack(code);
821
822 // TODO: Validate unpacked fields better
823 const missingFields = [
824 'codeId',
825 'codeChallengeMethod',
826 'codeChallenge',
827 'clientId',
828 'redirectUri',
829 'acceptedScopes',
830 'me',
831 'profile',
832 'identifier',
833 'minted',
834 ].filter((requiredField) => !(requiredField in oldSession));
835 if (missingFields.length) {
836 if (this.options.manager.allowLegacyNonPKCE
837 && missingFields.length === 2
838 && missingFields.includes('codeChallenge')
839 && missingFields.includes('codeChallengeMethod')) {
840 this.logger.debug(_scope, 'allowing legacy non-PKCE session', { ctx });
841 } else {
842 this.logger.debug(_scope, 'unpacked code is missing required field', { missingFields, ctx });
843 Manager._setError(ctx, 'invalid_request', 'code is not valid');
844 }
845 }
846
847 Object.assign(ctx.session, oldSession);
848 } catch (e) {
849 this.logger.debug(_scope, 'failed to parse code', { error: e, ctx });
850 Manager._setError(ctx, 'invalid_request', 'code is not valid');
851 }
852 } else {
853 Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code\'');
854 }
855 }
856
857
858 /**
859 * Ensure provided client_id matches session clientId.
860 * @param {Object} ctx
861 */
862 _checkSessionMatchingClientId(ctx) {
863 const _scope = _fileScope('_checkSessionMatchingClientId');
864
865 let clientId = ctx.parsedBody['client_id'];
866 if (clientId) {
867 try {
868 clientId = new URL(clientId);
869 ctx.session.clientId = new URL(ctx.session.clientId);
870 } catch (e) {
871 this.logger.debug(_scope, 'un-parsable client_id url', { ctx });
872 delete ctx.session.clientId;
873 Manager._setError(ctx, 'invalid_request', 'malformed client_id');
874 return;
875 }
876 if (clientId.href !== ctx.session.clientId.href) {
877 this.logger.debug(_scope, 'clientId mismatched', { clientId, ctx });
878 delete ctx.session.clientId;
879 Manager._setError(ctx, 'invalid_request', 'code does not belong to that client_id');
880 }
881 } else {
882 Manager._setError(ctx, 'invalid_request', 'missing required parameter \'client_id\'');
883 }
884 }
885
886
887 /**
888 * @param {Object} ctx
889 */
890 _checkSessionMatchingRedirectUri(ctx) {
891 const _scope = _fileScope('_checkSessionMatchingClientId');
892
893 let redirectUri = ctx.parsedBody['redirect_uri'];
894 if (redirectUri) {
895 try {
896 redirectUri = new URL(redirectUri);
897 ctx.session.redirectUri = new URL(ctx.session.redirectUri);
898 } catch (e) {
899 this.logger.debug(_scope, 'un-parsable redirect_uri url', { ctx });
900 delete ctx.session.redirectUri;
901 Manager._setError(ctx, 'invalid_request', 'malformed redirect_url');
902 return;
903 }
904 if (redirectUri.href !== ctx.session.redirectUri.href) {
905 this.logger.debug(_scope, 'redirectUri mismatched', { redirectUri, ctx });
906 delete ctx.session.redirectUri;
907 Manager._setError(ctx, 'invalid_request', 'code does not belong to that redirect_uri');
908 }
909 } else {
910 Manager._setError(ctx, 'invalid_request', 'missing required parameter \'redirect_uri\'');
911 }
912 }
913
914
915 /**
916 * Validate grant_type, either persist on session or set error.
917 * @param {Object} ctx
918 * @param {String[]} validGrantTypes
919 * @param {Boolean} treatEmptyAs
920 */
921 _checkGrantType(ctx, validGrantTypes = ['authorization_code'], treatEmptyAs = 'authorization_code') {
922 const _scope = _fileScope('_checkGrantType');
923
924 const grantType = ctx.parsedBody['grant_type'] || treatEmptyAs;
925 if (!ctx.parsedBody['grant_type'] && treatEmptyAs) {
926 this.logger.debug(_scope, `missing grant_type, treating as ${treatEmptyAs}`, { ctx });
927 }
928 if (validGrantTypes.includes(grantType)) {
929 ctx.session.grantType = grantType;
930 } else {
931 Manager._setError(ctx, 'invalid_request', 'grant_type not supported');
932 }
933 }
934
935
936 /**
937 * @param {Object} ctx
938 */
939 _checkSessionMatchingCodeVerifier(ctx) {
940 const _scope = _fileScope('_checkSessionMatchingCodeVerifier');
941
942 const codeVerifier = ctx.parsedBody['code_verifier'];
943 if (codeVerifier) {
944 try {
945 const valid = Communication.verifyChallenge(ctx.session.codeChallenge, codeVerifier, ctx.session.codeChallengeMethod);
946 if (!valid) {
947 this.logger.debug(_scope, 'challenge mismatched', { ctx });
948 Manager._setError(ctx, 'invalid_request', 'challenge verification failed');
949 }
950 } catch (e) /* istanbul ignore next */ {
951 this.logger.error(_scope, 'challenge validation failed', { error: e, ctx });
952 Manager._setError(ctx, 'invalid_request', 'challenge verification failed');
953 }
954 } else {
955 if (this.options.manager.allowLegacyNonPKCE
956 && !ctx.session.codeChallenge
957 && !ctx.session.codeChallengeMethod) {
958 this.logger.debug(_scope, 'allowing non-PKCE', { ctx });
959 return;
960 }
961 Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_verifier\'');
962 }
963 }
964
965
966 /**
967 * Attempt to revoke a token.
968 * @param {*} dbCtx
969 * @param {http.ServerResponse} res
970 * @param {Object} ctx
971 */
972 async _revokeToken(dbCtx, res, ctx) {
973 const _scope = _fileScope('_revokeToken');
974 try {
975 const token = ctx.parsedBody['token'];
976 const tokenTypeHint = ctx.parsedBody['token_type_hint'];
977 switch (tokenTypeHint) {
978 case undefined:
979 break;
980 case 'access_token':
981 break;
982 case 'refresh_token':
983 break;
984 default:
985 this.logger.debug(_scope, 'unknown token_type_hint', { ctx });
986 }
987 if (!token) {
988 throw new ValidationError('Token Missing');
989 }
990 ctx.token = await this.mysteryBox.unpack(token);
991 if (!(ctx.token?.c || ctx.token?.rc)) {
992 throw new ValidationError('Token Invalid');
993 }
994 } catch (e) {
995 this.logger.debug(_scope, 'invalid token', { error: e, ctx });
996 res.statusCode = 400;
997 res.end();
998 this.logger.info(_scope, 'finished, revoke request not valid', { error: e, ctx });
999 return;
1000 }
1001
1002 try {
1003 if (ctx.token.c) {
1004 await this.db.tokenRevokeByCodeId(dbCtx, ctx.token.c);
1005 } else {
1006 await this.db.tokenRefreshRevokeByCodeId(dbCtx, ctx.token.rc);
1007 }
1008 } catch (e) {
1009 if (e instanceof DBErrors.UnexpectedResult) {
1010 res.statusCode = 404;
1011 res.end();
1012 this.logger.info(_scope, 'finished, no token to revoke', { error: e, ctx });
1013 return;
1014 }
1015 this.logger.error(_scope, 'revoke token failed', { error: e, ctx });
1016 throw e;
1017 }
1018
1019 res.end();
1020 this.logger.info(_scope, 'finished, token revoked', { ctx });
1021 }
1022
1023
1024 /**
1025 * Legacy token validation flow.
1026 * @param {*} dbCtx
1027 * @param {http.ClientRequest} req
1028 * @param {http.ServerResponse} res
1029 * @param {Object} ctx
1030 */
1031 async _validateToken(dbCtx, req, res, ctx) {
1032 const _scope = _fileScope('_validateToken');
1033 await this._checkTokenValidationRequest(dbCtx, req, ctx);
1034 if (ctx.bearer.isValid) {
1035 Manager._sensitiveResponse(res);
1036 res.end(JSON.stringify({
1037 me: ctx.token.profile,
1038 'client_id': ctx.token.clientId,
1039 scope: ctx.token.scopes,
1040 }));
1041 this.logger.info(_scope, 'finished, token validated', { ctx });
1042 } else {
1043 const responseErrorParts = ['Bearer'];
1044 const error = ctx.session.error ? `error="${ctx.session.error}"` : '';
1045 if (error) {
1046 responseErrorParts.push(error);
1047 }
1048 const errorDescription = ctx.session.errorDescriptions ? `error_description="${ctx.session.errorDescriptions.join(', ')}"` : '';
1049 if (errorDescription) {
1050 responseErrorParts.push(errorDescription);
1051 }
1052 res.setHeader(Enum.Header.WWWAuthenticate, responseErrorParts.join(', '));
1053 this.logger.info(_scope, 'finished, token not validated', { ctx });
1054 throw new ResponseError(Enum.ErrorResponse.Unauthorized);
1055 }
1056 }
1057
1058
1059 /**
1060 * Given a list of newly-requested scopes, return a list of scopes
1061 * from previousScopes which are not in requestedScopes.
1062 * @param {String[]} previousScopes
1063 * @param {String[]} requestedScopes
1064 * @returns {String[]}
1065 */
1066 static _scopeDifference(previousScopes, requestedScopes) {
1067 const scopesToRemove = [];
1068 const existingScopesSet = new Set(previousScopes);
1069 const validRequestedScopes = requestedScopes.filter((s) => common.validScope(s));
1070 const requestedScopesSet = new Set(validRequestedScopes);
1071 existingScopesSet.forEach((s) => {
1072 if (!requestedScopesSet.has(s)) {
1073 scopesToRemove.push(s);
1074 }
1075 });
1076 return scopesToRemove;
1077 }
1078
1079
1080 /**
1081 * Redeem a refresh token for a new token.
1082 * @param {*} dbCtx
1083 * @param {http.ClientRequest} req
1084 * @param {http.ServerResponse} res
1085 * @param {Object} ctx
1086 */
1087 async _refreshToken(dbCtx, req, res, ctx) {
1088 const _scope = _fileScope('_refreshToken');
1089 this.logger.debug(_scope, 'called', { ctx });
1090
1091 const {
1092 'client_id': clientId,
1093 scope,
1094 } = ctx.parsedBody;
1095
1096 try {
1097 ctx.refreshToken = await this.mysteryBox.unpack(ctx.parsedBody['refresh_token']);
1098 } catch (e) {
1099 this.logger.debug(_scope, 'failed to unpack token', { error: e, ctx });
1100 }
1101
1102 const now = new Date();
1103 const nowEpoch = common.dateToEpoch(now);
1104
1105 await this.db.transaction(dbCtx, async (txCtx) => {
1106 if (ctx.refreshToken?.rc) {
1107 ctx.token = await this.db.tokenGetByCodeId(txCtx, ctx.refreshToken.rc);
1108 }
1109
1110 if (!ctx.token) {
1111 this.logger.debug(_scope, 'no token to refresh', { ctx });
1112 throw new ResponseError(Enum.ErrorResponse.NotFound);
1113 }
1114
1115 if (!ctx.token.refreshExpires
1116 || ctx.token.refreshExpires < now) {
1117 this.logger.debug(_scope, 'token not refreshable or refresh expired', { ctx });
1118 throw new ResponseError(Enum.ErrorResponse.BadRequest);
1119 }
1120
1121 const refreshExpiresEpoch = common.dateToEpoch(ctx.token.refreshExpires);
1122 if (ctx.refreshToken.exp < refreshExpiresEpoch) {
1123 this.logger.debug(_scope, 'token already refreshed', { ctx });
1124 throw new ResponseError(Enum.ErrorResponse.BadRequest);
1125 }
1126
1127 if (clientId !== ctx.token.clientId) {
1128 this.logger.debug(_scope, 'client identifier mismatch', { ctx });
1129 throw new ResponseError(Enum.ErrorResponse.BadRequest);
1130 }
1131
1132 const scopesToRemove = scope ? Manager._scopeDifference(ctx.token.scopes, scope.split(scopeSplitRE)) : [];
1133 if (scopesToRemove.length) {
1134 this.logger.debug(_scope, 'scope reduction requested', { ctx, scopesToRemove });
1135 }
1136
1137 const refreshedTokenData = await this.db.refreshCode(txCtx, ctx.refreshToken.rc, now, scopesToRemove);
1138 if (refreshedTokenData) {
1139 Object.assign(ctx.token, refreshedTokenData);
1140 } else {
1141 this.logger.debug(_scope, 'could not refresh token', { ctx });
1142 throw new ResponseError(Enum.ErrorResponse.NotFound);
1143 }
1144 }); // tx
1145
1146 const [token, refreshToken] = await Promise.all([
1147 {
1148 c: ctx.token.codeId,
1149 ts: nowEpoch,
1150 },
1151 {
1152 rc: ctx.token.codeId,
1153 ts: nowEpoch,
1154 exp: nowEpoch + ctx.token.refreshDuration,
1155 },
1156 ].map(this.mysteryBox.pack));
1157
1158 const response = {
1159 'access_token': token,
1160 'token_type': 'Bearer',
1161 ...(ctx.token.duration && { 'expires_in': nowEpoch + ctx.token.duration }),
1162 ...(refreshToken && { 'refresh_token': refreshToken }),
1163 scope: ctx.token.scopes.join(' '),
1164 me: ctx.session.me,
1165 ...(ctx.token.scopes.includes('profile') && { profile: ctx.token.profileData }),
1166 };
1167 if (ctx.token.scopes.includes('profile') && !ctx.token.scopes.includes('email')) {
1168 delete response?.profile?.email;
1169 }
1170
1171 Manager._sensitiveResponse(res);
1172 res.end(JSON.stringify(response));
1173 this.logger.info(_scope, 'finished', { ctx });
1174 }
1175
1176
1177 /**
1178 * Generate a new ticket for later redemption.
1179 * @param {Object} payload
1180 * @param {} payload.subject deliver ticket to this endpoint
1181 * @param {} payload.resource url the redeemed ticket is valid for accessing
1182 * @param {String[]} payload.scopes list of scopes assigned to ticket
1183 * @param {String} payload.identifier user generating ticket
1184 * @param {} payload.profile profile of user generating ticket
1185 * @param {Number} payload.ticketLifespanSeconds ticket redeemable for this long
1186 * @returns {String}
1187 */
1188 async _mintTicket({ subject, resource, scopes, identifier, profile, ticketLifespanSeconds }) {
1189 const _scope = _fileScope('_mintTicket');
1190 this.logger.debug(_scope, 'called', { subject, resource, scopes, identifier, profile, ticketLifespanSeconds });
1191
1192 const nowEpoch = common.dateToEpoch();
1193 return this.mysteryBox.pack({
1194 c: common.requestId(),
1195 iss: nowEpoch,
1196 exp: nowEpoch + ticketLifespanSeconds,
1197 sub: subject,
1198 res: resource,
1199 scope: scopes,
1200 ident: identifier,
1201 profile: profile,
1202 });
1203 }
1204
1205
1206 /**
1207 * @typedef Ticket
1208 * @property {String} codeId
1209 * @property {Date} issued
1210 * @property {Date} expires
1211 * @property {URL} subject
1212 * @property {URL} resource
1213 * @property {String[]} scopes
1214 * @property {String} identifier
1215 * @property {URL} profile
1216 */
1217 /**
1218 *
1219 * @param {String} ticket
1220 * @returns {Ticket}
1221 */
1222 async _unpackTicket(ticket) {
1223 const ticketObj = await this.mysteryBox.unpack(ticket);
1224 return {
1225 codeId: ticketObj.c,
1226 issued: new Date(ticketObj.iss * 1000),
1227 expires: new Date(ticketObj.exp * 1000),
1228 subject: new URL(ticketObj.sub),
1229 resource: new URL(ticketObj.res),
1230 scopes: ticketObj.scope,
1231 identifier: ticketObj.ident,
1232 profile: new URL(ticketObj.profile),
1233 };
1234 }
1235
1236
1237 /**
1238 * Redeem a ticket for a token.
1239 * @param {*} dbCtx
1240 * @param {http.ClientRequest} req
1241 * @param {http.ServerResponse} res
1242 * @param {Object} ctx
1243 */
1244 async _ticketAuthToken(dbCtx, req, res, ctx) {
1245 const _scope = _fileScope('_ticketAuthToken');
1246 this.logger.debug(_scope, 'called', { ctx });
1247
1248 try {
1249 ctx.ticket = await this._unpackTicket(ctx.parsedBody['ticket']);
1250 } catch (e) {
1251 this.logger.debug(_scope, 'failed to unpack ticket', { error: e, ctx });
1252 throw new ResponseError(Enum.ErrorResponse.BadRequest);
1253 }
1254
1255 const now = new Date();
1256 if (now > ctx.ticket.expires) {
1257 this.logger.debug(_scope, 'ticket has expired', { ctx });
1258 throw new ResponseError(Enum.ErrorResponse.Forbidden, { reason: 'Ticket has expired.', expired: ctx.ticket.expires });
1259 }
1260
1261 const nowEpoch = common.dateToEpoch(now);
1262 const token = await this.mysteryBox.pack({
1263 c: ctx.ticket.codeId,
1264 ts: nowEpoch,
1265 });
1266
1267 const response = {
1268 'access_token': token,
1269 'token_type': 'Bearer',
1270 scope: ctx.ticket.scopes.join(' '),
1271 me: ctx.ticket.profile.href,
1272 };
1273
1274 const isValid = await this.db.redeemCode(dbCtx, {
1275 created: now,
1276 codeId: ctx.ticket.codeId,
1277 isToken: true,
1278 clientId: ctx.ticket.subject.href,
1279 resource: ctx.ticket.resource.href,
1280 profile: ctx.ticket.profile.href,
1281 identifier: ctx.ticket.identifier,
1282 scopes: ctx.ticket.scopes,
1283 });
1284 if (!isValid) {
1285 this.logger.debug(_scope, 'redemption failed, already redeemed', { ctx });
1286 throw new ResponseError(Enum.ErrorResponse.Forbidden);
1287 }
1288
1289 Manager._sensitiveResponse(res);
1290 res.end(JSON.stringify(response));
1291 this.logger.info(_scope, 'finished', { ctx });
1292 }
1293
1294
1295 /**
1296 * Redeem a code for a token.
1297 * @param {*} dbCtx
1298 * @param {http.ClientRequest} req
1299 * @param {http.ServerResponse} res
1300 * @param {Object} ctx
1301 */
1302 async _codeToken(dbCtx, req, res, ctx) {
1303 const _scope = _fileScope('_codeToken');
1304 this.logger.debug(_scope, 'called', { ctx });
1305
1306 await this._restoreSessionFromCode(ctx);
1307 await this._checkSessionMatchingRedirectUri(ctx);
1308
1309 if (ctx.session.error) {
1310 throw new ResponseError(Enum.ErrorResponse.BadRequest);
1311 }
1312
1313 /**
1314 * Note the creation date here rather than in database, so that stored
1315 * expire dates are ensured to match those packed in tokens.
1316 * An alternative would be to return the date generated by the database,
1317 * but then we would need to hold the transaction open while minting the
1318 * tokens to ensure success. Perhaps that would be worth it, but for now
1319 * this is how it is.
1320 */
1321 const now = new Date();
1322 const nowEpoch = common.dateToEpoch(now);
1323 const tokenMinters = [];
1324
1325 tokenMinters.push(this.mysteryBox.pack({
1326 c: ctx.session.codeId,
1327 ts: nowEpoch,
1328 ...(ctx.session.tokenLifespan && { exp: nowEpoch + ctx.session.tokenLifespan }),
1329 }));
1330
1331 if (ctx.session.tokenLifespan
1332 && ctx.session.refreshLifespan) {
1333 tokenMinters.push(this.mysteryBox.pack({
1334 rc: ctx.session.codeId,
1335 ts: nowEpoch,
1336 exp: nowEpoch + ctx.session.refreshLifespan,
1337 }));
1338 }
1339
1340 const [token, refreshToken] = await Promise.all(tokenMinters);
1341
1342 const response = {
1343 'access_token': token,
1344 'token_type': 'Bearer',
1345 ...(ctx.session.tokenLifespan && { 'expires_in': nowEpoch + ctx.session.tokenLifespan }),
1346 ...(refreshToken && { 'refresh_token': refreshToken }),
1347 scope: ctx.session.acceptedScopes.join(' '),
1348 me: ctx.session.me,
1349 ...(ctx.session.acceptedScopes.includes('profile') && { profile: ctx.session.profile }),
1350 };
1351 if (!ctx.session.acceptedScopes.includes('email') && response.profile) {
1352 delete response.profile.email;
1353 }
1354
1355 const isValid = await this.db.redeemCode(dbCtx, {
1356 created: now,
1357 codeId: ctx.session.codeId,
1358 isToken: true,
1359 clientId: ctx.session.clientId,
1360 profile: ctx.session.me,
1361 identifier: ctx.session.identifier,
1362 scopes: ctx.session.acceptedScopes,
1363 lifespanSeconds: ctx.session.tokenLifespan,
1364 refreshLifespanSeconds: ctx.session.refreshLifespan,
1365 profileData: response.profile,
1366 });
1367 if (!isValid) {
1368 this.logger.debug(_scope, 'redemption failed, already redeemed', { ctx });
1369 throw new ResponseError(Enum.ErrorResponse.Forbidden);
1370 }
1371
1372 Manager._sensitiveResponse(res);
1373 res.end(JSON.stringify(response));
1374 this.logger.info(_scope, 'finished', { ctx });
1375 }
1376
1377
1378 /**
1379 * Issue, refresh, or validate a token.
1380 * @param {http.ClientRequest} req
1381 * @param {http.ServerResponse} res
1382 * @param {Object} ctx
1383 */
1384 async postToken(req, res, ctx) {
1385 const _scope = _fileScope('postToken');
1386 this.logger.debug(_scope, 'called', { ctx });
1387
1388 ctx.session = Object.assign({}, ctx.session);
1389
1390 await this.db.context(async (dbCtx) => {
1391
1392 // Is this a (legacy) revocation request?
1393 if (ctx.parsedBody['action'] === 'revoke') {
1394 await this._revokeToken(dbCtx, res, ctx);
1395 return;
1396 }
1397
1398 // Is this a (legacy) token validation request?
1399 if (req.getHeader(Enum.Header.Authorization)) {
1400 await this._validateToken(dbCtx, res, ctx);
1401 return;
1402 }
1403
1404 const validGrantTypes = [
1405 'authorization_code',
1406 'refresh_token',
1407 ...(this.queuePublisher && ['ticket'] || []),
1408 ];
1409 this._checkGrantType(ctx, validGrantTypes, 'authorization_code');
1410
1411 switch (ctx.session.grantType) {
1412 case 'refresh_token':
1413 return this._refreshToken(dbCtx, req, res, ctx);
1414
1415 case 'ticket':
1416 return this._ticketAuthToken(dbCtx, req, res, ctx);
1417
1418 case 'authorization_code':
1419 return this._codeToken(dbCtx, req, res, ctx);
1420
1421 default:
1422 this.logger.debug(_scope, 'unknown grant_type', { ctx });
1423 Manager._setError(ctx, 'invalid_request', 'grant_type not supported');
1424 }
1425
1426 // Only way of getting here is due to error.
1427 throw new ResponseError(Enum.ErrorResponse.BadRequest);
1428 }); // dbCtx
1429 }
1430
1431
1432 /**
1433 * Ingest token from authorization header, setting ctx.bearer.isValid appropriately.
1434 * ctx.bearer not set if auth method not recognized.
1435 * This is for legacy validation on token endpoint.
1436 * @param {*} dbCtx
1437 * @param {http.ClientRequest} req
1438 * @param {Object} ctx
1439 */
1440 async _checkTokenValidationRequest(dbCtx, req, ctx) {
1441 const _scope = _fileScope('_checkTokenValidationRequest');
1442 const authHeader = req.getHeader(Enum.Header.Authorization);
1443
1444 if (authHeader) {
1445 const [authMethod, authString] = common.splitFirst(authHeader, ' ', '');
1446 switch (authMethod.toLowerCase()) { // eslint-disable-line sonarjs/no-small-switch
1447 case 'bearer': {
1448 ctx.bearer = {
1449 isValid: false,
1450 };
1451 try {
1452 Object.assign(ctx.bearer, await this.mysteryBox.unpack(authString));
1453 } catch (e) {
1454 this.logger.debug(_scope, 'failed to unpack token', { ctx });
1455 Manager._setError(ctx, 'invalid_request', 'invalid token');
1456 return;
1457 }
1458 if (!ctx.bearer.c) {
1459 this.logger.debug(_scope, 'incomplete token', { ctx });
1460 Manager._setError(ctx, 'invalid_request', 'invalid token');
1461 return;
1462 }
1463
1464 try {
1465 ctx.token = await this.db.tokenGetByCodeId(dbCtx, ctx.bearer.c);
1466 } catch (e) {
1467 this.logger.error(_scope, 'failed to look up token', { error: e, ctx });
1468 throw e;
1469 }
1470
1471 if (!ctx.token) {
1472 this.logger.debug(_scope, 'no token found', { ctx });
1473 Manager._setError(ctx, 'invalid_request', 'invalid token');
1474 return;
1475 }
1476
1477 if (!ctx.token.isRevoked
1478 && ctx.token.expires > new Date()) {
1479 ctx.bearer.isValid = true;
1480 }
1481 break;
1482 }
1483
1484 default:
1485 this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
1486 return;
1487 }
1488 }
1489 }
1490
1491
1492 /**
1493 * Accept an unsolicited ticket proffering.
1494 * @param {http.ClientRequest} req
1495 * @param {http.ServerResponse} res
1496 * @param {Object} ctx
1497 */
1498 async postTicket(req, res, ctx) {
1499 const _scope = _fileScope('postTicket');
1500 this.logger.debug(_scope, 'called', { ctx });
1501
1502 if (!this.queuePublisher) {
1503 this.logger.debug(_scope, 'ticket endpoint not configured', { ctx });
1504 throw new ResponseError(Enum.ErrorResponse.BadRequest);
1505 }
1506
1507 const queueName = this.options.queues.ticketPublishName;
1508 const { ticket, resource, subject } = ctx.parsedBody;
1509
1510 try {
1511 new URL(resource);
1512 } catch (e) {
1513 this.logger.debug(_scope, 'unparsable resource', { ticket, resource, subject, ctx });
1514 throw new ResponseError(Enum.ErrorResponse.BadRequest);
1515 }
1516
1517 await this.db.context(async (dbCtx) => {
1518 const isValidProfile = await this.db.profileIsValid(dbCtx, subject);
1519 if (!isValidProfile) {
1520 this.logger.debug(_scope, 'invalid subject', { ticket, resource, subject, ctx });
1521 throw new ResponseError(Enum.ErrorResponse.NotFound, { error: 'subject not under our purview' });
1522 }
1523
1524 try {
1525 const result = await this.queuePublisher.publish(queueName, { ticket, resource, subject });
1526 this.logger.debug(_scope, 'accepted ticket offer', { queueName, ticket, resource, subject, ctx, result });
1527 } catch (e) {
1528 this.logger.error(_scope, 'failed to publish ticket to queue', { error: e, queueName, ticket, resource, subject, ctx });
1529 throw e; // return a 500
1530 }
1531
1532 res.statusCode = 202;
1533 res.end();
1534 this.logger.info(_scope, 'finished', { resource, subject, ctx });
1535 });
1536 }
1537
1538
1539 /**
1540 * Validate a token and return data about it.
1541 * @param {http.ServerResponse} res
1542 * @param {Object} ctx
1543 */
1544 async postIntrospection(res, ctx) {
1545 const _scope = _fileScope('postIntrospection');
1546 this.logger.debug(_scope, 'called', { ctx });
1547
1548 let response = {
1549 active: false,
1550 };
1551
1552 const tokenIsTicket = (ctx.parsedBody['token_hint_type'] || '').toLowerCase() === 'ticket';
1553
1554 try {
1555 const token = ctx.parsedBody['token'];
1556 if (tokenIsTicket) {
1557 ctx.token = await this._unpackTicket(token);
1558 } else {
1559 ctx.token = await this.mysteryBox.unpack(token);
1560 }
1561 } catch (e) {
1562 this.logger.debug(_scope, 'failed to unpack token', { error: e, ctx });
1563 }
1564
1565 if (ctx.token
1566 && !tokenIsTicket) {
1567 await this.db.context(async (dbCtx) => {
1568 ctx.token = await this.db.tokenGetByCodeId(dbCtx, ctx.token.c);
1569 }); // dbCtx
1570 }
1571
1572 if (ctx.token
1573 && !ctx.token.isRevoked) {
1574 // fuss around for postgres 'Infinity' date
1575 const expiresMs = (ctx.token.expires instanceof Date) ? ctx.token.expires.getTime() : ctx.token.expires;
1576 if (expiresMs > Date.now()) {
1577 response = {
1578 active: true,
1579 me: ctx.token.profile,
1580 ...(ctx.token.clientId && { 'client_id': ctx.token.clientId }),
1581 scope: ctx.token.scopes.join(' '),
1582 iat: common.dateToEpoch(ctx.token.created || ctx.token.issued),
1583 ...(isFinite(expiresMs) && { exp: Math.ceil(expiresMs / 1000) }),
1584 ...(tokenIsTicket && { 'token_type': 'ticket' }),
1585 };
1586 }
1587 }
1588
1589 Manager._sensitiveResponse(res);
1590 res.end(JSON.stringify(response));
1591 this.logger.info(_scope, 'finished', { ctx });
1592 }
1593
1594
1595 /**
1596 * Revoke a token or refresh token.
1597 * @param {http.ServerResponse} res
1598 * @param {Object} ctx
1599 */
1600 async postRevocation(res, ctx) {
1601 const _scope = _fileScope('postRevocation');
1602 this.logger.debug(_scope, 'called', { ctx });
1603
1604 try {
1605 await this.db.context(async (dbCtx) => {
1606 await this._revokeToken(dbCtx, res, ctx);
1607 });
1608 } catch (e) {
1609 this.logger.error(_scope, 'failed', { error: e, ctx });
1610 throw e;
1611 }
1612
1613 this.logger.info(_scope, 'finished', { ctx });
1614 }
1615
1616
1617 /**
1618 * Profile information for a token.
1619 * @param {http.ServerResponse} res
1620 * @param {Object} ctx
1621 */
1622 async postUserInfo(res, ctx) {
1623 const _scope = _fileScope('postUserInfo');
1624 this.logger.debug(_scope, 'called', { ctx });
1625
1626 const token = ctx.parsedBody['token'];
1627 if (!token) {
1628 res.statusCode = 400;
1629 res.end('"invalid_request"');
1630 this.logger.info(_scope, 'finished, invalid request', { ctx });
1631 return;
1632 }
1633
1634 try {
1635 ctx.token = await this.mysteryBox.unpack(ctx.parsedBody['token']);
1636 } catch (e) {
1637 this.logger.debug(_scope, 'failed to unpack token', { error: e, ctx });
1638 }
1639
1640 if (ctx.token) {
1641 await this.db.context(async (dbCtx) => {
1642 ctx.token = await this.db.tokenGetByCodeId(dbCtx, ctx.token.c);
1643 }); // dbCtx
1644 }
1645
1646 if (!ctx.token
1647 || ctx.token.isRevoked
1648 // || tokenIsExpired(token)
1649 ) {
1650 res.statusCode = 401;
1651 res.end('"invalid_token"');
1652 this.logger.info(_scope, 'finished, invalid token', { ctx });
1653 return;
1654 }
1655
1656 if (!ctx.token.scopes.includes('profile')) {
1657 res.statusCode = 403;
1658 res.end('"insufficient_scope"');
1659 this.logger.info(_scope, 'finished, insufficient scope', { ctx });
1660 return;
1661 }
1662
1663 const response = {
1664 ...ctx.token.profile,
1665 };
1666 if (!ctx.token.scopes.includes('email')) {
1667 delete response.email;
1668 }
1669
1670 Manager._sensitiveResponse(res);
1671 res.end(JSON.stringify(response));
1672
1673 this.logger.info(_scope, 'finished', { ctx });
1674 }
1675
1676
1677 /**
1678 * Show admin interface, allowing manipulation of profiles and scopes.
1679 * @param {http.ServerResponse} res
1680 * @param {Object} ctx
1681 */
1682 async getAdmin(res, ctx) {
1683 const _scope = _fileScope('getAdmin');
1684 this.logger.debug(_scope, 'called', { ctx });
1685
1686 const identifier = ctx.session.authenticatedIdentifier;
1687
1688 await this.db.context(async (dbCtx) => {
1689 ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
1690 ctx.tokens = await this.db.tokensGetByIdentifier(dbCtx, identifier);
1691 }); // dbCtx
1692
1693 res.end(Template.adminHTML(ctx, this.options));
1694
1695 this.logger.info(_scope, 'finished', { ctx });
1696 }
1697
1698
1699 /**
1700 * Process admin interface events.
1701 * @param {http.ServerResponse} res
1702 * @param {Object} ctx
1703 */
1704 async postAdmin(res, ctx) {
1705 const _scope = _fileScope('postAdmin');
1706 this.logger.debug(_scope, 'called', { ctx });
1707
1708 await this.db.context(async (dbCtx) => {
1709 const identifier = ctx.session.authenticatedIdentifier;
1710 const action = ctx?.parsedBody?.['action'] || '';
1711
1712 if (action === 'save-scopes') {
1713 // Update the convenience scopes set for profiles.
1714 // Expect 'scopes-<profile>' with value of array of scopes
1715 const profileKeys = ctx.parsedBody && Object.keys(ctx.parsedBody)
1716 .filter((k) => k.startsWith('scopes-'));
1717 try {
1718 await this.db.transaction(dbCtx, async (txCtx) => {
1719 await Promise.all(
1720 /* For each scopes-profile submitted, set those. */
1721 profileKeys.map((profileKey) => {
1722 /* elide 'scope-' prefix to get the profile */
1723 const profile = profileKey.slice(7);
1724 /* (should validate profile here) */
1725
1726 /* remove invalid scopes from submitted list */
1727 const scopes = ctx.parsedBody[profileKey].filter((scope) => scope && common.validScope(scope)); // eslint-disable-line security/detect-object-injection
1728 return this.db.profileScopesSetAll(txCtx, profile, scopes);
1729 }),
1730 );
1731 }); // txCtx
1732 ctx.notifications.push('Profile/Scope Availability Matrix updated!');
1733 } catch (e) {
1734 this.logger.error(_scope, 'did not set profile scopes', { error: e, ctx });
1735 ctx.errors.push('Failed to update profile scopes.');
1736 }
1737
1738 } else if (action === 'new-profile') {
1739 // Validate and create a new profile uri.
1740 let profile;
1741 const profileUri = ctx.parsedBody['profile'];
1742 try {
1743 profile = await this.communication.validateProfile(profileUri);
1744 } catch (e) {
1745 this.logger.debug(_scope, 'invalid profile url', { error: e, ctx });
1746 ctx.errors.push(`'${profileUri}' is not a valid profile URI.${(e instanceof CommunicationErrors.ValidationError) ? ('(' + e.message + ')') : ''}`);
1747 }
1748 if (profile) {
1749 // Validate profile uri
1750 const profileData = await this.communication.fetchProfile(profile);
1751 if (profileData.metadata.authorizationEndpoint !== this.selfAuthorizationEndpoint) {
1752 this.logger.debug(_scope, 'profile does not list this server as auth', { profileData, ctx });
1753 ctx.errors.push('Requested profile does not list this service, not adding.');
1754 } else {
1755 try {
1756 await this.db.transaction(dbCtx, async (txCtx) => {
1757 await this.db.profileIdentifierInsert(txCtx, profile.href, identifier);
1758 await this.db.profileScopesSetAll(txCtx, profile.href, ['profile', 'email']);
1759 }); // txCtx
1760 ctx.notifications.push('Profile added!');
1761 } catch (e) {
1762 this.logger.error(_scope, 'did not insert profile', { error: e, ctx });
1763 ctx.errors.push('Failed to add profile.');
1764 }
1765 }
1766 }
1767
1768 } else if (action === 'new-scope') {
1769 // Add or update a manually-added convenience scope.
1770 const { scope, application = '', description = '' } = ctx.parsedBody;
1771 if (scope) {
1772 if (!common.validScope(scope)) {
1773 ctx.errors.push(`"${scope}" is not a valid scope name, did not add it.`);
1774 } else {
1775 try {
1776 await this.db.scopeUpsert(dbCtx, scope, application, description, true);
1777 ctx.notifications.push('Scope List updated!');
1778 } catch (e) {
1779 this.logger.error(_scope, 'did not upsert scope', { error: e, scope, application, description, ctx });
1780 ctx.errors.push('Failed to update scope.');
1781 }
1782 }
1783 }
1784
1785 } else if (action.startsWith('delete-scope-')) {
1786 // Remove a manually-added convenience scope.
1787 const scope = decodeURIComponent(action.slice(13));
1788 if (scope) {
1789 try {
1790 const deleted = await this.db.scopeDelete(dbCtx, scope);
1791 if (deleted) {
1792 ctx.notifications.push('Scope deleted.');
1793 } else {
1794 ctx.notifications.push('Unable to delete scope.');
1795 }
1796 } catch (e) {
1797 this.logger.error(_scope, 'did not delete scope', { error: e, scope, ctx });
1798 ctx.errors.push('Failed to delete scope.');
1799 }
1800 }
1801
1802 } else if (action.startsWith('revoke-')) {
1803 // Revoke an active token.
1804 const codeId = action.slice(8);
1805 if (codeId) {
1806 try {
1807 await this.db.tokenRevokeByCodeId(dbCtx, codeId, identifier);
1808 ctx.notifications.push('Revoked token!');
1809 } catch (e) {
1810 this.logger.error(_scope, 'did not revoke token', { error: e, codeId, identifier, ctx });
1811 ctx.errors.push('Unable to revoke token.');
1812 }
1813 }
1814
1815 } else if (action) {
1816 ctx.errors.push(`Do not know how to '${action}'.`);
1817 }
1818
1819 ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
1820 ctx.tokens = await this.db.tokensGetByIdentifier(dbCtx, identifier);
1821 }); // dbCtx
1822
1823 res.end(Template.adminHTML(ctx, this.options));
1824
1825 this.logger.info(_scope, 'finished', { ctx });
1826 }
1827
1828
1829 /**
1830 * Show ticket proffer interface.
1831 * @param {http.ServerResponse} res
1832 * @param {Object} ctx
1833 */
1834 async getAdminTicket(res, ctx) {
1835 const _scope = _fileScope('getAdminTicket');
1836 this.logger.debug(_scope, 'called', { ctx });
1837
1838 const identifier = ctx.session.authenticatedIdentifier;
1839
1840 await this.db.context(async (dbCtx) => {
1841 ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
1842 ctx.profiles = ctx.profilesScopes.profiles;
1843 ctx.scopes = Object.keys(ctx.profilesScopes.scopeIndex);
1844 }); // dbCtx
1845
1846 res.end(Template.adminTicketHTML(ctx, this.options));
1847
1848 this.logger.info(_scope, 'finished', { ctx });
1849 }
1850
1851
1852 /**
1853 * Handle ticket proffer interface submission.
1854 * @param {http.ServerResponse} res
1855 * @param {Object} ctx
1856 */
1857 async postAdminTicket(res, ctx) {
1858 const _scope = _fileScope('postAdminTicket');
1859 this.logger.debug(_scope, 'called', { ctx });
1860
1861 switch (ctx.parsedBody['action']) { // eslint-disable-line sonarjs/no-small-switch
1862 case 'proffer-ticket': {
1863 const identifier = ctx.session.authenticatedIdentifier;
1864 [
1865 { ctxProp: 'ticketProfileUrl', bodyParam: 'profile', err: 'Invalid Profile URL selected.' },
1866 { ctxProp: 'ticketResourceUrl', bodyParam: 'resource', err: 'Invalid Resource URL.' },
1867 { ctxProp: 'ticketSubjectUrl', bodyParam: 'subject', err: 'Invalid Recipient URL.' },
1868 ].forEach((param) => {
1869 try {
1870 ctx[param.ctxProp] = new URL(ctx.parsedBody[param.bodyParam]);
1871 } catch (e) {
1872 this.logger.debug(_scope, `invalid ${param.bodyParam}`, { ctx });
1873 ctx.errors.push(param.err);
1874 }
1875 });
1876
1877 const subjectData = await this.communication.fetchProfile(ctx.ticketSubjectUrl);
1878 if (!subjectData?.metadata?.ticketEndpoint) {
1879 this.logger.debug(_scope, 'subject has no ticket endpoint', { ctx });
1880 ctx.errors.push('Recipient does not list a ticket endpoint to deliver to.');
1881 } else {
1882 try {
1883 ctx.ticketEndpointUrl = new URL(subjectData.metadata.ticketEndpoint);
1884 } catch (e) {
1885 this.logger.debug(_scope, 'subject has invalid ticket endpoint', { error: e, ctx });
1886 ctx.errors.push(`Recipient lists an invalid ticket endpoint, cannot deliver. (${e})`);
1887 }
1888 }
1889
1890 const scopesSet = new Set();
1891 const rawScopes = [
1892 ...(common.ensureArray(ctx.parsedBody['scopes'])),
1893 ...((ctx.parsedBody['adhoc'] || '').split(scopeSplitRE)),
1894 ].filter((scope) => scope);
1895 rawScopes.forEach((scope) => {
1896 if (common.validScope(scope)) {
1897 scopesSet.add(scope);
1898 } else {
1899 this.logger.debug(_scope, 'invalid adhoc scope', { scope, ctx });
1900 ctx.errors.push(`'${scope}' is not a valid scope.`);
1901 }
1902 });
1903 ctx.ticketScopes = [...scopesSet];
1904 const actionScopes = ctx.ticketScopes.filter((scope) => !['profile', 'email'].includes(scope));
1905 if (!actionScopes.length) {
1906 this.logger.debug(_scope, 'no valid scopes included', { ctx });
1907 ctx.errors.push('At least one actionable scope must be included.');
1908 }
1909
1910 if (!ctx.errors.length) {
1911 const ticketData = {
1912 subject: ctx.ticketSubjectUrl.href,
1913 resource: ctx.ticketResourceUrl.href,
1914 scopes: ctx.ticketScopes,
1915 identifier,
1916 profile: ctx.ticketProfileUrl.href,
1917 ticketLifespanSeconds: this.options.manager.ticketLifespanSeconds,
1918 };
1919 const ticket = await this._mintTicket(ticketData);
1920
1921 await this.db.context(async (dbCtx) => {
1922 // re-populate form fields
1923 ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
1924
1925 // TODO: queue ticket for delivery/retry to subject instead of trying immediately
1926 // ctx.notifications.push('Success! Ticket will be delivered!');
1927
1928 this.logger.debug(_scope, 'ticket created', { ctx, ticketData, subjectData });
1929
1930 try {
1931 const result = await this.communication.deliverTicket(ctx.ticketEndpointUrl, ctx.ticketResourceUrl, ctx.ticketSubjectUrl, ticket);
1932 ctx.notifications.push(`Success! Ticket was delivered. (${result?.statusText})`);
1933 this.logger.info(_scope, 'ticket delivered', { ctx, result });
1934 } catch (e) {
1935 this.logger.error(_scope, 'failed to deliver ticket', { ctx, error: e });
1936 ctx.errors.push(`Failed to deliver ticket. (${e})`);
1937 }
1938
1939 }); // dbCtx
1940
1941 } else {
1942 // populate form fields again
1943 await this.db.context(async (dbCtx) => {
1944 ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
1945 ctx.scopes = Object.keys(ctx.profilesScopes.scopeIndex);
1946 }); // dbCtx
1947 }
1948
1949 break;
1950 }
1951
1952 default:
1953 this.logger.debug(_scope, 'unknown action', { ctx });
1954 }
1955
1956 res.end(Template.adminTicketHTML(ctx, this.options));
1957
1958 this.logger.info(_scope, 'finished', { ctx });
1959 }
1960
1961
1962 /**
1963 * Report on generally uninteresting backend information.
1964 * Also allow a few event invocations.
1965 * @param {http.ServerResponse} res
1966 * @param {Object} ctx
1967 */
1968 async getAdminMaintenance(res, ctx) {
1969 const _scope = _fileScope('getAdminMaintenance');
1970 this.logger.debug(_scope, 'called', { ctx });
1971
1972 const maintenanceTasks = [];
1973
1974 await this.db.context(async (dbCtx) => {
1975
1976 Object.values(Enum.Chore).forEach((chore) => {
1977 if (chore in ctx.queryParams) {
1978 maintenanceTasks.push(
1979 this.chores.runChore(chore, 0), // Provide arg to force chore run.
1980 );
1981 ctx.notifications.push(`Running maintenance chore "${chore}".`);
1982 }
1983 });
1984
1985 await Promise.all(maintenanceTasks);
1986
1987 ctx.almanac = await this.db.almanacGetAll(dbCtx);
1988 }); // dbCtx
1989
1990 const winnowChoreEntry = ([name, value]) => [name, common.pick(value, ['intervalMs', 'nextSchedule'])];
1991 ctx.chores = Object.fromEntries(
1992 Object.entries(this.chores.chores).map(winnowChoreEntry),
1993 );
1994
1995 res.end(Template.adminMaintenanceHTML(ctx, this.options));
1996
1997 this.logger.info(_scope, 'finished', { ctx });
1998 }
1999
2000
2001 /**
2002 *
2003 * @param {http.ServerResponse} res
2004 * @param {Object} ctx
2005 */
2006 async getHealthcheck(res, ctx) {
2007 const _scope = _fileScope('getHealthcheck');
2008 this.logger.debug(_scope, 'called', { ctx });
2009 await this.db.healthCheck();
2010 res.end();
2011 }
2012
2013 }
2014
2015 module.exports = Manager;