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