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
);
199 ctx
.session
= Object
.assign({}, ctx
.session
, {
200 errorDescriptions: [],
203 // Ingest and validate expected data, populating ctx.session.
204 await
this._clientIdRequired(ctx
);
205 Manager
._redirectURIRequired(ctx
);
206 Manager
._responseTypeRequired(ctx
);
207 Manager
._stateRequired(ctx
);
208 this._codeChallengeMethodRequired(ctx
);
209 this._codeChallengeRequired(ctx
);
210 this._scopeOptional(ctx
);
211 await
this._meOptional(ctx
);
213 if (!ctx
.session
.clientIdentifier
|| !ctx
.session
.redirectUri
) {
214 // Do not redirect if either of these fields were invalid, just report error.
215 this.logger
.debug(_scope
, 'invalid request, not redirecting', { ctx
});
217 // Set error response for template to render.
218 ctx
.errors
.push('Cannot redirect to client application.');
220 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
222 res
.statusCode
= 400;
223 res
.end(Template
.authorizationErrorHTML(ctx
, this.options
));
224 this.logger
.info(_scope
, 'bad request', { ctx
});
228 await
this.db
.context(async (dbCtx
) => {
229 const profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, ctx
.authenticationId
);
230 Object
.assign(ctx
.session
, {
237 if (!ctx
.session
.profiles
.length
) {
238 this.logger
.error(_scope
, 'identifier has no profiles', { ctx
});
239 Manager
._setError(ctx
, 'access_denied', 'Profile not valid for the authenticated user.');
242 if (!this._profileValidForIdentifier(ctx
)) {
243 // if the hinted profile supplied in me does not match any known
244 // profile mappings for the authenticated identifier, remove the
245 // hint. UI will prompt to choose from available profiles.
246 this.logger
.debug(_scope
, 'removing provided me hint, not valid for identifier', { ctx
});
247 delete ctx
.session
.me
;
250 // Ugly support logic for allowing legacy non-pkce requests, for the micropub.rocks site until it is updated.
251 // Require both be missing to qualify as a legacy request, otherwise still fail.
252 const isMissingBothPKCE
= (!ctx
.session
.codeChallengeMethod
) && (!ctx
.session
.codeChallenge
);
253 if (isMissingBothPKCE
&& this.options
.manager
.allowLegacyNonPKCE
) {
254 ctx
.notifications
.push('<div class="legacy-warning">This request was submitted using an unsupported legacy format, which does not include PKCE safeguards! This is a security issue! This request should not be accepted!</div>');
256 if (!ctx
.session
.codeChallenge
) {
257 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge\'');
259 if (!ctx
.session
.codeChallengeMethod
) {
260 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
264 // If anything went wrong, redirect with error report.
265 if (ctx
.session
.error
) {
266 // Valid redirect_url and client_id, errors hop back to them.
267 this.logger
.debug(_scope
, 'invalid request, redirecting', { ctx
});
269 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
270 'state': ctx
.session
.state
,
271 'error': ctx
.session
.error
,
272 'error_description': ctx
.session
.errorDescriptions
.join(', '),
274 res
.statusCode
= 302; // Found
275 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
277 this.logger
.info(_scope
, 'bad request', { ctx
});
281 // Store the current state of this session, to be forwarded on to consent processing.
282 // This blob will be passed on as a form field in consent response.
283 ctx
.session
.persist
= await
this.mysteryBox
.pack({
284 id: common
.requestId(), // codeId in database
285 clientId: ctx
.session
.clientId
.href
,
286 clientIdentifier: ctx
.session
.clientIdentifier
,
287 redirectUri: ctx
.session
.redirectUri
.href
,
288 responseType: ctx
.session
.responseType
,
289 state: ctx
.session
.state
,
290 codeChallengeMethod: ctx
.session
.codeChallengeMethod
,
291 codeChallenge: ctx
.session
.codeChallenge
,
293 profiles: ctx
.session
.profiles
,
294 requestedScopes: ctx
.session
.scope
,
295 authenticationId: ctx
.authenticationId
,
298 // Present authenticated user the option to submit consent
299 const content
= Template
.authorizationRequestHTML(ctx
, this.options
);
302 this.logger
.info(_scope
, 'finished', { ctx
});
307 * Validates, fetches, and parses client_id url, populating clientIdentifier with client h-app data.
308 * @param {Object} ctx
310 async
_clientIdRequired(ctx
) {
311 if (ctx
.queryParams
['client_id']) {
313 ctx
.session
.clientId
= await
this.communication
.validateClientIdentifier(ctx
.queryParams
['client_id']);
314 ctx
.session
.clientIdentifier
= await
this.communication
.fetchClientIdentifier(ctx
.session
.clientId
);
315 if (!ctx
.session
.clientIdentifier
) {
316 Manager
._setError(ctx
, 'invalid_request', 'invalid client_id: could not fetch');
317 throw new ValidationError('could not fetch');
320 ctx
.session
.clientId
= undefined;
321 if (e
instanceof CommunicationErrors
.ValidationError
) {
322 Manager
._setError(ctx
, 'invalid_request', e
.message
);
324 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'client_id\'');
327 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'client_id\'');
333 * Ensure redirect_uri exists and is corroborated by clientIdentifier data.
334 * @param {Object} ctx
336 static _redirectURIRequired(ctx
) {
337 if (ctx
.queryParams
['redirect_uri']) {
339 ctx
.session
.redirectUri
= new URL(ctx
.queryParams
['redirect_uri']);
341 if (ctx
.session
.clientId
) {
342 // Either all these parts must match, or a specific alternative must be specified.
343 const redirectMatchesClientId
= ['protocol', 'hostname', 'port']
344 .map((p
) => ctx
.session
.redirectUri
[p
] == ctx
.session
.clientId
[p
]) // eslint-disable-line security/detect-object-injection
345 .reduce((acc
, match
) => acc
&& match
, true);
347 // Check for alternate redirect_uri entries on client_id data if no initial match
348 if (!redirectMatchesClientId
) {
349 const validRedirectUris
= ctx
.session
?.clientIdentifier
?.['rels']?.['redirect_uri'] || [];
350 if (!validRedirectUris
.includes(ctx
.session
.redirectUri
.href
)) {
351 Manager
._setError(ctx
, 'invalid_request', 'redirect_uri not valid for that client_id');
352 // Remove invalid redirect_uri from session; doing this eases error routing.
353 ctx
.session
.redirectUri
= undefined;
358 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'redirect_uri\'');
361 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'redirect_uri\'');
367 * response_type must be valid
368 * @param {Object} ctx
370 static _responseTypeRequired(ctx
) {
371 ctx
.session
.responseType
= ctx
.queryParams
['response_type'];
372 if (ctx
.session
.responseType
) {
373 // Must be one of these types
374 if (!['code'].includes(ctx
.session
.responseType
)) {
375 Manager
._setError(ctx
, 'unsupported_response_type', 'invalid value for parameter \'response_type\'');
378 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'response_type\'');
384 * A state parameter must be present
385 * @param {Object} ctx
387 static _stateRequired(ctx
) {
388 ctx
.session
.state
= ctx
.queryParams
['state'];
389 if (ctx
.session
.state
) {
390 // No restrictions on content of this
392 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'state\'');
398 * A code_challenge_method must be present and valid
399 * @param {Object} ctx
401 _codeChallengeMethodRequired(ctx
) {
402 ctx
.session
.codeChallengeMethod
= ctx
.queryParams
['code_challenge_method'];
403 if (ctx
.session
.codeChallengeMethod
) {
404 if (!supportedCodeChallengeMethods
.includes(ctx
.session
.codeChallengeMethod
)) {
405 Manager
._setError(ctx
, 'invalid_request', 'unsupported code_challenge_method');
408 if (this.options
.manager
.allowLegacyNonPKCE
) {
411 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
417 * A code_challenge must be present
418 * @param {Object} ctx
420 _codeChallengeRequired(ctx
) {
421 ctx
.session
.codeChallenge
= ctx
.queryParams
['code_challenge'];
422 if (ctx
.session
.codeChallenge
) {
423 if (!validBase64URLRE
.test(ctx
.session
.codeChallenge
)) {
424 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'code_challenge\'');
427 if (this.options
.manager
.allowLegacyNonPKCE
) {
430 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge\'');
436 * Scopes may be present, with one known combination limitation
437 * @param {Object} ctx
439 _scopeOptional(ctx
) {
440 const _scope
= _fileScope('_scopeOptional');
441 const scope
= ctx
.queryParams
['scope'];
442 ctx
.session
.scope
= [];
444 const allScopes
= scope
.split(scopeSplitRE
);
445 const validScopes
= allScopes
.filter((s
) => common
.validScope(s
));
446 ctx
.session
.scope
.push(...validScopes
);
447 if (allScopes
.length
!= validScopes
.length
) {
448 const invalidScopes
= allScopes
.filter((s
) => !common
.validScope(s
));
449 this.logger
.debug(_scope
, 'client requested invalid scope', { ctx
, invalidScopes
});
452 // If email scope is requested, profile scope must also be explicitly requested.
453 if (ctx
.session
.scope
.includes('email')
454 && !ctx
.session
.scope
.includes('profile')) {
455 Manager
._setError(ctx
, 'invalid_scope', 'cannot provide \'email\' scope without \'profile\' scope');
461 * Parses me, if provided
462 * @param {Object} ctx
464 async
_meOptional(ctx
) {
465 const me
= ctx
.queryParams
['me'];
468 ctx
.session
.me
= await
this.communication
.validateProfile(me
);
470 ctx
.session
.me
= undefined;
477 * Ensure authenticated identifier matches profile.
478 * @param {Object} ctx
481 _profileValidForIdentifier(ctx
) {
482 const _scope
= _fileScope('_profileValidForIdentifier');
484 if (!ctx
.session
.me
) {
485 this.logger
.debug(_scope
, 'no profile provided, cannot correlate', { ctx
});
489 return ctx
.session
.profiles
.includes(ctx
.session
.me
.href
);
494 * Get numeric value from form field data.
496 * @param {String} field
497 * @param {String} customField
500 _parseLifespan(ctx
, field
, customField
) {
501 const _scope
= _fileScope('_parseLifespan');
503 const presetValues
= {
509 const fieldValue
= ctx
.parsedBody
[field
]; // eslint-disable-line security/detect-object-injection
510 if (fieldValue
in presetValues
) {
511 return presetValues
[fieldValue
]; // eslint-disable-line security/detect-object-injection
514 if (fieldValue
=== 'custom') {
515 const expiresSeconds
= parseInt(ctx
.parsedBody
[customField
], 10); // eslint-disable-line security/detect-object-injection
516 if (isFinite(expiresSeconds
) && expiresSeconds
> 0) {
517 return expiresSeconds
;
519 this.logger
.debug(_scope
, 'invalid custom value', { ctx
, field
, customField
});
523 this.logger
.debug(_scope
, 'invalid value', { ctx
, field
, customField
});
529 * Validate any accepted scopes, ensure uniqueness, return as array.
530 * @param {Object} ctx
533 _parseConsentScopes(ctx
) {
534 const _scope
= _fileScope('_ingestConsentScopes');
535 const acceptedScopesSet
= new Set();
536 const rejectedScopesSet
= new Set();
538 const submittedScopes
= common
.ensureArray(ctx
.parsedBody
['accepted_scopes'])
539 .concat((ctx
.parsedBody
['ad_hoc_scopes'] || '').split(scopeSplitRE
));
540 submittedScopes
.forEach((scope
) => {
542 (common
.validScope(scope
) ? acceptedScopesSet : rejectedScopesSet
).add(scope
);
546 // If email scope was accepted but profile was not, elide email scope
547 if (acceptedScopesSet
.has('email')
548 && !acceptedScopesSet
.has('profile')) {
549 acceptedScopesSet
.delete('email');
550 rejectedScopesSet
.add('email (without profile)');
553 if (rejectedScopesSet
.size
) {
554 this.logger
.debug(_scope
, 'ignoring invalid scopes', { ctx
, rejectedScopes: Array
.from(rejectedScopesSet
) });
557 return Array
.from(acceptedScopesSet
);
562 * Parse and validate selected me is a valid profile option.
563 * @param {Object} ctx
566 _parseConsentMe(ctx
) {
567 const _scope
= _fileScope('_parseConsentMe');
568 const selectedMe
= ctx
.parsedBody
['me'];
570 const me
= new URL(selectedMe
);
571 if (ctx
.session
.profiles
.includes(me
.href
)) {
574 this.logger
.debug(_scope
, 'selected \'me\' profile not among available', { me
, available: ctx
.session
.profiles
, ctx
});
575 Manager
._setError(ctx
, 'invalid_request', 'invalid profile url');
578 this.logger
.debug(_scope
, 'failed to parse selected \'me\' as url', { error: e
, ctx
});
579 Manager
._setError(ctx
, 'invalid_request', 'invalid profile url');
586 * Get up-to-date profile data from selected profile endpoint.
587 * @param {Object} ctx
588 * @returns {Promise<Object>}
590 async
_fetchConsentProfileData(ctx
) {
591 const _scope
= _fileScope('_fetchConsentProfileData');
593 const profile
= await
this.communication
.fetchProfile(ctx
.session
.me
);
595 this.logger
.debug(_scope
, 'no profile data at \'me\' endpoint', { ctx
});
596 Manager
._setError(ctx
, 'temporarily_unavailable', 'unable to retrieve profile');
598 // Profile info gets persisted in code, only include known profile fields to help keep size down.
599 return common
.pick(profile
, [
607 this.logger
.debug(_scope
, 'failed to fetch \'me\' endpoint', { error: e
, ctx
});
608 Manager
._setError(ctx
, 'temporarily_unavailable', 'could not reach profile endpoint');
615 * Ingest user consent response details, redirect as needed.
616 * Receives POST request from consent page, expecting these form fields:
617 * session - encrypted data collected from initial auth call
618 * accept - 'true' if consent was granted
619 * accepted_scopes - list of scopes to grant
620 * ad_hoc_scopes - additional scopes specified by user
621 * me - selected profile to identify as
622 * expires - optional lifespan
623 * expires-seconds - optional custom lifespan
624 * refresh - optional refresh lifespan
625 * refresh-seconds - optional custom refresh lifespan
626 * @param {http.ServerResponse} res
627 * @param {Object} ctx
629 async
postConsent(res
, ctx
) {
630 const _scope
= _fileScope('postConsent');
631 this.logger
.debug(_scope
, 'called', { ctx
});
633 Manager
._sensitiveResponse(res
);
635 // Ensure session exists, persisting any login session data.
636 ctx
.session
= Object
.assign({}, ctx
.session
);
638 // Recover the session established on initial auth request.
639 const oldSession
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['session']);
640 Object
.assign(ctx
.session
, oldSession
);
641 ctx
.session
.redirectUri
= new URL(ctx
.session
.redirectUri
);
642 ctx
.session
.clientId
= new URL(ctx
.session
.clientId
);
644 this.logger
.debug(_scope
, 'failed to unpack session', { error: e
, ctx
});
645 Manager
._setError(ctx
, 'invalid_request', 'un-parsable data in authorization consent');
648 // If these are missing, we cannot proceed.
649 if (!ctx
.session
.clientId
|| !ctx
.session
.redirectUri
) {
650 // Set error response for html template to render.
652 'Cannot redirect to client application.',
655 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
657 res
.statusCode
= 400;
658 res
.end(Template
.authorizationErrorHTML(ctx
, this.options
));
659 this.logger
.info(_scope
, 'bad request, cannot redirect', { ctx
});
663 // TODO: Should probably re-validate more unpacked session values, even though those should be trustable.
665 // Check if we need to check anything else.
666 ctx
.session
.accept
= (ctx
.parsedBody
['accept'] === 'true');
667 if (!ctx
.session
.accept
) {
668 this.logger
.debug(_scope
, 'consent denied', { ctx
});
669 Manager
._setError(ctx
, 'access_denied', 'authorization was not granted');
672 ctx
.session
.acceptedScopes
= this._parseConsentScopes(ctx
);
673 ctx
.session
.me
= this._parseConsentMe(ctx
);
674 ctx
.session
.profile
= await
this._fetchConsentProfileData(ctx
);
675 ctx
.session
.tokenLifespan
= this._parseLifespan(ctx
, 'expires', 'expires-seconds');
676 if (ctx
.session
.tokenLifespan
) {
677 ctx
.session
.refreshLifespan
= this._parseLifespan(ctx
, 'refresh', 'refresh-seconds');
681 if (ctx
.session
.error
) {
682 this.logger
.debug(_scope
, 'invalid request, redirecting', { ctx
});
684 // Set all errors as parameters for client to interpret upon redirection.
685 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
686 'state': ctx
.session
.state
,
687 'error': ctx
.session
.error
,
688 'error_description': ctx
.session
.errorDescriptions
.join(', '),
690 res
.statusCode
= 302; // Found
691 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
693 this.logger
.info(_scope
, 'bad request, redirected', { ctx
});
697 // Consented, off we go. Keep all this session state as the code.
698 const code
= await
this.mysteryBox
.pack({
699 codeId: ctx
.session
.id
,
700 codeChallengeMethod: ctx
.session
.codeChallengeMethod
,
701 codeChallenge: ctx
.session
.codeChallenge
,
702 clientId: ctx
.session
.clientId
.href
,
703 redirectUri: ctx
.session
.redirectUri
.href
,
704 acceptedScopes: ctx
.session
.acceptedScopes
,
705 tokenLifespan: ctx
.session
.tokenLifespan
,
706 refreshLifespan: ctx
.session
.refreshLifespan
,
707 me: ctx
.session
.me
.href
,
708 profile: ctx
.session
.profile
,
709 identifier: ctx
.session
.authenticatedIdentifier
, // need this to pair with profile
713 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
715 'state': ctx
.session
.state
,
716 'iss': this.options
.dingus
.selfBaseUrl
,
718 res
.statusCode
= 302;
719 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
722 this.logger
.info(_scope
, 'finished', { ctx
});
727 * Redeem a code for a profile url, and maybe more profile info.
728 * @param {http.ServerResponse} res
729 * @param {Object} ctx
731 async
postAuthorization(res
, ctx
) {
732 const _scope
= _fileScope('postAuthorization');
733 this.logger
.debug(_scope
, 'called', { ctx
});
735 await
this._ingestPostAuthorizationRequest(ctx
);
739 ...(ctx
.session
?.acceptedScopes
?.includes('profile') && { profile: ctx
.session
.profile
}),
740 scope: ctx
.session
.acceptedScopes
,
742 if (response
.profile
&& !ctx
.session
?.acceptedScopes
?.includes('email')) {
743 delete response
.profile
.email
;
746 if (!ctx
.session
.error
) {
747 await
this.db
.context(async (dbCtx
) => {
748 // Record code redemption without token.
749 const valid
= await
this.db
.redeemCode(dbCtx
, {
750 codeId: ctx
.session
.codeId
,
753 clientId: ctx
.session
.clientId
.href
,
754 profile: ctx
.session
.me
,
755 identifier: ctx
.session
.identifier
,
756 scopes: ctx
.session
.acceptedScopes
,
757 lifespanSeconds: Math
.ceil(this.options
.manager
.codeValidityTimeoutMs
/ 1000),
758 profileData: response
.profile
,
761 this.logger
.debug(_scope
, 'code already redeemed', { ctx
});
762 Manager
._setError(ctx
, 'access_denied', 'code already redeemed');
767 if (ctx
.session
.error
) {
768 res
.statusCode
= 400;
769 res
.end(JSON
.stringify({
770 'error': ctx
.session
.error
,
771 'error_description': ctx
.session
.errorDescriptions
.join(', '),
773 this.logger
.info(_scope
, 'invalid request', { ctx
});
777 res
.end(JSON
.stringify(response
));
779 this.logger
.info(_scope
, 'finished', { ctx
, response
});
784 * Ingest an incoming authorization redemption request, parsing fields
785 * onto a new session object on the context.
787 * @param {Object} ctx
789 async
_ingestPostAuthorizationRequest(ctx
) {
790 const _scope
= _fileScope('_ingestPostAuthorizationRequest');
792 ctx
.session
= Object
.assign({}, ctx
.session
, {
793 errorDescriptions: [],
796 if (!ctx
.parsedBody
) {
797 this.logger
.debug(_scope
, 'no body data', { ctx
});
798 Manager
._setError(ctx
, 'invalid_request', 'missing data');
801 await
this._restoreSessionFromCode(ctx
);
802 this._checkSessionMatchingClientId(ctx
);
803 this._checkSessionMatchingRedirectUri(ctx
);
804 this._checkGrantType(ctx
);
805 this._checkSessionMatchingCodeVerifier(ctx
);
807 if (!ctx
.session
.me
|| !ctx
.session
.minted
) {
808 this.logger
.debug(_scope
, 'session missing fields', { ctx
});
809 Manager
._setError(ctx
, 'invalid_request', 'malformed code');
813 const expires
= new Date(ctx
.session
.minted
+ this.options
.manager
.codeValidityTimeoutMs
);
814 const now
= new Date();
816 this.logger
.debug(_scope
, 'code expired', { ctx
});
817 Manager
._setError(ctx
, 'invalid_request', 'code has expired');
823 * Unpack the session data from provided code overtop of context session ..
824 * @param {Object} ctx
826 async
_restoreSessionFromCode(ctx
) {
827 const _scope
= _fileScope('_restoreSessionFromCode');
829 const code
= ctx
.parsedBody
['code'];
832 const oldSession
= await
this.mysteryBox
.unpack(code
);
834 // TODO: Validate unpacked fields better
835 const missingFields
= [
837 'codeChallengeMethod',
846 ].filter((requiredField
) => !(requiredField
in oldSession
));
847 if (missingFields
.length
) {
848 if (this.options
.manager
.allowLegacyNonPKCE
849 && missingFields
.length
=== 2
850 && missingFields
.includes('codeChallenge')
851 && missingFields
.includes('codeChallengeMethod')) {
852 this.logger
.debug(_scope
, 'allowing legacy non-PKCE session', { ctx
});
854 this.logger
.debug(_scope
, 'unpacked code is missing required field', { missingFields
, ctx
});
855 Manager
._setError(ctx
, 'invalid_request', 'code is not valid');
859 Object
.assign(ctx
.session
, oldSession
);
861 this.logger
.debug(_scope
, 'failed to parse code', { error: e
, ctx
});
862 Manager
._setError(ctx
, 'invalid_request', 'code is not valid');
865 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code\'');
871 * Ensure provided client_id matches session clientId.
872 * @param {Object} ctx
874 _checkSessionMatchingClientId(ctx
) {
875 const _scope
= _fileScope('_checkSessionMatchingClientId');
877 let clientId
= ctx
.parsedBody
['client_id'];
880 clientId
= new URL(clientId
);
881 ctx
.session
.clientId
= new URL(ctx
.session
.clientId
);
883 this.logger
.debug(_scope
, 'un-parsable client_id url', { ctx
});
884 delete ctx
.session
.clientId
;
885 Manager
._setError(ctx
, 'invalid_request', 'malformed client_id');
888 if (clientId
.href
!== ctx
.session
.clientId
.href
) {
889 this.logger
.debug(_scope
, 'clientId mismatched', { clientId
, ctx
});
890 delete ctx
.session
.clientId
;
891 Manager
._setError(ctx
, 'invalid_request', 'code does not belong to that client_id');
894 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'client_id\'');
900 * @param {Object} ctx
902 _checkSessionMatchingRedirectUri(ctx
) {
903 const _scope
= _fileScope('_checkSessionMatchingClientId');
905 let redirectUri
= ctx
.parsedBody
['redirect_uri'];
908 redirectUri
= new URL(redirectUri
);
909 ctx
.session
.redirectUri
= new URL(ctx
.session
.redirectUri
);
911 this.logger
.debug(_scope
, 'un-parsable redirect_uri url', { ctx
});
912 delete ctx
.session
.redirectUri
;
913 Manager
._setError(ctx
, 'invalid_request', 'malformed redirect_url');
916 if (redirectUri
.href
!== ctx
.session
.redirectUri
.href
) {
917 this.logger
.debug(_scope
, 'redirectUri mismatched', { redirectUri
, ctx
});
918 delete ctx
.session
.redirectUri
;
919 Manager
._setError(ctx
, 'invalid_request', 'code does not belong to that redirect_uri');
922 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'redirect_uri\'');
928 * Validate grant_type, either persist on session or set error.
929 * @param {Object} ctx
930 * @param {String[]} validGrantTypes
931 * @param {Boolean} treatEmptyAs
933 _checkGrantType(ctx
, validGrantTypes
= ['authorization_code'], treatEmptyAs
= 'authorization_code') {
934 const _scope
= _fileScope('_checkGrantType');
936 const grantType
= ctx
.parsedBody
['grant_type'] || treatEmptyAs
;
937 if (!ctx
.parsedBody
['grant_type'] && treatEmptyAs
) {
938 this.logger
.debug(_scope
, `missing grant_type, treating as ${treatEmptyAs}`, { ctx
});
940 if (validGrantTypes
.includes(grantType
)) {
941 ctx
.session
.grantType
= grantType
;
943 Manager
._setError(ctx
, 'invalid_request', 'grant_type not supported');
949 * @param {Object} ctx
951 _checkSessionMatchingCodeVerifier(ctx
) {
952 const _scope
= _fileScope('_checkSessionMatchingCodeVerifier');
954 const codeVerifier
= ctx
.parsedBody
['code_verifier'];
957 const valid
= Communication
.verifyChallenge(ctx
.session
.codeChallenge
, codeVerifier
, ctx
.session
.codeChallengeMethod
);
959 this.logger
.debug(_scope
, 'challenge mismatched', { ctx
});
960 Manager
._setError(ctx
, 'invalid_request', 'challenge verification failed');
962 } catch (e
) /* istanbul ignore next */ {
963 this.logger
.error(_scope
, 'challenge validation failed', { error: e
, ctx
});
964 Manager
._setError(ctx
, 'invalid_request', 'challenge verification failed');
967 if (this.options
.manager
.allowLegacyNonPKCE
968 && !ctx
.session
.codeChallenge
969 && !ctx
.session
.codeChallengeMethod
) {
970 this.logger
.debug(_scope
, 'allowing non-PKCE', { ctx
});
973 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_verifier\'');
979 * Attempt to revoke a token.
981 * @param {http.ServerResponse} res
982 * @param {Object} ctx
984 async
_revokeToken(dbCtx
, res
, ctx
) {
985 const _scope
= _fileScope('_revokeToken');
987 const token
= ctx
.parsedBody
['token'];
988 const tokenTypeHint
= ctx
.parsedBody
['token_type_hint'];
989 switch (tokenTypeHint
) {
994 case 'refresh_token':
997 this.logger
.debug(_scope
, 'unknown token_type_hint', { ctx
});
1000 throw new ValidationError('Token Missing');
1002 ctx
.token
= await
this.mysteryBox
.unpack(token
);
1003 if (!(ctx
.token
?.c
|| ctx
.token
?.rc
)) {
1004 throw new ValidationError('Token Invalid');
1007 this.logger
.debug(_scope
, 'invalid token', { error: e
, ctx
});
1008 res
.statusCode
= 400;
1010 this.logger
.info(_scope
, 'finished, revoke request not valid', { error: e
, ctx
});
1016 await
this.db
.tokenRevokeByCodeId(dbCtx
, ctx
.token
.c
);
1018 await
this.db
.tokenRefreshRevokeByCodeId(dbCtx
, ctx
.token
.rc
);
1021 if (e
instanceof DBErrors
.UnexpectedResult
) {
1022 res
.statusCode
= 404;
1024 this.logger
.info(_scope
, 'finished, no token to revoke', { error: e
, ctx
});
1027 this.logger
.error(_scope
, 'revoke token failed', { error: e
, ctx
});
1032 this.logger
.info(_scope
, 'finished, token revoked', { ctx
});
1037 * Legacy token validation flow.
1039 * @param {http.ClientRequest} req
1040 * @param {http.ServerResponse} res
1041 * @param {Object} ctx
1043 async
_validateToken(dbCtx
, req
, res
, ctx
) {
1044 const _scope
= _fileScope('_validateToken');
1045 await
this._checkTokenValidationRequest(dbCtx
, req
, ctx
);
1046 if (ctx
.bearer
.isValid
) {
1047 Manager
._sensitiveResponse(res
);
1048 res
.end(JSON
.stringify({
1049 me: ctx
.token
.profile
,
1050 'client_id': ctx
.token
.clientId
,
1051 scope: ctx
.token
.scopes
,
1053 this.logger
.info(_scope
, 'finished, token validated', { ctx
});
1055 const responseErrorParts
= ['Bearer'];
1056 const error
= ctx
.session
.error
? `error="${ctx.session.error}"` : '';
1058 responseErrorParts
.push(error
);
1060 const errorDescription
= ctx
.session
.errorDescriptions
? `error_description="${ctx.session.errorDescriptions.join(', ')}"` : '';
1061 if (errorDescription
) {
1062 responseErrorParts
.push(errorDescription
);
1064 res
.setHeader(Enum
.Header
.WWWAuthenticate
, responseErrorParts
.join(', '));
1065 this.logger
.info(_scope
, 'finished, token not validated', { ctx
});
1066 throw new ResponseError(Enum
.ErrorResponse
.Unauthorized
);
1072 * Given a list of newly-requested scopes, return a list of scopes
1073 * from previousScopes which are not in requestedScopes.
1074 * @param {String[]} previousScopes
1075 * @param {String[]} requestedScopes
1076 * @returns {String[]}
1078 static _scopeDifference(previousScopes
, requestedScopes
) {
1079 const scopesToRemove
= [];
1080 const existingScopesSet
= new Set(previousScopes
);
1081 const validRequestedScopes
= requestedScopes
.filter((s
) => common
.validScope(s
));
1082 const requestedScopesSet
= new Set(validRequestedScopes
);
1083 existingScopesSet
.forEach((s
) => {
1084 if (!requestedScopesSet
.has(s
)) {
1085 scopesToRemove
.push(s
);
1088 return scopesToRemove
;
1093 * Redeem a refresh token for a new token.
1095 * @param {http.ClientRequest} req
1096 * @param {http.ServerResponse} res
1097 * @param {Object} ctx
1099 async
_refreshToken(dbCtx
, req
, res
, ctx
) {
1100 const _scope
= _fileScope('_refreshToken');
1101 this.logger
.debug(_scope
, 'called', { ctx
});
1104 'client_id': clientId
,
1109 ctx
.refreshToken
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['refresh_token']);
1111 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1114 const now
= new Date();
1115 const nowEpoch
= common
.dateToEpoch(now
);
1117 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1118 if (ctx
.refreshToken
?.rc
) {
1119 ctx
.token
= await
this.db
.tokenGetByCodeId(txCtx
, ctx
.refreshToken
.rc
);
1123 this.logger
.debug(_scope
, 'no token to refresh', { ctx
});
1124 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
1127 if (!ctx
.token
.refreshExpires
1128 || ctx
.token
.refreshExpires
< now
) {
1129 this.logger
.debug(_scope
, 'token not refreshable or refresh expired', { ctx
});
1130 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1133 const refreshExpiresEpoch
= common
.dateToEpoch(ctx
.token
.refreshExpires
);
1134 if (ctx
.refreshToken
.exp
< refreshExpiresEpoch
) {
1135 this.logger
.debug(_scope
, 'token already refreshed', { ctx
});
1136 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1139 if (clientId
!== ctx
.token
.clientId
) {
1140 this.logger
.debug(_scope
, 'client identifier mismatch', { ctx
});
1141 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1144 const scopesToRemove
= scope
? Manager
._scopeDifference(ctx
.token
.scopes
, scope
.split(scopeSplitRE
)) : [];
1145 if (scopesToRemove
.length
) {
1146 this.logger
.debug(_scope
, 'scope reduction requested', { ctx
, scopesToRemove
});
1149 const refreshedTokenData
= await
this.db
.refreshCode(txCtx
, ctx
.refreshToken
.rc
, now
, scopesToRemove
);
1150 if (refreshedTokenData
) {
1151 Object
.assign(ctx
.token
, refreshedTokenData
);
1153 this.logger
.debug(_scope
, 'could not refresh token', { ctx
});
1154 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
1158 const [token
, refreshToken
] = await Promise
.all([
1160 c: ctx
.token
.codeId
,
1164 rc: ctx
.token
.codeId
,
1166 exp: nowEpoch
+ ctx
.token
.refreshDuration
,
1168 ].map(this.mysteryBox
.pack
));
1171 'access_token': token
,
1172 'token_type': 'Bearer',
1173 ...(ctx
.token
.duration
&& { 'expires_in': nowEpoch
+ ctx
.token
.duration
}),
1174 ...(refreshToken
&& { 'refresh_token': refreshToken
}),
1175 scope: ctx
.token
.scopes
.join(' '),
1177 ...(ctx
.token
.scopes
.includes('profile') && { profile: ctx
.token
.profileData
}),
1179 if (ctx
.token
.scopes
.includes('profile') && !ctx
.token
.scopes
.includes('email')) {
1180 delete response
?.profile
?.email
;
1183 Manager
._sensitiveResponse(res
);
1184 res
.end(JSON
.stringify(response
));
1185 this.logger
.info(_scope
, 'finished', { ctx
});
1190 * Generate a new ticket for later redemption.
1191 * @param {Object} payload
1192 * @param {} payload.subject deliver ticket to this endpoint
1193 * @param {} payload.resource url the redeemed ticket is valid for accessing
1194 * @param {String[]} payload.scopes list of scopes assigned to ticket
1195 * @param {String} payload.identifier user generating ticket
1196 * @param {} payload.profile profile of user generating ticket
1197 * @param {Number} payload.ticketLifespanSeconds ticket redeemable for this long
1198 * @returns {Promise<String>}
1200 async
_mintTicket({ subject
, resource
, scopes
, identifier
, profile
, ticketLifespanSeconds
}) {
1201 const _scope
= _fileScope('_mintTicket');
1202 this.logger
.debug(_scope
, 'called', { subject
, resource
, scopes
, identifier
, profile
, ticketLifespanSeconds
});
1204 const nowEpoch
= common
.dateToEpoch();
1205 return this.mysteryBox
.pack({
1206 c: common
.requestId(),
1208 exp: nowEpoch
+ ticketLifespanSeconds
,
1220 * @property {String} codeId
1221 * @property {Date} issued
1222 * @property {Date} expires
1223 * @property {URL} subject
1224 * @property {URL} resource
1225 * @property {String[]} scopes
1226 * @property {String} identifier
1227 * @property {URL} profile
1231 * @param {String} ticket
1232 * @returns {Promise<Ticket>}
1234 async
_unpackTicket(ticket
) {
1235 const ticketObj
= await
this.mysteryBox
.unpack(ticket
);
1237 codeId: ticketObj
.c
,
1238 issued: new Date(ticketObj
.iat
* 1000),
1239 expires: new Date(ticketObj
.exp
* 1000),
1240 subject: new URL(ticketObj
.sub
),
1241 resource: new URL(ticketObj
.res
),
1242 scopes: ticketObj
.scope
,
1243 identifier: ticketObj
.ident
,
1244 profile: new URL(ticketObj
.profile
),
1250 * Redeem a ticket for a token.
1252 * @param {http.ClientRequest} req
1253 * @param {http.ServerResponse} res
1254 * @param {Object} ctx
1256 async
_ticketAuthToken(dbCtx
, req
, res
, ctx
) {
1257 const _scope
= _fileScope('_ticketAuthToken');
1258 this.logger
.debug(_scope
, 'called', { ctx
});
1261 ctx
.ticket
= await
this._unpackTicket(ctx
.parsedBody
['ticket']);
1263 this.logger
.debug(_scope
, 'failed to unpack ticket', { error: e
, ctx
});
1264 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1267 const now
= new Date();
1268 if (now
> ctx
.ticket
.expires
) {
1269 this.logger
.debug(_scope
, 'ticket has expired', { ctx
});
1270 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
, { reason: 'Ticket has expired.', expired: ctx
.ticket
.expires
});
1273 const nowEpoch
= common
.dateToEpoch(now
);
1274 const token
= await
this.mysteryBox
.pack({
1275 c: ctx
.ticket
.codeId
,
1280 'access_token': token
,
1281 'token_type': 'Bearer',
1282 scope: ctx
.ticket
.scopes
.join(' '),
1283 me: ctx
.ticket
.profile
.href
,
1286 const isValid
= await
this.db
.redeemCode(dbCtx
, {
1288 codeId: ctx
.ticket
.codeId
,
1290 clientId: ctx
.ticket
.subject
.href
,
1291 resource: ctx
.ticket
.resource
.href
,
1292 profile: ctx
.ticket
.profile
.href
,
1293 identifier: ctx
.ticket
.identifier
,
1294 scopes: ctx
.ticket
.scopes
,
1297 this.logger
.debug(_scope
, 'redemption failed, already redeemed', { ctx
});
1298 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
);
1301 Manager
._sensitiveResponse(res
);
1302 res
.end(JSON
.stringify(response
));
1303 this.logger
.info(_scope
, 'finished', { ctx
});
1308 * Redeem a code for a token.
1310 * @param {http.ClientRequest} req
1311 * @param {http.ServerResponse} res
1312 * @param {Object} ctx
1314 async
_codeToken(dbCtx
, req
, res
, ctx
) {
1315 const _scope
= _fileScope('_codeToken');
1316 this.logger
.debug(_scope
, 'called', { ctx
});
1318 await
this._restoreSessionFromCode(ctx
);
1319 this._checkSessionMatchingRedirectUri(ctx
);
1321 if (ctx
.session
.error
) {
1322 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1326 * Note the creation date here rather than in database, so that stored
1327 * expire dates are ensured to match those packed in tokens.
1328 * An alternative would be to return the date generated by the database,
1329 * but then we would need to hold the transaction open while minting the
1330 * tokens to ensure success. Perhaps that would be worth it, but for now
1331 * this is how it is.
1333 const now
= new Date();
1334 const nowEpoch
= common
.dateToEpoch(now
);
1335 const tokenMinters
= [];
1337 tokenMinters
.push(this.mysteryBox
.pack({
1338 c: ctx
.session
.codeId
,
1340 ...(ctx
.session
.tokenLifespan
&& { exp: nowEpoch
+ ctx
.session
.tokenLifespan
}),
1343 if (ctx
.session
.tokenLifespan
1344 && ctx
.session
.refreshLifespan
) {
1345 tokenMinters
.push(this.mysteryBox
.pack({
1346 rc: ctx
.session
.codeId
,
1348 exp: nowEpoch
+ ctx
.session
.refreshLifespan
,
1352 const [token
, refreshToken
] = await Promise
.all(tokenMinters
);
1355 'access_token': token
,
1356 'token_type': 'Bearer',
1357 ...(ctx
.session
.tokenLifespan
&& { 'expires_in': nowEpoch
+ ctx
.session
.tokenLifespan
}),
1358 ...(refreshToken
&& { 'refresh_token': refreshToken
}),
1359 scope: ctx
.session
.acceptedScopes
.join(' '),
1361 ...(ctx
.session
.acceptedScopes
.includes('profile') && { profile: ctx
.session
.profile
}),
1363 if (!ctx
.session
.acceptedScopes
.includes('email') && response
.profile
) {
1364 delete response
.profile
.email
;
1367 const isValid
= await
this.db
.redeemCode(dbCtx
, {
1369 codeId: ctx
.session
.codeId
,
1371 clientId: ctx
.session
.clientId
,
1372 profile: ctx
.session
.me
,
1373 identifier: ctx
.session
.identifier
,
1374 scopes: ctx
.session
.acceptedScopes
,
1375 lifespanSeconds: ctx
.session
.tokenLifespan
,
1376 refreshLifespanSeconds: ctx
.session
.refreshLifespan
,
1377 profileData: response
.profile
,
1380 this.logger
.debug(_scope
, 'redemption failed, already redeemed', { ctx
});
1381 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
);
1384 Manager
._sensitiveResponse(res
);
1385 res
.end(JSON
.stringify(response
));
1386 this.logger
.info(_scope
, 'finished', { ctx
});
1391 * Issue, refresh, or validate a token.
1392 * @param {http.ClientRequest} req
1393 * @param {http.ServerResponse} res
1394 * @param {Object} ctx
1396 async
postToken(req
, res
, ctx
) {
1397 const _scope
= _fileScope('postToken');
1398 this.logger
.debug(_scope
, 'called', { ctx
});
1400 ctx
.session
= Object
.assign({}, ctx
.session
);
1402 await
this.db
.context(async (dbCtx
) => {
1404 // Is this a (legacy) revocation request?
1405 if (ctx
.parsedBody
['action'] === 'revoke') {
1406 await
this._revokeToken(dbCtx
, res
, ctx
);
1410 // Is this a (legacy) token validation request?
1411 if (req
.getHeader(Enum
.Header
.Authorization
)) {
1412 await
this._validateToken(dbCtx
, res
, ctx
);
1416 const validGrantTypes
= [
1417 'authorization_code',
1419 ...(this.queuePublisher
&& ['ticket'] || []),
1421 this._checkGrantType(ctx
, validGrantTypes
, 'authorization_code');
1423 switch (ctx
.session
.grantType
) {
1424 case 'refresh_token':
1425 return this._refreshToken(dbCtx
, req
, res
, ctx
);
1428 return this._ticketAuthToken(dbCtx
, req
, res
, ctx
);
1430 case 'authorization_code':
1431 return this._codeToken(dbCtx
, req
, res
, ctx
);
1434 this.logger
.debug(_scope
, 'unknown grant_type', { ctx
});
1435 Manager
._setError(ctx
, 'invalid_request', 'grant_type not supported');
1438 // Only way of getting here is due to error.
1439 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1445 * Ingest token from authorization header, setting ctx.bearer.isValid appropriately.
1446 * ctx.bearer not set if auth method not recognized.
1447 * This is for legacy validation on token endpoint.
1449 * @param {http.ClientRequest} req
1450 * @param {Object} ctx
1452 async
_checkTokenValidationRequest(dbCtx
, req
, ctx
) {
1453 const _scope
= _fileScope('_checkTokenValidationRequest');
1454 const authHeader
= req
.getHeader(Enum
.Header
.Authorization
);
1457 const [authMethod
, authString
] = common
.splitFirst(authHeader
, ' ', '');
1458 switch (authMethod
.toLowerCase()) { // eslint-disable-line sonarjs/no-small-switch
1464 Object
.assign(ctx
.bearer
, await
this.mysteryBox
.unpack(authString
));
1466 this.logger
.debug(_scope
, 'failed to unpack token', { ctx
});
1467 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1470 if (!ctx
.bearer
.c
) {
1471 this.logger
.debug(_scope
, 'incomplete token', { ctx
});
1472 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1477 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.bearer
.c
);
1479 this.logger
.error(_scope
, 'failed to look up token', { error: e
, ctx
});
1484 this.logger
.debug(_scope
, 'no token found', { ctx
});
1485 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1489 if (!ctx
.token
.isRevoked
1490 && ctx
.token
.expires
> new Date()) {
1491 ctx
.bearer
.isValid
= true;
1497 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
1505 * Accept an unsolicited ticket proffering.
1506 * @param {http.ClientRequest} req
1507 * @param {http.ServerResponse} res
1508 * @param {Object} ctx
1510 async
postTicket(req
, res
, ctx
) {
1511 const _scope
= _fileScope('postTicket');
1512 this.logger
.debug(_scope
, 'called', { ctx
});
1514 if (!this.queuePublisher
) {
1515 this.logger
.debug(_scope
, 'ticket endpoint not configured', { ctx
});
1516 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1519 const queueName
= this.options
.queues
.ticketPublishName
;
1520 const { ticket
, resource
, subject
, iss
} = ctx
.parsedBody
;
1526 this.logger
.debug(_scope
, 'unparsable issuer', { ticket
, resource
, subject
, iss
, ctx
});
1527 // continue, will try resource for metadata
1533 this.logger
.debug(_scope
, 'unparsable resource', { ticket
, resource
, subject
, ctx
});
1534 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1537 await
this.db
.context(async (dbCtx
) => {
1538 const isValidProfile
= await
this.db
.profileIsValid(dbCtx
, subject
);
1539 if (!isValidProfile
) {
1540 this.logger
.debug(_scope
, 'invalid subject', { ticket
, resource
, subject
, ctx
});
1541 throw new ResponseError(Enum
.ErrorResponse
.NotFound
, { error: 'subject not under our purview' });
1545 const result
= await
this.queuePublisher
.publish(queueName
, { ticket
, resource
, subject
, iss
, epochMs: Date
.now() });
1546 this.logger
.debug(_scope
, 'accepted ticket offer', { queueName
, ticket
, resource
, subject
, ctx
, result
});
1548 this.logger
.error(_scope
, 'failed to publish ticket to queue', { error: e
, queueName
, ticket
, resource
, subject
, ctx
});
1549 throw e
; // return a 500
1552 res
.statusCode
= 202;
1554 this.logger
.info(_scope
, 'finished', { resource
, subject
, ctx
});
1560 * Process messages from proffered ticket queue.
1561 * Attempt to redeem ticket and publish to redeemed token queue.
1562 * @param {AMQPChannel} channel
1563 * @param {Buffer} message
1565 async
queuedTicketProcessor(channel
, message
) {
1566 const _scope
= _fileScope('queuedTicketProcessor');
1568 const queueName
= this.options
.queues
.ticketRedeemedName
;
1569 let payload
, ticket
, resource
, subject
, iss
;
1571 payload
= message
.content
.toString();
1573 this.logger
.debug(_scope
, 'processing ticket', { payload
});
1579 } = JSON
.parse(payload
));
1581 this.logger
.error(_scope
, 'could not parse message, discarding', { error: e
, message
});
1582 channel
.ack(message
);
1589 issuerUrlObj
= new URL(iss
);
1592 this.logger
.debug(_scope
, 'unparsable issuer, falling back to resource discovery', { error: e
, payload
});
1597 resourceUrlObj
= new URL(resource
);
1599 this.logger
.error(_scope
, 'unparsable resource, discarding', { payload
});
1600 channel
.ack(message
);
1604 let isNotRetryable
= false;
1606 await
this.db
.context(async (dbCtx
) => {
1610 token
= await
this.communication
.redeemTicket(ticket
, resourceUrlObj
, issuerUrlObj
);
1611 isNotRetryable
= true; // assume we cannot redeem a ticket more than once
1612 this.logger
.debug(_scope
, 'successfully redeemed ticket', { token
, payload
});
1613 channel
.ack(message
);
1615 this.logger
.error(_scope
, 'failed to redeem ticket', { error: e
, payload
});
1619 // persist our redemption
1620 const redeemedData
= {
1627 await
this.db
.ticketRedeemed(dbCtx
, redeemedData
);
1630 const result
= await
this.queuePublisher
.publish(queueName
, redeemedData
);
1631 this.logger
.info(_scope
, 'published ticket token', { queueName
, ticket
, resource
, subject
, iss
, result
});
1633 this.logger
.error(_scope
, 'failed to publish token to queue', { error: e
, queueName
, ticket
, token
, resource
, subject
});
1634 throw e
; // return a 500
1637 await
this.db
.ticketTokenPublished(dbCtx
, redeemedData
);
1642 isNotRetryable
= isNotRetryable
1643 || (e
instanceof CommunicationErrors
.ValidationError
)
1644 || (e
?.response
?.statusCode
< 500)
1646 if (isNotRetryable
) {
1647 this.logger
.error(_scope
, 'failed to process ticket, not requeuing', { error: e
, payload
});
1648 channel
.ack(message
);
1651 this.logger
.error(_scope
, 'failed to process ticket, requeuing', { error: e
, payload
});
1658 * Validate a token and return data about it.
1659 * @param {http.ServerResponse} res
1660 * @param {Object} ctx
1662 async
postIntrospection(res
, ctx
) {
1663 const _scope
= _fileScope('postIntrospection');
1664 this.logger
.debug(_scope
, 'called', { ctx
});
1670 const tokenIsTicket
= (ctx
.parsedBody
['token_hint_type'] || '').toLowerCase() === 'ticket';
1673 const token
= ctx
.parsedBody
['token'];
1674 if (tokenIsTicket
) {
1675 ctx
.token
= await
this._unpackTicket(token
);
1677 ctx
.token
= await
this.mysteryBox
.unpack(token
);
1680 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1684 && !tokenIsTicket
) {
1685 await
this.db
.context(async (dbCtx
) => {
1686 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.token
.c
);
1691 && !ctx
.token
.isRevoked
) {
1692 // fuss around for postgres 'Infinity' date
1693 const expiresMs
= (ctx
.token
.expires
instanceof Date
) ? ctx
.token
.expires
.getTime() : ctx
.token
.expires
;
1694 if (expiresMs
> Date
.now()) {
1697 me: ctx
.token
.profile
,
1698 ...(ctx
.token
.clientId
&& { 'client_id': ctx
.token
.clientId
}),
1699 scope: ctx
.token
.scopes
.join(' '),
1700 iat: common
.dateToEpoch(ctx
.token
.created
|| ctx
.token
.issued
),
1701 ...(isFinite(expiresMs
) && { exp: Math
.ceil(expiresMs
/ 1000) }),
1702 ...(tokenIsTicket
&& { 'token_type': 'ticket' }),
1707 Manager
._sensitiveResponse(res
);
1708 res
.end(JSON
.stringify(response
));
1709 this.logger
.info(_scope
, 'finished', { ctx
});
1714 * Revoke a token or refresh token.
1715 * @param {http.ServerResponse} res
1716 * @param {Object} ctx
1718 async
postRevocation(res
, ctx
) {
1719 const _scope
= _fileScope('postRevocation');
1720 this.logger
.debug(_scope
, 'called', { ctx
});
1723 await
this.db
.context(async (dbCtx
) => {
1724 await
this._revokeToken(dbCtx
, res
, ctx
);
1727 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
1731 this.logger
.info(_scope
, 'finished', { ctx
});
1736 * Profile information for a token.
1737 * @param {http.ServerResponse} res
1738 * @param {Object} ctx
1740 async
postUserInfo(res
, ctx
) {
1741 const _scope
= _fileScope('postUserInfo');
1742 this.logger
.debug(_scope
, 'called', { ctx
});
1744 const token
= ctx
.parsedBody
['token'];
1746 res
.statusCode
= 400;
1747 res
.end('"invalid_request"');
1748 this.logger
.info(_scope
, 'finished, invalid request', { ctx
});
1753 ctx
.token
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['token']);
1755 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1759 await
this.db
.context(async (dbCtx
) => {
1760 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.token
.c
);
1765 || ctx
.token
.isRevoked
1766 // || tokenIsExpired(token)
1768 res
.statusCode
= 401;
1769 res
.end('"invalid_token"');
1770 this.logger
.info(_scope
, 'finished, invalid token', { ctx
});
1774 if (!ctx
.token
.scopes
.includes('profile')) {
1775 res
.statusCode
= 403;
1776 res
.end('"insufficient_scope"');
1777 this.logger
.info(_scope
, 'finished, insufficient scope', { ctx
});
1782 ...ctx
.token
.profile
,
1784 if (!ctx
.token
.scopes
.includes('email')) {
1785 delete response
.email
;
1788 Manager
._sensitiveResponse(res
);
1789 res
.end(JSON
.stringify(response
));
1791 this.logger
.info(_scope
, 'finished', { ctx
});
1796 * Show admin interface, allowing manipulation of profiles and scopes.
1797 * @param {http.ServerResponse} res
1798 * @param {Object} ctx
1800 async
getAdmin(res
, ctx
) {
1801 const _scope
= _fileScope('getAdmin');
1802 this.logger
.debug(_scope
, 'called', { ctx
});
1804 const identifier
= ctx
.session
.authenticatedIdentifier
;
1806 await
this.db
.context(async (dbCtx
) => {
1807 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1808 ctx
.tokens
= await
this.db
.tokensGetByIdentifier(dbCtx
, identifier
);
1811 res
.end(Template
.adminHTML(ctx
, this.options
));
1813 this.logger
.info(_scope
, 'finished', { ctx
});
1818 * Process admin interface events.
1819 * @param {http.ServerResponse} res
1820 * @param {Object} ctx
1822 async
postAdmin(res
, ctx
) {
1823 const _scope
= _fileScope('postAdmin');
1824 this.logger
.debug(_scope
, 'called', { ctx
});
1826 await
this.db
.context(async (dbCtx
) => {
1827 const identifier
= ctx
.session
.authenticatedIdentifier
;
1828 const action
= ctx
?.parsedBody
?.['action'] || '';
1830 if (action
=== 'save-scopes') {
1831 // Update the convenience scopes set for profiles.
1832 // Expect 'scopes-<profile>' with value of array of scopes
1833 const profileKeys
= ctx
.parsedBody
&& Object
.keys(ctx
.parsedBody
)
1834 .filter((k
) => k
.startsWith('scopes-'));
1836 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1838 /* For each scopes-profile submitted, set those. */
1839 profileKeys
.map((profileKey
) => {
1840 /* elide 'scope-' prefix to get the profile */
1841 const profile
= profileKey
.slice(7);
1842 /* (should validate profile here) */
1844 /* remove invalid scopes from submitted list */
1845 const scopes
= ctx
.parsedBody
[profileKey
].filter((scope
) => scope
&& common
.validScope(scope
)); // eslint-disable-line security/detect-object-injection
1846 return this.db
.profileScopesSetAll(txCtx
, profile
, scopes
);
1850 ctx
.notifications
.push('Profile/Scope Availability Matrix updated!');
1852 this.logger
.error(_scope
, 'did not set profile scopes', { error: e
, ctx
});
1853 ctx
.errors
.push('Failed to update profile scopes.');
1856 } else if (action
=== 'new-profile') {
1857 // Validate and create a new profile uri.
1859 const profileUri
= ctx
.parsedBody
['profile'];
1861 profile
= await
this.communication
.validateProfile(profileUri
);
1863 this.logger
.debug(_scope
, 'invalid profile url', { error: e
, ctx
});
1864 ctx
.errors
.push(`'${profileUri}' is not a valid profile URI.${(e instanceof CommunicationErrors.ValidationError) ? ('(' + e.message + ')') : ''}`);
1867 // Validate profile uri
1868 const profileData
= await
this.communication
.fetchProfile(profile
);
1869 if (profileData
.metadata
.authorizationEndpoint
!== this.selfAuthorizationEndpoint
) {
1870 this.logger
.debug(_scope
, 'profile does not list this server as auth', { profileData
, ctx
});
1871 ctx
.errors
.push('Requested profile does not list this service, not adding.');
1874 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1875 await
this.db
.profileIdentifierInsert(txCtx
, profile
.href
, identifier
);
1876 await
this.db
.profileScopesSetAll(txCtx
, profile
.href
, ['profile', 'email']);
1878 ctx
.notifications
.push('Profile added!');
1880 this.logger
.error(_scope
, 'did not insert profile', { error: e
, ctx
});
1881 ctx
.errors
.push('Failed to add profile.');
1886 } else if (action
=== 'new-scope') {
1887 // Add or update a manually-added convenience scope.
1888 const { scope
, application
= '', description
= '' } = ctx
.parsedBody
;
1890 if (!common
.validScope(scope
)) {
1891 ctx
.errors
.push(`"${scope}" is not a valid scope name, did not add it.`);
1894 await
this.db
.scopeUpsert(dbCtx
, scope
, application
, description
, true);
1895 ctx
.notifications
.push('Scope List updated!');
1897 this.logger
.error(_scope
, 'did not upsert scope', { error: e
, scope
, application
, description
, ctx
});
1898 ctx
.errors
.push('Failed to update scope.');
1903 } else if (action
.startsWith('delete-scope-')) {
1904 // Remove a manually-added convenience scope.
1905 const scope
= decodeURIComponent(action
.slice(13));
1908 const deleted
= await
this.db
.scopeDelete(dbCtx
, scope
);
1910 ctx
.notifications
.push('Scope deleted.');
1912 ctx
.notifications
.push('Unable to delete scope.');
1915 this.logger
.error(_scope
, 'did not delete scope', { error: e
, scope
, ctx
});
1916 ctx
.errors
.push('Failed to delete scope.');
1920 } else if (action
.startsWith('revoke-')) {
1921 // Revoke an active token.
1922 const codeId
= action
.slice(8);
1925 await
this.db
.tokenRevokeByCodeId(dbCtx
, codeId
, identifier
);
1926 ctx
.notifications
.push('Revoked token!');
1928 this.logger
.error(_scope
, 'did not revoke token', { error: e
, codeId
, identifier
, ctx
});
1929 ctx
.errors
.push('Unable to revoke token.');
1933 } else if (action
) {
1934 ctx
.errors
.push(`Do not know how to '${action}'.`);
1937 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1938 ctx
.tokens
= await
this.db
.tokensGetByIdentifier(dbCtx
, identifier
);
1941 res
.end(Template
.adminHTML(ctx
, this.options
));
1943 this.logger
.info(_scope
, 'finished', { ctx
});
1948 * Show ticket proffer interface.
1949 * @param {http.ServerResponse} res
1950 * @param {Object} ctx
1952 async
getAdminTicket(res
, ctx
) {
1953 const _scope
= _fileScope('getAdminTicket');
1954 this.logger
.debug(_scope
, 'called', { ctx
});
1956 const identifier
= ctx
.session
.authenticatedIdentifier
;
1958 await
this.db
.context(async (dbCtx
) => {
1959 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1960 ctx
.profiles
= ctx
.profilesScopes
.profiles
;
1961 ctx
.scopes
= Object
.keys(ctx
.profilesScopes
.scopeIndex
);
1964 res
.end(Template
.adminTicketHTML(ctx
, this.options
));
1966 this.logger
.info(_scope
, 'finished', { ctx
});
1971 * Handle ticket proffer interface submission.
1972 * @param {http.ServerResponse} res
1973 * @param {Object} ctx
1975 async
postAdminTicket(res
, ctx
) {
1976 const _scope
= _fileScope('postAdminTicket');
1977 this.logger
.debug(_scope
, 'called', { ctx
});
1979 switch (ctx
.parsedBody
['action']) { // eslint-disable-line sonarjs/no-small-switch
1980 case 'proffer-ticket': {
1981 const identifier
= ctx
.session
.authenticatedIdentifier
;
1983 { ctxProp: 'ticketProfileUrl', bodyParam: 'profile', err: 'Invalid Profile URL selected.' },
1984 { ctxProp: 'ticketResourceUrl', bodyParam: 'resource', err: 'Invalid Resource URL.' },
1985 { ctxProp: 'ticketSubjectUrl', bodyParam: 'subject', err: 'Invalid Recipient URL.' },
1986 ].forEach((param
) => {
1988 ctx
[param
.ctxProp
] = new URL(ctx
.parsedBody
[param
.bodyParam
]);
1990 this.logger
.debug(_scope
, `invalid ${param.bodyParam}`, { ctx
});
1991 ctx
.errors
.push(param
.err
);
1995 const subjectData
= await
this.communication
.fetchProfile(ctx
.ticketSubjectUrl
);
1996 if (!subjectData
?.metadata
?.ticketEndpoint
) {
1997 this.logger
.debug(_scope
, 'subject has no ticket endpoint', { ctx
});
1998 ctx
.errors
.push('Recipient does not list a ticket endpoint to deliver to.');
2001 ctx
.ticketEndpointUrl
= new URL(subjectData
.metadata
.ticketEndpoint
);
2003 this.logger
.debug(_scope
, 'subject has invalid ticket endpoint', { error: e
, ctx
});
2004 ctx
.errors
.push(`Recipient lists an invalid ticket endpoint, cannot deliver. (${e})`);
2008 const scopesSet
= new Set();
2010 ...(common
.ensureArray(ctx
.parsedBody
['scopes'])),
2011 ...((ctx
.parsedBody
['adhoc'] || '').split(scopeSplitRE
)),
2012 ].filter((scope
) => scope
);
2013 rawScopes
.forEach((scope
) => {
2014 if (common
.validScope(scope
)) {
2015 scopesSet
.add(scope
);
2017 this.logger
.debug(_scope
, 'invalid adhoc scope', { scope
, ctx
});
2018 ctx
.errors
.push(`'${scope}' is not a valid scope.`);
2021 ctx
.ticketScopes
= [...scopesSet
];
2022 const actionScopes
= ctx
.ticketScopes
.filter((scope
) => !['profile', 'email'].includes(scope
));
2023 if (!actionScopes
.length
) {
2024 this.logger
.debug(_scope
, 'no valid scopes included', { ctx
});
2025 ctx
.errors
.push('At least one actionable scope must be included.');
2028 if (!ctx
.errors
.length
) {
2029 const ticketData
= {
2030 subject: ctx
.ticketSubjectUrl
.href
,
2031 resource: ctx
.ticketResourceUrl
.href
,
2032 scopes: ctx
.ticketScopes
,
2034 profile: ctx
.ticketProfileUrl
.href
,
2035 ticketLifespanSeconds: this.options
.manager
.ticketLifespanSeconds
,
2037 const ticket
= await
this._mintTicket(ticketData
);
2039 await
this.db
.context(async (dbCtx
) => {
2040 // re-populate form fields
2041 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
2043 // TODO: queue ticket for delivery/retry to subject instead of trying immediately
2044 // ctx.notifications.push('Success! Ticket will be delivered!');
2046 this.logger
.debug(_scope
, 'ticket created', { ctx
, ticketData
, subjectData
});
2049 const issuerUrl
= new URL(this.options
.dingus
.selfBaseUrl
);
2050 const result
= await
this.communication
.deliverTicket(ctx
.ticketEndpointUrl
, ctx
.ticketResourceUrl
, ctx
.ticketSubjectUrl
, ticket
, issuerUrl
);
2051 ctx
.notifications
.push(`Success! Ticket was delivered. (${result?.statusMessage})`);
2052 this.logger
.info(_scope
, 'ticket delivered', { ctx
});
2054 this.logger
.error(_scope
, 'failed to deliver ticket', { ctx
, error: e
});
2055 ctx
.errors
.push(`Failed to deliver ticket. (${e})`);
2061 // populate form fields again
2062 await
this.db
.context(async (dbCtx
) => {
2063 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
2064 ctx
.scopes
= Object
.keys(ctx
.profilesScopes
.scopeIndex
);
2072 this.logger
.debug(_scope
, 'unknown action', { ctx
});
2075 res
.end(Template
.adminTicketHTML(ctx
, this.options
));
2077 this.logger
.info(_scope
, 'finished', { ctx
});
2082 * Report on generally uninteresting backend information.
2083 * Also allow a few event invocations.
2084 * @param {http.ServerResponse} res
2085 * @param {Object} ctx
2087 async
getAdminMaintenance(res
, ctx
) {
2088 const _scope
= _fileScope('getAdminMaintenance');
2089 this.logger
.debug(_scope
, 'called', { ctx
});
2091 const maintenanceTasks
= [];
2093 await
this.db
.context(async (dbCtx
) => {
2095 Object
.values(Enum
.Chore
).forEach((chore
) => {
2096 if (chore
in ctx
.queryParams
) {
2097 maintenanceTasks
.push(
2098 this.chores
.runChore(chore
, 0), // Provide arg to force chore run.
2100 ctx
.notifications
.push(`Running maintenance chore "${chore}".`);
2104 await Promise
.all(maintenanceTasks
);
2106 ctx
.almanac
= await
this.db
.almanacGetAll(dbCtx
);
2109 const winnowChoreEntry
= ([name
, value
]) => [name
, common
.pick(value
, ['intervalMs', 'nextSchedule'])];
2110 ctx
.chores
= Object
.fromEntries(
2111 Object
.entries(this.chores
.chores
).map(winnowChoreEntry
),
2114 res
.end(Template
.adminMaintenanceHTML(ctx
, this.options
));
2116 this.logger
.info(_scope
, 'finished', { ctx
});
2122 * @param {http.ServerResponse} res
2123 * @param {Object} ctx
2125 async
getHealthcheck(res
, ctx
) {
2126 const _scope
= _fileScope('getHealthcheck');
2127 this.logger
.debug(_scope
, 'called', { ctx
});
2128 await
this.db
.healthCheck();
2134 module
.exports
= Manager
;