diff --git a/lib/clientBuilder.js b/lib/clientBuilder.js index 61f1db24c..bc95bfc65 100644 --- a/lib/clientBuilder.js +++ b/lib/clientBuilder.js @@ -98,7 +98,8 @@ function OktaAuthBuilder(args) { getWithRedirect: util.bind(token.getWithRedirect, sdk, sdk), parseFromUrl: util.bind(token.parseFromUrl, sdk, sdk), decode: util.bind(token.decodeToken, sdk), - refresh: util.bind(token.refreshToken, sdk, sdk) + refresh: util.bind(token.refreshToken, sdk, sdk), + getUserInfo: util.bind(token.getUserInfo, sdk, sdk) }; // This is exposed so we can set window.location in our tests diff --git a/lib/http.js b/lib/http.js index b438eb559..4e397c075 100644 --- a/lib/http.js +++ b/lib/http.js @@ -5,7 +5,14 @@ var Q = require('q'); var AuthApiError = require('./errors/AuthApiError'); var config = require('./config'); -function httpRequest(sdk, url, method, args, dontSaveResponse) { +function httpRequest(sdk, options) { + options = options || {}; + var url = options.url, + method = options.method, + args = options.args, + dontSaveResponse = options.dontSaveResponse, + accessToken = options.accessToken; + var headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', @@ -13,13 +20,17 @@ function httpRequest(sdk, url, method, args, dontSaveResponse) { }; util.extend(headers, sdk.options.headers || {}); - var options = { + if (accessToken && util.isString(accessToken)) { + headers['Authorization'] = 'Bearer ' + accessToken; + } + + var ajaxOptions = { headers: headers, data: args || undefined }; var err, res; - return new Q(sdk.options.ajaxRequest(method, url, options)) + return new Q(sdk.options.ajaxRequest(method, url, ajaxOptions)) .then(function(resp) { res = resp.responseText; if (res && util.isString(res)) { @@ -70,12 +81,21 @@ function httpRequest(sdk, url, method, args, dontSaveResponse) { function get(sdk, url, saveResponse) { url = util.isAbsoluteUrl(url) ? url : sdk.options.url + url; - return httpRequest(sdk, url, 'GET', undefined, !saveResponse); + return httpRequest(sdk, { + url: url, + method: 'GET', + dontSaveResponse: !saveResponse + }); } function post(sdk, url, args, dontSaveResponse) { url = util.isAbsoluteUrl(url) ? url : sdk.options.url + url; - return httpRequest(sdk, url, 'POST', args, dontSaveResponse); + return httpRequest(sdk, { + url: url, + method: 'POST', + args: args, + dontSaveResponse: dontSaveResponse + }); } module.exports = { diff --git a/lib/session.js b/lib/session.js index 011db7ef8..6257a63b5 100644 --- a/lib/session.js +++ b/lib/session.js @@ -36,7 +36,11 @@ function getSession(sdk) { } function closeSession(sdk) { - return http.httpRequest(sdk, sdk.options.url + '/api/v1/sessions/me', 'DELETE', undefined, true); + return http.httpRequest(sdk, { + url: sdk.options.url + '/api/v1/sessions/me', + method: 'DELETE', + dontSaveResponse: true + }); } function refreshSession(sdk) { diff --git a/lib/token.js b/lib/token.js index 4f374b2be..86ae352b2 100644 --- a/lib/token.js +++ b/lib/token.js @@ -677,6 +677,34 @@ function parseFromUrl(sdk, url) { }); } +function getUserInfo(sdk, accessTokenObject) { + if (!accessTokenObject || + (!isToken(accessTokenObject) && !accessTokenObject.accessToken)) { + return Q.reject(new AuthSdkError('getUserInfo requires an access token object')); + } + return http.httpRequest(sdk, { + url: sdk.options.url + '/oauth2/v1/userinfo', + method: 'GET', + dontSaveResponse: true, + accessToken: accessTokenObject.accessToken + }) + .fail(function(err) { + if (err.xhr && (err.xhr.status === 401 || err.xhr.status === 403)) { + var authenticateHeader = err.xhr.getResponseHeader('WWW-Authenticate'); + if (authenticateHeader) { + var errorMatches = authenticateHeader.match(/error="(.*?)"/) || []; + var errorDescriptionMatches = authenticateHeader.match(/error_description="(.*?)"/) || []; + var error = errorMatches[1]; + var errorDescription = errorDescriptionMatches[1]; + if (error && errorDescription) { + err = new OAuthError(error, errorDescription); + } + } + } + throw err; + }); +} + module.exports = { getToken: getToken, getWithoutPrompt: getWithoutPrompt, @@ -686,5 +714,6 @@ module.exports = { refreshIdToken: refreshIdToken, decodeToken: decodeToken, verifyIdToken: verifyIdToken, - refreshToken: refreshToken + refreshToken: refreshToken, + getUserInfo: getUserInfo }; diff --git a/test/spec/token.js b/test/spec/token.js index 649813113..840b22afa 100644 --- a/test/spec/token.js +++ b/test/spec/token.js @@ -1,13 +1,16 @@ define(function(require) { var OktaAuth = require('OktaAuth'); var tokens = require('../util/tokens'); + var util = require('../util/util'); var oauthUtil = require('../util/oauthUtil'); + var packageJson = require('../../package.json'); + var Q = require('q'); - describe('token.decode', function () { + function setupSync() { + return new OktaAuth({ url: 'http://example.okta.com' }); + } - function setupSync() { - return new OktaAuth({ url: 'http://example.okta.com' }); - } + describe('token.decode', function () { it('correctly decodes a token', function () { var oa = setupSync(); @@ -947,4 +950,118 @@ define(function(require) { } ); }); + + describe('token.getUserInfo', function() { + util.itMakesCorrectRequestResponse({ + title: 'allows retrieving UserInfo', + setup: { + request: { + uri: '/oauth2/v1/userinfo', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Okta-User-Agent-Extended': 'okta-auth-js-' + packageJson.version, + 'Authorization': 'Bearer ' + tokens.standardAccessToken + } + }, + response: 'userinfo' + }, + execute: function (test) { + return test.oa.token.getUserInfo(tokens.standardAccessTokenParsed); + }, + expectations: function (test, res) { + expect(res).toEqual({ + 'sub': '00u15ozp26ACQTGHJEBH', + 'email': 'samljackson@example.com', + 'email_verified': true + }); + } + }); + + it('throws an error if no arguments are passed instead', function(done) { + return Q.resolve(setupSync()) + .then(function (oa) { + return oa.token.getUserInfo(); + }) + .then(function () { + expect('not to be hit').toBe(true); + }) + .fail(function (err) { + expect(err.name).toEqual('AuthSdkError'); + expect(err.errorSummary).toBe('getUserInfo requires an access token object'); + }) + .fin(function () { + done(); + }); + }); + + it('throws an error if a string is passed instead of an accessToken object', function(done) { + return Q.resolve(setupSync()) + .then(function (oa) { + return oa.token.getUserInfo('just a string'); + }) + .then(function () { + expect('not to be hit').toBe(true); + }) + .fail(function (err) { + expect(err.name).toEqual('AuthSdkError'); + expect(err.errorSummary).toBe('getUserInfo requires an access token object'); + }) + .fin(function () { + done(); + }); + }); + + util.itErrorsCorrectly({ + title: 'returns correct error for 403', + setup: { + request: { + uri: '/oauth2/v1/userinfo', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Okta-User-Agent-Extended': 'okta-auth-js-' + packageJson.version, + 'Authorization': 'Bearer ' + tokens.standardAccessToken + } + }, + response: 'error-userinfo-insufficient-scope' + }, + execute: function (test) { + return test.oa.token.getUserInfo(tokens.standardAccessTokenParsed); + }, + expectations: function (test, err) { + expect(err.name).toEqual('OAuthError'); + expect(err.message).toEqual('The access token must provide access to at least one' + + ' of these scopes - profile, email, address or phone'); + expect(err.errorCode).toEqual('insufficient_scope'); + expect(err.errorSummary).toEqual('The access token must provide access to at least one' + + ' of these scopes - profile, email, address or phone'); + } + }); + + util.itErrorsCorrectly({ + title: 'returns correct error for 401', + setup: { + request: { + uri: '/oauth2/v1/userinfo', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-Okta-User-Agent-Extended': 'okta-auth-js-' + packageJson.version, + 'Authorization': 'Bearer ' + tokens.standardAccessToken + } + }, + response: 'error-userinfo-invalid-token' + }, + execute: function (test) { + return test.oa.token.getUserInfo(tokens.standardAccessTokenParsed); + }, + expectations: function (test, err) { + expect(err.name).toEqual('OAuthError'); + expect(err.message).toEqual('The access token is invalid.'); + expect(err.errorCode).toEqual('invalid_token'); + expect(err.errorSummary).toEqual('The access token is invalid.'); + } + }); + }); }); diff --git a/test/util/util.js b/test/util/util.js index 23e8af456..bc99dc6e3 100644 --- a/test/util/util.js +++ b/test/util/util.js @@ -82,6 +82,11 @@ define(function(require) { var deferred = $.Deferred(); var xhr = pair.response; + + xhr.getResponseHeader = function(name) { + return xhr.headers && xhr.headers[name]; + }; + if (xhr.status > 0 && xhr.status < 300) { // $.ajax send (data, textStatus, jqXHR) on success _.defer(function () { deferred.resolve(xhr.response, null, xhr); }); diff --git a/test/xhr/error-userinfo-insufficient-scope.js b/test/xhr/error-userinfo-insufficient-scope.js new file mode 100644 index 000000000..de0dbb86f --- /dev/null +++ b/test/xhr/error-userinfo-insufficient-scope.js @@ -0,0 +1,8 @@ +define({ + "status": 403, + "responseType": "json", + "headers": { + "WWW-Authenticate": "Bearer error=\"insufficient_scope\", error_description=\"The access token must provide access to at least one of these scopes - profile, email, address or phone\"" + }, + "response": {} +}); diff --git a/test/xhr/error-userinfo-invalid-token.js b/test/xhr/error-userinfo-invalid-token.js new file mode 100644 index 000000000..aa48e8c7e --- /dev/null +++ b/test/xhr/error-userinfo-invalid-token.js @@ -0,0 +1,8 @@ +define({ + "status": 401, + "responseType": "json", + "headers": { + "WWW-Authenticate": "Bearer error=\"invalid_token\", error_description=\"The access token is invalid.\"" + }, + "response": {} +}); diff --git a/test/xhr/userinfo.js b/test/xhr/userinfo.js new file mode 100644 index 000000000..e5104e06a --- /dev/null +++ b/test/xhr/userinfo.js @@ -0,0 +1,9 @@ +define({ + "status": 200, + "responseType": "json", + "response": { + "sub":"00u15ozp26ACQTGHJEBH", + "email":"samljackson@example.com", + "email_verified":true + } +});