update dependencies and devDependencies, address lint issues
[squeep-indie-auther] / src / manager.js
index 01a941643e093ba10a17dd86b5cd39c700214b3b..ce544b949a90603ef09b8d7448410aa264fcb9e0 100644 (file)
@@ -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,16 +19,21 @@ 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.chores = new Chores(logger, db, this.queuePublisher, options);
     this.mysteryBox = new MysteryBox(options);
     this.mysteryBox.on('statistics', common.mysteryBoxLogger(logger, _fileScope(this.constructor.name)));
 
@@ -51,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
@@ -102,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({
@@ -114,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));
@@ -124,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');
@@ -139,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');
@@ -180,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');
@@ -189,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);
@@ -298,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']) {
@@ -324,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']) {
@@ -347,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 {
@@ -358,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'];
@@ -375,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'];
@@ -389,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'];
@@ -408,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'];
@@ -427,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');
@@ -452,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;
       }
     }
@@ -468,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');
@@ -485,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');
@@ -520,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) {
@@ -553,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');
@@ -577,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<object>} profile data
    */
   async _fetchConsentProfileData(ctx) {
     const _scope = _fileScope('_fetchConsentProfileData');
@@ -609,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');
@@ -626,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']);
@@ -718,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');
@@ -776,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<void>}
    */
   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 });
@@ -814,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');
@@ -862,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');
@@ -872,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');
@@ -890,7 +905,7 @@ class Manager {
 
 
   /**
-   * @param {Object} ctx
+   * @param {object} ctx context
    */
   _checkSessionMatchingRedirectUri(ctx) {
     const _scope = _fileScope('_checkSessionMatchingClientId');
@@ -900,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');
@@ -919,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');
@@ -939,7 +954,7 @@ class Manager {
 
 
   /**
-   * @param {Object} ctx
+   * @param {object} ctx context
    */
   _checkSessionMatchingCodeVerifier(ctx) {
     const _scope = _fileScope('_checkSessionMatchingCodeVerifier');
@@ -970,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');
@@ -1028,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');
@@ -1064,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 = [];
@@ -1084,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');
@@ -1181,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<string>} ticket
+   */
   async _mintTicket({ subject, resource, scopes, identifier, profile, ticketLifespanSeconds }) {
     const _scope = _fileScope('_mintTicket');
     this.logger.debug(_scope, 'called', { subject, resource, scopes, identifier, profile, ticketLifespanSeconds });
@@ -1197,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,
@@ -1210,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>} 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),
@@ -1241,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<void>} 
    */
   async _ticketAuthToken(dbCtx, req, res, ctx) {
     const _scope = _fileScope('_ticketAuthToken');
@@ -1299,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);
@@ -1382,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) => {
 
@@ -1438,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<void>}
    */
   async _checkTokenValidationRequest(dbCtx, req, ctx) {
     const _scope = _fileScope('_checkTokenValidationRequest');
@@ -1455,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;
@@ -1496,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');
@@ -1510,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);
     }
@@ -1527,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 });
@@ -1541,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');
@@ -1599,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');
@@ -1621,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');
@@ -1681,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');
@@ -1703,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');
@@ -1716,16 +1845,16 @@ class Manager {
 
       if (action === 'save-scopes') {
         // Update the convenience scopes set for profiles.
-        // Expect 'scopes-<profile>' with value of array of scopes
+        // Expect 'scopes-<profile>[]' 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 */
@@ -1833,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');
@@ -1856,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');
@@ -1873,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);
           }
@@ -1894,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) => {
@@ -1933,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})`);
@@ -1967,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');
@@ -2005,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');
@@ -2017,4 +2147,4 @@ class Manager {
 
 }
 
-module.exports = Manager;
\ No newline at end of file
+module.exports = Manager;