X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=src%2Fmanager.js;h=ce544b949a90603ef09b8d7448410aa264fcb9e0;hb=f0bf29c75b0fd405ff92fa76f058e61162b87e43;hp=0d4302772b7405ce4539cb55640d5a3b9da011a1;hpb=8d74fc28d91c47cdaab2f2d0f2e811d1feab18f9;p=squeep-indie-auther diff --git a/src/manager.js b/src/manager.js index 0d43027..ce544b9 100644 --- a/src/manager.js +++ b/src/manager.js @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/no-duplicate-string */ 'use strict'; const common = require('./common'); @@ -8,7 +9,7 @@ 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 { Publisher: QueuePublisher, Consumer: QueueConsumer } = require('@squeep/amqp-helper'); const _fileScope = common.fileScope(__filename); @@ -18,17 +19,23 @@ const scopeSplitRE = / +/; const supportedCodeChallengeMethods = ['S256', 'SHA256']; +/** + * @typedef {import('node:http')} http + */ + 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.queueConsumer = new QueueConsumer(logger, options.queues.amqp); } - this.mysteryBox = new MysteryBox(logger, options); + this.chores = new Chores(logger, db, this.queuePublisher, options); + this.mysteryBox = new MysteryBox(options); + this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name))); // We need to know how the outside world sees us, to verify if a // profile indicates us as the auth server. @@ -50,18 +57,24 @@ class Manager { async _connectQueues() { await this.queuePublisher.connect(); await this.queuePublisher.establishAMQPPlumbing(this.options.queues.ticketPublishName); + await this.queuePublisher.establishAMQPPlumbing(this.options.queues.ticketRedeemedName); + + await this.queueConsumer.connect(); + await this.queueConsumer.establishAMQPPlumbing(this.options.queues.ticketPublishName); + const boundTicketProcessor = this.queuedTicketProcessor.bind(this); + await this.queueConsumer.consume(this.options.queues.ticketPublishName, boundTicketProcessor); } /** * 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 + * @param {object} ctx context + * @param {object} ctx.session session + * @param {string[]=} ctx.session.errorDescriptions errors + * @param {string=} ctx.session.error error + * @param {string} error error + * @param {string} errorDescription error */ static _setError(ctx, error, errorDescription) { const errorPrecedence = [ // By increasing severity @@ -101,7 +114,7 @@ class Manager { * 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 + * @param {http.ServerResponse} res response */ static _sensitiveResponse(res) { Object.entries({ @@ -113,8 +126,8 @@ class Manager { /** * Sets params entries as url search parameters. - * @param {URL} url - * @param {Object} params + * @param {URL} url url + * @param {object} params parameters */ static _setSearchParams(url, params) { Object.entries(params).forEach((param) => url.searchParams.set(...param)); @@ -123,9 +136,8 @@ class Manager { /** * Serve the informational root page. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async getRoot(res, ctx) { const _scope = _fileScope('getRoot'); @@ -138,8 +150,8 @@ class Manager { /** * Serve the metadata for this service. - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async getMeta(res, ctx) { const _scope = _fileScope('getMeta'); @@ -179,8 +191,8 @@ class Manager { * 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 + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async getAuthorization(res, ctx) { const _scope = _fileScope('getAuthorization'); @@ -188,9 +200,10 @@ class Manager { Manager._sensitiveResponse(res); - ctx.session = Object.assign({}, ctx.session, { + ctx.session = { + ...ctx.session, errorDescriptions: [], - }); + }; // Ingest and validate expected data, populating ctx.session. await this._clientIdRequired(ctx); @@ -297,7 +310,7 @@ class Manager { /** * Validates, fetches, and parses client_id url, populating clientIdentifier with client h-app data. - * @param {Object} ctx + * @param {object} ctx context */ async _clientIdRequired(ctx) { if (ctx.queryParams['client_id']) { @@ -323,7 +336,7 @@ class Manager { /** * Ensure redirect_uri exists and is corroborated by clientIdentifier data. - * @param {Object} ctx + * @param {object} ctx context */ static _redirectURIRequired(ctx) { if (ctx.queryParams['redirect_uri']) { @@ -346,7 +359,7 @@ class Manager { } } } - } catch (e) { + } catch (e) { // eslint-disable-line no-unused-vars Manager._setError(ctx, 'invalid_request', 'invalid value for parameter \'redirect_uri\''); } } else { @@ -357,7 +370,7 @@ class Manager { /** * response_type must be valid - * @param {Object} ctx + * @param {object} ctx context */ static _responseTypeRequired(ctx) { ctx.session.responseType = ctx.queryParams['response_type']; @@ -374,7 +387,7 @@ class Manager { /** * A state parameter must be present - * @param {Object} ctx + * @param {object} ctx context */ static _stateRequired(ctx) { ctx.session.state = ctx.queryParams['state']; @@ -388,7 +401,7 @@ class Manager { /** * A code_challenge_method must be present and valid - * @param {Object} ctx + * @param {object} ctx context */ _codeChallengeMethodRequired(ctx) { ctx.session.codeChallengeMethod = ctx.queryParams['code_challenge_method']; @@ -407,7 +420,7 @@ class Manager { /** * A code_challenge must be present - * @param {Object} ctx + * @param {object} ctx context */ _codeChallengeRequired(ctx) { ctx.session.codeChallenge = ctx.queryParams['code_challenge']; @@ -426,7 +439,7 @@ class Manager { /** * Scopes may be present, with one known combination limitation - * @param {Object} ctx + * @param {object} ctx context */ _scopeOptional(ctx) { const _scope = _fileScope('_scopeOptional'); @@ -451,14 +464,14 @@ class Manager { /** * Parses me, if provided - * @param {Object} ctx + * @param {object} ctx context */ async _meOptional(ctx) { const me = ctx.queryParams['me']; if (me) { try { ctx.session.me = await this.communication.validateProfile(me); - } catch (e) { + } catch (e) { // eslint-disable-line no-unused-vars ctx.session.me = undefined; } } @@ -467,8 +480,8 @@ class Manager { /** * Ensure authenticated identifier matches profile. - * @param {Object} ctx - * @returns {Boolean} + * @param {object} ctx context + * @returns {boolean} is valid */ _profileValidForIdentifier(ctx) { const _scope = _fileScope('_profileValidForIdentifier'); @@ -484,10 +497,10 @@ class Manager { /** * Get numeric value from form field data. - * @param {*} ctx - * @param {String} field - * @param {String} customField - * @returns {Number=} + * @param {*} ctx context + * @param {string} field field + * @param {string} customField custom field + * @returns {number=} lifespan */ _parseLifespan(ctx, field, customField) { const _scope = _fileScope('_parseLifespan'); @@ -519,15 +532,15 @@ class Manager { /** * Validate any accepted scopes, ensure uniqueness, return as array. - * @param {Object} ctx - * @returns {String=} + * @param {object} ctx context + * @returns {string[]} scopes */ _parseConsentScopes(ctx) { const _scope = _fileScope('_ingestConsentScopes'); const acceptedScopesSet = new Set(); const rejectedScopesSet = new Set(); - const submittedScopes = common.ensureArray(ctx.parsedBody['accepted_scopes']) + const submittedScopes = common.ensureArray(ctx.parsedBody['accepted_scopes[]']) .concat((ctx.parsedBody['ad_hoc_scopes'] || '').split(scopeSplitRE)); submittedScopes.forEach((scope) => { if (scope) { @@ -552,8 +565,8 @@ class Manager { /** * Parse and validate selected me is a valid profile option. - * @param {Object} ctx - * @returns {URL} + * @param {object} ctx context + * @returns {URL} url */ _parseConsentMe(ctx) { const _scope = _fileScope('_parseConsentMe'); @@ -576,8 +589,8 @@ class Manager { /** * Get up-to-date profile data from selected profile endpoint. - * @param {Object} ctx - * @returns {Object} + * @param {object} ctx context + * @returns {Promise} profile data */ async _fetchConsentProfileData(ctx) { const _scope = _fileScope('_fetchConsentProfileData'); @@ -608,15 +621,15 @@ class Manager { * 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 + * 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 + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async postConsent(res, ctx) { const _scope = _fileScope('postConsent'); @@ -625,7 +638,9 @@ class Manager { Manager._sensitiveResponse(res); // Ensure session exists, persisting any login session data. - ctx.session = Object.assign({}, ctx.session); + ctx.session = { + ...ctx.session, + }; try { // Recover the session established on initial auth request. const oldSession = await this.mysteryBox.unpack(ctx.parsedBody['session']); @@ -717,8 +732,8 @@ class Manager { /** * Redeem a code for a profile url, and maybe more profile info. - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async postAuthorization(res, ctx) { const _scope = _fileScope('postAuthorization'); @@ -775,15 +790,16 @@ class Manager { /** * Ingest an incoming authorization redemption request, parsing fields * onto a new session object on the context. - * @param {*} dbCtx - * @param {Object} ctx + * @param {object} ctx context + * @returns {Promise} */ async _ingestPostAuthorizationRequest(ctx) { const _scope = _fileScope('_ingestPostAuthorizationRequest'); - ctx.session = Object.assign({}, ctx.session, { + ctx.session = { + ...ctx.session, errorDescriptions: [], - }); + }; if (!ctx.parsedBody) { this.logger.debug(_scope, 'no body data', { ctx }); @@ -813,7 +829,7 @@ class Manager { /** * Unpack the session data from provided code overtop of context session .. - * @param {Object} ctx + * @param {object} ctx context */ async _restoreSessionFromCode(ctx) { const _scope = _fileScope('_restoreSessionFromCode'); @@ -861,7 +877,7 @@ class Manager { /** * Ensure provided client_id matches session clientId. - * @param {Object} ctx + * @param {object} ctx context */ _checkSessionMatchingClientId(ctx) { const _scope = _fileScope('_checkSessionMatchingClientId'); @@ -871,7 +887,7 @@ class Manager { try { clientId = new URL(clientId); ctx.session.clientId = new URL(ctx.session.clientId); - } catch (e) { + } catch (e) { // eslint-disable-line no-unused-vars this.logger.debug(_scope, 'un-parsable client_id url', { ctx }); delete ctx.session.clientId; Manager._setError(ctx, 'invalid_request', 'malformed client_id'); @@ -889,7 +905,7 @@ class Manager { /** - * @param {Object} ctx + * @param {object} ctx context */ _checkSessionMatchingRedirectUri(ctx) { const _scope = _fileScope('_checkSessionMatchingClientId'); @@ -899,7 +915,7 @@ class Manager { try { redirectUri = new URL(redirectUri); ctx.session.redirectUri = new URL(ctx.session.redirectUri); - } catch (e) { + } catch (e) { // eslint-disable-line no-unused-vars this.logger.debug(_scope, 'un-parsable redirect_uri url', { ctx }); delete ctx.session.redirectUri; Manager._setError(ctx, 'invalid_request', 'malformed redirect_url'); @@ -918,9 +934,9 @@ class Manager { /** * Validate grant_type, either persist on session or set error. - * @param {Object} ctx - * @param {String[]} validGrantTypes - * @param {Boolean} treatEmptyAs + * @param {object} ctx context + * @param {string[]} validGrantTypes grant types + * @param {string=} treatEmptyAs grant type */ _checkGrantType(ctx, validGrantTypes = ['authorization_code'], treatEmptyAs = 'authorization_code') { const _scope = _fileScope('_checkGrantType'); @@ -938,7 +954,7 @@ class Manager { /** - * @param {Object} ctx + * @param {object} ctx context */ _checkSessionMatchingCodeVerifier(ctx) { const _scope = _fileScope('_checkSessionMatchingCodeVerifier'); @@ -969,9 +985,9 @@ class Manager { /** * Attempt to revoke a token. - * @param {*} dbCtx - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {*} dbCtx db context + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async _revokeToken(dbCtx, res, ctx) { const _scope = _fileScope('_revokeToken'); @@ -1027,10 +1043,10 @@ class Manager { /** * Legacy token validation flow. - * @param {*} dbCtx - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {*} dbCtx db context + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async _validateToken(dbCtx, req, res, ctx) { const _scope = _fileScope('_validateToken'); @@ -1063,9 +1079,9 @@ class Manager { /** * 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[]} + * @param {string[]} previousScopes scopes + * @param {string[]} requestedScopes scopes + * @returns {string[]} scopes */ static _scopeDifference(previousScopes, requestedScopes) { const scopesToRemove = []; @@ -1083,10 +1099,10 @@ class Manager { /** * Redeem a refresh token for a new token. - * @param {*} dbCtx - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {*} dbCtx db context + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async _refreshToken(dbCtx, req, res, ctx) { const _scope = _fileScope('_refreshToken'); @@ -1180,15 +1196,15 @@ class Manager { /** * 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} - */ + * @param {object} payload payload + * @param {string} payload.subject deliver ticket to this endpoint + * @param {string} 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 {string} payload.profile profile of user generating ticket + * @param {number} payload.ticketLifespanSeconds ticket redeemable for this long + * @returns {Promise} ticket + */ async _mintTicket({ subject, resource, scopes, identifier, profile, ticketLifespanSeconds }) { const _scope = _fileScope('_mintTicket'); this.logger.debug(_scope, 'called', { subject, resource, scopes, identifier, profile, ticketLifespanSeconds }); @@ -1196,7 +1212,7 @@ class Manager { const nowEpoch = common.dateToEpoch(); return this.mysteryBox.pack({ c: common.requestId(), - iss: nowEpoch, + iat: nowEpoch, exp: nowEpoch + ticketLifespanSeconds, sub: subject, res: resource, @@ -1209,25 +1225,25 @@ class Manager { /** * @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 + * @property {string} codeId code id + * @property {Date} issued issued at + * @property {Date} expires expires at + * @property {URL} subject subject + * @property {URL} resource resource + * @property {string[]} scopes scopes + * @property {string} identifier identifier + * @property {URL} profile profile */ /** * - * @param {String} ticket - * @returns {Ticket} + * @param {string} ticket ticket + * @returns {Promise} ticket object */ async _unpackTicket(ticket) { const ticketObj = await this.mysteryBox.unpack(ticket); return { codeId: ticketObj.c, - issued: new Date(ticketObj.iss * 1000), + issued: new Date(ticketObj.iat * 1000), expires: new Date(ticketObj.exp * 1000), subject: new URL(ticketObj.sub), resource: new URL(ticketObj.res), @@ -1240,10 +1256,11 @@ class Manager { /** * Redeem a ticket for a token. - * @param {*} dbCtx - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {*} dbCtx db context + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context + * @returns {Promise} */ async _ticketAuthToken(dbCtx, req, res, ctx) { const _scope = _fileScope('_ticketAuthToken'); @@ -1298,17 +1315,17 @@ class Manager { /** * Redeem a code for a token. - * @param {*} dbCtx - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {*} dbCtx db context + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async _codeToken(dbCtx, req, res, ctx) { const _scope = _fileScope('_codeToken'); this.logger.debug(_scope, 'called', { ctx }); await this._restoreSessionFromCode(ctx); - await this._checkSessionMatchingRedirectUri(ctx); + this._checkSessionMatchingRedirectUri(ctx); if (ctx.session.error) { throw new ResponseError(Enum.ErrorResponse.BadRequest); @@ -1381,15 +1398,17 @@ class Manager { /** * Issue, refresh, or validate a token. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async postToken(req, res, ctx) { const _scope = _fileScope('postToken'); this.logger.debug(_scope, 'called', { ctx }); - ctx.session = Object.assign({}, ctx.session); + ctx.session = { + ...ctx.session, + }; await this.db.context(async (dbCtx) => { @@ -1437,9 +1456,10 @@ class Manager { * 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 + * @param {*} dbCtx db context + * @param {http.ClientRequest} req request + * @param {object} ctx context + * @returns {Promise} */ async _checkTokenValidationRequest(dbCtx, req, ctx) { const _scope = _fileScope('_checkTokenValidationRequest'); @@ -1454,7 +1474,7 @@ class Manager { }; try { Object.assign(ctx.bearer, await this.mysteryBox.unpack(authString)); - } catch (e) { + } catch (e) { // eslint-disable-line no-unused-vars this.logger.debug(_scope, 'failed to unpack token', { ctx }); Manager._setError(ctx, 'invalid_request', 'invalid token'); return; @@ -1495,9 +1515,9 @@ class Manager { /** * Accept an unsolicited ticket proffering. - * @param {http.ClientRequest} req - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ClientRequest} req request + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async postTicket(req, res, ctx) { const _scope = _fileScope('postTicket'); @@ -1509,11 +1529,19 @@ class Manager { } const queueName = this.options.queues.ticketPublishName; - const { ticket, resource, subject } = ctx.parsedBody; + const { ticket, resource, subject, iss } = ctx.parsedBody; + if (iss) { + try { + new URL(iss); + } catch (e) { // eslint-disable-line no-unused-vars + this.logger.debug(_scope, 'unparsable issuer', { ticket, resource, subject, iss, ctx }); + // continue, will try resource for metadata + } + } try { new URL(resource); - } catch (e) { + } catch (e) { // eslint-disable-line no-unused-vars this.logger.debug(_scope, 'unparsable resource', { ticket, resource, subject, ctx }); throw new ResponseError(Enum.ErrorResponse.BadRequest); } @@ -1526,7 +1554,7 @@ class Manager { } try { - const result = await this.queuePublisher.publish(queueName, { ticket, resource, subject }); + const result = await this.queuePublisher.publish(queueName, { ticket, resource, subject, iss, epochMs: Date.now() }); 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 }); @@ -1540,10 +1568,112 @@ class Manager { } + /** + * @typedef {object} AMQPChannel + * @property {Function} ack ack + */ + /** + * Process messages from proffered ticket queue. + * Attempt to redeem ticket and publish to redeemed token queue. + * @param {AMQPChannel} channel channel + * @param {Buffer} message message + */ + async queuedTicketProcessor(channel, message) { + const _scope = _fileScope('queuedTicketProcessor'); + + const queueName = this.options.queues.ticketRedeemedName; + let payload, ticket, resource, subject, iss; + try { + payload = message.content.toString(); + + this.logger.debug(_scope, 'processing ticket', { payload }); + ({ + ticket, + resource, + subject, + iss, + } = JSON.parse(payload)); + } catch (e) { + this.logger.error(_scope, 'could not parse message, discarding', { error: e, message }); + channel.ack(message); + return; + } + + let issuerUrlObj; + try { + if (iss) { + issuerUrlObj = new URL(iss); + } + } catch (e) { + this.logger.debug(_scope, 'unparsable issuer, falling back to resource discovery', { error: e, payload }); + } + + let resourceUrlObj; + try { + resourceUrlObj = new URL(resource); + } catch (e) { // eslint-disable-line no-unused-vars + this.logger.error(_scope, 'unparsable resource, discarding', { payload }); + channel.ack(message); + return; + } + + let isNotRetryable = false; + try { + await this.db.context(async (dbCtx) => { + + let token; + try { + token = await this.communication.redeemTicket(ticket, resourceUrlObj, issuerUrlObj); + isNotRetryable = true; // assume we cannot redeem a ticket more than once + this.logger.debug(_scope, 'successfully redeemed ticket', { token, payload }); + channel.ack(message); + } catch (e) { + this.logger.error(_scope, 'failed to redeem ticket', { error: e, payload }); + throw e; + } + + // persist our redemption + const redeemedData = { + subject, + resource, + iss, + ticket, + token, + }; + await this.db.ticketRedeemed(dbCtx, redeemedData); + + try { + const result = await this.queuePublisher.publish(queueName, redeemedData); + this.logger.info(_scope, 'published ticket token', { queueName, ticket, resource, subject, iss, result }); + } catch (e) { + this.logger.error(_scope, 'failed to publish token to queue', { error: e, queueName, ticket, token, resource, subject }); + throw e; // return a 500 + } + + await this.db.ticketTokenPublished(dbCtx, redeemedData); + + }); // dbCtx + + } catch (e) { + isNotRetryable = isNotRetryable + || (e instanceof CommunicationErrors.ValidationError) + || (e?.response?.statusCode < 500) + ; + if (isNotRetryable) { + this.logger.error(_scope, 'failed to process ticket, not requeuing', { error: e, payload }); + channel.ack(message); + return; + } + this.logger.error(_scope, 'failed to process ticket, requeuing', { error: e, payload }); + throw e; + } + } + + /** * Validate a token and return data about it. - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async postIntrospection(res, ctx) { const _scope = _fileScope('postIntrospection'); @@ -1598,8 +1728,8 @@ class Manager { /** * Revoke a token or refresh token. - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async postRevocation(res, ctx) { const _scope = _fileScope('postRevocation'); @@ -1620,8 +1750,8 @@ class Manager { /** * Profile information for a token. - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async postUserInfo(res, ctx) { const _scope = _fileScope('postUserInfo'); @@ -1680,8 +1810,8 @@ class Manager { /** * Show admin interface, allowing manipulation of profiles and scopes. - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async getAdmin(res, ctx) { const _scope = _fileScope('getAdmin'); @@ -1702,8 +1832,8 @@ class Manager { /** * Process admin interface events. - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async postAdmin(res, ctx) { const _scope = _fileScope('postAdmin'); @@ -1715,16 +1845,16 @@ class Manager { if (action === 'save-scopes') { // Update the convenience scopes set for profiles. - // Expect 'scopes-' with value of array of scopes + // Expect 'scopes-[]' with value of array of scopes const profileKeys = ctx.parsedBody && Object.keys(ctx.parsedBody) - .filter((k) => k.startsWith('scopes-')); + .filter((k) => k.startsWith('scopes-') && k.endsWith('[]')); 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); + /* elide 'scope-' prefix and '[]' postfix to get the profile */ + const profile = profileKey.slice(7, -2); /* (should validate profile here) */ /* remove invalid scopes from submitted list */ @@ -1832,8 +1962,8 @@ class Manager { /** * Show ticket proffer interface. - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async getAdminTicket(res, ctx) { const _scope = _fileScope('getAdminTicket'); @@ -1855,8 +1985,8 @@ class Manager { /** * Handle ticket proffer interface submission. - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async postAdminTicket(res, ctx) { const _scope = _fileScope('postAdminTicket'); @@ -1872,7 +2002,7 @@ class Manager { ].forEach((param) => { try { ctx[param.ctxProp] = new URL(ctx.parsedBody[param.bodyParam]); - } catch (e) { + } catch (e) { // eslint-disable-line no-unused-vars this.logger.debug(_scope, `invalid ${param.bodyParam}`, { ctx }); ctx.errors.push(param.err); } @@ -1893,7 +2023,7 @@ class Manager { const scopesSet = new Set(); const rawScopes = [ - ...(common.ensureArray(ctx.parsedBody['scopes'])), + ...(common.ensureArray(ctx.parsedBody['scopes[]'])), ...((ctx.parsedBody['adhoc'] || '').split(scopeSplitRE)), ].filter((scope) => scope); rawScopes.forEach((scope) => { @@ -1932,9 +2062,10 @@ class Manager { 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 }); + const issuerUrl = new URL(this.options.dingus.selfBaseUrl); + const result = await this.communication.deliverTicket(ctx.ticketEndpointUrl, ctx.ticketResourceUrl, ctx.ticketSubjectUrl, ticket, issuerUrl); + ctx.notifications.push(`Success! Ticket was delivered. (${result?.statusMessage})`); + this.logger.info(_scope, 'ticket delivered', { ctx }); } catch (e) { this.logger.error(_scope, 'failed to deliver ticket', { ctx, error: e }); ctx.errors.push(`Failed to deliver ticket. (${e})`); @@ -1966,8 +2097,8 @@ class Manager { /** * Report on generally uninteresting backend information. * Also allow a few event invocations. - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async getAdminMaintenance(res, ctx) { const _scope = _fileScope('getAdminMaintenance'); @@ -2004,8 +2135,8 @@ class Manager { /** * - * @param {http.ServerResponse} res - * @param {Object} ctx + * @param {http.ServerResponse} res response + * @param {object} ctx context */ async getHealthcheck(res, ctx) { const _scope = _fileScope('getHealthcheck'); @@ -2016,4 +2147,4 @@ class Manager { } -module.exports = Manager; \ No newline at end of file +module.exports = Manager;