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