--- /dev/null
+'use strict';
+
+const common = require('./common');
+const { ResponseError, ValidationError } = require('./errors');
+const Enum = require('./enum');
+const { Communication, Errors: CommunicationErrors } = require('@squeep/indieauth-helper');
+const Template = require('./template');
+const { MysteryBox } = require('@squeep/mystery-box');
+const DBErrors = require('./db/errors');
+const Chores = require('./chores');
+const { Publisher: QueuePublisher } = require('@squeep/amqp-helper');
+
+const _fileScope = common.fileScope(__filename);
+
+// These are used during request ingestion and validation
+const validBase64URLRE = /^[-A-Za-z0-9_]+$/;
+const scopeSplitRE = / +/;
+
+const supportedCodeChallengeMethods = ['S256', 'SHA256'];
+
+class Manager {
+ constructor(logger, db, options) {
+ this.options = options;
+ this.logger = logger;
+ this.db = db;
+ this.chores = new Chores(logger, db, options);
+ this.communication = new Communication(logger, options);
+ if (options.queues.amqp.url) {
+ this.queuePublisher = new QueuePublisher(logger, options.queues.amqp);
+ }
+ this.mysteryBox = new MysteryBox(logger, options);
+
+ // We need to know how the outside world sees us, to verify if a
+ // profile indicates us as the auth server.
+ // selfBaseUrl should already include proxy prefix and end with a /
+ this.selfAuthorizationEndpoint = options.dingus.selfBaseUrl + options.route.authorization;
+ }
+
+
+ /**
+ * Perform any async startup tasks.
+ */
+ async initialize() {
+ if (this.queuePublisher) {
+ await this._connectQueues();
+ }
+ }
+
+
+ async _connectQueues() {
+ await this.queuePublisher.connect();
+ await this.queuePublisher.establishAMQPPlumbing(this.options.queues.ticketPublishName);
+ }
+
+
+ /**
+ * Add an error to a session, keeping only the most-severe code, but all descriptions.
+ * This error is sent along on the redirection back to client endpoint.
+ * @param {Object} ctx
+ * @param {Object} ctx.session
+ * @param {String[]=} ctx.session.errorDescriptions
+ * @param {String=} ctx.session.error
+ * @param {String} error
+ * @param {String} errorDescription
+ */
+ static _setError(ctx, error, errorDescription) {
+ const errorPrecedence = [ // By increasing severity
+ 'invalid_scope',
+ 'unsupported_response_type',
+ 'access_denied',
+ 'unauthorized_client',
+ 'invalid_grant',
+ 'invalid_request',
+ 'temporarily_unavailable',
+ 'server_error',
+ ];
+ if (!(errorPrecedence.includes(error))) {
+ throw new RangeError(`invalid error value '${error}'`);
+ }
+ if (!ctx.session.errorDescriptions) {
+ ctx.session.errorDescriptions = [];
+ }
+ if (!common.validError(errorDescription)) {
+ throw new RangeError(`invalid error description '${errorDescription}'`);
+ }
+ const isHigherPrecedence = errorPrecedence.indexOf(error) > errorPrecedence.indexOf(ctx.session.error);
+ if (!ctx.session.error || isHigherPrecedence) {
+ ctx.session.error = error;
+ }
+ if (isHigherPrecedence) {
+ ctx.session.errorDescriptions.unshift(errorDescription);
+ } else {
+ ctx.session.errorDescriptions.push(errorDescription);
+ }
+ }
+
+
+ /**
+ * Discourage caching of a response.
+ * OAuth 2.1 ยง3.2.3
+ * The authorization server MUST include the HTTP Cache-Control response
+ * header field with a value of no-store in any response
+ * containing tokens, credentials, or other sensitive information.
+ * @param {http.ServerResponse} res
+ */
+ static _sensitiveResponse(res) {
+ Object.entries({
+ [Enum.Header.CacheControl]: 'no-store',
+ [Enum.Header.Pragma]: 'no-cache',
+ }).forEach(([k, v]) => res.setHeader(k, v));
+ }
+
+
+ /**
+ * Sets params entries as url search parameters.
+ * @param {URL} url
+ * @param {Object} params
+ */
+ static _setSearchParams(url, params) {
+ Object.entries(params).forEach((param) => url.searchParams.set(...param));
+ }
+
+
+ /**
+ * Serve the informational root page.
+ * @param {http.ClientRequest} req
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async getRoot(res, ctx) {
+ const _scope = _fileScope('getRoot');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ res.end(Template.rootHTML(ctx, this.options));
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Serve the metadata for this service.
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async getMeta(res, ctx) {
+ const _scope = _fileScope('getMeta');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ const base = this.options.dingus.selfBaseUrl;
+ const endpoint = (r) => `${base}${this.options.route[r]}`; // eslint-disable-line security/detect-object-injection
+
+ const metadata = {
+ issuer: base,
+ 'authorization_endpoint': endpoint('authorization'),
+ 'token_endpoint': endpoint('token'),
+ ...(this.queuePublisher && { 'ticket_endpoint': endpoint('ticket') }),
+ 'introspection_endpoint': endpoint('introspection'),
+ 'introspection_endpoint_auth_methods_supported': ['Bearer'],
+ 'revocation_endpoint': endpoint('revocation'),
+ 'revocation_endpoint_auth_methods_supported': ['none'],
+ 'scopes_supported': ['profile', 'email'], // only advertise minimum IA scopes
+ 'response_types_supported': 'code',
+ 'grant_types_supported': [
+ 'authorization_code',
+ 'refresh_token',
+ ...(this.queuePublisher && ['ticket'] || []),
+ ],
+ 'service_documentation': 'https://indieauth.spec.indieweb.org/',
+ 'code_challenge_methods_supported': supportedCodeChallengeMethods,
+ 'authorization_response_iss_parameter_supported': true,
+ 'userinfo_endpoint': endpoint('userinfo'),
+ };
+
+ res.end(JSON.stringify(metadata));
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Process an authorization request from a client.
+ * User has authenticated, check if user matches profile,
+ * present user with consent form.
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async getAuthorization(res, ctx) {
+ const _scope = _fileScope('getAuthorization');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ ctx.session = Object.assign({}, ctx.session, {
+ errorDescriptions: [],
+ });
+
+ // Ingest and validate expected data, populating ctx.session.
+ await this._clientIdRequired(ctx);
+ Manager._redirectURIRequired(ctx);
+ Manager._responseTypeRequired(ctx);
+ Manager._stateRequired(ctx);
+ this._codeChallengeMethodRequired(ctx);
+ this._codeChallengeRequired(ctx);
+ this._scopeOptional(ctx);
+ await this._meOptional(ctx);
+
+ if (!ctx.session.clientIdentifier || !ctx.session.redirectUri) {
+ // Do not redirect if either of these fields were invalid, just report error.
+ this.logger.debug(_scope, 'invalid request, not redirecting', { ctx });
+
+ // Set error response for template to render.
+ ctx.errors.push('Cannot redirect to client application.');
+ ctx.errorContent = [
+ 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
+ ];
+ res.statusCode = 400;
+ res.end(Template.authorizationErrorHTML(ctx, this.options));
+ this.logger.info(_scope, 'bad request', { ctx });
+ return;
+ }
+
+ await this.db.context(async (dbCtx) => {
+ const profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, ctx.authenticationId);
+ Object.assign(ctx.session, {
+ profiles: [],
+ profileScopes: {},
+ scopeIndex: {},
+ }, profilesScopes);
+ }); // dbCtx
+
+ if (!ctx.session.profiles.length) {
+ this.logger.error(_scope, 'identifier has no profiles', { ctx });
+ Manager._setError(ctx, 'access_denied', 'Profile not valid for the authenticated user.');
+ }
+
+ if (!this._profileValidForIdentifier(ctx)) {
+ // if the hinted profile supplied in me does not match any known
+ // profile mappings for the authenticated identifier, remove the
+ // hint. UI will prompt to choose from available profiles.
+ this.logger.debug(_scope, 'removing provided me hint, not valid for identifier', { ctx });
+ delete ctx.session.me;
+ }
+
+ // Ugly support logic for allowing legacy non-pkce requests, for the micropub.rocks site until it is updated.
+ // Require both be missing to qualify as a legacy request, otherwise still fail.
+ const isMissingBothPKCE = (!ctx.session.codeChallengeMethod) && (!ctx.session.codeChallenge);
+ if (isMissingBothPKCE && this.options.manager.allowLegacyNonPKCE) {
+ 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>');
+ } else {
+ if (!ctx.session.codeChallenge) {
+ Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_challenge\'');
+ }
+ if (!ctx.session.codeChallengeMethod) {
+ Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
+ }
+ }
+
+ // If anything went wrong, redirect with error report.
+ if (ctx.session.error) {
+ // Valid redirect_url and client_id, errors hop back to them.
+ this.logger.debug(_scope, 'invalid request, redirecting', { ctx });
+
+ Manager._setSearchParams(ctx.session.redirectUri, {
+ 'state': ctx.session.state,
+ 'error': ctx.session.error,
+ 'error_description': ctx.session.errorDescriptions.join(', '),
+ });
+ res.statusCode = 302; // Found
+ res.setHeader(Enum.Header.Location, ctx.session.redirectUri.href);
+ res.end();
+ this.logger.info(_scope, 'bad request', { ctx });
+ return;
+ }
+
+ // Store the current state of this session, to be forwarded on to consent processing.
+ // This blob will be passed on as a form field in consent response.
+ ctx.session.persist = await this.mysteryBox.pack({
+ id: common.requestId(), // codeId in database
+ clientId: ctx.session.clientId.href,
+ clientIdentifier: ctx.session.clientIdentifier,
+ redirectUri: ctx.session.redirectUri.href,
+ responseType: ctx.session.responseType,
+ state: ctx.session.state,
+ codeChallengeMethod: ctx.session.codeChallengeMethod,
+ codeChallenge: ctx.session.codeChallenge,
+ me: ctx.session.me,
+ profiles: ctx.session.profiles,
+ requestedScopes: ctx.session.scope,
+ authenticationId: ctx.authenticationId,
+ });
+
+ // Present authenticated user the option to submit consent
+ const content = Template.authorizationRequestHTML(ctx, this.options);
+ res.end(content);
+
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Validates, fetches, and parses client_id url, populating clientIdentifier with client h-app data.
+ * @param {Object} ctx
+ */
+ async _clientIdRequired(ctx) {
+ if (ctx.queryParams['client_id']) {
+ try {
+ ctx.session.clientId = await this.communication.validateClientIdentifier(ctx.queryParams['client_id']);
+ ctx.session.clientIdentifier = await this.communication.fetchClientIdentifier(ctx.session.clientId);
+ if (!ctx.session.clientIdentifier) {
+ Manager._setError(ctx, 'invalid_request', 'invalid client_id: could not fetch');
+ throw new ValidationError('could not fetch');
+ }
+ } catch (e) {
+ ctx.session.clientId = undefined;
+ if (e instanceof CommunicationErrors.ValidationError) {
+ Manager._setError(ctx, 'invalid_request', e.message);
+ }
+ Manager._setError(ctx, 'invalid_request', 'invalid value for parameter \'client_id\'');
+ }
+ } else {
+ Manager._setError(ctx, 'invalid_request', 'missing required parameter \'client_id\'');
+ }
+ }
+
+
+ /**
+ * Ensure redirect_uri exists and is corroborated by clientIdentifier data.
+ * @param {Object} ctx
+ */
+ static _redirectURIRequired(ctx) {
+ if (ctx.queryParams['redirect_uri']) {
+ try {
+ ctx.session.redirectUri = new URL(ctx.queryParams['redirect_uri']);
+
+ if (ctx.session.clientId) {
+ // Either all these parts must match, or a specific alternative must be specified.
+ const redirectMatchesClientId = ['protocol', 'hostname', 'port']
+ .map((p) => ctx.session.redirectUri[p] == ctx.session.clientId[p]) // eslint-disable-line security/detect-object-injection
+ .reduce((acc, match) => acc && match, true);
+
+ // Check for alternate redirect_uri entries on client_id data if no initial match
+ if (!redirectMatchesClientId) {
+ const validRedirectUris = ctx.session?.clientIdentifier?.['rels']?.['redirect_uri'] || [];
+ if (!validRedirectUris.includes(ctx.session.redirectUri.href)) {
+ Manager._setError(ctx, 'invalid_request', 'redirect_uri not valid for that client_id');
+ // Remove invalid redirect_uri from session; doing this eases error routing.
+ ctx.session.redirectUri = undefined;
+ }
+ }
+ }
+ } catch (e) {
+ Manager._setError(ctx, 'invalid_request', 'invalid value for parameter \'redirect_uri\'');
+ }
+ } else {
+ Manager._setError(ctx, 'invalid_request', 'missing required parameter \'redirect_uri\'');
+ }
+ }
+
+
+ /**
+ * response_type must be valid
+ * @param {Object} ctx
+ */
+ static _responseTypeRequired(ctx) {
+ ctx.session.responseType = ctx.queryParams['response_type'];
+ if (ctx.session.responseType) {
+ // Must be one of these types
+ if (!['code'].includes(ctx.session.responseType)) {
+ Manager._setError(ctx, 'unsupported_response_type', 'invalid value for parameter \'response_type\'');
+ }
+ } else {
+ Manager._setError(ctx, 'invalid_request', 'missing required parameter \'response_type\'');
+ }
+ }
+
+
+ /**
+ * A state parameter must be present
+ * @param {Object} ctx
+ */
+ static _stateRequired(ctx) {
+ ctx.session.state = ctx.queryParams['state'];
+ if (ctx.session.state) {
+ // No restrictions on content of this
+ } else {
+ Manager._setError(ctx, 'invalid_request', 'missing required parameter \'state\'');
+ }
+ }
+
+
+ /**
+ * A code_challenge_method must be present and valid
+ * @param {Object} ctx
+ */
+ _codeChallengeMethodRequired(ctx) {
+ ctx.session.codeChallengeMethod = ctx.queryParams['code_challenge_method'];
+ if (ctx.session.codeChallengeMethod) {
+ if (!supportedCodeChallengeMethods.includes(ctx.session.codeChallengeMethod)) {
+ Manager._setError(ctx, 'invalid_request', 'unsupported code_challenge_method');
+ }
+ } else {
+ if (this.options.manager.allowLegacyNonPKCE) {
+ return;
+ }
+ Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_challenge_method\'');
+ }
+ }
+
+
+ /**
+ * A code_challenge must be present
+ * @param {Object} ctx
+ */
+ _codeChallengeRequired(ctx) {
+ ctx.session.codeChallenge = ctx.queryParams['code_challenge'];
+ if (ctx.session.codeChallenge) {
+ if (!validBase64URLRE.test(ctx.session.codeChallenge)) {
+ Manager._setError(ctx, 'invalid_request', 'invalid value for parameter \'code_challenge\'');
+ }
+ } else {
+ if (this.options.manager.allowLegacyNonPKCE) {
+ return;
+ }
+ Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_challenge\'');
+ }
+ }
+
+
+ /**
+ * Scopes may be present, with one known combination limitation
+ * @param {Object} ctx
+ */
+ _scopeOptional(ctx) {
+ const _scope = _fileScope('_scopeOptional');
+ const scope = ctx.queryParams['scope'];
+ ctx.session.scope = [];
+ if (scope) {
+ const allScopes = scope.split(scopeSplitRE);
+ const validScopes = allScopes.filter((s) => common.validScope(s));
+ ctx.session.scope.push(...validScopes);
+ if (allScopes.length != validScopes.length) {
+ const invalidScopes = allScopes.filter((s) => !common.validScope(s));
+ this.logger.debug(_scope, 'client requested invalid scope', { ctx, invalidScopes });
+ }
+ }
+ // If email scope is requested, profile scope must also be explicitly requested.
+ if (ctx.session.scope.includes('email')
+ && !ctx.session.scope.includes('profile')) {
+ Manager._setError(ctx, 'invalid_scope', 'cannot provide \'email\' scope without \'profile\' scope');
+ }
+ }
+
+
+ /**
+ * Parses me, if provided
+ * @param {Object} ctx
+ */
+ async _meOptional(ctx) {
+ const me = ctx.queryParams['me'];
+ if (me) {
+ try {
+ ctx.session.me = await this.communication.validateProfile(me);
+ } catch (e) {
+ ctx.session.me = undefined;
+ }
+ }
+ }
+
+
+ /**
+ * Ensure authenticated identifier matches profile.
+ * @param {Object} ctx
+ * @returns {Boolean}
+ */
+ _profileValidForIdentifier(ctx) {
+ const _scope = _fileScope('_profileValidForIdentifier');
+
+ if (!ctx.session.me) {
+ this.logger.debug(_scope, 'no profile provided, cannot correlate', { ctx });
+ return false;
+ }
+
+ return ctx.session.profiles.includes(ctx.session.me.href);
+ }
+
+
+ /**
+ * Get numeric value from form field data.
+ * @param {*} ctx
+ * @param {String} field
+ * @param {String} customField
+ * @returns {Number=}
+ */
+ _parseLifespan(ctx, field, customField) {
+ const _scope = _fileScope('_parseLifespan');
+
+ const presetValues = {
+ 'never': undefined,
+ '1d': 86400,
+ '1w': 86400 * 7,
+ '1m': 86400 * 31,
+ };
+ const fieldValue = ctx.parsedBody[field]; // eslint-disable-line security/detect-object-injection
+ if (fieldValue in presetValues) {
+ return presetValues[fieldValue]; // eslint-disable-line security/detect-object-injection
+ }
+
+ if (fieldValue === 'custom') {
+ const expiresSeconds = parseInt(ctx.parsedBody[customField], 10); // eslint-disable-line security/detect-object-injection
+ if (isFinite(expiresSeconds) && expiresSeconds > 0) {
+ return expiresSeconds;
+ } else {
+ this.logger.debug(_scope, 'invalid custom value', { ctx, field, customField });
+ }
+ }
+
+ this.logger.debug(_scope, 'invalid value', { ctx, field, customField });
+ return undefined;
+ }
+
+
+ /**
+ * Validate any accepted scopes, ensure uniqueness, return as array.
+ * @param {Object} ctx
+ * @returns {String=}
+ */
+ _parseConsentScopes(ctx) {
+ const _scope = _fileScope('_ingestConsentScopes');
+ const acceptedScopesSet = new Set();
+ const rejectedScopesSet = new Set();
+
+ const submittedScopes = common.ensureArray(ctx.parsedBody['accepted_scopes'])
+ .concat((ctx.parsedBody['ad_hoc_scopes'] || '').split(scopeSplitRE));
+ submittedScopes.forEach((scope) => {
+ if (scope) {
+ (common.validScope(scope) ? acceptedScopesSet : rejectedScopesSet).add(scope);
+ }
+ });
+
+ // If email scope was accepted but profile was not, elide email scope
+ if (acceptedScopesSet.has('email')
+ && !acceptedScopesSet.has('profile')) {
+ acceptedScopesSet.delete('email');
+ rejectedScopesSet.add('email (without profile)');
+ }
+
+ if (rejectedScopesSet.size) {
+ this.logger.debug(_scope, 'ignoring invalid scopes', { ctx, rejectedScopes: Array.from(rejectedScopesSet) });
+ }
+
+ return Array.from(acceptedScopesSet);
+ }
+
+
+ /**
+ * Parse and validate selected me is a valid profile option.
+ * @param {Object} ctx
+ * @returns {URL}
+ */
+ _parseConsentMe(ctx) {
+ const _scope = _fileScope('_parseConsentMe');
+ const selectedMe = ctx.parsedBody['me'];
+ try {
+ const me = new URL(selectedMe);
+ if (ctx.session.profiles.includes(me.href)) {
+ return me;
+ } else {
+ this.logger.debug(_scope, 'selected \'me\' profile not among available', { me, available: ctx.session.profiles, ctx });
+ Manager._setError(ctx, 'invalid_request', 'invalid profile url');
+ }
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to parse selected \'me\' as url', { error: e, ctx });
+ Manager._setError(ctx, 'invalid_request', 'invalid profile url');
+ }
+ return undefined;
+ }
+
+
+ /**
+ * Get up-to-date profile data from selected profile endpoint.
+ * @param {Object} ctx
+ * @returns {Object}
+ */
+ async _fetchConsentProfileData(ctx) {
+ const _scope = _fileScope('_fetchConsentProfileData');
+ try {
+ const profile = await this.communication.fetchProfile(ctx.session.me);
+ if (!profile) {
+ this.logger.debug(_scope, 'no profile data at \'me\' endpoint', { ctx });
+ Manager._setError(ctx, 'temporarily_unavailable', 'unable to retrieve profile');
+ } else {
+ // Profile info gets persisted in code, only include known profile fields to help keep size down.
+ return common.pick(profile, [
+ 'name',
+ 'photo',
+ 'url',
+ 'email',
+ ]);
+ }
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to fetch \'me\' endpoint', { error: e, ctx });
+ Manager._setError(ctx, 'temporarily_unavailable', 'could not reach profile endpoint');
+ }
+ return undefined;
+ }
+
+
+ /**
+ * Ingest user consent response details, redirect as needed.
+ * Receives POST request from consent page, expecting these form fields:
+ * session - encrypted data collected from initial auth call
+ * accept - 'true' if consent was granted
+ * accepted_scopes - list of scopes to grant
+ * ad_hoc_scopes - additional scopes specified by user
+ * me - selected profile to identify as
+ * expires - optional lifespan
+ * expires-seconds - optional custom lifespan
+ * refresh - optional refresh lifespan
+ * refresh-seconds - optional custom refresh lifespan
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async postConsent(res, ctx) {
+ const _scope = _fileScope('postConsent');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ // Ensure session exists, persisting any login session data.
+ ctx.session = Object.assign({}, ctx.session);
+ try {
+ // Recover the session established on initial auth request.
+ const oldSession = await this.mysteryBox.unpack(ctx.parsedBody['session']);
+ Object.assign(ctx.session, oldSession);
+ ctx.session.redirectUri = new URL(ctx.session.redirectUri);
+ ctx.session.clientId = new URL(ctx.session.clientId);
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to unpack session', { error: e, ctx });
+ Manager._setError(ctx, 'invalid_request', 'un-parsable data in authorization consent');
+ }
+
+ // If these are missing, we cannot proceed.
+ if (!ctx.session.clientId || !ctx.session.redirectUri) {
+ // Set error response for html template to render.
+ ctx.errors = [
+ 'Cannot redirect to client application.',
+ ];
+ ctx.errorContent = [
+ 'There was an error in the request sent by the application attempting to authenticate you. Check with that service.',
+ ];
+ res.statusCode = 400;
+ res.end(Template.authorizationErrorHTML(ctx, this.options));
+ this.logger.info(_scope, 'bad request, cannot redirect', { ctx });
+ return;
+ }
+
+ // TODO: Should probably re-validate more unpacked session values, even though those should be trustable.
+
+ // Check if we need to check anything else.
+ ctx.session.accept = (ctx.parsedBody['accept'] === 'true');
+ if (!ctx.session.accept) {
+ this.logger.debug(_scope, 'consent denied', { ctx });
+ Manager._setError(ctx, 'access_denied', 'authorization was not granted');
+ } else {
+ // Ingest form data.
+ ctx.session.acceptedScopes = this._parseConsentScopes(ctx);
+ ctx.session.me = this._parseConsentMe(ctx);
+ ctx.session.profile = await this._fetchConsentProfileData(ctx);
+ ctx.session.tokenLifespan = this._parseLifespan(ctx, 'expires', 'expires-seconds');
+ if (ctx.session.tokenLifespan) {
+ ctx.session.refreshLifespan = this._parseLifespan(ctx, 'refresh', 'refresh-seconds');
+ }
+ }
+
+ if (ctx.session.error) {
+ this.logger.debug(_scope, 'invalid request, redirecting', { ctx });
+
+ // Set all errors as parameters for client to interpret upon redirection.
+ Manager._setSearchParams(ctx.session.redirectUri, {
+ 'state': ctx.session.state,
+ 'error': ctx.session.error,
+ 'error_description': ctx.session.errorDescriptions.join(', '),
+ });
+ res.statusCode = 302; // Found
+ res.setHeader(Enum.Header.Location, ctx.session.redirectUri.href);
+ res.end();
+ this.logger.info(_scope, 'bad request, redirected', { ctx });
+ return;
+ }
+
+ // Consented, off we go. Keep all this session state as the code.
+ const code = await this.mysteryBox.pack({
+ codeId: ctx.session.id,
+ codeChallengeMethod: ctx.session.codeChallengeMethod,
+ codeChallenge: ctx.session.codeChallenge,
+ clientId: ctx.session.clientId.href,
+ redirectUri: ctx.session.redirectUri.href,
+ acceptedScopes: ctx.session.acceptedScopes,
+ tokenLifespan: ctx.session.tokenLifespan,
+ refreshLifespan: ctx.session.refreshLifespan,
+ me: ctx.session.me.href,
+ profile: ctx.session.profile,
+ identifier: ctx.session.authenticatedIdentifier, // need this to pair with profile
+ minted: Date.now(),
+ });
+
+ Manager._setSearchParams(ctx.session.redirectUri, {
+ 'code': code,
+ 'state': ctx.session.state,
+ 'iss': this.options.dingus.selfBaseUrl,
+ });
+ res.statusCode = 302;
+ res.setHeader(Enum.Header.Location, ctx.session.redirectUri.href);
+ res.end();
+
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Redeem a code for a profile url, and maybe more profile info.
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async postAuthorization(res, ctx) {
+ const _scope = _fileScope('postAuthorization');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ await this._ingestPostAuthorizationRequest(ctx);
+
+ const response = {
+ me: ctx.session.me,
+ ...(ctx.session?.acceptedScopes?.includes('profile') && { profile: ctx.session.profile }),
+ scope: ctx.session.acceptedScopes,
+ };
+ if (response.profile && !ctx.session?.acceptedScopes?.includes('email')) {
+ delete response.profile.email;
+ }
+
+ if (!ctx.session.error) {
+ await this.db.context(async (dbCtx) => {
+ // Record code redemption without token.
+ const valid = await this.db.redeemCode(dbCtx, {
+ codeId: ctx.session.codeId,
+ created: new Date(),
+ isToken: false,
+ clientId: ctx.session.clientId.href,
+ profile: ctx.session.me,
+ identifier: ctx.session.identifier,
+ scopes: ctx.session.acceptedScopes,
+ lifespanSeconds: Math.ceil(this.options.manager.codeValidityTimeoutMs / 1000),
+ profileData: response.profile,
+ });
+ if (!valid) {
+ this.logger.debug(_scope, 'code already redeemed', { ctx });
+ Manager._setError(ctx, 'access_denied', 'code already redeemed');
+ }
+ }); // dbCtx
+ }
+
+ if (ctx.session.error) {
+ res.statusCode = 400;
+ res.end(JSON.stringify({
+ 'error': ctx.session.error,
+ 'error_description': ctx.session.errorDescriptions.join(', '),
+ }));
+ this.logger.info(_scope, 'invalid request', { ctx });
+ return;
+ }
+
+ res.end(JSON.stringify(response));
+
+ this.logger.info(_scope, 'finished', { ctx, response });
+ }
+
+
+ /**
+ * Ingest an incoming authorization redemption request, parsing fields
+ * onto a new session object on the context.
+ * @param {*} dbCtx
+ * @param {Object} ctx
+ */
+ async _ingestPostAuthorizationRequest(ctx) {
+ const _scope = _fileScope('_ingestPostAuthorizationRequest');
+
+ ctx.session = Object.assign({}, ctx.session, {
+ errorDescriptions: [],
+ });
+
+ if (!ctx.parsedBody) {
+ this.logger.debug(_scope, 'no body data', { ctx });
+ Manager._setError(ctx, 'invalid_request', 'missing data');
+ }
+
+ await this._restoreSessionFromCode(ctx);
+ this._checkSessionMatchingClientId(ctx);
+ this._checkSessionMatchingRedirectUri(ctx);
+ this._checkGrantType(ctx);
+ this._checkSessionMatchingCodeVerifier(ctx);
+
+ if (!ctx.session.me || !ctx.session.minted) {
+ this.logger.debug(_scope, 'session missing fields', { ctx });
+ Manager._setError(ctx, 'invalid_request', 'malformed code');
+ return;
+ }
+
+ const expires = new Date(ctx.session.minted + this.options.manager.codeValidityTimeoutMs);
+ const now = new Date();
+ if (expires < now) {
+ this.logger.debug(_scope, 'code expired', { ctx });
+ Manager._setError(ctx, 'invalid_request', 'code has expired');
+ }
+ }
+
+
+ /**
+ * Unpack the session data from provided code overtop of context session ..
+ * @param {Object} ctx
+ */
+ async _restoreSessionFromCode(ctx) {
+ const _scope = _fileScope('_restoreSessionFromCode');
+
+ const code = ctx.parsedBody['code'];
+ if (code) {
+ try {
+ const oldSession = await this.mysteryBox.unpack(code);
+
+ // TODO: Validate unpacked fields better
+ const missingFields = [
+ 'codeId',
+ 'codeChallengeMethod',
+ 'codeChallenge',
+ 'clientId',
+ 'redirectUri',
+ 'acceptedScopes',
+ 'me',
+ 'profile',
+ 'identifier',
+ 'minted',
+ ].filter((requiredField) => !(requiredField in oldSession));
+ if (missingFields.length) {
+ if (this.options.manager.allowLegacyNonPKCE
+ && missingFields.length === 2
+ && missingFields.includes('codeChallenge')
+ && missingFields.includes('codeChallengeMethod')) {
+ this.logger.debug(_scope, 'allowing legacy non-PKCE session', { ctx });
+ } else {
+ this.logger.debug(_scope, 'unpacked code is missing required field', { missingFields, ctx });
+ Manager._setError(ctx, 'invalid_request', 'code is not valid');
+ }
+ }
+
+ Object.assign(ctx.session, oldSession);
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to parse code', { error: e, ctx });
+ Manager._setError(ctx, 'invalid_request', 'code is not valid');
+ }
+ } else {
+ Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code\'');
+ }
+ }
+
+
+ /**
+ * Ensure provided client_id matches session clientId.
+ * @param {Object} ctx
+ */
+ _checkSessionMatchingClientId(ctx) {
+ const _scope = _fileScope('_checkSessionMatchingClientId');
+
+ let clientId = ctx.parsedBody['client_id'];
+ if (clientId) {
+ try {
+ clientId = new URL(clientId);
+ ctx.session.clientId = new URL(ctx.session.clientId);
+ } catch (e) {
+ this.logger.debug(_scope, 'un-parsable client_id url', { ctx });
+ delete ctx.session.clientId;
+ Manager._setError(ctx, 'invalid_request', 'malformed client_id');
+ return;
+ }
+ if (clientId.href !== ctx.session.clientId.href) {
+ this.logger.debug(_scope, 'clientId mismatched', { clientId, ctx });
+ delete ctx.session.clientId;
+ Manager._setError(ctx, 'invalid_request', 'code does not belong to that client_id');
+ }
+ } else {
+ Manager._setError(ctx, 'invalid_request', 'missing required parameter \'client_id\'');
+ }
+ }
+
+
+ /**
+ * @param {Object} ctx
+ */
+ _checkSessionMatchingRedirectUri(ctx) {
+ const _scope = _fileScope('_checkSessionMatchingClientId');
+
+ let redirectUri = ctx.parsedBody['redirect_uri'];
+ if (redirectUri) {
+ try {
+ redirectUri = new URL(redirectUri);
+ ctx.session.redirectUri = new URL(ctx.session.redirectUri);
+ } catch (e) {
+ this.logger.debug(_scope, 'un-parsable redirect_uri url', { ctx });
+ delete ctx.session.redirectUri;
+ Manager._setError(ctx, 'invalid_request', 'malformed redirect_url');
+ return;
+ }
+ if (redirectUri.href !== ctx.session.redirectUri.href) {
+ this.logger.debug(_scope, 'redirectUri mismatched', { redirectUri, ctx });
+ delete ctx.session.redirectUri;
+ Manager._setError(ctx, 'invalid_request', 'code does not belong to that redirect_uri');
+ }
+ } else {
+ Manager._setError(ctx, 'invalid_request', 'missing required parameter \'redirect_uri\'');
+ }
+ }
+
+
+ /**
+ * Validate grant_type, either persist on session or set error.
+ * @param {Object} ctx
+ * @param {String[]} validGrantTypes
+ * @param {Boolean} treatEmptyAs
+ */
+ _checkGrantType(ctx, validGrantTypes = ['authorization_code'], treatEmptyAs = 'authorization_code') {
+ const _scope = _fileScope('_checkGrantType');
+
+ const grantType = ctx.parsedBody['grant_type'] || treatEmptyAs;
+ if (!ctx.parsedBody['grant_type'] && treatEmptyAs) {
+ this.logger.debug(_scope, `missing grant_type, treating as ${treatEmptyAs}`, { ctx });
+ }
+ if (validGrantTypes.includes(grantType)) {
+ ctx.session.grantType = grantType;
+ } else {
+ Manager._setError(ctx, 'invalid_request', 'grant_type not supported');
+ }
+ }
+
+
+ /**
+ * @param {Object} ctx
+ */
+ _checkSessionMatchingCodeVerifier(ctx) {
+ const _scope = _fileScope('_checkSessionMatchingCodeVerifier');
+
+ const codeVerifier = ctx.parsedBody['code_verifier'];
+ if (codeVerifier) {
+ try {
+ const valid = Communication.verifyChallenge(ctx.session.codeChallenge, codeVerifier, ctx.session.codeChallengeMethod);
+ if (!valid) {
+ this.logger.debug(_scope, 'challenge mismatched', { ctx });
+ Manager._setError(ctx, 'invalid_request', 'challenge verification failed');
+ }
+ } catch (e) /* istanbul ignore next */ {
+ this.logger.error(_scope, 'challenge validation failed', { error: e, ctx });
+ Manager._setError(ctx, 'invalid_request', 'challenge verification failed');
+ }
+ } else {
+ if (this.options.manager.allowLegacyNonPKCE
+ && !ctx.session.codeChallenge
+ && !ctx.session.codeChallengeMethod) {
+ this.logger.debug(_scope, 'allowing non-PKCE', { ctx });
+ return;
+ }
+ Manager._setError(ctx, 'invalid_request', 'missing required parameter \'code_verifier\'');
+ }
+ }
+
+
+ /**
+ * Attempt to revoke a token.
+ * @param {*} dbCtx
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async _revokeToken(dbCtx, res, ctx) {
+ const _scope = _fileScope('_revokeToken');
+ try {
+ const token = ctx.parsedBody['token'];
+ const tokenTypeHint = ctx.parsedBody['token_type_hint'];
+ switch (tokenTypeHint) {
+ case undefined:
+ break;
+ case 'access_token':
+ break;
+ case 'refresh_token':
+ break;
+ default:
+ this.logger.debug(_scope, 'unknown token_type_hint', { ctx });
+ }
+ if (!token) {
+ throw new ValidationError('Token Missing');
+ }
+ ctx.token = await this.mysteryBox.unpack(token);
+ if (!(ctx.token?.c || ctx.token?.rc)) {
+ throw new ValidationError('Token Invalid');
+ }
+ } catch (e) {
+ this.logger.debug(_scope, 'invalid token', { error: e, ctx });
+ res.statusCode = 400;
+ res.end();
+ this.logger.info(_scope, 'finished, revoke request not valid', { error: e, ctx });
+ return;
+ }
+
+ try {
+ if (ctx.token.c) {
+ await this.db.tokenRevokeByCodeId(dbCtx, ctx.token.c);
+ } else {
+ await this.db.tokenRefreshRevokeByCodeId(dbCtx, ctx.token.rc);
+ }
+ } catch (e) {
+ if (e instanceof DBErrors.UnexpectedResult) {
+ res.statusCode = 404;
+ res.end();
+ this.logger.info(_scope, 'finished, no token to revoke', { error: e, ctx });
+ return;
+ }
+ this.logger.error(_scope, 'revoke token failed', { error: e, ctx });
+ throw e;
+ }
+
+ res.end();
+ this.logger.info(_scope, 'finished, token revoked', { ctx });
+ }
+
+
+ /**
+ * Legacy token validation flow.
+ * @param {*} dbCtx
+ * @param {http.ClientRequest} req
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async _validateToken(dbCtx, req, res, ctx) {
+ const _scope = _fileScope('_validateToken');
+ await this._checkTokenValidationRequest(dbCtx, req, ctx);
+ if (ctx.bearer.isValid) {
+ Manager._sensitiveResponse(res);
+ res.end(JSON.stringify({
+ me: ctx.token.profile,
+ 'client_id': ctx.token.clientId,
+ scope: ctx.token.scopes,
+ }));
+ this.logger.info(_scope, 'finished, token validated', { ctx });
+ } else {
+ const responseErrorParts = ['Bearer'];
+ const error = ctx.session.error ? `error="${ctx.session.error}"` : '';
+ if (error) {
+ responseErrorParts.push(error);
+ }
+ const errorDescription = ctx.session.errorDescriptions ? `error_description="${ctx.session.errorDescriptions.join(', ')}"` : '';
+ if (errorDescription) {
+ responseErrorParts.push(errorDescription);
+ }
+ res.setHeader(Enum.Header.WWWAuthenticate, responseErrorParts.join(', '));
+ this.logger.info(_scope, 'finished, token not validated', { ctx });
+ throw new ResponseError(Enum.ErrorResponse.Unauthorized);
+ }
+ }
+
+
+ /**
+ * Given a list of newly-requested scopes, return a list of scopes
+ * from previousScopes which are not in requestedScopes.
+ * @param {String[]} previousScopes
+ * @param {String[]} requestedScopes
+ * @returns {String[]}
+ */
+ static _scopeDifference(previousScopes, requestedScopes) {
+ const scopesToRemove = [];
+ const existingScopesSet = new Set(previousScopes);
+ const validRequestedScopes = requestedScopes.filter((s) => common.validScope(s));
+ const requestedScopesSet = new Set(validRequestedScopes);
+ existingScopesSet.forEach((s) => {
+ if (!requestedScopesSet.has(s)) {
+ scopesToRemove.push(s);
+ }
+ });
+ return scopesToRemove;
+ }
+
+
+ /**
+ * Redeem a refresh token for a new token.
+ * @param {*} dbCtx
+ * @param {http.ClientRequest} req
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async _refreshToken(dbCtx, req, res, ctx) {
+ const _scope = _fileScope('_refreshToken');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ const {
+ 'client_id': clientId,
+ scope,
+ } = ctx.parsedBody;
+
+ try {
+ ctx.refreshToken = await this.mysteryBox.unpack(ctx.parsedBody['refresh_token']);
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to unpack token', { error: e, ctx });
+ }
+
+ const now = new Date();
+ const nowEpoch = common.dateToEpoch(now);
+
+ await this.db.transaction(dbCtx, async (txCtx) => {
+ if (ctx.refreshToken?.rc) {
+ ctx.token = await this.db.tokenGetByCodeId(txCtx, ctx.refreshToken.rc);
+ }
+
+ if (!ctx.token) {
+ this.logger.debug(_scope, 'no token to refresh', { ctx });
+ throw new ResponseError(Enum.ErrorResponse.NotFound);
+ }
+
+ if (!ctx.token.refreshExpires
+ || ctx.token.refreshExpires < now) {
+ this.logger.debug(_scope, 'token not refreshable or refresh expired', { ctx });
+ throw new ResponseError(Enum.ErrorResponse.BadRequest);
+ }
+
+ const refreshExpiresEpoch = common.dateToEpoch(ctx.token.refreshExpires);
+ if (ctx.refreshToken.exp < refreshExpiresEpoch) {
+ this.logger.debug(_scope, 'token already refreshed', { ctx });
+ throw new ResponseError(Enum.ErrorResponse.BadRequest);
+ }
+
+ if (clientId !== ctx.token.clientId) {
+ this.logger.debug(_scope, 'client identifier mismatch', { ctx });
+ throw new ResponseError(Enum.ErrorResponse.BadRequest);
+ }
+
+ const scopesToRemove = scope ? Manager._scopeDifference(ctx.token.scopes, scope.split(scopeSplitRE)) : [];
+ if (scopesToRemove.length) {
+ this.logger.debug(_scope, 'scope reduction requested', { ctx, scopesToRemove });
+ }
+
+ const refreshedTokenData = await this.db.refreshCode(txCtx, ctx.refreshToken.rc, now, scopesToRemove);
+ if (refreshedTokenData) {
+ Object.assign(ctx.token, refreshedTokenData);
+ } else {
+ this.logger.debug(_scope, 'could not refresh token', { ctx });
+ throw new ResponseError(Enum.ErrorResponse.NotFound);
+ }
+ }); // tx
+
+ const [token, refreshToken] = await Promise.all([
+ {
+ c: ctx.token.codeId,
+ ts: nowEpoch,
+ },
+ {
+ rc: ctx.token.codeId,
+ ts: nowEpoch,
+ exp: nowEpoch + ctx.token.refreshDuration,
+ },
+ ].map(this.mysteryBox.pack));
+
+ const response = {
+ 'access_token': token,
+ 'token_type': 'Bearer',
+ ...(ctx.token.duration && { 'expires_in': nowEpoch + ctx.token.duration }),
+ ...(refreshToken && { 'refresh_token': refreshToken }),
+ scope: ctx.token.scopes.join(' '),
+ me: ctx.session.me,
+ ...(ctx.token.scopes.includes('profile') && { profile: ctx.token.profileData }),
+ };
+ if (ctx.token.scopes.includes('profile') && !ctx.token.scopes.includes('email')) {
+ delete response?.profile?.email;
+ }
+
+ Manager._sensitiveResponse(res);
+ res.end(JSON.stringify(response));
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Generate a new ticket for later redemption.
+ * @param {Object} payload
+ * @param {} payload.subject deliver ticket to this endpoint
+ * @param {} payload.resource url the redeemed ticket is valid for accessing
+ * @param {String[]} payload.scopes list of scopes assigned to ticket
+ * @param {String} payload.identifier user generating ticket
+ * @param {} payload.profile profile of user generating ticket
+ * @param {Number} payload.ticketLifespanSeconds ticket redeemable for this long
+ * @returns {String}
+ */
+ async _mintTicket({ subject, resource, scopes, identifier, profile, ticketLifespanSeconds }) {
+ const _scope = _fileScope('_mintTicket');
+ this.logger.debug(_scope, 'called', { subject, resource, scopes, identifier, profile, ticketLifespanSeconds });
+
+ const nowEpoch = common.dateToEpoch();
+ return this.mysteryBox.pack({
+ c: common.requestId(),
+ iss: nowEpoch,
+ exp: nowEpoch + ticketLifespanSeconds,
+ sub: subject,
+ res: resource,
+ scope: scopes,
+ ident: identifier,
+ profile: profile,
+ });
+ }
+
+
+ /**
+ * @typedef Ticket
+ * @property {String} codeId
+ * @property {Date} issued
+ * @property {Date} expires
+ * @property {URL} subject
+ * @property {URL} resource
+ * @property {String[]} scopes
+ * @property {String} identifier
+ * @property {URL} profile
+ */
+ /**
+ *
+ * @param {String} ticket
+ * @returns {Ticket}
+ */
+ async _unpackTicket(ticket) {
+ const ticketObj = await this.mysteryBox.unpack(ticket);
+ return {
+ codeId: ticketObj.c,
+ issued: new Date(ticketObj.iss * 1000),
+ expires: new Date(ticketObj.exp * 1000),
+ subject: new URL(ticketObj.sub),
+ resource: new URL(ticketObj.res),
+ scopes: ticketObj.scope,
+ identifier: ticketObj.ident,
+ profile: new URL(ticketObj.profile),
+ };
+ }
+
+
+ /**
+ * Redeem a ticket for a token.
+ * @param {*} dbCtx
+ * @param {http.ClientRequest} req
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async _ticketAuthToken(dbCtx, req, res, ctx) {
+ const _scope = _fileScope('_ticketAuthToken');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ try {
+ ctx.ticket = await this._unpackTicket(ctx.parsedBody['ticket']);
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to unpack ticket', { error: e, ctx });
+ throw new ResponseError(Enum.ErrorResponse.BadRequest);
+ }
+
+ const now = new Date();
+ if (now > ctx.ticket.expires) {
+ this.logger.debug(_scope, 'ticket has expired', { ctx });
+ throw new ResponseError(Enum.ErrorResponse.Forbidden, { reason: 'Ticket has expired.', expired: ctx.ticket.expires });
+ }
+
+ const nowEpoch = common.dateToEpoch(now);
+ const token = await this.mysteryBox.pack({
+ c: ctx.ticket.codeId,
+ ts: nowEpoch,
+ });
+
+ const response = {
+ 'access_token': token,
+ 'token_type': 'Bearer',
+ scope: ctx.ticket.scopes.join(' '),
+ me: ctx.ticket.profile.href,
+ };
+
+ const isValid = await this.db.redeemCode(dbCtx, {
+ created: now,
+ codeId: ctx.ticket.codeId,
+ isToken: true,
+ clientId: ctx.ticket.subject.href,
+ resource: ctx.ticket.resource.href,
+ profile: ctx.ticket.profile.href,
+ identifier: ctx.ticket.identifier,
+ scopes: ctx.ticket.scopes,
+ });
+ if (!isValid) {
+ this.logger.debug(_scope, 'redemption failed, already redeemed', { ctx });
+ throw new ResponseError(Enum.ErrorResponse.Forbidden);
+ }
+
+ Manager._sensitiveResponse(res);
+ res.end(JSON.stringify(response));
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Redeem a code for a token.
+ * @param {*} dbCtx
+ * @param {http.ClientRequest} req
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async _codeToken(dbCtx, req, res, ctx) {
+ const _scope = _fileScope('_codeToken');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ await this._restoreSessionFromCode(ctx);
+ await this._checkSessionMatchingRedirectUri(ctx);
+
+ if (ctx.session.error) {
+ throw new ResponseError(Enum.ErrorResponse.BadRequest);
+ }
+
+ /**
+ * Note the creation date here rather than in database, so that stored
+ * expire dates are ensured to match those packed in tokens.
+ * An alternative would be to return the date generated by the database,
+ * but then we would need to hold the transaction open while minting the
+ * tokens to ensure success. Perhaps that would be worth it, but for now
+ * this is how it is.
+ */
+ const now = new Date();
+ const nowEpoch = common.dateToEpoch(now);
+ const tokenMinters = [];
+
+ tokenMinters.push(this.mysteryBox.pack({
+ c: ctx.session.codeId,
+ ts: nowEpoch,
+ ...(ctx.session.tokenLifespan && { exp: nowEpoch + ctx.session.tokenLifespan }),
+ }));
+
+ if (ctx.session.tokenLifespan
+ && ctx.session.refreshLifespan) {
+ tokenMinters.push(this.mysteryBox.pack({
+ rc: ctx.session.codeId,
+ ts: nowEpoch,
+ exp: nowEpoch + ctx.session.refreshLifespan,
+ }));
+ }
+
+ const [token, refreshToken] = await Promise.all(tokenMinters);
+
+ const response = {
+ 'access_token': token,
+ 'token_type': 'Bearer',
+ ...(ctx.session.tokenLifespan && { 'expires_in': nowEpoch + ctx.session.tokenLifespan }),
+ ...(refreshToken && { 'refresh_token': refreshToken }),
+ scope: ctx.session.acceptedScopes.join(' '),
+ me: ctx.session.me,
+ ...(ctx.session.acceptedScopes.includes('profile') && { profile: ctx.session.profile }),
+ };
+ if (!ctx.session.acceptedScopes.includes('email') && response.profile) {
+ delete response.profile.email;
+ }
+
+ const isValid = await this.db.redeemCode(dbCtx, {
+ created: now,
+ codeId: ctx.session.codeId,
+ isToken: true,
+ clientId: ctx.session.clientId,
+ profile: ctx.session.me,
+ identifier: ctx.session.identifier,
+ scopes: ctx.session.acceptedScopes,
+ lifespanSeconds: ctx.session.tokenLifespan,
+ refreshLifespanSeconds: ctx.session.refreshLifespan,
+ profileData: response.profile,
+ });
+ if (!isValid) {
+ this.logger.debug(_scope, 'redemption failed, already redeemed', { ctx });
+ throw new ResponseError(Enum.ErrorResponse.Forbidden);
+ }
+
+ Manager._sensitiveResponse(res);
+ res.end(JSON.stringify(response));
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Issue, refresh, or validate a token.
+ * @param {http.ClientRequest} req
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async postToken(req, res, ctx) {
+ const _scope = _fileScope('postToken');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ ctx.session = Object.assign({}, ctx.session);
+
+ await this.db.context(async (dbCtx) => {
+
+ // Is this a (legacy) revocation request?
+ if (ctx.parsedBody['action'] === 'revoke') {
+ await this._revokeToken(dbCtx, res, ctx);
+ return;
+ }
+
+ // Is this a (legacy) token validation request?
+ if (req.getHeader(Enum.Header.Authorization)) {
+ await this._validateToken(dbCtx, res, ctx);
+ return;
+ }
+
+ const validGrantTypes = [
+ 'authorization_code',
+ 'refresh_token',
+ ...(this.queuePublisher && ['ticket'] || []),
+ ];
+ this._checkGrantType(ctx, validGrantTypes, 'authorization_code');
+
+ switch (ctx.session.grantType) {
+ case 'refresh_token':
+ return this._refreshToken(dbCtx, req, res, ctx);
+
+ case 'ticket':
+ return this._ticketAuthToken(dbCtx, req, res, ctx);
+
+ case 'authorization_code':
+ return this._codeToken(dbCtx, req, res, ctx);
+
+ default:
+ this.logger.debug(_scope, 'unknown grant_type', { ctx });
+ Manager._setError(ctx, 'invalid_request', 'grant_type not supported');
+ }
+
+ // Only way of getting here is due to error.
+ throw new ResponseError(Enum.ErrorResponse.BadRequest);
+ }); // dbCtx
+ }
+
+
+ /**
+ * Ingest token from authorization header, setting ctx.bearer.isValid appropriately.
+ * ctx.bearer not set if auth method not recognized.
+ * This is for legacy validation on token endpoint.
+ * @param {*} dbCtx
+ * @param {http.ClientRequest} req
+ * @param {Object} ctx
+ */
+ async _checkTokenValidationRequest(dbCtx, req, ctx) {
+ const _scope = _fileScope('_checkTokenValidationRequest');
+ const authHeader = req.getHeader(Enum.Header.Authorization);
+
+ if (authHeader) {
+ const [authMethod, authString] = common.splitFirst(authHeader, ' ', '');
+ switch (authMethod.toLowerCase()) { // eslint-disable-line sonarjs/no-small-switch
+ case 'bearer': {
+ ctx.bearer = {
+ isValid: false,
+ };
+ try {
+ Object.assign(ctx.bearer, await this.mysteryBox.unpack(authString));
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to unpack token', { ctx });
+ Manager._setError(ctx, 'invalid_request', 'invalid token');
+ return;
+ }
+ if (!ctx.bearer.c) {
+ this.logger.debug(_scope, 'incomplete token', { ctx });
+ Manager._setError(ctx, 'invalid_request', 'invalid token');
+ return;
+ }
+
+ try {
+ ctx.token = await this.db.tokenGetByCodeId(dbCtx, ctx.bearer.c);
+ } catch (e) {
+ this.logger.error(_scope, 'failed to look up token', { error: e, ctx });
+ throw e;
+ }
+
+ if (!ctx.token) {
+ this.logger.debug(_scope, 'no token found', { ctx });
+ Manager._setError(ctx, 'invalid_request', 'invalid token');
+ return;
+ }
+
+ if (!ctx.token.isRevoked
+ && ctx.token.expires > new Date()) {
+ ctx.bearer.isValid = true;
+ }
+ break;
+ }
+
+ default:
+ this.logger.debug(_scope, 'unknown authorization scheme', { ctx });
+ return;
+ }
+ }
+ }
+
+
+ /**
+ * Accept an unsolicited ticket proffering.
+ * @param {http.ClientRequest} req
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async postTicket(req, res, ctx) {
+ const _scope = _fileScope('postTicket');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ if (!this.queuePublisher) {
+ this.logger.debug(_scope, 'ticket endpoint not configured', { ctx });
+ throw new ResponseError(Enum.ErrorResponse.BadRequest);
+ }
+
+ const queueName = this.options.queues.ticketPublishName;
+ const { ticket, resource, subject } = ctx.parsedBody;
+
+ try {
+ new URL(resource);
+ } catch (e) {
+ this.logger.debug(_scope, 'unparsable resource', { ticket, resource, subject, ctx });
+ throw new ResponseError(Enum.ErrorResponse.BadRequest);
+ }
+
+ await this.db.context(async (dbCtx) => {
+ const isValidProfile = await this.db.profileIsValid(dbCtx, subject);
+ if (!isValidProfile) {
+ this.logger.debug(_scope, 'invalid subject', { ticket, resource, subject, ctx });
+ throw new ResponseError(Enum.ErrorResponse.NotFound, { error: 'subject not under our purview' });
+ }
+
+ try {
+ const result = await this.queuePublisher.publish(queueName, { ticket, resource, subject });
+ this.logger.debug(_scope, 'accepted ticket offer', { queueName, ticket, resource, subject, ctx, result });
+ } catch (e) {
+ this.logger.error(_scope, 'failed to publish ticket to queue', { error: e, queueName, ticket, resource, subject, ctx });
+ throw e; // return a 500
+ }
+
+ res.statusCode = 202;
+ res.end();
+ this.logger.info(_scope, 'finished', { resource, subject, ctx });
+ });
+ }
+
+
+ /**
+ * Validate a token and return data about it.
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async postIntrospection(res, ctx) {
+ const _scope = _fileScope('postIntrospection');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ let response = {
+ active: false,
+ };
+
+ const tokenIsTicket = (ctx.parsedBody['token_hint_type'] || '').toLowerCase() === 'ticket';
+
+ try {
+ const token = ctx.parsedBody['token'];
+ if (tokenIsTicket) {
+ ctx.token = await this._unpackTicket(token);
+ } else {
+ ctx.token = await this.mysteryBox.unpack(token);
+ }
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to unpack token', { error: e, ctx });
+ }
+
+ if (ctx.token
+ && !tokenIsTicket) {
+ await this.db.context(async (dbCtx) => {
+ ctx.token = await this.db.tokenGetByCodeId(dbCtx, ctx.token.c);
+ }); // dbCtx
+ }
+
+ if (ctx.token
+ && !ctx.token.isRevoked) {
+ // fuss around for postgres 'Infinity' date
+ const expiresMs = (ctx.token.expires instanceof Date) ? ctx.token.expires.getTime() : ctx.token.expires;
+ if (expiresMs > Date.now()) {
+ response = {
+ active: true,
+ me: ctx.token.profile,
+ ...(ctx.token.clientId && { 'client_id': ctx.token.clientId }),
+ scope: ctx.token.scopes.join(' '),
+ iat: common.dateToEpoch(ctx.token.created || ctx.token.issued),
+ ...(isFinite(expiresMs) && { exp: Math.ceil(expiresMs / 1000) }),
+ ...(tokenIsTicket && { 'token_type': 'ticket' }),
+ };
+ }
+ }
+
+ Manager._sensitiveResponse(res);
+ res.end(JSON.stringify(response));
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Revoke a token or refresh token.
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async postRevocation(res, ctx) {
+ const _scope = _fileScope('postRevocation');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ try {
+ await this.db.context(async (dbCtx) => {
+ await this._revokeToken(dbCtx, res, ctx);
+ });
+ } catch (e) {
+ this.logger.error(_scope, 'failed', { error: e, ctx });
+ throw e;
+ }
+
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Profile information for a token.
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async postUserInfo(res, ctx) {
+ const _scope = _fileScope('postUserInfo');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ const token = ctx.parsedBody['token'];
+ if (!token) {
+ res.statusCode = 400;
+ res.end('"invalid_request"');
+ this.logger.info(_scope, 'finished, invalid request', { ctx });
+ return;
+ }
+
+ try {
+ ctx.token = await this.mysteryBox.unpack(ctx.parsedBody['token']);
+ } catch (e) {
+ this.logger.debug(_scope, 'failed to unpack token', { error: e, ctx });
+ }
+
+ if (ctx.token) {
+ await this.db.context(async (dbCtx) => {
+ ctx.token = await this.db.tokenGetByCodeId(dbCtx, ctx.token.c);
+ }); // dbCtx
+ }
+
+ if (!ctx.token
+ || ctx.token.isRevoked
+ // || tokenIsExpired(token)
+ ) {
+ res.statusCode = 401;
+ res.end('"invalid_token"');
+ this.logger.info(_scope, 'finished, invalid token', { ctx });
+ return;
+ }
+
+ if (!ctx.token.scopes.includes('profile')) {
+ res.statusCode = 403;
+ res.end('"insufficient_scope"');
+ this.logger.info(_scope, 'finished, insufficient scope', { ctx });
+ return;
+ }
+
+ const response = {
+ ...ctx.token.profile,
+ };
+ if (!ctx.token.scopes.includes('email')) {
+ delete response.email;
+ }
+
+ Manager._sensitiveResponse(res);
+ res.end(JSON.stringify(response));
+
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Show admin interface, allowing manipulation of profiles and scopes.
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async getAdmin(res, ctx) {
+ const _scope = _fileScope('getAdmin');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ const identifier = ctx.session.authenticatedIdentifier;
+
+ await this.db.context(async (dbCtx) => {
+ ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
+ ctx.tokens = await this.db.tokensGetByIdentifier(dbCtx, identifier);
+ }); // dbCtx
+
+ res.end(Template.adminHTML(ctx, this.options));
+
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Process admin interface events.
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async postAdmin(res, ctx) {
+ const _scope = _fileScope('postAdmin');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ await this.db.context(async (dbCtx) => {
+ const identifier = ctx.session.authenticatedIdentifier;
+ const action = ctx?.parsedBody?.['action'] || '';
+
+ if (action === 'save-scopes') {
+ // Update the convenience scopes set for profiles.
+ // Expect 'scopes-<profile>' with value of array of scopes
+ const profileKeys = ctx.parsedBody && Object.keys(ctx.parsedBody)
+ .filter((k) => k.startsWith('scopes-'));
+ try {
+ await this.db.transaction(dbCtx, async (txCtx) => {
+ await Promise.all(
+ /* For each scopes-profile submitted, set those. */
+ profileKeys.map((profileKey) => {
+ /* elide 'scope-' prefix to get the profile */
+ const profile = profileKey.slice(7);
+ /* (should validate profile here) */
+
+ /* remove invalid scopes from submitted list */
+ const scopes = ctx.parsedBody[profileKey].filter((scope) => scope && common.validScope(scope)); // eslint-disable-line security/detect-object-injection
+ return this.db.profileScopesSetAll(txCtx, profile, scopes);
+ }),
+ );
+ }); // txCtx
+ ctx.notifications.push('Profile/Scope Availability Matrix updated!');
+ } catch (e) {
+ this.logger.error(_scope, 'did not set profile scopes', { error: e, ctx });
+ ctx.errors.push('Failed to update profile scopes.');
+ }
+
+ } else if (action === 'new-profile') {
+ // Validate and create a new profile uri.
+ let profile;
+ const profileUri = ctx.parsedBody['profile'];
+ try {
+ profile = await this.communication.validateProfile(profileUri);
+ } catch (e) {
+ this.logger.debug(_scope, 'invalid profile url', { error: e, ctx });
+ ctx.errors.push(`'${profileUri}' is not a valid profile URI.${(e instanceof CommunicationErrors.ValidationError) ? ('(' + e.message + ')') : ''}`);
+ }
+ if (profile) {
+ // Validate profile uri
+ const profileData = await this.communication.fetchProfile(profile);
+ if (profileData.metadata.authorizationEndpoint !== this.selfAuthorizationEndpoint) {
+ this.logger.debug(_scope, 'profile does not list this server as auth', { profileData, ctx });
+ ctx.errors.push('Requested profile does not list this service, not adding.');
+ } else {
+ try {
+ await this.db.transaction(dbCtx, async (txCtx) => {
+ await this.db.profileIdentifierInsert(txCtx, profile.href, identifier);
+ await this.db.profileScopesSetAll(txCtx, profile.href, ['profile', 'email']);
+ }); // txCtx
+ ctx.notifications.push('Profile added!');
+ } catch (e) {
+ this.logger.error(_scope, 'did not insert profile', { error: e, ctx });
+ ctx.errors.push('Failed to add profile.');
+ }
+ }
+ }
+
+ } else if (action === 'new-scope') {
+ // Add or update a manually-added convenience scope.
+ const { scope, application = '', description = '' } = ctx.parsedBody;
+ if (scope) {
+ if (!common.validScope(scope)) {
+ ctx.errors.push(`"${scope}" is not a valid scope name, did not add it.`);
+ } else {
+ try {
+ await this.db.scopeUpsert(dbCtx, scope, application, description, true);
+ ctx.notifications.push('Scope List updated!');
+ } catch (e) {
+ this.logger.error(_scope, 'did not upsert scope', { error: e, scope, application, description, ctx });
+ ctx.errors.push('Failed to update scope.');
+ }
+ }
+ }
+
+ } else if (action.startsWith('delete-scope-')) {
+ // Remove a manually-added convenience scope.
+ const scope = decodeURIComponent(action.slice(13));
+ if (scope) {
+ try {
+ const deleted = await this.db.scopeDelete(dbCtx, scope);
+ if (deleted) {
+ ctx.notifications.push('Scope deleted.');
+ } else {
+ ctx.notifications.push('Unable to delete scope.');
+ }
+ } catch (e) {
+ this.logger.error(_scope, 'did not delete scope', { error: e, scope, ctx });
+ ctx.errors.push('Failed to delete scope.');
+ }
+ }
+
+ } else if (action.startsWith('revoke-')) {
+ // Revoke an active token.
+ const codeId = action.slice(8);
+ if (codeId) {
+ try {
+ await this.db.tokenRevokeByCodeId(dbCtx, codeId, identifier);
+ ctx.notifications.push('Revoked token!');
+ } catch (e) {
+ this.logger.error(_scope, 'did not revoke token', { error: e, codeId, identifier, ctx });
+ ctx.errors.push('Unable to revoke token.');
+ }
+ }
+
+ } else if (action) {
+ ctx.errors.push(`Do not know how to '${action}'.`);
+ }
+
+ ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
+ ctx.tokens = await this.db.tokensGetByIdentifier(dbCtx, identifier);
+ }); // dbCtx
+
+ res.end(Template.adminHTML(ctx, this.options));
+
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Show ticket proffer interface.
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async getAdminTicket(res, ctx) {
+ const _scope = _fileScope('getAdminTicket');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ const identifier = ctx.session.authenticatedIdentifier;
+
+ await this.db.context(async (dbCtx) => {
+ ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
+ ctx.profiles = ctx.profilesScopes.profiles;
+ ctx.scopes = Object.keys(ctx.profilesScopes.scopeIndex);
+ }); // dbCtx
+
+ res.end(Template.adminTicketHTML(ctx, this.options));
+
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Handle ticket proffer interface submission.
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async postAdminTicket(res, ctx) {
+ const _scope = _fileScope('postAdminTicket');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ switch (ctx.parsedBody['action']) { // eslint-disable-line sonarjs/no-small-switch
+ case 'proffer-ticket': {
+ const identifier = ctx.session.authenticatedIdentifier;
+ [
+ { ctxProp: 'ticketProfileUrl', bodyParam: 'profile', err: 'Invalid Profile URL selected.' },
+ { ctxProp: 'ticketResourceUrl', bodyParam: 'resource', err: 'Invalid Resource URL.' },
+ { ctxProp: 'ticketSubjectUrl', bodyParam: 'subject', err: 'Invalid Recipient URL.' },
+ ].forEach((param) => {
+ try {
+ ctx[param.ctxProp] = new URL(ctx.parsedBody[param.bodyParam]);
+ } catch (e) {
+ this.logger.debug(_scope, `invalid ${param.bodyParam}`, { ctx });
+ ctx.errors.push(param.err);
+ }
+ });
+
+ const subjectData = await this.communication.fetchProfile(ctx.ticketSubjectUrl);
+ if (!subjectData?.metadata?.ticketEndpoint) {
+ this.logger.debug(_scope, 'subject has no ticket endpoint', { ctx });
+ ctx.errors.push('Recipient does not list a ticket endpoint to deliver to.');
+ } else {
+ try {
+ ctx.ticketEndpointUrl = new URL(subjectData.metadata.ticketEndpoint);
+ } catch (e) {
+ this.logger.debug(_scope, 'subject has invalid ticket endpoint', { error: e, ctx });
+ ctx.errors.push(`Recipient lists an invalid ticket endpoint, cannot deliver. (${e})`);
+ }
+ }
+
+ const scopesSet = new Set();
+ const rawScopes = [
+ ...(common.ensureArray(ctx.parsedBody['scopes'])),
+ ...((ctx.parsedBody['adhoc'] || '').split(scopeSplitRE)),
+ ].filter((scope) => scope);
+ rawScopes.forEach((scope) => {
+ if (common.validScope(scope)) {
+ scopesSet.add(scope);
+ } else {
+ this.logger.debug(_scope, 'invalid adhoc scope', { scope, ctx });
+ ctx.errors.push(`'${scope}' is not a valid scope.`);
+ }
+ });
+ ctx.ticketScopes = [...scopesSet];
+ const actionScopes = ctx.ticketScopes.filter((scope) => !['profile', 'email'].includes(scope));
+ if (!actionScopes.length) {
+ this.logger.debug(_scope, 'no valid scopes included', { ctx });
+ ctx.errors.push('At least one actionable scope must be included.');
+ }
+
+ if (!ctx.errors.length) {
+ const ticketData = {
+ subject: ctx.ticketSubjectUrl.href,
+ resource: ctx.ticketResourceUrl.href,
+ scopes: ctx.ticketScopes,
+ identifier,
+ profile: ctx.ticketProfileUrl.href,
+ ticketLifespanSeconds: this.options.manager.ticketLifespanSeconds,
+ };
+ const ticket = await this._mintTicket(ticketData);
+
+ await this.db.context(async (dbCtx) => {
+ // re-populate form fields
+ ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
+
+ // TODO: queue ticket for delivery/retry to subject instead of trying immediately
+ // ctx.notifications.push('Success! Ticket will be delivered!');
+
+ this.logger.debug(_scope, 'ticket created', { ctx, ticketData, subjectData });
+
+ try {
+ const result = await this.communication.deliverTicket(ctx.ticketEndpointUrl, ctx.ticketResourceUrl, ctx.ticketSubjectUrl, ticket);
+ ctx.notifications.push(`Success! Ticket was delivered. (${result?.statusText})`);
+ this.logger.info(_scope, 'ticket delivered', { ctx, result });
+ } catch (e) {
+ this.logger.error(_scope, 'failed to deliver ticket', { ctx, error: e });
+ ctx.errors.push(`Failed to deliver ticket. (${e})`);
+ }
+
+ }); // dbCtx
+
+ } else {
+ // populate form fields again
+ await this.db.context(async (dbCtx) => {
+ ctx.profilesScopes = await this.db.profilesScopesByIdentifier(dbCtx, identifier);
+ ctx.scopes = Object.keys(ctx.profilesScopes.scopeIndex);
+ }); // dbCtx
+ }
+
+ break;
+ }
+
+ default:
+ this.logger.debug(_scope, 'unknown action', { ctx });
+ }
+
+ res.end(Template.adminTicketHTML(ctx, this.options));
+
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ * Report on generally uninteresting backend information.
+ * Also allow a few event invocations.
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async getAdminMaintenance(res, ctx) {
+ const _scope = _fileScope('getAdminMaintenance');
+ this.logger.debug(_scope, 'called', { ctx });
+
+ const maintenanceTasks = [];
+
+ await this.db.context(async (dbCtx) => {
+
+ Object.values(Enum.Chore).forEach((chore) => {
+ if (chore in ctx.queryParams) {
+ maintenanceTasks.push(
+ this.chores.runChore(chore, 0), // Provide arg to force chore run.
+ );
+ ctx.notifications.push(`Running maintenance chore "${chore}".`);
+ }
+ });
+
+ await Promise.all(maintenanceTasks);
+
+ ctx.almanac = await this.db.almanacGetAll(dbCtx);
+ }); // dbCtx
+
+ const winnowChoreEntry = ([name, value]) => [name, common.pick(value, ['intervalMs', 'nextSchedule'])];
+ ctx.chores = Object.fromEntries(
+ Object.entries(this.chores.chores).map(winnowChoreEntry),
+ );
+
+ res.end(Template.adminMaintenanceHTML(ctx, this.options));
+
+ this.logger.info(_scope, 'finished', { ctx });
+ }
+
+
+ /**
+ *
+ * @param {http.ServerResponse} res
+ * @param {Object} ctx
+ */
+ async getHealthcheck(res, ctx) {
+ const _scope = _fileScope('getHealthcheck');
+ this.logger.debug(_scope, 'called', { ctx });
+ await this.db.healthCheck();
+ res.end();
+ }
+
+}
+
+module.exports = Manager;
\ No newline at end of file