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');
13 const _fileScope
= common
.fileScope(__filename
);
15 // These are used during request ingestion and validation
16 const validBase64URLRE
= /^[-A-Za-z0-9_]+$/;
17 const scopeSplitRE
= / +/;
19 const supportedCodeChallengeMethods
= ['S256', 'SHA256'];
22 constructor(logger
, db
, options
) {
23 this.options
= options
;
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
);
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
)));
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
;
43 * Perform any async startup tasks.
46 if (this.queuePublisher
) {
47 await
this._connectQueues();
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
);
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
);
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.
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
74 static _setError(ctx
, error
, errorDescription
) {
75 const errorPrecedence
= [ // By increasing severity
77 'unsupported_response_type',
79 'unauthorized_client',
82 'temporarily_unavailable',
85 if (!(errorPrecedence
.includes(error
))) {
86 throw new RangeError(`invalid error value '${error}'`);
88 if (!ctx
.session
.errorDescriptions
) {
89 ctx
.session
.errorDescriptions
= [];
91 if (!common
.validError(errorDescription
)) {
92 throw new RangeError(`invalid error description '${errorDescription}'`);
94 const isHigherPrecedence
= errorPrecedence
.indexOf(error
) > errorPrecedence
.indexOf(ctx
.session
.error
);
95 if (!ctx
.session
.error
|| isHigherPrecedence
) {
96 ctx
.session
.error
= error
;
98 if (isHigherPrecedence
) {
99 ctx
.session
.errorDescriptions
.unshift(errorDescription
);
101 ctx
.session
.errorDescriptions
.push(errorDescription
);
107 * Discourage caching of a response.
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
114 static _sensitiveResponse(res
) {
116 [Enum
.Header
.CacheControl
]: 'no-store',
117 [Enum
.Header
.Pragma
]: 'no-cache',
118 }).forEach(([k
, v
]) => res
.setHeader(k
, v
));
123 * Sets params entries as url search parameters.
125 * @param {Object} params
127 static _setSearchParams(url
, params
) {
128 Object
.entries(params
).forEach((param
) => url
.searchParams
.set(...param
));
133 * Serve the informational root page.
134 * @param {http.ClientRequest} req
135 * @param {http.ServerResponse} res
136 * @param {Object} ctx
138 async
getRoot(res
, ctx
) {
139 const _scope
= _fileScope('getRoot');
140 this.logger
.debug(_scope
, 'called', { ctx
});
142 res
.end(Template
.rootHTML(ctx
, this.options
));
143 this.logger
.info(_scope
, 'finished', { ctx
});
148 * Serve the metadata for this service.
149 * @param {http.ServerResponse} res
150 * @param {Object} ctx
152 async
getMeta(res
, ctx
) {
153 const _scope
= _fileScope('getMeta');
154 this.logger
.debug(_scope
, 'called', { ctx
});
156 const base
= this.options
.dingus
.selfBaseUrl
;
157 const endpoint
= (r
) => `${base}${this.options.route[r]}`; // eslint-disable-line security/detect-object-injection
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',
173 ...(this.queuePublisher
&& ['ticket'] || []),
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'),
181 res
.end(JSON
.stringify(metadata
));
182 this.logger
.info(_scope
, 'finished', { ctx
});
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
193 async
getAuthorization(res
, ctx
) {
194 const _scope
= _fileScope('getAuthorization');
195 this.logger
.debug(_scope
, 'called', { ctx
});
197 Manager
._sensitiveResponse(res
);
201 errorDescriptions: [],
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
);
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
});
218 // Set error response for template to render.
219 ctx
.errors
.push('Cannot redirect to client application.');
221 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
223 res
.statusCode
= 400;
224 res
.end(Template
.authorizationErrorHTML(ctx
, this.options
));
225 this.logger
.info(_scope
, 'bad request', { ctx
});
229 await
this.db
.context(async (dbCtx
) => {
230 const profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, ctx
.authenticationId
);
231 Object
.assign(ctx
.session
, {
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.');
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
;
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>');
257 if (!ctx
.session
.codeChallenge
) {
258 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge\'');
260 if (!ctx
.session
.codeChallengeMethod
) {
261 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
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
});
270 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
271 'state': ctx
.session
.state
,
272 'error': ctx
.session
.error
,
273 'error_description': ctx
.session
.errorDescriptions
.join(', '),
275 res
.statusCode
= 302; // Found
276 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
278 this.logger
.info(_scope
, 'bad request', { ctx
});
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
,
294 profiles: ctx
.session
.profiles
,
295 requestedScopes: ctx
.session
.scope
,
296 authenticationId: ctx
.authenticationId
,
299 // Present authenticated user the option to submit consent
300 const content
= Template
.authorizationRequestHTML(ctx
, this.options
);
303 this.logger
.info(_scope
, 'finished', { ctx
});
308 * Validates, fetches, and parses client_id url, populating clientIdentifier with client h-app data.
309 * @param {Object} ctx
311 async
_clientIdRequired(ctx
) {
312 if (ctx
.queryParams
['client_id']) {
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');
321 ctx
.session
.clientId
= undefined;
322 if (e
instanceof CommunicationErrors
.ValidationError
) {
323 Manager
._setError(ctx
, 'invalid_request', e
.message
);
325 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'client_id\'');
328 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'client_id\'');
334 * Ensure redirect_uri exists and is corroborated by clientIdentifier data.
335 * @param {Object} ctx
337 static _redirectURIRequired(ctx
) {
338 if (ctx
.queryParams
['redirect_uri']) {
340 ctx
.session
.redirectUri
= new URL(ctx
.queryParams
['redirect_uri']);
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);
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;
359 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'redirect_uri\'');
362 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'redirect_uri\'');
368 * response_type must be valid
369 * @param {Object} ctx
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\'');
379 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'response_type\'');
385 * A state parameter must be present
386 * @param {Object} ctx
388 static _stateRequired(ctx
) {
389 ctx
.session
.state
= ctx
.queryParams
['state'];
390 if (ctx
.session
.state
) {
391 // No restrictions on content of this
393 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'state\'');
399 * A code_challenge_method must be present and valid
400 * @param {Object} ctx
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');
409 if (this.options
.manager
.allowLegacyNonPKCE
) {
412 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
418 * A code_challenge must be present
419 * @param {Object} ctx
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\'');
428 if (this.options
.manager
.allowLegacyNonPKCE
) {
431 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge\'');
437 * Scopes may be present, with one known combination limitation
438 * @param {Object} ctx
440 _scopeOptional(ctx
) {
441 const _scope
= _fileScope('_scopeOptional');
442 const scope
= ctx
.queryParams
['scope'];
443 ctx
.session
.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
});
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');
462 * Parses me, if provided
463 * @param {Object} ctx
465 async
_meOptional(ctx
) {
466 const me
= ctx
.queryParams
['me'];
469 ctx
.session
.me
= await
this.communication
.validateProfile(me
);
471 ctx
.session
.me
= undefined;
478 * Ensure authenticated identifier matches profile.
479 * @param {Object} ctx
482 _profileValidForIdentifier(ctx
) {
483 const _scope
= _fileScope('_profileValidForIdentifier');
485 if (!ctx
.session
.me
) {
486 this.logger
.debug(_scope
, 'no profile provided, cannot correlate', { ctx
});
490 return ctx
.session
.profiles
.includes(ctx
.session
.me
.href
);
495 * Get numeric value from form field data.
497 * @param {String} field
498 * @param {String} customField
501 _parseLifespan(ctx
, field
, customField
) {
502 const _scope
= _fileScope('_parseLifespan');
504 const presetValues
= {
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
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
;
520 this.logger
.debug(_scope
, 'invalid custom value', { ctx
, field
, customField
});
524 this.logger
.debug(_scope
, 'invalid value', { ctx
, field
, customField
});
530 * Validate any accepted scopes, ensure uniqueness, return as array.
531 * @param {Object} ctx
534 _parseConsentScopes(ctx
) {
535 const _scope
= _fileScope('_ingestConsentScopes');
536 const acceptedScopesSet
= new Set();
537 const rejectedScopesSet
= new Set();
539 const submittedScopes
= common
.ensureArray(ctx
.parsedBody
['accepted_scopes'])
540 .concat((ctx
.parsedBody
['ad_hoc_scopes'] || '').split(scopeSplitRE
));
541 submittedScopes
.forEach((scope
) => {
543 (common
.validScope(scope
) ? acceptedScopesSet : rejectedScopesSet
).add(scope
);
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)');
554 if (rejectedScopesSet
.size
) {
555 this.logger
.debug(_scope
, 'ignoring invalid scopes', { ctx
, rejectedScopes: Array
.from(rejectedScopesSet
) });
558 return Array
.from(acceptedScopesSet
);
563 * Parse and validate selected me is a valid profile option.
564 * @param {Object} ctx
567 _parseConsentMe(ctx
) {
568 const _scope
= _fileScope('_parseConsentMe');
569 const selectedMe
= ctx
.parsedBody
['me'];
571 const me
= new URL(selectedMe
);
572 if (ctx
.session
.profiles
.includes(me
.href
)) {
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');
579 this.logger
.debug(_scope
, 'failed to parse selected \'me\' as url', { error: e
, ctx
});
580 Manager
._setError(ctx
, 'invalid_request', 'invalid profile url');
587 * Get up-to-date profile data from selected profile endpoint.
588 * @param {Object} ctx
589 * @returns {Promise<Object>}
591 async
_fetchConsentProfileData(ctx
) {
592 const _scope
= _fileScope('_fetchConsentProfileData');
594 const profile
= await
this.communication
.fetchProfile(ctx
.session
.me
);
596 this.logger
.debug(_scope
, 'no profile data at \'me\' endpoint', { ctx
});
597 Manager
._setError(ctx
, 'temporarily_unavailable', 'unable to retrieve profile');
599 // Profile info gets persisted in code, only include known profile fields to help keep size down.
600 return common
.pick(profile
, [
608 this.logger
.debug(_scope
, 'failed to fetch \'me\' endpoint', { error: e
, ctx
});
609 Manager
._setError(ctx
, 'temporarily_unavailable', 'could not reach profile endpoint');
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
630 async
postConsent(res
, ctx
) {
631 const _scope
= _fileScope('postConsent');
632 this.logger
.debug(_scope
, 'called', { ctx
});
634 Manager
._sensitiveResponse(res
);
636 // Ensure session exists, persisting any login session data.
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
);
647 this.logger
.debug(_scope
, 'failed to unpack session', { error: e
, ctx
});
648 Manager
._setError(ctx
, 'invalid_request', 'un-parsable data in authorization consent');
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.
655 'Cannot redirect to client application.',
658 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
660 res
.statusCode
= 400;
661 res
.end(Template
.authorizationErrorHTML(ctx
, this.options
));
662 this.logger
.info(_scope
, 'bad request, cannot redirect', { ctx
});
666 // TODO: Should probably re-validate more unpacked session values, even though those should be trustable.
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');
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');
684 if (ctx
.session
.error
) {
685 this.logger
.debug(_scope
, 'invalid request, redirecting', { ctx
});
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(', '),
693 res
.statusCode
= 302; // Found
694 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
696 this.logger
.info(_scope
, 'bad request, redirected', { ctx
});
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
716 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
718 'state': ctx
.session
.state
,
719 'iss': this.options
.dingus
.selfBaseUrl
,
721 res
.statusCode
= 302;
722 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
725 this.logger
.info(_scope
, 'finished', { ctx
});
730 * Redeem a code for a profile url, and maybe more profile info.
731 * @param {http.ServerResponse} res
732 * @param {Object} ctx
734 async
postAuthorization(res
, ctx
) {
735 const _scope
= _fileScope('postAuthorization');
736 this.logger
.debug(_scope
, 'called', { ctx
});
738 await
this._ingestPostAuthorizationRequest(ctx
);
742 ...(ctx
.session
?.acceptedScopes
?.includes('profile') && { profile: ctx
.session
.profile
}),
743 scope: ctx
.session
.acceptedScopes
,
745 if (response
.profile
&& !ctx
.session
?.acceptedScopes
?.includes('email')) {
746 delete response
.profile
.email
;
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
,
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
,
764 this.logger
.debug(_scope
, 'code already redeemed', { ctx
});
765 Manager
._setError(ctx
, 'access_denied', 'code already redeemed');
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(', '),
776 this.logger
.info(_scope
, 'invalid request', { ctx
});
780 res
.end(JSON
.stringify(response
));
782 this.logger
.info(_scope
, 'finished', { ctx
, response
});
787 * Ingest an incoming authorization redemption request, parsing fields
788 * onto a new session object on the context.
790 * @param {Object} ctx
792 async
_ingestPostAuthorizationRequest(ctx
) {
793 const _scope
= _fileScope('_ingestPostAuthorizationRequest');
797 errorDescriptions: [],
800 if (!ctx
.parsedBody
) {
801 this.logger
.debug(_scope
, 'no body data', { ctx
});
802 Manager
._setError(ctx
, 'invalid_request', 'missing data');
805 await
this._restoreSessionFromCode(ctx
);
806 this._checkSessionMatchingClientId(ctx
);
807 this._checkSessionMatchingRedirectUri(ctx
);
808 this._checkGrantType(ctx
);
809 this._checkSessionMatchingCodeVerifier(ctx
);
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');
817 const expires
= new Date(ctx
.session
.minted
+ this.options
.manager
.codeValidityTimeoutMs
);
818 const now
= new Date();
820 this.logger
.debug(_scope
, 'code expired', { ctx
});
821 Manager
._setError(ctx
, 'invalid_request', 'code has expired');
827 * Unpack the session data from provided code overtop of context session ..
828 * @param {Object} ctx
830 async
_restoreSessionFromCode(ctx
) {
831 const _scope
= _fileScope('_restoreSessionFromCode');
833 const code
= ctx
.parsedBody
['code'];
836 const oldSession
= await
this.mysteryBox
.unpack(code
);
838 // TODO: Validate unpacked fields better
839 const missingFields
= [
841 'codeChallengeMethod',
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
});
858 this.logger
.debug(_scope
, 'unpacked code is missing required field', { missingFields
, ctx
});
859 Manager
._setError(ctx
, 'invalid_request', 'code is not valid');
863 Object
.assign(ctx
.session
, oldSession
);
865 this.logger
.debug(_scope
, 'failed to parse code', { error: e
, ctx
});
866 Manager
._setError(ctx
, 'invalid_request', 'code is not valid');
869 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code\'');
875 * Ensure provided client_id matches session clientId.
876 * @param {Object} ctx
878 _checkSessionMatchingClientId(ctx
) {
879 const _scope
= _fileScope('_checkSessionMatchingClientId');
881 let clientId
= ctx
.parsedBody
['client_id'];
884 clientId
= new URL(clientId
);
885 ctx
.session
.clientId
= new URL(ctx
.session
.clientId
);
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');
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');
898 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'client_id\'');
904 * @param {Object} ctx
906 _checkSessionMatchingRedirectUri(ctx
) {
907 const _scope
= _fileScope('_checkSessionMatchingClientId');
909 let redirectUri
= ctx
.parsedBody
['redirect_uri'];
912 redirectUri
= new URL(redirectUri
);
913 ctx
.session
.redirectUri
= new URL(ctx
.session
.redirectUri
);
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');
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');
926 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'redirect_uri\'');
932 * Validate grant_type, either persist on session or set error.
933 * @param {Object} ctx
934 * @param {String[]} validGrantTypes
935 * @param {Boolean} treatEmptyAs
937 _checkGrantType(ctx
, validGrantTypes
= ['authorization_code'], treatEmptyAs
= 'authorization_code') {
938 const _scope
= _fileScope('_checkGrantType');
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
});
944 if (validGrantTypes
.includes(grantType
)) {
945 ctx
.session
.grantType
= grantType
;
947 Manager
._setError(ctx
, 'invalid_request', 'grant_type not supported');
953 * @param {Object} ctx
955 _checkSessionMatchingCodeVerifier(ctx
) {
956 const _scope
= _fileScope('_checkSessionMatchingCodeVerifier');
958 const codeVerifier
= ctx
.parsedBody
['code_verifier'];
961 const valid
= Communication
.verifyChallenge(ctx
.session
.codeChallenge
, codeVerifier
, ctx
.session
.codeChallengeMethod
);
963 this.logger
.debug(_scope
, 'challenge mismatched', { ctx
});
964 Manager
._setError(ctx
, 'invalid_request', 'challenge verification failed');
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');
971 if (this.options
.manager
.allowLegacyNonPKCE
972 && !ctx
.session
.codeChallenge
973 && !ctx
.session
.codeChallengeMethod
) {
974 this.logger
.debug(_scope
, 'allowing non-PKCE', { ctx
});
977 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_verifier\'');
983 * Attempt to revoke a token.
985 * @param {http.ServerResponse} res
986 * @param {Object} ctx
988 async
_revokeToken(dbCtx
, res
, ctx
) {
989 const _scope
= _fileScope('_revokeToken');
991 const token
= ctx
.parsedBody
['token'];
992 const tokenTypeHint
= ctx
.parsedBody
['token_type_hint'];
993 switch (tokenTypeHint
) {
998 case 'refresh_token':
1001 this.logger
.debug(_scope
, 'unknown token_type_hint', { ctx
});
1004 throw new ValidationError('Token Missing');
1006 ctx
.token
= await
this.mysteryBox
.unpack(token
);
1007 if (!(ctx
.token
?.c
|| ctx
.token
?.rc
)) {
1008 throw new ValidationError('Token Invalid');
1011 this.logger
.debug(_scope
, 'invalid token', { error: e
, ctx
});
1012 res
.statusCode
= 400;
1014 this.logger
.info(_scope
, 'finished, revoke request not valid', { error: e
, ctx
});
1020 await
this.db
.tokenRevokeByCodeId(dbCtx
, ctx
.token
.c
);
1022 await
this.db
.tokenRefreshRevokeByCodeId(dbCtx
, ctx
.token
.rc
);
1025 if (e
instanceof DBErrors
.UnexpectedResult
) {
1026 res
.statusCode
= 404;
1028 this.logger
.info(_scope
, 'finished, no token to revoke', { error: e
, ctx
});
1031 this.logger
.error(_scope
, 'revoke token failed', { error: e
, ctx
});
1036 this.logger
.info(_scope
, 'finished, token revoked', { ctx
});
1041 * Legacy token validation flow.
1043 * @param {http.ClientRequest} req
1044 * @param {http.ServerResponse} res
1045 * @param {Object} ctx
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
,
1057 this.logger
.info(_scope
, 'finished, token validated', { ctx
});
1059 const responseErrorParts
= ['Bearer'];
1060 const error
= ctx
.session
.error
? `error="${ctx.session.error}"` : '';
1062 responseErrorParts
.push(error
);
1064 const errorDescription
= ctx
.session
.errorDescriptions
? `error_description="${ctx.session.errorDescriptions.join(', ')}"` : '';
1065 if (errorDescription
) {
1066 responseErrorParts
.push(errorDescription
);
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
);
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[]}
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
);
1092 return scopesToRemove
;
1097 * Redeem a refresh token for a new token.
1099 * @param {http.ClientRequest} req
1100 * @param {http.ServerResponse} res
1101 * @param {Object} ctx
1103 async
_refreshToken(dbCtx
, req
, res
, ctx
) {
1104 const _scope
= _fileScope('_refreshToken');
1105 this.logger
.debug(_scope
, 'called', { ctx
});
1108 'client_id': clientId
,
1113 ctx
.refreshToken
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['refresh_token']);
1115 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1118 const now
= new Date();
1119 const nowEpoch
= common
.dateToEpoch(now
);
1121 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1122 if (ctx
.refreshToken
?.rc
) {
1123 ctx
.token
= await
this.db
.tokenGetByCodeId(txCtx
, ctx
.refreshToken
.rc
);
1127 this.logger
.debug(_scope
, 'no token to refresh', { ctx
});
1128 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
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
);
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
);
1143 if (clientId
!== ctx
.token
.clientId
) {
1144 this.logger
.debug(_scope
, 'client identifier mismatch', { ctx
});
1145 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
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
});
1153 const refreshedTokenData
= await
this.db
.refreshCode(txCtx
, ctx
.refreshToken
.rc
, now
, scopesToRemove
);
1154 if (refreshedTokenData
) {
1155 Object
.assign(ctx
.token
, refreshedTokenData
);
1157 this.logger
.debug(_scope
, 'could not refresh token', { ctx
});
1158 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
1162 const [token
, refreshToken
] = await Promise
.all([
1164 c: ctx
.token
.codeId
,
1168 rc: ctx
.token
.codeId
,
1170 exp: nowEpoch
+ ctx
.token
.refreshDuration
,
1172 ].map(this.mysteryBox
.pack
));
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(' '),
1181 ...(ctx
.token
.scopes
.includes('profile') && { profile: ctx
.token
.profileData
}),
1183 if (ctx
.token
.scopes
.includes('profile') && !ctx
.token
.scopes
.includes('email')) {
1184 delete response
?.profile
?.email
;
1187 Manager
._sensitiveResponse(res
);
1188 res
.end(JSON
.stringify(response
));
1189 this.logger
.info(_scope
, 'finished', { ctx
});
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>}
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
});
1208 const nowEpoch
= common
.dateToEpoch();
1209 return this.mysteryBox
.pack({
1210 c: common
.requestId(),
1212 exp: nowEpoch
+ ticketLifespanSeconds
,
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
1235 * @param {String} ticket
1236 * @returns {Promise<Ticket>}
1238 async
_unpackTicket(ticket
) {
1239 const ticketObj
= await
this.mysteryBox
.unpack(ticket
);
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
),
1254 * Redeem a ticket for a token.
1256 * @param {http.ClientRequest} req
1257 * @param {http.ServerResponse} res
1258 * @param {Object} ctx
1260 async
_ticketAuthToken(dbCtx
, req
, res
, ctx
) {
1261 const _scope
= _fileScope('_ticketAuthToken');
1262 this.logger
.debug(_scope
, 'called', { ctx
});
1265 ctx
.ticket
= await
this._unpackTicket(ctx
.parsedBody
['ticket']);
1267 this.logger
.debug(_scope
, 'failed to unpack ticket', { error: e
, ctx
});
1268 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
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
});
1277 const nowEpoch
= common
.dateToEpoch(now
);
1278 const token
= await
this.mysteryBox
.pack({
1279 c: ctx
.ticket
.codeId
,
1284 'access_token': token
,
1285 'token_type': 'Bearer',
1286 scope: ctx
.ticket
.scopes
.join(' '),
1287 me: ctx
.ticket
.profile
.href
,
1290 const isValid
= await
this.db
.redeemCode(dbCtx
, {
1292 codeId: ctx
.ticket
.codeId
,
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
,
1301 this.logger
.debug(_scope
, 'redemption failed, already redeemed', { ctx
});
1302 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
);
1305 Manager
._sensitiveResponse(res
);
1306 res
.end(JSON
.stringify(response
));
1307 this.logger
.info(_scope
, 'finished', { ctx
});
1312 * Redeem a code for a token.
1314 * @param {http.ClientRequest} req
1315 * @param {http.ServerResponse} res
1316 * @param {Object} ctx
1318 async
_codeToken(dbCtx
, req
, res
, ctx
) {
1319 const _scope
= _fileScope('_codeToken');
1320 this.logger
.debug(_scope
, 'called', { ctx
});
1322 await
this._restoreSessionFromCode(ctx
);
1323 this._checkSessionMatchingRedirectUri(ctx
);
1325 if (ctx
.session
.error
) {
1326 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
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.
1337 const now
= new Date();
1338 const nowEpoch
= common
.dateToEpoch(now
);
1339 const tokenMinters
= [];
1341 tokenMinters
.push(this.mysteryBox
.pack({
1342 c: ctx
.session
.codeId
,
1344 ...(ctx
.session
.tokenLifespan
&& { exp: nowEpoch
+ ctx
.session
.tokenLifespan
}),
1347 if (ctx
.session
.tokenLifespan
1348 && ctx
.session
.refreshLifespan
) {
1349 tokenMinters
.push(this.mysteryBox
.pack({
1350 rc: ctx
.session
.codeId
,
1352 exp: nowEpoch
+ ctx
.session
.refreshLifespan
,
1356 const [token
, refreshToken
] = await Promise
.all(tokenMinters
);
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(' '),
1365 ...(ctx
.session
.acceptedScopes
.includes('profile') && { profile: ctx
.session
.profile
}),
1367 if (!ctx
.session
.acceptedScopes
.includes('email') && response
.profile
) {
1368 delete response
.profile
.email
;
1371 const isValid
= await
this.db
.redeemCode(dbCtx
, {
1373 codeId: ctx
.session
.codeId
,
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
,
1384 this.logger
.debug(_scope
, 'redemption failed, already redeemed', { ctx
});
1385 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
);
1388 Manager
._sensitiveResponse(res
);
1389 res
.end(JSON
.stringify(response
));
1390 this.logger
.info(_scope
, 'finished', { ctx
});
1395 * Issue, refresh, or validate a token.
1396 * @param {http.ClientRequest} req
1397 * @param {http.ServerResponse} res
1398 * @param {Object} ctx
1400 async
postToken(req
, res
, ctx
) {
1401 const _scope
= _fileScope('postToken');
1402 this.logger
.debug(_scope
, 'called', { ctx
});
1408 await
this.db
.context(async (dbCtx
) => {
1410 // Is this a (legacy) revocation request?
1411 if (ctx
.parsedBody
['action'] === 'revoke') {
1412 await
this._revokeToken(dbCtx
, res
, ctx
);
1416 // Is this a (legacy) token validation request?
1417 if (req
.getHeader(Enum
.Header
.Authorization
)) {
1418 await
this._validateToken(dbCtx
, res
, ctx
);
1422 const validGrantTypes
= [
1423 'authorization_code',
1425 ...(this.queuePublisher
&& ['ticket'] || []),
1427 this._checkGrantType(ctx
, validGrantTypes
, 'authorization_code');
1429 switch (ctx
.session
.grantType
) {
1430 case 'refresh_token':
1431 return this._refreshToken(dbCtx
, req
, res
, ctx
);
1434 return this._ticketAuthToken(dbCtx
, req
, res
, ctx
);
1436 case 'authorization_code':
1437 return this._codeToken(dbCtx
, req
, res
, ctx
);
1440 this.logger
.debug(_scope
, 'unknown grant_type', { ctx
});
1441 Manager
._setError(ctx
, 'invalid_request', 'grant_type not supported');
1444 // Only way of getting here is due to error.
1445 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
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.
1455 * @param {http.ClientRequest} req
1456 * @param {Object} ctx
1458 async
_checkTokenValidationRequest(dbCtx
, req
, ctx
) {
1459 const _scope
= _fileScope('_checkTokenValidationRequest');
1460 const authHeader
= req
.getHeader(Enum
.Header
.Authorization
);
1463 const [authMethod
, authString
] = common
.splitFirst(authHeader
, ' ', '');
1464 switch (authMethod
.toLowerCase()) { // eslint-disable-line sonarjs/no-small-switch
1470 Object
.assign(ctx
.bearer
, await
this.mysteryBox
.unpack(authString
));
1472 this.logger
.debug(_scope
, 'failed to unpack token', { ctx
});
1473 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1476 if (!ctx
.bearer
.c
) {
1477 this.logger
.debug(_scope
, 'incomplete token', { ctx
});
1478 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1483 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.bearer
.c
);
1485 this.logger
.error(_scope
, 'failed to look up token', { error: e
, ctx
});
1490 this.logger
.debug(_scope
, 'no token found', { ctx
});
1491 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1495 if (!ctx
.token
.isRevoked
1496 && ctx
.token
.expires
> new Date()) {
1497 ctx
.bearer
.isValid
= true;
1503 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
1511 * Accept an unsolicited ticket proffering.
1512 * @param {http.ClientRequest} req
1513 * @param {http.ServerResponse} res
1514 * @param {Object} ctx
1516 async
postTicket(req
, res
, ctx
) {
1517 const _scope
= _fileScope('postTicket');
1518 this.logger
.debug(_scope
, 'called', { ctx
});
1520 if (!this.queuePublisher
) {
1521 this.logger
.debug(_scope
, 'ticket endpoint not configured', { ctx
});
1522 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1525 const queueName
= this.options
.queues
.ticketPublishName
;
1526 const { ticket
, resource
, subject
, iss
} = ctx
.parsedBody
;
1532 this.logger
.debug(_scope
, 'unparsable issuer', { ticket
, resource
, subject
, iss
, ctx
});
1533 // continue, will try resource for metadata
1539 this.logger
.debug(_scope
, 'unparsable resource', { ticket
, resource
, subject
, ctx
});
1540 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
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' });
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
});
1554 this.logger
.error(_scope
, 'failed to publish ticket to queue', { error: e
, queueName
, ticket
, resource
, subject
, ctx
});
1555 throw e
; // return a 500
1558 res
.statusCode
= 202;
1560 this.logger
.info(_scope
, 'finished', { resource
, subject
, ctx
});
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
1571 async
queuedTicketProcessor(channel
, message
) {
1572 const _scope
= _fileScope('queuedTicketProcessor');
1574 const queueName
= this.options
.queues
.ticketRedeemedName
;
1575 let payload
, ticket
, resource
, subject
, iss
;
1577 payload
= message
.content
.toString();
1579 this.logger
.debug(_scope
, 'processing ticket', { payload
});
1585 } = JSON
.parse(payload
));
1587 this.logger
.error(_scope
, 'could not parse message, discarding', { error: e
, message
});
1588 channel
.ack(message
);
1595 issuerUrlObj
= new URL(iss
);
1598 this.logger
.debug(_scope
, 'unparsable issuer, falling back to resource discovery', { error: e
, payload
});
1603 resourceUrlObj
= new URL(resource
);
1605 this.logger
.error(_scope
, 'unparsable resource, discarding', { payload
});
1606 channel
.ack(message
);
1610 let isNotRetryable
= false;
1612 await
this.db
.context(async (dbCtx
) => {
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
);
1621 this.logger
.error(_scope
, 'failed to redeem ticket', { error: e
, payload
});
1625 // persist our redemption
1626 const redeemedData
= {
1633 await
this.db
.ticketRedeemed(dbCtx
, redeemedData
);
1636 const result
= await
this.queuePublisher
.publish(queueName
, redeemedData
);
1637 this.logger
.info(_scope
, 'published ticket token', { queueName
, ticket
, resource
, subject
, iss
, result
});
1639 this.logger
.error(_scope
, 'failed to publish token to queue', { error: e
, queueName
, ticket
, token
, resource
, subject
});
1640 throw e
; // return a 500
1643 await
this.db
.ticketTokenPublished(dbCtx
, redeemedData
);
1648 isNotRetryable
= isNotRetryable
1649 || (e
instanceof CommunicationErrors
.ValidationError
)
1650 || (e
?.response
?.statusCode
< 500)
1652 if (isNotRetryable
) {
1653 this.logger
.error(_scope
, 'failed to process ticket, not requeuing', { error: e
, payload
});
1654 channel
.ack(message
);
1657 this.logger
.error(_scope
, 'failed to process ticket, requeuing', { error: e
, payload
});
1664 * Validate a token and return data about it.
1665 * @param {http.ServerResponse} res
1666 * @param {Object} ctx
1668 async
postIntrospection(res
, ctx
) {
1669 const _scope
= _fileScope('postIntrospection');
1670 this.logger
.debug(_scope
, 'called', { ctx
});
1676 const tokenIsTicket
= (ctx
.parsedBody
['token_hint_type'] || '').toLowerCase() === 'ticket';
1679 const token
= ctx
.parsedBody
['token'];
1680 if (tokenIsTicket
) {
1681 ctx
.token
= await
this._unpackTicket(token
);
1683 ctx
.token
= await
this.mysteryBox
.unpack(token
);
1686 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1690 && !tokenIsTicket
) {
1691 await
this.db
.context(async (dbCtx
) => {
1692 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.token
.c
);
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()) {
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' }),
1713 Manager
._sensitiveResponse(res
);
1714 res
.end(JSON
.stringify(response
));
1715 this.logger
.info(_scope
, 'finished', { ctx
});
1720 * Revoke a token or refresh token.
1721 * @param {http.ServerResponse} res
1722 * @param {Object} ctx
1724 async
postRevocation(res
, ctx
) {
1725 const _scope
= _fileScope('postRevocation');
1726 this.logger
.debug(_scope
, 'called', { ctx
});
1729 await
this.db
.context(async (dbCtx
) => {
1730 await
this._revokeToken(dbCtx
, res
, ctx
);
1733 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
1737 this.logger
.info(_scope
, 'finished', { ctx
});
1742 * Profile information for a token.
1743 * @param {http.ServerResponse} res
1744 * @param {Object} ctx
1746 async
postUserInfo(res
, ctx
) {
1747 const _scope
= _fileScope('postUserInfo');
1748 this.logger
.debug(_scope
, 'called', { ctx
});
1750 const token
= ctx
.parsedBody
['token'];
1752 res
.statusCode
= 400;
1753 res
.end('"invalid_request"');
1754 this.logger
.info(_scope
, 'finished, invalid request', { ctx
});
1759 ctx
.token
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['token']);
1761 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1765 await
this.db
.context(async (dbCtx
) => {
1766 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.token
.c
);
1771 || ctx
.token
.isRevoked
1772 // || tokenIsExpired(token)
1774 res
.statusCode
= 401;
1775 res
.end('"invalid_token"');
1776 this.logger
.info(_scope
, 'finished, invalid token', { ctx
});
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
});
1788 ...ctx
.token
.profile
,
1790 if (!ctx
.token
.scopes
.includes('email')) {
1791 delete response
.email
;
1794 Manager
._sensitiveResponse(res
);
1795 res
.end(JSON
.stringify(response
));
1797 this.logger
.info(_scope
, 'finished', { ctx
});
1802 * Show admin interface, allowing manipulation of profiles and scopes.
1803 * @param {http.ServerResponse} res
1804 * @param {Object} ctx
1806 async
getAdmin(res
, ctx
) {
1807 const _scope
= _fileScope('getAdmin');
1808 this.logger
.debug(_scope
, 'called', { ctx
});
1810 const identifier
= ctx
.session
.authenticatedIdentifier
;
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
);
1817 res
.end(Template
.adminHTML(ctx
, this.options
));
1819 this.logger
.info(_scope
, 'finished', { ctx
});
1824 * Process admin interface events.
1825 * @param {http.ServerResponse} res
1826 * @param {Object} ctx
1828 async
postAdmin(res
, ctx
) {
1829 const _scope
= _fileScope('postAdmin');
1830 this.logger
.debug(_scope
, 'called', { ctx
});
1832 await
this.db
.context(async (dbCtx
) => {
1833 const identifier
= ctx
.session
.authenticatedIdentifier
;
1834 const action
= ctx
?.parsedBody
?.['action'] || '';
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-'));
1842 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
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) */
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
);
1856 ctx
.notifications
.push('Profile/Scope Availability Matrix updated!');
1858 this.logger
.error(_scope
, 'did not set profile scopes', { error: e
, ctx
});
1859 ctx
.errors
.push('Failed to update profile scopes.');
1862 } else if (action
=== 'new-profile') {
1863 // Validate and create a new profile uri.
1865 const profileUri
= ctx
.parsedBody
['profile'];
1867 profile
= await
this.communication
.validateProfile(profileUri
);
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 + ')') : ''}`);
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.');
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']);
1884 ctx
.notifications
.push('Profile added!');
1886 this.logger
.error(_scope
, 'did not insert profile', { error: e
, ctx
});
1887 ctx
.errors
.push('Failed to add profile.');
1892 } else if (action
=== 'new-scope') {
1893 // Add or update a manually-added convenience scope.
1894 const { scope
, application
= '', description
= '' } = ctx
.parsedBody
;
1896 if (!common
.validScope(scope
)) {
1897 ctx
.errors
.push(`"${scope}" is not a valid scope name, did not add it.`);
1900 await
this.db
.scopeUpsert(dbCtx
, scope
, application
, description
, true);
1901 ctx
.notifications
.push('Scope List updated!');
1903 this.logger
.error(_scope
, 'did not upsert scope', { error: e
, scope
, application
, description
, ctx
});
1904 ctx
.errors
.push('Failed to update scope.');
1909 } else if (action
.startsWith('delete-scope-')) {
1910 // Remove a manually-added convenience scope.
1911 const scope
= decodeURIComponent(action
.slice(13));
1914 const deleted
= await
this.db
.scopeDelete(dbCtx
, scope
);
1916 ctx
.notifications
.push('Scope deleted.');
1918 ctx
.notifications
.push('Unable to delete scope.');
1921 this.logger
.error(_scope
, 'did not delete scope', { error: e
, scope
, ctx
});
1922 ctx
.errors
.push('Failed to delete scope.');
1926 } else if (action
.startsWith('revoke-')) {
1927 // Revoke an active token.
1928 const codeId
= action
.slice(8);
1931 await
this.db
.tokenRevokeByCodeId(dbCtx
, codeId
, identifier
);
1932 ctx
.notifications
.push('Revoked token!');
1934 this.logger
.error(_scope
, 'did not revoke token', { error: e
, codeId
, identifier
, ctx
});
1935 ctx
.errors
.push('Unable to revoke token.');
1939 } else if (action
) {
1940 ctx
.errors
.push(`Do not know how to '${action}'.`);
1943 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1944 ctx
.tokens
= await
this.db
.tokensGetByIdentifier(dbCtx
, identifier
);
1947 res
.end(Template
.adminHTML(ctx
, this.options
));
1949 this.logger
.info(_scope
, 'finished', { ctx
});
1954 * Show ticket proffer interface.
1955 * @param {http.ServerResponse} res
1956 * @param {Object} ctx
1958 async
getAdminTicket(res
, ctx
) {
1959 const _scope
= _fileScope('getAdminTicket');
1960 this.logger
.debug(_scope
, 'called', { ctx
});
1962 const identifier
= ctx
.session
.authenticatedIdentifier
;
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
);
1970 res
.end(Template
.adminTicketHTML(ctx
, this.options
));
1972 this.logger
.info(_scope
, 'finished', { ctx
});
1977 * Handle ticket proffer interface submission.
1978 * @param {http.ServerResponse} res
1979 * @param {Object} ctx
1981 async
postAdminTicket(res
, ctx
) {
1982 const _scope
= _fileScope('postAdminTicket');
1983 this.logger
.debug(_scope
, 'called', { ctx
});
1985 switch (ctx
.parsedBody
['action']) { // eslint-disable-line sonarjs/no-small-switch
1986 case 'proffer-ticket': {
1987 const identifier
= ctx
.session
.authenticatedIdentifier
;
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
) => {
1994 ctx
[param
.ctxProp
] = new URL(ctx
.parsedBody
[param
.bodyParam
]);
1996 this.logger
.debug(_scope
, `invalid ${param.bodyParam}`, { ctx
});
1997 ctx
.errors
.push(param
.err
);
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.');
2007 ctx
.ticketEndpointUrl
= new URL(subjectData
.metadata
.ticketEndpoint
);
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})`);
2014 const scopesSet
= new Set();
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
);
2023 this.logger
.debug(_scope
, 'invalid adhoc scope', { scope
, ctx
});
2024 ctx
.errors
.push(`'${scope}' is not a valid scope.`);
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.');
2034 if (!ctx
.errors
.length
) {
2035 const ticketData
= {
2036 subject: ctx
.ticketSubjectUrl
.href
,
2037 resource: ctx
.ticketResourceUrl
.href
,
2038 scopes: ctx
.ticketScopes
,
2040 profile: ctx
.ticketProfileUrl
.href
,
2041 ticketLifespanSeconds: this.options
.manager
.ticketLifespanSeconds
,
2043 const ticket
= await
this._mintTicket(ticketData
);
2045 await
this.db
.context(async (dbCtx
) => {
2046 // re-populate form fields
2047 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
2049 // TODO: queue ticket for delivery/retry to subject instead of trying immediately
2050 // ctx.notifications.push('Success! Ticket will be delivered!');
2052 this.logger
.debug(_scope
, 'ticket created', { ctx
, ticketData
, subjectData
});
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
});
2060 this.logger
.error(_scope
, 'failed to deliver ticket', { ctx
, error: e
});
2061 ctx
.errors
.push(`Failed to deliver ticket. (${e})`);
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
);
2078 this.logger
.debug(_scope
, 'unknown action', { ctx
});
2081 res
.end(Template
.adminTicketHTML(ctx
, this.options
));
2083 this.logger
.info(_scope
, 'finished', { ctx
});
2088 * Report on generally uninteresting backend information.
2089 * Also allow a few event invocations.
2090 * @param {http.ServerResponse} res
2091 * @param {Object} ctx
2093 async
getAdminMaintenance(res
, ctx
) {
2094 const _scope
= _fileScope('getAdminMaintenance');
2095 this.logger
.debug(_scope
, 'called', { ctx
});
2097 const maintenanceTasks
= [];
2099 await
this.db
.context(async (dbCtx
) => {
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.
2106 ctx
.notifications
.push(`Running maintenance chore "${chore}".`);
2110 await Promise
.all(maintenanceTasks
);
2112 ctx
.almanac
= await
this.db
.almanacGetAll(dbCtx
);
2115 const winnowChoreEntry
= ([name
, value
]) => [name
, common
.pick(value
, ['intervalMs', 'nextSchedule'])];
2116 ctx
.chores
= Object
.fromEntries(
2117 Object
.entries(this.chores
.chores
).map(winnowChoreEntry
),
2120 res
.end(Template
.adminMaintenanceHTML(ctx
, this.options
));
2122 this.logger
.info(_scope
, 'finished', { ctx
});
2128 * @param {http.ServerResponse} res
2129 * @param {Object} ctx
2131 async
getHealthcheck(res
, ctx
) {
2132 const _scope
= _fileScope('getHealthcheck');
2133 this.logger
.debug(_scope
, 'called', { ctx
});
2134 await
this.db
.healthCheck();
2140 module
.exports
= Manager
;