Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions examples/ropc-with-public-clients/hooks.js
Original file line number Diff line number Diff line change
@@ -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);
};
81 changes: 81 additions & 0 deletions examples/ropc-with-public-clients/server.js
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 1 addition & 1 deletion lib/cc/grantToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion lib/cc/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
10 changes: 7 additions & 3 deletions lib/common/makeSetup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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?",
Expand Down
4 changes: 2 additions & 2 deletions lib/common/validateGrantTokenRequest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand All @@ -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;
}
Expand Down
59 changes: 32 additions & 27 deletions lib/ropc/grantToken.js
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -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);
}
Expand All @@ -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));
}
};
2 changes: 1 addition & 1 deletion lib/ropc/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading