diff --git a/.versions b/.versions index 736c8ee..cb93d71 100644 --- a/.versions +++ b/.versions @@ -1,70 +1,65 @@ -accounts-base@2.2.10 -accounts-password@2.4.0 -allow-deny@1.1.1 -babel-compiler@7.10.5 -babel-runtime@1.5.1 -base64@1.0.12 -binary-heap@1.0.11 -boilerplate-generator@1.7.2 -caching-compiler@1.2.2 -callback-hook@1.5.1 -check@1.3.2 -coffeescript@1.0.17 -dburles:mongo-collection-instances@0.1.3 -ddp@1.4.1 -ddp-client@2.6.1 -ddp-common@1.4.0 -ddp-rate-limiter@1.2.1 -ddp-server@2.7.0 -diff-sequence@1.1.2 -dynamic-import@0.7.3 -ecmascript@0.16.8 -ecmascript-runtime@0.8.1 -ecmascript-runtime-client@0.12.1 -ecmascript-runtime-server@0.11.0 -ejson@1.1.3 -email@2.2.5 -fetch@0.1.4 -geojson-utils@1.0.11 -http@1.4.4 -id-map@1.1.1 -inter-process-messaging@0.1.1 +accounts-base@3.0.4 +accounts-password@3.0.3 +allow-deny@2.1.0 +babel-compiler@7.11.3 +babel-runtime@1.5.2 +base64@1.0.13 +binary-heap@1.0.12 +boilerplate-generator@2.0.0 +callback-hook@1.6.0 +check@1.4.4 +core-runtime@1.0.0 +dburles:mongo-collection-instances@1.0.0 +ddp@1.4.2 +ddp-client@3.1.0 +ddp-common@1.4.4 +ddp-rate-limiter@1.2.2 +ddp-server@3.1.0 +diff-sequence@1.1.3 +dynamic-import@0.7.4 +ecmascript@0.16.10 +ecmascript-runtime@0.8.3 +ecmascript-runtime-client@0.12.2 +ecmascript-runtime-server@0.11.1 +ejson@1.1.4 +email@3.1.2 +facts-base@1.0.2 +fetch@0.1.5 +geojson-utils@1.0.12 +id-map@1.2.0 +inter-process-messaging@0.1.2 jkuester:http@2.1.0 -leaonline:oauth2-server@5.1.0 -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@5.1.0 -localstorage@1.2.0 -logging@1.3.3 -meteor@1.11.5 -meteortesting:browser-tests@1.3.5 -meteortesting:mocha@2.0.3 -meteortesting:mocha-core@8.0.1 -minimongo@1.9.3 -modern-browsers@0.1.10 -modules@0.20.0 -modules-runtime@0.13.1 -mongo@1.16.8 -mongo-decimal@0.1.3 -mongo-dev-server@1.1.0 -mongo-id@1.0.8 -npm-mongo@4.17.2 -ordered-dict@1.1.0 -practicalmeteor:chai@1.9.2_3 -promise@0.12.2 -random@1.2.1 -rate-limit@1.1.1 -react-fast-refresh@0.2.8 -reactive-var@1.0.12 -reload@1.3.1 -retry@1.1.0 -routepolicy@1.1.1 -sha@1.0.9 -socket-stream-client@0.5.2 -tracker@1.3.3 -typescript@4.9.5 -underscore@1.6.0 -url@1.3.2 -webapp@1.13.8 -webapp-hashing@1.1.1 +lai:collection-extensions@1.0.0 +leaonline:oauth2-server@6.0.0 +local-test:leaonline:oauth2-server@6.0.0 +localstorage@1.2.1 +logging@1.3.5 +meteor@2.1.0 +meteortesting:browser-tests@1.7.0 +meteortesting:mocha@3.2.0 +meteortesting:mocha-core@8.2.0 +minimongo@2.0.2 +modern-browsers@0.2.0 +modules@0.20.3 +modules-runtime@0.13.2 +mongo@2.1.0 +mongo-decimal@0.2.0 +mongo-dev-server@1.1.1 +mongo-id@1.0.9 +npm-mongo@6.10.2 +ordered-dict@1.2.0 +promise@1.0.0 +random@1.2.2 +rate-limit@1.1.2 +react-fast-refresh@0.2.9 +reactive-var@1.0.13 +reload@1.3.2 +retry@1.1.1 +routepolicy@1.1.2 +sha@1.0.10 +socket-stream-client@0.6.0 +tracker@1.3.4 +typescript@5.6.3 +url@1.3.5 +webapp@2.0.5 +webapp-hashing@1.1.2 diff --git a/API.md b/API.md index 46ee425..7252973 100644 --- a/API.md +++ b/API.md @@ -46,12 +46,16 @@ Uses the following values to check:

  • 'saveRefreshToken',
  • 'saveToken',
  • 'getAccessToken'
  • +
  • 'revokeToken'
  • UserValidation

    Used to register handlers for different instances that validate users. This allows you to validate user access on a client-based level.

    +
    validateParamsboolean
    +

    Abstraction that checks given query/body params against a given schema

    +
    app : Object

    Wrapped WebApp with express-style get/post and default use routes.

    @@ -76,6 +80,8 @@ Implements the OAuth2Server model with Meteor-Mongo bindings. * [.saveRefreshToken(token, clientId, expires, user)](#OAuthMeteorModel+saveRefreshToken) ⇒ Promise.<\*> * [.getRefreshToken()](#OAuthMeteorModel+getRefreshToken) * [.grantTypeAllowed(clientId, grantType)](#OAuthMeteorModel+grantTypeAllowed) ⇒ boolean + * [.verifyScope(accessToken, scope)](#OAuthMeteorModel+verifyScope) ⇒ Promise.<boolean> + * [.revokeToken()](#OAuthMeteorModel+revokeToken) @@ -199,6 +205,24 @@ getRefreshToken(token) should return an object with: | clientId | | grantType | + + +### oAuthMeteorModel.verifyScope(accessToken, scope) ⇒ Promise.<boolean> +Compares expected scope from token with actual scope from request + +**Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) + +| Param | +| --- | +| accessToken | +| scope | + + + +### oAuthMeteorModel.revokeToken() +revokeToken(refreshToken) is required and should return true + +**Kind**: instance method of [OAuthMeteorModel](#OAuthMeteorModel) ## OAuth2ServerDefaults : Object @@ -250,6 +274,7 @@ Defaults to a 500 response, unless further details were added. | res | | | | options | Object | options with error information | | options.error | String | Error name | +| options.logError | boolean | optional flag to log the erroe to the console | | options.description | String | Error description | | options.uri | String | Optional uri to redirect to when error occurs | | options.status | Number | Optional statuscode, defaults to 500 | @@ -274,6 +299,7 @@ Uses the following values to check: - 'saveRefreshToken', - 'saveToken', - 'getAccessToken' +- 'revokeToken' **Kind**: global constant **Returns**: boolean - true if valid, otherwise false @@ -289,6 +315,24 @@ Used to register handlers for different instances that validate users. This allows you to validate user access on a client-based level. **Kind**: global constant + +* [UserValidation](#UserValidation) + * [.register(instance, validationHandler)](#UserValidation.register) + * [.isValid(instance, handlerArgs)](#UserValidation.isValid) ⇒ \* + + + +### UserValidation.register(instance, validationHandler) +Registers a validation method that allows +to validate users on custom logic. + +**Kind**: static method of [UserValidation](#UserValidation) + +| Param | Type | Description | +| --- | --- | --- | +| instance | [OAuth2Server](#OAuth2Server) | | +| validationHandler | function | sync or async function that performs the validation | + ### UserValidation.isValid(instance, handlerArgs) ⇒ \* @@ -302,53 +346,23 @@ Delegates `handlerArgs` to the registered validation handler. | instance | [OAuth2Server](#OAuth2Server) | | handlerArgs | \* | - + -## app : Object -Wrapped `WebApp` with express-style get/post and default use routes. +## validateParams ⇒ boolean +Abstraction that checks given query/body params against a given schema **Kind**: global constant -**See**: https://docs.meteor.com/packages/webapp.html - -* [app](#app) : Object - * [.get(url, handler)](#app.get) - * [.post(url, handler)](#app.post) - * [.use(args)](#app.use) - - - -### app.get(url, handler) -Creates a get route for a given handler - -**Kind**: static method of [app](#app) - -| Param | Type | -| --- | --- | -| url | string | -| handler | function | - - - -### app.post(url, handler) -Creates a post route for a given handler. -If headers' content-type does not equal to `application/x-www-form-urlencoded` -then it will be transformed accordingly. - -**Kind**: static method of [app](#app) - -| Param | Type | -| --- | --- | -| url | string | -| handler | function | - - - -### app.use(args) -Default wrapper around `WebApp.use` - -**Kind**: static method of [app](#app) | Param | | --- | -| args | +| actualParams | +| requiredParams | +| debug | + + +## app : Object +Wrapped `WebApp` with express-style get/post and default use routes. + +**Kind**: global constant +**See**: https://docs.meteor.com/packages/webapp.html diff --git a/HISTORY.md b/HISTORY.md index 0b00e19..02ca912 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,15 @@ # History +### 6.0.0 +- Meteor 3 / Express compatibility +- added scope verification in authenticated routes +- improved internal logging +- fix bug in validation for custom models +- fix support for explicit `client.id` field + +## 5.0.0 +- sync support for @node-oauth/oauth2-server 5.x by + ## 4.2.1 - this is a patch release, fixing a syntax error (that never got picked up, due to wrong linter config) diff --git a/lib/middleware/getDebugMiddleware.js b/lib/middleware/getDebugMiddleware.js index b1f70ae..63b2be4 100644 --- a/lib/middleware/getDebugMiddleware.js +++ b/lib/middleware/getDebugMiddleware.js @@ -4,12 +4,29 @@ import { debug } from '../utils/console' * Creates a middleware to debug routes on an instance level * @private * @param instance - * @return {function(*, *, *): *} + * @param options {object?} optional options + * @param options.description {string?} optional way to descrive the next handler + * @param options.data {boolean?} optional flag to log body/query */ -export const getDebugMiddleWare = instance => (req, res, next) => { - if (instance.debug === true) { +export const getDebugMiddleWare = (instance, options = {}) => { + if (!instance.debug) { + return function (req, res, next) { next() } + } + + return function (req, res, next) { const baseUrl = req.originalUrl.split('?')[0] - debug(req.method, baseUrl, req.query || req.body) + let message = `${req.method} ${baseUrl}` + + if (options.description) { + message = `${message} (${options.description})` + } + + if (options.data) { + const data = { query: req.query, body: req.body } + message = `${message} data: ${data}` + } + + debug(message) + next() } - return next() } diff --git a/lib/middleware/secureHandler.js b/lib/middleware/secureHandler.js index 5adaad9..6f4ce57 100644 --- a/lib/middleware/secureHandler.js +++ b/lib/middleware/secureHandler.js @@ -8,11 +8,10 @@ import { bind } from '../utils/bind' * @param handler * @return {Function} */ -export const secureHandler = (self, handler) => bind(function (req, res, next) { +export const secureHandler = (self, handler) => bind(async function (req, res, next) { const that = this - try { - handler.call(that, req, res, next) + return handler.call(that, req, res, next) } catch (anyError) { // to avoid server-crashes we wrap all request handlers and // catch the error here, creating a default 500 response diff --git a/lib/model/meteor-model.js b/lib/model/meteor-model.js index 3cdadd9..ae43fe4 100644 --- a/lib/model/meteor-model.js +++ b/lib/model/meteor-model.js @@ -1,68 +1,76 @@ import { Random } from 'meteor/random' -import { bind } from '../utils/bind' export const collections = { + /** @type {Mongo.Collection} */ AccessTokens: undefined, + /** @type {Mongo.Collection} */ RefreshTokens: undefined, + /** @type {Mongo.Collection} */ Clients: undefined, + /** @type {Mongo.Collection} */ AuthCodes: undefined } /** - * @private used by OAuthMeteorModel.prototype.getAccessToken + * used by OAuthMeteorModel.prototype.getAccessToken + * @private */ -export const getAccessToken = bind(function (bearerToken) { - return collections.AccessTokens.findOne({ accessToken: bearerToken }) -}) +export const getAccessToken = async (accessToken) => { + return collections.AccessTokens.findOneAsync({ accessToken }) +} /** - * @private used by OAuthMeteorModel.prototype.createClient + * used by OAuthMeteorModel.prototype.createClient + * @private */ -export const createClient = bind(function ({ title, homepage, description, privacyLink, redirectUris, grants, clientId, secret }) { - const existingClient = collections.Clients.findOne({ title }) +export const createClient = async ({ title, homepage, description, privacyLink, redirectUris, grants, clientId, secret }) => { + const existingClient = await collections.Clients.findOneAsync({ title }) if (existingClient) { const updateValues = { description, privacyLink, redirectUris, grants } if (clientId) updateValues.clientId = clientId if (secret) updateValues.secret = secret - return collections.Clients.update(existingClient._id, { + return collections.Clients.updateAsync(existingClient._id, { $set: updateValues }) } - - const clientDocId = collections.Clients.insert({ + const id = clientId ?? Random.id(16) + const clientDocId = await collections.Clients.insertAsync({ title, homepage, description, privacyLink, redirectUris, - clientId: clientId || Random.id(16), + clientId: id, + id, // required by oauth-2-server which secret: secret || Random.id(32), grants }) - return collections.Clients.findOne(clientDocId) -}) + return collections.Clients.findOneAsync(clientDocId) +} /** - * @private used by OAuthMeteorModel.prototype.getClient + * used by OAuthMeteorModel.prototype.getClient + * @private */ -export const getClient = bind(function (clientId, secret) { - const clientDoc = collections.Clients.findOne({ +export const getClient = async (clientId, secret) => { + const clientDoc = await collections.Clients.findOneAsync({ clientId, secret: secret || undefined // secret can be undefined or null but should act as the same }) return clientDoc || false -}) +} /** - * @private used by OAuthMeteorModel.prototype.saveToken + * used by OAuthMeteorModel.prototype.saveToken + * @private */ -export const saveToken = bind(function (tokenDoc, clientDoc, userDoc) { - const tokenDocId = collections.AccessTokens.insert({ +export const saveToken = async (tokenDoc, clientDoc, userDoc) => { + const tokenDocId = await collections.AccessTokens.insertAsync({ accessToken: tokenDoc.accessToken, accessTokenExpiresAt: tokenDoc.accessTokenExpiresAt, refreshToken: tokenDoc.refreshToken, @@ -75,78 +83,81 @@ export const saveToken = bind(function (tokenDoc, clientDoc, userDoc) { id: userDoc.id } }) - return collections.AccessTokens.findOne(tokenDocId) -}) + return collections.AccessTokens.findOneAsync(tokenDocId) +} /** - * @private used by OAuthMeteorModel.prototype.getAuthorizationCode + * used by OAuthMeteorModel.prototype.getAuthorizationCode + * @private */ -export const getAuthorizationCode = bind(function (authorizationCode) { - return collections.AuthCodes.findOne({ authorizationCode }) -}) +export const getAuthorizationCode = async (authorizationCode) => { + return collections.AuthCodes.findOneAsync({ authorizationCode }) +} /** - * @private used by OAuthMeteorModel.prototype.saveAuthorizationCode + * used by OAuthMeteorModel.prototype.saveAuthorizationCode + * @private */ -export const saveAuthorizationCode = bind(function saveAuthCode (code, client, user) { +export const saveAuthorizationCode = async (code, client, user) => { const { authorizationCode } = code const { expiresAt } = code const { redirectUri } = code - collections.AuthCodes.upsert({ authorizationCode }, { + await collections.AuthCodes.upsertAsync({ authorizationCode }, { authorizationCode, expiresAt, redirectUri, scope: code.scope, client: { - id: client.clientId + // xxx: fix for newer oauth2-server versions + id: client.id ?? client.clientId }, user: { id: user.id } }) - return collections.AuthCodes.findOne({ authorizationCode }) -}) + return collections.AuthCodes.findOneAsync({ authorizationCode }) +} /** - * @private used by OAuthMeteorModel.prototype.revokeAuthorizationCode + * used by OAuthMeteorModel.prototype.revokeAuthorizationCode + * @private */ -export const revokeAuthorizationCode = bind(function revokeAuthorizationCode ({ authorizationCode }) { - const docCount = collections.AuthCodes.find({ authorizationCode }).count() +export const revokeAuthorizationCode = async ({ authorizationCode }) => { + const docCount = await collections.AuthCodes.countDocuments({ authorizationCode }) if (docCount === 0) { return true } - return collections.AuthCodes.remove({ authorizationCode }) === docCount -}) + const removeCount = await collections.AuthCodes.removeAsync({ authorizationCode }) + return removeCount === docCount +} /** - * @private used by OAuthMeteorModel.prototype.saveRefreshToken + * used by OAuthMeteorModel.prototype.saveRefreshToken + * @private */ -export const saveRefreshToken = bind(function (token, clientId, expires, user) { - return collections.RefreshTokens.insert({ +export const saveRefreshToken = async (token, clientId, expires, user) => { + return collections.RefreshTokens.insertAsync({ refreshToken: token, clientId, userId: user.id, expires }) -}) +} /** - * @private used by OAuthMeteorModel.prototype.getRefreshToken + * used by OAuthMeteorModel.prototype.getRefreshToken + * @private */ -export const getRefreshToken = bind(function (refreshToken) { - return collections.AccessTokens.findOne({ refreshToken }) -}) - -export const revokeToken = bind(function (token) { - const docCount = collections.AccessTokens.find({ refreshToken: token.refreshToken }).count() - if (docCount === 0) { - return true - } +export const getRefreshToken = async (refreshToken) => { + return collections.RefreshTokens.findOneAsync({ refreshToken }) +} - return collections.AccessTokens.remove({ refreshToken: token.refreshToken }) === docCount -}) +export const revokeToken = async (token) => { + const result = await collections.AccessTokens.removeAsync({ refreshToken: token.refreshToken }) + return !!result +} diff --git a/lib/model/model.js b/lib/model/model.js index 8231e22..ff205d9 100644 --- a/lib/model/model.js +++ b/lib/model/model.js @@ -20,9 +20,7 @@ import { class OAuthMeteorModel { constructor (config = {}) { const modelConfig = { ...DefaultModelConfig, ...config } - this.debug = modelConfig.debug - collections.AccessTokens = createCollection(modelConfig.accessTokensCollection, modelConfig.accessTokensCollectionName) collections.RefreshTokens = createCollection(modelConfig.refreshTokensCollection, modelConfig.refreshTokensCollectionName) collections.AuthCodes = createCollection(modelConfig.authCodesCollection, modelConfig.authCodesCollectionName) @@ -36,7 +34,7 @@ class OAuthMeteorModel { log (...args) { if (this.debug === true) { - console.log('[OAuth2Server][model]:', ...args) + console.debug('[OAuth2Server][model]:', ...args) } } @@ -49,7 +47,7 @@ class OAuthMeteorModel { user (Object) */ async getAccessToken (bearerToken) { - this.log('getAccessToken (bearerToken:', bearerToken, ')') + this.log(`getAccessToken (bearerToken: '${bearerToken}')`) return getAccessToken(bearerToken) } @@ -69,6 +67,7 @@ class OAuthMeteorModel { async createClient ({ title, homepage, description, privacyLink, redirectUris, grants, clientId, secret }) { this.log(`createClient (${redirectUris})`) return createClient({ + id: clientId, // xxx: fix for newer oauth2-server versions that explicitly check for .id presence title, homepage, description, @@ -87,7 +86,16 @@ class OAuthMeteorModel { */ async getClient (clientId, secret) { this.log(`getClient (clientId: ${clientId}) (secret: ${secret})`) - return getClient(clientId, secret) + const clientDoc = await getClient(clientId, secret) + if (!clientDoc) return clientDoc + + // xxx: fixes compatibility with newer versions of oauth2-server + // which checks for the client.id value, instead of client.clientId + if (!clientDoc.id) { + clientDoc.id = clientDoc.clientId + } + + return clientDoc } /** @@ -176,6 +184,16 @@ class OAuthMeteorModel { return ['authorization_code', 'refresh_token'].includes(grantType) } + /** + * Compares expected scope from token with actual scope from request + * @param accessToken + * @param scope + * @return {Promise} + */ + async verifyScope (accessToken, scope) { + return accessToken.scope.sort().join(',') === scope.sort().join(',') + } + /** * revokeToken(refreshToken) is required and should return true */ diff --git a/lib/oauth.js b/lib/oauth.js index e5e7930..28c49cf 100644 --- a/lib/oauth.js +++ b/lib/oauth.js @@ -2,9 +2,9 @@ import { Meteor } from 'meteor/meteor' import { check } from 'meteor/check' import { Accounts } from 'meteor/accounts-base' +import * as Log from './utils/console' // utils -import { bind } from './utils/bind' import { getDebugMiddleWare } from './middleware/getDebugMiddleware' // model @@ -41,7 +41,10 @@ const { Request, Response } = OAuthserver * @return {function():Mongo.Cursor} * @private */ -const publishAuthorizedClients = (pubName) => { +const publishAuthorizedClients = (pubName, debug) => { + if (debug) { + Log.debug('publish authorized clients as', pubName) + } return Meteor.publish(pubName, function () { if (!this.userId) { return this.ready() @@ -61,13 +64,14 @@ export class OAuth2Server { * @param model * @param routes * @param debug + * @param logError * @return {OAuth2Server} */ - constructor ({ serverOptions = {}, model, routes, debug } = {}) { + constructor ({ serverOptions = {}, model, routes, debug, logError } = {}) { check(serverOptions, OptionsSchema.serverOptions) if (debug) { - console.debug('[OAuth2Server]: create new instance') - console.debug('[OAuth2Server]: serveroptions', serverOptions) + Log.debug('create new instance') + Log.debug('serveroptions', serverOptions) } this.instanceId = Random.id() this.config = { @@ -87,12 +91,13 @@ export class OAuth2Server { this.app = app this.debug = debug + this.logError = logError const oauthOptions = Object.assign({ model: this.model }, serverOptions) this.oauth = new OAuthserver(oauthOptions) const authorizedPubName = (serverOptions && serverOptions.authorizedPublicationName) || 'authorizedOAuth' - publishAuthorizedClients(authorizedPubName) + publishAuthorizedClients(authorizedPubName, this.debug) initRoutes(this, routes) return this } @@ -121,7 +126,7 @@ export class OAuth2Server { * @param grants * @param clientId * @param secret - * @returns {} + * @returns {object} */ async registerClient ({ title, homepage, description, privacyLink, redirectUris, grants, clientId, secret }) { return this.model.createClient({ @@ -136,6 +141,9 @@ export class OAuth2Server { }) } + /** + * @private + */ authorizeHandler (options) { const self = this return async function (req, res, next) { @@ -144,15 +152,18 @@ export class OAuth2Server { try { const code = await self.oauth.authorize(request, response, options) + Log.debug('authorization code', code) res.locals.oauth = { code: code } next() } catch (err) { - res.writeHead(500) - res.end(err) + res.status(500).json(err) } } } + /** + * @private + */ authenticateHandler (options) { const self = this return async function (req, res, next) { @@ -168,7 +179,8 @@ export class OAuth2Server { status: err.status, error: err.name, description: err.message, - debug: self.debug + debug: self.debug, + logError: self.logError }) } } @@ -177,28 +189,36 @@ export class OAuth2Server { /** * Allows to create `get` or `post` routes, that are only * accessible to authenticated users. + * @param options {object?} optional options + * @param options.scope {string} optional scope to check. Model must implement {verifyScope} if used! * @return {{get:function, post:function}} */ - authenticatedRoute () { + authenticatedRoute (options = {}) { const self = this - const debugMiddleware = getDebugMiddleWare(self) - const authHandler = self.authenticateHandler() + let authOptions + if (options.scope) { + authOptions = { + addAcceptedScopesHeader: true, + addAuthorizedScopesHeader: true, + scope: options.scope + } + } + const authHandler = self.authenticateHandler(authOptions) return { get (route, fn) { - app.get(route, debugMiddleware) - app.get(route, authHandler) - app.get(route, secureHandler(self, fn)) + app.get(route, authHandler, secureHandler(self, fn)) }, post (route, fn) { - return app.post(route, debugMiddleware, authHandler, fn) + app.post(route, authHandler, secureHandler(self, fn)) } } } } -const initRoutes = (self, { accessTokenUrl = '/oauth/token', authorizeUrl = '/oauth/authorize', errorUrl = '/oauth/error', fallbackUrl = '/oauth/*' } = {}) => { - const debugMiddleware = getDebugMiddleWare(self) - +const initRoutes = (self, { + accessTokenUrl = '/oauth/token', + authorizeUrl = '/oauth/authorize' +} = {}) => { const validateResponseType = (req, res) => { const responseType = req.method.toLowerCase() === 'get' ? req.query.response_type @@ -249,19 +269,11 @@ const initRoutes = (self, { accessTokenUrl = '/oauth/token', authorizeUrl = '/oa return redirectUri } - const route = (method, url, handler) => { - const targetFn = self.app[method] - if (self.debug) { - targetFn.call(self.app, url, debugMiddleware) - } - - // we automatically bound any route - // to ensure a functional fiber running - // and to support Meteor and Mongo features - targetFn.call(self.app, url, bind(function (req, res, next) { + const route = ({ method, url, description, handler }) => { + const wrapper = async function (req, res, next) { const that = this try { - handler.call(that, req, res, next) + return handler.call(that, req, res, next) } catch (unknownException) { const state = req && req.query && req.query.state errorHandler(res, { @@ -273,7 +285,23 @@ const initRoutes = (self, { accessTokenUrl = '/oauth/token', authorizeUrl = '/oa originalError: unknownException }) } - })) + } + + const handlers = [] + + if (self.debug) { + const debugMiddleware = getDebugMiddleWare(self, { description }) + handlers.push(debugMiddleware) + Log.debug('Create route', method, url) + } + + handlers.push(wrapper) + + switch (method) { + case 'get': return app.get(url, ...handlers) + case 'post': return app.post(url, ...handlers) + default: return app.use(url, ...handlers) + } } // STEP 1: VALIDATE CLIENT REQUEST @@ -281,85 +309,95 @@ const initRoutes = (self, { accessTokenUrl = '/oauth/token', authorizeUrl = '/oa // 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, async 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 - }) - } + route({ + method: 'get', + url: authorizeUrl, + description: 'step 1 - validate initial request', + handler: async 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 validResponseType = validateResponseType(req, res) + if (!validResponseType) return res.end() - const client = await getValidatedClient(req, res) - if (!client) return + const client = await getValidatedClient(req, res) + if (!client) return res.end() - const redirectUri = getValidatedRedirectUri(req, res, client) - if (!redirectUri) return + const redirectUri = getValidatedRedirectUri(req, res, client) + if (!redirectUri) return res.end() - return next() + next() + } }) // STEP 2: ADD USER TO THE REQUEST // validate all inputs again, since all inputs - // could have been manipulated within form - route('post', authorizeUrl, async 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 - }) - } + // could have been manipulated within the form + route({ + method: 'post', + url: authorizeUrl, + description: 'step 2 - add user to request', + handler: async 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 = await getValidatedClient(req, res) - if (!client) return + const client = await getValidatedClient(req, res) + if (!client) return - const validRedirectUri = getValidatedRedirectUri(req, res, client) - if (!validRedirectUri) 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) - }) + // 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 = await Meteor.users.findOneAsync({ + '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 } + // 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 - }) - } + if (!user || !(await 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 + 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 } }) - } + const updateDoc = req.body.allowed === 'false' + ? { $pull: { 'oauth.authorizedClients': client.clientId } } + : { $addToSet: { 'oauth.authorizedClients': client.clientId } } - // make this work on a post route - req.query.allowed = req.body.allowed + await Meteor.users.updateAsync(id, updateDoc) - return next() + // make this work on a post route + req.query.allowed = req.body.allowed + + return next() + } }) // STEP 3: GENERATE AUTHORIZATION CODE RESPONSE @@ -367,39 +405,41 @@ const initRoutes = (self, { accessTokenUrl = '/oauth/token', authorizeUrl = '/oa // - 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, async function (req, res /*, next */) { - const request = new Request(req) - const response = new Response(res) - const authorizeOptions = { - authenticateHandler: { - handle: function (request, response) { - return request.user + route({ + method: 'post', + url: authorizeUrl, + description: 'step 3 - authorization code response', + handler: async function (req, res /*, next */) { + const request = new Request(req) + const response = new Response(res) + const authorizeOptions = { + authenticateHandler: { + handle: function (request, response) { + return request.user + } } } - } - try { - const code = await self.oauth.authorize(request, response, authorizeOptions) - const query = new URLSearchParams({ - code: code.authorizationCode, - user: req.user.id, - state: req.body.state - }) + try { + const code = await self.oauth.authorize(request, response, authorizeOptions) + 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 (err) { - errorHandler(res, { - originalError: err, - error: err.name, - description: err.message, - status: err.statusCode, - state: req.body.state, - debug: self.debug - }) + const finalRedirectUri = `${req.body.redirect_uri}?${query}` + res.redirect(302, finalRedirectUri) + } catch (err) { + errorHandler(res, { + originalError: err, + error: err.name, + description: err.message, + status: err.statusCode, + state: req.body.state, + debug: self.debug + }) + } } }) @@ -407,50 +447,51 @@ const initRoutes = (self, { accessTokenUrl = '/oauth/token', authorizeUrl = '/oa // - validate params // - validate authorization code // - issue accessToken and refreshToken - route('post', accessTokenUrl, async function (req, res /*, next */) { - if (!validateParams(req.body, req.body?.refresh_token ? requiredRefreshTokenPostParams : 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 - }) - } + route({ + method: 'post', + url: accessTokenUrl, + description: 'step 4 - generate access token response', + handler: async function (req, res /*, next */) { + if (!validateParams(req.body, req.body?.refresh_token ? requiredRefreshTokenPostParams : 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) + // XXX: conformity for the token endpoint + req.headers['Content-Type'] = 'application/x-www-form-urlencoded' - try { - const token = await self.oauth.token(request, response) - 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 (err) { - return errorHandler(res, { - error: 'unauthorized_client', - description: err.message, - state: req.body.state, - debug: self.debug, - status: err.statusCode - }) - } - }) + const request = new Request(req) + const response = new Response(res) - route('use', fallbackUrl, function (req, res, next) { - return errorHandler(res, { - error: 'route not found', - status: 404, - debug: self.debug - }) + try { + const token = await self.oauth.token(request, response) + res + .set({ + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + Pragma: 'no-cache' + }) + .status(200) + .json({ + access_token: token.accessToken, + token_type: 'bearer', + expires_in: token.accessTokenExpiresAt, + refresh_token: token.refreshToken + }) + } catch (err) { + return errorHandler(res, { + error: 'unauthorized_client', + description: err.message, + state: req.body.state, + debug: self.debug, + status: err.statusCode + }) + } + } }) } diff --git a/lib/utils/error.js b/lib/utils/error.js index dd5d6da..4062bbc 100644 --- a/lib/utils/error.js +++ b/lib/utils/error.js @@ -6,6 +6,7 @@ import { error } from './console' * @param res * @param options {Object} options with error information * @param options.error {String} Error name + * @param options.logError {boolean} optional flag to log the erroe to the console * @param options.description {String} Error description * @param options.uri {String?} Optional uri to redirect to when error occurs * @param options.status {Number?} Optional statuscode, defaults to 500 @@ -17,22 +18,23 @@ import { error } from './console' export const errorHandler = function (res, options) { // { error, description, uri, status, state, debug, originalError } const errCode = options.status || 500 - res.writeHead(errCode, { 'Content-Type': 'application/json' }) + res.status(errCode) + res.set({ 'Content-Type': 'application/json' }) // by default we log the error that will be used as response - error(`[error] ${errCode} - ${options.error} - ${options.description}`) + if (options.logError) { + error(`[error] ${errCode} - ${options.error} - ${options.description}`) + } if (options.debug && options.originalError) { error('[original error]:') error(options.originalError) } - const body = JSON.stringify({ + res.json({ error: options.error, error_description: options.description, error_uri: options.uri, state: options.state - }, null, 2) - - res.end(body) + }) } diff --git a/lib/utils/isModelInterface.js b/lib/utils/isModelInterface.js index e963e2f..728f57d 100644 --- a/lib/utils/isModelInterface.js +++ b/lib/utils/isModelInterface.js @@ -34,7 +34,7 @@ const modelNames = [ * @return {boolean} true if valid, otherwise false */ export const isModelInterface = model => { - return model && Object.keys(model).some(property => { - return modelNames.includes(property) && typeof model[property] === 'function' + return model && modelNames.every(name => { + return typeof model[name] === 'function' }) } diff --git a/lib/validation/UserValidation.js b/lib/validation/UserValidation.js index 580b743..202a4ae 100644 --- a/lib/validation/UserValidation.js +++ b/lib/validation/UserValidation.js @@ -7,9 +7,16 @@ import { warn } from '../utils/console' */ export const UserValidation = {} +/** @private */ const validationHandlers = new WeakMap() -UserValidation.register = function (instance = {}, validationHandler) { +/** + * Registers a validation method that allows + * to validate users on custom logic. + * @param instance {OAuth2Server} + * @param validationHandler {function} sync or async function that performs the validation + */ +UserValidation.register = function (instance, validationHandler) { const instanceCheck = { instanceId: instance.instanceId } check(instanceCheck, Match.ObjectIncluding({ instanceId: String @@ -25,7 +32,7 @@ UserValidation.register = function (instance = {}, validationHandler) { * @param handlerArgs {*} * @return {*} should return truthy/falsy value */ -UserValidation.isValid = function (instance = {}, handlerArgs) { +UserValidation.isValid = async function (instance, handlerArgs) { // we assume, that if there is no validation handler registered // then the developers intended to do so. However, we will print an info. if (!validationHandlers.has(instance)) { diff --git a/lib/validation/validateParams.js b/lib/validation/validateParams.js index 36ad52c..c9ca4e5 100644 --- a/lib/validation/validateParams.js +++ b/lib/validation/validateParams.js @@ -1,5 +1,13 @@ import { check } from 'meteor/check' +import { error } from '../utils/console' +/** + * Abstraction that checks given query/body params against a given schema + * @param actualParams + * @param requiredParams + * @param debug + * @return {boolean} + */ export const validateParams = (actualParams, requiredParams, debug) => { if (!actualParams || !requiredParams) { return false @@ -12,7 +20,7 @@ export const validateParams = (actualParams, requiredParams, debug) => { return true } catch (e) { if (debug) { - console.error(`[validation error]: key <${requiredParamKey}> => expected <${expected}>, got <${actual}>`) + error(`[validation error]: key <${requiredParamKey}> => expected <${expected}>, got <${actual}>`) } return false } diff --git a/lib/webapp.js b/lib/webapp.js index 55da2fd..dbc1940 100644 --- a/lib/webapp.js +++ b/lib/webapp.js @@ -1,65 +1,12 @@ import { WebApp } from 'meteor/webapp' -import { info } from './utils/console' import bodyParser from 'body-parser' -/** - * @private - */ -const server = WebApp.connectHandlers -server.use(bodyParser.urlencoded({ extended: false })) - /** * Wrapped `WebApp` with express-style get/post and default use routes. * @see https://docs.meteor.com/packages/webapp.html * @type {{get: get, post: post, use: use}} */ -const app = { - /** - * Creates a get route for a given handler - * @param url {string} - * @param handler {function} - */ - get (url, handler) { - server.use(url, function (req, res, next) { - if (req.method.toLowerCase() === 'get') { - handler.call(this, req, res, next) - } else { - next() - } - }) - }, - /** - * Creates a post route for a given handler. - * If headers' content-type does not equal to `application/x-www-form-urlencoded` - * then it will be transformed accordingly. - * - * @param url {string} - * @param handler {function} - */ - post (url, handler) { - server.use(url, function (req, res, next) { - if (req.method.toLowerCase() === 'post') { - 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) - } - handler.call(this, req, res, next) - } else { - next() - } - }) - }, - - /** - * Default wrapper around `WebApp.use` - * @param args - */ - use (...args) { - server.use(...args) - } -} +export const app = WebApp.handlers -export { app } +app.use(bodyParser.json()) +app.use(bodyParser.urlencoded({ extended: true })) diff --git a/package.js b/package.js index 7b217a6..eb0345f 100644 --- a/package.js +++ b/package.js @@ -1,37 +1,35 @@ /* eslint-env meteor */ Package.describe({ name: 'leaonline:oauth2-server', - version: '5.1.0', + version: '6.0.0', summary: 'Node OAuth2 Server (v4) with Meteor bindings', git: 'https://github.com/leaonline/oauth2-server.git' }) Package.onUse(function (api) { - api.versionsFrom(['1.6', '2.3']) - api.use('ecmascript@0.12.7') + api.versionsFrom(['3.0']) + api.use('ecmascript') api.mainModule('lib/oauth.js', 'server') }) Npm.depends({ - '@node-oauth/oauth2-server': '5.1.0', - 'body-parser': '1.20.0' + '@node-oauth/oauth2-server': '5.2.0', + 'body-parser': '1.20.3' }) Package.onTest(function (api) { api.use([ - 'lmieulet:meteor-legacy-coverage', - 'lmieulet:meteor-coverage@3.2.0', - 'lmieulet:meteor-packages-coverage', - 'meteortesting:mocha@2.0.0' + // FIXME: include, once we have a working coverage for Meteor 3 + // 'lmieulet:meteor-legacy-coverage@0.4.0', + // 'lmieulet:meteor-coverage@4.3.0', + 'meteortesting:mocha@3.2.0' ]) api.use('ecmascript') api.use('mongo') api.use('jkuester:http@2.1.0') - api.use('dburles:mongo-collection-instances') - api.use('accounts-base@2.0.0') - api.use('accounts-password@2.0.0') - api.use('practicalmeteor:chai') - // api.mainModule('oauth-tests.js', 'server') + api.use('dburles:mongo-collection-instances@1.0.0') + api.use('accounts-base') + api.use('accounts-password') api.addFiles([ 'tests/error-tests.js', diff --git a/test-proxy/.meteor/packages b/test-proxy/.meteor/packages index 166dd6f..c6915a7 100644 --- a/test-proxy/.meteor/packages +++ b/test-proxy/.meteor/packages @@ -4,16 +4,16 @@ # 'meteor add' and 'meteor remove' will edit this file for you, # but you can also edit it by hand. -meteor-base@1.5.1 # Packages every Meteor app needs to have -mobile-experience@1.1.1 # Packages for a great mobile UX -mongo@1.16.8 # The database Meteor supports right now -static-html@1.3.2 # Define static page content in .html files -reactive-var@1.0.12 # Reactive variable for tracker -tracker@1.3.3 # Meteor's client-side reactive programming library +meteor-base@1.5.2 # Packages every Meteor app needs to have +mobile-experience@1.1.2 # Packages for a great mobile UX +mongo@2.0.0 # The database Meteor supports right now +static-html@1.3.3 # Define static page content in .html files +reactive-var@1.0.13 # Reactive variable for tracker +tracker@1.3.4 # Meteor's client-side reactive programming library -standard-minifier-css@1.9.2 # CSS minifier run for production mode -standard-minifier-js@2.8.1 # JS minifier run for production mode -es5-shim@4.8.0 # ECMAScript 5 compatibility for older browsers -ecmascript@0.16.8 # Enable ECMAScript2015+ syntax in app code -typescript@4.9.5 # Enable TypeScript syntax in .ts and .tsx modules -shell-server@0.5.0 # Server-side component of the `meteor shell` command +standard-minifier-css@1.9.3 # CSS minifier run for production mode +standard-minifier-js@3.0.0 # JS minifier run for production mode +es5-shim@4.8.1 # ECMAScript 5 compatibility for older browsers +ecmascript@0.16.9 # Enable ECMAScript2015+ syntax in app code +typescript@5.4.3 # Enable TypeScript syntax in .ts and .tsx modules +shell-server@0.6.0 # Server-side component of the `meteor shell` command diff --git a/test-proxy/.meteor/release b/test-proxy/.meteor/release index 966586c..508bc95 100644 --- a/test-proxy/.meteor/release +++ b/test-proxy/.meteor/release @@ -1 +1 @@ -METEOR@2.15 +METEOR@3.0.1 diff --git a/test-proxy/.meteor/versions b/test-proxy/.meteor/versions index 304b1e5..9ffc0b4 100644 --- a/test-proxy/.meteor/versions +++ b/test-proxy/.meteor/versions @@ -1,68 +1,70 @@ -allow-deny@1.1.1 -autoupdate@1.8.0 -babel-compiler@7.10.5 -babel-runtime@1.5.1 -base64@1.0.12 -binary-heap@1.0.11 -blaze-tools@1.1.4 -boilerplate-generator@1.7.2 -caching-compiler@1.2.2 -caching-html-compiler@1.2.2 -callback-hook@1.5.1 -check@1.3.2 -ddp@1.4.1 -ddp-client@2.6.1 -ddp-common@1.4.0 -ddp-server@2.7.0 -diff-sequence@1.1.2 -dynamic-import@0.7.3 -ecmascript@0.16.8 -ecmascript-runtime@0.8.1 -ecmascript-runtime-client@0.12.1 -ecmascript-runtime-server@0.11.0 -ejson@1.1.3 -es5-shim@4.8.0 -fetch@0.1.4 -geojson-utils@1.0.11 -hot-code-push@1.0.4 -html-tools@1.1.4 -htmljs@1.2.0 -id-map@1.1.1 -inter-process-messaging@0.1.1 -launch-screen@2.0.0 -logging@1.3.3 -meteor@1.11.5 -meteor-base@1.5.1 -minifier-css@1.6.4 -minifier-js@2.7.5 -minimongo@1.9.3 -mobile-experience@1.1.1 -mobile-status-bar@1.1.0 -modern-browsers@0.1.10 -modules@0.20.0 -modules-runtime@0.13.1 -mongo@1.16.9 -mongo-decimal@0.1.3 -mongo-dev-server@1.1.0 -mongo-id@1.0.8 -npm-mongo@4.17.2 -ordered-dict@1.1.0 -promise@0.12.2 -random@1.2.1 -react-fast-refresh@0.2.8 -reactive-var@1.0.12 -reload@1.3.1 -retry@1.1.0 -routepolicy@1.1.1 -shell-server@0.5.0 -socket-stream-client@0.5.2 -spacebars-compiler@1.3.2 -standard-minifier-css@1.9.2 -standard-minifier-js@2.8.1 -static-html@1.3.2 -templating-tools@1.2.3 -tracker@1.3.3 -typescript@4.9.5 -underscore@1.6.1 -webapp@1.13.8 -webapp-hashing@1.1.1 +allow-deny@2.0.0 +autoupdate@2.0.0 +babel-compiler@7.11.0 +babel-runtime@1.5.2 +base64@1.0.13 +binary-heap@1.0.12 +blaze-tools@2.0.0 +boilerplate-generator@2.0.0 +caching-compiler@2.0.0 +caching-html-compiler@2.0.0 +callback-hook@1.6.0 +check@1.4.2 +core-runtime@1.0.0 +ddp@1.4.2 +ddp-client@3.0.0 +ddp-common@1.4.3 +ddp-server@3.0.0 +diff-sequence@1.1.3 +dynamic-import@0.7.4 +ecmascript@0.16.9 +ecmascript-runtime@0.8.2 +ecmascript-runtime-client@0.12.2 +ecmascript-runtime-server@0.11.1 +ejson@1.1.4 +es5-shim@4.8.1 +facts-base@1.0.2 +fetch@0.1.5 +geojson-utils@1.0.12 +hot-code-push@1.0.5 +html-tools@2.0.0 +htmljs@2.0.1 +id-map@1.2.0 +inter-process-messaging@0.1.2 +launch-screen@2.0.1 +logging@1.3.5 +meteor@2.0.0 +meteor-base@1.5.2 +minifier-css@2.0.0 +minifier-js@3.0.0 +minimongo@2.0.0 +mobile-experience@1.1.2 +mobile-status-bar@1.1.1 +modern-browsers@0.1.11 +modules@0.20.1 +modules-runtime@0.13.2 +mongo@2.0.0 +mongo-decimal@0.1.4-beta300.7 +mongo-dev-server@1.1.1 +mongo-id@1.0.9 +npm-mongo@4.17.3 +ordered-dict@1.2.0 +promise@1.0.0 +random@1.2.2 +react-fast-refresh@0.2.9 +reactive-var@1.0.13 +reload@1.3.2 +retry@1.1.1 +routepolicy@1.1.2 +shell-server@0.6.0 +socket-stream-client@0.5.3 +spacebars-compiler@2.0.0 +standard-minifier-css@1.9.3 +standard-minifier-js@3.0.0 +static-html@1.3.3 +templating-tools@2.0.0 +tracker@1.3.4 +typescript@5.4.3 +underscore@1.6.4 +webapp@2.0.0 +webapp-hashing@1.1.2 diff --git a/tests/error-tests.js b/tests/error-tests.js index 054cd11..f168e95 100644 --- a/tests/error-tests.js +++ b/tests/error-tests.js @@ -1,17 +1,24 @@ /* eslint-env mocha */ import { Random } from 'meteor/random' -import { assert } from 'meteor/practicalmeteor:chai' +import { assert } from 'chai' import { errorHandler } from '../lib/utils/error' class Res { - writeHead (httpStatus, options) { + status (httpStatus) { this.httpStatus = httpStatus + } + + set (options) { this.options = options } - end (body) { + send (body) { this.body = body } + + json (body) { + this.body = JSON.stringify(body) + } } describe('errorHandler', function () { diff --git a/tests/model-tests.js b/tests/model-tests.js index 4026c28..8ecd194 100644 --- a/tests/model-tests.js +++ b/tests/model-tests.js @@ -17,7 +17,7 @@ const GrantTypes = { const assertCollection = name => { const collection = Mongo.Collection.get(name) assert.isDefined(collection) - assert.equal(collection.constructor.name, 'Collection') + assert.instanceOf(collection, Mongo.Collection) } describe('model', function () { @@ -33,11 +33,11 @@ describe('model', function () { randomClientsName = Random.id() }) - afterEach(function () { - Mongo.Collection.get(DefaultModelConfig.clientsCollectionName).remove({}) - Mongo.Collection.get(DefaultModelConfig.accessTokensCollectionName).remove({}) - Mongo.Collection.get(DefaultModelConfig.refreshTokensCollectionName).remove({}) - Mongo.Collection.get(DefaultModelConfig.authCodesCollectionName).remove({}) + afterEach(async () => { + await Mongo.Collection.get(DefaultModelConfig.clientsCollectionName).removeAsync({}) + await Mongo.Collection.get(DefaultModelConfig.accessTokensCollectionName).removeAsync({}) + await Mongo.Collection.get(DefaultModelConfig.refreshTokensCollectionName).removeAsync({}) + await Mongo.Collection.get(DefaultModelConfig.authCodesCollectionName).removeAsync({}) }) describe('constructor', function () { @@ -81,13 +81,13 @@ describe('model', function () { }) describe('createClient', function () { - it('creates a client with minimum required credentials', function () { + it('creates a client with minimum required credentials', async () => { const model = new OAuthMeteorModel() const title = Random.id() const redirectUris = [Meteor.absoluteUrl(`/${Random.id()}`)] const grants = [GrantTypes.authorization_code] - const clientDocId = Promise.await(model.createClient({ title, redirectUris, grants })) - const clientDoc = Mongo.Collection.get(DefaultModelConfig.clientsCollectionName).findOne(clientDocId) + const clientDocId = await (model.createClient({ title, redirectUris, grants })) + const clientDoc = await Mongo.Collection.get(DefaultModelConfig.clientsCollectionName).findOneAsync(clientDocId) assert.isDefined(clientDoc) assert.isDefined(clientDoc.clientId) @@ -97,15 +97,15 @@ describe('model', function () { assert.deepEqual(clientDoc.grants, grants) }) - it('creates a client with an already given clientId and secret', function () { + it('creates a client with an already given clientId and secret', async () => { const model = new OAuthMeteorModel() const title = Random.id() const clientId = Random.id(16) const secret = Random.id(32) const redirectUris = [Meteor.absoluteUrl(`/${Random.id()}`)] const grants = [GrantTypes.authorization_code] - const clientDocId = Promise.await(model.createClient({ title, redirectUris, grants, clientId, secret })) - const clientDoc = Mongo.Collection.get(DefaultModelConfig.clientsCollectionName).findOne(clientDocId) + const clientDocId = await (model.createClient({ title, redirectUris, grants, clientId, secret })) + const clientDoc = await Mongo.Collection.get(DefaultModelConfig.clientsCollectionName).findOneAsync(clientDocId) assert.isDefined(clientDoc) assert.equal(clientDoc.clientId, clientId) @@ -120,42 +120,42 @@ describe('model', function () { let model let clientDoc - beforeEach(function () { + beforeEach(async () => { model = new OAuthMeteorModel() const title = Random.id() const redirectUris = [Meteor.absoluteUrl(`/${Random.id()}`)] const grants = [GrantTypes.authorization_code] - const clientDocId = Promise.await(model.createClient({ title, redirectUris, grants })) - clientDoc = Mongo.Collection.get(DefaultModelConfig.clientsCollectionName).findOne(clientDocId) + const clientDocId = await (model.createClient({ title, redirectUris, grants })) + clientDoc = await Mongo.Collection.get(DefaultModelConfig.clientsCollectionName).findOneAsync(clientDocId) }) - it('returns a client by clientId', function () { + it('returns a client by clientId', async () => { const { clientId } = clientDoc - const actualClientDoc = Promise.await(model.getClient(clientId)) + const actualClientDoc = await (model.getClient(clientId)) assert.deepEqual(actualClientDoc, clientDoc) }) - it('returns a client on null secret', function () { + it('returns a client on null secret', async () => { const { clientId } = clientDoc - const actualClientDoc = Promise.await(model.getClient(clientId, null)) + const actualClientDoc = await (model.getClient(clientId, null)) assert.deepEqual(actualClientDoc, clientDoc) }) - it('returns false if no client is found', function () { - const falsey = Promise.await(model.getClient(Random.id())) + it('returns false if no client is found', async () => { + const falsey = await (model.getClient(Random.id())) assert.isFalse(falsey) }) - it('returns a client by clientId and clientSecret', function () { + it('returns a client by clientId and clientSecret', async () => { const { clientId } = clientDoc const { secret } = clientDoc - const actualClientDoc = Promise.await(model.getClient(clientId, secret)) + const actualClientDoc = await (model.getClient(clientId, secret)) assert.deepEqual(actualClientDoc, clientDoc) }) - it('returns false if clientSecret is incorrect', function () { + it('returns false if clientSecret is incorrect', async () => { const { clientId } = clientDoc - const falsey = Promise.await(model.getClient(clientId, Random.id())) + const falsey = await (model.getClient(clientId, Random.id())) assert.isFalse(falsey) }) }) @@ -196,7 +196,7 @@ describe('model', function () { it('returns a saved token', async () => { const collection = Mongo.Collection.get(DefaultModelConfig.accessTokensCollectionName) const accessToken = Random.id() - const docId = collection.insert({ accessToken }) + const docId = await collection.insertAsync({ accessToken }) const tokenDoc = await model.getAccessToken(accessToken) expect(tokenDoc).to.deep.equal({ _id: docId, @@ -205,6 +205,44 @@ describe('model', function () { }) }) + describe('verifyScope', () => { + let model + + beforeEach(function () { + model = new OAuthMeteorModel() + }) + + it('returns true if the access token scope meets the expected scope', async () => { + expect(await model.verifyScope({ scope: ['foo'] }, ['foo'])).to.equal(true) + expect(await model.verifyScope({ scope: ['foo'] }, ['foo', 'bar'])).to.equal(false) + expect(await model.verifyScope({ scope: ['foo'] }, [])).to.equal(false) + expect(await model.verifyScope({ scope: [] }, ['foo'])).to.equal(false) + expect(await model.verifyScope({ scope: ['foo', 'bar'] }, ['foo'])).to.equal(false) + }) + }) + + describe('revokeRefreshToken', () => { + let model + + beforeEach(function () { + model = new OAuthMeteorModel() + }) + + it('returns true if the refresh token was revoked', async () => { + const collection = Mongo.Collection.get(DefaultModelConfig.accessTokensCollectionName) + const refreshToken = Random.id() + await collection.insertAsync({ refreshToken }) + const tokenDoc = await model.revokeToken({ refreshToken }) + assert.isTrue(tokenDoc) + }) + + it('returns false if the refresh token was not found', async () => { + const refreshToken = Random.id() + const tokenDoc = await model.revokeToken({ refreshToken }) + assert.isFalse(tokenDoc) + }) + }) + describe('saveAuthorizationCode', function () { it('is not yet implemented') }) diff --git a/tests/oauth-tests.js b/tests/oauth-tests.js index c50a902..3cd7e23 100644 --- a/tests/oauth-tests.js +++ b/tests/oauth-tests.js @@ -1,7 +1,7 @@ /* eslint-env mocha */ import { Meteor } from 'meteor/meteor' import { Mongo } from 'meteor/mongo' -import { assert } from 'meteor/practicalmeteor:chai' +import { assert } from 'chai' import { Random } from 'meteor/random' import { Accounts } from 'meteor/accounts-base' import { HTTP } from 'meteor/jkuester:http' @@ -47,13 +47,18 @@ describe('constructor', function () { it('can be created with a custom model', function () { const model = { - getAccessToken: function () { - return new Promise('works!') - } + getAccessToken: async () => true, + getAuthorizationCode: async () => true, + getClient: async () => true, + getRefreshToken: async () => true, + revokeAuthorizationCode: async () => true, + saveAuthorizationCode: async () => true, + saveRefreshToken: async () => true, + saveToken: async () => true, + revokeToken: async () => true } const server = new OAuth2Server({ model }) assert.isDefined(server) - console.debug(server.model) assert.deepEqual(server.model, model) }) @@ -79,31 +84,31 @@ describe('integration tests of OAuth2 workflows', function () { const logErrors = false const authCodeServer = new OAuth2Server({ debug, model: { debug }, routes }) - const get = (url, params, done, cb) => { + const get = (url, params, cb) => new Promise((resolve, reject) => { const fullUrl = Meteor.absoluteUrl(url) HTTP.get(fullUrl, params, (err, res) => { - if (err && logErrors) console.error(err) + if (err && logErrors) return reject(err) try { cb(res) - done() + resolve() } catch (e) { - done(e) + reject(e) } }) - } + }) - const post = (url, params, done, cb) => { + const post = (url, params, cb) => new Promise((resolve, reject) => { const fullUrl = Meteor.absoluteUrl(url) HTTP.post(fullUrl, params, (err, res) => { - if (err && logErrors) console.error(err) + if (err && logErrors) return reject(err) try { cb(res) - done() + resolve() } catch (e) { - done(e) + reject(e) } }) - } + }) let ClientCollection let clientDoc @@ -116,76 +121,76 @@ describe('integration tests of OAuth2 workflows', function () { redirectUris: [Meteor.absoluteUrl(`/${Random.id()}`)], grants: ['authorization_code'] }) - clientDoc = ClientCollection.findOne(clientDocId) + clientDoc = await ClientCollection.findOneAsync(clientDocId) assert.isDefined(clientDoc) // for the user we are faking the // login token to simulare a user, that is // currently logged in - const userId = Accounts.createUser({ username: Random.id(), password: Random.id() }) + const userId = await Accounts.createUserAsync({ username: Random.id(), password: Random.id() }) const token = Random.id() const hashedToken = Accounts._hashLoginToken(token) - Meteor.users.update(userId, { + await Meteor.users.updateAsync(userId, { $set: { token: token, 'services.resume.loginTokens.hashedToken': hashedToken } }) - user = Meteor.users.findOne(userId) + user = await Meteor.users.findOneAsync(userId) }) describe('Authorization Request', function () { - it('returns a valid response for a valid request', function (done) { + it('returns a valid response for a valid request', async () => { const params = { client_id: clientDoc.clientId, response_type: 'code', redirect_uri: clientDoc.redirectUris[0], state: Random.id() } - get(routes.authorizeUrl, { params }, done, res => { + await get(routes.authorizeUrl, { params }, res => { assert.equal(res.statusCode, 200) assert.equal(res.data, null) }) }) - it('returns an invalid_request error for invalid formed requests', function (done) { + it('returns an invalid_request error for invalid formed requests', async () => { const params = { state: Random.id() } - get(routes.authorizeUrl, { params }, done, (res) => { + await get(routes.authorizeUrl, { params }, (res) => { assert.equal(res.statusCode, 400) assert.equal(res.data.error, 'invalid_request') assert.equal(res.data.state, params.state) }) }) - it('returns unsupported_response_type if the response method is not supported by the server', function (done) { + it('returns unsupported_response_type if the response method is not supported by the server', async () => { const params = { client_id: clientDoc.clientId, response_type: Random.id(), redirect_uri: clientDoc.redirectUris[0], state: Random.id() } - get(routes.authorizeUrl, { params }, done, res => { + await get(routes.authorizeUrl, { params }, res => { assert.equal(res.statusCode, 415) assert.equal(res.data.error, 'unsupported_response_type') assert.equal(res.data.state, params.state) }) }) - it('returns an unauthorized_client error for invalid clients', function (done) { + it('returns an unauthorized_client error for invalid clients', async () => { const params = { client_id: Random.id(), response_type: 'code', redirect_uri: clientDoc.redirectUris[0], state: Random.id() } - get(routes.authorizeUrl, { params }, done, (res) => { + await get(routes.authorizeUrl, { params }, (res) => { assert.equal(res.statusCode, 401) assert.equal(res.data.error, 'unauthorized_client') assert.equal(res.data.state, params.state) }) }) - it('returns an invalid_request on invalid redirect_uri', function (done) { + it('returns an invalid_request on invalid redirect_uri', async () => { const invalidRedirectUri = Meteor.absoluteUrl(`/${Random.id()}`) const params = { client_id: clientDoc.clientId, @@ -193,7 +198,7 @@ describe('integration tests of OAuth2 workflows', function () { redirect_uri: invalidRedirectUri, state: Random.id() } - get(routes.authorizeUrl, { params }, done, (res) => { + await get(routes.authorizeUrl, { params }, (res) => { assert.equal(res.statusCode, 400) assert.equal(res.data.error, 'invalid_request') assert.equal(res.data.error_description, `Invalid redirection uri ${invalidRedirectUri}`) @@ -204,7 +209,7 @@ describe('integration tests of OAuth2 workflows', function () { describe('Authorization Response', function () { [true, false].forEach(followRedirects => { - it(`issues an authorization code and delivers it to the client via redirect follow=${followRedirects}`, function (done) { + it(`issues an authorization code and delivers it to the client via redirect follow=${followRedirects}`, async () => { const params = { client_id: clientDoc.clientId, response_type: 'code', @@ -218,7 +223,7 @@ describe('integration tests of OAuth2 workflows', function () { // redirect and expect a 200 repsonse or, if we don't follow, // we expect a 302 response with location header, which can be used // by the client to manually follow - post(routes.authorizeUrl, { params, followRedirects }, done, res => { + await post(routes.authorizeUrl, { params, followRedirects }, res => { if (followRedirects) { assert.equal(res.statusCode, 200) assert.equal(res.headers.location, undefined) @@ -235,7 +240,7 @@ describe('integration tests of OAuth2 workflows', function () { }) }) - it('returns an access_denied error when no user exists for the given token', function (done) { + it('returns an access_denied error when no user exists for the given token', async () => { const params = { client_id: clientDoc.clientId, response_type: 'code', @@ -244,14 +249,14 @@ describe('integration tests of OAuth2 workflows', function () { token: Random.id(), allowed: undefined } - post(routes.authorizeUrl, { params }, done, res => { + await post(routes.authorizeUrl, { params }, res => { assert.equal(res.statusCode, 400) assert.equal(res.data.error, 'access_denied') assert.equal(res.data.state, params.state) }) }) - it('returns an access_denied error when the user denied the request', function (done) { + it('returns an access_denied error when the user denied the request', async () => { const params = { client_id: clientDoc.clientId, response_type: 'code', @@ -260,7 +265,7 @@ describe('integration tests of OAuth2 workflows', function () { token: user.token, allowed: 'false' } - post(routes.authorizeUrl, { params }, done, res => { + await post(routes.authorizeUrl, { params }, res => { assert.equal(res.statusCode, 400) assert.equal(res.data.error, 'access_denied') assert.equal(res.data.state, params.state) @@ -269,25 +274,25 @@ describe('integration tests of OAuth2 workflows', function () { }) describe('Access Token Request', function () { - it('returns an invalid_request error on missing credentials', function (done) { + it('returns an invalid_request error on missing credentials', async () => { const params = { state: Random.id() } - post(routes.accessTokenUrl, { params }, done, res => { + await post(routes.accessTokenUrl, { params }, res => { assert.equal(res.statusCode, 400) assert.equal(res.data.error, 'invalid_request') assert.equal(res.data.state, params.state) }) }) - it('returns an invalid_request error if the redirect uri is not correct', function (done) { + it('returns an invalid_request error if the redirect uri is not correct', async function () { const authorizationCode = Random.id() const expiresAt = new Date(new Date().getTime() + 30000) - authCodeServer.model.saveAuthorizationCode({ + await authCodeServer.model.saveAuthorizationCode({ authorizationCode, expiresAt, redirectUri: clientDoc.redirectUris[0] - }, { client_id: clientDoc.clientId }, { id: user._id }) + }, clientDoc, { id: user._id }) const params = { code: authorizationCode, @@ -298,21 +303,21 @@ describe('integration tests of OAuth2 workflows', function () { grant_type: 'authorization_code' } - post(routes.accessTokenUrl, { params }, done, res => { + await post(routes.accessTokenUrl, { params }, res => { assert.equal(res.statusCode, 400) assert.equal(res.data.error, 'unauthorized_client') assert.equal(res.data.state, params.state) }) }) - it('returns an unauthorized_client error when the client does not provide a secret', function (done) { + it('returns an unauthorized_client error when the client does not provide a secret', async () => { const authorizationCode = Random.id() const expiresAt = new Date(new Date().getTime() + 30000) - authCodeServer.model.saveAuthorizationCode({ + await authCodeServer.model.saveAuthorizationCode({ authorizationCode, expiresAt, redirectUri: clientDoc.redirectUris[0] - }, {}, { id: user._id }) + }, clientDoc, { id: user._id }) const params = { code: authorizationCode, @@ -323,7 +328,7 @@ describe('integration tests of OAuth2 workflows', function () { grant_type: 'authorization_code' } - post(routes.accessTokenUrl, { params }, done, res => { + await post(routes.accessTokenUrl, { params }, res => { assert.equal(res.statusCode, 400) assert.equal(res.data.error, 'unauthorized_client') assert.equal(res.data.error_description, 'Invalid client: client is invalid') @@ -331,7 +336,7 @@ describe('integration tests of OAuth2 workflows', function () { }) }) - it('returns an unauthorized_client error when the given code is not found', function (done) { + it('returns an unauthorized_client error when the given code is not found', async () => { const params = { code: Random.id(), client_id: clientDoc.clientId, @@ -341,7 +346,7 @@ describe('integration tests of OAuth2 workflows', function () { grant_type: 'authorization_code' } - post(routes.accessTokenUrl, { params }, done, res => { + await post(routes.accessTokenUrl, { params }, res => { assert.equal(res.statusCode, 400) assert.equal(res.data.error, 'unauthorized_client') assert.equal(res.data.error_description, 'Invalid grant: authorization code is invalid') @@ -351,14 +356,16 @@ describe('integration tests of OAuth2 workflows', function () { }) describe('Access Token Response', function () { - it('issues an access token for a valid request', function (done) { + it('issues an access token for a valid request', async () => { const authorizationCode = Random.id() const expiresAt = new Date(new Date().getTime() + 30000) - authCodeServer.model.saveAuthorizationCode({ + const code = { authorizationCode, expiresAt, redirectUri: clientDoc.redirectUris[0] - }, {}, { id: user._id }) + } + + await authCodeServer.model.saveAuthorizationCode(code, clientDoc, { id: user._id }) const params = { code: authorizationCode, @@ -369,11 +376,11 @@ describe('integration tests of OAuth2 workflows', function () { grant_type: 'authorization_code' } - post(routes.accessTokenUrl, { params }, done, res => { + await post(routes.accessTokenUrl, { params }, res => { assert.equal(res.statusCode, 200) const headers = res.headers - assert.equal(headers['content-type'], 'application/json') + assert.equal(headers['content-type'].includes('application/json'), true) assert.equal(headers['cache-control'], 'no-store') assert.equal(headers.pragma, 'no-cache') diff --git a/tests/test-helpers.tests.js b/tests/test-helpers.tests.js index 3d6215f..be25f62 100644 --- a/tests/test-helpers.tests.js +++ b/tests/test-helpers.tests.js @@ -1,8 +1,8 @@ import { Mongo } from 'meteor/mongo' -import { assert } from 'meteor/practicalmeteor:chai' +import { assert } from 'chai' export const assertCollection = name => { const collection = Mongo.Collection.get(name) assert.isDefined(collection) - assert.equal(collection.constructor.name, 'Collection') + assert.instanceOf(collection, Mongo.Collection) } diff --git a/tests/validation-tests.js b/tests/validation-tests.js index 2704c1f..feac2a8 100644 --- a/tests/validation-tests.js +++ b/tests/validation-tests.js @@ -35,7 +35,6 @@ describe('validation', function () { }) describe(UserValidation.register.name, function () { it('throws if key is not an instance with instanceId', function () { - expect(() => UserValidation.register()).to.throw('Match error: Expected string, got undefined in field instanceId') expect(() => UserValidation.register({})).to.throw('Match error: Expected string, got undefined in field instanceId') }) it('throws if fct ist not a function', function () { @@ -43,22 +42,22 @@ describe('validation', function () { }) }) describe(UserValidation.isValid.name, function () { - it('returns true if not registered (skips)', function () { - const instance = { instanceId, debug: true } - expect(UserValidation.isValid()).to.equal(true) - expect(UserValidation.isValid(instance)).to.equal(true) + it('returns true if not registered (skips)', async function () { + const instance = { instanceId } + expect(await UserValidation.isValid({})).to.equal(true) + expect(await UserValidation.isValid(instance)).to.equal(true) }) - it('returns true if registered and handler passes', function () { - const instance = { instanceId, debug: true } + it('returns true if registered and handler passes', async function () { + const instance = { instanceId } const handler = () => true UserValidation.register(instance, handler) - expect(UserValidation.isValid(instance)).to.equal(true) + expect(await UserValidation.isValid(instance)).to.equal(true) }) - it('returns false if registered and handler denies', function () { - const instance = { instanceId, debug: true } + it('returns false if registered and handler denies', async function () { + const instance = { instanceId } const handler = () => false UserValidation.register(instance, handler) - expect(UserValidation.isValid(instance)).to.equal(false) + expect(await UserValidation.isValid(instance)).to.equal(false) }) }) }) diff --git a/tests/webapp-tests.js b/tests/webapp-tests.js index 72a83aa..b7a70d7 100644 --- a/tests/webapp-tests.js +++ b/tests/webapp-tests.js @@ -2,7 +2,7 @@ import { Meteor } from 'meteor/meteor' import { HTTP } from 'meteor/jkuester:http' import { Random } from 'meteor/random' -import { assert } from 'meteor/practicalmeteor:chai' +import { assert } from 'chai' import { app } from '../lib/webapp' const toUrl = path => Meteor.absoluteUrl(path) @@ -70,22 +70,6 @@ describe('webapp', function () { }) }) - it('transforms any request to application/x-www-form-urlencoded', function (done) { - const route = Random.id() - const url = toUrl(route) - - app.post(`/${route}`, function (req, res, next) { - try { - assert.equal(req.headers['content-type'], 'application/x-www-form-urlencoded') - finish(res, done) - } catch (e) { - finish(res, done, e) - } - }) - - HTTP.post(url) - }) - it('creates a POST route which is not reachable via GET request', function (done) { const route = Random.id() const url = toUrl(route)