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(options
);
32 this.mysteryBox
.on('statistics', common
.mysteryBoxLogger(logger
, _fileScope(this.constructor.name
)));
34 // We need to know how the outside world sees us, to verify if a
35 // profile indicates us as the auth server.
36 // selfBaseUrl should already include proxy prefix and end with a /
37 this.selfAuthorizationEndpoint
= options
.dingus
.selfBaseUrl
+ options
.route
.authorization
;
42 * Perform any async startup tasks.
45 if (this.queuePublisher
) {
46 await
this._connectQueues();
51 async
_connectQueues() {
52 await
this.queuePublisher
.connect();
53 await
this.queuePublisher
.establishAMQPPlumbing(this.options
.queues
.ticketPublishName
);
58 * Add an error to a session, keeping only the most-severe code, but all descriptions.
59 * This error is sent along on the redirection back to client endpoint.
61 * @param {Object} ctx.session
62 * @param {String[]=} ctx.session.errorDescriptions
63 * @param {String=} ctx.session.error
64 * @param {String} error
65 * @param {String} errorDescription
67 static _setError(ctx
, error
, errorDescription
) {
68 const errorPrecedence
= [ // By increasing severity
70 'unsupported_response_type',
72 'unauthorized_client',
75 'temporarily_unavailable',
78 if (!(errorPrecedence
.includes(error
))) {
79 throw new RangeError(`invalid error value '${error}'`);
81 if (!ctx
.session
.errorDescriptions
) {
82 ctx
.session
.errorDescriptions
= [];
84 if (!common
.validError(errorDescription
)) {
85 throw new RangeError(`invalid error description '${errorDescription}'`);
87 const isHigherPrecedence
= errorPrecedence
.indexOf(error
) > errorPrecedence
.indexOf(ctx
.session
.error
);
88 if (!ctx
.session
.error
|| isHigherPrecedence
) {
89 ctx
.session
.error
= error
;
91 if (isHigherPrecedence
) {
92 ctx
.session
.errorDescriptions
.unshift(errorDescription
);
94 ctx
.session
.errorDescriptions
.push(errorDescription
);
100 * Discourage caching of a response.
102 * The authorization server MUST include the HTTP Cache-Control response
103 * header field with a value of no-store in any response
104 * containing tokens, credentials, or other sensitive information.
105 * @param {http.ServerResponse} res
107 static _sensitiveResponse(res
) {
109 [Enum
.Header
.CacheControl
]: 'no-store',
110 [Enum
.Header
.Pragma
]: 'no-cache',
111 }).forEach(([k
, v
]) => res
.setHeader(k
, v
));
116 * Sets params entries as url search parameters.
118 * @param {Object} params
120 static _setSearchParams(url
, params
) {
121 Object
.entries(params
).forEach((param
) => url
.searchParams
.set(...param
));
126 * Serve the informational root page.
127 * @param {http.ClientRequest} req
128 * @param {http.ServerResponse} res
129 * @param {Object} ctx
131 async
getRoot(res
, ctx
) {
132 const _scope
= _fileScope('getRoot');
133 this.logger
.debug(_scope
, 'called', { ctx
});
135 res
.end(Template
.rootHTML(ctx
, this.options
));
136 this.logger
.info(_scope
, 'finished', { ctx
});
141 * Serve the metadata for this service.
142 * @param {http.ServerResponse} res
143 * @param {Object} ctx
145 async
getMeta(res
, ctx
) {
146 const _scope
= _fileScope('getMeta');
147 this.logger
.debug(_scope
, 'called', { ctx
});
149 const base
= this.options
.dingus
.selfBaseUrl
;
150 const endpoint
= (r
) => `${base}${this.options.route[r]}`; // eslint-disable-line security/detect-object-injection
154 'authorization_endpoint': endpoint('authorization'),
155 'token_endpoint': endpoint('token'),
156 ...(this.queuePublisher
&& { 'ticket_endpoint': endpoint('ticket') }),
157 'introspection_endpoint': endpoint('introspection'),
158 'introspection_endpoint_auth_methods_supported': ['Bearer'],
159 'revocation_endpoint': endpoint('revocation'),
160 'revocation_endpoint_auth_methods_supported': ['none'],
161 'scopes_supported': ['profile', 'email'], // only advertise minimum IA scopes
162 'response_types_supported': 'code',
163 'grant_types_supported': [
164 'authorization_code',
166 ...(this.queuePublisher
&& ['ticket'] || []),
168 'service_documentation': 'https://indieauth.spec.indieweb.org/',
169 'code_challenge_methods_supported': supportedCodeChallengeMethods
,
170 'authorization_response_iss_parameter_supported': true,
171 'userinfo_endpoint': endpoint('userinfo'),
174 res
.end(JSON
.stringify(metadata
));
175 this.logger
.info(_scope
, 'finished', { ctx
});
180 * Process an authorization request from a client.
181 * User has authenticated, check if user matches profile,
182 * present user with consent form.
183 * @param {http.ServerResponse} res
184 * @param {Object} ctx
186 async
getAuthorization(res
, ctx
) {
187 const _scope
= _fileScope('getAuthorization');
188 this.logger
.debug(_scope
, 'called', { ctx
});
190 Manager
._sensitiveResponse(res
);
192 ctx
.session
= Object
.assign({}, ctx
.session
, {
193 errorDescriptions: [],
196 // Ingest and validate expected data, populating ctx.session.
197 await
this._clientIdRequired(ctx
);
198 Manager
._redirectURIRequired(ctx
);
199 Manager
._responseTypeRequired(ctx
);
200 Manager
._stateRequired(ctx
);
201 this._codeChallengeMethodRequired(ctx
);
202 this._codeChallengeRequired(ctx
);
203 this._scopeOptional(ctx
);
204 await
this._meOptional(ctx
);
206 if (!ctx
.session
.clientIdentifier
|| !ctx
.session
.redirectUri
) {
207 // Do not redirect if either of these fields were invalid, just report error.
208 this.logger
.debug(_scope
, 'invalid request, not redirecting', { ctx
});
210 // Set error response for template to render.
211 ctx
.errors
.push('Cannot redirect to client application.');
213 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
215 res
.statusCode
= 400;
216 res
.end(Template
.authorizationErrorHTML(ctx
, this.options
));
217 this.logger
.info(_scope
, 'bad request', { ctx
});
221 await
this.db
.context(async (dbCtx
) => {
222 const profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, ctx
.authenticationId
);
223 Object
.assign(ctx
.session
, {
230 if (!ctx
.session
.profiles
.length
) {
231 this.logger
.error(_scope
, 'identifier has no profiles', { ctx
});
232 Manager
._setError(ctx
, 'access_denied', 'Profile not valid for the authenticated user.');
235 if (!this._profileValidForIdentifier(ctx
)) {
236 // if the hinted profile supplied in me does not match any known
237 // profile mappings for the authenticated identifier, remove the
238 // hint. UI will prompt to choose from available profiles.
239 this.logger
.debug(_scope
, 'removing provided me hint, not valid for identifier', { ctx
});
240 delete ctx
.session
.me
;
243 // Ugly support logic for allowing legacy non-pkce requests, for the micropub.rocks site until it is updated.
244 // Require both be missing to qualify as a legacy request, otherwise still fail.
245 const isMissingBothPKCE
= (!ctx
.session
.codeChallengeMethod
) && (!ctx
.session
.codeChallenge
);
246 if (isMissingBothPKCE
&& this.options
.manager
.allowLegacyNonPKCE
) {
247 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>');
249 if (!ctx
.session
.codeChallenge
) {
250 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge\'');
252 if (!ctx
.session
.codeChallengeMethod
) {
253 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
257 // If anything went wrong, redirect with error report.
258 if (ctx
.session
.error
) {
259 // Valid redirect_url and client_id, errors hop back to them.
260 this.logger
.debug(_scope
, 'invalid request, redirecting', { ctx
});
262 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
263 'state': ctx
.session
.state
,
264 'error': ctx
.session
.error
,
265 'error_description': ctx
.session
.errorDescriptions
.join(', '),
267 res
.statusCode
= 302; // Found
268 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
270 this.logger
.info(_scope
, 'bad request', { ctx
});
274 // Store the current state of this session, to be forwarded on to consent processing.
275 // This blob will be passed on as a form field in consent response.
276 ctx
.session
.persist
= await
this.mysteryBox
.pack({
277 id: common
.requestId(), // codeId in database
278 clientId: ctx
.session
.clientId
.href
,
279 clientIdentifier: ctx
.session
.clientIdentifier
,
280 redirectUri: ctx
.session
.redirectUri
.href
,
281 responseType: ctx
.session
.responseType
,
282 state: ctx
.session
.state
,
283 codeChallengeMethod: ctx
.session
.codeChallengeMethod
,
284 codeChallenge: ctx
.session
.codeChallenge
,
286 profiles: ctx
.session
.profiles
,
287 requestedScopes: ctx
.session
.scope
,
288 authenticationId: ctx
.authenticationId
,
291 // Present authenticated user the option to submit consent
292 const content
= Template
.authorizationRequestHTML(ctx
, this.options
);
295 this.logger
.info(_scope
, 'finished', { ctx
});
300 * Validates, fetches, and parses client_id url, populating clientIdentifier with client h-app data.
301 * @param {Object} ctx
303 async
_clientIdRequired(ctx
) {
304 if (ctx
.queryParams
['client_id']) {
306 ctx
.session
.clientId
= await
this.communication
.validateClientIdentifier(ctx
.queryParams
['client_id']);
307 ctx
.session
.clientIdentifier
= await
this.communication
.fetchClientIdentifier(ctx
.session
.clientId
);
308 if (!ctx
.session
.clientIdentifier
) {
309 Manager
._setError(ctx
, 'invalid_request', 'invalid client_id: could not fetch');
310 throw new ValidationError('could not fetch');
313 ctx
.session
.clientId
= undefined;
314 if (e
instanceof CommunicationErrors
.ValidationError
) {
315 Manager
._setError(ctx
, 'invalid_request', e
.message
);
317 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'client_id\'');
320 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'client_id\'');
326 * Ensure redirect_uri exists and is corroborated by clientIdentifier data.
327 * @param {Object} ctx
329 static _redirectURIRequired(ctx
) {
330 if (ctx
.queryParams
['redirect_uri']) {
332 ctx
.session
.redirectUri
= new URL(ctx
.queryParams
['redirect_uri']);
334 if (ctx
.session
.clientId
) {
335 // Either all these parts must match, or a specific alternative must be specified.
336 const redirectMatchesClientId
= ['protocol', 'hostname', 'port']
337 .map((p
) => ctx
.session
.redirectUri
[p
] == ctx
.session
.clientId
[p
]) // eslint-disable-line security/detect-object-injection
338 .reduce((acc
, match
) => acc
&& match
, true);
340 // Check for alternate redirect_uri entries on client_id data if no initial match
341 if (!redirectMatchesClientId
) {
342 const validRedirectUris
= ctx
.session
?.clientIdentifier
?.['rels']?.['redirect_uri'] || [];
343 if (!validRedirectUris
.includes(ctx
.session
.redirectUri
.href
)) {
344 Manager
._setError(ctx
, 'invalid_request', 'redirect_uri not valid for that client_id');
345 // Remove invalid redirect_uri from session; doing this eases error routing.
346 ctx
.session
.redirectUri
= undefined;
351 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'redirect_uri\'');
354 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'redirect_uri\'');
360 * response_type must be valid
361 * @param {Object} ctx
363 static _responseTypeRequired(ctx
) {
364 ctx
.session
.responseType
= ctx
.queryParams
['response_type'];
365 if (ctx
.session
.responseType
) {
366 // Must be one of these types
367 if (!['code'].includes(ctx
.session
.responseType
)) {
368 Manager
._setError(ctx
, 'unsupported_response_type', 'invalid value for parameter \'response_type\'');
371 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'response_type\'');
377 * A state parameter must be present
378 * @param {Object} ctx
380 static _stateRequired(ctx
) {
381 ctx
.session
.state
= ctx
.queryParams
['state'];
382 if (ctx
.session
.state
) {
383 // No restrictions on content of this
385 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'state\'');
391 * A code_challenge_method must be present and valid
392 * @param {Object} ctx
394 _codeChallengeMethodRequired(ctx
) {
395 ctx
.session
.codeChallengeMethod
= ctx
.queryParams
['code_challenge_method'];
396 if (ctx
.session
.codeChallengeMethod
) {
397 if (!supportedCodeChallengeMethods
.includes(ctx
.session
.codeChallengeMethod
)) {
398 Manager
._setError(ctx
, 'invalid_request', 'unsupported code_challenge_method');
401 if (this.options
.manager
.allowLegacyNonPKCE
) {
404 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
410 * A code_challenge must be present
411 * @param {Object} ctx
413 _codeChallengeRequired(ctx
) {
414 ctx
.session
.codeChallenge
= ctx
.queryParams
['code_challenge'];
415 if (ctx
.session
.codeChallenge
) {
416 if (!validBase64URLRE
.test(ctx
.session
.codeChallenge
)) {
417 Manager
._setError(ctx
, 'invalid_request', 'invalid value for parameter \'code_challenge\'');
420 if (this.options
.manager
.allowLegacyNonPKCE
) {
423 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_challenge\'');
429 * Scopes may be present, with one known combination limitation
430 * @param {Object} ctx
432 _scopeOptional(ctx
) {
433 const _scope
= _fileScope('_scopeOptional');
434 const scope
= ctx
.queryParams
['scope'];
435 ctx
.session
.scope
= [];
437 const allScopes
= scope
.split(scopeSplitRE
);
438 const validScopes
= allScopes
.filter((s
) => common
.validScope(s
));
439 ctx
.session
.scope
.push(...validScopes
);
440 if (allScopes
.length
!= validScopes
.length
) {
441 const invalidScopes
= allScopes
.filter((s
) => !common
.validScope(s
));
442 this.logger
.debug(_scope
, 'client requested invalid scope', { ctx
, invalidScopes
});
445 // If email scope is requested, profile scope must also be explicitly requested.
446 if (ctx
.session
.scope
.includes('email')
447 && !ctx
.session
.scope
.includes('profile')) {
448 Manager
._setError(ctx
, 'invalid_scope', 'cannot provide \'email\' scope without \'profile\' scope');
454 * Parses me, if provided
455 * @param {Object} ctx
457 async
_meOptional(ctx
) {
458 const me
= ctx
.queryParams
['me'];
461 ctx
.session
.me
= await
this.communication
.validateProfile(me
);
463 ctx
.session
.me
= undefined;
470 * Ensure authenticated identifier matches profile.
471 * @param {Object} ctx
474 _profileValidForIdentifier(ctx
) {
475 const _scope
= _fileScope('_profileValidForIdentifier');
477 if (!ctx
.session
.me
) {
478 this.logger
.debug(_scope
, 'no profile provided, cannot correlate', { ctx
});
482 return ctx
.session
.profiles
.includes(ctx
.session
.me
.href
);
487 * Get numeric value from form field data.
489 * @param {String} field
490 * @param {String} customField
493 _parseLifespan(ctx
, field
, customField
) {
494 const _scope
= _fileScope('_parseLifespan');
496 const presetValues
= {
502 const fieldValue
= ctx
.parsedBody
[field
]; // eslint-disable-line security/detect-object-injection
503 if (fieldValue
in presetValues
) {
504 return presetValues
[fieldValue
]; // eslint-disable-line security/detect-object-injection
507 if (fieldValue
=== 'custom') {
508 const expiresSeconds
= parseInt(ctx
.parsedBody
[customField
], 10); // eslint-disable-line security/detect-object-injection
509 if (isFinite(expiresSeconds
) && expiresSeconds
> 0) {
510 return expiresSeconds
;
512 this.logger
.debug(_scope
, 'invalid custom value', { ctx
, field
, customField
});
516 this.logger
.debug(_scope
, 'invalid value', { ctx
, field
, customField
});
522 * Validate any accepted scopes, ensure uniqueness, return as array.
523 * @param {Object} ctx
526 _parseConsentScopes(ctx
) {
527 const _scope
= _fileScope('_ingestConsentScopes');
528 const acceptedScopesSet
= new Set();
529 const rejectedScopesSet
= new Set();
531 const submittedScopes
= common
.ensureArray(ctx
.parsedBody
['accepted_scopes'])
532 .concat((ctx
.parsedBody
['ad_hoc_scopes'] || '').split(scopeSplitRE
));
533 submittedScopes
.forEach((scope
) => {
535 (common
.validScope(scope
) ? acceptedScopesSet : rejectedScopesSet
).add(scope
);
539 // If email scope was accepted but profile was not, elide email scope
540 if (acceptedScopesSet
.has('email')
541 && !acceptedScopesSet
.has('profile')) {
542 acceptedScopesSet
.delete('email');
543 rejectedScopesSet
.add('email (without profile)');
546 if (rejectedScopesSet
.size
) {
547 this.logger
.debug(_scope
, 'ignoring invalid scopes', { ctx
, rejectedScopes: Array
.from(rejectedScopesSet
) });
550 return Array
.from(acceptedScopesSet
);
555 * Parse and validate selected me is a valid profile option.
556 * @param {Object} ctx
559 _parseConsentMe(ctx
) {
560 const _scope
= _fileScope('_parseConsentMe');
561 const selectedMe
= ctx
.parsedBody
['me'];
563 const me
= new URL(selectedMe
);
564 if (ctx
.session
.profiles
.includes(me
.href
)) {
567 this.logger
.debug(_scope
, 'selected \'me\' profile not among available', { me
, available: ctx
.session
.profiles
, ctx
});
568 Manager
._setError(ctx
, 'invalid_request', 'invalid profile url');
571 this.logger
.debug(_scope
, 'failed to parse selected \'me\' as url', { error: e
, ctx
});
572 Manager
._setError(ctx
, 'invalid_request', 'invalid profile url');
579 * Get up-to-date profile data from selected profile endpoint.
580 * @param {Object} ctx
583 async
_fetchConsentProfileData(ctx
) {
584 const _scope
= _fileScope('_fetchConsentProfileData');
586 const profile
= await
this.communication
.fetchProfile(ctx
.session
.me
);
588 this.logger
.debug(_scope
, 'no profile data at \'me\' endpoint', { ctx
});
589 Manager
._setError(ctx
, 'temporarily_unavailable', 'unable to retrieve profile');
591 // Profile info gets persisted in code, only include known profile fields to help keep size down.
592 return common
.pick(profile
, [
600 this.logger
.debug(_scope
, 'failed to fetch \'me\' endpoint', { error: e
, ctx
});
601 Manager
._setError(ctx
, 'temporarily_unavailable', 'could not reach profile endpoint');
608 * Ingest user consent response details, redirect as needed.
609 * Receives POST request from consent page, expecting these form fields:
610 * session - encrypted data collected from initial auth call
611 * accept - 'true' if consent was granted
612 * accepted_scopes - list of scopes to grant
613 * ad_hoc_scopes - additional scopes specified by user
614 * me - selected profile to identify as
615 * expires - optional lifespan
616 * expires-seconds - optional custom lifespan
617 * refresh - optional refresh lifespan
618 * refresh-seconds - optional custom refresh lifespan
619 * @param {http.ServerResponse} res
620 * @param {Object} ctx
622 async
postConsent(res
, ctx
) {
623 const _scope
= _fileScope('postConsent');
624 this.logger
.debug(_scope
, 'called', { ctx
});
626 Manager
._sensitiveResponse(res
);
628 // Ensure session exists, persisting any login session data.
629 ctx
.session
= Object
.assign({}, ctx
.session
);
631 // Recover the session established on initial auth request.
632 const oldSession
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['session']);
633 Object
.assign(ctx
.session
, oldSession
);
634 ctx
.session
.redirectUri
= new URL(ctx
.session
.redirectUri
);
635 ctx
.session
.clientId
= new URL(ctx
.session
.clientId
);
637 this.logger
.debug(_scope
, 'failed to unpack session', { error: e
, ctx
});
638 Manager
._setError(ctx
, 'invalid_request', 'un-parsable data in authorization consent');
641 // If these are missing, we cannot proceed.
642 if (!ctx
.session
.clientId
|| !ctx
.session
.redirectUri
) {
643 // Set error response for html template to render.
645 'Cannot redirect to client application.',
648 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
650 res
.statusCode
= 400;
651 res
.end(Template
.authorizationErrorHTML(ctx
, this.options
));
652 this.logger
.info(_scope
, 'bad request, cannot redirect', { ctx
});
656 // TODO: Should probably re-validate more unpacked session values, even though those should be trustable.
658 // Check if we need to check anything else.
659 ctx
.session
.accept
= (ctx
.parsedBody
['accept'] === 'true');
660 if (!ctx
.session
.accept
) {
661 this.logger
.debug(_scope
, 'consent denied', { ctx
});
662 Manager
._setError(ctx
, 'access_denied', 'authorization was not granted');
665 ctx
.session
.acceptedScopes
= this._parseConsentScopes(ctx
);
666 ctx
.session
.me
= this._parseConsentMe(ctx
);
667 ctx
.session
.profile
= await
this._fetchConsentProfileData(ctx
);
668 ctx
.session
.tokenLifespan
= this._parseLifespan(ctx
, 'expires', 'expires-seconds');
669 if (ctx
.session
.tokenLifespan
) {
670 ctx
.session
.refreshLifespan
= this._parseLifespan(ctx
, 'refresh', 'refresh-seconds');
674 if (ctx
.session
.error
) {
675 this.logger
.debug(_scope
, 'invalid request, redirecting', { ctx
});
677 // Set all errors as parameters for client to interpret upon redirection.
678 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
679 'state': ctx
.session
.state
,
680 'error': ctx
.session
.error
,
681 'error_description': ctx
.session
.errorDescriptions
.join(', '),
683 res
.statusCode
= 302; // Found
684 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
686 this.logger
.info(_scope
, 'bad request, redirected', { ctx
});
690 // Consented, off we go. Keep all this session state as the code.
691 const code
= await
this.mysteryBox
.pack({
692 codeId: ctx
.session
.id
,
693 codeChallengeMethod: ctx
.session
.codeChallengeMethod
,
694 codeChallenge: ctx
.session
.codeChallenge
,
695 clientId: ctx
.session
.clientId
.href
,
696 redirectUri: ctx
.session
.redirectUri
.href
,
697 acceptedScopes: ctx
.session
.acceptedScopes
,
698 tokenLifespan: ctx
.session
.tokenLifespan
,
699 refreshLifespan: ctx
.session
.refreshLifespan
,
700 me: ctx
.session
.me
.href
,
701 profile: ctx
.session
.profile
,
702 identifier: ctx
.session
.authenticatedIdentifier
, // need this to pair with profile
706 Manager
._setSearchParams(ctx
.session
.redirectUri
, {
708 'state': ctx
.session
.state
,
709 'iss': this.options
.dingus
.selfBaseUrl
,
711 res
.statusCode
= 302;
712 res
.setHeader(Enum
.Header
.Location
, ctx
.session
.redirectUri
.href
);
715 this.logger
.info(_scope
, 'finished', { ctx
});
720 * Redeem a code for a profile url, and maybe more profile info.
721 * @param {http.ServerResponse} res
722 * @param {Object} ctx
724 async
postAuthorization(res
, ctx
) {
725 const _scope
= _fileScope('postAuthorization');
726 this.logger
.debug(_scope
, 'called', { ctx
});
728 await
this._ingestPostAuthorizationRequest(ctx
);
732 ...(ctx
.session
?.acceptedScopes
?.includes('profile') && { profile: ctx
.session
.profile
}),
733 scope: ctx
.session
.acceptedScopes
,
735 if (response
.profile
&& !ctx
.session
?.acceptedScopes
?.includes('email')) {
736 delete response
.profile
.email
;
739 if (!ctx
.session
.error
) {
740 await
this.db
.context(async (dbCtx
) => {
741 // Record code redemption without token.
742 const valid
= await
this.db
.redeemCode(dbCtx
, {
743 codeId: ctx
.session
.codeId
,
746 clientId: ctx
.session
.clientId
.href
,
747 profile: ctx
.session
.me
,
748 identifier: ctx
.session
.identifier
,
749 scopes: ctx
.session
.acceptedScopes
,
750 lifespanSeconds: Math
.ceil(this.options
.manager
.codeValidityTimeoutMs
/ 1000),
751 profileData: response
.profile
,
754 this.logger
.debug(_scope
, 'code already redeemed', { ctx
});
755 Manager
._setError(ctx
, 'access_denied', 'code already redeemed');
760 if (ctx
.session
.error
) {
761 res
.statusCode
= 400;
762 res
.end(JSON
.stringify({
763 'error': ctx
.session
.error
,
764 'error_description': ctx
.session
.errorDescriptions
.join(', '),
766 this.logger
.info(_scope
, 'invalid request', { ctx
});
770 res
.end(JSON
.stringify(response
));
772 this.logger
.info(_scope
, 'finished', { ctx
, response
});
777 * Ingest an incoming authorization redemption request, parsing fields
778 * onto a new session object on the context.
780 * @param {Object} ctx
782 async
_ingestPostAuthorizationRequest(ctx
) {
783 const _scope
= _fileScope('_ingestPostAuthorizationRequest');
785 ctx
.session
= Object
.assign({}, ctx
.session
, {
786 errorDescriptions: [],
789 if (!ctx
.parsedBody
) {
790 this.logger
.debug(_scope
, 'no body data', { ctx
});
791 Manager
._setError(ctx
, 'invalid_request', 'missing data');
794 await
this._restoreSessionFromCode(ctx
);
795 this._checkSessionMatchingClientId(ctx
);
796 this._checkSessionMatchingRedirectUri(ctx
);
797 this._checkGrantType(ctx
);
798 this._checkSessionMatchingCodeVerifier(ctx
);
800 if (!ctx
.session
.me
|| !ctx
.session
.minted
) {
801 this.logger
.debug(_scope
, 'session missing fields', { ctx
});
802 Manager
._setError(ctx
, 'invalid_request', 'malformed code');
806 const expires
= new Date(ctx
.session
.minted
+ this.options
.manager
.codeValidityTimeoutMs
);
807 const now
= new Date();
809 this.logger
.debug(_scope
, 'code expired', { ctx
});
810 Manager
._setError(ctx
, 'invalid_request', 'code has expired');
816 * Unpack the session data from provided code overtop of context session ..
817 * @param {Object} ctx
819 async
_restoreSessionFromCode(ctx
) {
820 const _scope
= _fileScope('_restoreSessionFromCode');
822 const code
= ctx
.parsedBody
['code'];
825 const oldSession
= await
this.mysteryBox
.unpack(code
);
827 // TODO: Validate unpacked fields better
828 const missingFields
= [
830 'codeChallengeMethod',
839 ].filter((requiredField
) => !(requiredField
in oldSession
));
840 if (missingFields
.length
) {
841 if (this.options
.manager
.allowLegacyNonPKCE
842 && missingFields
.length
=== 2
843 && missingFields
.includes('codeChallenge')
844 && missingFields
.includes('codeChallengeMethod')) {
845 this.logger
.debug(_scope
, 'allowing legacy non-PKCE session', { ctx
});
847 this.logger
.debug(_scope
, 'unpacked code is missing required field', { missingFields
, ctx
});
848 Manager
._setError(ctx
, 'invalid_request', 'code is not valid');
852 Object
.assign(ctx
.session
, oldSession
);
854 this.logger
.debug(_scope
, 'failed to parse code', { error: e
, ctx
});
855 Manager
._setError(ctx
, 'invalid_request', 'code is not valid');
858 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code\'');
864 * Ensure provided client_id matches session clientId.
865 * @param {Object} ctx
867 _checkSessionMatchingClientId(ctx
) {
868 const _scope
= _fileScope('_checkSessionMatchingClientId');
870 let clientId
= ctx
.parsedBody
['client_id'];
873 clientId
= new URL(clientId
);
874 ctx
.session
.clientId
= new URL(ctx
.session
.clientId
);
876 this.logger
.debug(_scope
, 'un-parsable client_id url', { ctx
});
877 delete ctx
.session
.clientId
;
878 Manager
._setError(ctx
, 'invalid_request', 'malformed client_id');
881 if (clientId
.href
!== ctx
.session
.clientId
.href
) {
882 this.logger
.debug(_scope
, 'clientId mismatched', { clientId
, ctx
});
883 delete ctx
.session
.clientId
;
884 Manager
._setError(ctx
, 'invalid_request', 'code does not belong to that client_id');
887 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'client_id\'');
893 * @param {Object} ctx
895 _checkSessionMatchingRedirectUri(ctx
) {
896 const _scope
= _fileScope('_checkSessionMatchingClientId');
898 let redirectUri
= ctx
.parsedBody
['redirect_uri'];
901 redirectUri
= new URL(redirectUri
);
902 ctx
.session
.redirectUri
= new URL(ctx
.session
.redirectUri
);
904 this.logger
.debug(_scope
, 'un-parsable redirect_uri url', { ctx
});
905 delete ctx
.session
.redirectUri
;
906 Manager
._setError(ctx
, 'invalid_request', 'malformed redirect_url');
909 if (redirectUri
.href
!== ctx
.session
.redirectUri
.href
) {
910 this.logger
.debug(_scope
, 'redirectUri mismatched', { redirectUri
, ctx
});
911 delete ctx
.session
.redirectUri
;
912 Manager
._setError(ctx
, 'invalid_request', 'code does not belong to that redirect_uri');
915 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'redirect_uri\'');
921 * Validate grant_type, either persist on session or set error.
922 * @param {Object} ctx
923 * @param {String[]} validGrantTypes
924 * @param {Boolean} treatEmptyAs
926 _checkGrantType(ctx
, validGrantTypes
= ['authorization_code'], treatEmptyAs
= 'authorization_code') {
927 const _scope
= _fileScope('_checkGrantType');
929 const grantType
= ctx
.parsedBody
['grant_type'] || treatEmptyAs
;
930 if (!ctx
.parsedBody
['grant_type'] && treatEmptyAs
) {
931 this.logger
.debug(_scope
, `missing grant_type, treating as ${treatEmptyAs}`, { ctx
});
933 if (validGrantTypes
.includes(grantType
)) {
934 ctx
.session
.grantType
= grantType
;
936 Manager
._setError(ctx
, 'invalid_request', 'grant_type not supported');
942 * @param {Object} ctx
944 _checkSessionMatchingCodeVerifier(ctx
) {
945 const _scope
= _fileScope('_checkSessionMatchingCodeVerifier');
947 const codeVerifier
= ctx
.parsedBody
['code_verifier'];
950 const valid
= Communication
.verifyChallenge(ctx
.session
.codeChallenge
, codeVerifier
, ctx
.session
.codeChallengeMethod
);
952 this.logger
.debug(_scope
, 'challenge mismatched', { ctx
});
953 Manager
._setError(ctx
, 'invalid_request', 'challenge verification failed');
955 } catch (e
) /* istanbul ignore next */ {
956 this.logger
.error(_scope
, 'challenge validation failed', { error: e
, ctx
});
957 Manager
._setError(ctx
, 'invalid_request', 'challenge verification failed');
960 if (this.options
.manager
.allowLegacyNonPKCE
961 && !ctx
.session
.codeChallenge
962 && !ctx
.session
.codeChallengeMethod
) {
963 this.logger
.debug(_scope
, 'allowing non-PKCE', { ctx
});
966 Manager
._setError(ctx
, 'invalid_request', 'missing required parameter \'code_verifier\'');
972 * Attempt to revoke a token.
974 * @param {http.ServerResponse} res
975 * @param {Object} ctx
977 async
_revokeToken(dbCtx
, res
, ctx
) {
978 const _scope
= _fileScope('_revokeToken');
980 const token
= ctx
.parsedBody
['token'];
981 const tokenTypeHint
= ctx
.parsedBody
['token_type_hint'];
982 switch (tokenTypeHint
) {
987 case 'refresh_token':
990 this.logger
.debug(_scope
, 'unknown token_type_hint', { ctx
});
993 throw new ValidationError('Token Missing');
995 ctx
.token
= await
this.mysteryBox
.unpack(token
);
996 if (!(ctx
.token
?.c
|| ctx
.token
?.rc
)) {
997 throw new ValidationError('Token Invalid');
1000 this.logger
.debug(_scope
, 'invalid token', { error: e
, ctx
});
1001 res
.statusCode
= 400;
1003 this.logger
.info(_scope
, 'finished, revoke request not valid', { error: e
, ctx
});
1009 await
this.db
.tokenRevokeByCodeId(dbCtx
, ctx
.token
.c
);
1011 await
this.db
.tokenRefreshRevokeByCodeId(dbCtx
, ctx
.token
.rc
);
1014 if (e
instanceof DBErrors
.UnexpectedResult
) {
1015 res
.statusCode
= 404;
1017 this.logger
.info(_scope
, 'finished, no token to revoke', { error: e
, ctx
});
1020 this.logger
.error(_scope
, 'revoke token failed', { error: e
, ctx
});
1025 this.logger
.info(_scope
, 'finished, token revoked', { ctx
});
1030 * Legacy token validation flow.
1032 * @param {http.ClientRequest} req
1033 * @param {http.ServerResponse} res
1034 * @param {Object} ctx
1036 async
_validateToken(dbCtx
, req
, res
, ctx
) {
1037 const _scope
= _fileScope('_validateToken');
1038 await
this._checkTokenValidationRequest(dbCtx
, req
, ctx
);
1039 if (ctx
.bearer
.isValid
) {
1040 Manager
._sensitiveResponse(res
);
1041 res
.end(JSON
.stringify({
1042 me: ctx
.token
.profile
,
1043 'client_id': ctx
.token
.clientId
,
1044 scope: ctx
.token
.scopes
,
1046 this.logger
.info(_scope
, 'finished, token validated', { ctx
});
1048 const responseErrorParts
= ['Bearer'];
1049 const error
= ctx
.session
.error
? `error="${ctx.session.error}"` : '';
1051 responseErrorParts
.push(error
);
1053 const errorDescription
= ctx
.session
.errorDescriptions
? `error_description="${ctx.session.errorDescriptions.join(', ')}"` : '';
1054 if (errorDescription
) {
1055 responseErrorParts
.push(errorDescription
);
1057 res
.setHeader(Enum
.Header
.WWWAuthenticate
, responseErrorParts
.join(', '));
1058 this.logger
.info(_scope
, 'finished, token not validated', { ctx
});
1059 throw new ResponseError(Enum
.ErrorResponse
.Unauthorized
);
1065 * Given a list of newly-requested scopes, return a list of scopes
1066 * from previousScopes which are not in requestedScopes.
1067 * @param {String[]} previousScopes
1068 * @param {String[]} requestedScopes
1069 * @returns {String[]}
1071 static _scopeDifference(previousScopes
, requestedScopes
) {
1072 const scopesToRemove
= [];
1073 const existingScopesSet
= new Set(previousScopes
);
1074 const validRequestedScopes
= requestedScopes
.filter((s
) => common
.validScope(s
));
1075 const requestedScopesSet
= new Set(validRequestedScopes
);
1076 existingScopesSet
.forEach((s
) => {
1077 if (!requestedScopesSet
.has(s
)) {
1078 scopesToRemove
.push(s
);
1081 return scopesToRemove
;
1086 * Redeem a refresh token for a new token.
1088 * @param {http.ClientRequest} req
1089 * @param {http.ServerResponse} res
1090 * @param {Object} ctx
1092 async
_refreshToken(dbCtx
, req
, res
, ctx
) {
1093 const _scope
= _fileScope('_refreshToken');
1094 this.logger
.debug(_scope
, 'called', { ctx
});
1097 'client_id': clientId
,
1102 ctx
.refreshToken
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['refresh_token']);
1104 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1107 const now
= new Date();
1108 const nowEpoch
= common
.dateToEpoch(now
);
1110 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1111 if (ctx
.refreshToken
?.rc
) {
1112 ctx
.token
= await
this.db
.tokenGetByCodeId(txCtx
, ctx
.refreshToken
.rc
);
1116 this.logger
.debug(_scope
, 'no token to refresh', { ctx
});
1117 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
1120 if (!ctx
.token
.refreshExpires
1121 || ctx
.token
.refreshExpires
< now
) {
1122 this.logger
.debug(_scope
, 'token not refreshable or refresh expired', { ctx
});
1123 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1126 const refreshExpiresEpoch
= common
.dateToEpoch(ctx
.token
.refreshExpires
);
1127 if (ctx
.refreshToken
.exp
< refreshExpiresEpoch
) {
1128 this.logger
.debug(_scope
, 'token already refreshed', { ctx
});
1129 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1132 if (clientId
!== ctx
.token
.clientId
) {
1133 this.logger
.debug(_scope
, 'client identifier mismatch', { ctx
});
1134 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1137 const scopesToRemove
= scope
? Manager
._scopeDifference(ctx
.token
.scopes
, scope
.split(scopeSplitRE
)) : [];
1138 if (scopesToRemove
.length
) {
1139 this.logger
.debug(_scope
, 'scope reduction requested', { ctx
, scopesToRemove
});
1142 const refreshedTokenData
= await
this.db
.refreshCode(txCtx
, ctx
.refreshToken
.rc
, now
, scopesToRemove
);
1143 if (refreshedTokenData
) {
1144 Object
.assign(ctx
.token
, refreshedTokenData
);
1146 this.logger
.debug(_scope
, 'could not refresh token', { ctx
});
1147 throw new ResponseError(Enum
.ErrorResponse
.NotFound
);
1151 const [token
, refreshToken
] = await Promise
.all([
1153 c: ctx
.token
.codeId
,
1157 rc: ctx
.token
.codeId
,
1159 exp: nowEpoch
+ ctx
.token
.refreshDuration
,
1161 ].map(this.mysteryBox
.pack
));
1164 'access_token': token
,
1165 'token_type': 'Bearer',
1166 ...(ctx
.token
.duration
&& { 'expires_in': nowEpoch
+ ctx
.token
.duration
}),
1167 ...(refreshToken
&& { 'refresh_token': refreshToken
}),
1168 scope: ctx
.token
.scopes
.join(' '),
1170 ...(ctx
.token
.scopes
.includes('profile') && { profile: ctx
.token
.profileData
}),
1172 if (ctx
.token
.scopes
.includes('profile') && !ctx
.token
.scopes
.includes('email')) {
1173 delete response
?.profile
?.email
;
1176 Manager
._sensitiveResponse(res
);
1177 res
.end(JSON
.stringify(response
));
1178 this.logger
.info(_scope
, 'finished', { ctx
});
1183 * Generate a new ticket for later redemption.
1184 * @param {Object} payload
1185 * @param {} payload.subject deliver ticket to this endpoint
1186 * @param {} payload.resource url the redeemed ticket is valid for accessing
1187 * @param {String[]} payload.scopes list of scopes assigned to ticket
1188 * @param {String} payload.identifier user generating ticket
1189 * @param {} payload.profile profile of user generating ticket
1190 * @param {Number} payload.ticketLifespanSeconds ticket redeemable for this long
1193 async
_mintTicket({ subject
, resource
, scopes
, identifier
, profile
, ticketLifespanSeconds
}) {
1194 const _scope
= _fileScope('_mintTicket');
1195 this.logger
.debug(_scope
, 'called', { subject
, resource
, scopes
, identifier
, profile
, ticketLifespanSeconds
});
1197 const nowEpoch
= common
.dateToEpoch();
1198 return this.mysteryBox
.pack({
1199 c: common
.requestId(),
1201 exp: nowEpoch
+ ticketLifespanSeconds
,
1213 * @property {String} codeId
1214 * @property {Date} issued
1215 * @property {Date} expires
1216 * @property {URL} subject
1217 * @property {URL} resource
1218 * @property {String[]} scopes
1219 * @property {String} identifier
1220 * @property {URL} profile
1224 * @param {String} ticket
1227 async
_unpackTicket(ticket
) {
1228 const ticketObj
= await
this.mysteryBox
.unpack(ticket
);
1230 codeId: ticketObj
.c
,
1231 issued: new Date(ticketObj
.iat
* 1000),
1232 expires: new Date(ticketObj
.exp
* 1000),
1233 subject: new URL(ticketObj
.sub
),
1234 resource: new URL(ticketObj
.res
),
1235 scopes: ticketObj
.scope
,
1236 identifier: ticketObj
.ident
,
1237 profile: new URL(ticketObj
.profile
),
1243 * Redeem a ticket for a token.
1245 * @param {http.ClientRequest} req
1246 * @param {http.ServerResponse} res
1247 * @param {Object} ctx
1249 async
_ticketAuthToken(dbCtx
, req
, res
, ctx
) {
1250 const _scope
= _fileScope('_ticketAuthToken');
1251 this.logger
.debug(_scope
, 'called', { ctx
});
1254 ctx
.ticket
= await
this._unpackTicket(ctx
.parsedBody
['ticket']);
1256 this.logger
.debug(_scope
, 'failed to unpack ticket', { error: e
, ctx
});
1257 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1260 const now
= new Date();
1261 if (now
> ctx
.ticket
.expires
) {
1262 this.logger
.debug(_scope
, 'ticket has expired', { ctx
});
1263 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
, { reason: 'Ticket has expired.', expired: ctx
.ticket
.expires
});
1266 const nowEpoch
= common
.dateToEpoch(now
);
1267 const token
= await
this.mysteryBox
.pack({
1268 c: ctx
.ticket
.codeId
,
1273 'access_token': token
,
1274 'token_type': 'Bearer',
1275 scope: ctx
.ticket
.scopes
.join(' '),
1276 me: ctx
.ticket
.profile
.href
,
1279 const isValid
= await
this.db
.redeemCode(dbCtx
, {
1281 codeId: ctx
.ticket
.codeId
,
1283 clientId: ctx
.ticket
.subject
.href
,
1284 resource: ctx
.ticket
.resource
.href
,
1285 profile: ctx
.ticket
.profile
.href
,
1286 identifier: ctx
.ticket
.identifier
,
1287 scopes: ctx
.ticket
.scopes
,
1290 this.logger
.debug(_scope
, 'redemption failed, already redeemed', { ctx
});
1291 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
);
1294 Manager
._sensitiveResponse(res
);
1295 res
.end(JSON
.stringify(response
));
1296 this.logger
.info(_scope
, 'finished', { ctx
});
1301 * Redeem a code for a token.
1303 * @param {http.ClientRequest} req
1304 * @param {http.ServerResponse} res
1305 * @param {Object} ctx
1307 async
_codeToken(dbCtx
, req
, res
, ctx
) {
1308 const _scope
= _fileScope('_codeToken');
1309 this.logger
.debug(_scope
, 'called', { ctx
});
1311 await
this._restoreSessionFromCode(ctx
);
1312 await
this._checkSessionMatchingRedirectUri(ctx
);
1314 if (ctx
.session
.error
) {
1315 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1319 * Note the creation date here rather than in database, so that stored
1320 * expire dates are ensured to match those packed in tokens.
1321 * An alternative would be to return the date generated by the database,
1322 * but then we would need to hold the transaction open while minting the
1323 * tokens to ensure success. Perhaps that would be worth it, but for now
1324 * this is how it is.
1326 const now
= new Date();
1327 const nowEpoch
= common
.dateToEpoch(now
);
1328 const tokenMinters
= [];
1330 tokenMinters
.push(this.mysteryBox
.pack({
1331 c: ctx
.session
.codeId
,
1333 ...(ctx
.session
.tokenLifespan
&& { exp: nowEpoch
+ ctx
.session
.tokenLifespan
}),
1336 if (ctx
.session
.tokenLifespan
1337 && ctx
.session
.refreshLifespan
) {
1338 tokenMinters
.push(this.mysteryBox
.pack({
1339 rc: ctx
.session
.codeId
,
1341 exp: nowEpoch
+ ctx
.session
.refreshLifespan
,
1345 const [token
, refreshToken
] = await Promise
.all(tokenMinters
);
1348 'access_token': token
,
1349 'token_type': 'Bearer',
1350 ...(ctx
.session
.tokenLifespan
&& { 'expires_in': nowEpoch
+ ctx
.session
.tokenLifespan
}),
1351 ...(refreshToken
&& { 'refresh_token': refreshToken
}),
1352 scope: ctx
.session
.acceptedScopes
.join(' '),
1354 ...(ctx
.session
.acceptedScopes
.includes('profile') && { profile: ctx
.session
.profile
}),
1356 if (!ctx
.session
.acceptedScopes
.includes('email') && response
.profile
) {
1357 delete response
.profile
.email
;
1360 const isValid
= await
this.db
.redeemCode(dbCtx
, {
1362 codeId: ctx
.session
.codeId
,
1364 clientId: ctx
.session
.clientId
,
1365 profile: ctx
.session
.me
,
1366 identifier: ctx
.session
.identifier
,
1367 scopes: ctx
.session
.acceptedScopes
,
1368 lifespanSeconds: ctx
.session
.tokenLifespan
,
1369 refreshLifespanSeconds: ctx
.session
.refreshLifespan
,
1370 profileData: response
.profile
,
1373 this.logger
.debug(_scope
, 'redemption failed, already redeemed', { ctx
});
1374 throw new ResponseError(Enum
.ErrorResponse
.Forbidden
);
1377 Manager
._sensitiveResponse(res
);
1378 res
.end(JSON
.stringify(response
));
1379 this.logger
.info(_scope
, 'finished', { ctx
});
1384 * Issue, refresh, or validate a token.
1385 * @param {http.ClientRequest} req
1386 * @param {http.ServerResponse} res
1387 * @param {Object} ctx
1389 async
postToken(req
, res
, ctx
) {
1390 const _scope
= _fileScope('postToken');
1391 this.logger
.debug(_scope
, 'called', { ctx
});
1393 ctx
.session
= Object
.assign({}, ctx
.session
);
1395 await
this.db
.context(async (dbCtx
) => {
1397 // Is this a (legacy) revocation request?
1398 if (ctx
.parsedBody
['action'] === 'revoke') {
1399 await
this._revokeToken(dbCtx
, res
, ctx
);
1403 // Is this a (legacy) token validation request?
1404 if (req
.getHeader(Enum
.Header
.Authorization
)) {
1405 await
this._validateToken(dbCtx
, res
, ctx
);
1409 const validGrantTypes
= [
1410 'authorization_code',
1412 ...(this.queuePublisher
&& ['ticket'] || []),
1414 this._checkGrantType(ctx
, validGrantTypes
, 'authorization_code');
1416 switch (ctx
.session
.grantType
) {
1417 case 'refresh_token':
1418 return this._refreshToken(dbCtx
, req
, res
, ctx
);
1421 return this._ticketAuthToken(dbCtx
, req
, res
, ctx
);
1423 case 'authorization_code':
1424 return this._codeToken(dbCtx
, req
, res
, ctx
);
1427 this.logger
.debug(_scope
, 'unknown grant_type', { ctx
});
1428 Manager
._setError(ctx
, 'invalid_request', 'grant_type not supported');
1431 // Only way of getting here is due to error.
1432 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1438 * Ingest token from authorization header, setting ctx.bearer.isValid appropriately.
1439 * ctx.bearer not set if auth method not recognized.
1440 * This is for legacy validation on token endpoint.
1442 * @param {http.ClientRequest} req
1443 * @param {Object} ctx
1445 async
_checkTokenValidationRequest(dbCtx
, req
, ctx
) {
1446 const _scope
= _fileScope('_checkTokenValidationRequest');
1447 const authHeader
= req
.getHeader(Enum
.Header
.Authorization
);
1450 const [authMethod
, authString
] = common
.splitFirst(authHeader
, ' ', '');
1451 switch (authMethod
.toLowerCase()) { // eslint-disable-line sonarjs/no-small-switch
1457 Object
.assign(ctx
.bearer
, await
this.mysteryBox
.unpack(authString
));
1459 this.logger
.debug(_scope
, 'failed to unpack token', { ctx
});
1460 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1463 if (!ctx
.bearer
.c
) {
1464 this.logger
.debug(_scope
, 'incomplete token', { ctx
});
1465 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1470 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.bearer
.c
);
1472 this.logger
.error(_scope
, 'failed to look up token', { error: e
, ctx
});
1477 this.logger
.debug(_scope
, 'no token found', { ctx
});
1478 Manager
._setError(ctx
, 'invalid_request', 'invalid token');
1482 if (!ctx
.token
.isRevoked
1483 && ctx
.token
.expires
> new Date()) {
1484 ctx
.bearer
.isValid
= true;
1490 this.logger
.debug(_scope
, 'unknown authorization scheme', { ctx
});
1498 * Accept an unsolicited ticket proffering.
1499 * @param {http.ClientRequest} req
1500 * @param {http.ServerResponse} res
1501 * @param {Object} ctx
1503 async
postTicket(req
, res
, ctx
) {
1504 const _scope
= _fileScope('postTicket');
1505 this.logger
.debug(_scope
, 'called', { ctx
});
1507 if (!this.queuePublisher
) {
1508 this.logger
.debug(_scope
, 'ticket endpoint not configured', { ctx
});
1509 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1512 const queueName
= this.options
.queues
.ticketPublishName
;
1513 const { ticket
, resource
, subject
} = ctx
.parsedBody
;
1518 this.logger
.debug(_scope
, 'unparsable resource', { ticket
, resource
, subject
, ctx
});
1519 throw new ResponseError(Enum
.ErrorResponse
.BadRequest
);
1522 await
this.db
.context(async (dbCtx
) => {
1523 const isValidProfile
= await
this.db
.profileIsValid(dbCtx
, subject
);
1524 if (!isValidProfile
) {
1525 this.logger
.debug(_scope
, 'invalid subject', { ticket
, resource
, subject
, ctx
});
1526 throw new ResponseError(Enum
.ErrorResponse
.NotFound
, { error: 'subject not under our purview' });
1530 const result
= await
this.queuePublisher
.publish(queueName
, { ticket
, resource
, subject
, epochMs: Date
.now() });
1531 this.logger
.debug(_scope
, 'accepted ticket offer', { queueName
, ticket
, resource
, subject
, ctx
, result
});
1533 this.logger
.error(_scope
, 'failed to publish ticket to queue', { error: e
, queueName
, ticket
, resource
, subject
, ctx
});
1534 throw e
; // return a 500
1537 res
.statusCode
= 202;
1539 this.logger
.info(_scope
, 'finished', { resource
, subject
, ctx
});
1545 * Validate a token and return data about it.
1546 * @param {http.ServerResponse} res
1547 * @param {Object} ctx
1549 async
postIntrospection(res
, ctx
) {
1550 const _scope
= _fileScope('postIntrospection');
1551 this.logger
.debug(_scope
, 'called', { ctx
});
1557 const tokenIsTicket
= (ctx
.parsedBody
['token_hint_type'] || '').toLowerCase() === 'ticket';
1560 const token
= ctx
.parsedBody
['token'];
1561 if (tokenIsTicket
) {
1562 ctx
.token
= await
this._unpackTicket(token
);
1564 ctx
.token
= await
this.mysteryBox
.unpack(token
);
1567 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1571 && !tokenIsTicket
) {
1572 await
this.db
.context(async (dbCtx
) => {
1573 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.token
.c
);
1578 && !ctx
.token
.isRevoked
) {
1579 // fuss around for postgres 'Infinity' date
1580 const expiresMs
= (ctx
.token
.expires
instanceof Date
) ? ctx
.token
.expires
.getTime() : ctx
.token
.expires
;
1581 if (expiresMs
> Date
.now()) {
1584 me: ctx
.token
.profile
,
1585 ...(ctx
.token
.clientId
&& { 'client_id': ctx
.token
.clientId
}),
1586 scope: ctx
.token
.scopes
.join(' '),
1587 iat: common
.dateToEpoch(ctx
.token
.created
|| ctx
.token
.issued
),
1588 ...(isFinite(expiresMs
) && { exp: Math
.ceil(expiresMs
/ 1000) }),
1589 ...(tokenIsTicket
&& { 'token_type': 'ticket' }),
1594 Manager
._sensitiveResponse(res
);
1595 res
.end(JSON
.stringify(response
));
1596 this.logger
.info(_scope
, 'finished', { ctx
});
1601 * Revoke a token or refresh token.
1602 * @param {http.ServerResponse} res
1603 * @param {Object} ctx
1605 async
postRevocation(res
, ctx
) {
1606 const _scope
= _fileScope('postRevocation');
1607 this.logger
.debug(_scope
, 'called', { ctx
});
1610 await
this.db
.context(async (dbCtx
) => {
1611 await
this._revokeToken(dbCtx
, res
, ctx
);
1614 this.logger
.error(_scope
, 'failed', { error: e
, ctx
});
1618 this.logger
.info(_scope
, 'finished', { ctx
});
1623 * Profile information for a token.
1624 * @param {http.ServerResponse} res
1625 * @param {Object} ctx
1627 async
postUserInfo(res
, ctx
) {
1628 const _scope
= _fileScope('postUserInfo');
1629 this.logger
.debug(_scope
, 'called', { ctx
});
1631 const token
= ctx
.parsedBody
['token'];
1633 res
.statusCode
= 400;
1634 res
.end('"invalid_request"');
1635 this.logger
.info(_scope
, 'finished, invalid request', { ctx
});
1640 ctx
.token
= await
this.mysteryBox
.unpack(ctx
.parsedBody
['token']);
1642 this.logger
.debug(_scope
, 'failed to unpack token', { error: e
, ctx
});
1646 await
this.db
.context(async (dbCtx
) => {
1647 ctx
.token
= await
this.db
.tokenGetByCodeId(dbCtx
, ctx
.token
.c
);
1652 || ctx
.token
.isRevoked
1653 // || tokenIsExpired(token)
1655 res
.statusCode
= 401;
1656 res
.end('"invalid_token"');
1657 this.logger
.info(_scope
, 'finished, invalid token', { ctx
});
1661 if (!ctx
.token
.scopes
.includes('profile')) {
1662 res
.statusCode
= 403;
1663 res
.end('"insufficient_scope"');
1664 this.logger
.info(_scope
, 'finished, insufficient scope', { ctx
});
1669 ...ctx
.token
.profile
,
1671 if (!ctx
.token
.scopes
.includes('email')) {
1672 delete response
.email
;
1675 Manager
._sensitiveResponse(res
);
1676 res
.end(JSON
.stringify(response
));
1678 this.logger
.info(_scope
, 'finished', { ctx
});
1683 * Show admin interface, allowing manipulation of profiles and scopes.
1684 * @param {http.ServerResponse} res
1685 * @param {Object} ctx
1687 async
getAdmin(res
, ctx
) {
1688 const _scope
= _fileScope('getAdmin');
1689 this.logger
.debug(_scope
, 'called', { ctx
});
1691 const identifier
= ctx
.session
.authenticatedIdentifier
;
1693 await
this.db
.context(async (dbCtx
) => {
1694 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1695 ctx
.tokens
= await
this.db
.tokensGetByIdentifier(dbCtx
, identifier
);
1698 res
.end(Template
.adminHTML(ctx
, this.options
));
1700 this.logger
.info(_scope
, 'finished', { ctx
});
1705 * Process admin interface events.
1706 * @param {http.ServerResponse} res
1707 * @param {Object} ctx
1709 async
postAdmin(res
, ctx
) {
1710 const _scope
= _fileScope('postAdmin');
1711 this.logger
.debug(_scope
, 'called', { ctx
});
1713 await
this.db
.context(async (dbCtx
) => {
1714 const identifier
= ctx
.session
.authenticatedIdentifier
;
1715 const action
= ctx
?.parsedBody
?.['action'] || '';
1717 if (action
=== 'save-scopes') {
1718 // Update the convenience scopes set for profiles.
1719 // Expect 'scopes-<profile>' with value of array of scopes
1720 const profileKeys
= ctx
.parsedBody
&& Object
.keys(ctx
.parsedBody
)
1721 .filter((k
) => k
.startsWith('scopes-'));
1723 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1725 /* For each scopes-profile submitted, set those. */
1726 profileKeys
.map((profileKey
) => {
1727 /* elide 'scope-' prefix to get the profile */
1728 const profile
= profileKey
.slice(7);
1729 /* (should validate profile here) */
1731 /* remove invalid scopes from submitted list */
1732 const scopes
= ctx
.parsedBody
[profileKey
].filter((scope
) => scope
&& common
.validScope(scope
)); // eslint-disable-line security/detect-object-injection
1733 return this.db
.profileScopesSetAll(txCtx
, profile
, scopes
);
1737 ctx
.notifications
.push('Profile/Scope Availability Matrix updated!');
1739 this.logger
.error(_scope
, 'did not set profile scopes', { error: e
, ctx
});
1740 ctx
.errors
.push('Failed to update profile scopes.');
1743 } else if (action
=== 'new-profile') {
1744 // Validate and create a new profile uri.
1746 const profileUri
= ctx
.parsedBody
['profile'];
1748 profile
= await
this.communication
.validateProfile(profileUri
);
1750 this.logger
.debug(_scope
, 'invalid profile url', { error: e
, ctx
});
1751 ctx
.errors
.push(`'${profileUri}' is not a valid profile URI.${(e instanceof CommunicationErrors.ValidationError) ? ('(' + e.message + ')') : ''}`);
1754 // Validate profile uri
1755 const profileData
= await
this.communication
.fetchProfile(profile
);
1756 if (profileData
.metadata
.authorizationEndpoint
!== this.selfAuthorizationEndpoint
) {
1757 this.logger
.debug(_scope
, 'profile does not list this server as auth', { profileData
, ctx
});
1758 ctx
.errors
.push('Requested profile does not list this service, not adding.');
1761 await
this.db
.transaction(dbCtx
, async (txCtx
) => {
1762 await
this.db
.profileIdentifierInsert(txCtx
, profile
.href
, identifier
);
1763 await
this.db
.profileScopesSetAll(txCtx
, profile
.href
, ['profile', 'email']);
1765 ctx
.notifications
.push('Profile added!');
1767 this.logger
.error(_scope
, 'did not insert profile', { error: e
, ctx
});
1768 ctx
.errors
.push('Failed to add profile.');
1773 } else if (action
=== 'new-scope') {
1774 // Add or update a manually-added convenience scope.
1775 const { scope
, application
= '', description
= '' } = ctx
.parsedBody
;
1777 if (!common
.validScope(scope
)) {
1778 ctx
.errors
.push(`"${scope}" is not a valid scope name, did not add it.`);
1781 await
this.db
.scopeUpsert(dbCtx
, scope
, application
, description
, true);
1782 ctx
.notifications
.push('Scope List updated!');
1784 this.logger
.error(_scope
, 'did not upsert scope', { error: e
, scope
, application
, description
, ctx
});
1785 ctx
.errors
.push('Failed to update scope.');
1790 } else if (action
.startsWith('delete-scope-')) {
1791 // Remove a manually-added convenience scope.
1792 const scope
= decodeURIComponent(action
.slice(13));
1795 const deleted
= await
this.db
.scopeDelete(dbCtx
, scope
);
1797 ctx
.notifications
.push('Scope deleted.');
1799 ctx
.notifications
.push('Unable to delete scope.');
1802 this.logger
.error(_scope
, 'did not delete scope', { error: e
, scope
, ctx
});
1803 ctx
.errors
.push('Failed to delete scope.');
1807 } else if (action
.startsWith('revoke-')) {
1808 // Revoke an active token.
1809 const codeId
= action
.slice(8);
1812 await
this.db
.tokenRevokeByCodeId(dbCtx
, codeId
, identifier
);
1813 ctx
.notifications
.push('Revoked token!');
1815 this.logger
.error(_scope
, 'did not revoke token', { error: e
, codeId
, identifier
, ctx
});
1816 ctx
.errors
.push('Unable to revoke token.');
1820 } else if (action
) {
1821 ctx
.errors
.push(`Do not know how to '${action}'.`);
1824 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1825 ctx
.tokens
= await
this.db
.tokensGetByIdentifier(dbCtx
, identifier
);
1828 res
.end(Template
.adminHTML(ctx
, this.options
));
1830 this.logger
.info(_scope
, 'finished', { ctx
});
1835 * Show ticket proffer interface.
1836 * @param {http.ServerResponse} res
1837 * @param {Object} ctx
1839 async
getAdminTicket(res
, ctx
) {
1840 const _scope
= _fileScope('getAdminTicket');
1841 this.logger
.debug(_scope
, 'called', { ctx
});
1843 const identifier
= ctx
.session
.authenticatedIdentifier
;
1845 await
this.db
.context(async (dbCtx
) => {
1846 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1847 ctx
.profiles
= ctx
.profilesScopes
.profiles
;
1848 ctx
.scopes
= Object
.keys(ctx
.profilesScopes
.scopeIndex
);
1851 res
.end(Template
.adminTicketHTML(ctx
, this.options
));
1853 this.logger
.info(_scope
, 'finished', { ctx
});
1858 * Handle ticket proffer interface submission.
1859 * @param {http.ServerResponse} res
1860 * @param {Object} ctx
1862 async
postAdminTicket(res
, ctx
) {
1863 const _scope
= _fileScope('postAdminTicket');
1864 this.logger
.debug(_scope
, 'called', { ctx
});
1866 switch (ctx
.parsedBody
['action']) { // eslint-disable-line sonarjs/no-small-switch
1867 case 'proffer-ticket': {
1868 const identifier
= ctx
.session
.authenticatedIdentifier
;
1870 { ctxProp: 'ticketProfileUrl', bodyParam: 'profile', err: 'Invalid Profile URL selected.' },
1871 { ctxProp: 'ticketResourceUrl', bodyParam: 'resource', err: 'Invalid Resource URL.' },
1872 { ctxProp: 'ticketSubjectUrl', bodyParam: 'subject', err: 'Invalid Recipient URL.' },
1873 ].forEach((param
) => {
1875 ctx
[param
.ctxProp
] = new URL(ctx
.parsedBody
[param
.bodyParam
]);
1877 this.logger
.debug(_scope
, `invalid ${param.bodyParam}`, { ctx
});
1878 ctx
.errors
.push(param
.err
);
1882 const subjectData
= await
this.communication
.fetchProfile(ctx
.ticketSubjectUrl
);
1883 if (!subjectData
?.metadata
?.ticketEndpoint
) {
1884 this.logger
.debug(_scope
, 'subject has no ticket endpoint', { ctx
});
1885 ctx
.errors
.push('Recipient does not list a ticket endpoint to deliver to.');
1888 ctx
.ticketEndpointUrl
= new URL(subjectData
.metadata
.ticketEndpoint
);
1890 this.logger
.debug(_scope
, 'subject has invalid ticket endpoint', { error: e
, ctx
});
1891 ctx
.errors
.push(`Recipient lists an invalid ticket endpoint, cannot deliver. (${e})`);
1895 const scopesSet
= new Set();
1897 ...(common
.ensureArray(ctx
.parsedBody
['scopes'])),
1898 ...((ctx
.parsedBody
['adhoc'] || '').split(scopeSplitRE
)),
1899 ].filter((scope
) => scope
);
1900 rawScopes
.forEach((scope
) => {
1901 if (common
.validScope(scope
)) {
1902 scopesSet
.add(scope
);
1904 this.logger
.debug(_scope
, 'invalid adhoc scope', { scope
, ctx
});
1905 ctx
.errors
.push(`'${scope}' is not a valid scope.`);
1908 ctx
.ticketScopes
= [...scopesSet
];
1909 const actionScopes
= ctx
.ticketScopes
.filter((scope
) => !['profile', 'email'].includes(scope
));
1910 if (!actionScopes
.length
) {
1911 this.logger
.debug(_scope
, 'no valid scopes included', { ctx
});
1912 ctx
.errors
.push('At least one actionable scope must be included.');
1915 if (!ctx
.errors
.length
) {
1916 const ticketData
= {
1917 subject: ctx
.ticketSubjectUrl
.href
,
1918 resource: ctx
.ticketResourceUrl
.href
,
1919 scopes: ctx
.ticketScopes
,
1921 profile: ctx
.ticketProfileUrl
.href
,
1922 ticketLifespanSeconds: this.options
.manager
.ticketLifespanSeconds
,
1924 const ticket
= await
this._mintTicket(ticketData
);
1926 await
this.db
.context(async (dbCtx
) => {
1927 // re-populate form fields
1928 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1930 // TODO: queue ticket for delivery/retry to subject instead of trying immediately
1931 // ctx.notifications.push('Success! Ticket will be delivered!');
1933 this.logger
.debug(_scope
, 'ticket created', { ctx
, ticketData
, subjectData
});
1936 const result
= await
this.communication
.deliverTicket(ctx
.ticketEndpointUrl
, ctx
.ticketResourceUrl
, ctx
.ticketSubjectUrl
, ticket
);
1937 ctx
.notifications
.push(`Success! Ticket was delivered. (${result?.statusMessage})`);
1938 this.logger
.info(_scope
, 'ticket delivered', { ctx
, result
});
1940 this.logger
.error(_scope
, 'failed to deliver ticket', { ctx
, error: e
});
1941 ctx
.errors
.push(`Failed to deliver ticket. (${e})`);
1947 // populate form fields again
1948 await
this.db
.context(async (dbCtx
) => {
1949 ctx
.profilesScopes
= await
this.db
.profilesScopesByIdentifier(dbCtx
, identifier
);
1950 ctx
.scopes
= Object
.keys(ctx
.profilesScopes
.scopeIndex
);
1958 this.logger
.debug(_scope
, 'unknown action', { ctx
});
1961 res
.end(Template
.adminTicketHTML(ctx
, this.options
));
1963 this.logger
.info(_scope
, 'finished', { ctx
});
1968 * Report on generally uninteresting backend information.
1969 * Also allow a few event invocations.
1970 * @param {http.ServerResponse} res
1971 * @param {Object} ctx
1973 async
getAdminMaintenance(res
, ctx
) {
1974 const _scope
= _fileScope('getAdminMaintenance');
1975 this.logger
.debug(_scope
, 'called', { ctx
});
1977 const maintenanceTasks
= [];
1979 await
this.db
.context(async (dbCtx
) => {
1981 Object
.values(Enum
.Chore
).forEach((chore
) => {
1982 if (chore
in ctx
.queryParams
) {
1983 maintenanceTasks
.push(
1984 this.chores
.runChore(chore
, 0), // Provide arg to force chore run.
1986 ctx
.notifications
.push(`Running maintenance chore "${chore}".`);
1990 await Promise
.all(maintenanceTasks
);
1992 ctx
.almanac
= await
this.db
.almanacGetAll(dbCtx
);
1995 const winnowChoreEntry
= ([name
, value
]) => [name
, common
.pick(value
, ['intervalMs', 'nextSchedule'])];
1996 ctx
.chores
= Object
.fromEntries(
1997 Object
.entries(this.chores
.chores
).map(winnowChoreEntry
),
2000 res
.end(Template
.adminMaintenanceHTML(ctx
, this.options
));
2002 this.logger
.info(_scope
, 'finished', { ctx
});
2008 * @param {http.ServerResponse} res
2009 * @param {Object} ctx
2011 async
getHealthcheck(res
, ctx
) {
2012 const _scope
= _fileScope('getHealthcheck');
2013 this.logger
.debug(_scope
, 'called', { ctx
});
2014 await
this.db
.healthCheck();
2020 module
.exports
= Manager
;