From 594617c2d7cf312c3a75b567929a4587f718823e Mon Sep 17 00:00:00 2001 From: Domenic Denicola Date: Sun, 27 Apr 2014 22:46:28 -0400 Subject: [PATCH] Add public clients option to ROPC. Closes #21. See also #20. --- README.md | 20 ++ examples/ropc-with-public-clients/hooks.js | 54 ++++++ examples/ropc-with-public-clients/server.js | 81 +++++++++ lib/cc/grantToken.js | 2 +- lib/cc/index.js | 2 +- lib/common/makeSetup.js | 10 +- lib/common/validateGrantTokenRequest.js | 4 +- lib/ropc/grantToken.js | 59 +++--- lib/ropc/index.js | 2 +- package.json | 3 +- test/ropc-unit.coffee | 171 +++++++++++++++++- ...opc-with-public-clients-integration.coffee | 98 ++++++++++ 12 files changed, 462 insertions(+), 44 deletions(-) create mode 100644 examples/ropc-with-public-clients/hooks.js create mode 100644 examples/ropc-with-public-clients/server.js create mode 100644 test/ropc-with-public-clients-integration.coffee diff --git a/README.md b/README.md index f3c0c10..0606e04 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,25 @@ server error while looking up the token. If the token is valid, it is likely use object indicating that so that your routes can check it later, e.g. `req.authenticated = true` or `req.username = lookupUsernameFrom(token)`. +#### Allowing Public Clients + +The Resource Owner Password Credentials flow can be used with both public and confidential [client types][]. If you +wish to allow public clients, i.e. you want to allow your server to grant tokens without requiring client credentials, +then you can specify this by passing the string `"allow public clients"` in place of a `validateClient` hook. That is: + +```js +restifyOAuth2.ropc(server, { + hooks: { + validateClient: "allow public clients", + grantUserToken: function () { ... }, + authenticateToken: function () { ... } + } +}); +``` + +In this case `grantUserToken` (and `grantScopes`, below) will not be passed `clientId` and `clientSecret` values in +their credentials objects. + ### Scope-Granting Hook Optionally, it is possible to limit the [scope][] of the issued tokens, so that you can implement an authorization @@ -197,6 +216,7 @@ A secret resource that only authenticated users can access. [oauth2-token-rel]: http://tools.ietf.org/html/draft-wmills-oauth-lrdd-07#section-3.2 [web-linking]: http://tools.ietf.org/html/rfc5988 [www-authenticate]: http://tools.ietf.org/html/rfc2617#section-3.2.1 +[client types]: http://tools.ietf.org/html/rfc6749#section-2.1 [scope]: http://tools.ietf.org/html/rfc6749#section-3.3 [example ROPC hooks]: https://github.com/domenic/restify-oauth2/blob/master/examples/ropc/hooks.js [example CC hooks]: https://github.com/domenic/restify-oauth2/blob/master/examples/cc/hooks.js diff --git a/examples/ropc-with-public-clients/hooks.js b/examples/ropc-with-public-clients/hooks.js new file mode 100644 index 0000000..66063b8 --- /dev/null +++ b/examples/ropc-with-public-clients/hooks.js @@ -0,0 +1,54 @@ +"use strict"; + +var _ = require("underscore"); +var crypto = require("crypto"); + +var database = { + users: { + AzureDiamond: { password: "hunter2" }, + Cthon98: { password: "*********" } + }, + tokensToUsernames: {} +}; + +function generateToken(data) { + var random = Math.floor(Math.random() * 100001); + var timestamp = (new Date()).getTime(); + var sha256 = crypto.createHmac("sha256", random + "WOO" + timestamp); + + return sha256.update(data).digest("base64"); +} + +exports.validateClient = "allow public clients"; + +exports.grantUserToken = function (credentials, req, cb) { + var isValid = _.has(database.users, credentials.username) && + database.users[credentials.username].password === credentials.password; + if (isValid) { + // If the user authenticates, generate a token for them and store it so `exports.authenticateToken` below + // can look it up later. + + var token = generateToken(credentials.username + ":" + credentials.password); + database.tokensToUsernames[token] = credentials.username; + + // Call back with the token so Restify-OAuth2 can pass it on to the client. + return cb(null, token); + } + + // Call back with `false` to signal the username/password combination did not authenticate. + // Calling back with an error would be reserved for internal server error situations. + cb(null, false); +}; + +exports.authenticateToken = function (token, req, cb) { + if (_.has(database.tokensToUsernames, token)) { + // If the token authenticates, set the corresponding property on the request, and call back with `true`. + // The routes can now use these properties to check if the request is authorized and authenticated. + req.username = database.tokensToUsernames[token]; + return cb(null, true); + } + + // If the token does not authenticate, call back with `false` to signal that. + // Calling back with an error would be reserved for internal server error situations. + cb(null, false); +}; diff --git a/examples/ropc-with-public-clients/server.js b/examples/ropc-with-public-clients/server.js new file mode 100644 index 0000000..971c749 --- /dev/null +++ b/examples/ropc-with-public-clients/server.js @@ -0,0 +1,81 @@ +"use strict"; + +var restify = require("restify"); +var restifyOAuth2 = require("../.."); +var hooks = require("./hooks"); + +// NB: we're using [HAL](http://stateless.co/hal_specification.html) here to communicate RESTful links among our +// resources, but you could use any JSON linking format, or XML, or even just Link headers. + +var server = restify.createServer({ + name: "Example Restify-OAuth2 Resource Owner Password Credentials Server", + version: require("../../package.json").version, + formatters: { + "application/hal+json": function (req, res, body) { + return res.formatters["application/json"](req, res, body); + } + } +}); + +var RESOURCES = Object.freeze({ + INITIAL: "/", + TOKEN: "/token", + PUBLIC: "/public", + SECRET: "/secret" +}); + +server.use(restify.authorizationParser()); +server.use(restify.bodyParser({ mapParams: false })); +restifyOAuth2.ropc(server, { tokenEndpoint: RESOURCES.TOKEN, hooks: hooks }); + + + +server.get(RESOURCES.INITIAL, function (req, res) { + var response = { + _links: { + self: { href: RESOURCES.INITIAL }, + "http://rel.example.com/public": { href: RESOURCES.PUBLIC } + } + }; + + if (req.username) { + response._links["http://rel.example.com/secret"] = { href: RESOURCES.SECRET }; + } else { + response._links["oauth2-token"] = { + href: RESOURCES.TOKEN, + "grant-types": "password", + "token-types": "bearer" + }; + } + + res.contentType = "application/hal+json"; + res.send(response); +}); + +server.get(RESOURCES.PUBLIC, function (req, res) { + res.send({ + "public resource": "is public", + "it's not even": "a linked HAL resource", + "just plain": "application/json", + "personalized message": req.username ? "hi, " + req.username + "!" : "hello stranger!" + }); +}); + +server.get(RESOURCES.SECRET, function (req, res) { + if (!req.username) { + return res.sendUnauthenticated(); + } + + var response = { + "users with a token": "have access to this secret data", + _links: { + self: { href: RESOURCES.SECRET }, + parent: { href: RESOURCES.INITIAL } + } + }; + + res.contentType = "application/hal+json"; + res.send(response); +}); + +server.listen(8080); diff --git a/lib/cc/grantToken.js b/lib/cc/grantToken.js index c29e21f..8ea5527 100644 --- a/lib/cc/grantToken.js +++ b/lib/cc/grantToken.js @@ -5,7 +5,7 @@ var finishGrantingToken = require("../common/finishGrantingToken"); var makeOAuthError = require("../common/makeOAuthError"); module.exports = function grantToken(req, res, next, options) { - if (!validateGrantTokenRequest("client_credentials", req, next)) { + if (!validateGrantTokenRequest("client_credentials", req, next, { validateAuthorization: true })) { return; } diff --git a/lib/cc/index.js b/lib/cc/index.js index a2ea112..18ca855 100644 --- a/lib/cc/index.js +++ b/lib/cc/index.js @@ -6,4 +6,4 @@ var grantToken = require("./grantToken"); var grantTypes = "client_credentials"; var requiredHooks = ["grantClientToken", "authenticateToken"]; -module.exports = makeSetup(grantTypes, requiredHooks, grantToken); +module.exports = makeSetup(grantTypes, requiredHooks, grantToken, { allowAllowPublicClients: true }); diff --git a/lib/common/makeSetup.js b/lib/common/makeSetup.js index 3348683..228d0d9 100644 --- a/lib/common/makeSetup.js +++ b/lib/common/makeSetup.js @@ -2,7 +2,7 @@ var _ = require("underscore"); -module.exports = function makeSetup(grantTypes, requiredHooks, grantToken) { +module.exports = function makeSetup(grantTypes, requiredHooks, grantToken, setupOptions) { var errorSenders = require("./makeErrorSenders")(grantTypes); var handleAuthenticatedResource = require("./makeHandleAuthenticatedResource")(errorSenders); @@ -11,18 +11,22 @@ module.exports = function makeSetup(grantTypes, requiredHooks, grantToken) { throw new Error("Must supply hooks."); } requiredHooks.forEach(function (hookName) { - if (typeof options.hooks[hookName] !== "function") { + if (options.hooks[hookName] === undefined) { throw new Error("Must supply " + hookName + " hook."); } }); - if (typeof options.hooks.grantScopes !== "function") { + if (options.hooks.grantScopes === undefined) { // By default, grant no scopes. options.hooks.grantScopes = function (credentials, scopesRequested, req, cb) { cb(null, []); }; } + if (options.hooks.validateClient === "allow public clients" && !setupOptions.allowAllowPublicClients) { + throw new Error("Public clients are not allowed for this OAuth2 flow."); + } + options = _.defaults(options, { tokenEndpoint: "/token", wwwAuthenticateRealm: "Who goes there?", diff --git a/lib/common/validateGrantTokenRequest.js b/lib/common/validateGrantTokenRequest.js index 339e6e3..41bece4 100644 --- a/lib/common/validateGrantTokenRequest.js +++ b/lib/common/validateGrantTokenRequest.js @@ -3,7 +3,7 @@ var _ = require("underscore"); var makeOAuthError = require("./makeOAuthError"); -module.exports = function validateGrantTokenRequest(grantType, req, next) { +module.exports = function validateGrantTokenRequest(grantType, req, next, validationOptions) { function sendBadRequestError(type, description) { next(makeOAuthError("BadRequest", type, description)); } @@ -24,7 +24,7 @@ module.exports = function validateGrantTokenRequest(grantType, req, next) { return false; } - if (!req.authorization || !req.authorization.basic) { + if (validationOptions.validateAuthorization && (!req.authorization || !req.authorization.basic)) { sendBadRequestError("invalid_request", "Must include a basic access authentication header."); return false; } diff --git a/lib/ropc/grantToken.js b/lib/ropc/grantToken.js index a26d2fa..6483976 100644 --- a/lib/ropc/grantToken.js +++ b/lib/ropc/grantToken.js @@ -1,17 +1,13 @@ "use strict"; +var _ = require("underscore"); var validateGrantTokenRequest = require("../common/validateGrantTokenRequest"); var finishGrantingToken = require("../common/finishGrantingToken"); var makeOAuthError = require("../common/makeOAuthError"); module.exports = function grantToken(req, res, next, options) { - function sendUnauthorizedError(type, description) { - res.header("WWW-Authenticate", "Basic realm=\"" + description + "\""); - next(makeOAuthError("Unauthorized", type, description)); - } - - - if (!validateGrantTokenRequest("password", req, next)) { + var validateAuthorization = options.hooks.validateClient !== "allow public clients"; + if (!validateGrantTokenRequest("password", req, next, { validateAuthorization: validateAuthorization })) { return; } @@ -26,21 +22,31 @@ module.exports = function grantToken(req, res, next, options) { return next(makeOAuthError("BadRequest", "invalid_request", "Must specify password field.")); } - var clientId = req.authorization.basic.username; - var clientSecret = req.authorization.basic.password; - var clientCredentials = { clientId: clientId, clientSecret: clientSecret }; + if (options.hooks.validateClient === "allow public clients") { + validateUser({}); + } else { + var clientId = req.authorization.basic.username; + var clientSecret = req.authorization.basic.password; + var clientCredentials = { clientId: clientId, clientSecret: clientSecret }; + + options.hooks.validateClient(clientCredentials, req, function (error, result) { + if (error) { + return next(error); + } + + if (!result) { + return sendUnauthorizedError("invalid_client", "Client ID and secret did not validate."); + } - options.hooks.validateClient(clientCredentials, req, function (error, result) { - if (error) { - return next(error); - } + validateUser({ clientId: clientId, clientSecret: clientSecret }); + }); + } - if (!result) { - return sendUnauthorizedError("invalid_client", "Client ID and secret did not validate."); - } + function validateUser(credentials) { + credentials.username = username; + credentials.password = password; - var allCredentials = { clientId: clientId, clientSecret: clientSecret, username: username, password: password }; - options.hooks.grantUserToken(allCredentials, req, function (error, token) { + options.hooks.grantUserToken(credentials, req, function (error, token) { if (error) { return next(error); } @@ -49,14 +55,13 @@ module.exports = function grantToken(req, res, next, options) { return sendUnauthorizedError("invalid_grant", "Username and password did not authenticate."); } - var allCredentials = { - clientId: clientId, - clientSecret: clientSecret, - username: username, - password: password, - token: token - }; + var allCredentials = _.extend({ token: token }, credentials); finishGrantingToken(allCredentials, token, options, req, res, next); }); - }); + } + + function sendUnauthorizedError(type, description) { + res.header("WWW-Authenticate", "Basic realm=\"" + description + "\""); + next(makeOAuthError("Unauthorized", type, description)); + } }; diff --git a/lib/ropc/index.js b/lib/ropc/index.js index 0f637ff..8d47879 100644 --- a/lib/ropc/index.js +++ b/lib/ropc/index.js @@ -6,4 +6,4 @@ var grantToken = require("./grantToken"); var grantTypes = "password"; var requiredHooks = ["validateClient", "grantUserToken", "authenticateToken"]; -module.exports = makeSetup(grantTypes, requiredHooks, grantToken); +module.exports = makeSetup(grantTypes, requiredHooks, grantToken, { allowAllowPublicClients: true }); diff --git a/package.json b/package.json index 8f3c6f3..5b4e16f 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,11 @@ "bugs": "http://github.com/domenic/restify-oauth2/issues", "main": "lib/index.js", "scripts": { - "test": "npm run test-ropc-unit && npm run test-cc-unit && npm run test-ropc-integration && npm run test-cc-integration && npm run test-cc-with-scopes-integration", + "test": "npm run test-ropc-unit && npm run test-cc-unit && npm run test-ropc-integration && npm run test-ropc-with-public-clients-integration && npm run test-cc-integration && npm run test-cc-with-scopes-integration", "test-ropc-unit": "mocha test/ropc-unit.coffee --reporter spec --compilers coffee:coffee-script", "test-cc-unit": "mocha test/cc-unit.coffee --reporter spec --compilers coffee:coffee-script", "test-ropc-integration": "vows test/ropc-integration.coffee --spec", + "test-ropc-with-public-clients-integration": "vows test/ropc-with-public-clients-integration.coffee --spec", "test-cc-integration": "vows test/cc-integration.coffee --spec", "test-cc-with-scopes-integration": "vows test/cc-with-scopes-integration.coffee --spec", "lint": "jshint lib && jshint examples" diff --git a/test/ropc-unit.coffee b/test/ropc-unit.coffee index 310f61b..a1cc937 100644 --- a/test/ropc-unit.coffee +++ b/test/ropc-unit.coffee @@ -94,8 +94,33 @@ beforeEach -> } } + optionsWithPublicClients = { + tokenEndpoint + wwwAuthenticateRealm + tokenExpirationTime + hooks: { + validateClient: "allow public clients" + @authenticateToken + @grantUserToken + } + } + + optionsWithScopeAndPublicClients = { + tokenEndpoint + wwwAuthenticateRealm + tokenExpirationTime + hooks: { + validateClient: "allow public clients" + @authenticateToken + @grantUserToken + @grantScopes + } + } + @doIt = => restifyOAuth2.ropc(@server, options) @doItWithScopes = => restifyOAuth2.ropc(@server, optionsWithScope) + @doItWithPublicClients = => restifyOAuth2.ropc(@server, optionsWithPublicClients) + @doItWithScopesAndPublicClient = => restifyOAuth2.ropc(@server, optionsWithScopeAndPublicClients) describe "Resource Owner Password Credentials flow", -> it "should set up the token endpoint", -> @@ -345,17 +370,147 @@ describe "Resource Owner Password Credentials flow", -> @grantUserToken.should.not.have.been.called describe "without an authorization header", -> - it "should send a 400 response with error_type=invalid_request", -> - @doIt() + describe "when public clients are disallowed", -> + it "should send a 400 response with error_type=invalid_request", -> + @doIt() - @res.should.be.an.oauthError("BadRequest", "invalid_request", - "Must include a basic access authentication header.") + @res.should.be.an.oauthError("BadRequest", "invalid_request", + "Must include a basic access authentication header.") - it "should not call the `validateClient` or `grantUserToken` hooks", -> - @doIt() + it "should not call the `validateClient` or `grantUserToken` hooks", -> + @doIt() + + @validateClient.should.not.have.been.called + @grantUserToken.should.not.have.been.called + + describe "when public clients are allowed", -> + beforeEach -> + baseDoIt = @doItWithPublicClients + @doIt = => + baseDoIt() + @postToTokenEndpoint() + + describe "and the request has a username field", -> + beforeEach -> + @username = "username123" + @req.body.username = @username + + describe "and a password field", -> + beforeEach -> + @password = "password456" + @req.body.password = @password + + it "should not validate the client", -> + @doIt() + + @validateClient.should.not.have.been.called + + it "should use the username and password body fields to grant a token", -> + @doIt() + + @grantUserToken.should.have.been.calledWith( + { @username, @password }, + @req + ) + + describe "when `grantUserToken` calls back with a token", -> + beforeEach -> + @token = "token123" + @grantUserToken.yields(null, @token) + + describe "and a `grantScopes` hook is defined", -> + beforeEach -> + baseDoIt = @doItWithScopesAndPublicClient + @doIt = => + baseDoIt() + @postToTokenEndpoint() + @requestedScopes = ["one", "two"] + @req.body.scope = @requestedScopes.join(" ") + + it "should use only the resource-owner credentials to grant scopes", -> + @doIt() + + @grantScopes.should.have.been.calledWith( + { @username, @password, @token }, + @requestedScopes + ) + + describe "and a `grantScopes` hook is not defined", -> + beforeEach -> @grantScopes = undefined + + it "should send a response with access_token, token_type, and expires_in " + + "set", -> + @doIt() + + @res.send.should.have.been.calledWith( + access_token: @token, + token_type: "Bearer" + expires_in: tokenExpirationTime + ) + + it "should call `next`", -> + @doIt() + + @tokenNext.should.have.been.calledWithExactly() + + describe "when `grantUserToken` calls back with `false`", -> + beforeEach -> @grantUserToken.yields(null, false) + + it "should send a 401 response with error_type=invalid_grant", -> + @doIt() + + @res.should.be.an.oauthError("Unauthorized", "invalid_grant", + "Username and password did not authenticate.") + + describe "when `grantUserToken` calls back with `null`", -> + beforeEach -> @grantUserToken.yields(null, null) + + it "should send a 401 response with error_type=invalid_grant", -> + @doIt() + + @res.should.be.an.oauthError("Unauthorized", "invalid_grant", + "Username and password did not authenticate.") + + describe "when `grantUserToken` calls back with an error", -> + beforeEach -> + @error = new Error("Bad things happened, internally.") + @grantUserToken.yields(@error) + + it "should call `next` with that error", -> + @doIt() + + @tokenNext.should.have.been.calledWithExactly(@error) + + describe "that has no password field", -> + beforeEach -> @req.body.password = null + + it "should send a 400 response with error_type=invalid_request", -> + @doIt() + + @res.should.be.an.oauthError("BadRequest", "invalid_request", + "Must specify password field.") + + it "should not call the `validateClient` or `grantUserToken` hooks", -> + @doIt() + + @validateClient.should.not.have.been.called + @grantUserToken.should.not.have.been.called + + describe "that has no username field", -> + beforeEach -> @req.body.username = null + + it "should send a 400 response with error_type=invalid_request", -> + @doIt() + + @res.should.be.an.oauthError("BadRequest", "invalid_request", + "Must specify username field.") + + it "should not call the `validateClient` or `grantUserToken` hooks", -> + @doIt() + + @validateClient.should.not.have.been.called + @grantUserToken.should.not.have.been.called - @validateClient.should.not.have.been.called - @grantUserToken.should.not.have.been.called describe "with an authorization header that does not contain basic access credentials", -> beforeEach -> diff --git a/test/ropc-with-public-clients-integration.coffee b/test/ropc-with-public-clients-integration.coffee new file mode 100644 index 0000000..899549a --- /dev/null +++ b/test/ropc-with-public-clients-integration.coffee @@ -0,0 +1,98 @@ +"use strict" + +apiEasy = require("api-easy") +require("chai").should() + +require("../examples/ropc-with-public-clients/server") # starts the server + +[username, password] = ["AzureDiamond", "hunter2"] + +# State modified by some tests, and then used by later ones +accessToken = null + +suite = apiEasy.describe("Restify–OAuth2 Example Server") + +suite.before "Set token if available", (outgoing) -> + if accessToken + outgoing.headers.Authorization = "Bearer #{accessToken}" + + return outgoing + +suite + .use("localhost", 8080) # TODO: https!! + .get("/secret") + .expect(401) + .expect("should respond with WWW-Authenticate and Link headers", (err, res, body) -> + expectedLink = '; rel="oauth2-token"; grant-types="password"; token-types="bearer"' + + res.headers.should.have.property("www-authenticate").that.equals('Bearer realm="Who goes there?"') + res.headers.should.have.property("link").that.equals(expectedLink) + ) + .next() + .get("/") + .expect( + 200, + _links: + self: href: "/" + "http://rel.example.com/public": href: "/public" + "oauth2-token": + href: "/token" + "grant-types": "password" + "token-types": "bearer" + ) + .next() + .get("/public") + .expect(200) + .next() + .path("/token") + .discuss("with a basic authentication header and valid user credentials") + .setHeader("Authorization", "Basic MTIzOjQ1Ng==") + .setHeader("Content-Type", "application/json") + .post({ grant_type: "password", username, password }) + .expect(200) + .expect("should respond with the token", (err, res, body) -> + result = JSON.parse(body) + + result.should.have.property("token_type", "Bearer") + result.should.have.property("access_token") + + accessToken = result.access_token + ) + .undiscuss() + .discuss("with no basic authenticaiton header and valid user credentials") + .setHeader("Content-Type", "application/json") + .post({ grant_type: "password", username, password }) + .expect(200) + .expect("should respond with the token", (err, res, body) -> + result = JSON.parse(body) + + result.should.have.property("token_type", "Bearer") + result.should.have.property("access_token") + + accessToken = result.access_token + ) + .undiscuss() + .discuss("with invalid user credentials") + .setHeader("Content-Type", "application/json") + .post({ grant_type: "password", username: "blargh", password: "asdf" }) + .expect(401) + .expect("should respond with error: invalid_grant", (err, res, body) -> + JSON.parse(body).should.have.property("error", "invalid_grant") + ) + .undiscuss() + .unpath().next() + .get("/") + .expect( + 200, + _links: + self: href: "/" + "http://rel.example.com/public": href: "/public" + "http://rel.example.com/secret": href: "/secret" + ) + .get("/secret") + .expect(200) + .next() + .get("/public") + .expect(200) + .next() +.export(module)