diff --git a/lib/Client.js b/lib/Client.js index b3c2bedb..0c8ac8da 100644 --- a/lib/Client.js +++ b/lib/Client.js @@ -313,6 +313,14 @@ Client.prototype.getAccessToken = function() { return this.getResource(args.href, args.options, require('./resource/AccessToken'), args.callback); }; +Client.prototype.getApiKeyById = function() { + var args = utils.resolveArgs(arguments, ['id', 'options', 'callback']); + + var href = this.config.client.baseUrl + '/apiKeys/' + args.id; + + return this.getResource(href, args.options, require('./resource/ApiKey'), args.callback); +}; + /** * Retrieves a {@link RefreshToken} resource. * diff --git a/lib/cache/CacheHandler.js b/lib/cache/CacheHandler.js index 93876bf9..25234c7a 100644 --- a/lib/cache/CacheHandler.js +++ b/lib/cache/CacheHandler.js @@ -1,6 +1,7 @@ 'use strict'; var async = require('async'); +var xtend = require('xtend'); var _ = require('../underscore'); var Cache = require('./Cache'); @@ -10,7 +11,7 @@ var utils = require('../utils'); var CACHE_REGIONS = ['applications', 'directories', 'accounts', 'groups', 'groupMemberships', 'tenants', 'accountStoreMappings','apiKeys','idSiteNonces', - 'customData', 'organizations']; + 'customData', 'organizations', 'authTokens', 'accessTokens']; // singleton of DisabledCache wrapped into Cache instance var disabledCache = new Cache({store: DisabledCache}); @@ -123,6 +124,12 @@ function buildCacheableResourcesFromParentObject(object){ if(parentResource.href){ resourcesToCache.push(parentResource); } + + // This happens when a resource is a 302 direct from one to another, + // cache the returned data under the requestedUrl as well + if (object.requestData && object.requestData.originalHref !== object.href) { + resourcesToCache.push(xtend(object,{href: object.requestData.originalHref})); + } } return resourcesToCache; } diff --git a/lib/ds/DataStore.js b/lib/ds/DataStore.js index 3db5e8dd..89292334 100644 --- a/lib/ds/DataStore.js +++ b/lib/ds/DataStore.js @@ -50,6 +50,7 @@ function DataStore(config) { this.cacheHandler = config.cacheHandler || new CacheHandler(config); this.nonceStore = config.nonceStore || new NonceStore(this); this.apiKeyEncryptionOptions = new ApiKeyEncryptedOptions(config.apiKeyEncryptionOptions); + this.resourceRequestLogger = config.resourceRequestLogger; var _request = null; Object.defineProperty(this, '_request', { get: function(){return _request;}, @@ -70,7 +71,12 @@ DataStore.prototype._wrapGetResourceResponse = instance = instantiate(InstanceCtor, data, query, this); } if(instance instanceof ApiKey){ - instance._decrypt(cb); + async.parallel([ + instance._decrypt.bind(instance), + _this.cacheHandler.put.bind(_this.cacheHandler, instance.href, instance) + ],function (err, results) { + cb(err, err ? null : results[0]); + }); } else if(utils.isCollectionData(instance) && instance.items[0] && @@ -132,7 +138,24 @@ DataStore.prototype.getResource = function getResource(/* href, [query], [Instan InstanceCtor: (args.length > 0 && args[0] instanceof Function) ? args.shift() : InstanceResource }; - return _this.exec(callback); + function loggerProxy(err, resource) { + + // TODO - finish this idea of a "resourceRequestLogger" so that it is ready + // for public consumption. Until then, don't bloat resources with the requestData + // object, though we need to keep it until this point so that we can make use + // of the originalHref for caching of access token redirects. + + if (resource) { + if (_this.resourceRequestLogger) { + _this.resourceRequestLogger(resource.requestData); + } + delete resource.requestData; + } + + callback.apply(null, arguments); + } + + return _this.exec(loggerProxy); }; /** @@ -270,6 +293,10 @@ DataStore.prototype._buildRequestQuery = function(){ return queryStringObj; }; +function applyRequestData(resource, data){ + resource.requestData = data; +} + /** * @private * @param callback @@ -289,10 +316,19 @@ DataStore.prototype.exec = function executeRequest(callback){ }; var ctor = _this._request.InstanceCtor; - + var begin = new Date().getTime(); function doRequest(){ try { _this.requestExecutor.execute(request, function onGetResourceRequestResult(err, body) { + if (body) { + applyRequestData(body, { + begin: begin, + end: new Date().getTime(), + originalHref: request.uri, + fromCache: false + }); + } + _this._wrapGetResourceResponse(err, body, ctor, query, callback); }); } catch (err) { @@ -315,6 +351,14 @@ DataStore.prototype.exec = function executeRequest(callback){ _this.cacheHandler.get(cacheKey, function onCacheResult(err, entry) { if (err || entry) { + if (entry) { + applyRequestData(entry, { + begin: begin, + end: new Date().getTime(), + originalHref: request.uri, + fromCache: true + }); + } _this._wrapGetResourceResponse(err, entry, ctor, query, callback); return; } diff --git a/lib/jwt/jwt-authenticator.js b/lib/jwt/jwt-authenticator.js index 1c9c0379..1b7fdfcb 100644 --- a/lib/jwt/jwt-authenticator.js +++ b/lib/jwt/jwt-authenticator.js @@ -1,6 +1,7 @@ 'use strict'; var njwt = require('njwt'); +var util = require('util'); var OauthAccessTokenAuthenticator = require('../authc/OauthAccessTokenAuthenticator'); var ApiAuthRequestError = require('../error/ApiAuthRequestError'); @@ -11,6 +12,8 @@ var JwtAuthenticationResult = require('./jwt-authentication-result'); * * @constructor * + * @deprecated Please use {@link StormpathAccessTokenAuthenticator} instead. + * * @description * * Creates an authenticator that can be used to validate JWTs that have been @@ -60,13 +63,16 @@ JwtAuthenticator.prototype.withLocalValidation = function withLocalValidation() return this; }; -JwtAuthenticator.prototype.withCookie = function withCookie(cookieName){ +JwtAuthenticator.prototype.withCookie = function withCookie(cookieName) { this.configuredCookieName = cookieName; return this; }; -JwtAuthenticator.prototype.unauthenticated = function unauthenticated(){ - return new ApiAuthRequestError({userMessage:'Unauthorized', statusCode: 401}); +JwtAuthenticator.prototype.unauthenticated = function unauthenticated() { + return new ApiAuthRequestError({ + userMessage: 'Unauthorized', + statusCode: 401 + }); }; /** @@ -89,81 +95,80 @@ JwtAuthenticator.prototype.unauthenticated = function unauthenticated(){ * }); * */ -JwtAuthenticator.prototype.authenticate = function authenticate(token,cb){ +JwtAuthenticator.prototype.authenticate = util.deprecate(function authenticate(token, cb) { var self = this; - var secret = self.application.dataStore.requestExecutor.options.client.apiKey.secret; try { - njwt.verify(token,secret,function(err,jwt){ - if(err){ + njwt.verify(token, secret, function(err, jwt) { + if (err) { err.statusCode = 401; - cb(err); - }else{ - - // If the KID exists, this was issued by our API from a password grant - - if(jwt.header.kid){ - if(self.localValidation){ - // Transfers all body fields to `claims` to maintain consistency - // with remote results. Does not remove the body as to preserve - // backwards compatibility. - jwt.claims = {}; - Object.keys(jwt.body).forEach(function(key) { - if (jwt.body.hasOwnProperty(key)) { - jwt.claims[key] = jwt.body[key]; - } - }); - - return cb(null, new JwtAuthenticationResult(self.application,{ - jwt: token, - expandedJwt: jwt, - localValidation: true, - account: { - href: jwt.body.sub - } - })); + return cb(err); + } + + // If there is no KID, this means it was issued by the SDK (not the + // API) from a client credentials grant so we have to do remote + // validation in a different way. + if (!jwt.header.kid) { + var authenticator = new OauthAccessTokenAuthenticator(self.application, token); + return authenticator.authenticate(cb); + } + + // If the KID exists, this was issued by our API from a password grant + if (self.localValidation) { + // Transfers all body fields to `claims` to maintain consistency + // with remote results. Does not remove the body as to preserve + // backwards compatibility. + jwt.claims = {}; + + Object.keys(jwt.body).forEach(function(key) { + if (jwt.body.hasOwnProperty(key)) { + jwt.claims[key] = jwt.body[key]; + } + }); + + return cb(null, new JwtAuthenticationResult(self.application, { + jwt: token, + expandedJwt: jwt, + localValidation: true, + account: { + href: jwt.body.sub } - var href = self.application.href + '/authTokens/' + token; - self.application.dataStore.getResource(href,function(err,response){ - if(err){ + })); + } + + var href = self.application.href + '/authTokens/' + token; + + self.application.dataStore.getResource(href, function(err, response) { + if (err) { + return cb(err); + } + + // Preserve scope + if (jwt.body.scope) { + return njwt.verify(response.jwt, secret, function(err, newJwt) { + if (err) { cb(err); - }else{ - // Preserve scope - if (jwt.body.scope) { - return njwt.verify(response.jwt, secret, function(err, newJwt) { - if (err) { - cb(err); - } - - // Copy the scope on the authorized token - newJwt.body.scope = jwt.body.scope; - newJwt.setSigningKey(secret); - response.jwt = newJwt.compact(); - response.expandedJwt.claims.scope = jwt.body.scope; - - cb(null, new JwtAuthenticationResult(self.application,response)); - }); - } - - cb(null, new JwtAuthenticationResult(self.application,response)); } - }); - }else{ - // If there is no KID, this means it was issued by the SDK (not the - // API) from a client credentials grant so we have to do remote - // validation in a different way. - var authenticator = new OauthAccessTokenAuthenticator(self.application, token); - authenticator.authenticate(cb); + // Copy the scope on the authorized token + newJwt.body.scope = jwt.body.scope; + newJwt.setSigningKey(secret); + response.jwt = newJwt.compact(); + response.expandedJwt.claims.scope = jwt.body.scope; + + cb(null, new JwtAuthenticationResult(self.application, response)); + }); } - } + + cb(null, new JwtAuthenticationResult(self.application, response)); + }); }); - } catch (err) { + } catch (err) { cb(err); } return this; -}; +}, 'JwtAuthenticator is deprecated, please use StormpathAccessTokenAuthenticator instead.'); module.exports = JwtAuthenticator; diff --git a/lib/oauth/stormpath-access-token-authentication-result.js b/lib/oauth/stormpath-access-token-authentication-result.js new file mode 100644 index 00000000..f586f9dd --- /dev/null +++ b/lib/oauth/stormpath-access-token-authentication-result.js @@ -0,0 +1,102 @@ +'use strict'; + +var utils = require('../utils'); + +/** + * @constructor + * + * @description + * + * Encapsulates the access token resource response, obtained from the `/accessTokens` collection. + * + * @param {Client} client + * An initialized Stormpath Client for the tenant the issued the token. + * + * @param {AccessTokenResponse} accessTokenResponse + * The access token response from the Stormpath REST API. + */ +function StormpathAccessTokenAuthenticationResult(client, data) { + if (!(this instanceof StormpathAccessTokenAuthenticationResult)) { + return new StormpathAccessTokenAuthenticationResult(client, data); + } + + Object.defineProperty(this, 'client', { + enumerable: false, + value: client + }); + + for (var key in data) { + if (data.hasOwnProperty(key)) { + this[key] = data[key]; + } + } +} + +/** + * @name StormpathAccessTokenAuthenticationResult#account + * + * @description + * + * An object literal with an href pointer to the account that has authenticated. + * Use {@link StormpathAccessTokenAuthenticationResult#getAccount StormpathAccessTokenAuthenticationResult.getAccount()} + * to fetch the full {@link Account} resource. + * + * @type {Object} + */ +StormpathAccessTokenAuthenticationResult.prototype.account = null; + +/** + * An object literal with an href pointer to the application that issued this + * token. Use {@link StormpathAccessTokenAuthenticationResult#getApplication StormpathAccessTokenAuthenticationResult.getApplication()} + * to fetch the full {@link Application} resource. + * + * @type {Object} + */ +StormpathAccessTokenAuthenticationResult.prototype.application = null; + +/** + * @name StormpathAccessTokenAuthenticationResult#jwt + * + * @description + * + * The JWT access token string that was provided for authentication. + * + * @type {String} + */ +StormpathAccessTokenAuthenticationResult.prototype.jwt = null; + +/** + * @name StormpathAccessTokenAuthenticationResult#expandedJwt + * + * @description + * + * An object that allows you to inspect the body, claims, and header of the + * access token. + * + * @type {Object} + */ +StormpathAccessTokenAuthenticationResult.prototype.expandedJwt = null; + +/** + * @function + * + * @description Get the account resource of the account that has authenticated. + * + * @param {ExpansionOptions} options + * Options for expanding the fetched {@link Account} resource. + * + * @param {Function} callback + * The callback to call with the parameters (err, {@link Account}). + */ +StormpathAccessTokenAuthenticationResult.prototype.getAccount = function getAccount( /* [options,] callback */ ) { + var args = utils.resolveArgs(arguments, ['options', 'callback'], true); + this.client.getAccount(this.account.href, args.options, require('../resource/Account'), args.callback); +}; + +StormpathAccessTokenAuthenticationResult.prototype.getApplication = function getApplication( /* [options,] callback */ ) { + var args = utils.resolveArgs(arguments, ['options', 'callback'], true); + + this.client.getApplication(this.application.href, args.options, require('../resource/Application'), args.callback); +}; + +module.exports = StormpathAccessTokenAuthenticationResult; diff --git a/lib/oauth/stormpath-access-token-authenticator.js b/lib/oauth/stormpath-access-token-authenticator.js new file mode 100644 index 00000000..69d8db80 --- /dev/null +++ b/lib/oauth/stormpath-access-token-authenticator.js @@ -0,0 +1,179 @@ +var nJwt = require('njwt'); +var Client = require('../Client'); +var StormpathAccessTokenAuthenticationResult = require('./stormpath-access-token-authentication-result'); + +function tenantAdminKeyResolver(client, kid, callback) { + client.getApiKeyById(kid, function(err, apiKey) { + if (err) { + return callback(err); + } + + apiKey.getAccount(function(err, account) { + if (err) { + return callback(err); + } + + account.getDirectory(function(err, directory) { + if (err) { + return callback(err); + } + + if (directory.name === 'Stormpath Administrators') { + return callback(null, apiKey.secret); + } + + callback(new Error('Invalid kid')); + }); + }); + }); +} + +/** + * @class + * + * @constructor + * + * @description + * + * Creates an authenticator that can be used to validate access tokens that have + * been issued by a Stormpath Tenant, using API Keys created for accounts in that + * tenant's "Stormpath Administrators" directory. Access tokens can be issued + * with one of the following methods: + * + * - {@link OAuthClientCredentialsAuthenticator#authenticate OAuthClientCredentialsAuthenticator.authenticate()} + * - {@link OAuthPasswordGrantRequestAuthenticator#authenticate OAuthPasswordGrantRequestAuthenticator.authenticate()} + * - {@link OAuthStormpathTokenAuthenticator#authenticate OAuthStormpathTokenAuthenticator.authenticate()} + * + * @param {Client} client A constructed {@link Client} instance, which will be bound to + * the Stormpath Tenant in which access token tokens will be validated. + * + * @example + * var client = new stormpath.Client(); + * + * var authenticator = new stormpath.StormpathAccessTokenAuthenticator(client); + * + */ +function StormpathAccessTokenAuthenticator(client) { + if (!(this instanceof StormpathAccessTokenAuthenticator)) { + return new StormpathAccessTokenAuthenticator(client); + } + + if (!(client instanceof Client)) { + throw new Error('StormpathAccessTokenAuthenticator must be given a Stormpath client instance'); + } + + this.client = client; + this.verifier = nJwt.createVerifier().withKeyResolver(tenantAdminKeyResolver.bind(null, client)); +} + +StormpathAccessTokenAuthenticator.prototype.localValidation = false; + +/** + * Calling this method will convert this authenticator to "local validation" mode. + * In this mode, the authenticator will cache the related access token resource, + * and subsequent authentication attempts will skip the REST API request for the + * access token resource, until that resource expires from the local cache, as + * configured by your caching rules (see {@link Client}). This can speed up your + * authentication layer, as the authentication is now done locally without a + * network request. + * + * **Warning. This mode has a security tradeoff.** Because of the caching nature + * of this mode, access tokens will be considred valid until the expire, and your + * local application will not know if another process as deleted this resource from + * the Stormpath REST API. When using local validation, we suggest shorter + * expiration times, as configured by the issuing application's {@link OAuthPolicy}. + * + * @example + * + * var authenticator = new stormpath.StormpathAccessTokenAuthenticator(client); + * + * authenticator.withLocalValidation(); + */ +StormpathAccessTokenAuthenticator.prototype.withLocalValidation = function withLocalValidation() { + this.localValidation = true; + return this; +}; + +/** + * Indicate a specific Stormpath Application as an additional authorization check. + * If an application is specified, the authentication attempt will fail if the + * access token was not issued by the specified application. + * + * @param {Application|Object} application An {@link Application} instance, or object + * literal with an href property that indicates the desired application. + * + * @example + * + * var client = new stormpath.Client(); + * + * var authenticator = new stormpath.StormpathAccessTokenAuthenticator(client); + * + * var applicationHref = 'https://api.stormpath.com/v1/applications/3WIeKpaEjPHfLmy6GIvbwv'; + * + * authenticator.forApplication({ + * href: applicationHref + * }); + */ +StormpathAccessTokenAuthenticator.prototype.forApplication = function forApplication(application) { + this.forApplicationHref = application.href; + return this; +}; + +/** + * Authenticates an access token, in the form of compact JWT string. + * + * @param {String} jwtAccessTokenString The compact JWT string + * @param {Function} callback Will be called with (err, {@link StormpathAccessTokenAuthenticationResult}). + * + * @example + * var jwtAccessTokenString = 'eyJraWQiOiI2NldURFJVM1paSkNZVFJVVlZTUUw3WEJOIiwic3R0IjoiYWNjZXNzIiwiYWxnIjoiSFMyNTYifQ.eyJqdGkiOiIzV0llS3N1SmR6YWR5YzN4U1ltc1l6IiwiaWF0IjoxNDY5ODMzNzQ3LCJpc3MiOiJodHRwczovL2FwaS5zdG9ybXBhdGguY29tL3YxL2FwcGxpY2F0aW9ucy8yNGs3SG5ET3o0dFE5QVJzQnRQVU42Iiwic3ViIjoiaHR0cHM6Ly9hcGkuc3Rvcm1wYXRoLmNvbS92MS9hY2NvdW50cy8yRWRHb3htbGpuODBlRHZjM0JzS05EIiwiZXhwIjoxNDY5ODM0MzQ3LCJydGkiOiIzV0llS3BhRWpQSGZMbXk2R0l2Ynd2In0.9J7HvhgJZxvxuE-0PiarTDTFPCVVLR_nvRByULNA01Q'; + * + * authenticator.authenticate(jwtAccessTokenString, function(err, authenticationResult) { + * if (err) { + * console.log(err); + * } else { + * authenticationResult.getAccount(function(err, account){ + * console.log('Authenticated Account', account); + * }); + * } + * }); + */ +StormpathAccessTokenAuthenticator.prototype.authenticate = function authenticate(jwtAccessTokenString, callback) { + var self = this; + + self.verifier.verify(jwtAccessTokenString, function(err, jwt) { + if (err) { + return callback(err); + } + + var resourceHref; + + // Validate against the provided application, if configured. + // Otherwise, validate directly against the access token resource + if (self.forApplicationHref) { + resourceHref = self.forApplicationHref + '/authTokens/' + jwtAccessTokenString; + } else { + resourceHref = self.client.config.client.baseUrl + '/accessTokens/' + jwt.body.jti; + } + + // Bypass the cache if local validation is disabled. + var query = self.localValidation ? null : { + nocache: true + }; + + self.client.getResource(resourceHref, query, function(err, authTokenResponse) { + if (err) { + return callback(err); + } + + // If the incoming token has scope, preserve that for the developer. + if (jwt.body.scope) { + authTokenResponse.expandedJwt.claims.scope = jwt.claims.scope; + } + + return callback(null, new StormpathAccessTokenAuthenticationResult(self.client, authTokenResponse)); + }); + }); +}; + +module.exports = StormpathAccessTokenAuthenticator; diff --git a/lib/resource/Application.js b/lib/resource/Application.js index 0c9d2cb6..7e34945a 100644 --- a/lib/resource/Application.js +++ b/lib/resource/Application.js @@ -1143,6 +1143,11 @@ Application.prototype.getAccountStoreMappings = function getAccountStoreMappings return this.dataStore.getResource(this.accountStoreMappings.href, args.options, ApplicationAccountStoreMapping, args.callback); }; +Application.prototype.getAuthToken = function getAccountStoreMappings(/* jwtAccessTokenString, [options,] callback */) { + var args = utils.resolveArgs(arguments, ['jwtAccessTokenString', 'options', 'callback'], true); + return this.dataStore.getResource(this.href + '/authTokens/' + args. jwtAccessTokenString, args.options, InstanceResource, args.callback); +}; + /** * Retrieves the {@link ApplicationAccountStoreMapping} that represents the link * to the Application's default Account Store, which is the {@link Directory} diff --git a/lib/stormpath.js b/lib/stormpath.js index f06e55ea..b61e0377 100644 --- a/lib/stormpath.js +++ b/lib/stormpath.js @@ -25,6 +25,7 @@ module.exports = { OAuthClientCredentialsAuthenticationResult: clientCredentialsGrant.authenticationResult, SamlIdpUrlBuilder: require('./saml/SamlIdpUrlBuilder'), AssertionAuthenticationResult: require('./authc/AssertionAuthenticationResult'), + StormpathAccessTokenAuthenticator: require('./oauth/stormpath-access-token-authenticator'), StormpathAssertionAuthenticator: require('./authc/StormpathAssertionAuthenticator'), JwtAuthenticator: require('./jwt/jwt-authenticator'), OAuthAuthenticator: require('./oauth/authenticator') diff --git a/package.json b/package.json index c7b9a9a9..0c2b6d20 100644 --- a/package.json +++ b/package.json @@ -46,14 +46,15 @@ "jwt-simple": "~0.4.0", "memcached": "~2.2.2", "moment": "^2.15.2", - "njwt": "^0.3.1", + "njwt": "^0.4.0", "properties-parser": "~0.3.1", "redis": "~2.6.2", "request": "~2.74.0", "stormpath-config": "0.0.27", "underscore": "~1.5.2", "underscore.string": "~3.2.3", - "uuid": "^3.0.0" + "uuid": "^3.0.0", + "xtend": "^4.0.1" }, "devDependencies": { "benchmark": "^2.0.0", diff --git a/test/it/access-token-authenticator.js b/test/it/access-token-authenticator.js new file mode 100644 index 00000000..02e26886 --- /dev/null +++ b/test/it/access-token-authenticator.js @@ -0,0 +1,199 @@ +var async = require('async'); +var nJwt = require('njwt'); +var uuid = require('uuid'); + +var common = require('../common'); +var helpers = require('./helpers'); +var assert = common.assert; + +var stormpath = require('../../'); +var StormpathAccessTokenAuthenticationResult = require('../../lib/oauth/stormpath-access-token-authentication-result'); + +function getAccessTokenOrFail(account, application, done, callback) { + stormpath.OAuthAuthenticator(application).authenticate({ + body: { + grant_type: 'password', + username: account.username, + password: account.password + } + }, function(err, result) { + if (err) { + return done(err); + } + + callback(result.accessTokenResponse.access_token); + }); +} + +describe('StormpathAccessTokenAuthenticator', function() { + var application1, application2; + var environmentApiKey, environmentClient; + var newAccount = helpers.fakeAccount(); + var otherApiKey, otherClient; + + before(function(done) { + async.parallel({ + application: helpers.createApplication.bind(null), + apiKey: function(next) { + // Create another API Key in this tenant, so that we can test the ability to + // validate a token with multiple keys within the same tenant. + helpers.getClient(function(client) { + environmentClient = client; + environmentApiKey = client.config.client.apiKey; + + client.getDirectories({ + name: 'Stormpath Administrators', + expand: 'accounts' + }, function(err, collection) { + if (err) { + return done(err); + } + + var href = collection.items[0].accounts.items[0].href + '/apiKeys'; + + client.createResource(href, null, function(err, apiKey) { + if (err) { + return done(err); + } + + next(null, apiKey); + }); + }); + }); + } + }, function(err, results) { + if (err) { + return done(err); + } + + otherApiKey = results.apiKey; + application1 = results.application; + + otherClient = new stormpath.Client({ + apiKey: results.apiKey + }); + + async.parallel({ + account: application1.createAccount.bind(application1, newAccount), + application: helpers.createApplication.bind(null) + }, function(err, results) { + if (err) { + return done(err); + } + + application2 = results.application; + + done(); + }); + }); + }); + + after(function(done) { + async.parallel([ + helpers.cleanupApplicationAndStores.bind(null, application1), + helpers.cleanupApplicationAndStores.bind(null, application2), + otherApiKey.delete.bind(otherApiKey) + ], done); + }); + + it('should validate tokens that are issued by the current tenant', function(done) { + // Test that two different keys for the same tenant will yield the same result + getAccessTokenOrFail(newAccount, application1, done, function(accessToken) { + var authenticatorA = stormpath.StormpathAccessTokenAuthenticator(environmentClient); + var authenticatorB = stormpath.StormpathAccessTokenAuthenticator(otherClient); + + async.parallel({ + resultA: authenticatorA.authenticate.bind(authenticatorA, accessToken), + resultB: authenticatorB.authenticate.bind(authenticatorB, accessToken) + }, function(err, results) { + + if (err) { + return done(err); + } + + assert.instanceOf(results.resultA, StormpathAccessTokenAuthenticationResult); + assert.instanceOf(results.resultB, StormpathAccessTokenAuthenticationResult); + + done(); + }); + }); + }); + + it('should reject tokens that are not signed by a key belonging to the current tenant', function(done) { + // A token that is not signed by this tenant + var otherJwt = nJwt.create({ + hello: 'world' + }, uuid()); + + otherJwt.header.kid = 'foo'; + + var otherToken = otherJwt.compact(); + var authenticator = stormpath.StormpathAccessTokenAuthenticator(environmentClient); + + authenticator.authenticate(otherToken, function(err) { + assert.instanceOf(err, Error); + assert.equal(err.message, 'Error while resolving signing key for kid "foo"'); + done(); + }); + }); + + it('should reject tokens that try to forge the kid header', function(done) { + var otherJwt = nJwt.create({ + hello: 'world' + }, uuid()); + + otherJwt.header.kid = environmentApiKey.id; + var otherToken = otherJwt.compact(); + + var authenticator = stormpath.StormpathAccessTokenAuthenticator(environmentClient); + + authenticator.authenticate(otherToken, function(err) { + assert.instanceOf(err, Error); + assert.equal(err.message, 'Signature verification failed'); + done(); + }); + }); + + it('should reject tokens if they are not issued by the expected application', function(done) { + getAccessTokenOrFail(newAccount, application1, done, function(accessToken) { + var authenticator = stormpath.StormpathAccessTokenAuthenticator(environmentClient); + authenticator.forApplication(application2); + + authenticator.authenticate(accessToken, function(err) { + assert.instanceOf(err, Error); + assert.equal(err.code, 10014); + done(); + }); + }); + }); + + it('should allow caching for faster authentication', function(done) { + getAccessTokenOrFail(newAccount, application1, done, function(accessToken) { + var authenticator = stormpath.StormpathAccessTokenAuthenticator(new stormpath.Client()); + + authenticator.withLocalValidation(); + + // First attempt with a new client, will take longer due to fetching resources for the first time. + authenticator.authenticate(accessToken, function(err) { + if (err) { + return done(err); + } + + // Second attempt should come back immediately, because + // we use a memory cache by default + var now = new Date().getTime(); + + authenticator.authenticate(accessToken, function(err) { + if (err) { + return done(err); + } + + var delta = new Date().getTime() - now; + assert.isBelow(delta, 10); + + done(); + }); + }); + }); + }); +}); diff --git a/test/sp.client_test.js b/test/sp.client_test.js index e489b610..fc867d85 100644 --- a/test/sp.client_test.js +++ b/test/sp.client_test.js @@ -7,6 +7,7 @@ var expect = common.expect; var Account = require('../lib/resource/Account'); var Application = require('../lib/resource/Application'); +var ApiKey = require('../lib/resource/ApiKey'); var Challenge = require('../lib/resource/Challenge'); var Client = require('../lib/Client'); var DataStore = require('../lib/ds/DataStore'); @@ -79,7 +80,11 @@ describe('Client', function () { client.on('ready', function () { expect(client._dataStore.requestExecutor.baseUrl).to.equal('https://api.stormpath.com/v1'); // restore environment value - process.env.STORMPATH_CLIENT_BASEURL = oldValue; + if (oldValue) { + process.env.STORMPATH_CLIENT_BASEURL = oldValue; + } else { + delete process.env.STORMPATH_CLIENT_BASEURL; + } done(); }); @@ -99,7 +104,11 @@ describe('Client', function () { client.on('ready', function () { expect(client._dataStore.requestExecutor.baseUrl).to.equal(url); // restore environment value - process.env.STORMPATH_CLIENT_BASEURL = oldValue; + if (oldValue) { + process.env.STORMPATH_CLIENT_BASEURL = oldValue; + } else { + delete process.env.STORMPATH_CLIENT_BASEURL; + } done(); }); }); @@ -107,6 +116,7 @@ describe('Client', function () { it('should allow me to change the base url through the environment',function(done){ // temporarily set a new environment provided url, save the old one if it exists var oldValue = process.env.STORMPATH_CLIENT_BASEURL; + process.env.STORMPATH_CLIENT_BASEURL = 'https://foo/v1'; var client = makeTestClient({apiKey: apiKey }); @@ -118,7 +128,11 @@ describe('Client', function () { client.on('ready', function () { expect(client._dataStore.requestExecutor.baseUrl).to.equal('https://foo/v1'); // restore environment value - process.env.STORMPATH_CLIENT_BASEURL = oldValue; + if (oldValue) { + process.env.STORMPATH_CLIENT_BASEURL = oldValue; + } else { + delete process.env.STORMPATH_CLIENT_BASEURL; + } done(); }); }); @@ -1027,6 +1041,41 @@ describe('Client', function () { }); }); + describe('call to get ApiKeyById', function () { + var sandbox, client, getResourceStub, cbSpy, href; + + before(function (done) { + sandbox = sinon.sandbox.create(); + cbSpy = sandbox.spy(); + href = 'https://api.stormpath.com/v1/apiKeys/foo'; + + client = makeTestClient(); + + client.on('error', function (err) { + throw err; + }); + + client.on('ready', function () { + getResourceStub = sandbox.stub(client._dataStore, 'getResource', function (href, options, ctor, cb) { + cb(); + }); + + client.getApiKeyById('foo', null, cbSpy); + + done(); + }); + }); + + after(function () { + sandbox.restore(); + }); + + it('should get account', function () { + getResourceStub.should.have.been + .calledWith(href, null, ApiKey, cbSpy); + }); + }); + describe('call to get application', function () { var sandbox, client, getResourceStub, cbSpy, href, opt; before(function (done) {