X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=src%2Fmanager.js;h=ce544b949a90603ef09b8d7448410aa264fcb9e0;hb=f0bf29c75b0fd405ff92fa76f058e61162b87e43;hp=c79c7529670b8b83dba45f55ccb824716bd427c6;hpb=726cd980f0ed5588cfe8cbb2d994d5e4aef6e292;p=squeep-indie-auther diff --git a/src/manager.js b/src/manager.js index c79c752..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'); @@ -18,6 +19,10 @@ const scopeSplitRE = / +/; const supportedCodeChallengeMethods = ['S256', 'SHA256']; +/** + * @typedef {import('node:http')} http + */ + class Manager { constructor(logger, db, options) { this.options = options; @@ -64,12 +69,12 @@ class Manager { /** * 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 @@ -109,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({ @@ -121,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)); @@ -131,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'); @@ -146,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'); @@ -187,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'); @@ -196,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); @@ -305,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']) { @@ -331,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']) { @@ -354,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 { @@ -365,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']; @@ -382,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']; @@ -396,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']; @@ -415,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']; @@ -434,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'); @@ -459,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; } } @@ -475,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'); @@ -492,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'); @@ -527,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) { @@ -560,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'); @@ -584,8 +589,8 @@ class Manager { /** * Get up-to-date profile data from selected profile endpoint. - * @param {Object} ctx - * @returns {Promise} + * @param {object} ctx context + * @returns {Promise} profile data */ async _fetchConsentProfileData(ctx) { const _scope = _fileScope('_fetchConsentProfileData'); @@ -616,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'); @@ -633,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']); @@ -725,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'); @@ -783,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 }); @@ -821,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'); @@ -869,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'); @@ -879,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'); @@ -897,7 +905,7 @@ class Manager { /** - * @param {Object} ctx + * @param {object} ctx context */ _checkSessionMatchingRedirectUri(ctx) { const _scope = _fileScope('_checkSessionMatchingClientId'); @@ -907,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'); @@ -926,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'); @@ -946,7 +954,7 @@ class Manager { /** - * @param {Object} ctx + * @param {object} ctx context */ _checkSessionMatchingCodeVerifier(ctx) { const _scope = _fileScope('_checkSessionMatchingCodeVerifier'); @@ -977,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'); @@ -1035,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'); @@ -1071,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 = []; @@ -1091,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'); @@ -1188,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 {Promise} - */ + * @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 }); @@ -1217,19 +1225,19 @@ 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 {Promise} + * @param {string} ticket ticket + * @returns {Promise} ticket object */ async _unpackTicket(ticket) { const ticketObj = await this.mysteryBox.unpack(ticket); @@ -1248,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'); @@ -1306,10 +1315,10 @@ 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'); @@ -1389,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) => { @@ -1445,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'); @@ -1462,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; @@ -1503,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'); @@ -1522,14 +1534,14 @@ class Manager { if (iss) { try { new URL(iss); - } catch (e) { + } 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); } @@ -1556,11 +1568,15 @@ 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 - * @param {Buffer} message + * @param {AMQPChannel} channel channel + * @param {Buffer} message message */ async queuedTicketProcessor(channel, message) { const _scope = _fileScope('queuedTicketProcessor'); @@ -1595,7 +1611,7 @@ class Manager { let resourceUrlObj; try { resourceUrlObj = new URL(resource); - } catch (e) { + } catch (e) { // eslint-disable-line no-unused-vars this.logger.error(_scope, 'unparsable resource, discarding', { payload }); channel.ack(message); return; @@ -1656,8 +1672,8 @@ class Manager { /** * 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'); @@ -1712,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'); @@ -1734,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'); @@ -1794,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'); @@ -1816,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'); @@ -1829,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 */ @@ -1946,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'); @@ -1969,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'); @@ -1986,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); } @@ -2007,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) => { @@ -2081,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'); @@ -2119,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');