1 /* eslint-disable sonarjs/no-duplicate-string */
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');
14 const _fileScope
= common
.fileScope(__filename
);
16 // These are used during request ingestion and validation
17 const validBase64URLRE
= /^[-A-Za-z0-9_]+$/;
18 const scopeSplitRE
= / +/;
20 const supportedCodeChallengeMethods
= ['S256', 'SHA256'];
23 * @typedef {import('node:http')} http
27 constructor(logger
, db
, options
) {
28 this.options
= options
;
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
);
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
)));
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
;
48 * Perform any async startup tasks.
51 if (this.queuePublisher
) {
52 await
this._connectQueues();
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
);
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
);
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
79 static _setError(ctx
, error
, errorDescription
) {
80 const errorPrecedence
= [ // By increasing severity
82 'unsupported_response_type',
84 'unauthorized_client',
87 'temporarily_unavailable',
90 if (!(errorPrecedence
.includes(error
))) {
91 throw new RangeError(`invalid error value '${error}'`);
93 if (!ctx
.session
.errorDescriptions
) {
94 ctx
.session
.errorDescriptions
= [];
96 if (!common
.validError(errorDescription
)) {
97 throw new RangeError(`invalid error description '${errorDescription}'`);
99 const isHigherPrecedence
= errorPrecedence
.indexOf(error
) > errorPrecedence
.indexOf(ctx
.session
.error
);
100 if (!ctx
.session
.error
|| isHigherPrecedence
) {
101 ctx
.session
.error
= error
;
103 if (isHigherPrecedence
) {
104 ctx
.session
.errorDescriptions
.unshift(errorDescription
);
106 ctx
.session
.errorDescriptions
.push(errorDescription
);
112 * Discourage caching of a response.
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
119 static _sensitiveResponse(res
) {
121 [Enum
.Header
.CacheControl
]: 'no-store',
122 [Enum
.Header
.Pragma
]: 'no-cache',
123 }).forEach(([k
, v
]) => res
.setHeader(k
, v
));
128 * Sets params entries as url search parameters.
129 * @param {URL} url url
130 * @param {object} params parameters
132 static _setSearchParams(url
, params
) {
133 Object
.entries(params
).forEach((param
) => url
.searchParams
.set(...param
));
138 * Serve the informational root page.
139 * @param {http.ServerResponse} res response
140 * @param {object} ctx context
142 async
getRoot(res
, ctx
) {
143 const _scope
= _fileScope('getRoot');
144 this.logger
.debug(_scope
, 'called', { ctx
});
146 res
.end(Template
.rootHTML(ctx
, this.options
));
147 this.logger
.info(_scope
, 'finished', { ctx
});
152 * Serve the metadata for this service.
153 * @param {http.ServerResponse} res response
154 * @param {object} ctx context
156 async
getMeta(res
, ctx
) {
157 const _scope
= _fileScope('getMeta');
158 this.logger
.debug(_scope
, 'called', { ctx
});
160 const base
= this.options
.dingus
.selfBaseUrl
;
161 const endpoint
= (r
) => `${base}${this.options.route[r]}`; // eslint-disable-line security/detect-object-injection
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',
177 ...(this.queuePublisher
&& ['ticket'] || []),
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'),
185 res
.end(JSON
.stringify(metadata
));
186 this.logger
.info(_scope
, 'finished', { ctx
});
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
197 async
getAuthorization(res
, ctx
) {
198 const _scope
= _fileScope('getAuthorization');
199 this.logger
.debug(_scope
, 'called', { ctx
});
201 Manager
._sensitiveResponse(res
);
205 errorDescriptions: [],
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
);
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
});
222 // Set error response for template to render.
223 ctx
.errors
.push('Cannot redirect to client application.');
225 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
227 res
.statusCode
= 400;
228 res
.end(Template
.authorizationErrorHTML(ctx
, this.options
));
229 this.logger
.info(_scope
, 'bad request', { ctx
});
233 await
this.db
.context(async (dbCtx
) => {
234 const profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, ctx
.authenticationId
);
235 Object
.assign(ctx
.session
, {
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.');
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
;
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>');
261 if (!ctx
.session
.codeChallenge
) {
262 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge\'');
264 if (!ctx
.session
.codeChallengeMethod
) {
265 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
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
});
274 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
275 'state': ctx
.session
.state
,
276 'error': ctx
.session
.error
,
277 'error_description': ctx
.session
.errorDescriptions
.join(', '),
279 res
.statusCode
= 302; // Found
280 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
282 this.logger
.info(_scope
, 'bad request', { ctx
});
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
,
298 profiles: ctx
.session
.profiles
,
299 requestedScopes: ctx
.session
.scope
,
300 authenticationId: ctx
.authenticationId
,
303 // Present authenticated user the option to submit consent
304 const content
= Template
.authorizationRequestHTML(ctx
, this.options
);
307 this.logger
.info(_scope
, 'finished', { ctx
});
312 * Validates, fetches, and parses client_id url, populating clientIdentifier with client h-app data.
313 * @param {object} ctx context
315 async
_clientIdRequired(ctx
) {
316 if (ctx
.queryParams
['client_id']) {
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');
325 ctx
.session
.clientId
= undefined;
326 if (e
instanceof CommunicationErrors
.ValidationError
) {
327 Manager
._setError(ctx
, 'invalid_request', e
.message
);
329 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'client_id\'');
332 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'client_id\'');
338 * Ensure redirect_uri exists and is corroborated by clientIdentifier data.
339 * @param {object} ctx context
341 static _redirectURIRequired(ctx
) {
342 if (ctx
.queryParams
['redirect_uri']) {
344 ctx
.session
.redirectUri
= new URL(ctx
.queryParams
['redirect_uri']);
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);
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;
362 } catch (e
) { // eslint-disable-line no-unused-vars
363 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'redirect_uri\'');
366 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'redirect_uri\'');
372 * response_type must be valid
373 * @param {object} ctx context
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\'');
383 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'response_type\'');
389 * A state parameter must be present
390 * @param {object} ctx context
392 static _stateRequired(ctx
) {
393 ctx
.session
.state
= ctx
.queryParams
['state'];
394 if (ctx
.session
.state
) {
395 // No restrictions on content of this
397 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'state\'');
403 * A code_challenge_method must be present and valid
404 * @param {object} ctx context
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');
413 if (this.options
.manager
.allowLegacyNonPKCE
) {
416 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
422 * A code_challenge must be present
423 * @param {object} ctx context
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\'');
432 if (this.options
.manager
.allowLegacyNonPKCE
) {
435 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge\'');
441 * Scopes may be present, with one known combination limitation
442 * @param {object} ctx context
444 _scopeOptional(ctx
) {
445 const _scope
= _fileScope('_scopeOptional');
446 const scope
= ctx
.queryParams
['scope'];
447 ctx
.session
.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
});
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');
466 * Parses me, if provided
467 * @param {object} ctx context
469 async
_meOptional(ctx
) {
470 const me
= ctx
.queryParams
['me'];
473 ctx
.session
.me
= await
this.communication
.validateProfile(me
);
474 } catch (e
) { // eslint-disable-line no-unused-vars
475 ctx
.session
.me
= undefined;
482 * Ensure authenticated identifier matches profile.
483 * @param {object} ctx context
484 * @returns {boolean} is valid
486 _profileValidForIdentifier(ctx
) {
487 const _scope
= _fileScope('_profileValidForIdentifier');
489 if (!ctx
.session
.me
) {
490 this.logger
.debug(_scope
, 'no profile provided, cannot correlate', { ctx
});
494 return ctx
.session
.profiles
.includes(ctx
.session
.me
.href
);
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
505 _parseLifespan(ctx
, field
, customField
) {
506 const _scope
= _fileScope('_parseLifespan');
508 const presetValues
= {
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
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
;
524 this.logger
.debug(_scope
, 'invalid custom value', { ctx
, field
, customField
});
528 this.logger
.debug(_scope
, 'invalid value', { ctx
, field
, customField
});
534 * Validate any accepted scopes, ensure uniqueness, return as array.
535 * @param {object} ctx context
536 * @returns {string[]} scopes
538 _parseConsentScopes(ctx
) {
539 const _scope
= _fileScope('_ingestConsentScopes');
540 const acceptedScopesSet
= new Set();
541 const rejectedScopesSet
= new Set();
543 const submittedScopes
= common
.ensureArray(ctx
.parsedBody
['accepted_scopes[]'])
544 .concat((ctx
.parsedBody
['ad_hoc_scopes'] || '').split(scopeSplitRE
));
545 submittedScopes
.forEach((scope
) => {
547 (common
.validScope(scope
) ? acceptedScopesSet : rejectedScopesSet
).add(scope
);
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)');
558 if (rejectedScopesSet
.size
) {
559 this.logger
.debug(_scope
, 'ignoring invalid scopes', { ctx
, rejectedScopes: Array
.from(rejectedScopesSet
) });
562 return Array
.from(acceptedScopesSet
);
567 * Parse and validate selected me is a valid profile option.
568 * @param {object} ctx context
571 _parseConsentMe(ctx
) {
572 const _scope
= _fileScope('_parseConsentMe');
573 const selectedMe
= ctx
.parsedBody
['me'];
575 const me
= new URL(selectedMe
);
576 if (ctx
.session
.profiles
.includes(me
.href
)) {
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');
583 this.logger
.debug(_scope
, 'failed to parse selected \'me\' as url', { error: e
, ctx
});
584 Manager
._setError(ctx
, 'invalid_request', 'invalid profile url');
591 * Get up-to-date profile data from selected profile endpoint.
592 * @param {object} ctx context
593 * @returns {Promise<object>} profile data
595 async
_fetchConsentProfileData(ctx
) {
596 const _scope
= _fileScope('_fetchConsentProfileData');
598 const profile
= await
this.communication
.fetchProfile(ctx
.session
.me
);
600 this.logger
.debug(_scope
, 'no profile data at \'me\' endpoint', { ctx
});
601 Manager
._setError(ctx
, 'temporarily_unavailable', 'unable to retrieve profile');
603 // Profile info gets persisted in code, only include known profile fields to help keep size down.
604 return common
.pick(profile
, [
612 this.logger
.debug(_scope
, 'failed to fetch \'me\' endpoint', { error: e
, ctx
});
613 Manager
._setError(ctx
, 'temporarily_unavailable', 'could not reach profile endpoint');
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
634 async
postConsent(res
, ctx
) {
635 const _scope
= _fileScope('postConsent');
636 this.logger
.debug(_scope
, 'called', { ctx
});
638 Manager
._sensitiveResponse(res
);
640 // Ensure session exists, persisting any login session data.
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
);
651 this.logger
.debug(_scope
, 'failed to unpack session', { error: e
, ctx
});
652 Manager
._setError(ctx
, 'invalid_request', 'un-parsable data in authorization consent');
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.
659 'Cannot redirect to client application.',
662 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
664 res
.statusCode
= 400;
665 res
.end(Template
.authorizationErrorHTML(ctx
, this.options
));
666 this.logger
.info(_scope
, 'bad request, cannot redirect', { ctx
});
670 // TODO: Should probably re-validate more unpacked session values, even though those should be trustable.
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');
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');
688 if (ctx
.session
.error
) {
689 this.logger
.debug(_scope
, 'invalid request, redirecting', { ctx
});
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(', '),
697 res
.statusCode
= 302; // Found
698 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
700 this.logger
.info(_scope
, 'bad request, redirected', { ctx
});
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
720 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
722 'state': ctx
.session
.state
,
723 'iss': this.options
.dingus
.selfBaseUrl
,
725 res
.statusCode
= 302;
726 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
729 this.logger
.info(_scope
, 'finished', { ctx
});
734 * Redeem a code for a profile url, and maybe more profile info.
735 * @param {http.ServerResponse} res response
736 * @param {object} ctx context
738 async
postAuthorization(res
, ctx
) {
739 const _scope
= _fileScope('postAuthorization');
740 this.logger
.debug(_scope
, 'called', { ctx
});
742 await
this._ingestPostAuthorizationRequest(ctx
);
746 ...(ctx
.session
?.acceptedScopes
?.includes('profile') && { profile: ctx
.session
.profile
}),
747 scope: ctx
.session
.acceptedScopes
,
749 if (response
.profile
&& !ctx
.session
?.acceptedScopes
?.includes('email')) {
750 delete response
.profile
.email
;
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
,
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
,
768 this.logger
.debug(_scope
, 'code already redeemed', { ctx
});
769 Manager
._setError(ctx
, 'access_denied', 'code already redeemed');
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(', '),
780 this.logger
.info(_scope
, 'invalid request', { ctx
});
784 res
.end(JSON
.stringify(response
));
786 this.logger
.info(_scope
, 'finished', { ctx
, response
});
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>}
796 async
_ingestPostAuthorizationRequest(ctx
) {
797 const _scope
= _fileScope('_ingestPostAuthorizationRequest');
801 errorDescriptions: [],
804 if (!ctx
.parsedBody
) {
805 this.logger
.debug(_scope
, 'no body data', { ctx
});
806 Manager
._setError(ctx
, 'invalid_request', 'missing data');
809 await
this._restoreSessionFromCode(ctx
);
810 this._checkSessionMatchingClientId(ctx
);
811 this._checkSessionMatchingRedirectUri(ctx
);
812 this._checkGrantType(ctx
);
813 this._checkSessionMatchingCodeVerifier(ctx
);
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');
821 const expires
= new Date(ctx
.session
.minted
+ this.options
.manager
.codeValidityTimeoutMs
);
822 const now
= new Date();
824 this.logger
.debug(_scope
, 'code expired', { ctx
});
825 Manager
._setError(ctx
, 'invalid_request', 'code has expired');
831 * Unpack the session data from provided code overtop of context session ..
832 * @param {object} ctx context
834 async
_restoreSessionFromCode(ctx
) {
835 const _scope
= _fileScope('_restoreSessionFromCode');
837 const code
= ctx
.parsedBody
['code'];
840 const oldSession
= await
this.mysteryBox
.unpack(code
);
842 // TODO: Validate unpacked fields better
843 const missingFields
= [
845 'codeChallengeMethod',
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
});
862 this.logger
.debug(_scope
, 'unpacked code is missing required field', { missingFields
, ctx
});
863 Manager
._setError(ctx
, 'invalid_request', 'code is not valid');
867 Object
.assign(ctx
.session
, oldSession
);
869 this.logger
.debug(_scope
, 'failed to parse code', { error: e
, ctx
});
870 Manager
._setError(ctx
, 'invalid_request', 'code is not valid');
873 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code\'');
879 * Ensure provided client_id matches session clientId.
880 * @param {object} ctx context
882 _checkSessionMatchingClientId(ctx
) {
883 const _scope
= _fileScope('_checkSessionMatchingClientId');
885 let clientId
= ctx
.parsedBody
['client_id'];
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');
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');
902 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'client_id\'');
908 * @param {object} ctx context
910 _checkSessionMatchingRedirectUri(ctx
) {
911 const _scope
= _fileScope('_checkSessionMatchingClientId');
913 let redirectUri
= ctx
.parsedBody
['redirect_uri'];
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');
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');
930 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'redirect_uri\'');
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
941 _checkGrantType(ctx
, validGrantTypes
= ['authorization_code'], treatEmptyAs
= 'authorization_code') {
942 const _scope
= _fileScope('_checkGrantType');
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
});
948 if (validGrantTypes
.includes(grantType
)) {
949 ctx
.session
.grantType
= grantType
;
951 Manager
._setError(ctx
, 'invalid_request', 'grant_type not supported');
957 * @param {object} ctx context
959 _checkSessionMatchingCodeVerifier(ctx
) {
960 const _scope
= _fileScope('_checkSessionMatchingCodeVerifier');
962 const codeVerifier
= ctx
.parsedBody
['code_verifier'];
965 const valid
= Communication
.verifyChallenge(ctx
.session
.codeChallenge
, codeVerifier
, ctx
.session
.codeChallengeMethod
);
967 this.logger
.debug(_scope
, 'challenge mismatched', { ctx
});
968 Manager
._setError(ctx
, 'invalid_request', 'challenge verification failed');
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');
975 if (this.options
.manager
.allowLegacyNonPKCE
976 && !ctx
.session
.codeChallenge
977 && !ctx
.session
.codeChallengeMethod
) {
978 this.logger
.debug(_scope
, 'allowing non-PKCE', { ctx
});
981 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_verifier\'');
987 * Attempt to revoke a token.
988 * @param {*} dbCtx db context
989 * @param {http.ServerResponse} res response
990 * @param {object} ctx context
992 async
_revokeToken(dbCtx
, res
, ctx
) {
993 const _scope
= _fileScope('_revokeToken');
995 const token
= ctx
.parsedBody
['token'];
996 const tokenTypeHint
= ctx
.parsedBody
['token_type_hint'];
997 switch (tokenTypeHint
) {
1000 case 'access_token':
1002 case 'refresh_token':
1005 this.logger
.debug(_scope
, 'unknown token_type_hint', { ctx
});
1008 throw new ValidationError('Token Missing');
1010 ctx
.token
= await
this.mysteryBox
.unpack(token
);
1011 if (!(ctx
.token
?.c
|| ctx
.token
?.rc
)) {
1012 throw new ValidationError('Token Invalid');
1015 this.logger
.debug(_scope
, 'invalid token', { error: e
, ctx
});
1016 res
.statusCode
= 400;
1018 this.logger
.info(_scope
, 'finished, revoke request not valid', { error: e
, ctx
});
1024 await
this.db
.tokenRevokeByCodeId(dbCtx
, ctx
.token
.c
);
1026 await
this.db
.tokenRefreshRevokeByCodeId(dbCtx
, ctx
.token
.rc
);
1029 if (e
instanceof DBErrors
.UnexpectedResult
) {
1030 res
.statusCode
= 404;
1032 this.logger
.info(_scope
, 'finished, no token to revoke', { error: e
, ctx
});
1035 this.logger
.error(_scope
, 'revoke token failed', { error: e
, ctx
});
1040 this.logger
.info(_scope
, 'finished, token revoked', { ctx
});
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
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
,
1061 this.logger
.info(_scope
, 'finished, token validated', { ctx
});
1063 const responseErrorParts
= ['Bearer'];
1064 const error
= ctx
.session
.error
? `error="${ctx.session.error}"` : '';
1066 responseErrorParts
.push(error
);
1068 const errorDescription
= ctx
.session
.errorDescriptions
? `error_description="${ctx.session.errorDescriptions.join(', ')}"` : '';
1069 if (errorDescription
) {
1070 responseErrorParts
.push(errorDescription
);
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
);
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
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
);
1096 return scopesToRemove
;
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
1107 async
_refreshToken(dbCtx
, req
, res
, ctx
) {
1108 const _scope
= _fileScope('_refreshToken');
1109 this.logger
.debug(_scope
, 'called', { ctx
});
1112 'client_id': clientId
,
1117 ctx
.refreshToken
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['refresh_token']);
1119 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1122 const now
= new Date();
1123 const nowEpoch
= common
.dateToEpoch(now
);
1125 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1126 if (ctx
.refreshToken
?.rc
) {
1127 ctx
.token
= await
this.db
.tokenGetByCodeId(txCtx
, ctx
.refreshToken
.rc
);
1131 this.logger
.debug(_scope
, 'no token to refresh', { ctx
});
1132 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
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
);
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
);
1147 if (clientId
!== ctx
.token
.clientId
) {
1148 this.logger
.debug(_scope
, 'client identifier mismatch', { ctx
});
1149 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
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
});
1157 const refreshedTokenData
= await
this.db
.refreshCode(txCtx
, ctx
.refreshToken
.rc
, now
, scopesToRemove
);
1158 if (refreshedTokenData
) {
1159 Object
.assign(ctx
.token
, refreshedTokenData
);
1161 this.logger
.debug(_scope
, 'could not refresh token', { ctx
});
1162 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
1166 const [token
, refreshToken
] = await Promise
.all([
1168 c: ctx
.token
.codeId
,
1172 rc: ctx
.token
.codeId
,
1174 exp: nowEpoch
+ ctx
.token
.refreshDuration
,
1176 ].map(this.mysteryBox
.pack
));
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(' '),
1185 ...(ctx
.token
.scopes
.includes('profile') && { profile: ctx
.token
.profileData
}),
1187 if (ctx
.token
.scopes
.includes('profile') && !ctx
.token
.scopes
.includes('email')) {
1188 delete response
?.profile
?.email
;
1191 Manager
._sensitiveResponse(res
);
1192 res
.end(JSON
.stringify(response
));
1193 this.logger
.info(_scope
, 'finished', { ctx
});
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
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
});
1212 const nowEpoch
= common
.dateToEpoch();
1213 return this.mysteryBox
.pack({
1214 c: common
.requestId(),
1216 exp: nowEpoch
+ ticketLifespanSeconds
,
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
1239 * @param {string} ticket ticket
1240 * @returns {Promise<Ticket>} ticket object
1242 async
_unpackTicket(ticket
) {
1243 const ticketObj
= await
this.mysteryBox
.unpack(ticket
);
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
),
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>}
1265 async
_ticketAuthToken(dbCtx
, req
, res
, ctx
) {
1266 const _scope
= _fileScope('_ticketAuthToken');
1267 this.logger
.debug(_scope
, 'called', { ctx
});
1270 ctx
.ticket
= await
this._unpackTicket(ctx
.parsedBody
['ticket']);
1272 this.logger
.debug(_scope
, 'failed to unpack ticket', { error: e
, ctx
});
1273 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
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
});
1282 const nowEpoch
= common
.dateToEpoch(now
);
1283 const token
= await
this.mysteryBox
.pack({
1284 c: ctx
.ticket
.codeId
,
1289 'access_token': token
,
1290 'token_type': 'Bearer',
1291 scope: ctx
.ticket
.scopes
.join(' '),
1292 me: ctx
.ticket
.profile
.href
,
1295 const isValid
= await
this.db
.redeemCode(dbCtx
, {
1297 codeId: ctx
.ticket
.codeId
,
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
,
1306 this.logger
.debug(_scope
, 'redemption failed, already redeemed', { ctx
});
1307 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
);
1310 Manager
._sensitiveResponse(res
);
1311 res
.end(JSON
.stringify(response
));
1312 this.logger
.info(_scope
, 'finished', { ctx
});
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
1323 async
_codeToken(dbCtx
, req
, res
, ctx
) {
1324 const _scope
= _fileScope('_codeToken');
1325 this.logger
.debug(_scope
, 'called', { ctx
});
1327 await
this._restoreSessionFromCode(ctx
);
1328 this._checkSessionMatchingRedirectUri(ctx
);
1330 if (ctx
.session
.error
) {
1331 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
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.
1342 const now
= new Date();
1343 const nowEpoch
= common
.dateToEpoch(now
);
1344 const tokenMinters
= [];
1346 tokenMinters
.push(this.mysteryBox
.pack({
1347 c: ctx
.session
.codeId
,
1349 ...(ctx
.session
.tokenLifespan
&& { exp: nowEpoch
+ ctx
.session
.tokenLifespan
}),
1352 if (ctx
.session
.tokenLifespan
1353 && ctx
.session
.refreshLifespan
) {
1354 tokenMinters
.push(this.mysteryBox
.pack({
1355 rc: ctx
.session
.codeId
,
1357 exp: nowEpoch
+ ctx
.session
.refreshLifespan
,
1361 const [token
, refreshToken
] = await Promise
.all(tokenMinters
);
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(' '),
1370 ...(ctx
.session
.acceptedScopes
.includes('profile') && { profile: ctx
.session
.profile
}),
1372 if (!ctx
.session
.acceptedScopes
.includes('email') && response
.profile
) {
1373 delete response
.profile
.email
;
1376 const isValid
= await
this.db
.redeemCode(dbCtx
, {
1378 codeId: ctx
.session
.codeId
,
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
,
1389 this.logger
.debug(_scope
, 'redemption failed, already redeemed', { ctx
});
1390 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
);
1393 Manager
._sensitiveResponse(res
);
1394 res
.end(JSON
.stringify(response
));
1395 this.logger
.info(_scope
, 'finished', { ctx
});
1400 * Issue, refresh, or validate a token.
1401 * @param {http.ClientRequest} req request
1402 * @param {http.ServerResponse} res response
1403 * @param {object} ctx context
1405 async
postToken(req
, res
, ctx
) {
1406 const _scope
= _fileScope('postToken');
1407 this.logger
.debug(_scope
, 'called', { ctx
});
1413 await
this.db
.context(async (dbCtx
) => {
1415 // Is this a (legacy) revocation request?
1416 if (ctx
.parsedBody
['action'] === 'revoke') {
1417 await
this._revokeToken(dbCtx
, res
, ctx
);
1421 // Is this a (legacy) token validation request?
1422 if (req
.getHeader(Enum
.Header
.Authorization
)) {
1423 await
this._validateToken(dbCtx
, res
, ctx
);
1427 const validGrantTypes
= [
1428 'authorization_code',
1430 ...(this.queuePublisher
&& ['ticket'] || []),
1432 this._checkGrantType(ctx
, validGrantTypes
, 'authorization_code');
1434 switch (ctx
.session
.grantType
) {
1435 case 'refresh_token':
1436 return this._refreshToken(dbCtx
, req
, res
, ctx
);
1439 return this._ticketAuthToken(dbCtx
, req
, res
, ctx
);
1441 case 'authorization_code':
1442 return this._codeToken(dbCtx
, req
, res
, ctx
);
1445 this.logger
.debug(_scope
, 'unknown grant_type', { ctx
});
1446 Manager
._setError(ctx
, 'invalid_request', 'grant_type not supported');
1449 // Only way of getting here is due to error.
1450 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
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>}
1464 async
_checkTokenValidationRequest(dbCtx
, req
, ctx
) {
1465 const _scope
= _fileScope('_checkTokenValidationRequest');
1466 const authHeader
= req
.getHeader(Enum
.Header
.Authorization
);
1469 const [authMethod
, authString
] = common
.splitFirst(authHeader
, ' ', '');
1470 switch (authMethod
.toLowerCase()) { // eslint-disable-line sonarjs/no-small-switch
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');
1482 if (!ctx
.bearer
.c
) {
1483 this.logger
.debug(_scope
, 'incomplete token', { ctx
});
1484 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1489 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.bearer
.c
);
1491 this.logger
.error(_scope
, 'failed to look up token', { error: e
, ctx
});
1496 this.logger
.debug(_scope
, 'no token found', { ctx
});
1497 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1501 if (!ctx
.token
.isRevoked
1502 && ctx
.token
.expires
> new Date()) {
1503 ctx
.bearer
.isValid
= true;
1509 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
1517 * Accept an unsolicited ticket proffering.
1518 * @param {http.ClientRequest} req request
1519 * @param {http.ServerResponse} res response
1520 * @param {object} ctx context
1522 async
postTicket(req
, res
, ctx
) {
1523 const _scope
= _fileScope('postTicket');
1524 this.logger
.debug(_scope
, 'called', { ctx
});
1526 if (!this.queuePublisher
) {
1527 this.logger
.debug(_scope
, 'ticket endpoint not configured', { ctx
});
1528 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1531 const queueName
= this.options
.queues
.ticketPublishName
;
1532 const { ticket
, resource
, subject
, iss
} = ctx
.parsedBody
;
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
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
);
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' });
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
});
1560 this.logger
.error(_scope
, 'failed to publish ticket to queue', { error: e
, queueName
, ticket
, resource
, subject
, ctx
});
1561 throw e
; // return a 500
1564 res
.statusCode
= 202;
1566 this.logger
.info(_scope
, 'finished', { resource
, subject
, ctx
});
1572 * @typedef {object} AMQPChannel
1573 * @property {Function} ack ack
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
1581 async
queuedTicketProcessor(channel
, message
) {
1582 const _scope
= _fileScope('queuedTicketProcessor');
1584 const queueName
= this.options
.queues
.ticketRedeemedName
;
1585 let payload
, ticket
, resource
, subject
, iss
;
1587 payload
= message
.content
.toString();
1589 this.logger
.debug(_scope
, 'processing ticket', { payload
});
1595 } = JSON
.parse(payload
));
1597 this.logger
.error(_scope
, 'could not parse message, discarding', { error: e
, message
});
1598 channel
.ack(message
);
1605 issuerUrlObj
= new URL(iss
);
1608 this.logger
.debug(_scope
, 'unparsable issuer, falling back to resource discovery', { error: e
, payload
});
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
);
1620 let isNotRetryable
= false;
1622 await
this.db
.context(async (dbCtx
) => {
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
);
1631 this.logger
.error(_scope
, 'failed to redeem ticket', { error: e
, payload
});
1635 // persist our redemption
1636 const redeemedData
= {
1643 await
this.db
.ticketRedeemed(dbCtx
, redeemedData
);
1646 const result
= await
this.queuePublisher
.publish(queueName
, redeemedData
);
1647 this.logger
.info(_scope
, 'published ticket token', { queueName
, ticket
, resource
, subject
, iss
, result
});
1649 this.logger
.error(_scope
, 'failed to publish token to queue', { error: e
, queueName
, ticket
, token
, resource
, subject
});
1650 throw e
; // return a 500
1653 await
this.db
.ticketTokenPublished(dbCtx
, redeemedData
);
1658 isNotRetryable
= isNotRetryable
1659 || (e
instanceof CommunicationErrors
.ValidationError
)
1660 || (e
?.response
?.statusCode
< 500)
1662 if (isNotRetryable
) {
1663 this.logger
.error(_scope
, 'failed to process ticket, not requeuing', { error: e
, payload
});
1664 channel
.ack(message
);
1667 this.logger
.error(_scope
, 'failed to process ticket, requeuing', { error: e
, payload
});
1674 * Validate a token and return data about it.
1675 * @param {http.ServerResponse} res response
1676 * @param {object} ctx context
1678 async
postIntrospection(res
, ctx
) {
1679 const _scope
= _fileScope('postIntrospection');
1680 this.logger
.debug(_scope
, 'called', { ctx
});
1686 const tokenIsTicket
= (ctx
.parsedBody
['token_hint_type'] || '').toLowerCase() === 'ticket';
1689 const token
= ctx
.parsedBody
['token'];
1690 if (tokenIsTicket
) {
1691 ctx
.token
= await
this._unpackTicket(token
);
1693 ctx
.token
= await
this.mysteryBox
.unpack(token
);
1696 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1700 && !tokenIsTicket
) {
1701 await
this.db
.context(async (dbCtx
) => {
1702 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.token
.c
);
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()) {
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' }),
1723 Manager
._sensitiveResponse(res
);
1724 res
.end(JSON
.stringify(response
));
1725 this.logger
.info(_scope
, 'finished', { ctx
});
1730 * Revoke a token or refresh token.
1731 * @param {http.ServerResponse} res response
1732 * @param {object} ctx context
1734 async
postRevocation(res
, ctx
) {
1735 const _scope
= _fileScope('postRevocation');
1736 this.logger
.debug(_scope
, 'called', { ctx
});
1739 await
this.db
.context(async (dbCtx
) => {
1740 await
this._revokeToken(dbCtx
, res
, ctx
);
1743 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
1747 this.logger
.info(_scope
, 'finished', { ctx
});
1752 * Profile information for a token.
1753 * @param {http.ServerResponse} res response
1754 * @param {object} ctx context
1756 async
postUserInfo(res
, ctx
) {
1757 const _scope
= _fileScope('postUserInfo');
1758 this.logger
.debug(_scope
, 'called', { ctx
});
1760 const token
= ctx
.parsedBody
['token'];
1762 res
.statusCode
= 400;
1763 res
.end('"invalid_request"');
1764 this.logger
.info(_scope
, 'finished, invalid request', { ctx
});
1769 ctx
.token
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['token']);
1771 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1775 await
this.db
.context(async (dbCtx
) => {
1776 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.token
.c
);
1781 || ctx
.token
.isRevoked
1782 // || tokenIsExpired(token)
1784 res
.statusCode
= 401;
1785 res
.end('"invalid_token"');
1786 this.logger
.info(_scope
, 'finished, invalid token', { ctx
});
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
});
1798 ...ctx
.token
.profile
,
1800 if (!ctx
.token
.scopes
.includes('email')) {
1801 delete response
.email
;
1804 Manager
._sensitiveResponse(res
);
1805 res
.end(JSON
.stringify(response
));
1807 this.logger
.info(_scope
, 'finished', { ctx
});
1812 * Show admin interface, allowing manipulation of profiles and scopes.
1813 * @param {http.ServerResponse} res response
1814 * @param {object} ctx context
1816 async
getAdmin(res
, ctx
) {
1817 const _scope
= _fileScope('getAdmin');
1818 this.logger
.debug(_scope
, 'called', { ctx
});
1820 const identifier
= ctx
.session
.authenticatedIdentifier
;
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
);
1827 res
.end(Template
.adminHTML(ctx
, this.options
));
1829 this.logger
.info(_scope
, 'finished', { ctx
});
1834 * Process admin interface events.
1835 * @param {http.ServerResponse} res response
1836 * @param {object} ctx context
1838 async
postAdmin(res
, ctx
) {
1839 const _scope
= _fileScope('postAdmin');
1840 this.logger
.debug(_scope
, 'called', { ctx
});
1842 await
this.db
.context(async (dbCtx
) => {
1843 const identifier
= ctx
.session
.authenticatedIdentifier
;
1844 const action
= ctx
?.parsedBody
?.['action'] || '';
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('[]'));
1852 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
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) */
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
);
1866 ctx
.notifications
.push('Profile/Scope Availability Matrix updated!');
1868 this.logger
.error(_scope
, 'did not set profile scopes', { error: e
, ctx
});
1869 ctx
.errors
.push('Failed to update profile scopes.');
1872 } else if (action
=== 'new-profile') {
1873 // Validate and create a new profile uri.
1875 const profileUri
= ctx
.parsedBody
['profile'];
1877 profile
= await
this.communication
.validateProfile(profileUri
);
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 + ')') : ''}`);
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.');
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']);
1894 ctx
.notifications
.push('Profile added!');
1896 this.logger
.error(_scope
, 'did not insert profile', { error: e
, ctx
});
1897 ctx
.errors
.push('Failed to add profile.');
1902 } else if (action
=== 'new-scope') {
1903 // Add or update a manually-added convenience scope.
1904 const { scope
, application
= '', description
= '' } = ctx
.parsedBody
;
1906 if (!common
.validScope(scope
)) {
1907 ctx
.errors
.push(`"${scope}" is not a valid scope name, did not add it.`);
1910 await
this.db
.scopeUpsert(dbCtx
, scope
, application
, description
, true);
1911 ctx
.notifications
.push('Scope List updated!');
1913 this.logger
.error(_scope
, 'did not upsert scope', { error: e
, scope
, application
, description
, ctx
});
1914 ctx
.errors
.push('Failed to update scope.');
1919 } else if (action
.startsWith('delete-scope-')) {
1920 // Remove a manually-added convenience scope.
1921 const scope
= decodeURIComponent(action
.slice(13));
1924 const deleted
= await
this.db
.scopeDelete(dbCtx
, scope
);
1926 ctx
.notifications
.push('Scope deleted.');
1928 ctx
.notifications
.push('Unable to delete scope.');
1931 this.logger
.error(_scope
, 'did not delete scope', { error: e
, scope
, ctx
});
1932 ctx
.errors
.push('Failed to delete scope.');
1936 } else if (action
.startsWith('revoke-')) {
1937 // Revoke an active token.
1938 const codeId
= action
.slice(8);
1941 await
this.db
.tokenRevokeByCodeId(dbCtx
, codeId
, identifier
);
1942 ctx
.notifications
.push('Revoked token!');
1944 this.logger
.error(_scope
, 'did not revoke token', { error: e
, codeId
, identifier
, ctx
});
1945 ctx
.errors
.push('Unable to revoke token.');
1949 } else if (action
) {
1950 ctx
.errors
.push(`Do not know how to '${action}'.`);
1953 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1954 ctx
.tokens
= await
this.db
.tokensGetByIdentifier(dbCtx
, identifier
);
1957 res
.end(Template
.adminHTML(ctx
, this.options
));
1959 this.logger
.info(_scope
, 'finished', { ctx
});
1964 * Show ticket proffer interface.
1965 * @param {http.ServerResponse} res response
1966 * @param {object} ctx context
1968 async
getAdminTicket(res
, ctx
) {
1969 const _scope
= _fileScope('getAdminTicket');
1970 this.logger
.debug(_scope
, 'called', { ctx
});
1972 const identifier
= ctx
.session
.authenticatedIdentifier
;
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
);
1980 res
.end(Template
.adminTicketHTML(ctx
, this.options
));
1982 this.logger
.info(_scope
, 'finished', { ctx
});
1987 * Handle ticket proffer interface submission.
1988 * @param {http.ServerResponse} res response
1989 * @param {object} ctx context
1991 async
postAdminTicket(res
, ctx
) {
1992 const _scope
= _fileScope('postAdminTicket');
1993 this.logger
.debug(_scope
, 'called', { ctx
});
1995 switch (ctx
.parsedBody
['action']) { // eslint-disable-line sonarjs/no-small-switch
1996 case 'proffer-ticket': {
1997 const identifier
= ctx
.session
.authenticatedIdentifier
;
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
) => {
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
);
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.');
2017 ctx
.ticketEndpointUrl
= new URL(subjectData
.metadata
.ticketEndpoint
);
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})`);
2024 const scopesSet
= new Set();
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
);
2033 this.logger
.debug(_scope
, 'invalid adhoc scope', { scope
, ctx
});
2034 ctx
.errors
.push(`'${scope}' is not a valid scope.`);
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.');
2044 if (!ctx
.errors
.length
) {
2045 const ticketData
= {
2046 subject: ctx
.ticketSubjectUrl
.href
,
2047 resource: ctx
.ticketResourceUrl
.href
,
2048 scopes: ctx
.ticketScopes
,
2050 profile: ctx
.ticketProfileUrl
.href
,
2051 ticketLifespanSeconds: this.options
.manager
.ticketLifespanSeconds
,
2053 const ticket
= await
this._mintTicket(ticketData
);
2055 await
this.db
.context(async (dbCtx
) => {
2056 // re-populate form fields
2057 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
2059 // TODO: queue ticket for delivery/retry to subject instead of trying immediately
2060 // ctx.notifications.push('Success! Ticket will be delivered!');
2062 this.logger
.debug(_scope
, 'ticket created', { ctx
, ticketData
, subjectData
});
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
});
2070 this.logger
.error(_scope
, 'failed to deliver ticket', { ctx
, error: e
});
2071 ctx
.errors
.push(`Failed to deliver ticket. (${e})`);
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
);
2088 this.logger
.debug(_scope
, 'unknown action', { ctx
});
2091 res
.end(Template
.adminTicketHTML(ctx
, this.options
));
2093 this.logger
.info(_scope
, 'finished', { ctx
});
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
2103 async
getAdminMaintenance(res
, ctx
) {
2104 const _scope
= _fileScope('getAdminMaintenance');
2105 this.logger
.debug(_scope
, 'called', { ctx
});
2107 const maintenanceTasks
= [];
2109 await
this.db
.context(async (dbCtx
) => {
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.
2116 ctx
.notifications
.push(`Running maintenance chore "${chore}".`);
2120 await Promise
.all(maintenanceTasks
);
2122 ctx
.almanac
= await
this.db
.almanacGetAll(dbCtx
);
2125 const winnowChoreEntry
= ([name
, value
]) => [name
, common
.pick(value
, ['intervalMs', 'nextSchedule'])];
2126 ctx
.chores
= Object
.fromEntries(
2127 Object
.entries(this.chores
.chores
).map(winnowChoreEntry
),
2130 res
.end(Template
.adminMaintenanceHTML(ctx
, this.options
));
2132 this.logger
.info(_scope
, 'finished', { ctx
});
2138 * @param {http.ServerResponse} res response
2139 * @param {object} ctx context
2141 async
getHealthcheck(res
, ctx
) {
2142 const _scope
= _fileScope('getHealthcheck');
2143 this.logger
.debug(_scope
, 'called', { ctx
});
2144 await
this.db
.healthCheck();
2150 module
.exports
= Manager
;