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