diff --git a/.versions b/.versions index 85b1308..298743b 100644 --- a/.versions +++ b/.versions @@ -1,4 +1,4 @@ -accounts-base@2.2.3 +accounts-base@2.2.4 accounts-password@2.3.1 allow-deny@1.1.1 babel-compiler@7.9.0 @@ -12,10 +12,10 @@ check@1.3.1 coffeescript@1.0.17 dburles:mongo-collection-instances@0.1.3 ddp@1.4.0 -ddp-client@2.5.0 +ddp-client@2.6.0 ddp-common@1.4.0 ddp-rate-limiter@1.1.0 -ddp-server@2.5.0 +ddp-server@2.6.0 diff-sequence@1.1.1 dynamic-import@0.7.2 ecmascript@0.16.2 @@ -30,26 +30,25 @@ http@1.4.4 id-map@1.1.1 inter-process-messaging@0.1.1 jkuester:http@2.1.0 -leaonline:oauth2-server@4.2.1 lmieulet:meteor-coverage@3.2.0 lmieulet:meteor-legacy-coverage@0.1.0 lmieulet:meteor-packages-coverage@0.1.0 -local-test:leaonline:oauth2-server@4.2.1 +local-test:tmgrawv:oauth2-server@4.2.3 localstorage@1.2.0 logging@1.3.1 -meteor@1.10.0 +meteor@1.10.1 meteortesting:browser-tests@1.3.5 meteortesting:mocha@2.0.3 meteortesting:mocha-core@8.0.1 -minimongo@1.8.0 +minimongo@1.9.0 modern-browsers@0.1.8 -modules@0.18.0 +modules@0.19.0 modules-runtime@0.13.0 -mongo@1.15.0 +mongo@1.16.0 mongo-decimal@0.1.3 mongo-dev-server@1.1.0 mongo-id@1.0.8 -npm-mongo@4.3.1 +npm-mongo@4.9.0 ordered-dict@1.1.0 practicalmeteor:chai@1.9.2_3 promise@0.12.0 @@ -63,6 +62,7 @@ routepolicy@1.1.1 service-configuration@1.3.0 sha@1.0.9 socket-stream-client@0.5.0 +tmgrawv:oauth2-server@4.2.3 tracker@1.2.0 underscore@1.0.10 url@1.3.2 diff --git a/lib/middleware/getDebugMiddleware.js b/lib/middleware/getDebugMiddleware.js index b1f70ae..ba090f0 100644 --- a/lib/middleware/getDebugMiddleware.js +++ b/lib/middleware/getDebugMiddleware.js @@ -9,7 +9,7 @@ import { debug } from '../utils/console' export const getDebugMiddleWare = instance => (req, res, next) => { if (instance.debug === true) { const baseUrl = req.originalUrl.split('?')[0] - debug(req.method, baseUrl, req.query || req.body) + debug(req.method, baseUrl, { query: req.query, body: req.body }) } return next() } diff --git a/lib/model/DefaultModelConfig.js b/lib/model/DefaultModelConfig.js index d0763d0..08ed87a 100644 --- a/lib/model/DefaultModelConfig.js +++ b/lib/model/DefaultModelConfig.js @@ -1,7 +1,7 @@ /** * Default collection names for the model collections. * @type {{accessTokensCollectionName: string, refreshTokensCollectionName: string, clientsCollectionName: string, authCodesCollectionName: string, debug: boolean}} - */ +*/ export const DefaultModelConfig = { accessTokensCollectionName: 'oauth_access_tokens', refreshTokensCollectionName: 'oauth_refresh_tokens', diff --git a/lib/oauth.js b/lib/oauth.js index 2eb65a4..cc0c5d7 100644 --- a/lib/oauth.js +++ b/lib/oauth.js @@ -28,26 +28,10 @@ import { secureHandler } from './middleware/secureHandler' // oauth import OAuthserver from '@node-oauth/oauth2-server' +import { requiredRefreshTokenPostParams } from './validation/requiredRefreshTokenPostParams' const { Request, Response } = OAuthserver -/** - * Publishes all authorized clients for the current authenticated user. - * Does not touch any other user-related fields. - * Allows to inject a custom publication-name. - * @param pubName {string} - * @return {function():Mongo.Cursor} - * @private - */ -const publishAuthorizedClients = (pubName) => { - return Meteor.publish(pubName, function () { - if (!this.userId) { - return this.ready() - } - return Meteor.users.find({ _id: this.userId }, { fields: { 'oauth.authorizedClients': 1 } }) - }) -} - /** * The base class of this package. * Represents an oauth2-server with a default model setup for Meteor/Mongo. @@ -61,7 +45,7 @@ export class OAuth2Server { * @param debug * @return {OAuth2Server} */ - constructor ({ serverOptions = {}, model, routes, debug } = {}) { + constructor ({ serverOptions = {}, model, routes, debug, checkModelInterface = true } = {}) { check(serverOptions, OptionsSchema.serverOptions) this.instanceId = Random.id() @@ -71,7 +55,7 @@ export class OAuth2Server { } // if we have passed our own model instance we directly assign it as model, - if (isModelInterface(model)) { + if (!checkModelInterface || isModelInterface(model)) { this.config.model = null this.model = model } @@ -88,9 +72,10 @@ export class OAuth2Server { const oauthOptions = Object.assign({ model: this.model }, serverOptions) this.oauth = new OAuthserver(oauthOptions) - const authorizedPubName = (serverOptions && serverOptions.authorizedPublicationName) || 'authorizedOAuth' - publishAuthorizedClients(authorizedPubName) - initRoutes(this, routes) + this.validateClientRequest() + this.addUserToRequest() + this.generateAuthorizationCodeResponse() + this.generateAccessTokenResponse() return this } @@ -189,20 +174,215 @@ export class OAuth2Server { app.get(route, secureHandler(self, fn)) }, post (route, fn) { - return app.post(route, debugMiddleware, authHandler, fn) + app.post(route, debugMiddleware) + app.post(route, authHandler) + app.post(route, secureHandler(self, fn)) } } } -} -const initRoutes = (self, { accessTokenUrl = '/oauth/token', authorizeUrl = '/oauth/authorize', errorUrl = '/oauth/error', fallbackUrl = '/oauth/*' } = {}) => { - const debugMiddleware = getDebugMiddleWare(self) + // STEP 1: VALIDATE CLIENT REQUEST + // Note from https://www.oauth.com/oauth2-servers/authorization/the-authorization-response/ + // If there is something wrong with the syntax of the request, such as the redirect_uri or client_id is invalid, + // then it’s important not to redirect the user and instead you should show the error message directly. + // This is to avoid letting your authorization server be used as an open redirector. + validateClientRequest () { + const self = this + const { authorizeUrl } = this.config.routes + self.route('get', authorizeUrl, function (req, res, next) { + const validRequestParams = self.validateRequestParams(req, res, requiredAuthorizeGetParams) + if (!validRequestParams) return; + + const validResponseType = self.validateResponseType(req, res) + if (!validResponseType) return + + const client = self.getValidatedClient(req, res) + if (!client) return + + const redirectUri = self.getValidatedRedirectUri(req, res, client) + if (!redirectUri) return + + return next() + }) + } + + // STEP 2: ADD USER TO THE REQUEST + // validate all inputs again, since all inputs + // could have been manipulated within form + addUserToRequest () { + const self = this + const { authorizeUrl } = this.config.routes + self.route('post', authorizeUrl, function (req, res, next) { + const validRequestParams = self.validateRequestParams(req, res, requiredAuthorizeGetParams) + if (!validRequestParams) return; + + const client = self.getValidatedClient(req, res) + if (!client) return + + const validRedirectUri = self.getValidatedRedirectUri(req, res, client) + if (!validRedirectUri) return + + // token refers here to the Meteor.loginToken, + // which is assigned, once the user has been validly logged-in + // only valid tokens can be used to find a user + // in the Meteor.users collection + const user = Meteor.users.findOne({ + 'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(req.body.token) + }) + + // we fail already here if no user has been found + // since the oauth-node sever would repsond with a + // 503 error, while it should be a 400 + const validateUserCredentials = { user, client } + + if (!user || !UserValidation.isValid(self, validateUserCredentials)) { + return errorHandler(res, { + status: 400, + error: 'access_denied', + description: 'You are no valid user', + state: req.body.state, + debug: self.debug + }) + } + + const id = user._id + req.user = { id } // TODO add fields from scope + + if (req.body.allowed === 'false') { + Meteor.users.update(id, { $pull: { 'oauth.authorizedClients': client.clientId } }) + } + else { + Meteor.users.update(id, { $addToSet: { 'oauth.authorizedClients': client.clientId } }) + } + + // make this work on a post route + req.query.allowed = req.body.allowed + + return next() + }) + } + + generateAuthorizationCodeResponse () { + const self = this + const { authorizeUrl } = this.config.routes + // STEP 3: GENERATE AUTHORIZATION CODE RESPONSE + // - use the user form the prior middleware for the authentication handler + // - on allow, assign the client_id to the user's authorized clients + // - on deny, ...? + // - construct the redirect query and redirect to the redirect_uri + self.route('post', authorizeUrl, function (req, res /*, next */) { + const request = new Request(req) + const response = new Response(res) + const authorizeOptions = { + authenticateHandler: { + handle: function (request, response) { + return request.user + } + } + } + + return self.oauth.authorize(request, response, authorizeOptions) + .then(bind(function (code) { + const query = new URLSearchParams({ + code: code.authorizationCode, + user: req.user.id, + state: req.body.state + }) + + const finalRedirectUri = `${req.body.redirect_uri}?${query}` + + res.statusCode = 302 + res.setHeader('Location', finalRedirectUri) + res.end() + })) + .catch(function (err) { + errorHandler(res, { + originalError: err, + error: err.name, + description: err.message, + status: err.statusCode, + state: req.body.state, + debug: self.debug + }) + }) + }) + } + + generateAccessTokenResponse () { + const self = this + const { accessTokenUrl } = this.config.routes + // STEP 4: GENERATE ACCESS TOKEN RESPONSE + // - validate params + // - validate authorization code + // - issue accessToken and refreshToken + self.route('post', accessTokenUrl, function (req, res, next) { + if (![ 'authorization_code', 'refresh_token' ].includes(req.body.grant_type)) { + return errorHandler(res, { + status: 400, + error: 'invalid_grant', + description: 'The grant_type is not supported', + state: _req.body.state, + debug: self.debug, + }) + } + + if (req.body.grant_type === 'authorization_code') { + const validParams = self.validateRequestParams(req, res, requiredAccessTokenPostParams) + if (!validParams) return + } + + if (req.body.grant_type === 'refresh_token') { + const validParams = self.validateRequestParams(req, res, requiredRefreshTokenPostParams) + if (!validParams) return + } + + const request = new Request(req) + const response = new Response(res) + return self.oauth.token(request, response) + .then(function (token) { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + Pragma: 'no-cache' + }) + const body = JSON.stringify({ + access_token: token.accessToken, + token_type: 'bearer', + expires_in: token.accessTokenExpiresAt, + refresh_token: token.refreshToken + }) + res.end(body) + }) + .catch(function (err) { + return errorHandler(res, { + error: err.name, + description: err.message, + state: req.body.state, + debug: self.debug, + status: err.statusCode + }) + }) + }) + } - const validateResponseType = (req, res) => { + initFallbackRoutes () { + const self = this + const { fallbackUrl } = this.config.routes + self.route('use', fallbackUrl, function (req, res, next) { + return errorHandler(res, { + error: 'route not found', + status: 404, + debug: self.debug + }) + }) + } + + validateResponseType (req, res) { + const self = this const responseType = req.method.toLowerCase() === 'get' ? req.query.response_type : req.body.response_type - if (responseType !== 'code' && responseType !== 'token') { + if (responseType !== 'code') { return errorHandler(res, { status: 415, error: 'unsupported_response_type', @@ -214,7 +394,24 @@ const initRoutes = (self, { accessTokenUrl = '/oauth/token', authorizeUrl = '/oa return true } - const getValidatedClient = (req, res) => { + validateRequestParams (req, res, expectedParams) { + const self = this + const params = req.method.toLowerCase() === 'get' ? req.query : req.body + + if (!validateParams(params, expectedParams, self.debug)) { + return errorHandler(res, { + status: 400, + error: 'invalid_request', + description: 'One or more request parameters are invalid', + state: req.query.state, + debug: self.debug + }) + } + return true + } + + getValidatedClient (req, res) { + const self = this const clientId = req.method.toLowerCase() === 'get' ? req.query.client_id : req.body.client_id const secret = req.method.toLowerCase() === 'get' ? req.query.client_secret : req.body.client_secret const client = Promise.await(self.model.getClient(clientId, secret)) @@ -231,7 +428,8 @@ const initRoutes = (self, { accessTokenUrl = '/oauth/token', authorizeUrl = '/oa return client } - const getValidatedRedirectUri = (req, res, client) => { + getValidatedRedirectUri (req, res, client) { + const self = this const redirectUris = [].concat(client.redirectUris) const redirectUri = req.method.toLowerCase() === 'get' ? req.query.redirect_uri : req.body.redirect_uri if (redirectUris.indexOf(redirectUri) === -1) { @@ -246,7 +444,9 @@ const initRoutes = (self, { accessTokenUrl = '/oauth/token', authorizeUrl = '/oa return redirectUri } - const route = (method, url, handler) => { + route (method, url, handler) { + const self = this; + const debugMiddleware = getDebugMiddleWare(self) const targetFn = self.app[method] if (self.debug) { targetFn.call(self.app, url, debugMiddleware) @@ -273,185 +473,26 @@ const initRoutes = (self, { accessTokenUrl = '/oauth/token', authorizeUrl = '/oa } })) } +} +const validation = { + requiredAuthorizeGetParams, + requiredAuthorizePostParams, + requiredRefreshTokenPostParams, + requiredAccessTokenPostParams, + UserValidation, + validateParams +} +const handlers = { + errorHandler +} - // STEP 1: VALIDATE CLIENT REQUEST - // Note from https://www.oauth.com/oauth2-servers/authorization/the-authorization-response/ - // If there is something wrong with the syntax of the request, such as the redirect_uri or client_id is invalid, - // then it’s important not to redirect the user and instead you should show the error message directly. - // This is to avoid letting your authorization server be used as an open redirector. - route('get', authorizeUrl, function (req, res, next) { - if (!validateParams(req.query, requiredAuthorizeGetParams, self.debug)) { - return errorHandler(res, { - status: 400, - error: 'invalid_request', - description: 'One or more request parameters are invalid', - state: req.query.state, - debug: self.debug - }) - } - - const validResponseType = validateResponseType(req, res) - if (!validResponseType) return - - const client = getValidatedClient(req, res) - if (!client) return - - const redirectUri = getValidatedRedirectUri(req, res, client) - if (!redirectUri) return - - return next() - }) - - // STEP 2: ADD USER TO THE REQUEST - // validate all inputs again, since all inputs - // could have been manipulated within form - route('post', authorizeUrl, function (req, res, next) { - if (!validateParams(req.body, requiredAuthorizePostParams, self.debug)) { - return errorHandler(res, { - error: 'invalid_request', - description: 'One or more request parameters are invalid', - state: req.body.state, - debug: self.debug, - status: 400 - }) - } - - const client = getValidatedClient(req, res) - if (!client) return - - const validRedirectUri = getValidatedRedirectUri(req, res, client) - if (!validRedirectUri) return - - // token refers here to the Meteor.loginToken, - // which is assigned, once the user has been validly logged-in - // only valid tokens can be used to find a user - // in the Meteor.users collection - const user = Meteor.users.findOne({ - 'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(req.body.token) - }) - - // we fail already here if no user has been found - // since the oauth-node sever would repsond with a - // 503 error, while it should be a 400 - const validateUserCredentials = { user, client } - - if (!user || !UserValidation.isValid(self, validateUserCredentials)) { - return errorHandler(res, { - status: 400, - error: 'access_denied', - description: 'You are no valid user', - state: req.body.state, - debug: self.debug - }) - } - - const id = user._id - req.user = { id } // TODO add fields from scope - - if (req.body.allowed === 'false') { - Meteor.users.update(id, { $pull: { 'oauth.authorizedClients': client.clientId } }) - } - else { - Meteor.users.update(id, { $addToSet: { 'oauth.authorizedClients': client.clientId } }) - } - - // make this work on a post route - req.query.allowed = req.body.allowed - - return next() - }) - - // STEP 3: GENERATE AUTHORIZATION CODE RESPONSE - // - use the user form the prior middleware for the authentication handler - // - on allow, assign the client_id to the user's authorized clients - // - on deny, ...? - // - construct the redirect query and redirect to the redirect_uri - route('post', authorizeUrl, function (req, res /*, next */) { - const request = new Request(req) - const response = new Response(res) - const authorizeOptions = { - authenticateHandler: { - handle: function (request, response) { - return request.user - } - } - } - - return self.oauth.authorize(request, response, authorizeOptions) - .then(bind(function (code) { - const query = new URLSearchParams({ - code: code.authorizationCode, - user: req.user.id, - state: req.body.state - }) - - const finalRedirectUri = `${req.body.redirect_uri}?${query}` - - res.statusCode = 302 - res.setHeader('Location', finalRedirectUri) - res.end() - })) - .catch(function (err) { - errorHandler(res, { - originalError: err, - error: err.name, - description: err.message, - status: err.statusCode, - state: req.body.state, - debug: self.debug - }) - }) - }) - - // STEP 4: GENERATE ACCESS TOKEN RESPONSE - // - validate params - // - validate authorization code - // - issue accessToken and refreshToken - route('post', accessTokenUrl, function (req, res, next) { - if (!validateParams(req.body, requiredAccessTokenPostParams, self.debug)) { - return errorHandler(res, { - status: 400, - error: 'invalid_request', - description: 'One or more request parameters are invalid', - state: req.body.state, - debug: self.debug - }) - } - - const request = new Request(req) - const response = new Response(res) - - return self.oauth.token(request, response) - .then(function (token) { - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - Pragma: 'no-cache' - }) - const body = JSON.stringify({ - access_token: token.accessToken, - token_type: 'bearer', - expires_in: token.accessTokenExpiresAt, - refresh_token: token.refreshToken - }) - res.end(body) - }) - .catch(function (err) { - return errorHandler(res, { - error: 'unauthorized_client', - description: err.message, - state: req.body.state, - debug: self.debug, - status: err.statusCode - }) - }) - }) - - route('use', fallbackUrl, function (req, res, next) { - return errorHandler(res, { - error: 'route not found', - status: 404, - debug: self.debug - }) - }) +const middlewares = { + secureHandler } + +export { + validation, + handlers, + middlewares, + app +} \ No newline at end of file diff --git a/lib/utils/error.js b/lib/utils/error.js index dd5d6da..40583da 100644 --- a/lib/utils/error.js +++ b/lib/utils/error.js @@ -20,7 +20,7 @@ export const errorHandler = function (res, options) { res.writeHead(errCode, { 'Content-Type': 'application/json' }) // by default we log the error that will be used as response - error(`[error] ${errCode} - ${options.error} - ${options.description}`) + options.debug && error(`[error] ${errCode} - ${options.error} - ${options.description}`) if (options.debug && options.originalError) { error('[original error]:') diff --git a/lib/validation/requiredRefreshTokenPostParams.js b/lib/validation/requiredRefreshTokenPostParams.js new file mode 100644 index 0000000..cfe5acd --- /dev/null +++ b/lib/validation/requiredRefreshTokenPostParams.js @@ -0,0 +1,12 @@ + +import { Match } from 'meteor/check' +import { nonEmptyString } from './nonEmptyString' + +const isNonEmptyString = Match.Where(nonEmptyString) + +export const requiredRefreshTokenPostParams = { + refresh_token: isNonEmptyString, + client_id: isNonEmptyString, + client_secret: isNonEmptyString, + grant_type: 'refresh_token', +} diff --git a/lib/webapp.js b/lib/webapp.js index dd9c70b..f79deee 100644 --- a/lib/webapp.js +++ b/lib/webapp.js @@ -6,7 +6,8 @@ import bodyParser from 'body-parser' * @private */ const server = WebApp.connectHandlers -server.use(bodyParser.urlencoded({ extended: false })) +server.use(bodyParser.urlencoded({ extended: true })) +server.use(bodyParser.json()) /** * Wrapped `WebApp` with express-style get/post and default use routes. @@ -43,7 +44,6 @@ const app = { if (req.headers['content-type'] !== 'application/x-www-form-urlencoded') { // Transforms requests which are POST and aren't "x-www-form-urlencoded" content type // and they pass the required information as query strings - info('Transforming a request to form-urlencoded with the query going to the body.') req.headers['content-type'] = 'application/x-www-form-urlencoded' req.body = Object.assign({}, req.body, req.query) } diff --git a/package.js b/package.js index ee56d44..c634ef3 100644 --- a/package.js +++ b/package.js @@ -1,9 +1,9 @@ /* eslint-env meteor */ Package.describe({ - name: 'leaonline:oauth2-server', - version: '4.2.1', + name: 'tmgrawv:oauth2-server', + version: '4.2.3', summary: 'Node OAuth2 Server (v4) with Meteor bindings', - git: 'https://github.com/leaonline/oauth2-server.git' + git: 'https://github.com/ravitmg/oauth2-server.git' }) Package.onUse(function (api) {