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