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 ctx
.session
= Object
.assign({}, ctx
.session
, {
190 errorDescriptions: [],
193 // Ingest and validate expected data, populating ctx.session.
194 await
this._clientIdRequired(ctx
);
195 Manager
._redirectURIRequired(ctx
);
196 Manager
._responseTypeRequired(ctx
);
197 Manager
._stateRequired(ctx
);
198 this._codeChallengeMethodRequired(ctx
);
199 this._codeChallengeRequired(ctx
);
200 this._scopeOptional(ctx
);
201 await
this._meOptional(ctx
);
203 if (!ctx
.session
.clientIdentifier
|| !ctx
.session
.redirectUri
) {
204 // Do not redirect if either of these fields were invalid, just report error.
205 this.logger
.debug(_scope
, 'invalid request, not redirecting', { ctx
});
207 // Set error response for template to render.
208 ctx
.errors
.push('Cannot redirect to client application.');
210 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
212 res
.statusCode
= 400;
213 res
.end(Template
.authorizationErrorHTML(ctx
, this.options
));
214 this.logger
.info(_scope
, 'bad request', { ctx
});
218 await
this.db
.context(async (dbCtx
) => {
219 const profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, ctx
.authenticationId
);
220 Object
.assign(ctx
.session
, {
227 if (!ctx
.session
.profiles
.length
) {
228 this.logger
.error(_scope
, 'identifier has no profiles', { ctx
});
229 Manager
._setError(ctx
, 'access_denied', 'Profile not valid for the authenticated user.');
232 if (!this._profileValidForIdentifier(ctx
)) {
233 // if the hinted profile supplied in me does not match any known
234 // profile mappings for the authenticated identifier, remove the
235 // hint. UI will prompt to choose from available profiles.
236 this.logger
.debug(_scope
, 'removing provided me hint, not valid for identifier', { ctx
});
237 delete ctx
.session
.me
;
240 // Ugly support logic for allowing legacy non-pkce requests, for the micropub.rocks site until it is updated.
241 // Require both be missing to qualify as a legacy request, otherwise still fail.
242 const isMissingBothPKCE
= (!ctx
.session
.codeChallengeMethod
) && (!ctx
.session
.codeChallenge
);
243 if (isMissingBothPKCE
&& this.options
.manager
.allowLegacyNonPKCE
) {
244 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>');
246 if (!ctx
.session
.codeChallenge
) {
247 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge\'');
249 if (!ctx
.session
.codeChallengeMethod
) {
250 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
254 // If anything went wrong, redirect with error report.
255 if (ctx
.session
.error
) {
256 // Valid redirect_url and client_id, errors hop back to them.
257 this.logger
.debug(_scope
, 'invalid request, redirecting', { ctx
});
259 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
260 'state': ctx
.session
.state
,
261 'error': ctx
.session
.error
,
262 'error_description': ctx
.session
.errorDescriptions
.join(', '),
264 res
.statusCode
= 302; // Found
265 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
267 this.logger
.info(_scope
, 'bad request', { ctx
});
271 // Store the current state of this session, to be forwarded on to consent processing.
272 // This blob will be passed on as a form field in consent response.
273 ctx
.session
.persist
= await
this.mysteryBox
.pack({
274 id: common
.requestId(), // codeId in database
275 clientId: ctx
.session
.clientId
.href
,
276 clientIdentifier: ctx
.session
.clientIdentifier
,
277 redirectUri: ctx
.session
.redirectUri
.href
,
278 responseType: ctx
.session
.responseType
,
279 state: ctx
.session
.state
,
280 codeChallengeMethod: ctx
.session
.codeChallengeMethod
,
281 codeChallenge: ctx
.session
.codeChallenge
,
283 profiles: ctx
.session
.profiles
,
284 requestedScopes: ctx
.session
.scope
,
285 authenticationId: ctx
.authenticationId
,
288 // Present authenticated user the option to submit consent
289 const content
= Template
.authorizationRequestHTML(ctx
, this.options
);
292 this.logger
.info(_scope
, 'finished', { ctx
});
297 * Validates, fetches, and parses client_id url, populating clientIdentifier with client h-app data.
298 * @param {Object} ctx
300 async
_clientIdRequired(ctx
) {
301 if (ctx
.queryParams
['client_id']) {
303 ctx
.session
.clientId
= await
this.communication
.validateClientIdentifier(ctx
.queryParams
['client_id']);
304 ctx
.session
.clientIdentifier
= await
this.communication
.fetchClientIdentifier(ctx
.session
.clientId
);
305 if (!ctx
.session
.clientIdentifier
) {
306 Manager
._setError(ctx
, 'invalid_request', 'invalid client_id: could not fetch');
307 throw new ValidationError('could not fetch');
310 ctx
.session
.clientId
= undefined;
311 if (e
instanceof CommunicationErrors
.ValidationError
) {
312 Manager
._setError(ctx
, 'invalid_request', e
.message
);
314 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'client_id\'');
317 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'client_id\'');
323 * Ensure redirect_uri exists and is corroborated by clientIdentifier data.
324 * @param {Object} ctx
326 static _redirectURIRequired(ctx
) {
327 if (ctx
.queryParams
['redirect_uri']) {
329 ctx
.session
.redirectUri
= new URL(ctx
.queryParams
['redirect_uri']);
331 if (ctx
.session
.clientId
) {
332 // Either all these parts must match, or a specific alternative must be specified.
333 const redirectMatchesClientId
= ['protocol', 'hostname', 'port']
334 .map((p
) => ctx
.session
.redirectUri
[p
] == ctx
.session
.clientId
[p
]) // eslint-disable-line security/detect-object-injection
335 .reduce((acc
, match
) => acc
&& match
, true);
337 // Check for alternate redirect_uri entries on client_id data if no initial match
338 if (!redirectMatchesClientId
) {
339 const validRedirectUris
= ctx
.session
?.clientIdentifier
?.['rels']?.['redirect_uri'] || [];
340 if (!validRedirectUris
.includes(ctx
.session
.redirectUri
.href
)) {
341 Manager
._setError(ctx
, 'invalid_request', 'redirect_uri not valid for that client_id');
342 // Remove invalid redirect_uri from session; doing this eases error routing.
343 ctx
.session
.redirectUri
= undefined;
348 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'redirect_uri\'');
351 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'redirect_uri\'');
357 * response_type must be valid
358 * @param {Object} ctx
360 static _responseTypeRequired(ctx
) {
361 ctx
.session
.responseType
= ctx
.queryParams
['response_type'];
362 if (ctx
.session
.responseType
) {
363 // Must be one of these types
364 if (!['code'].includes(ctx
.session
.responseType
)) {
365 Manager
._setError(ctx
, 'unsupported_response_type', 'invalid value for parameter \'response_type\'');
368 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'response_type\'');
374 * A state parameter must be present
375 * @param {Object} ctx
377 static _stateRequired(ctx
) {
378 ctx
.session
.state
= ctx
.queryParams
['state'];
379 if (ctx
.session
.state
) {
380 // No restrictions on content of this
382 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'state\'');
388 * A code_challenge_method must be present and valid
389 * @param {Object} ctx
391 _codeChallengeMethodRequired(ctx
) {
392 ctx
.session
.codeChallengeMethod
= ctx
.queryParams
['code_challenge_method'];
393 if (ctx
.session
.codeChallengeMethod
) {
394 if (!supportedCodeChallengeMethods
.includes(ctx
.session
.codeChallengeMethod
)) {
395 Manager
._setError(ctx
, 'invalid_request', 'unsupported code_challenge_method');
398 if (this.options
.manager
.allowLegacyNonPKCE
) {
401 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
407 * A code_challenge must be present
408 * @param {Object} ctx
410 _codeChallengeRequired(ctx
) {
411 ctx
.session
.codeChallenge
= ctx
.queryParams
['code_challenge'];
412 if (ctx
.session
.codeChallenge
) {
413 if (!validBase64URLRE
.test(ctx
.session
.codeChallenge
)) {
414 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'code_challenge\'');
417 if (this.options
.manager
.allowLegacyNonPKCE
) {
420 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge\'');
426 * Scopes may be present, with one known combination limitation
427 * @param {Object} ctx
429 _scopeOptional(ctx
) {
430 const _scope
= _fileScope('_scopeOptional');
431 const scope
= ctx
.queryParams
['scope'];
432 ctx
.session
.scope
= [];
434 const allScopes
= scope
.split(scopeSplitRE
);
435 const validScopes
= allScopes
.filter((s
) => common
.validScope(s
));
436 ctx
.session
.scope
.push(...validScopes
);
437 if (allScopes
.length
!= validScopes
.length
) {
438 const invalidScopes
= allScopes
.filter((s
) => !common
.validScope(s
));
439 this.logger
.debug(_scope
, 'client requested invalid scope', { ctx
, invalidScopes
});
442 // If email scope is requested, profile scope must also be explicitly requested.
443 if (ctx
.session
.scope
.includes('email')
444 && !ctx
.session
.scope
.includes('profile')) {
445 Manager
._setError(ctx
, 'invalid_scope', 'cannot provide \'email\' scope without \'profile\' scope');
451 * Parses me, if provided
452 * @param {Object} ctx
454 async
_meOptional(ctx
) {
455 const me
= ctx
.queryParams
['me'];
458 ctx
.session
.me
= await
this.communication
.validateProfile(me
);
460 ctx
.session
.me
= undefined;
467 * Ensure authenticated identifier matches profile.
468 * @param {Object} ctx
471 _profileValidForIdentifier(ctx
) {
472 const _scope
= _fileScope('_profileValidForIdentifier');
474 if (!ctx
.session
.me
) {
475 this.logger
.debug(_scope
, 'no profile provided, cannot correlate', { ctx
});
479 return ctx
.session
.profiles
.includes(ctx
.session
.me
.href
);
484 * Get numeric value from form field data.
486 * @param {String} field
487 * @param {String} customField
490 _parseLifespan(ctx
, field
, customField
) {
491 const _scope
= _fileScope('_parseLifespan');
493 const presetValues
= {
499 const fieldValue
= ctx
.parsedBody
[field
]; // eslint-disable-line security/detect-object-injection
500 if (fieldValue
in presetValues
) {
501 return presetValues
[fieldValue
]; // eslint-disable-line security/detect-object-injection
504 if (fieldValue
=== 'custom') {
505 const expiresSeconds
= parseInt(ctx
.parsedBody
[customField
], 10); // eslint-disable-line security/detect-object-injection
506 if (isFinite(expiresSeconds
) && expiresSeconds
> 0) {
507 return expiresSeconds
;
509 this.logger
.debug(_scope
, 'invalid custom value', { ctx
, field
, customField
});
513 this.logger
.debug(_scope
, 'invalid value', { ctx
, field
, customField
});
519 * Validate any accepted scopes, ensure uniqueness, return as array.
520 * @param {Object} ctx
523 _parseConsentScopes(ctx
) {
524 const _scope
= _fileScope('_ingestConsentScopes');
525 const acceptedScopesSet
= new Set();
526 const rejectedScopesSet
= new Set();
528 const submittedScopes
= common
.ensureArray(ctx
.parsedBody
['accepted_scopes'])
529 .concat((ctx
.parsedBody
['ad_hoc_scopes'] || '').split(scopeSplitRE
));
530 submittedScopes
.forEach((scope
) => {
532 (common
.validScope(scope
) ? acceptedScopesSet : rejectedScopesSet
).add(scope
);
536 // If email scope was accepted but profile was not, elide email scope
537 if (acceptedScopesSet
.has('email')
538 && !acceptedScopesSet
.has('profile')) {
539 acceptedScopesSet
.delete('email');
540 rejectedScopesSet
.add('email (without profile)');
543 if (rejectedScopesSet
.size
) {
544 this.logger
.debug(_scope
, 'ignoring invalid scopes', { ctx
, rejectedScopes: Array
.from(rejectedScopesSet
) });
547 return Array
.from(acceptedScopesSet
);
552 * Parse and validate selected me is a valid profile option.
553 * @param {Object} ctx
556 _parseConsentMe(ctx
) {
557 const _scope
= _fileScope('_parseConsentMe');
558 const selectedMe
= ctx
.parsedBody
['me'];
560 const me
= new URL(selectedMe
);
561 if (ctx
.session
.profiles
.includes(me
.href
)) {
564 this.logger
.debug(_scope
, 'selected \'me\' profile not among available', { me
, available: ctx
.session
.profiles
, ctx
});
565 Manager
._setError(ctx
, 'invalid_request', 'invalid profile url');
568 this.logger
.debug(_scope
, 'failed to parse selected \'me\' as url', { error: e
, ctx
});
569 Manager
._setError(ctx
, 'invalid_request', 'invalid profile url');
576 * Get up-to-date profile data from selected profile endpoint.
577 * @param {Object} ctx
580 async
_fetchConsentProfileData(ctx
) {
581 const _scope
= _fileScope('_fetchConsentProfileData');
583 const profile
= await
this.communication
.fetchProfile(ctx
.session
.me
);
585 this.logger
.debug(_scope
, 'no profile data at \'me\' endpoint', { ctx
});
586 Manager
._setError(ctx
, 'temporarily_unavailable', 'unable to retrieve profile');
588 // Profile info gets persisted in code, only include known profile fields to help keep size down.
589 return common
.pick(profile
, [
597 this.logger
.debug(_scope
, 'failed to fetch \'me\' endpoint', { error: e
, ctx
});
598 Manager
._setError(ctx
, 'temporarily_unavailable', 'could not reach profile endpoint');
605 * Ingest user consent response details, redirect as needed.
606 * Receives POST request from consent page, expecting these form fields:
607 * session - encrypted data collected from initial auth call
608 * accept - 'true' if consent was granted
609 * accepted_scopes - list of scopes to grant
610 * ad_hoc_scopes - additional scopes specified by user
611 * me - selected profile to identify as
612 * expires - optional lifespan
613 * expires-seconds - optional custom lifespan
614 * refresh - optional refresh lifespan
615 * refresh-seconds - optional custom refresh lifespan
616 * @param {http.ServerResponse} res
617 * @param {Object} ctx
619 async
postConsent(res
, ctx
) {
620 const _scope
= _fileScope('postConsent');
621 this.logger
.debug(_scope
, 'called', { ctx
});
623 // Ensure session exists, persisting any login session data.
624 ctx
.session
= Object
.assign({}, ctx
.session
);
626 // Recover the session established on initial auth request.
627 const oldSession
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['session']);
628 Object
.assign(ctx
.session
, oldSession
);
629 ctx
.session
.redirectUri
= new URL(ctx
.session
.redirectUri
);
630 ctx
.session
.clientId
= new URL(ctx
.session
.clientId
);
632 this.logger
.debug(_scope
, 'failed to unpack session', { error: e
, ctx
});
633 Manager
._setError(ctx
, 'invalid_request', 'un-parsable data in authorization consent');
636 // If these are missing, we cannot proceed.
637 if (!ctx
.session
.clientId
|| !ctx
.session
.redirectUri
) {
638 // Set error response for html template to render.
640 'Cannot redirect to client application.',
643 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
645 res
.statusCode
= 400;
646 res
.end(Template
.authorizationErrorHTML(ctx
, this.options
));
647 this.logger
.info(_scope
, 'bad request, cannot redirect', { ctx
});
651 // TODO: Should probably re-validate more unpacked session values, even though those should be trustable.
653 // Check if we need to check anything else.
654 ctx
.session
.accept
= (ctx
.parsedBody
['accept'] === 'true');
655 if (!ctx
.session
.accept
) {
656 this.logger
.debug(_scope
, 'consent denied', { ctx
});
657 Manager
._setError(ctx
, 'access_denied', 'authorization was not granted');
660 ctx
.session
.acceptedScopes
= this._parseConsentScopes(ctx
);
661 ctx
.session
.me
= this._parseConsentMe(ctx
);
662 ctx
.session
.profile
= await
this._fetchConsentProfileData(ctx
);
663 ctx
.session
.tokenLifespan
= this._parseLifespan(ctx
, 'expires', 'expires-seconds');
664 if (ctx
.session
.tokenLifespan
) {
665 ctx
.session
.refreshLifespan
= this._parseLifespan(ctx
, 'refresh', 'refresh-seconds');
669 if (ctx
.session
.error
) {
670 this.logger
.debug(_scope
, 'invalid request, redirecting', { ctx
});
672 // Set all errors as parameters for client to interpret upon redirection.
673 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
674 'state': ctx
.session
.state
,
675 'error': ctx
.session
.error
,
676 'error_description': ctx
.session
.errorDescriptions
.join(', '),
678 res
.statusCode
= 302; // Found
679 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
681 this.logger
.info(_scope
, 'bad request, redirected', { ctx
});
685 // Consented, off we go. Keep all this session state as the code.
686 const code
= await
this.mysteryBox
.pack({
687 codeId: ctx
.session
.id
,
688 codeChallengeMethod: ctx
.session
.codeChallengeMethod
,
689 codeChallenge: ctx
.session
.codeChallenge
,
690 clientId: ctx
.session
.clientId
.href
,
691 redirectUri: ctx
.session
.redirectUri
.href
,
692 acceptedScopes: ctx
.session
.acceptedScopes
,
693 tokenLifespan: ctx
.session
.tokenLifespan
,
694 refreshLifespan: ctx
.session
.refreshLifespan
,
695 me: ctx
.session
.me
.href
,
696 profile: ctx
.session
.profile
,
697 identifier: ctx
.session
.authenticatedIdentifier
, // need this to pair with profile
701 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
703 'state': ctx
.session
.state
,
704 'iss': this.options
.dingus
.selfBaseUrl
,
706 res
.statusCode
= 302;
707 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
710 this.logger
.info(_scope
, 'finished', { ctx
});
715 * Redeem a code for a profile url, and maybe more profile info.
716 * @param {http.ServerResponse} res
717 * @param {Object} ctx
719 async
postAuthorization(res
, ctx
) {
720 const _scope
= _fileScope('postAuthorization');
721 this.logger
.debug(_scope
, 'called', { ctx
});
723 await
this._ingestPostAuthorizationRequest(ctx
);
727 ...(ctx
.session
?.acceptedScopes
?.includes('profile') && { profile: ctx
.session
.profile
}),
728 scope: ctx
.session
.acceptedScopes
,
730 if (response
.profile
&& !ctx
.session
?.acceptedScopes
?.includes('email')) {
731 delete response
.profile
.email
;
734 if (!ctx
.session
.error
) {
735 await
this.db
.context(async (dbCtx
) => {
736 // Record code redemption without token.
737 const valid
= await
this.db
.redeemCode(dbCtx
, {
738 codeId: ctx
.session
.codeId
,
741 clientId: ctx
.session
.clientId
.href
,
742 profile: ctx
.session
.me
,
743 identifier: ctx
.session
.identifier
,
744 scopes: ctx
.session
.acceptedScopes
,
745 lifespanSeconds: Math
.ceil(this.options
.manager
.codeValidityTimeoutMs
/ 1000),
746 profileData: response
.profile
,
749 this.logger
.debug(_scope
, 'code already redeemed', { ctx
});
750 Manager
._setError(ctx
, 'access_denied', 'code already redeemed');
755 if (ctx
.session
.error
) {
756 res
.statusCode
= 400;
757 res
.end(JSON
.stringify({
758 'error': ctx
.session
.error
,
759 'error_description': ctx
.session
.errorDescriptions
.join(', '),
761 this.logger
.info(_scope
, 'invalid request', { ctx
});
765 res
.end(JSON
.stringify(response
));
767 this.logger
.info(_scope
, 'finished', { ctx
, response
});
772 * Ingest an incoming authorization redemption request, parsing fields
773 * onto a new session object on the context.
775 * @param {Object} ctx
777 async
_ingestPostAuthorizationRequest(ctx
) {
778 const _scope
= _fileScope('_ingestPostAuthorizationRequest');
780 ctx
.session
= Object
.assign({}, ctx
.session
, {
781 errorDescriptions: [],
784 if (!ctx
.parsedBody
) {
785 this.logger
.debug(_scope
, 'no body data', { ctx
});
786 Manager
._setError(ctx
, 'invalid_request', 'missing data');
789 await
this._restoreSessionFromCode(ctx
);
790 this._checkSessionMatchingClientId(ctx
);
791 this._checkSessionMatchingRedirectUri(ctx
);
792 this._checkGrantType(ctx
);
793 this._checkSessionMatchingCodeVerifier(ctx
);
795 if (!ctx
.session
.me
|| !ctx
.session
.minted
) {
796 this.logger
.debug(_scope
, 'session missing fields', { ctx
});
797 Manager
._setError(ctx
, 'invalid_request', 'malformed code');
801 const expires
= new Date(ctx
.session
.minted
+ this.options
.manager
.codeValidityTimeoutMs
);
802 const now
= new Date();
804 this.logger
.debug(_scope
, 'code expired', { ctx
});
805 Manager
._setError(ctx
, 'invalid_request', 'code has expired');
811 * Unpack the session data from provided code overtop of context session ..
812 * @param {Object} ctx
814 async
_restoreSessionFromCode(ctx
) {
815 const _scope
= _fileScope('_restoreSessionFromCode');
817 const code
= ctx
.parsedBody
['code'];
820 const oldSession
= await
this.mysteryBox
.unpack(code
);
822 // TODO: Validate unpacked fields better
823 const missingFields
= [
825 'codeChallengeMethod',
834 ].filter((requiredField
) => !(requiredField
in oldSession
));
835 if (missingFields
.length
) {
836 if (this.options
.manager
.allowLegacyNonPKCE
837 && missingFields
.length
=== 2
838 && missingFields
.includes('codeChallenge')
839 && missingFields
.includes('codeChallengeMethod')) {
840 this.logger
.debug(_scope
, 'allowing legacy non-PKCE session', { ctx
});
842 this.logger
.debug(_scope
, 'unpacked code is missing required field', { missingFields
, ctx
});
843 Manager
._setError(ctx
, 'invalid_request', 'code is not valid');
847 Object
.assign(ctx
.session
, oldSession
);
849 this.logger
.debug(_scope
, 'failed to parse code', { error: e
, ctx
});
850 Manager
._setError(ctx
, 'invalid_request', 'code is not valid');
853 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code\'');
859 * Ensure provided client_id matches session clientId.
860 * @param {Object} ctx
862 _checkSessionMatchingClientId(ctx
) {
863 const _scope
= _fileScope('_checkSessionMatchingClientId');
865 let clientId
= ctx
.parsedBody
['client_id'];
868 clientId
= new URL(clientId
);
869 ctx
.session
.clientId
= new URL(ctx
.session
.clientId
);
871 this.logger
.debug(_scope
, 'un-parsable client_id url', { ctx
});
872 delete ctx
.session
.clientId
;
873 Manager
._setError(ctx
, 'invalid_request', 'malformed client_id');
876 if (clientId
.href
!== ctx
.session
.clientId
.href
) {
877 this.logger
.debug(_scope
, 'clientId mismatched', { clientId
, ctx
});
878 delete ctx
.session
.clientId
;
879 Manager
._setError(ctx
, 'invalid_request', 'code does not belong to that client_id');
882 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'client_id\'');
888 * @param {Object} ctx
890 _checkSessionMatchingRedirectUri(ctx
) {
891 const _scope
= _fileScope('_checkSessionMatchingClientId');
893 let redirectUri
= ctx
.parsedBody
['redirect_uri'];
896 redirectUri
= new URL(redirectUri
);
897 ctx
.session
.redirectUri
= new URL(ctx
.session
.redirectUri
);
899 this.logger
.debug(_scope
, 'un-parsable redirect_uri url', { ctx
});
900 delete ctx
.session
.redirectUri
;
901 Manager
._setError(ctx
, 'invalid_request', 'malformed redirect_url');
904 if (redirectUri
.href
!== ctx
.session
.redirectUri
.href
) {
905 this.logger
.debug(_scope
, 'redirectUri mismatched', { redirectUri
, ctx
});
906 delete ctx
.session
.redirectUri
;
907 Manager
._setError(ctx
, 'invalid_request', 'code does not belong to that redirect_uri');
910 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'redirect_uri\'');
916 * Validate grant_type, either persist on session or set error.
917 * @param {Object} ctx
918 * @param {String[]} validGrantTypes
919 * @param {Boolean} treatEmptyAs
921 _checkGrantType(ctx
, validGrantTypes
= ['authorization_code'], treatEmptyAs
= 'authorization_code') {
922 const _scope
= _fileScope('_checkGrantType');
924 const grantType
= ctx
.parsedBody
['grant_type'] || treatEmptyAs
;
925 if (!ctx
.parsedBody
['grant_type'] && treatEmptyAs
) {
926 this.logger
.debug(_scope
, `missing grant_type, treating as ${treatEmptyAs}`, { ctx
});
928 if (validGrantTypes
.includes(grantType
)) {
929 ctx
.session
.grantType
= grantType
;
931 Manager
._setError(ctx
, 'invalid_request', 'grant_type not supported');
937 * @param {Object} ctx
939 _checkSessionMatchingCodeVerifier(ctx
) {
940 const _scope
= _fileScope('_checkSessionMatchingCodeVerifier');
942 const codeVerifier
= ctx
.parsedBody
['code_verifier'];
945 const valid
= Communication
.verifyChallenge(ctx
.session
.codeChallenge
, codeVerifier
, ctx
.session
.codeChallengeMethod
);
947 this.logger
.debug(_scope
, 'challenge mismatched', { ctx
});
948 Manager
._setError(ctx
, 'invalid_request', 'challenge verification failed');
950 } catch (e
) /* istanbul ignore next */ {
951 this.logger
.error(_scope
, 'challenge validation failed', { error: e
, ctx
});
952 Manager
._setError(ctx
, 'invalid_request', 'challenge verification failed');
955 if (this.options
.manager
.allowLegacyNonPKCE
956 && !ctx
.session
.codeChallenge
957 && !ctx
.session
.codeChallengeMethod
) {
958 this.logger
.debug(_scope
, 'allowing non-PKCE', { ctx
});
961 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_verifier\'');
967 * Attempt to revoke a token.
969 * @param {http.ServerResponse} res
970 * @param {Object} ctx
972 async
_revokeToken(dbCtx
, res
, ctx
) {
973 const _scope
= _fileScope('_revokeToken');
975 const token
= ctx
.parsedBody
['token'];
976 const tokenTypeHint
= ctx
.parsedBody
['token_type_hint'];
977 switch (tokenTypeHint
) {
982 case 'refresh_token':
985 this.logger
.debug(_scope
, 'unknown token_type_hint', { ctx
});
988 throw new ValidationError('Token Missing');
990 ctx
.token
= await
this.mysteryBox
.unpack(token
);
991 if (!(ctx
.token
?.c
|| ctx
.token
?.rc
)) {
992 throw new ValidationError('Token Invalid');
995 this.logger
.debug(_scope
, 'invalid token', { error: e
, ctx
});
996 res
.statusCode
= 400;
998 this.logger
.info(_scope
, 'finished, revoke request not valid', { error: e
, ctx
});
1004 await
this.db
.tokenRevokeByCodeId(dbCtx
, ctx
.token
.c
);
1006 await
this.db
.tokenRefreshRevokeByCodeId(dbCtx
, ctx
.token
.rc
);
1009 if (e
instanceof DBErrors
.UnexpectedResult
) {
1010 res
.statusCode
= 404;
1012 this.logger
.info(_scope
, 'finished, no token to revoke', { error: e
, ctx
});
1015 this.logger
.error(_scope
, 'revoke token failed', { error: e
, ctx
});
1020 this.logger
.info(_scope
, 'finished, token revoked', { ctx
});
1025 * Legacy token validation flow.
1027 * @param {http.ClientRequest} req
1028 * @param {http.ServerResponse} res
1029 * @param {Object} ctx
1031 async
_validateToken(dbCtx
, req
, res
, ctx
) {
1032 const _scope
= _fileScope('_validateToken');
1033 await
this._checkTokenValidationRequest(dbCtx
, req
, ctx
);
1034 if (ctx
.bearer
.isValid
) {
1035 Manager
._sensitiveResponse(res
);
1036 res
.end(JSON
.stringify({
1037 me: ctx
.token
.profile
,
1038 'client_id': ctx
.token
.clientId
,
1039 scope: ctx
.token
.scopes
,
1041 this.logger
.info(_scope
, 'finished, token validated', { ctx
});
1043 const responseErrorParts
= ['Bearer'];
1044 const error
= ctx
.session
.error
? `error="${ctx.session.error}"` : '';
1046 responseErrorParts
.push(error
);
1048 const errorDescription
= ctx
.session
.errorDescriptions
? `error_description="${ctx.session.errorDescriptions.join(', ')}"` : '';
1049 if (errorDescription
) {
1050 responseErrorParts
.push(errorDescription
);
1052 res
.setHeader(Enum
.Header
.WWWAuthenticate
, responseErrorParts
.join(', '));
1053 this.logger
.info(_scope
, 'finished, token not validated', { ctx
});
1054 throw new ResponseError(Enum
.ErrorResponse
.Unauthorized
);
1060 * Given a list of newly-requested scopes, return a list of scopes
1061 * from previousScopes which are not in requestedScopes.
1062 * @param {String[]} previousScopes
1063 * @param {String[]} requestedScopes
1064 * @returns {String[]}
1066 static _scopeDifference(previousScopes
, requestedScopes
) {
1067 const scopesToRemove
= [];
1068 const existingScopesSet
= new Set(previousScopes
);
1069 const validRequestedScopes
= requestedScopes
.filter((s
) => common
.validScope(s
));
1070 const requestedScopesSet
= new Set(validRequestedScopes
);
1071 existingScopesSet
.forEach((s
) => {
1072 if (!requestedScopesSet
.has(s
)) {
1073 scopesToRemove
.push(s
);
1076 return scopesToRemove
;
1081 * Redeem a refresh token for a new token.
1083 * @param {http.ClientRequest} req
1084 * @param {http.ServerResponse} res
1085 * @param {Object} ctx
1087 async
_refreshToken(dbCtx
, req
, res
, ctx
) {
1088 const _scope
= _fileScope('_refreshToken');
1089 this.logger
.debug(_scope
, 'called', { ctx
});
1092 'client_id': clientId
,
1097 ctx
.refreshToken
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['refresh_token']);
1099 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1102 const now
= new Date();
1103 const nowEpoch
= common
.dateToEpoch(now
);
1105 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1106 if (ctx
.refreshToken
?.rc
) {
1107 ctx
.token
= await
this.db
.tokenGetByCodeId(txCtx
, ctx
.refreshToken
.rc
);
1111 this.logger
.debug(_scope
, 'no token to refresh', { ctx
});
1112 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
1115 if (!ctx
.token
.refreshExpires
1116 || ctx
.token
.refreshExpires
< now
) {
1117 this.logger
.debug(_scope
, 'token not refreshable or refresh expired', { ctx
});
1118 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1121 const refreshExpiresEpoch
= common
.dateToEpoch(ctx
.token
.refreshExpires
);
1122 if (ctx
.refreshToken
.exp
< refreshExpiresEpoch
) {
1123 this.logger
.debug(_scope
, 'token already refreshed', { ctx
});
1124 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1127 if (clientId
!== ctx
.token
.clientId
) {
1128 this.logger
.debug(_scope
, 'client identifier mismatch', { ctx
});
1129 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1132 const scopesToRemove
= scope
? Manager
._scopeDifference(ctx
.token
.scopes
, scope
.split(scopeSplitRE
)) : [];
1133 if (scopesToRemove
.length
) {
1134 this.logger
.debug(_scope
, 'scope reduction requested', { ctx
, scopesToRemove
});
1137 const refreshedTokenData
= await
this.db
.refreshCode(txCtx
, ctx
.refreshToken
.rc
, now
, scopesToRemove
);
1138 if (refreshedTokenData
) {
1139 Object
.assign(ctx
.token
, refreshedTokenData
);
1141 this.logger
.debug(_scope
, 'could not refresh token', { ctx
});
1142 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
1146 const [token
, refreshToken
] = await Promise
.all([
1148 c: ctx
.token
.codeId
,
1152 rc: ctx
.token
.codeId
,
1154 exp: nowEpoch
+ ctx
.token
.refreshDuration
,
1156 ].map(this.mysteryBox
.pack
));
1159 'access_token': token
,
1160 'token_type': 'Bearer',
1161 ...(ctx
.token
.duration
&& { 'expires_in': nowEpoch
+ ctx
.token
.duration
}),
1162 ...(refreshToken
&& { 'refresh_token': refreshToken
}),
1163 scope: ctx
.token
.scopes
.join(' '),
1165 ...(ctx
.token
.scopes
.includes('profile') && { profile: ctx
.token
.profileData
}),
1167 if (ctx
.token
.scopes
.includes('profile') && !ctx
.token
.scopes
.includes('email')) {
1168 delete response
?.profile
?.email
;
1171 Manager
._sensitiveResponse(res
);
1172 res
.end(JSON
.stringify(response
));
1173 this.logger
.info(_scope
, 'finished', { ctx
});
1178 * Generate a new ticket for later redemption.
1179 * @param {Object} payload
1180 * @param {} payload.subject deliver ticket to this endpoint
1181 * @param {} payload.resource url the redeemed ticket is valid for accessing
1182 * @param {String[]} payload.scopes list of scopes assigned to ticket
1183 * @param {String} payload.identifier user generating ticket
1184 * @param {} payload.profile profile of user generating ticket
1185 * @param {Number} payload.ticketLifespanSeconds ticket redeemable for this long
1188 async
_mintTicket({ subject
, resource
, scopes
, identifier
, profile
, ticketLifespanSeconds
}) {
1189 const _scope
= _fileScope('_mintTicket');
1190 this.logger
.debug(_scope
, 'called', { subject
, resource
, scopes
, identifier
, profile
, ticketLifespanSeconds
});
1192 const nowEpoch
= common
.dateToEpoch();
1193 return this.mysteryBox
.pack({
1194 c: common
.requestId(),
1196 exp: nowEpoch
+ ticketLifespanSeconds
,
1208 * @property {String} codeId
1209 * @property {Date} issued
1210 * @property {Date} expires
1211 * @property {URL} subject
1212 * @property {URL} resource
1213 * @property {String[]} scopes
1214 * @property {String} identifier
1215 * @property {URL} profile
1219 * @param {String} ticket
1222 async
_unpackTicket(ticket
) {
1223 const ticketObj
= await
this.mysteryBox
.unpack(ticket
);
1225 codeId: ticketObj
.c
,
1226 issued: new Date(ticketObj
.iss
* 1000),
1227 expires: new Date(ticketObj
.exp
* 1000),
1228 subject: new URL(ticketObj
.sub
),
1229 resource: new URL(ticketObj
.res
),
1230 scopes: ticketObj
.scope
,
1231 identifier: ticketObj
.ident
,
1232 profile: new URL(ticketObj
.profile
),
1238 * Redeem a ticket for a token.
1240 * @param {http.ClientRequest} req
1241 * @param {http.ServerResponse} res
1242 * @param {Object} ctx
1244 async
_ticketAuthToken(dbCtx
, req
, res
, ctx
) {
1245 const _scope
= _fileScope('_ticketAuthToken');
1246 this.logger
.debug(_scope
, 'called', { ctx
});
1249 ctx
.ticket
= await
this._unpackTicket(ctx
.parsedBody
['ticket']);
1251 this.logger
.debug(_scope
, 'failed to unpack ticket', { error: e
, ctx
});
1252 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1255 const now
= new Date();
1256 if (now
> ctx
.ticket
.expires
) {
1257 this.logger
.debug(_scope
, 'ticket has expired', { ctx
});
1258 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
, { reason: 'Ticket has expired.', expired: ctx
.ticket
.expires
});
1261 const nowEpoch
= common
.dateToEpoch(now
);
1262 const token
= await
this.mysteryBox
.pack({
1263 c: ctx
.ticket
.codeId
,
1268 'access_token': token
,
1269 'token_type': 'Bearer',
1270 scope: ctx
.ticket
.scopes
.join(' '),
1271 me: ctx
.ticket
.profile
.href
,
1274 const isValid
= await
this.db
.redeemCode(dbCtx
, {
1276 codeId: ctx
.ticket
.codeId
,
1278 clientId: ctx
.ticket
.subject
.href
,
1279 resource: ctx
.ticket
.resource
.href
,
1280 profile: ctx
.ticket
.profile
.href
,
1281 identifier: ctx
.ticket
.identifier
,
1282 scopes: ctx
.ticket
.scopes
,
1285 this.logger
.debug(_scope
, 'redemption failed, already redeemed', { ctx
});
1286 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
);
1289 Manager
._sensitiveResponse(res
);
1290 res
.end(JSON
.stringify(response
));
1291 this.logger
.info(_scope
, 'finished', { ctx
});
1296 * Redeem a code for a token.
1298 * @param {http.ClientRequest} req
1299 * @param {http.ServerResponse} res
1300 * @param {Object} ctx
1302 async
_codeToken(dbCtx
, req
, res
, ctx
) {
1303 const _scope
= _fileScope('_codeToken');
1304 this.logger
.debug(_scope
, 'called', { ctx
});
1306 await
this._restoreSessionFromCode(ctx
);
1307 await
this._checkSessionMatchingRedirectUri(ctx
);
1309 if (ctx
.session
.error
) {
1310 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1314 * Note the creation date here rather than in database, so that stored
1315 * expire dates are ensured to match those packed in tokens.
1316 * An alternative would be to return the date generated by the database,
1317 * but then we would need to hold the transaction open while minting the
1318 * tokens to ensure success. Perhaps that would be worth it, but for now
1319 * this is how it is.
1321 const now
= new Date();
1322 const nowEpoch
= common
.dateToEpoch(now
);
1323 const tokenMinters
= [];
1325 tokenMinters
.push(this.mysteryBox
.pack({
1326 c: ctx
.session
.codeId
,
1328 ...(ctx
.session
.tokenLifespan
&& { exp: nowEpoch
+ ctx
.session
.tokenLifespan
}),
1331 if (ctx
.session
.tokenLifespan
1332 && ctx
.session
.refreshLifespan
) {
1333 tokenMinters
.push(this.mysteryBox
.pack({
1334 rc: ctx
.session
.codeId
,
1336 exp: nowEpoch
+ ctx
.session
.refreshLifespan
,
1340 const [token
, refreshToken
] = await Promise
.all(tokenMinters
);
1343 'access_token': token
,
1344 'token_type': 'Bearer',
1345 ...(ctx
.session
.tokenLifespan
&& { 'expires_in': nowEpoch
+ ctx
.session
.tokenLifespan
}),
1346 ...(refreshToken
&& { 'refresh_token': refreshToken
}),
1347 scope: ctx
.session
.acceptedScopes
.join(' '),
1349 ...(ctx
.session
.acceptedScopes
.includes('profile') && { profile: ctx
.session
.profile
}),
1351 if (!ctx
.session
.acceptedScopes
.includes('email') && response
.profile
) {
1352 delete response
.profile
.email
;
1355 const isValid
= await
this.db
.redeemCode(dbCtx
, {
1357 codeId: ctx
.session
.codeId
,
1359 clientId: ctx
.session
.clientId
,
1360 profile: ctx
.session
.me
,
1361 identifier: ctx
.session
.identifier
,
1362 scopes: ctx
.session
.acceptedScopes
,
1363 lifespanSeconds: ctx
.session
.tokenLifespan
,
1364 refreshLifespanSeconds: ctx
.session
.refreshLifespan
,
1365 profileData: response
.profile
,
1368 this.logger
.debug(_scope
, 'redemption failed, already redeemed', { ctx
});
1369 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
);
1372 Manager
._sensitiveResponse(res
);
1373 res
.end(JSON
.stringify(response
));
1374 this.logger
.info(_scope
, 'finished', { ctx
});
1379 * Issue, refresh, or validate a token.
1380 * @param {http.ClientRequest} req
1381 * @param {http.ServerResponse} res
1382 * @param {Object} ctx
1384 async
postToken(req
, res
, ctx
) {
1385 const _scope
= _fileScope('postToken');
1386 this.logger
.debug(_scope
, 'called', { ctx
});
1388 ctx
.session
= Object
.assign({}, ctx
.session
);
1390 await
this.db
.context(async (dbCtx
) => {
1392 // Is this a (legacy) revocation request?
1393 if (ctx
.parsedBody
['action'] === 'revoke') {
1394 await
this._revokeToken(dbCtx
, res
, ctx
);
1398 // Is this a (legacy) token validation request?
1399 if (req
.getHeader(Enum
.Header
.Authorization
)) {
1400 await
this._validateToken(dbCtx
, res
, ctx
);
1404 const validGrantTypes
= [
1405 'authorization_code',
1407 ...(this.queuePublisher
&& ['ticket'] || []),
1409 this._checkGrantType(ctx
, validGrantTypes
, 'authorization_code');
1411 switch (ctx
.session
.grantType
) {
1412 case 'refresh_token':
1413 return this._refreshToken(dbCtx
, req
, res
, ctx
);
1416 return this._ticketAuthToken(dbCtx
, req
, res
, ctx
);
1418 case 'authorization_code':
1419 return this._codeToken(dbCtx
, req
, res
, ctx
);
1422 this.logger
.debug(_scope
, 'unknown grant_type', { ctx
});
1423 Manager
._setError(ctx
, 'invalid_request', 'grant_type not supported');
1426 // Only way of getting here is due to error.
1427 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1433 * Ingest token from authorization header, setting ctx.bearer.isValid appropriately.
1434 * ctx.bearer not set if auth method not recognized.
1435 * This is for legacy validation on token endpoint.
1437 * @param {http.ClientRequest} req
1438 * @param {Object} ctx
1440 async
_checkTokenValidationRequest(dbCtx
, req
, ctx
) {
1441 const _scope
= _fileScope('_checkTokenValidationRequest');
1442 const authHeader
= req
.getHeader(Enum
.Header
.Authorization
);
1445 const [authMethod
, authString
] = common
.splitFirst(authHeader
, ' ', '');
1446 switch (authMethod
.toLowerCase()) { // eslint-disable-line sonarjs/no-small-switch
1452 Object
.assign(ctx
.bearer
, await
this.mysteryBox
.unpack(authString
));
1454 this.logger
.debug(_scope
, 'failed to unpack token', { ctx
});
1455 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1458 if (!ctx
.bearer
.c
) {
1459 this.logger
.debug(_scope
, 'incomplete token', { ctx
});
1460 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1465 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.bearer
.c
);
1467 this.logger
.error(_scope
, 'failed to look up token', { error: e
, ctx
});
1472 this.logger
.debug(_scope
, 'no token found', { ctx
});
1473 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1477 if (!ctx
.token
.isRevoked
1478 && ctx
.token
.expires
> new Date()) {
1479 ctx
.bearer
.isValid
= true;
1485 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
1493 * Accept an unsolicited ticket proffering.
1494 * @param {http.ClientRequest} req
1495 * @param {http.ServerResponse} res
1496 * @param {Object} ctx
1498 async
postTicket(req
, res
, ctx
) {
1499 const _scope
= _fileScope('postTicket');
1500 this.logger
.debug(_scope
, 'called', { ctx
});
1502 if (!this.queuePublisher
) {
1503 this.logger
.debug(_scope
, 'ticket endpoint not configured', { ctx
});
1504 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1507 const queueName
= this.options
.queues
.ticketPublishName
;
1508 const { ticket
, resource
, subject
} = ctx
.parsedBody
;
1513 this.logger
.debug(_scope
, 'unparsable resource', { ticket
, resource
, subject
, ctx
});
1514 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1517 await
this.db
.context(async (dbCtx
) => {
1518 const isValidProfile
= await
this.db
.profileIsValid(dbCtx
, subject
);
1519 if (!isValidProfile
) {
1520 this.logger
.debug(_scope
, 'invalid subject', { ticket
, resource
, subject
, ctx
});
1521 throw new ResponseError(Enum
.ErrorResponse
.NotFound
, { error: 'subject not under our purview' });
1525 const result
= await
this.queuePublisher
.publish(queueName
, { ticket
, resource
, subject
});
1526 this.logger
.debug(_scope
, 'accepted ticket offer', { queueName
, ticket
, resource
, subject
, ctx
, result
});
1528 this.logger
.error(_scope
, 'failed to publish ticket to queue', { error: e
, queueName
, ticket
, resource
, subject
, ctx
});
1529 throw e
; // return a 500
1532 res
.statusCode
= 202;
1534 this.logger
.info(_scope
, 'finished', { resource
, subject
, ctx
});
1540 * Validate a token and return data about it.
1541 * @param {http.ServerResponse} res
1542 * @param {Object} ctx
1544 async
postIntrospection(res
, ctx
) {
1545 const _scope
= _fileScope('postIntrospection');
1546 this.logger
.debug(_scope
, 'called', { ctx
});
1552 const tokenIsTicket
= (ctx
.parsedBody
['token_hint_type'] || '').toLowerCase() === 'ticket';
1555 const token
= ctx
.parsedBody
['token'];
1556 if (tokenIsTicket
) {
1557 ctx
.token
= await
this._unpackTicket(token
);
1559 ctx
.token
= await
this.mysteryBox
.unpack(token
);
1562 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1566 && !tokenIsTicket
) {
1567 await
this.db
.context(async (dbCtx
) => {
1568 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.token
.c
);
1573 && !ctx
.token
.isRevoked
) {
1574 // fuss around for postgres 'Infinity' date
1575 const expiresMs
= (ctx
.token
.expires
instanceof Date
) ? ctx
.token
.expires
.getTime() : ctx
.token
.expires
;
1576 if (expiresMs
> Date
.now()) {
1579 me: ctx
.token
.profile
,
1580 ...(ctx
.token
.clientId
&& { 'client_id': ctx
.token
.clientId
}),
1581 scope: ctx
.token
.scopes
.join(' '),
1582 iat: common
.dateToEpoch(ctx
.token
.created
|| ctx
.token
.issued
),
1583 ...(isFinite(expiresMs
) && { exp: Math
.ceil(expiresMs
/ 1000) }),
1584 ...(tokenIsTicket
&& { 'token_type': 'ticket' }),
1589 Manager
._sensitiveResponse(res
);
1590 res
.end(JSON
.stringify(response
));
1591 this.logger
.info(_scope
, 'finished', { ctx
});
1596 * Revoke a token or refresh token.
1597 * @param {http.ServerResponse} res
1598 * @param {Object} ctx
1600 async
postRevocation(res
, ctx
) {
1601 const _scope
= _fileScope('postRevocation');
1602 this.logger
.debug(_scope
, 'called', { ctx
});
1605 await
this.db
.context(async (dbCtx
) => {
1606 await
this._revokeToken(dbCtx
, res
, ctx
);
1609 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
1613 this.logger
.info(_scope
, 'finished', { ctx
});
1618 * Profile information for a token.
1619 * @param {http.ServerResponse} res
1620 * @param {Object} ctx
1622 async
postUserInfo(res
, ctx
) {
1623 const _scope
= _fileScope('postUserInfo');
1624 this.logger
.debug(_scope
, 'called', { ctx
});
1626 const token
= ctx
.parsedBody
['token'];
1628 res
.statusCode
= 400;
1629 res
.end('"invalid_request"');
1630 this.logger
.info(_scope
, 'finished, invalid request', { ctx
});
1635 ctx
.token
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['token']);
1637 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1641 await
this.db
.context(async (dbCtx
) => {
1642 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.token
.c
);
1647 || ctx
.token
.isRevoked
1648 // || tokenIsExpired(token)
1650 res
.statusCode
= 401;
1651 res
.end('"invalid_token"');
1652 this.logger
.info(_scope
, 'finished, invalid token', { ctx
});
1656 if (!ctx
.token
.scopes
.includes('profile')) {
1657 res
.statusCode
= 403;
1658 res
.end('"insufficient_scope"');
1659 this.logger
.info(_scope
, 'finished, insufficient scope', { ctx
});
1664 ...ctx
.token
.profile
,
1666 if (!ctx
.token
.scopes
.includes('email')) {
1667 delete response
.email
;
1670 Manager
._sensitiveResponse(res
);
1671 res
.end(JSON
.stringify(response
));
1673 this.logger
.info(_scope
, 'finished', { ctx
});
1678 * Show admin interface, allowing manipulation of profiles and scopes.
1679 * @param {http.ServerResponse} res
1680 * @param {Object} ctx
1682 async
getAdmin(res
, ctx
) {
1683 const _scope
= _fileScope('getAdmin');
1684 this.logger
.debug(_scope
, 'called', { ctx
});
1686 const identifier
= ctx
.session
.authenticatedIdentifier
;
1688 await
this.db
.context(async (dbCtx
) => {
1689 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1690 ctx
.tokens
= await
this.db
.tokensGetByIdentifier(dbCtx
, identifier
);
1693 res
.end(Template
.adminHTML(ctx
, this.options
));
1695 this.logger
.info(_scope
, 'finished', { ctx
});
1700 * Process admin interface events.
1701 * @param {http.ServerResponse} res
1702 * @param {Object} ctx
1704 async
postAdmin(res
, ctx
) {
1705 const _scope
= _fileScope('postAdmin');
1706 this.logger
.debug(_scope
, 'called', { ctx
});
1708 await
this.db
.context(async (dbCtx
) => {
1709 const identifier
= ctx
.session
.authenticatedIdentifier
;
1710 const action
= ctx
?.parsedBody
?.['action'] || '';
1712 if (action
=== 'save-scopes') {
1713 // Update the convenience scopes set for profiles.
1714 // Expect 'scopes-<profile>' with value of array of scopes
1715 const profileKeys
= ctx
.parsedBody
&& Object
.keys(ctx
.parsedBody
)
1716 .filter((k
) => k
.startsWith('scopes-'));
1718 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1720 /* For each scopes-profile submitted, set those. */
1721 profileKeys
.map((profileKey
) => {
1722 /* elide 'scope-' prefix to get the profile */
1723 const profile
= profileKey
.slice(7);
1724 /* (should validate profile here) */
1726 /* remove invalid scopes from submitted list */
1727 const scopes
= ctx
.parsedBody
[profileKey
].filter((scope
) => scope
&& common
.validScope(scope
)); // eslint-disable-line security/detect-object-injection
1728 return this.db
.profileScopesSetAll(txCtx
, profile
, scopes
);
1732 ctx
.notifications
.push('Profile/Scope Availability Matrix updated!');
1734 this.logger
.error(_scope
, 'did not set profile scopes', { error: e
, ctx
});
1735 ctx
.errors
.push('Failed to update profile scopes.');
1738 } else if (action
=== 'new-profile') {
1739 // Validate and create a new profile uri.
1741 const profileUri
= ctx
.parsedBody
['profile'];
1743 profile
= await
this.communication
.validateProfile(profileUri
);
1745 this.logger
.debug(_scope
, 'invalid profile url', { error: e
, ctx
});
1746 ctx
.errors
.push(`'${profileUri}' is not a valid profile URI.${(e instanceof CommunicationErrors.ValidationError) ? ('(' + e.message + ')') : ''}`);
1749 // Validate profile uri
1750 const profileData
= await
this.communication
.fetchProfile(profile
);
1751 if (profileData
.metadata
.authorizationEndpoint
!== this.selfAuthorizationEndpoint
) {
1752 this.logger
.debug(_scope
, 'profile does not list this server as auth', { profileData
, ctx
});
1753 ctx
.errors
.push('Requested profile does not list this service, not adding.');
1756 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1757 await
this.db
.profileIdentifierInsert(txCtx
, profile
.href
, identifier
);
1758 await
this.db
.profileScopesSetAll(txCtx
, profile
.href
, ['profile', 'email']);
1760 ctx
.notifications
.push('Profile added!');
1762 this.logger
.error(_scope
, 'did not insert profile', { error: e
, ctx
});
1763 ctx
.errors
.push('Failed to add profile.');
1768 } else if (action
=== 'new-scope') {
1769 // Add or update a manually-added convenience scope.
1770 const { scope
, application
= '', description
= '' } = ctx
.parsedBody
;
1772 if (!common
.validScope(scope
)) {
1773 ctx
.errors
.push(`"${scope}" is not a valid scope name, did not add it.`);
1776 await
this.db
.scopeUpsert(dbCtx
, scope
, application
, description
, true);
1777 ctx
.notifications
.push('Scope List updated!');
1779 this.logger
.error(_scope
, 'did not upsert scope', { error: e
, scope
, application
, description
, ctx
});
1780 ctx
.errors
.push('Failed to update scope.');
1785 } else if (action
.startsWith('delete-scope-')) {
1786 // Remove a manually-added convenience scope.
1787 const scope
= decodeURIComponent(action
.slice(13));
1790 const deleted
= await
this.db
.scopeDelete(dbCtx
, scope
);
1792 ctx
.notifications
.push('Scope deleted.');
1794 ctx
.notifications
.push('Unable to delete scope.');
1797 this.logger
.error(_scope
, 'did not delete scope', { error: e
, scope
, ctx
});
1798 ctx
.errors
.push('Failed to delete scope.');
1802 } else if (action
.startsWith('revoke-')) {
1803 // Revoke an active token.
1804 const codeId
= action
.slice(8);
1807 await
this.db
.tokenRevokeByCodeId(dbCtx
, codeId
, identifier
);
1808 ctx
.notifications
.push('Revoked token!');
1810 this.logger
.error(_scope
, 'did not revoke token', { error: e
, codeId
, identifier
, ctx
});
1811 ctx
.errors
.push('Unable to revoke token.');
1815 } else if (action
) {
1816 ctx
.errors
.push(`Do not know how to '${action}'.`);
1819 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1820 ctx
.tokens
= await
this.db
.tokensGetByIdentifier(dbCtx
, identifier
);
1823 res
.end(Template
.adminHTML(ctx
, this.options
));
1825 this.logger
.info(_scope
, 'finished', { ctx
});
1830 * Show ticket proffer interface.
1831 * @param {http.ServerResponse} res
1832 * @param {Object} ctx
1834 async
getAdminTicket(res
, ctx
) {
1835 const _scope
= _fileScope('getAdminTicket');
1836 this.logger
.debug(_scope
, 'called', { ctx
});
1838 const identifier
= ctx
.session
.authenticatedIdentifier
;
1840 await
this.db
.context(async (dbCtx
) => {
1841 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1842 ctx
.profiles
= ctx
.profilesScopes
.profiles
;
1843 ctx
.scopes
= Object
.keys(ctx
.profilesScopes
.scopeIndex
);
1846 res
.end(Template
.adminTicketHTML(ctx
, this.options
));
1848 this.logger
.info(_scope
, 'finished', { ctx
});
1853 * Handle ticket proffer interface submission.
1854 * @param {http.ServerResponse} res
1855 * @param {Object} ctx
1857 async
postAdminTicket(res
, ctx
) {
1858 const _scope
= _fileScope('postAdminTicket');
1859 this.logger
.debug(_scope
, 'called', { ctx
});
1861 switch (ctx
.parsedBody
['action']) { // eslint-disable-line sonarjs/no-small-switch
1862 case 'proffer-ticket': {
1863 const identifier
= ctx
.session
.authenticatedIdentifier
;
1865 { ctxProp: 'ticketProfileUrl', bodyParam: 'profile', err: 'Invalid Profile URL selected.' },
1866 { ctxProp: 'ticketResourceUrl', bodyParam: 'resource', err: 'Invalid Resource URL.' },
1867 { ctxProp: 'ticketSubjectUrl', bodyParam: 'subject', err: 'Invalid Recipient URL.' },
1868 ].forEach((param
) => {
1870 ctx
[param
.ctxProp
] = new URL(ctx
.parsedBody
[param
.bodyParam
]);
1872 this.logger
.debug(_scope
, `invalid ${param.bodyParam}`, { ctx
});
1873 ctx
.errors
.push(param
.err
);
1877 const subjectData
= await
this.communication
.fetchProfile(ctx
.ticketSubjectUrl
);
1878 if (!subjectData
?.metadata
?.ticketEndpoint
) {
1879 this.logger
.debug(_scope
, 'subject has no ticket endpoint', { ctx
});
1880 ctx
.errors
.push('Recipient does not list a ticket endpoint to deliver to.');
1883 ctx
.ticketEndpointUrl
= new URL(subjectData
.metadata
.ticketEndpoint
);
1885 this.logger
.debug(_scope
, 'subject has invalid ticket endpoint', { error: e
, ctx
});
1886 ctx
.errors
.push(`Recipient lists an invalid ticket endpoint, cannot deliver. (${e})`);
1890 const scopesSet
= new Set();
1892 ...(common
.ensureArray(ctx
.parsedBody
['scopes'])),
1893 ...((ctx
.parsedBody
['adhoc'] || '').split(scopeSplitRE
)),
1894 ].filter((scope
) => scope
);
1895 rawScopes
.forEach((scope
) => {
1896 if (common
.validScope(scope
)) {
1897 scopesSet
.add(scope
);
1899 this.logger
.debug(_scope
, 'invalid adhoc scope', { scope
, ctx
});
1900 ctx
.errors
.push(`'${scope}' is not a valid scope.`);
1903 ctx
.ticketScopes
= [...scopesSet
];
1904 const actionScopes
= ctx
.ticketScopes
.filter((scope
) => !['profile', 'email'].includes(scope
));
1905 if (!actionScopes
.length
) {
1906 this.logger
.debug(_scope
, 'no valid scopes included', { ctx
});
1907 ctx
.errors
.push('At least one actionable scope must be included.');
1910 if (!ctx
.errors
.length
) {
1911 const ticketData
= {
1912 subject: ctx
.ticketSubjectUrl
.href
,
1913 resource: ctx
.ticketResourceUrl
.href
,
1914 scopes: ctx
.ticketScopes
,
1916 profile: ctx
.ticketProfileUrl
.href
,
1917 ticketLifespanSeconds: this.options
.manager
.ticketLifespanSeconds
,
1919 const ticket
= await
this._mintTicket(ticketData
);
1921 await
this.db
.context(async (dbCtx
) => {
1922 // re-populate form fields
1923 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1925 // TODO: queue ticket for delivery/retry to subject instead of trying immediately
1926 // ctx.notifications.push('Success! Ticket will be delivered!');
1928 this.logger
.debug(_scope
, 'ticket created', { ctx
, ticketData
, subjectData
});
1931 const result
= await
this.communication
.deliverTicket(ctx
.ticketEndpointUrl
, ctx
.ticketResourceUrl
, ctx
.ticketSubjectUrl
, ticket
);
1932 ctx
.notifications
.push(`Success! Ticket was delivered. (${result?.statusText})`);
1933 this.logger
.info(_scope
, 'ticket delivered', { ctx
, result
});
1935 this.logger
.error(_scope
, 'failed to deliver ticket', { ctx
, error: e
});
1936 ctx
.errors
.push(`Failed to deliver ticket. (${e})`);
1942 // populate form fields again
1943 await
this.db
.context(async (dbCtx
) => {
1944 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1945 ctx
.scopes
= Object
.keys(ctx
.profilesScopes
.scopeIndex
);
1953 this.logger
.debug(_scope
, 'unknown action', { ctx
});
1956 res
.end(Template
.adminTicketHTML(ctx
, this.options
));
1958 this.logger
.info(_scope
, 'finished', { ctx
});
1963 * Report on generally uninteresting backend information.
1964 * Also allow a few event invocations.
1965 * @param {http.ServerResponse} res
1966 * @param {Object} ctx
1968 async
getAdminMaintenance(res
, ctx
) {
1969 const _scope
= _fileScope('getAdminMaintenance');
1970 this.logger
.debug(_scope
, 'called', { ctx
});
1972 const maintenanceTasks
= [];
1974 await
this.db
.context(async (dbCtx
) => {
1976 Object
.values(Enum
.Chore
).forEach((chore
) => {
1977 if (chore
in ctx
.queryParams
) {
1978 maintenanceTasks
.push(
1979 this.chores
.runChore(chore
, 0), // Provide arg to force chore run.
1981 ctx
.notifications
.push(`Running maintenance chore "${chore}".`);
1985 await Promise
.all(maintenanceTasks
);
1987 ctx
.almanac
= await
this.db
.almanacGetAll(dbCtx
);
1990 const winnowChoreEntry
= ([name
, value
]) => [name
, common
.pick(value
, ['intervalMs', 'nextSchedule'])];
1991 ctx
.chores
= Object
.fromEntries(
1992 Object
.entries(this.chores
.chores
).map(winnowChoreEntry
),
1995 res
.end(Template
.adminMaintenanceHTML(ctx
, this.options
));
1997 this.logger
.info(_scope
, 'finished', { ctx
});
2003 * @param {http.ServerResponse} res
2004 * @param {Object} ctx
2006 async
getHealthcheck(res
, ctx
) {
2007 const _scope
= _fileScope('getHealthcheck');
2008 this.logger
.debug(_scope
, 'called', { ctx
});
2009 await
this.db
.healthCheck();
2015 module
.exports
= Manager
;