diff --git a/ghost/core/core/server/services/auth/api-key/admin.js b/ghost/core/core/server/services/auth/api-key/admin.js index 578eaa56ae..02ca73097d 100644 --- a/ghost/core/core/server/services/auth/api-key/admin.js +++ b/ghost/core/core/server/services/auth/api-key/admin.js @@ -17,6 +17,7 @@ const messages = { }; let JWT_OPTIONS_DEFAULTS = { + /** @type import('jsonwebtoken').Algorithm[] */ algorithms: ['HS256'], maxAge: '5m' }; @@ -60,7 +61,7 @@ const authenticate = function apiKeyAdminAuth(req, res, next) { })); } - return authenticateWithToken(req, res, next, {token, JWT_OPTIONS: JWT_OPTIONS_DEFAULTS}); + return wrappedAuthenticateWithToken(req, res, next, {token}); }; const authenticateWithUrl = function apiKeyAuthenticateWithUrl(req, res, next) { @@ -72,9 +73,20 @@ const authenticateWithUrl = function apiKeyAuthenticateWithUrl(req, res, next) { })); } // CASE: Scheduler publish URLs can have long maxAge but controllerd by expiry and neverBefore - return authenticateWithToken(req, res, next, {token, JWT_OPTIONS: _.omit(JWT_OPTIONS_DEFAULTS, 'maxAge')}); + return wrappedAuthenticateWithToken(req, res, next, {token, ignoreMaxAge: true}); }; +async function wrappedAuthenticateWithToken(req, res, next, options) { + try { + const {apiKey, user} = await authenticateWithToken(req.originalUrl, options.token, options.ignoreMaxAge); + req.api_key = apiKey; + req.user = user; + next(); + } catch (err) { + next(err); + } +} + /** * Admin API key authentication flow: * 1. extract the JWT token from the `Authorization: Ghost xxxx` header or from URL(for schedules) @@ -89,114 +101,109 @@ const authenticateWithUrl = function apiKeyAuthenticateWithUrl(req, res, next) { * - the "Audience" claim should match the requested API path * https://tools.ietf.org/html/rfc7519#section-4.1.3 */ -const authenticateWithToken = async function apiKeyAuthenticateWithToken(req, res, next, {token, JWT_OPTIONS}) { +const authenticateWithToken = async function apiKeyAuthenticateWithToken(originalUrl, token, ignoreMaxAge) { const decoded = jwt.decode(token, {complete: true}); + const jwtValidationOptions = ignoreMaxAge ? _.omit(JWT_OPTIONS_DEFAULTS, 'maxAge') : JWT_OPTIONS_DEFAULTS; if (!decoded || !decoded.header) { - return next(new errors.BadRequestError({ + throw new errors.BadRequestError({ message: tpl(messages.invalidToken), code: 'INVALID_JWT' - })); + }); } const apiKeyId = decoded.header.kid; if (!apiKeyId) { - return next(new errors.BadRequestError({ + throw new errors.BadRequestError({ message: tpl(messages.adminApiKidMissing), code: 'MISSING_ADMIN_API_KID' - })); + }); + } + + const apiKey = await models.ApiKey.findOne({id: apiKeyId}, {withRelated: ['integration']}); + + if (!apiKey) { + throw new errors.UnauthorizedError({ + message: tpl(messages.unknownAdminApiKey), + code: 'UNKNOWN_ADMIN_API_KEY' + }); + } + + if (apiKey.get('type') !== 'admin') { + throw new errors.UnauthorizedError({ + message: tpl(messages.invalidApiKeyType), + code: 'INVALID_API_KEY_TYPE' + }); + } + + // CASE: blocking all non-internal: "custom" and "builtin" integration requests when the limit is reached + if (limitService.isLimited('customIntegrations') + && (apiKey.relations.integration && !['internal', 'core'].includes(apiKey.relations.integration.get('type')))) { + // NOTE: using "checkWouldGoOverLimit" instead of "checkIsOverLimit" here because flag limits don't have + // a concept of measuring if the limit has been surpassed + await limitService.errorIfWouldGoOverLimit('customIntegrations'); + } + + // Decoding from hex and transforming into bytes is here to + // keep comparison of the bytes that are stored in the secret. + // Useful context: + // https://github.com/auth0/node-jsonwebtoken/issues/208#issuecomment-231861138 + const secret = Buffer.from(apiKey.get('secret'), 'hex'); + + // Using req.originalUrl means we get the right url even if version-rewrites have happened + const {version, api} = legacyApiPathMatch(originalUrl); + + // ensure the token was meant for this api + let options; + + if (version) { + // CASE: legacy versioned api request + options = Object.assign({ + audience: new RegExp(`/?${version}/${api}/?$`) + }, jwtValidationOptions); + } else { + options = Object.assign({ + audience: new RegExp(`/?${api}/?$`) + }, jwtValidationOptions); } try { - const apiKey = await models.ApiKey.findOne({id: apiKeyId}, {withRelated: ['integration']}); - - if (!apiKey) { - return next(new errors.UnauthorizedError({ - message: tpl(messages.unknownAdminApiKey), - code: 'UNKNOWN_ADMIN_API_KEY' - })); - } - - if (apiKey.get('type') !== 'admin') { - return next(new errors.UnauthorizedError({ - message: tpl(messages.invalidApiKeyType), - code: 'INVALID_API_KEY_TYPE' - })); - } - - // CASE: blocking all non-internal: "custom" and "builtin" integration requests when the limit is reached - if (limitService.isLimited('customIntegrations') - && (apiKey.relations.integration && !['internal', 'core'].includes(apiKey.relations.integration.get('type')))) { - // NOTE: using "checkWouldGoOverLimit" instead of "checkIsOverLimit" here because flag limits don't have - // a concept of measuring if the limit has been surpassed - await limitService.errorIfWouldGoOverLimit('customIntegrations'); - } - - // Decoding from hex and transforming into bytes is here to - // keep comparison of the bytes that are stored in the secret. - // Useful context: - // https://github.com/auth0/node-jsonwebtoken/issues/208#issuecomment-231861138 - const secret = Buffer.from(apiKey.get('secret'), 'hex'); - - // Using req.originalUrl means we get the right url even if version-rewrites have happened - const {version, api} = legacyApiPathMatch(req.originalUrl); - - // ensure the token was meant for this api - let options; - - if (version) { - // CASE: legacy versioned api request - options = Object.assign({ - audience: new RegExp(`/?${version}/${api}/?$`) - }, JWT_OPTIONS); - } else { - options = Object.assign({ - audience: new RegExp(`/?${api}/?$`) - }, JWT_OPTIONS); - } - - try { - jwt.verify(token, secret, options); - } catch (err) { - if (err.name === 'TokenExpiredError' || err.name === 'JsonWebTokenError') { - return next(new errors.UnauthorizedError({ - message: tpl(messages.invalidTokenWithMessage, {message: err.message}), - code: 'INVALID_JWT', - err - })); - } - - // unknown error - return next(new errors.InternalServerError({err})); - } - - // authenticated OK - - if (apiKey.get('user_id')) { - // fetch the user and store it on the request for later checks and logging - const user = await models.User.findOne( - {id: apiKey.get('user_id'), status: 'active'}, - {require: true} - ); - - req.user = user; - } - - // store the api key on the request for later checks and logging - req.api_key = apiKey; - - next(); + jwt.verify(token, secret, options); } catch (err) { - if (err instanceof errors.HostLimitError) { - next(err); - } else { - next(new errors.InternalServerError({err})); + if (err.name === 'TokenExpiredError' || err.name === 'JsonWebTokenError') { + throw new errors.UnauthorizedError({ + message: tpl(messages.invalidTokenWithMessage, {message: err.message}), + code: 'INVALID_JWT', + err + }); } + + // unknown error + throw new errors.InternalServerError({err}); } + + // authenticated OK + let result = { + user: null, + apiKey: apiKey + }; + + if (apiKey.get('user_id')) { + // fetch the user and store it on the request for later checks and logging + const user = await models.User.findOne( + {id: apiKey.get('user_id'), status: 'active'}, + {require: true} + ); + + result.user = user; + } + + return result; }; module.exports = { authenticate, - authenticateWithUrl + authenticateWithUrl, + authenticateWithToken };