X-Git-Url: http://git.squeep.com/?p=squeep-indie-auther;a=blobdiff_plain;f=src%2Fmanager.js;fp=src%2Fmanager.js;h=6c16414adc6853cea2e90a9089292689cb11cb5d;hp=0000000000000000000000000000000000000000;hb=b0103b0d496262c438b40bc20304081dbfe41e73;hpb=8ed81748bce7cea7904cac7225b20a60cafdfc16 diff --git a/src/manager.js b/src/manager.js new file mode 100644 index 0000000..6c16414 --- /dev/null +++ b/src/manager.js @@ -0,0 +1,2015 @@ +'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('
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!
'); + } 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-' 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