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