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
} = 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.chores
= new Chores(logger
, db
, options
);
27 this.communication
= new Communication(logger
, options
);
28 if (options
.queues
.amqp
.url
) {
29 this.queuePublisher
= new QueuePublisher(logger
, options
.queues
.amqp
);
31 this.mysteryBox
= new MysteryBox(logger
, options
);
33 // We need to know how the outside world sees us, to verify if a
34 // profile indicates us as the auth server.
35 // selfBaseUrl should already include proxy prefix and end with a /
36 this.selfAuthorizationEndpoint
= options
.dingus
.selfBaseUrl
+ options
.route
.authorization
;
41 * Perform any async startup tasks.
44 if (this.queuePublisher
) {
45 await
this._connectQueues();
50 async
_connectQueues() {
51 await
this.queuePublisher
.connect();
52 await
this.queuePublisher
.establishAMQPPlumbing(this.options
.queues
.ticketPublishName
);
57 * Add an error to a session, keeping only the most-severe code, but all descriptions.
58 * This error is sent along on the redirection back to client endpoint.
60 * @param {Object} ctx.session
61 * @param {String[]=} ctx.session.errorDescriptions
62 * @param {String=} ctx.session.error
63 * @param {String} error
64 * @param {String} errorDescription
66 static _setError(ctx
, error
, errorDescription
) {
67 const errorPrecedence
= [ // By increasing severity
69 'unsupported_response_type',
71 'unauthorized_client',
74 'temporarily_unavailable',
77 if (!(errorPrecedence
.includes(error
))) {
78 throw new RangeError(`invalid error value '${error}'`);
80 if (!ctx
.session
.errorDescriptions
) {
81 ctx
.session
.errorDescriptions
= [];
83 if (!common
.validError(errorDescription
)) {
84 throw new RangeError(`invalid error description '${errorDescription}'`);
86 const isHigherPrecedence
= errorPrecedence
.indexOf(error
) > errorPrecedence
.indexOf(ctx
.session
.error
);
87 if (!ctx
.session
.error
|| isHigherPrecedence
) {
88 ctx
.session
.error
= error
;
90 if (isHigherPrecedence
) {
91 ctx
.session
.errorDescriptions
.unshift(errorDescription
);
93 ctx
.session
.errorDescriptions
.push(errorDescription
);
99 * Discourage caching of a response.
101 * The authorization server MUST include the HTTP Cache-Control response
102 * header field with a value of no-store in any response
103 * containing tokens, credentials, or other sensitive information.
104 * @param {http.ServerResponse} res
106 static _sensitiveResponse(res
) {
108 [Enum
.Header
.CacheControl
]: 'no-store',
109 [Enum
.Header
.Pragma
]: 'no-cache',
110 }).forEach(([k
, v
]) => res
.setHeader(k
, v
));
115 * Sets params entries as url search parameters.
117 * @param {Object} params
119 static _setSearchParams(url
, params
) {
120 Object
.entries(params
).forEach((param
) => url
.searchParams
.set(...param
));
125 * Serve the informational root page.
126 * @param {http.ClientRequest} req
127 * @param {http.ServerResponse} res
128 * @param {Object} ctx
130 async
getRoot(res
, ctx
) {
131 const _scope
= _fileScope('getRoot');
132 this.logger
.debug(_scope
, 'called', { ctx
});
134 res
.end(Template
.rootHTML(ctx
, this.options
));
135 this.logger
.info(_scope
, 'finished', { ctx
});
140 * Serve the metadata for this service.
141 * @param {http.ServerResponse} res
142 * @param {Object} ctx
144 async
getMeta(res
, ctx
) {
145 const _scope
= _fileScope('getMeta');
146 this.logger
.debug(_scope
, 'called', { ctx
});
148 const base
= this.options
.dingus
.selfBaseUrl
;
149 const endpoint
= (r
) => `${base}${this.options.route[r]}`; // eslint-disable-line security/detect-object-injection
153 'authorization_endpoint': endpoint('authorization'),
154 'token_endpoint': endpoint('token'),
155 ...(this.queuePublisher
&& { 'ticket_endpoint': endpoint('ticket') }),
156 'introspection_endpoint': endpoint('introspection'),
157 'introspection_endpoint_auth_methods_supported': ['Bearer'],
158 'revocation_endpoint': endpoint('revocation'),
159 'revocation_endpoint_auth_methods_supported': ['none'],
160 'scopes_supported': ['profile', 'email'], // only advertise minimum IA scopes
161 'response_types_supported': 'code',
162 'grant_types_supported': [
163 'authorization_code',
165 ...(this.queuePublisher
&& ['ticket'] || []),
167 'service_documentation': 'https://indieauth.spec.indieweb.org/',
168 'code_challenge_methods_supported': supportedCodeChallengeMethods
,
169 'authorization_response_iss_parameter_supported': true,
170 'userinfo_endpoint': endpoint('userinfo'),
173 res
.end(JSON
.stringify(metadata
));
174 this.logger
.info(_scope
, 'finished', { ctx
});
179 * Process an authorization request from a client.
180 * User has authenticated, check if user matches profile,
181 * present user with consent form.
182 * @param {http.ServerResponse} res
183 * @param {Object} ctx
185 async
getAuthorization(res
, ctx
) {
186 const _scope
= _fileScope('getAuthorization');
187 this.logger
.debug(_scope
, 'called', { ctx
});
189 Manager
._sensitiveResponse(res
);
191 ctx
.session
= Object
.assign({}, ctx
.session
, {
192 errorDescriptions: [],
195 // Ingest and validate expected data, populating ctx.session.
196 await
this._clientIdRequired(ctx
);
197 Manager
._redirectURIRequired(ctx
);
198 Manager
._responseTypeRequired(ctx
);
199 Manager
._stateRequired(ctx
);
200 this._codeChallengeMethodRequired(ctx
);
201 this._codeChallengeRequired(ctx
);
202 this._scopeOptional(ctx
);
203 await
this._meOptional(ctx
);
205 if (!ctx
.session
.clientIdentifier
|| !ctx
.session
.redirectUri
) {
206 // Do not redirect if either of these fields were invalid, just report error.
207 this.logger
.debug(_scope
, 'invalid request, not redirecting', { ctx
});
209 // Set error response for template to render.
210 ctx
.errors
.push('Cannot redirect to client application.');
212 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
214 res
.statusCode
= 400;
215 res
.end(Template
.authorizationErrorHTML(ctx
, this.options
));
216 this.logger
.info(_scope
, 'bad request', { ctx
});
220 await
this.db
.context(async (dbCtx
) => {
221 const profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, ctx
.authenticationId
);
222 Object
.assign(ctx
.session
, {
229 if (!ctx
.session
.profiles
.length
) {
230 this.logger
.error(_scope
, 'identifier has no profiles', { ctx
});
231 Manager
._setError(ctx
, 'access_denied', 'Profile not valid for the authenticated user.');
234 if (!this._profileValidForIdentifier(ctx
)) {
235 // if the hinted profile supplied in me does not match any known
236 // profile mappings for the authenticated identifier, remove the
237 // hint. UI will prompt to choose from available profiles.
238 this.logger
.debug(_scope
, 'removing provided me hint, not valid for identifier', { ctx
});
239 delete ctx
.session
.me
;
242 // Ugly support logic for allowing legacy non-pkce requests, for the micropub.rocks site until it is updated.
243 // Require both be missing to qualify as a legacy request, otherwise still fail.
244 const isMissingBothPKCE
= (!ctx
.session
.codeChallengeMethod
) && (!ctx
.session
.codeChallenge
);
245 if (isMissingBothPKCE
&& this.options
.manager
.allowLegacyNonPKCE
) {
246 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>');
248 if (!ctx
.session
.codeChallenge
) {
249 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge\'');
251 if (!ctx
.session
.codeChallengeMethod
) {
252 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
256 // If anything went wrong, redirect with error report.
257 if (ctx
.session
.error
) {
258 // Valid redirect_url and client_id, errors hop back to them.
259 this.logger
.debug(_scope
, 'invalid request, redirecting', { ctx
});
261 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
262 'state': ctx
.session
.state
,
263 'error': ctx
.session
.error
,
264 'error_description': ctx
.session
.errorDescriptions
.join(', '),
266 res
.statusCode
= 302; // Found
267 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
269 this.logger
.info(_scope
, 'bad request', { ctx
});
273 // Store the current state of this session, to be forwarded on to consent processing.
274 // This blob will be passed on as a form field in consent response.
275 ctx
.session
.persist
= await
this.mysteryBox
.pack({
276 id: common
.requestId(), // codeId in database
277 clientId: ctx
.session
.clientId
.href
,
278 clientIdentifier: ctx
.session
.clientIdentifier
,
279 redirectUri: ctx
.session
.redirectUri
.href
,
280 responseType: ctx
.session
.responseType
,
281 state: ctx
.session
.state
,
282 codeChallengeMethod: ctx
.session
.codeChallengeMethod
,
283 codeChallenge: ctx
.session
.codeChallenge
,
285 profiles: ctx
.session
.profiles
,
286 requestedScopes: ctx
.session
.scope
,
287 authenticationId: ctx
.authenticationId
,
290 // Present authenticated user the option to submit consent
291 const content
= Template
.authorizationRequestHTML(ctx
, this.options
);
294 this.logger
.info(_scope
, 'finished', { ctx
});
299 * Validates, fetches, and parses client_id url, populating clientIdentifier with client h-app data.
300 * @param {Object} ctx
302 async
_clientIdRequired(ctx
) {
303 if (ctx
.queryParams
['client_id']) {
305 ctx
.session
.clientId
= await
this.communication
.validateClientIdentifier(ctx
.queryParams
['client_id']);
306 ctx
.session
.clientIdentifier
= await
this.communication
.fetchClientIdentifier(ctx
.session
.clientId
);
307 if (!ctx
.session
.clientIdentifier
) {
308 Manager
._setError(ctx
, 'invalid_request', 'invalid client_id: could not fetch');
309 throw new ValidationError('could not fetch');
312 ctx
.session
.clientId
= undefined;
313 if (e
instanceof CommunicationErrors
.ValidationError
) {
314 Manager
._setError(ctx
, 'invalid_request', e
.message
);
316 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'client_id\'');
319 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'client_id\'');
325 * Ensure redirect_uri exists and is corroborated by clientIdentifier data.
326 * @param {Object} ctx
328 static _redirectURIRequired(ctx
) {
329 if (ctx
.queryParams
['redirect_uri']) {
331 ctx
.session
.redirectUri
= new URL(ctx
.queryParams
['redirect_uri']);
333 if (ctx
.session
.clientId
) {
334 // Either all these parts must match, or a specific alternative must be specified.
335 const redirectMatchesClientId
= ['protocol', 'hostname', 'port']
336 .map((p
) => ctx
.session
.redirectUri
[p
] == ctx
.session
.clientId
[p
]) // eslint-disable-line security/detect-object-injection
337 .reduce((acc
, match
) => acc
&& match
, true);
339 // Check for alternate redirect_uri entries on client_id data if no initial match
340 if (!redirectMatchesClientId
) {
341 const validRedirectUris
= ctx
.session
?.clientIdentifier
?.['rels']?.['redirect_uri'] || [];
342 if (!validRedirectUris
.includes(ctx
.session
.redirectUri
.href
)) {
343 Manager
._setError(ctx
, 'invalid_request', 'redirect_uri not valid for that client_id');
344 // Remove invalid redirect_uri from session; doing this eases error routing.
345 ctx
.session
.redirectUri
= undefined;
350 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'redirect_uri\'');
353 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'redirect_uri\'');
359 * response_type must be valid
360 * @param {Object} ctx
362 static _responseTypeRequired(ctx
) {
363 ctx
.session
.responseType
= ctx
.queryParams
['response_type'];
364 if (ctx
.session
.responseType
) {
365 // Must be one of these types
366 if (!['code'].includes(ctx
.session
.responseType
)) {
367 Manager
._setError(ctx
, 'unsupported_response_type', 'invalid value for parameter \'response_type\'');
370 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'response_type\'');
376 * A state parameter must be present
377 * @param {Object} ctx
379 static _stateRequired(ctx
) {
380 ctx
.session
.state
= ctx
.queryParams
['state'];
381 if (ctx
.session
.state
) {
382 // No restrictions on content of this
384 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'state\'');
390 * A code_challenge_method must be present and valid
391 * @param {Object} ctx
393 _codeChallengeMethodRequired(ctx
) {
394 ctx
.session
.codeChallengeMethod
= ctx
.queryParams
['code_challenge_method'];
395 if (ctx
.session
.codeChallengeMethod
) {
396 if (!supportedCodeChallengeMethods
.includes(ctx
.session
.codeChallengeMethod
)) {
397 Manager
._setError(ctx
, 'invalid_request', 'unsupported code_challenge_method');
400 if (this.options
.manager
.allowLegacyNonPKCE
) {
403 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
409 * A code_challenge must be present
410 * @param {Object} ctx
412 _codeChallengeRequired(ctx
) {
413 ctx
.session
.codeChallenge
= ctx
.queryParams
['code_challenge'];
414 if (ctx
.session
.codeChallenge
) {
415 if (!validBase64URLRE
.test(ctx
.session
.codeChallenge
)) {
416 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'code_challenge\'');
419 if (this.options
.manager
.allowLegacyNonPKCE
) {
422 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge\'');
428 * Scopes may be present, with one known combination limitation
429 * @param {Object} ctx
431 _scopeOptional(ctx
) {
432 const _scope
= _fileScope('_scopeOptional');
433 const scope
= ctx
.queryParams
['scope'];
434 ctx
.session
.scope
= [];
436 const allScopes
= scope
.split(scopeSplitRE
);
437 const validScopes
= allScopes
.filter((s
) => common
.validScope(s
));
438 ctx
.session
.scope
.push(...validScopes
);
439 if (allScopes
.length
!= validScopes
.length
) {
440 const invalidScopes
= allScopes
.filter((s
) => !common
.validScope(s
));
441 this.logger
.debug(_scope
, 'client requested invalid scope', { ctx
, invalidScopes
});
444 // If email scope is requested, profile scope must also be explicitly requested.
445 if (ctx
.session
.scope
.includes('email')
446 && !ctx
.session
.scope
.includes('profile')) {
447 Manager
._setError(ctx
, 'invalid_scope', 'cannot provide \'email\' scope without \'profile\' scope');
453 * Parses me, if provided
454 * @param {Object} ctx
456 async
_meOptional(ctx
) {
457 const me
= ctx
.queryParams
['me'];
460 ctx
.session
.me
= await
this.communication
.validateProfile(me
);
462 ctx
.session
.me
= undefined;
469 * Ensure authenticated identifier matches profile.
470 * @param {Object} ctx
473 _profileValidForIdentifier(ctx
) {
474 const _scope
= _fileScope('_profileValidForIdentifier');
476 if (!ctx
.session
.me
) {
477 this.logger
.debug(_scope
, 'no profile provided, cannot correlate', { ctx
});
481 return ctx
.session
.profiles
.includes(ctx
.session
.me
.href
);
486 * Get numeric value from form field data.
488 * @param {String} field
489 * @param {String} customField
492 _parseLifespan(ctx
, field
, customField
) {
493 const _scope
= _fileScope('_parseLifespan');
495 const presetValues
= {
501 const fieldValue
= ctx
.parsedBody
[field
]; // eslint-disable-line security/detect-object-injection
502 if (fieldValue
in presetValues
) {
503 return presetValues
[fieldValue
]; // eslint-disable-line security/detect-object-injection
506 if (fieldValue
=== 'custom') {
507 const expiresSeconds
= parseInt(ctx
.parsedBody
[customField
], 10); // eslint-disable-line security/detect-object-injection
508 if (isFinite(expiresSeconds
) && expiresSeconds
> 0) {
509 return expiresSeconds
;
511 this.logger
.debug(_scope
, 'invalid custom value', { ctx
, field
, customField
});
515 this.logger
.debug(_scope
, 'invalid value', { ctx
, field
, customField
});
521 * Validate any accepted scopes, ensure uniqueness, return as array.
522 * @param {Object} ctx
525 _parseConsentScopes(ctx
) {
526 const _scope
= _fileScope('_ingestConsentScopes');
527 const acceptedScopesSet
= new Set();
528 const rejectedScopesSet
= new Set();
530 const submittedScopes
= common
.ensureArray(ctx
.parsedBody
['accepted_scopes'])
531 .concat((ctx
.parsedBody
['ad_hoc_scopes'] || '').split(scopeSplitRE
));
532 submittedScopes
.forEach((scope
) => {
534 (common
.validScope(scope
) ? acceptedScopesSet : rejectedScopesSet
).add(scope
);
538 // If email scope was accepted but profile was not, elide email scope
539 if (acceptedScopesSet
.has('email')
540 && !acceptedScopesSet
.has('profile')) {
541 acceptedScopesSet
.delete('email');
542 rejectedScopesSet
.add('email (without profile)');
545 if (rejectedScopesSet
.size
) {
546 this.logger
.debug(_scope
, 'ignoring invalid scopes', { ctx
, rejectedScopes: Array
.from(rejectedScopesSet
) });
549 return Array
.from(acceptedScopesSet
);
554 * Parse and validate selected me is a valid profile option.
555 * @param {Object} ctx
558 _parseConsentMe(ctx
) {
559 const _scope
= _fileScope('_parseConsentMe');
560 const selectedMe
= ctx
.parsedBody
['me'];
562 const me
= new URL(selectedMe
);
563 if (ctx
.session
.profiles
.includes(me
.href
)) {
566 this.logger
.debug(_scope
, 'selected \'me\' profile not among available', { me
, available: ctx
.session
.profiles
, ctx
});
567 Manager
._setError(ctx
, 'invalid_request', 'invalid profile url');
570 this.logger
.debug(_scope
, 'failed to parse selected \'me\' as url', { error: e
, ctx
});
571 Manager
._setError(ctx
, 'invalid_request', 'invalid profile url');
578 * Get up-to-date profile data from selected profile endpoint.
579 * @param {Object} ctx
582 async
_fetchConsentProfileData(ctx
) {
583 const _scope
= _fileScope('_fetchConsentProfileData');
585 const profile
= await
this.communication
.fetchProfile(ctx
.session
.me
);
587 this.logger
.debug(_scope
, 'no profile data at \'me\' endpoint', { ctx
});
588 Manager
._setError(ctx
, 'temporarily_unavailable', 'unable to retrieve profile');
590 // Profile info gets persisted in code, only include known profile fields to help keep size down.
591 return common
.pick(profile
, [
599 this.logger
.debug(_scope
, 'failed to fetch \'me\' endpoint', { error: e
, ctx
});
600 Manager
._setError(ctx
, 'temporarily_unavailable', 'could not reach profile endpoint');
607 * Ingest user consent response details, redirect as needed.
608 * Receives POST request from consent page, expecting these form fields:
609 * session - encrypted data collected from initial auth call
610 * accept - 'true' if consent was granted
611 * accepted_scopes - list of scopes to grant
612 * ad_hoc_scopes - additional scopes specified by user
613 * me - selected profile to identify as
614 * expires - optional lifespan
615 * expires-seconds - optional custom lifespan
616 * refresh - optional refresh lifespan
617 * refresh-seconds - optional custom refresh lifespan
618 * @param {http.ServerResponse} res
619 * @param {Object} ctx
621 async
postConsent(res
, ctx
) {
622 const _scope
= _fileScope('postConsent');
623 this.logger
.debug(_scope
, 'called', { ctx
});
625 Manager
._sensitiveResponse(res
);
627 // Ensure session exists, persisting any login session data.
628 ctx
.session
= Object
.assign({}, ctx
.session
);
630 // Recover the session established on initial auth request.
631 const oldSession
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['session']);
632 Object
.assign(ctx
.session
, oldSession
);
633 ctx
.session
.redirectUri
= new URL(ctx
.session
.redirectUri
);
634 ctx
.session
.clientId
= new URL(ctx
.session
.clientId
);
636 this.logger
.debug(_scope
, 'failed to unpack session', { error: e
, ctx
});
637 Manager
._setError(ctx
, 'invalid_request', 'un-parsable data in authorization consent');
640 // If these are missing, we cannot proceed.
641 if (!ctx
.session
.clientId
|| !ctx
.session
.redirectUri
) {
642 // Set error response for html template to render.
644 'Cannot redirect to client application.',
647 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
649 res
.statusCode
= 400;
650 res
.end(Template
.authorizationErrorHTML(ctx
, this.options
));
651 this.logger
.info(_scope
, 'bad request, cannot redirect', { ctx
});
655 // TODO: Should probably re-validate more unpacked session values, even though those should be trustable.
657 // Check if we need to check anything else.
658 ctx
.session
.accept
= (ctx
.parsedBody
['accept'] === 'true');
659 if (!ctx
.session
.accept
) {
660 this.logger
.debug(_scope
, 'consent denied', { ctx
});
661 Manager
._setError(ctx
, 'access_denied', 'authorization was not granted');
664 ctx
.session
.acceptedScopes
= this._parseConsentScopes(ctx
);
665 ctx
.session
.me
= this._parseConsentMe(ctx
);
666 ctx
.session
.profile
= await
this._fetchConsentProfileData(ctx
);
667 ctx
.session
.tokenLifespan
= this._parseLifespan(ctx
, 'expires', 'expires-seconds');
668 if (ctx
.session
.tokenLifespan
) {
669 ctx
.session
.refreshLifespan
= this._parseLifespan(ctx
, 'refresh', 'refresh-seconds');
673 if (ctx
.session
.error
) {
674 this.logger
.debug(_scope
, 'invalid request, redirecting', { ctx
});
676 // Set all errors as parameters for client to interpret upon redirection.
677 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
678 'state': ctx
.session
.state
,
679 'error': ctx
.session
.error
,
680 'error_description': ctx
.session
.errorDescriptions
.join(', '),
682 res
.statusCode
= 302; // Found
683 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
685 this.logger
.info(_scope
, 'bad request, redirected', { ctx
});
689 // Consented, off we go. Keep all this session state as the code.
690 const code
= await
this.mysteryBox
.pack({
691 codeId: ctx
.session
.id
,
692 codeChallengeMethod: ctx
.session
.codeChallengeMethod
,
693 codeChallenge: ctx
.session
.codeChallenge
,
694 clientId: ctx
.session
.clientId
.href
,
695 redirectUri: ctx
.session
.redirectUri
.href
,
696 acceptedScopes: ctx
.session
.acceptedScopes
,
697 tokenLifespan: ctx
.session
.tokenLifespan
,
698 refreshLifespan: ctx
.session
.refreshLifespan
,
699 me: ctx
.session
.me
.href
,
700 profile: ctx
.session
.profile
,
701 identifier: ctx
.session
.authenticatedIdentifier
, // need this to pair with profile
705 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
707 'state': ctx
.session
.state
,
708 'iss': this.options
.dingus
.selfBaseUrl
,
710 res
.statusCode
= 302;
711 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
714 this.logger
.info(_scope
, 'finished', { ctx
});
719 * Redeem a code for a profile url, and maybe more profile info.
720 * @param {http.ServerResponse} res
721 * @param {Object} ctx
723 async
postAuthorization(res
, ctx
) {
724 const _scope
= _fileScope('postAuthorization');
725 this.logger
.debug(_scope
, 'called', { ctx
});
727 await
this._ingestPostAuthorizationRequest(ctx
);
731 ...(ctx
.session
?.acceptedScopes
?.includes('profile') && { profile: ctx
.session
.profile
}),
732 scope: ctx
.session
.acceptedScopes
,
734 if (response
.profile
&& !ctx
.session
?.acceptedScopes
?.includes('email')) {
735 delete response
.profile
.email
;
738 if (!ctx
.session
.error
) {
739 await
this.db
.context(async (dbCtx
) => {
740 // Record code redemption without token.
741 const valid
= await
this.db
.redeemCode(dbCtx
, {
742 codeId: ctx
.session
.codeId
,
745 clientId: ctx
.session
.clientId
.href
,
746 profile: ctx
.session
.me
,
747 identifier: ctx
.session
.identifier
,
748 scopes: ctx
.session
.acceptedScopes
,
749 lifespanSeconds: Math
.ceil(this.options
.manager
.codeValidityTimeoutMs
/ 1000),
750 profileData: response
.profile
,
753 this.logger
.debug(_scope
, 'code already redeemed', { ctx
});
754 Manager
._setError(ctx
, 'access_denied', 'code already redeemed');
759 if (ctx
.session
.error
) {
760 res
.statusCode
= 400;
761 res
.end(JSON
.stringify({
762 'error': ctx
.session
.error
,
763 'error_description': ctx
.session
.errorDescriptions
.join(', '),
765 this.logger
.info(_scope
, 'invalid request', { ctx
});
769 res
.end(JSON
.stringify(response
));
771 this.logger
.info(_scope
, 'finished', { ctx
, response
});
776 * Ingest an incoming authorization redemption request, parsing fields
777 * onto a new session object on the context.
779 * @param {Object} ctx
781 async
_ingestPostAuthorizationRequest(ctx
) {
782 const _scope
= _fileScope('_ingestPostAuthorizationRequest');
784 ctx
.session
= Object
.assign({}, ctx
.session
, {
785 errorDescriptions: [],
788 if (!ctx
.parsedBody
) {
789 this.logger
.debug(_scope
, 'no body data', { ctx
});
790 Manager
._setError(ctx
, 'invalid_request', 'missing data');
793 await
this._restoreSessionFromCode(ctx
);
794 this._checkSessionMatchingClientId(ctx
);
795 this._checkSessionMatchingRedirectUri(ctx
);
796 this._checkGrantType(ctx
);
797 this._checkSessionMatchingCodeVerifier(ctx
);
799 if (!ctx
.session
.me
|| !ctx
.session
.minted
) {
800 this.logger
.debug(_scope
, 'session missing fields', { ctx
});
801 Manager
._setError(ctx
, 'invalid_request', 'malformed code');
805 const expires
= new Date(ctx
.session
.minted
+ this.options
.manager
.codeValidityTimeoutMs
);
806 const now
= new Date();
808 this.logger
.debug(_scope
, 'code expired', { ctx
});
809 Manager
._setError(ctx
, 'invalid_request', 'code has expired');
815 * Unpack the session data from provided code overtop of context session ..
816 * @param {Object} ctx
818 async
_restoreSessionFromCode(ctx
) {
819 const _scope
= _fileScope('_restoreSessionFromCode');
821 const code
= ctx
.parsedBody
['code'];
824 const oldSession
= await
this.mysteryBox
.unpack(code
);
826 // TODO: Validate unpacked fields better
827 const missingFields
= [
829 'codeChallengeMethod',
838 ].filter((requiredField
) => !(requiredField
in oldSession
));
839 if (missingFields
.length
) {
840 if (this.options
.manager
.allowLegacyNonPKCE
841 && missingFields
.length
=== 2
842 && missingFields
.includes('codeChallenge')
843 && missingFields
.includes('codeChallengeMethod')) {
844 this.logger
.debug(_scope
, 'allowing legacy non-PKCE session', { ctx
});
846 this.logger
.debug(_scope
, 'unpacked code is missing required field', { missingFields
, ctx
});
847 Manager
._setError(ctx
, 'invalid_request', 'code is not valid');
851 Object
.assign(ctx
.session
, oldSession
);
853 this.logger
.debug(_scope
, 'failed to parse code', { error: e
, ctx
});
854 Manager
._setError(ctx
, 'invalid_request', 'code is not valid');
857 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code\'');
863 * Ensure provided client_id matches session clientId.
864 * @param {Object} ctx
866 _checkSessionMatchingClientId(ctx
) {
867 const _scope
= _fileScope('_checkSessionMatchingClientId');
869 let clientId
= ctx
.parsedBody
['client_id'];
872 clientId
= new URL(clientId
);
873 ctx
.session
.clientId
= new URL(ctx
.session
.clientId
);
875 this.logger
.debug(_scope
, 'un-parsable client_id url', { ctx
});
876 delete ctx
.session
.clientId
;
877 Manager
._setError(ctx
, 'invalid_request', 'malformed client_id');
880 if (clientId
.href
!== ctx
.session
.clientId
.href
) {
881 this.logger
.debug(_scope
, 'clientId mismatched', { clientId
, ctx
});
882 delete ctx
.session
.clientId
;
883 Manager
._setError(ctx
, 'invalid_request', 'code does not belong to that client_id');
886 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'client_id\'');
892 * @param {Object} ctx
894 _checkSessionMatchingRedirectUri(ctx
) {
895 const _scope
= _fileScope('_checkSessionMatchingClientId');
897 let redirectUri
= ctx
.parsedBody
['redirect_uri'];
900 redirectUri
= new URL(redirectUri
);
901 ctx
.session
.redirectUri
= new URL(ctx
.session
.redirectUri
);
903 this.logger
.debug(_scope
, 'un-parsable redirect_uri url', { ctx
});
904 delete ctx
.session
.redirectUri
;
905 Manager
._setError(ctx
, 'invalid_request', 'malformed redirect_url');
908 if (redirectUri
.href
!== ctx
.session
.redirectUri
.href
) {
909 this.logger
.debug(_scope
, 'redirectUri mismatched', { redirectUri
, ctx
});
910 delete ctx
.session
.redirectUri
;
911 Manager
._setError(ctx
, 'invalid_request', 'code does not belong to that redirect_uri');
914 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'redirect_uri\'');
920 * Validate grant_type, either persist on session or set error.
921 * @param {Object} ctx
922 * @param {String[]} validGrantTypes
923 * @param {Boolean} treatEmptyAs
925 _checkGrantType(ctx
, validGrantTypes
= ['authorization_code'], treatEmptyAs
= 'authorization_code') {
926 const _scope
= _fileScope('_checkGrantType');
928 const grantType
= ctx
.parsedBody
['grant_type'] || treatEmptyAs
;
929 if (!ctx
.parsedBody
['grant_type'] && treatEmptyAs
) {
930 this.logger
.debug(_scope
, `missing grant_type, treating as ${treatEmptyAs}`, { ctx
});
932 if (validGrantTypes
.includes(grantType
)) {
933 ctx
.session
.grantType
= grantType
;
935 Manager
._setError(ctx
, 'invalid_request', 'grant_type not supported');
941 * @param {Object} ctx
943 _checkSessionMatchingCodeVerifier(ctx
) {
944 const _scope
= _fileScope('_checkSessionMatchingCodeVerifier');
946 const codeVerifier
= ctx
.parsedBody
['code_verifier'];
949 const valid
= Communication
.verifyChallenge(ctx
.session
.codeChallenge
, codeVerifier
, ctx
.session
.codeChallengeMethod
);
951 this.logger
.debug(_scope
, 'challenge mismatched', { ctx
});
952 Manager
._setError(ctx
, 'invalid_request', 'challenge verification failed');
954 } catch (e
) /* istanbul ignore next */ {
955 this.logger
.error(_scope
, 'challenge validation failed', { error: e
, ctx
});
956 Manager
._setError(ctx
, 'invalid_request', 'challenge verification failed');
959 if (this.options
.manager
.allowLegacyNonPKCE
960 && !ctx
.session
.codeChallenge
961 && !ctx
.session
.codeChallengeMethod
) {
962 this.logger
.debug(_scope
, 'allowing non-PKCE', { ctx
});
965 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_verifier\'');
971 * Attempt to revoke a token.
973 * @param {http.ServerResponse} res
974 * @param {Object} ctx
976 async
_revokeToken(dbCtx
, res
, ctx
) {
977 const _scope
= _fileScope('_revokeToken');
979 const token
= ctx
.parsedBody
['token'];
980 const tokenTypeHint
= ctx
.parsedBody
['token_type_hint'];
981 switch (tokenTypeHint
) {
986 case 'refresh_token':
989 this.logger
.debug(_scope
, 'unknown token_type_hint', { ctx
});
992 throw new ValidationError('Token Missing');
994 ctx
.token
= await
this.mysteryBox
.unpack(token
);
995 if (!(ctx
.token
?.c
|| ctx
.token
?.rc
)) {
996 throw new ValidationError('Token Invalid');
999 this.logger
.debug(_scope
, 'invalid token', { error: e
, ctx
});
1000 res
.statusCode
= 400;
1002 this.logger
.info(_scope
, 'finished, revoke request not valid', { error: e
, ctx
});
1008 await
this.db
.tokenRevokeByCodeId(dbCtx
, ctx
.token
.c
);
1010 await
this.db
.tokenRefreshRevokeByCodeId(dbCtx
, ctx
.token
.rc
);
1013 if (e
instanceof DBErrors
.UnexpectedResult
) {
1014 res
.statusCode
= 404;
1016 this.logger
.info(_scope
, 'finished, no token to revoke', { error: e
, ctx
});
1019 this.logger
.error(_scope
, 'revoke token failed', { error: e
, ctx
});
1024 this.logger
.info(_scope
, 'finished, token revoked', { ctx
});
1029 * Legacy token validation flow.
1031 * @param {http.ClientRequest} req
1032 * @param {http.ServerResponse} res
1033 * @param {Object} ctx
1035 async
_validateToken(dbCtx
, req
, res
, ctx
) {
1036 const _scope
= _fileScope('_validateToken');
1037 await
this._checkTokenValidationRequest(dbCtx
, req
, ctx
);
1038 if (ctx
.bearer
.isValid
) {
1039 Manager
._sensitiveResponse(res
);
1040 res
.end(JSON
.stringify({
1041 me: ctx
.token
.profile
,
1042 'client_id': ctx
.token
.clientId
,
1043 scope: ctx
.token
.scopes
,
1045 this.logger
.info(_scope
, 'finished, token validated', { ctx
});
1047 const responseErrorParts
= ['Bearer'];
1048 const error
= ctx
.session
.error
? `error="${ctx.session.error}"` : '';
1050 responseErrorParts
.push(error
);
1052 const errorDescription
= ctx
.session
.errorDescriptions
? `error_description="${ctx.session.errorDescriptions.join(', ')}"` : '';
1053 if (errorDescription
) {
1054 responseErrorParts
.push(errorDescription
);
1056 res
.setHeader(Enum
.Header
.WWWAuthenticate
, responseErrorParts
.join(', '));
1057 this.logger
.info(_scope
, 'finished, token not validated', { ctx
});
1058 throw new ResponseError(Enum
.ErrorResponse
.Unauthorized
);
1064 * Given a list of newly-requested scopes, return a list of scopes
1065 * from previousScopes which are not in requestedScopes.
1066 * @param {String[]} previousScopes
1067 * @param {String[]} requestedScopes
1068 * @returns {String[]}
1070 static _scopeDifference(previousScopes
, requestedScopes
) {
1071 const scopesToRemove
= [];
1072 const existingScopesSet
= new Set(previousScopes
);
1073 const validRequestedScopes
= requestedScopes
.filter((s
) => common
.validScope(s
));
1074 const requestedScopesSet
= new Set(validRequestedScopes
);
1075 existingScopesSet
.forEach((s
) => {
1076 if (!requestedScopesSet
.has(s
)) {
1077 scopesToRemove
.push(s
);
1080 return scopesToRemove
;
1085 * Redeem a refresh token for a new token.
1087 * @param {http.ClientRequest} req
1088 * @param {http.ServerResponse} res
1089 * @param {Object} ctx
1091 async
_refreshToken(dbCtx
, req
, res
, ctx
) {
1092 const _scope
= _fileScope('_refreshToken');
1093 this.logger
.debug(_scope
, 'called', { ctx
});
1096 'client_id': clientId
,
1101 ctx
.refreshToken
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['refresh_token']);
1103 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1106 const now
= new Date();
1107 const nowEpoch
= common
.dateToEpoch(now
);
1109 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1110 if (ctx
.refreshToken
?.rc
) {
1111 ctx
.token
= await
this.db
.tokenGetByCodeId(txCtx
, ctx
.refreshToken
.rc
);
1115 this.logger
.debug(_scope
, 'no token to refresh', { ctx
});
1116 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
1119 if (!ctx
.token
.refreshExpires
1120 || ctx
.token
.refreshExpires
< now
) {
1121 this.logger
.debug(_scope
, 'token not refreshable or refresh expired', { ctx
});
1122 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1125 const refreshExpiresEpoch
= common
.dateToEpoch(ctx
.token
.refreshExpires
);
1126 if (ctx
.refreshToken
.exp
< refreshExpiresEpoch
) {
1127 this.logger
.debug(_scope
, 'token already refreshed', { ctx
});
1128 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1131 if (clientId
!== ctx
.token
.clientId
) {
1132 this.logger
.debug(_scope
, 'client identifier mismatch', { ctx
});
1133 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1136 const scopesToRemove
= scope
? Manager
._scopeDifference(ctx
.token
.scopes
, scope
.split(scopeSplitRE
)) : [];
1137 if (scopesToRemove
.length
) {
1138 this.logger
.debug(_scope
, 'scope reduction requested', { ctx
, scopesToRemove
});
1141 const refreshedTokenData
= await
this.db
.refreshCode(txCtx
, ctx
.refreshToken
.rc
, now
, scopesToRemove
);
1142 if (refreshedTokenData
) {
1143 Object
.assign(ctx
.token
, refreshedTokenData
);
1145 this.logger
.debug(_scope
, 'could not refresh token', { ctx
});
1146 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
1150 const [token
, refreshToken
] = await Promise
.all([
1152 c: ctx
.token
.codeId
,
1156 rc: ctx
.token
.codeId
,
1158 exp: nowEpoch
+ ctx
.token
.refreshDuration
,
1160 ].map(this.mysteryBox
.pack
));
1163 'access_token': token
,
1164 'token_type': 'Bearer',
1165 ...(ctx
.token
.duration
&& { 'expires_in': nowEpoch
+ ctx
.token
.duration
}),
1166 ...(refreshToken
&& { 'refresh_token': refreshToken
}),
1167 scope: ctx
.token
.scopes
.join(' '),
1169 ...(ctx
.token
.scopes
.includes('profile') && { profile: ctx
.token
.profileData
}),
1171 if (ctx
.token
.scopes
.includes('profile') && !ctx
.token
.scopes
.includes('email')) {
1172 delete response
?.profile
?.email
;
1175 Manager
._sensitiveResponse(res
);
1176 res
.end(JSON
.stringify(response
));
1177 this.logger
.info(_scope
, 'finished', { ctx
});
1182 * Generate a new ticket for later redemption.
1183 * @param {Object} payload
1184 * @param {} payload.subject deliver ticket to this endpoint
1185 * @param {} payload.resource url the redeemed ticket is valid for accessing
1186 * @param {String[]} payload.scopes list of scopes assigned to ticket
1187 * @param {String} payload.identifier user generating ticket
1188 * @param {} payload.profile profile of user generating ticket
1189 * @param {Number} payload.ticketLifespanSeconds ticket redeemable for this long
1192 async
_mintTicket({ subject
, resource
, scopes
, identifier
, profile
, ticketLifespanSeconds
}) {
1193 const _scope
= _fileScope('_mintTicket');
1194 this.logger
.debug(_scope
, 'called', { subject
, resource
, scopes
, identifier
, profile
, ticketLifespanSeconds
});
1196 const nowEpoch
= common
.dateToEpoch();
1197 return this.mysteryBox
.pack({
1198 c: common
.requestId(),
1200 exp: nowEpoch
+ ticketLifespanSeconds
,
1212 * @property {String} codeId
1213 * @property {Date} issued
1214 * @property {Date} expires
1215 * @property {URL} subject
1216 * @property {URL} resource
1217 * @property {String[]} scopes
1218 * @property {String} identifier
1219 * @property {URL} profile
1223 * @param {String} ticket
1226 async
_unpackTicket(ticket
) {
1227 const ticketObj
= await
this.mysteryBox
.unpack(ticket
);
1229 codeId: ticketObj
.c
,
1230 issued: new Date(ticketObj
.iss
* 1000),
1231 expires: new Date(ticketObj
.exp
* 1000),
1232 subject: new URL(ticketObj
.sub
),
1233 resource: new URL(ticketObj
.res
),
1234 scopes: ticketObj
.scope
,
1235 identifier: ticketObj
.ident
,
1236 profile: new URL(ticketObj
.profile
),
1242 * Redeem a ticket for a token.
1244 * @param {http.ClientRequest} req
1245 * @param {http.ServerResponse} res
1246 * @param {Object} ctx
1248 async
_ticketAuthToken(dbCtx
, req
, res
, ctx
) {
1249 const _scope
= _fileScope('_ticketAuthToken');
1250 this.logger
.debug(_scope
, 'called', { ctx
});
1253 ctx
.ticket
= await
this._unpackTicket(ctx
.parsedBody
['ticket']);
1255 this.logger
.debug(_scope
, 'failed to unpack ticket', { error: e
, ctx
});
1256 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1259 const now
= new Date();
1260 if (now
> ctx
.ticket
.expires
) {
1261 this.logger
.debug(_scope
, 'ticket has expired', { ctx
});
1262 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
, { reason: 'Ticket has expired.', expired: ctx
.ticket
.expires
});
1265 const nowEpoch
= common
.dateToEpoch(now
);
1266 const token
= await
this.mysteryBox
.pack({
1267 c: ctx
.ticket
.codeId
,
1272 'access_token': token
,
1273 'token_type': 'Bearer',
1274 scope: ctx
.ticket
.scopes
.join(' '),
1275 me: ctx
.ticket
.profile
.href
,
1278 const isValid
= await
this.db
.redeemCode(dbCtx
, {
1280 codeId: ctx
.ticket
.codeId
,
1282 clientId: ctx
.ticket
.subject
.href
,
1283 resource: ctx
.ticket
.resource
.href
,
1284 profile: ctx
.ticket
.profile
.href
,
1285 identifier: ctx
.ticket
.identifier
,
1286 scopes: ctx
.ticket
.scopes
,
1289 this.logger
.debug(_scope
, 'redemption failed, already redeemed', { ctx
});
1290 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
);
1293 Manager
._sensitiveResponse(res
);
1294 res
.end(JSON
.stringify(response
));
1295 this.logger
.info(_scope
, 'finished', { ctx
});
1300 * Redeem a code for a token.
1302 * @param {http.ClientRequest} req
1303 * @param {http.ServerResponse} res
1304 * @param {Object} ctx
1306 async
_codeToken(dbCtx
, req
, res
, ctx
) {
1307 const _scope
= _fileScope('_codeToken');
1308 this.logger
.debug(_scope
, 'called', { ctx
});
1310 await
this._restoreSessionFromCode(ctx
);
1311 await
this._checkSessionMatchingRedirectUri(ctx
);
1313 if (ctx
.session
.error
) {
1314 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1318 * Note the creation date here rather than in database, so that stored
1319 * expire dates are ensured to match those packed in tokens.
1320 * An alternative would be to return the date generated by the database,
1321 * but then we would need to hold the transaction open while minting the
1322 * tokens to ensure success. Perhaps that would be worth it, but for now
1323 * this is how it is.
1325 const now
= new Date();
1326 const nowEpoch
= common
.dateToEpoch(now
);
1327 const tokenMinters
= [];
1329 tokenMinters
.push(this.mysteryBox
.pack({
1330 c: ctx
.session
.codeId
,
1332 ...(ctx
.session
.tokenLifespan
&& { exp: nowEpoch
+ ctx
.session
.tokenLifespan
}),
1335 if (ctx
.session
.tokenLifespan
1336 && ctx
.session
.refreshLifespan
) {
1337 tokenMinters
.push(this.mysteryBox
.pack({
1338 rc: ctx
.session
.codeId
,
1340 exp: nowEpoch
+ ctx
.session
.refreshLifespan
,
1344 const [token
, refreshToken
] = await Promise
.all(tokenMinters
);
1347 'access_token': token
,
1348 'token_type': 'Bearer',
1349 ...(ctx
.session
.tokenLifespan
&& { 'expires_in': nowEpoch
+ ctx
.session
.tokenLifespan
}),
1350 ...(refreshToken
&& { 'refresh_token': refreshToken
}),
1351 scope: ctx
.session
.acceptedScopes
.join(' '),
1353 ...(ctx
.session
.acceptedScopes
.includes('profile') && { profile: ctx
.session
.profile
}),
1355 if (!ctx
.session
.acceptedScopes
.includes('email') && response
.profile
) {
1356 delete response
.profile
.email
;
1359 const isValid
= await
this.db
.redeemCode(dbCtx
, {
1361 codeId: ctx
.session
.codeId
,
1363 clientId: ctx
.session
.clientId
,
1364 profile: ctx
.session
.me
,
1365 identifier: ctx
.session
.identifier
,
1366 scopes: ctx
.session
.acceptedScopes
,
1367 lifespanSeconds: ctx
.session
.tokenLifespan
,
1368 refreshLifespanSeconds: ctx
.session
.refreshLifespan
,
1369 profileData: response
.profile
,
1372 this.logger
.debug(_scope
, 'redemption failed, already redeemed', { ctx
});
1373 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
);
1376 Manager
._sensitiveResponse(res
);
1377 res
.end(JSON
.stringify(response
));
1378 this.logger
.info(_scope
, 'finished', { ctx
});
1383 * Issue, refresh, or validate a token.
1384 * @param {http.ClientRequest} req
1385 * @param {http.ServerResponse} res
1386 * @param {Object} ctx
1388 async
postToken(req
, res
, ctx
) {
1389 const _scope
= _fileScope('postToken');
1390 this.logger
.debug(_scope
, 'called', { ctx
});
1392 ctx
.session
= Object
.assign({}, ctx
.session
);
1394 await
this.db
.context(async (dbCtx
) => {
1396 // Is this a (legacy) revocation request?
1397 if (ctx
.parsedBody
['action'] === 'revoke') {
1398 await
this._revokeToken(dbCtx
, res
, ctx
);
1402 // Is this a (legacy) token validation request?
1403 if (req
.getHeader(Enum
.Header
.Authorization
)) {
1404 await
this._validateToken(dbCtx
, res
, ctx
);
1408 const validGrantTypes
= [
1409 'authorization_code',
1411 ...(this.queuePublisher
&& ['ticket'] || []),
1413 this._checkGrantType(ctx
, validGrantTypes
, 'authorization_code');
1415 switch (ctx
.session
.grantType
) {
1416 case 'refresh_token':
1417 return this._refreshToken(dbCtx
, req
, res
, ctx
);
1420 return this._ticketAuthToken(dbCtx
, req
, res
, ctx
);
1422 case 'authorization_code':
1423 return this._codeToken(dbCtx
, req
, res
, ctx
);
1426 this.logger
.debug(_scope
, 'unknown grant_type', { ctx
});
1427 Manager
._setError(ctx
, 'invalid_request', 'grant_type not supported');
1430 // Only way of getting here is due to error.
1431 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1437 * Ingest token from authorization header, setting ctx.bearer.isValid appropriately.
1438 * ctx.bearer not set if auth method not recognized.
1439 * This is for legacy validation on token endpoint.
1441 * @param {http.ClientRequest} req
1442 * @param {Object} ctx
1444 async
_checkTokenValidationRequest(dbCtx
, req
, ctx
) {
1445 const _scope
= _fileScope('_checkTokenValidationRequest');
1446 const authHeader
= req
.getHeader(Enum
.Header
.Authorization
);
1449 const [authMethod
, authString
] = common
.splitFirst(authHeader
, ' ', '');
1450 switch (authMethod
.toLowerCase()) { // eslint-disable-line sonarjs/no-small-switch
1456 Object
.assign(ctx
.bearer
, await
this.mysteryBox
.unpack(authString
));
1458 this.logger
.debug(_scope
, 'failed to unpack token', { ctx
});
1459 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1462 if (!ctx
.bearer
.c
) {
1463 this.logger
.debug(_scope
, 'incomplete token', { ctx
});
1464 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1469 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.bearer
.c
);
1471 this.logger
.error(_scope
, 'failed to look up token', { error: e
, ctx
});
1476 this.logger
.debug(_scope
, 'no token found', { ctx
});
1477 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1481 if (!ctx
.token
.isRevoked
1482 && ctx
.token
.expires
> new Date()) {
1483 ctx
.bearer
.isValid
= true;
1489 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
1497 * Accept an unsolicited ticket proffering.
1498 * @param {http.ClientRequest} req
1499 * @param {http.ServerResponse} res
1500 * @param {Object} ctx
1502 async
postTicket(req
, res
, ctx
) {
1503 const _scope
= _fileScope('postTicket');
1504 this.logger
.debug(_scope
, 'called', { ctx
});
1506 if (!this.queuePublisher
) {
1507 this.logger
.debug(_scope
, 'ticket endpoint not configured', { ctx
});
1508 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1511 const queueName
= this.options
.queues
.ticketPublishName
;
1512 const { ticket
, resource
, subject
} = ctx
.parsedBody
;
1517 this.logger
.debug(_scope
, 'unparsable resource', { ticket
, resource
, subject
, ctx
});
1518 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1521 await
this.db
.context(async (dbCtx
) => {
1522 const isValidProfile
= await
this.db
.profileIsValid(dbCtx
, subject
);
1523 if (!isValidProfile
) {
1524 this.logger
.debug(_scope
, 'invalid subject', { ticket
, resource
, subject
, ctx
});
1525 throw new ResponseError(Enum
.ErrorResponse
.NotFound
, { error: 'subject not under our purview' });
1529 const result
= await
this.queuePublisher
.publish(queueName
, { ticket
, resource
, subject
});
1530 this.logger
.debug(_scope
, 'accepted ticket offer', { queueName
, ticket
, resource
, subject
, ctx
, result
});
1532 this.logger
.error(_scope
, 'failed to publish ticket to queue', { error: e
, queueName
, ticket
, resource
, subject
, ctx
});
1533 throw e
; // return a 500
1536 res
.statusCode
= 202;
1538 this.logger
.info(_scope
, 'finished', { resource
, subject
, ctx
});
1544 * Validate a token and return data about it.
1545 * @param {http.ServerResponse} res
1546 * @param {Object} ctx
1548 async
postIntrospection(res
, ctx
) {
1549 const _scope
= _fileScope('postIntrospection');
1550 this.logger
.debug(_scope
, 'called', { ctx
});
1556 const tokenIsTicket
= (ctx
.parsedBody
['token_hint_type'] || '').toLowerCase() === 'ticket';
1559 const token
= ctx
.parsedBody
['token'];
1560 if (tokenIsTicket
) {
1561 ctx
.token
= await
this._unpackTicket(token
);
1563 ctx
.token
= await
this.mysteryBox
.unpack(token
);
1566 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1570 && !tokenIsTicket
) {
1571 await
this.db
.context(async (dbCtx
) => {
1572 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.token
.c
);
1577 && !ctx
.token
.isRevoked
) {
1578 // fuss around for postgres 'Infinity' date
1579 const expiresMs
= (ctx
.token
.expires
instanceof Date
) ? ctx
.token
.expires
.getTime() : ctx
.token
.expires
;
1580 if (expiresMs
> Date
.now()) {
1583 me: ctx
.token
.profile
,
1584 ...(ctx
.token
.clientId
&& { 'client_id': ctx
.token
.clientId
}),
1585 scope: ctx
.token
.scopes
.join(' '),
1586 iat: common
.dateToEpoch(ctx
.token
.created
|| ctx
.token
.issued
),
1587 ...(isFinite(expiresMs
) && { exp: Math
.ceil(expiresMs
/ 1000) }),
1588 ...(tokenIsTicket
&& { 'token_type': 'ticket' }),
1593 Manager
._sensitiveResponse(res
);
1594 res
.end(JSON
.stringify(response
));
1595 this.logger
.info(_scope
, 'finished', { ctx
});
1600 * Revoke a token or refresh token.
1601 * @param {http.ServerResponse} res
1602 * @param {Object} ctx
1604 async
postRevocation(res
, ctx
) {
1605 const _scope
= _fileScope('postRevocation');
1606 this.logger
.debug(_scope
, 'called', { ctx
});
1609 await
this.db
.context(async (dbCtx
) => {
1610 await
this._revokeToken(dbCtx
, res
, ctx
);
1613 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
1617 this.logger
.info(_scope
, 'finished', { ctx
});
1622 * Profile information for a token.
1623 * @param {http.ServerResponse} res
1624 * @param {Object} ctx
1626 async
postUserInfo(res
, ctx
) {
1627 const _scope
= _fileScope('postUserInfo');
1628 this.logger
.debug(_scope
, 'called', { ctx
});
1630 const token
= ctx
.parsedBody
['token'];
1632 res
.statusCode
= 400;
1633 res
.end('"invalid_request"');
1634 this.logger
.info(_scope
, 'finished, invalid request', { ctx
});
1639 ctx
.token
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['token']);
1641 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1645 await
this.db
.context(async (dbCtx
) => {
1646 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.token
.c
);
1651 || ctx
.token
.isRevoked
1652 // || tokenIsExpired(token)
1654 res
.statusCode
= 401;
1655 res
.end('"invalid_token"');
1656 this.logger
.info(_scope
, 'finished, invalid token', { ctx
});
1660 if (!ctx
.token
.scopes
.includes('profile')) {
1661 res
.statusCode
= 403;
1662 res
.end('"insufficient_scope"');
1663 this.logger
.info(_scope
, 'finished, insufficient scope', { ctx
});
1668 ...ctx
.token
.profile
,
1670 if (!ctx
.token
.scopes
.includes('email')) {
1671 delete response
.email
;
1674 Manager
._sensitiveResponse(res
);
1675 res
.end(JSON
.stringify(response
));
1677 this.logger
.info(_scope
, 'finished', { ctx
});
1682 * Show admin interface, allowing manipulation of profiles and scopes.
1683 * @param {http.ServerResponse} res
1684 * @param {Object} ctx
1686 async
getAdmin(res
, ctx
) {
1687 const _scope
= _fileScope('getAdmin');
1688 this.logger
.debug(_scope
, 'called', { ctx
});
1690 const identifier
= ctx
.session
.authenticatedIdentifier
;
1692 await
this.db
.context(async (dbCtx
) => {
1693 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1694 ctx
.tokens
= await
this.db
.tokensGetByIdentifier(dbCtx
, identifier
);
1697 res
.end(Template
.adminHTML(ctx
, this.options
));
1699 this.logger
.info(_scope
, 'finished', { ctx
});
1704 * Process admin interface events.
1705 * @param {http.ServerResponse} res
1706 * @param {Object} ctx
1708 async
postAdmin(res
, ctx
) {
1709 const _scope
= _fileScope('postAdmin');
1710 this.logger
.debug(_scope
, 'called', { ctx
});
1712 await
this.db
.context(async (dbCtx
) => {
1713 const identifier
= ctx
.session
.authenticatedIdentifier
;
1714 const action
= ctx
?.parsedBody
?.['action'] || '';
1716 if (action
=== 'save-scopes') {
1717 // Update the convenience scopes set for profiles.
1718 // Expect 'scopes-<profile>' with value of array of scopes
1719 const profileKeys
= ctx
.parsedBody
&& Object
.keys(ctx
.parsedBody
)
1720 .filter((k
) => k
.startsWith('scopes-'));
1722 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1724 /* For each scopes-profile submitted, set those. */
1725 profileKeys
.map((profileKey
) => {
1726 /* elide 'scope-' prefix to get the profile */
1727 const profile
= profileKey
.slice(7);
1728 /* (should validate profile here) */
1730 /* remove invalid scopes from submitted list */
1731 const scopes
= ctx
.parsedBody
[profileKey
].filter((scope
) => scope
&& common
.validScope(scope
)); // eslint-disable-line security/detect-object-injection
1732 return this.db
.profileScopesSetAll(txCtx
, profile
, scopes
);
1736 ctx
.notifications
.push('Profile/Scope Availability Matrix updated!');
1738 this.logger
.error(_scope
, 'did not set profile scopes', { error: e
, ctx
});
1739 ctx
.errors
.push('Failed to update profile scopes.');
1742 } else if (action
=== 'new-profile') {
1743 // Validate and create a new profile uri.
1745 const profileUri
= ctx
.parsedBody
['profile'];
1747 profile
= await
this.communication
.validateProfile(profileUri
);
1749 this.logger
.debug(_scope
, 'invalid profile url', { error: e
, ctx
});
1750 ctx
.errors
.push(`'${profileUri}' is not a valid profile URI.${(e instanceof CommunicationErrors.ValidationError) ? ('(' + e.message + ')') : ''}`);
1753 // Validate profile uri
1754 const profileData
= await
this.communication
.fetchProfile(profile
);
1755 if (profileData
.metadata
.authorizationEndpoint
!== this.selfAuthorizationEndpoint
) {
1756 this.logger
.debug(_scope
, 'profile does not list this server as auth', { profileData
, ctx
});
1757 ctx
.errors
.push('Requested profile does not list this service, not adding.');
1760 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1761 await
this.db
.profileIdentifierInsert(txCtx
, profile
.href
, identifier
);
1762 await
this.db
.profileScopesSetAll(txCtx
, profile
.href
, ['profile', 'email']);
1764 ctx
.notifications
.push('Profile added!');
1766 this.logger
.error(_scope
, 'did not insert profile', { error: e
, ctx
});
1767 ctx
.errors
.push('Failed to add profile.');
1772 } else if (action
=== 'new-scope') {
1773 // Add or update a manually-added convenience scope.
1774 const { scope
, application
= '', description
= '' } = ctx
.parsedBody
;
1776 if (!common
.validScope(scope
)) {
1777 ctx
.errors
.push(`"${scope}" is not a valid scope name, did not add it.`);
1780 await
this.db
.scopeUpsert(dbCtx
, scope
, application
, description
, true);
1781 ctx
.notifications
.push('Scope List updated!');
1783 this.logger
.error(_scope
, 'did not upsert scope', { error: e
, scope
, application
, description
, ctx
});
1784 ctx
.errors
.push('Failed to update scope.');
1789 } else if (action
.startsWith('delete-scope-')) {
1790 // Remove a manually-added convenience scope.
1791 const scope
= decodeURIComponent(action
.slice(13));
1794 const deleted
= await
this.db
.scopeDelete(dbCtx
, scope
);
1796 ctx
.notifications
.push('Scope deleted.');
1798 ctx
.notifications
.push('Unable to delete scope.');
1801 this.logger
.error(_scope
, 'did not delete scope', { error: e
, scope
, ctx
});
1802 ctx
.errors
.push('Failed to delete scope.');
1806 } else if (action
.startsWith('revoke-')) {
1807 // Revoke an active token.
1808 const codeId
= action
.slice(8);
1811 await
this.db
.tokenRevokeByCodeId(dbCtx
, codeId
, identifier
);
1812 ctx
.notifications
.push('Revoked token!');
1814 this.logger
.error(_scope
, 'did not revoke token', { error: e
, codeId
, identifier
, ctx
});
1815 ctx
.errors
.push('Unable to revoke token.');
1819 } else if (action
) {
1820 ctx
.errors
.push(`Do not know how to '${action}'.`);
1823 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1824 ctx
.tokens
= await
this.db
.tokensGetByIdentifier(dbCtx
, identifier
);
1827 res
.end(Template
.adminHTML(ctx
, this.options
));
1829 this.logger
.info(_scope
, 'finished', { ctx
});
1834 * Show ticket proffer interface.
1835 * @param {http.ServerResponse} res
1836 * @param {Object} ctx
1838 async
getAdminTicket(res
, ctx
) {
1839 const _scope
= _fileScope('getAdminTicket');
1840 this.logger
.debug(_scope
, 'called', { ctx
});
1842 const identifier
= ctx
.session
.authenticatedIdentifier
;
1844 await
this.db
.context(async (dbCtx
) => {
1845 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1846 ctx
.profiles
= ctx
.profilesScopes
.profiles
;
1847 ctx
.scopes
= Object
.keys(ctx
.profilesScopes
.scopeIndex
);
1850 res
.end(Template
.adminTicketHTML(ctx
, this.options
));
1852 this.logger
.info(_scope
, 'finished', { ctx
});
1857 * Handle ticket proffer interface submission.
1858 * @param {http.ServerResponse} res
1859 * @param {Object} ctx
1861 async
postAdminTicket(res
, ctx
) {
1862 const _scope
= _fileScope('postAdminTicket');
1863 this.logger
.debug(_scope
, 'called', { ctx
});
1865 switch (ctx
.parsedBody
['action']) { // eslint-disable-line sonarjs/no-small-switch
1866 case 'proffer-ticket': {
1867 const identifier
= ctx
.session
.authenticatedIdentifier
;
1869 { ctxProp: 'ticketProfileUrl', bodyParam: 'profile', err: 'Invalid Profile URL selected.' },
1870 { ctxProp: 'ticketResourceUrl', bodyParam: 'resource', err: 'Invalid Resource URL.' },
1871 { ctxProp: 'ticketSubjectUrl', bodyParam: 'subject', err: 'Invalid Recipient URL.' },
1872 ].forEach((param
) => {
1874 ctx
[param
.ctxProp
] = new URL(ctx
.parsedBody
[param
.bodyParam
]);
1876 this.logger
.debug(_scope
, `invalid ${param.bodyParam}`, { ctx
});
1877 ctx
.errors
.push(param
.err
);
1881 const subjectData
= await
this.communication
.fetchProfile(ctx
.ticketSubjectUrl
);
1882 if (!subjectData
?.metadata
?.ticketEndpoint
) {
1883 this.logger
.debug(_scope
, 'subject has no ticket endpoint', { ctx
});
1884 ctx
.errors
.push('Recipient does not list a ticket endpoint to deliver to.');
1887 ctx
.ticketEndpointUrl
= new URL(subjectData
.metadata
.ticketEndpoint
);
1889 this.logger
.debug(_scope
, 'subject has invalid ticket endpoint', { error: e
, ctx
});
1890 ctx
.errors
.push(`Recipient lists an invalid ticket endpoint, cannot deliver. (${e})`);
1894 const scopesSet
= new Set();
1896 ...(common
.ensureArray(ctx
.parsedBody
['scopes'])),
1897 ...((ctx
.parsedBody
['adhoc'] || '').split(scopeSplitRE
)),
1898 ].filter((scope
) => scope
);
1899 rawScopes
.forEach((scope
) => {
1900 if (common
.validScope(scope
)) {
1901 scopesSet
.add(scope
);
1903 this.logger
.debug(_scope
, 'invalid adhoc scope', { scope
, ctx
});
1904 ctx
.errors
.push(`'${scope}' is not a valid scope.`);
1907 ctx
.ticketScopes
= [...scopesSet
];
1908 const actionScopes
= ctx
.ticketScopes
.filter((scope
) => !['profile', 'email'].includes(scope
));
1909 if (!actionScopes
.length
) {
1910 this.logger
.debug(_scope
, 'no valid scopes included', { ctx
});
1911 ctx
.errors
.push('At least one actionable scope must be included.');
1914 if (!ctx
.errors
.length
) {
1915 const ticketData
= {
1916 subject: ctx
.ticketSubjectUrl
.href
,
1917 resource: ctx
.ticketResourceUrl
.href
,
1918 scopes: ctx
.ticketScopes
,
1920 profile: ctx
.ticketProfileUrl
.href
,
1921 ticketLifespanSeconds: this.options
.manager
.ticketLifespanSeconds
,
1923 const ticket
= await
this._mintTicket(ticketData
);
1925 await
this.db
.context(async (dbCtx
) => {
1926 // re-populate form fields
1927 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1929 // TODO: queue ticket for delivery/retry to subject instead of trying immediately
1930 // ctx.notifications.push('Success! Ticket will be delivered!');
1932 this.logger
.debug(_scope
, 'ticket created', { ctx
, ticketData
, subjectData
});
1935 const result
= await
this.communication
.deliverTicket(ctx
.ticketEndpointUrl
, ctx
.ticketResourceUrl
, ctx
.ticketSubjectUrl
, ticket
);
1936 ctx
.notifications
.push(`Success! Ticket was delivered. (${result?.statusText})`);
1937 this.logger
.info(_scope
, 'ticket delivered', { ctx
, result
});
1939 this.logger
.error(_scope
, 'failed to deliver ticket', { ctx
, error: e
});
1940 ctx
.errors
.push(`Failed to deliver ticket. (${e})`);
1946 // populate form fields again
1947 await
this.db
.context(async (dbCtx
) => {
1948 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1949 ctx
.scopes
= Object
.keys(ctx
.profilesScopes
.scopeIndex
);
1957 this.logger
.debug(_scope
, 'unknown action', { ctx
});
1960 res
.end(Template
.adminTicketHTML(ctx
, this.options
));
1962 this.logger
.info(_scope
, 'finished', { ctx
});
1967 * Report on generally uninteresting backend information.
1968 * Also allow a few event invocations.
1969 * @param {http.ServerResponse} res
1970 * @param {Object} ctx
1972 async
getAdminMaintenance(res
, ctx
) {
1973 const _scope
= _fileScope('getAdminMaintenance');
1974 this.logger
.debug(_scope
, 'called', { ctx
});
1976 const maintenanceTasks
= [];
1978 await
this.db
.context(async (dbCtx
) => {
1980 Object
.values(Enum
.Chore
).forEach((chore
) => {
1981 if (chore
in ctx
.queryParams
) {
1982 maintenanceTasks
.push(
1983 this.chores
.runChore(chore
, 0), // Provide arg to force chore run.
1985 ctx
.notifications
.push(`Running maintenance chore "${chore}".`);
1989 await Promise
.all(maintenanceTasks
);
1991 ctx
.almanac
= await
this.db
.almanacGetAll(dbCtx
);
1994 const winnowChoreEntry
= ([name
, value
]) => [name
, common
.pick(value
, ['intervalMs', 'nextSchedule'])];
1995 ctx
.chores
= Object
.fromEntries(
1996 Object
.entries(this.chores
.chores
).map(winnowChoreEntry
),
1999 res
.end(Template
.adminMaintenanceHTML(ctx
, this.options
));
2001 this.logger
.info(_scope
, 'finished', { ctx
});
2007 * @param {http.ServerResponse} res
2008 * @param {Object} ctx
2010 async
getHealthcheck(res
, ctx
) {
2011 const _scope
= _fileScope('getHealthcheck');
2012 this.logger
.debug(_scope
, 'called', { ctx
});
2013 await
this.db
.healthCheck();
2019 module
.exports
= Manager
;