diff --git a/.gitignore b/.gitignore index a98ab44..101d74e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ docs app/bower_components .editorconfig .gitattributes -.jshintrc .tmp # Dev diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..c82708b --- /dev/null +++ b/.jshintrc @@ -0,0 +1,23 @@ +{ + "node": true, + "browser": true, + "esnext": true, + "bitwise": true, + "camelcase": false, + "curly": true, + "eqeqeq": true, + "immed": true, + "indent": 2, + "latedef": true, + "newcap": true, + "noarg": true, + "quotmark": "single", + "undef": true, + "unused": true, + "strict": true, + "trailing": true, + "smarttabs": true, + "globals": { + "angular": false + } +} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 83f4e22..244b7e8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,3 @@ language: node_js node_js: - - '0.8' - '0.10' -before_script: - - 'npm install -g bower grunt-cli' - - 'bower install' diff --git a/CHANGELOG.md b/CHANGELOG.md index f23b23a..5cf1d51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,111 @@ # Changelog +## 0.4.10 (Apr 25, 2016) + +* Support for loading keys from `.well-known/openid-configuration` + +## 0.4.9 (Mar 13, 2016) + +* Add DI explicit annotation to Endpoint factory +* Add missing dependency to Access Token service + +## 0.4.8 (Feb 12, 2016) + +* Fixed issues related to state change + +## 0.4.7 (Feb 11, 2016) + +* Add logout capability +* Add session validity checking feature + +## 0.4.6 (Jan 07, 2016) + +* Fix JWK format support of OIDC public key + +## 0.4.5 (Dec 16, 2015) + +* Add OpenID support + +## 0.4.4 (Nov 19, 2015) + +* Webpack Fix + +## 0.4.3 (Nov 19, 2015) + +* Added nonce parameter to directive + +## 0.4.2 (Jun 19, 2015) + +* Make the code more JSHint friendly +* Fix expiry definition in the access token service + +## 0.4.1 (Jun 11, 2015) + +* Add OpenID support + +## 0.4.0 (May 26, 2015) + +* Fix jshint config file +* Token `expires_in` property is now optional + +## 0.3.10 (April 20, 2015) + +* Add Storage service + +## 0.3.9 (April 14, 2015) + +* Add inline annotations for dependency injection + +## 0.3.8 (February 06, 2015) + +* Upgrade to AngularJS v1.3.12. + +## 0.3.6 (Dicember 03, 2014) + +* Broadcast event oauth:tokenDestroy after a logout. + +## 0.3.5 (November 29, 2014) + +* Remove access token and change directive text to 'logout' when token is expired. + +## 0.3.3 (November 25, 2014) + +* Add Fixed Code method option + +## 0.3.2 (November 16, 2014) + +* Add Authorization Code method option + +## 0.3.1 (November 3, 2014) + +* Replace $timeout with $interval #50 +* Add broadcast “oath:profile” once profile is retrieved. #51 +* Add travis + +## 0.3.0 (October 30, 2014) + +* Fix bug on access token definition from hash +* Correctly running tests with E2E protractor + +## 0.2.8 (August 27, 2014) + +* Fix `expries_at` not being set in some situations +* Only use session storage when oAuth hash not in URL +* Only remove oAuth2 tokens from hash + ## 0.2.7 (August 26, 2014) -* Fixed `expires_at` not being set -* Fixed `expired()` calculation +* Fix `expires_at` not being set +* Fix `expired()` calculation ## 0.2.6 (August 14, 2014) -* Removed encoding for OAuth 2.0 scope. +* Remove encoding for OAuth 2.0 scope. ## 0.2.4 (August 13, 2014) -* Removed settings for HTML5 mode -* Added logic to fire the oauth:expired event when the token expires. Before it was raised +* Remove settings for HTML5 mode +* Add logic to fire the oauth:expired event when the token expires. Before it was raised only when the request was returning a 401. ## 0.2.2 (July 11, 2014) @@ -28,6 +121,6 @@ per https://github.com/andreareginato/oauth-ng/issues/16 ## 0.2.0 (June 1, 2014) -* Updated name from ng-oauth to oauth-ng +* Update name from ng-oauth to oauth-ng * New documentation site andreareginato.github.io/oauth-ng * Major refactoring diff --git a/Gruntfile.js b/Gruntfile.js index bd21cae..ffd8782 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -23,7 +23,7 @@ module.exports = function (grunt) { }; try { - var component = require('./bower.json') + var component = require('./bower.json'); yeomanConfig.name = component.name || 'no-name'; yeomanConfig.version = component.version || '0.0.0.undefined'; } catch (e) {} @@ -114,6 +114,7 @@ module.exports = function (grunt) { ], test: { options: { + jasmine: true, jshintrc: 'test/.jshintrc' }, src: ['test/spec/{,*/}*.js'] @@ -229,9 +230,14 @@ module.exports = function (grunt) { // Test settings karma: { - unit: { + options: { configFile: 'karma.conf.js', + }, + unit: { singleRun: false + }, + once: { + singleRun: true } }, @@ -278,7 +284,15 @@ module.exports = function (grunt) { 'concurrent:test', 'autoprefixer', 'connect:test', - 'karma' + 'karma:once' + ]); + + grunt.registerTask('test:unit', [ + 'clean:server', + 'concurrent:test', + 'autoprefixer', + 'connect:test', + 'karma:unit' ]); grunt.registerTask('build', [ diff --git a/README.md b/README.md index ffabaae..bd45c18 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# AngularJS directive for OAuth 2.0 +# AngularJS directive for OAuth 2.0 [![Build Status](https://travis-ci.org/andreareginato/oauth-ng.svg?branch=master)](https://travis-ci.org/andreareginato/oauth-ng) + AngularJS directive for the [OAuth 2.0 Implicit Flow](http://tools.ietf.org/html/rfc6749#section-1.3.2). ## Documentation -[![oauth-ng](http://i.imgur.com/C0xCJcr.png)](https://andreareginato.github.com/oauth-ng) +[![oauth-ng](http://i.imgur.com/C0xCJcr.png)](https://angularjs-oauth.github.io/oauth-ng) ## Contributing @@ -17,6 +18,23 @@ Please also update `gh-pages` branch with documentation when applicable. * Fork and clone the repository * Run `npm install && bower install` +### OAuth 2.0 supported grant types + +We support both [OAuth 2.0 Authorization code Flow](http://tools.ietf.org/html/rfc6749#section-1.3.1) +and the [OAuth 2.0 Implicit Flow](http://tools.ietf.org/html/rfc6749#section-1.3.2). + +#### Authorization code flow + +See: http://tools.ietf.org/html/rfc6749#section-4.1 + +To use the Authorization code flow set response-type="code" in the oauth directive. + +#### Implicit flow + +See: http://tools.ietf.org/html/rfc6749#section-4.2 + +To use the Implicit flow set response-type="token" in the oauth directive. + ### Unit tests (karma) `npm install && bower install` @@ -36,14 +54,14 @@ Follow [github](https://github.com/styleguide/) guidelines. ### Feedback -Use the [issue tracker](http://github.com/andreareginato/oauth-ng/issues) for bugs. +Use the [issue tracker](http://github.com/angularjs-oauth/oauth-ng/issues) for bugs. [Mail](mailto:andrea.reginato@gmail.com) or [Tweet](http://twitter.com/andreareginato) us for any idea that can improve the project. ### Links -* [GIT Repository](http://github.com/andreareginato/oauth-ng) -* [Website](https://andreareginato.github.com/oauth-ng) +* [GIT Repository](http://github.com/angularjs-oauth/oauth-ng) +* [Website](https://angularjs-oauth.github.io/oauth-ng) ## Authors @@ -51,18 +69,22 @@ that can improve the project. Project created and released as open-source thanks to [Lelylan](http://lelylan.com). * [Andrea Reginato](http://twitter.com/andreareginato) +* [Massimiliano Sartoretto](http://twitter.com/___Sarto) ## Contributors -Special thanks to all [contributors](https://github.com/andreareginato/oauth-ng/contributors) +Special thanks to all [contributors](https://github.com/angularjs-oauth/oauth-ng/contributors) for submitting patches. ## Changelog -See [CHANGELOG](https://github.com/andreareginato/oauth-ng/blob/master/CHANGELOG.md) +See [CHANGELOG](https://github.com/angularjs-oauth/oauth-ng/blob/master/CHANGELOG.md) + +## TODO +:white_medium_square: [OAuth 2.0 Authorization code Flow](http://tools.ietf.org/html/rfc6749#section-1.3.1) ## Copyright Copyright (c) 2014 [Lelylan](http://lelylan.com). -See [LICENSE](https://github.com/andreareginato/oauth-ng/blob/master/LICENSE.md) for details. +See [LICENSE](https://github.com/angularjs-oauth/oauth-ng/blob/master/LICENSE.md) for details. diff --git a/app/index.html b/app/index.html index 037b7cb..6b1ce97 100644 --- a/app/index.html +++ b/app/index.html @@ -17,6 +17,7 @@ + @@ -32,20 +33,26 @@ client-id="CLIENT_ID_HERE" redirect-uri="REDIRECT_URI_HERE" profile-uri="PROFILE_URI_HERE" - scope="SCOPE_HERE"> + scope="SCOPE_HERE" + storage="STORAGE_TYPE_HERE"> + + + + + diff --git a/app/scripts/app.js b/app/scripts/app.js index 3755008..2d70a1e 100644 --- a/app/scripts/app.js +++ b/app/scripts/app.js @@ -1,15 +1,18 @@ 'use strict'; // App libraries -var app = angular.module('oauth', [ +angular.module('oauth', [ 'oauth.directive', // login directive + 'oauth.idToken', // id token service (only for OpenID Connect) + 'oauth.oidcConfig', // for loading OIDC configuration from .well-known/openid-configuration endpoint 'oauth.accessToken', // access token service 'oauth.endpoint', // oauth endpoint service 'oauth.profile', // profile model - 'oauth.interceptor' // bearer token interceptor + 'oauth.storage', // storage + 'oauth.interceptor', // bearer token interceptor + 'oauth.configuration' // token appender ]) - -angular.module('oauth').config(['$locationProvider','$httpProvider', + .config(['$locationProvider','$httpProvider', function($locationProvider, $httpProvider) { $httpProvider.interceptors.push('ExpiredInterceptor'); }]); diff --git a/app/scripts/directives/oauth.js b/app/scripts/directives/oauth.js index 08cf7c0..ce2ff26 100644 --- a/app/scripts/directives/oauth.js +++ b/app/scripts/directives/oauth.js @@ -2,111 +2,171 @@ var directives = angular.module('oauth.directive', []); -directives.directive('oauth', function(AccessToken, Endpoint, Profile, $location, $rootScope, $compile, $http, $templateCache) { - - var definition = { - restrict: 'AE', - replace: true, - scope: { - site: '@', // (required) set the oauth server host (e.g. http://oauth.example.com) - clientId: '@', // (required) client id - redirectUri: '@', // (required) client redirect uri - scope: '@', // (optional) scope - profileUri: '@', // (optional) user profile uri (e.g http://example.com/me) - template: '@', // (optional) template to render (e.g views/templates/default.html) - text: '@', // (optional) login text - authorizePath: '@', // (optional) authorization url - state: '@' // (optional) An arbitrary unique string created by your app to guard against Cross-site Request Forgery - } - }; - - definition.link = function postLink(scope, element, attrs) { - scope.show = 'none'; - - scope.$watch('clientId', function(value) { init() }); - - var init = function() { - initAttributes(); // sets defaults - compile(); // compiles the desired layout - Endpoint.set(scope); // sets the oauth authorization url - AccessToken.set(scope); // sets the access token object (if existing, from fragment or session) - initProfile(scope); // gets the profile resource (if existing the access token) - initView(); // sets the view (logged in or out) - }; - - var initAttributes = function() { - scope.authorizePath = scope.authorizePath || '/oauth/authorize'; - scope.tokenPath = scope.tokenPath || '/oauth/token'; - scope.template = scope.template || 'views/templates/default.html'; - scope.text = scope.text || 'Sign In'; - scope.state = scope.state || undefined; - scope.scope = scope.scope || undefined; - }; - - var compile = function() { - $http.get(scope.template, { cache: $templateCache }).success(function(html) { - element.html(html); - $compile(element.contents())(scope); - }); - }; - - var initProfile = function(scope) { - var token = AccessToken.get(); - - if (token && token.access_token && scope.profileUri) { - Profile.find(scope.profileUri).success(function(response) { - scope.profile = response - }) +directives.directive('oauth', [ + 'IdToken', + 'AccessToken', + 'Endpoint', + 'Profile', + 'Storage', + 'OidcConfig', + '$location', + '$rootScope', + '$compile', + '$http', + '$templateCache', + '$timeout', + function(IdToken, AccessToken, Endpoint, Profile, Storage, OidcConfig, $location, $rootScope, $compile, $http, $templateCache, $timeout) { + + var definition = { + restrict: 'AE', + replace: true, + scope: { + site: '@', // (required) set the oauth server host (e.g. http://oauth.example.com) + clientId: '@', // (required) client id + redirectUri: '@', // (required) client redirect uri + responseType: '@', // (optional) response type, defaults to token (use 'token' for implicit flow and 'code' for authorization code flow + scope: '@', // (optional) scope + profileUri: '@', // (optional) user profile uri (e.g http://example.com/me) + template: '@', // (optional) template to render (e.g views/templates/default.html) + text: '@', // (optional) login text + authorizePath: '@', // (optional) authorization url + state: '@', // (optional) An arbitrary unique string created by your app to guard against Cross-site Request Forgery + storage: '@', // (optional) Store token in 'sessionStorage' or 'localStorage', defaults to 'sessionStorage' + nonce: '@', // (optional) Send nonce on auth request + // OpenID Connect extras, more details in id-token.js: + issuer: '@', // (optional for OpenID Connect) issuer of the id_token, should match the 'iss' claim in id_token payload + subject: '@', // (optional for OpenID Connect) subject of the id_token, should match the 'sub' claim in id_token payload + pubKey: '@', // (optional for OpenID Connect) the public key(RSA public key or X509 certificate in PEM format) to verify the signature + wellKnown: '@', // (optional for OpenID Connect) whether to load public key according to .well-known/openid-configuration endpoint + logoutPath: '@', // (optional) A url to go to at logout + sessionPath: '@' // (optional) A url to use to check the validity of the current token. } }; - var initView = function() { - var token = AccessToken.get(); - - if (!token) { return loggedOut() } // without access token it's logged out - if (token.access_token) { return authorized() } // if there is the access token we are done - if (token.error) { return denied() } // if the request has been denied we fire the denied event - }; + definition.link = function postLink(scope, element) { + scope.show = 'none'; - scope.login = function() { - Endpoint.redirect(); - }; - - scope.logout = function() { - AccessToken.destroy(scope); - loggedOut(); - }; + scope.$watch('clientId', function() { + init(); + }); - // user is authorized - var authorized = function() { - $rootScope.$broadcast('oauth:authorized', AccessToken.get()); - scope.show = 'logged-in'; - }; + var init = function() { + initAttributes(); // sets defaults + Storage.use(scope.storage);// set storage + compile(); // compiles the desired layout + Endpoint.set(scope); // sets the oauth authorization url + OidcConfig.load(scope) // loads OIDC configuration from .well-known/openid-configuration if necessary + .then(function() { + IdToken.set(scope); + AccessToken.set(scope); // sets the access token object (if existing, from fragment or session) + initProfile(scope); // gets the profile resource (if existing the access token) + initView(); // sets the view (logged in or out) + checkValidity(); // ensure the validity of the current token + }); + }; + + var initAttributes = function() { + scope.authorizePath = scope.authorizePath || '/oauth/authorize'; + scope.tokenPath = scope.tokenPath || '/oauth/token'; + scope.template = scope.template || 'views/templates/default.html'; + scope.responseType = scope.responseType || 'token'; + scope.text = scope.text || 'Sign In'; + scope.state = scope.state || undefined; + scope.scope = scope.scope || undefined; + scope.storage = scope.storage || 'sessionStorage'; + }; + + var compile = function() { + $http.get(scope.template, { cache: $templateCache }).then(function(html) { + element.html(html.data); + $compile(element.contents())(scope); + }); + }; + + var initProfile = function(scope) { + var token = AccessToken.get(); + + if (token && token.access_token && scope.profileUri) { + Profile.find(scope.profileUri).then(function(response) { + scope.profile = response.data; + }); + } + }; + + var initView = function () { + var token = AccessToken.get(); + + if (!token) { + return scope.login(); + } // without access token it's logged out, so we attempt to log in + if (AccessToken.expired()) { + return expired(); + } // with a token, but it's expired + if (token.access_token) { + return authorized(); + } // if there is the access token we are done + if (token.error) { + return denied(); + } // if the request has been denied we fire the denied event + }; + + scope.login = function () { + Endpoint.redirect(); + }; + + scope.logout = function () { + Endpoint.logout(); + $rootScope.$broadcast('oauth:loggedOut'); + scope.show = 'logged-out'; + }; + + scope.$on('oauth:expired',expired); + + // user is authorized + var authorized = function() { + $rootScope.$broadcast('oauth:authorized', AccessToken.get()); + scope.show = 'logged-in'; + }; + + var expired = function() { + $rootScope.$broadcast('oauth:expired'); + scope.logout(); + }; + + // set the oauth directive to the denied status + var denied = function() { + scope.show = 'denied'; + $rootScope.$broadcast('oauth:denied'); + }; + + var checkValidity = function() { + Endpoint.checkValidity().then(function() { + $rootScope.$broadcast('oauth:valid'); + }).catch(function(message){ + $rootScope.$broadcast('oauth:invalid', message); + }); + }; + + var refreshDirective = function () { + scope.$apply(); + }; + + // Updates the template at runtime + scope.$on('oauth:template:update', function(event, template) { + scope.template = template; + compile(scope); + }); - // set the oauth directive to the logged-out status - var loggedOut = function() { - $rootScope.$broadcast('oauth:logout'); - scope.show = 'logged-out'; - }; + // Hack to update the directive content on logout + scope.$on('$routeChangeSuccess', function () { + $timeout(refreshDirective); + }); - // set the oauth directive to the denied status - var denied = function() { - scope.show = 'denied'; - $rootScope.$broadcast('oauth:denied'); + scope.$on('$stateChangeSuccess', function () { + $timeout(refreshDirective); + }); }; - // Updates the template at runtime - scope.$on('oauth:template:update', function(event, template) { - scope.template = template; - compile(scope); - }); - - // Hack to update the directive content on logout - // TODO think to a cleaner solution - scope.$on('$routeChangeSuccess', function () { - init(); - }); - }; - - return definition -}); + return definition; + } +]); diff --git a/app/scripts/interceptors/oauth-interceptor.js b/app/scripts/interceptors/oauth-interceptor.js index ba27246..be3d8a9 100644 --- a/app/scripts/interceptors/oauth-interceptor.js +++ b/app/scripts/interceptors/oauth-interceptor.js @@ -2,22 +2,23 @@ var interceptorService = angular.module('oauth.interceptor', []); -interceptorService.factory('ExpiredInterceptor', function ($rootScope, $q, $sessionStorage) { +interceptorService.factory('ExpiredInterceptor', ['Storage', '$rootScope', function (Storage, $rootScope) { var service = {}; service.request = function(config) { - var token = $sessionStorage.token; + var token = Storage.get('token'); - if (token && expired(token)) + if (token && expired(token)) { $rootScope.$broadcast('oauth:expired', token); + } return config; }; var expired = function(token) { - return (token && token.expires_at && new Date(token.expires_at) < new Date()) + return (token && token.expires_at && new Date(token.expires_at) < new Date()); }; return service; -}); +}]); diff --git a/app/scripts/services/access-token.js b/app/scripts/services/access-token.js index 9bf875a..7e34d9b 100644 --- a/app/scripts/services/access-token.js +++ b/app/scripts/services/access-token.js @@ -1,164 +1,204 @@ 'use strict'; -var accessTokenService = angular.module('oauth.accessToken', ['ngStorage']); - -accessTokenService.factory('AccessToken', function($rootScope, $location, $sessionStorage, $timeout) { - - var service = {}; - var token = null; - - - /* +var accessTokenService = angular.module('oauth.accessToken', []); + +accessTokenService.factory('AccessToken', ['Storage', '$rootScope', '$location', '$interval', '$timeout', 'IdToken', function(Storage, $rootScope, $location, $interval, $timeout, IdToken){ + + var service = { + token: null + }, + hashFragmentKeys = [ + //Oauth2 keys per http://tools.ietf.org/html/rfc6749#section-4.2.2 + 'access_token', 'token_type', 'expires_in', 'scope', 'state', + 'error','error_description', + //Additional OpenID Connect key per http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthResponse + 'id_token' + ]; + var expiresAtEvent = null; + + /** * Returns the access token. */ + service.get = function(){ + return this.token; + }; - service.get = function() { - return token - } - - - /* + /** * Sets and returns the access token. It tries (in order) the following strategies: * - takes the token from the fragment URI * - takes the token from the sessionStorage */ + service.set = function(){ + this.setTokenFromString($location.hash()); - service.set = function() { - service.setTokenFromString($location.hash()); - service.setTokenFromSession(); - return token - } + //If hash is present in URL always use it, cuz its coming from oAuth2 provider redirect + if(null === service.token){ + setTokenFromSession(); + } + return this.token; + }; - /* - * Delete the access token and remove the session. + /** + * Delete the access token and remove the session. + * @returns {null} */ + service.destroy = function(){ + Storage.delete('token'); + this.token = null; + return this.token; + }; - service.destroy = function() { - delete $sessionStorage.token; - return token = null; - } - - - /* + /** * Tells if the access token is expired. */ + service.expired = function(){ + return (this.token && this.token.expires_at && new Date(this.token.expires_at) < new Date()); + }; - service.expired = function() { - return (token && token.expires_at && token.expires_at < new Date()); - } + /** + * Get the access token from a string and save it + * @param hash + */ + service.setTokenFromString = function(hash){ + var params = getTokenFromString(hash); + if(params){ + removeFragment(); + setToken(params); + setExpiresAt(); + // We have to save it again to make sure expires_at is set + // and the expiry event is set up properly + setToken(this.token); + $rootScope.$broadcast('oauth:login', service.token); + } + }; + /** + * updates the expiration of the token + */ + service.updateExpiry = function(newExpiresIn){ + this.token.expires_in = newExpiresIn; + setExpiresAt(); + }; /* * * * * * * * * * * PRIVATE METHODS * * * * * * * * * * */ - - /* - * Get the access token from a string and save it + /** + * Set the access token from the sessionStorage. */ - - service.setTokenFromString = function(hash) { - var token = getTokenFromString(hash); - - if (token) { - removeFragment(); - service.setToken(token); - setExpiresAt(token); - $rootScope.$broadcast('oauth:login', token); + var setTokenFromSession = function(){ + var params = Storage.get('token'); + if (params) { + setToken(params); } }; + /** + * Set the access token. + * + * @param params + * @returns {*|{}} + */ + var setToken = function(params){ + service.token = service.token || {}; // init the token + angular.extend(service.token, params); // set the access token params + setTokenInSession(); // save the token into the session + setExpiresAtEvent(); // event to fire when the token expires + + return service.token; + }; - /* + /** * Parse the fragment URI and return an object + * @param hash + * @returns {{}} */ + var getTokenFromString = function(hash){ + var params = {}, + regex = /([^&=]+)=([^&]*)/g, + m; - var getTokenFromString = function(hash) { - var splitted = hash.split('&'); - var params = {}; - - for (var i = 0; i < splitted.length; i++) { - var param = splitted[i].split('='); - var key = param[0]; - var value = param[1]; - params[key] = value + while ((m = regex.exec(hash)) !== null) { + params[decodeURIComponent(m[1])] = decodeURIComponent(m[2]); } - if (params.access_token || params.error) + // OpenID Connect + if (params.id_token && !params.error) { + IdToken.validateTokensAndPopulateClaims(params); return params; - } - - - /* - * Set the access token from the sessionStorage. - */ - - service.setTokenFromSession = function() { - if ($sessionStorage.token) { - var params = $sessionStorage.token; - params.expires_at = new Date(params.expires_at); - service.setToken(params); } - } + // Oauth2 + if(params.access_token || params.error){ + return params; + } + }; - /* + /** * Save the access token into the session */ - - var setTokenInSession = function() { - $sessionStorage.token = token; - } - - - /* - * Set the access token. - */ - - service.setToken = function(params) { - token = token || {}; // init the token - angular.extend(token, params); // set the access token params - setTokenInSession(); // save the token into the session - setExpiresAtEvent(); // event to fire when the token expires - - return token; + var setTokenInSession = function(){ + Storage.set('token', service.token); }; - - /* + /** * Set the access token expiration date (useful for refresh logics) */ - - var setExpiresAt = function(token) { - if (token) { + var setExpiresAt = function(){ + if (!service.token) { + return; + } + if(typeof(service.token.expires_in) !== 'undefined' && service.token.expires_in !== null) { var expires_at = new Date(); - expires_at.setSeconds(expires_at.getSeconds() + parseInt(token.expires_in) - 60); // 60 seconds less to secure browser and response latency - token.expires_at = expires_at; + expires_at.setSeconds(expires_at.getSeconds() + parseInt(service.token.expires_in)-60); // 60 seconds less to secure browser and response latency + service.token.expires_at = expires_at; + } + else { + service.token.expires_at = null; } }; - /* + /** * Set the timeout at which the expired event is fired */ + var setExpiresAtEvent = function(){ + // Don't bother if there's no expires token + if (typeof(service.token.expires_at) === 'undefined' || service.token.expires_at === null) { + return; + } + cancelExpiresAtEvent(); + var time = (new Date(service.token.expires_at))-(new Date()); + if(time && time > 0 && time <= 2147483647){ + expiresAtEvent = $interval(function(){ + $rootScope.$broadcast('oauth:expired', service.token); + }, time, 1); + } + }; - var setExpiresAtEvent = function() { - var time = (new Date(token.expires_at)) - (new Date()) - if (time) { $timeout(function() { $rootScope.$broadcast('oauth:expired', token) }, time) } - } - + var cancelExpiresAtEvent = function() { + if(expiresAtEvent) { + $timeout.cancel(expiresAtEvent); + expiresAtEvent = undefined; + } + }; - /* - * Remove the fragment URI - * TODO we need to remove only the access token + /** + * Remove the oAuth2 pieces from the hash fragment */ - - var removeFragment = function(scope) { - $location.hash(''); - } - + var removeFragment = function(){ + var curHash = $location.hash(); + angular.forEach(hashFragmentKeys,function(hashKey){ + var re = new RegExp('&'+hashKey+'(=[^&]*)?|^'+hashKey+'(=[^&]*)?&?'); + curHash = curHash.replace(re,''); + }); + + $location.hash(curHash); + }; return service; -}); + +}]); diff --git a/app/scripts/services/endpoint.js b/app/scripts/services/endpoint.js index 5353e40..6db8df6 100644 --- a/app/scripts/services/endpoint.js +++ b/app/scripts/services/endpoint.js @@ -2,49 +2,105 @@ var endpointClient = angular.module('oauth.endpoint', []); -endpointClient.factory('Endpoint', function(AccessToken, $location) { +endpointClient.factory('Endpoint', ['$rootScope', 'AccessToken', '$q', '$http', function($rootScope, AccessToken, $q, $http) { var service = {}; - var url; + var buildOauthUrl = function (path, params) { + var oAuthScope = (params.scope) ? encodeURIComponent(params.scope) : '', + state = (params.state) ? encodeURIComponent(params.state) : '', + authPathHasQuery = (params.authorizePath.indexOf('?') == -1) ? false : true, + appendChar = (authPathHasQuery) ? '&' : '?', //if authorizePath has ? already append OAuth2 params + nonceParam = (params.nonce) ? '&nonce=' + params.nonce : '', + responseType = encodeURIComponent(params.responseType); + + return params.site + + path + + appendChar + 'response_type=' + responseType + '&' + + 'client_id=' + encodeURIComponent(params.clientId) + '&' + + 'redirect_uri=' + encodeURIComponent(params.redirectUri) + '&' + + 'scope=' + oAuthScope + '&' + + 'state=' + state + nonceParam; + }; + + var extendValidity = function (tokenInfo) { + AccessToken.updateExpiry(tokenInfo.expires); + }; /* * Defines the authorization URL */ - service.set = function(scope) { - var oAuthScope = (scope.scope) ? scope.scope : '', - state = (scope.state) ? encodeURIComponent(scope.state) : '', - authPathHasQuery = (scope.authorizePath.indexOf('?') == -1) ? false : true, - appendChar = (authPathHasQuery) ? '&' : '?'; //if authorizePath has ? already append OAuth2 params + service.set = function(configuration) { + this.config = configuration; + return this.get(); + }; - url = scope.site + - scope.authorizePath + - appendChar + 'response_type=token&' + - 'client_id=' + encodeURIComponent(scope.clientId) + '&' + - 'redirect_uri=' + encodeURIComponent(scope.redirectUri) + '&' + - 'scope=' + oAuthScope + '&' + - 'state=' + state; + /* + * Returns the authorization URL + */ - return url; + service.get = function(overrides) { + var params = angular.extend( {}, service.config, overrides); + return buildOauthUrl(params.authorizePath, params); }; /* - * Returns the authorization URL + * Redirects the app to the authorization URL */ - service.get = function() { - return url; + service.redirect = function(overrides) { + var targetLocation = this.get(overrides); + $rootScope.$broadcast('oauth:logging-in'); + window.location.replace(targetLocation); }; + /* + * Alias for 'redirect' + */ + service.login = function() { + service.redirect(); + }; /* - * Redirects the app to the authorization URL + * Check the validity of the token if a session path is available + */ + service.checkValidity = function() { + var params = service.config; + if( params.sessionPath ) { + var token = AccessToken.get(); + if( !token ) { + return $q.reject("No token configured"); + } + var path = params.site + params.sessionPath + "?token=" + token.access_token; + return $http.get(path).then( function(httpResponse) { + var tokenInfo = httpResponse.data; + if(tokenInfo.valid) { + extendValidity(tokenInfo); + return true; + } else { + return $q.reject("Server replied: token is invalid."); + } + }); + } else { + return $q.reject("You must give a :session-path param in order to validate the token.") + } + }; + + /* + * Destroys the session, sends the user to the logout url if set. + * First broadcasts 'logging-out' and then 'logout' when finished. */ - service.redirect = function() { - window.location.replace(url); + service.logout = function() { + var params = service.config; + AccessToken.destroy(); + $rootScope.$broadcast('oauth:logging-out'); + if( params.logoutPath ) { + window.location.replace(buildOauthUrl(params.logoutPath, params)); + } + $rootScope.$broadcast('oauth:logout'); }; return service; -}); +}]); diff --git a/app/scripts/services/id-token.js b/app/scripts/services/id-token.js new file mode 100644 index 0000000..d6fada3 --- /dev/null +++ b/app/scripts/services/id-token.js @@ -0,0 +1,271 @@ +'use strict'; + +var idTokenService = angular.module('oauth.idToken', []); + +idTokenService.factory('IdToken', ['Storage', function(Storage) { + + var service = { + issuer: null, + subject: null, + //clientId, should match 'aud' claim + clientId: null, + /* + The public key to verify the signature, supports: + 1.RSA public key in PEM string: e.g. "-----BEGIN PUBLIC KEY..." + 2.X509 certificate in PEM string: e.g. "-----BEGIN CERTIFICATE..." + 3.JWK (Json Web Key): e.g. {kty: "RSA", n: "0vx7...", e: "AQAB"} + + If not set, the id_token header should carry the key or the url to retrieve the key + */ + pubKey: null + }; + /** + * OidcException + * @param {string } message - The exception error message + * @constructor + */ + function OidcException(message) { + this.name = 'OidcException'; + this.message = message; + } + OidcException.prototype = new Error(); + OidcException.prototype.constructor = OidcException; + + /** + * For initialization + * @param scope + */ + service.set = function(scope) { + this.issuer = scope.issuer; + this.subject = scope.subject; + this.clientId = scope.clientId; + this.pubKey = scope.pubKey; + }; + + /** + * Validate id_token and access_token(if there's one) + * If validation passes, the id_token payload(claims) will be populated to 'params' + * Otherwise error will set to 'params' and tokens will be removed + * + * @param params + */ + service.validateTokensAndPopulateClaims = function(params) { + var valid = false; + var message = ''; + try { + valid = this.validateIdToken(params.id_token); + /* + if response_type is 'id_token token', then we will get both id_token and access_token, + access_token needs to be validated as well + */ + if (valid && params.access_token) { + valid = this.validateAccessToken(params.id_token, params.access_token); + } + } catch (error) { + message = error.message; + } + + if (valid) { + params.id_token_claims = getIdTokenPayload(params.id_token); + } else { + params.id_token = null; + params.access_token = null; + params.error = 'Failed to validate token:' + message; + } + }; + + + /** + * Validates the id_token + * @param {String} idToken The id_token + * @returns {boolean} True if all the check passes, False otherwise + */ + service.validateIdToken = function(idToken) { + return this.verifyIdTokenSig(idToken) && this.verifyIdTokenInfo(idToken); + }; + + /** + * Validate access_token based on the 'alg' and 'at_hash' value of the id_token header + * per spec: http://openid.net/specs/openid-connect-core-1_0.html#ImplicitTokenValidation + * + * @param idToken The id_token + * @param accessToken The access_token + * @returns {boolean} true if validation passes + */ + service.validateAccessToken = function(idToken, accessToken) { + var header = getJsonObject(getIdTokenParts(idToken)[0]); + if (header.at_hash) { + var shalevel = header.alg.substr(2); + if (shalevel !== '256' && shalevel !== '384' && shalevel !== '512') { + throw new OidcException('Unsupported hash algorithm, expecting sha256, sha384, or sha512'); + } + var md = new KJUR.crypto.MessageDigest({'alg':'sha'+ shalevel, 'prov':'cryptojs'}); + //hex representation of the hash + var hexStr = md.digestString(accessToken); + //take first 128bits and base64url encoding it + var expected = hextob64u(hexStr.substring(0, 32)); + + return expected === header.at_hash; + } else { + return true; + } + }; + + /** + * Verifies the ID Token signature using the specified public key + * The id_token header can optionally carry public key or the url to retrieve the public key + * Otherwise will use the public key configured using 'pubKey' + * + * Supports only RSA signatures ['RS256', 'RS384', 'RS512'] + * @param {string}idToken The ID Token string + * @returns {boolean} Indicates whether the signature is valid or not + * @throws {OidcException} + */ + service.verifyIdTokenSig = function (idToken) { + + var idtParts = getIdTokenParts(idToken); + var header = getJsonObject(idtParts[0]); + + if(!header.alg || header.alg.substr(0, 2) !== 'RS') { + throw new OidcException('Unsupported JWS signature algorithm ' + header.alg); + } + + var matchedPubKey = null; + + if (header.jwk) { + //Take the JWK if it comes with the id_token + matchedPubKey = header.jwk; + if (matchedPubKey.kid && header.kid && matchedPubKey.kid !== header.kid) { + throw new OidcException('Json Web Key ID not match'); + } + /* + TODO: Support for "jku" (JWK Set URL), "x5u" (X.509 URL), "x5c" (X.509 Certificate Chain) parameter to get key + per http://tools.ietf.org/html/draft-ietf-jose-json-web-signature-26#page-9 + */ + } else { + //Try to load the key from .well-known configuration + var oidcConfig = Storage.get('oidcConfig'); + if (angular.isDefined(oidcConfig) && oidcConfig.jwks && oidcConfig.jwks.keys) { + oidcConfig.jwks.keys.forEach(function(key) { + if (key.kid === header.kid) { + matchedPubKey = key; + } + }); + } else { + //Use configured public key + var jwk = getJsonObject(this.pubKey); + matchedPubKey = jwk ? jwk : this.pubKey; //JWK or PEM + } + } + + if(!matchedPubKey) { + throw new OidcException('No public key found to verify signature'); + } + + return rsaVerifyJWS(idToken, matchedPubKey, header.alg); + }; + + /** + * Validates the information in the ID Token against configuration + * @param {string} idtoken The ID Token string + * @returns {boolean} Validity of the ID Token + * @throws {OidcException} + */ + service.verifyIdTokenInfo = function(idtoken) { + var valid = false; + + if (idtoken) { + var idtParts = getIdTokenParts(idtoken); + var payload = getJsonObject(idtParts[1]); + if (payload) { + var now = new Date() / 1000; + if (payload.iat > now + 60) + throw new OidcException('ID Token issued time is later than current time'); + + if (payload.exp < now ) + throw new OidcException('ID Token expired'); + + if (now < payload.ntb) + throw new OidcException('ID Token is invalid before '+ payload.ntb); + + if (payload.iss && this.issuer && payload.iss !== this.issuer) + throw new OidcException('Invalid issuer ' + payload.iss + ' != ' + this.issuer); + + if (payload.sub && this.subject && payload.sub !== this.subject) + throw new OidcException('Invalid subject ' + payload.sub + ' != ' + this.subject); + + if (payload.aud) { + if (payload.aud instanceof Array && !KJUR.jws.JWS.inArray(this.clientId, payload.aud)) { + throw new OidcException('Client not in intended audience:' + payload.aud); + } + if (typeof payload.aud === 'string' && payload.aud !== this.clientId) { + throw new OidcException('Invalid audience ' + payload.aud + ' != ' + this.clientId); + } + } + + //TODO: nonce support ? probably need to redo current nonce support + //if(payload['nonce'] != sessionStorage['nonce']) + // throw new OidcException('invalid nonce'); + valid = true; + } else + throw new OidcException('Unable to parse JWS payload'); + } + return valid; + }; + + /** + * Verifies the JWS string using the JWK + * @param {string} jws The JWS string + * @param {object} pubKey The public key that will be used to verify the signature + * @param {string} alg The algorithm string. Expecting 'RS256', 'RS384', or 'RS512' + * @returns {boolean} Validity of the JWS signature + * @throws {OidcException} + */ + var rsaVerifyJWS = function (jws, pubKey, alg) { + /* + convert various public key format to RSAKey object + see @KEYUTIL.getKey for a full list of supported input format + */ + var rsaKey = KEYUTIL.getKey(pubKey); + + return KJUR.jws.JWS.verify(jws, rsaKey, [alg]); + }; + + /** + * Splits the ID Token string into the individual JWS parts + * @param {string} id_token ID Token + * @returns {Array} An array of the JWS compact serialization components (header, payload, signature) + */ + var getIdTokenParts = function (id_token) { + var jws = new KJUR.jws.JWS(); + jws.parseJWS(id_token); + return [jws.parsedJWS.headS, jws.parsedJWS.payloadS, jws.parsedJWS.si]; + }; + + /** + * Get the contents of the ID Token payload as an JSON object + * @param {string} id_token ID Token + * @returns {object} The ID Token payload JSON object + */ + var getIdTokenPayload = function (id_token) { + var parts = getIdTokenParts(id_token); + if(parts) + return getJsonObject(parts[1]); + }; + + /** + * Get the JSON object from the JSON string + * @param {string} jsonS JSON string + * @returns {object|null} JSON object or null + */ + var getJsonObject = function (jsonS) { + var jws = KJUR.jws.JWS; + if(jws.isSafeJSONString(jsonS)) { + return jws.readSafeJSONString(jsonS); + } + return null; + }; + + return service; + +}]); diff --git a/app/scripts/services/oauth-configuration.js b/app/scripts/services/oauth-configuration.js new file mode 100644 index 0000000..2cdc22a --- /dev/null +++ b/app/scripts/services/oauth-configuration.js @@ -0,0 +1,38 @@ +'use strict'; + +var oauthConfigurationService = angular.module('oauth.configuration', []); + +oauthConfigurationService.provider('OAuthConfiguration', function() { + var _config = {}; + + this.init = function(config, httpProvider) { + _config.protectedResources = config.protectedResources || []; + httpProvider.interceptors.push('AuthInterceptor'); + }; + + this.$get = function() { + return { + getConfig: function() { + return _config; + } + }; + }; +}) +.factory('AuthInterceptor', ['OAuthConfiguration', 'AccessToken', function(OAuthConfiguration, AccessToken) { + return { + 'request': function(config) { + OAuthConfiguration.getConfig().protectedResources.forEach(function(resource) { + // If the url is one of the protected resources, we want to see if there's a token and then + // add the token if it exists. + if (config.url.indexOf(resource) > -1) { + var token = AccessToken.get(); + if (token) { + config.headers.Authorization = 'Bearer ' + token.access_token; + } + } + }); + + return config; + } + }; +}]); \ No newline at end of file diff --git a/app/scripts/services/oidc-config.js b/app/scripts/services/oidc-config.js new file mode 100644 index 0000000..f3e73e9 --- /dev/null +++ b/app/scripts/services/oidc-config.js @@ -0,0 +1,70 @@ +(function() { + 'use strict'; + + angular.module('oauth.oidcConfig', []) + .factory('OidcConfig', ['Storage', '$http', '$q', '$log', OidcConfig]); + + function OidcConfig(Storage, $http, $q, $log) { + var cache = null; + return { + load: load + }; + + function load(scope) { + if (scope.issuer && scope.wellKnown && scope.wellKnown !== "false") { + var promise = loadConfig(scope.issuer); + if (scope.wellKnown === "sync") { + return promise; + } + } + return $q.when(1); + } + + function loadConfig(iss) { + if (cache === null) { + cache = Storage.get('oidcConfig'); + } + if (angular.isDefined(cache)) { + return $q.when(cache); + } else { + return loadOpenidConfiguration(iss) + .then(saveCache) + .then(loadJwks) + .then(saveCache, errorLogger); + } + } + + function errorLogger(err) { + $log.error("Could not load OIDC config:", err); + return $q.reject(err); + } + + function saveCache(o) { + Storage.set('oidcConfig', cache); + return o; + } + + function joinPath(x,y) { + return x + (x.charAt(x.length - 1) === '/' ? '' : '/') + y; + } + + function loadOpenidConfiguration(iss) { + var configUri = joinPath(iss, ".well-known/openid-configuration"); + return $http.get(configUri).then(function(res) { + return cache = res.data; + }, function(err) { + return $q.reject("Could not get config info from " + configUri + ' . Check the availability of this url.'); + }); + } + + function loadJwks(oidcConf) { + if (oidcConf.jwks_uri) { + return $http.get(oidcConf.jwks_uri).then(function(res) { + return oidcConf.jwks = res.data; + }); + } else { + return $q.reject("No jwks_uri found."); + } + } + } +})(); diff --git a/app/scripts/services/profile.js b/app/scripts/services/profile.js index 671136a..4d257fc 100644 --- a/app/scripts/services/profile.js +++ b/app/scripts/services/profile.js @@ -1,18 +1,21 @@ 'use strict'; -var profileClient = angular.module('oauth.profile', []) +var profileClient = angular.module('oauth.profile', []); -profileClient.factory('Profile', function($http, AccessToken) { +profileClient.factory('Profile', ['$http', 'AccessToken', '$rootScope', function($http, AccessToken, $rootScope) { var service = {}; var profile; service.find = function(uri) { var promise = $http.get(uri, { headers: headers() }); - promise.success(function(response) { profile = response }); + promise.then(function(response) { + profile = response.data; + $rootScope.$broadcast('oauth:profile', profile); + }); return promise; }; - service.get = function(uri) { + service.get = function() { return profile; }; @@ -26,4 +29,4 @@ profileClient.factory('Profile', function($http, AccessToken) { }; return service; -}); +}]); diff --git a/app/scripts/services/storage.js b/app/scripts/services/storage.js new file mode 100644 index 0000000..d2d298c --- /dev/null +++ b/app/scripts/services/storage.js @@ -0,0 +1,49 @@ +'use strict'; + +var storageService = angular.module('oauth.storage', ['ngStorage']); + +storageService.factory('Storage', ['$rootScope', '$sessionStorage', '$localStorage', function($rootScope, $sessionStorage, $localStorage){ + + var service = { + storage: $sessionStorage // By default + }; + + /** + * Deletes the item from storage, + * Returns the item's previous value + */ + service.delete = function (name) { + var stored = this.get(name); + delete this.storage[name]; + return stored; + }; + + /** + * Returns the item from storage + */ + service.get = function (name) { + return this.storage[name]; + }; + + /** + * Sets the item in storage to the value specified + * Returns the item's value + */ + service.set = function (name, value) { + this.storage[name] = value; + return this.get(name); + }; + + /** + * Change the storage service being used + */ + service.use = function (storage) { + if (storage === 'sessionStorage') { + this.storage = $sessionStorage; + } else if (storage === 'localStorage') { + this.storage = $localStorage; + } + }; + + return service; +}]); \ No newline at end of file diff --git a/bower.json b/bower.json index 124f89e..c690710 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "oauth-ng", - "version": "0.2.7", + "version": "0.4.10", "main": [ "dist/oauth-ng.js", "dist/views/templates/default" @@ -14,8 +14,9 @@ "package.json" ], "dependencies": { - "angular": "~1.2.22", - "ngstorage": "~0.3.0" + "angular": "~1.3.12", + "ngstorage": "~0.3.0", + "jsrsasign": "~5.0.5" }, "devDependencies": { "json3": "~3.2.4", @@ -23,9 +24,9 @@ "timecop": "~0.1.1", "bootstrap-sass": "~3.0.2", "jquery": "~1.9.1", - "angular-mocks": "~1.2.22" + "angular-mocks": "~1.3.12" }, "resolutions": { - "angular": "~1.2.22" + "angular": "1.3.20" } } diff --git a/dist/oauth-ng.js b/dist/oauth-ng.js index e930a88..3ad2269 100644 --- a/dist/oauth-ng.js +++ b/dist/oauth-ng.js @@ -1,252 +1,697 @@ -/* oauth-ng - v0.2.7 - 2014-08-26 */ +/* oauth-ng - v0.4.10 - 2016-05-25 */ 'use strict'; // App libraries -var app = angular.module('oauth', [ +angular.module('oauth', [ 'oauth.directive', // login directive + 'oauth.idToken', // id token service (only for OpenID Connect) + 'oauth.oidcConfig', // for loading OIDC configuration from .well-known/openid-configuration endpoint 'oauth.accessToken', // access token service 'oauth.endpoint', // oauth endpoint service 'oauth.profile', // profile model - 'oauth.interceptor' // bearer token interceptor + 'oauth.storage', // storage + 'oauth.interceptor', // bearer token interceptor + 'oauth.configuration' // token appender ]) - -angular.module('oauth').config(['$locationProvider','$httpProvider', + .config(['$locationProvider','$httpProvider', function($locationProvider, $httpProvider) { $httpProvider.interceptors.push('ExpiredInterceptor'); }]); 'use strict'; -var accessTokenService = angular.module('oauth.accessToken', ['ngStorage']); - -accessTokenService.factory('AccessToken', function($rootScope, $location, $sessionStorage, $timeout) { +var idTokenService = angular.module('oauth.idToken', []); - var service = {}; - var token = null; +idTokenService.factory('IdToken', ['Storage', function(Storage) { + var service = { + issuer: null, + subject: null, + //clientId, should match 'aud' claim + clientId: null, + /* + The public key to verify the signature, supports: + 1.RSA public key in PEM string: e.g. "-----BEGIN PUBLIC KEY..." + 2.X509 certificate in PEM string: e.g. "-----BEGIN CERTIFICATE..." + 3.JWK (Json Web Key): e.g. {kty: "RSA", n: "0vx7...", e: "AQAB"} - /* - * Returns the access token. + If not set, the id_token header should carry the key or the url to retrieve the key + */ + pubKey: null + }; + /** + * OidcException + * @param {string } message - The exception error message + * @constructor */ - - service.get = function() { - return token + function OidcException(message) { + this.name = 'OidcException'; + this.message = message; } + OidcException.prototype = new Error(); + OidcException.prototype.constructor = OidcException; + /** + * For initialization + * @param scope + */ + service.set = function(scope) { + this.issuer = scope.issuer; + this.subject = scope.subject; + this.clientId = scope.clientId; + this.pubKey = scope.pubKey; + }; - /* - * Sets and returns the access token. It tries (in order) the following strategies: - * - takes the token from the fragment URI - * - takes the token from the sessionStorage + /** + * Validate id_token and access_token(if there's one) + * If validation passes, the id_token payload(claims) will be populated to 'params' + * Otherwise error will set to 'params' and tokens will be removed + * + * @param params */ + service.validateTokensAndPopulateClaims = function(params) { + var valid = false; + var message = ''; + try { + valid = this.validateIdToken(params.id_token); + /* + if response_type is 'id_token token', then we will get both id_token and access_token, + access_token needs to be validated as well + */ + if (valid && params.access_token) { + valid = this.validateAccessToken(params.id_token, params.access_token); + } + } catch (error) { + message = error.message; + } - service.set = function() { - service.setTokenFromString($location.hash()); - service.setTokenFromSession(); - return token - } + if (valid) { + params.id_token_claims = getIdTokenPayload(params.id_token); + } else { + params.id_token = null; + params.access_token = null; + params.error = 'Failed to validate token:' + message; + } + }; - /* - * Delete the access token and remove the session. + /** + * Validates the id_token + * @param {String} idToken The id_token + * @returns {boolean} True if all the check passes, False otherwise */ + service.validateIdToken = function(idToken) { + return this.verifyIdTokenSig(idToken) && this.verifyIdTokenInfo(idToken); + }; - service.destroy = function() { - delete $sessionStorage.token; - return token = null; - } - + /** + * Validate access_token based on the 'alg' and 'at_hash' value of the id_token header + * per spec: http://openid.net/specs/openid-connect-core-1_0.html#ImplicitTokenValidation + * + * @param idToken The id_token + * @param accessToken The access_token + * @returns {boolean} true if validation passes + */ + service.validateAccessToken = function(idToken, accessToken) { + var header = getJsonObject(getIdTokenParts(idToken)[0]); + if (header.at_hash) { + var shalevel = header.alg.substr(2); + if (shalevel !== '256' && shalevel !== '384' && shalevel !== '512') { + throw new OidcException('Unsupported hash algorithm, expecting sha256, sha384, or sha512'); + } + var md = new KJUR.crypto.MessageDigest({'alg':'sha'+ shalevel, 'prov':'cryptojs'}); + //hex representation of the hash + var hexStr = md.digestString(accessToken); + //take first 128bits and base64url encoding it + var expected = hextob64u(hexStr.substring(0, 32)); + + return expected === header.at_hash; + } else { + return true; + } + }; - /* - * Tells if the access token is expired. + /** + * Verifies the ID Token signature using the specified public key + * The id_token header can optionally carry public key or the url to retrieve the public key + * Otherwise will use the public key configured using 'pubKey' + * + * Supports only RSA signatures ['RS256', 'RS384', 'RS512'] + * @param {string}idToken The ID Token string + * @returns {boolean} Indicates whether the signature is valid or not + * @throws {OidcException} */ + service.verifyIdTokenSig = function (idToken) { - service.expired = function() { - return (token && token.expires_at && token.expires_at < new Date()); - } + var idtParts = getIdTokenParts(idToken); + var header = getJsonObject(idtParts[0]); + if(!header.alg || header.alg.substr(0, 2) !== 'RS') { + throw new OidcException('Unsupported JWS signature algorithm ' + header.alg); + } + var matchedPubKey = null; - /* * * * * * * * * * - * PRIVATE METHODS * - * * * * * * * * * */ + if (header.jwk) { + //Take the JWK if it comes with the id_token + matchedPubKey = header.jwk; + if (matchedPubKey.kid && header.kid && matchedPubKey.kid !== header.kid) { + throw new OidcException('Json Web Key ID not match'); + } + /* + TODO: Support for "jku" (JWK Set URL), "x5u" (X.509 URL), "x5c" (X.509 Certificate Chain) parameter to get key + per http://tools.ietf.org/html/draft-ietf-jose-json-web-signature-26#page-9 + */ + } else { + //Try to load the key from .well-known configuration + var oidcConfig = Storage.get('oidcConfig'); + if (angular.isDefined(oidcConfig) && oidcConfig.jwks && oidcConfig.jwks.keys) { + oidcConfig.jwks.keys.forEach(function(key) { + if (key.kid === header.kid) { + matchedPubKey = key; + } + }); + } else { + //Use configured public key + var jwk = getJsonObject(this.pubKey); + matchedPubKey = jwk ? jwk : this.pubKey; //JWK or PEM + } + } + if(!matchedPubKey) { + throw new OidcException('No public key found to verify signature'); + } - /* - * Get the access token from a string and save it + return rsaVerifyJWS(idToken, matchedPubKey, header.alg); + }; + + /** + * Validates the information in the ID Token against configuration + * @param {string} idtoken The ID Token string + * @returns {boolean} Validity of the ID Token + * @throws {OidcException} */ + service.verifyIdTokenInfo = function(idtoken) { + var valid = false; + + if (idtoken) { + var idtParts = getIdTokenParts(idtoken); + var payload = getJsonObject(idtParts[1]); + if (payload) { + var now = new Date() / 1000; + if (payload.iat > now + 60) + throw new OidcException('ID Token issued time is later than current time'); + + if (payload.exp < now ) + throw new OidcException('ID Token expired'); + + if (now < payload.ntb) + throw new OidcException('ID Token is invalid before '+ payload.ntb); + + if (payload.iss && this.issuer && payload.iss !== this.issuer) + throw new OidcException('Invalid issuer ' + payload.iss + ' != ' + this.issuer); + + if (payload.sub && this.subject && payload.sub !== this.subject) + throw new OidcException('Invalid subject ' + payload.sub + ' != ' + this.subject); + + if (payload.aud) { + if (payload.aud instanceof Array && !KJUR.jws.JWS.inArray(this.clientId, payload.aud)) { + throw new OidcException('Client not in intended audience:' + payload.aud); + } + if (typeof payload.aud === 'string' && payload.aud !== this.clientId) { + throw new OidcException('Invalid audience ' + payload.aud + ' != ' + this.clientId); + } + } + + //TODO: nonce support ? probably need to redo current nonce support + //if(payload['nonce'] != sessionStorage['nonce']) + // throw new OidcException('invalid nonce'); + valid = true; + } else + throw new OidcException('Unable to parse JWS payload'); + } + return valid; + }; - service.setTokenFromString = function(hash) { - var token = getTokenFromString(hash); + /** + * Verifies the JWS string using the JWK + * @param {string} jws The JWS string + * @param {object} pubKey The public key that will be used to verify the signature + * @param {string} alg The algorithm string. Expecting 'RS256', 'RS384', or 'RS512' + * @returns {boolean} Validity of the JWS signature + * @throws {OidcException} + */ + var rsaVerifyJWS = function (jws, pubKey, alg) { + /* + convert various public key format to RSAKey object + see @KEYUTIL.getKey for a full list of supported input format + */ + var rsaKey = KEYUTIL.getKey(pubKey); + + return KJUR.jws.JWS.verify(jws, rsaKey, [alg]); + }; - if (token) { - removeFragment(); - service.setToken(token); - setExpiresAt(token); - $rootScope.$broadcast('oauth:login', token); - } + /** + * Splits the ID Token string into the individual JWS parts + * @param {string} id_token ID Token + * @returns {Array} An array of the JWS compact serialization components (header, payload, signature) + */ + var getIdTokenParts = function (id_token) { + var jws = new KJUR.jws.JWS(); + jws.parseJWS(id_token); + return [jws.parsedJWS.headS, jws.parsedJWS.payloadS, jws.parsedJWS.si]; }; + /** + * Get the contents of the ID Token payload as an JSON object + * @param {string} id_token ID Token + * @returns {object} The ID Token payload JSON object + */ + var getIdTokenPayload = function (id_token) { + var parts = getIdTokenParts(id_token); + if(parts) + return getJsonObject(parts[1]); + }; - /* - * Parse the fragment URI and return an object + /** + * Get the JSON object from the JSON string + * @param {string} jsonS JSON string + * @returns {object|null} JSON object or null */ + var getJsonObject = function (jsonS) { + var jws = KJUR.jws.JWS; + if(jws.isSafeJSONString(jsonS)) { + return jws.readSafeJSONString(jsonS); + } + return null; + }; + + return service; - var getTokenFromString = function(hash) { - var splitted = hash.split('&'); - var params = {}; +}]); - for (var i = 0; i < splitted.length; i++) { - var param = splitted[i].split('='); - var key = param[0]; - var value = param[1]; - params[key] = value +(function() { + 'use strict'; + + angular.module('oauth.oidcConfig', []) + .factory('OidcConfig', ['Storage', '$http', '$q', '$log', OidcConfig]); + + function OidcConfig(Storage, $http, $q, $log) { + var cache = null; + return { + load: load + }; + + function load(scope) { + if (scope.issuer && scope.wellKnown && scope.wellKnown !== "false") { + var promise = loadConfig(scope.issuer); + if (scope.wellKnown === "sync") { + return promise; + } + } + return $q.when(1); } - if (params.access_token || params.error) - return params; + function loadConfig(iss) { + if (cache === null) { + cache = Storage.get('oidcConfig'); + } + if (angular.isDefined(cache)) { + return $q.when(cache); + } else { + return loadOpenidConfiguration(iss) + .then(saveCache) + .then(loadJwks) + .then(saveCache, errorLogger); + } + } + + function errorLogger(err) { + $log.error("Could not load OIDC config:", err); + return $q.reject(err); + } + + function saveCache(o) { + Storage.set('oidcConfig', cache); + return o; + } + + function joinPath(x,y) { + return x + (x.charAt(x.length - 1) === '/' ? '' : '/') + y; + } + + function loadOpenidConfiguration(iss) { + var configUri = joinPath(iss, ".well-known/openid-configuration"); + return $http.get(configUri).then(function(res) { + return cache = res.data; + }, function(err) { + return $q.reject("Could not get config info from " + configUri + ' . Check the availability of this url.'); + }); + } + + function loadJwks(oidcConf) { + if (oidcConf.jwks_uri) { + return $http.get(oidcConf.jwks_uri).then(function(res) { + return oidcConf.jwks = res.data; + }); + } else { + return $q.reject("No jwks_uri found."); + } + } } +})(); + +'use strict'; +var accessTokenService = angular.module('oauth.accessToken', []); - /* - * Set the access token from the sessionStorage. +accessTokenService.factory('AccessToken', ['Storage', '$rootScope', '$location', '$interval', '$timeout', 'IdToken', function(Storage, $rootScope, $location, $interval, $timeout, IdToken){ + + var service = { + token: null + }, + hashFragmentKeys = [ + //Oauth2 keys per http://tools.ietf.org/html/rfc6749#section-4.2.2 + 'access_token', 'token_type', 'expires_in', 'scope', 'state', + 'error','error_description', + //Additional OpenID Connect key per http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthResponse + 'id_token' + ]; + var expiresAtEvent = null; + + /** + * Returns the access token. + */ + service.get = function(){ + return this.token; + }; + + /** + * Sets and returns the access token. It tries (in order) the following strategies: + * - takes the token from the fragment URI + * - takes the token from the sessionStorage */ + service.set = function(){ + this.setTokenFromString($location.hash()); - service.setTokenFromSession = function() { - if ($sessionStorage.token) { - var params = $sessionStorage.token; - params.expires_at = new Date(params.expires_at); - service.setToken(params); + //If hash is present in URL always use it, cuz its coming from oAuth2 provider redirect + if(null === service.token){ + setTokenFromSession(); } - } + return this.token; + }; - /* - * Save the access token into the session + /** + * Delete the access token and remove the session. + * @returns {null} */ + service.destroy = function(){ + Storage.delete('token'); + this.token = null; + return this.token; + }; - var setTokenInSession = function() { - $sessionStorage.token = token; - } + /** + * Tells if the access token is expired. + */ + service.expired = function(){ + return (this.token && this.token.expires_at && new Date(this.token.expires_at) < new Date()); + }; + /** + * Get the access token from a string and save it + * @param hash + */ + service.setTokenFromString = function(hash){ + var params = getTokenFromString(hash); - /* - * Set the access token. + if(params){ + removeFragment(); + setToken(params); + setExpiresAt(); + // We have to save it again to make sure expires_at is set + // and the expiry event is set up properly + setToken(this.token); + $rootScope.$broadcast('oauth:login', service.token); + } + }; + + /** + * updates the expiration of the token + */ + service.updateExpiry = function(newExpiresIn){ + this.token.expires_in = newExpiresIn; + setExpiresAt(); + }; + + /* * * * * * * * * * + * PRIVATE METHODS * + * * * * * * * * * */ + + /** + * Set the access token from the sessionStorage. */ + var setTokenFromSession = function(){ + var params = Storage.get('token'); + if (params) { + setToken(params); + } + }; - service.setToken = function(params) { - token = token || {}; // init the token - angular.extend(token, params); // set the access token params + /** + * Set the access token. + * + * @param params + * @returns {*|{}} + */ + var setToken = function(params){ + service.token = service.token || {}; // init the token + angular.extend(service.token, params); // set the access token params setTokenInSession(); // save the token into the session setExpiresAtEvent(); // event to fire when the token expires - return token; + return service.token; }; + /** + * Parse the fragment URI and return an object + * @param hash + * @returns {{}} + */ + var getTokenFromString = function(hash){ + var params = {}, + regex = /([^&=]+)=([^&]*)/g, + m; - /* - * Set the access token expiration date (useful for refresh logics) + while ((m = regex.exec(hash)) !== null) { + params[decodeURIComponent(m[1])] = decodeURIComponent(m[2]); + } + + // OpenID Connect + if (params.id_token && !params.error) { + IdToken.validateTokensAndPopulateClaims(params); + return params; + } + + // Oauth2 + if(params.access_token || params.error){ + return params; + } + }; + + /** + * Save the access token into the session */ + var setTokenInSession = function(){ + Storage.set('token', service.token); + }; - var setExpiresAt = function(token) { - if (token) { + /** + * Set the access token expiration date (useful for refresh logics) + */ + var setExpiresAt = function(){ + if (!service.token) { + return; + } + if(typeof(service.token.expires_in) !== 'undefined' && service.token.expires_in !== null) { var expires_at = new Date(); - expires_at.setSeconds(expires_at.getSeconds() + parseInt(token.expires_in) - 60); // 60 seconds less to secure browser and response latency - token.expires_at = expires_at; + expires_at.setSeconds(expires_at.getSeconds() + parseInt(service.token.expires_in)-60); // 60 seconds less to secure browser and response latency + service.token.expires_at = expires_at; + } + else { + service.token.expires_at = null; } }; - /* + /** * Set the timeout at which the expired event is fired */ + var setExpiresAtEvent = function(){ + // Don't bother if there's no expires token + if (typeof(service.token.expires_at) === 'undefined' || service.token.expires_at === null) { + return; + } + cancelExpiresAtEvent(); + var time = (new Date(service.token.expires_at))-(new Date()); + if(time && time > 0 && time <= 2147483647){ + expiresAtEvent = $interval(function(){ + $rootScope.$broadcast('oauth:expired', service.token); + }, time, 1); + } + }; - var setExpiresAtEvent = function() { - var time = (new Date(token.expires_at)) - (new Date()) - if (time) { $timeout(function() { $rootScope.$broadcast('oauth:expired', token) }, time) } - } - + var cancelExpiresAtEvent = function() { + if(expiresAtEvent) { + $timeout.cancel(expiresAtEvent); + expiresAtEvent = undefined; + } + }; - /* - * Remove the fragment URI - * TODO we need to remove only the access token + /** + * Remove the oAuth2 pieces from the hash fragment */ + var removeFragment = function(){ + var curHash = $location.hash(); + angular.forEach(hashFragmentKeys,function(hashKey){ + var re = new RegExp('&'+hashKey+'(=[^&]*)?|^'+hashKey+'(=[^&]*)?&?'); + curHash = curHash.replace(re,''); + }); - var removeFragment = function(scope) { - $location.hash(''); - } - + $location.hash(curHash); + }; return service; -}); + +}]); 'use strict'; var endpointClient = angular.module('oauth.endpoint', []); -endpointClient.factory('Endpoint', function(AccessToken, $location) { +endpointClient.factory('Endpoint', ['$rootScope', 'AccessToken', '$q', '$http', function($rootScope, AccessToken, $q, $http) { var service = {}; - var url; + var buildOauthUrl = function (path, params) { + var oAuthScope = (params.scope) ? encodeURIComponent(params.scope) : '', + state = (params.state) ? encodeURIComponent(params.state) : '', + authPathHasQuery = (params.authorizePath.indexOf('?') == -1) ? false : true, + appendChar = (authPathHasQuery) ? '&' : '?', //if authorizePath has ? already append OAuth2 params + nonceParam = (params.nonce) ? '&nonce=' + params.nonce : '', + responseType = encodeURIComponent(params.responseType); + + return params.site + + path + + appendChar + 'response_type=' + responseType + '&' + + 'client_id=' + encodeURIComponent(params.clientId) + '&' + + 'redirect_uri=' + encodeURIComponent(params.redirectUri) + '&' + + 'scope=' + oAuthScope + '&' + + 'state=' + state + nonceParam; + }; + + var extendValidity = function (tokenInfo) { + AccessToken.updateExpiry(tokenInfo.expires); + }; /* * Defines the authorization URL */ - service.set = function(scope) { - var oAuthScope = (scope.scope) ? scope.scope : '', - state = (scope.state) ? encodeURIComponent(scope.state) : '', - authPathHasQuery = (scope.authorizePath.indexOf('?') == -1) ? false : true, - appendChar = (authPathHasQuery) ? '&' : '?'; //if authorizePath has ? already append OAuth2 params + service.set = function(configuration) { + this.config = configuration; + return this.get(); + }; - url = scope.site + - scope.authorizePath + - appendChar + 'response_type=token&' + - 'client_id=' + encodeURIComponent(scope.clientId) + '&' + - 'redirect_uri=' + encodeURIComponent(scope.redirectUri) + '&' + - 'scope=' + oAuthScope + '&' + - 'state=' + state; + /* + * Returns the authorization URL + */ - return url; + service.get = function(overrides) { + var params = angular.extend( {}, service.config, overrides); + return buildOauthUrl(params.authorizePath, params); }; /* - * Returns the authorization URL + * Redirects the app to the authorization URL */ - service.get = function() { - return url; + service.redirect = function(overrides) { + var targetLocation = this.get(overrides); + $rootScope.$broadcast('oauth:logging-in'); + window.location.replace(targetLocation); }; + /* + * Alias for 'redirect' + */ + service.login = function() { + service.redirect(); + }; /* - * Redirects the app to the authorization URL + * Check the validity of the token if a session path is available + */ + service.checkValidity = function() { + var params = service.config; + if( params.sessionPath ) { + var token = AccessToken.get(); + if( !token ) { + return $q.reject("No token configured"); + } + var path = params.site + params.sessionPath + "?token=" + token.access_token; + return $http.get(path).then( function(httpResponse) { + var tokenInfo = httpResponse.data; + if(tokenInfo.valid) { + extendValidity(tokenInfo); + return true; + } else { + return $q.reject("Server replied: token is invalid."); + } + }); + } else { + return $q.reject("You must give a :session-path param in order to validate the token.") + } + }; + + /* + * Destroys the session, sends the user to the logout url if set. + * First broadcasts 'logging-out' and then 'logout' when finished. */ - service.redirect = function() { - window.location.replace(url); + service.logout = function() { + var params = service.config; + AccessToken.destroy(); + $rootScope.$broadcast('oauth:logging-out'); + if( params.logoutPath ) { + window.location.replace(buildOauthUrl(params.logoutPath, params)); + } + $rootScope.$broadcast('oauth:logout'); }; return service; -}); +}]); 'use strict'; -var profileClient = angular.module('oauth.profile', []) +var profileClient = angular.module('oauth.profile', []); -profileClient.factory('Profile', function($http, AccessToken) { +profileClient.factory('Profile', ['$http', 'AccessToken', '$rootScope', function($http, AccessToken, $rootScope) { var service = {}; var profile; service.find = function(uri) { var promise = $http.get(uri, { headers: headers() }); - promise.success(function(response) { profile = response }); + promise.then(function(response) { + profile = response.data; + $rootScope.$broadcast('oauth:profile', profile); + }); return promise; }; - service.get = function(uri) { + service.get = function() { return profile; }; @@ -260,141 +705,289 @@ profileClient.factory('Profile', function($http, AccessToken) { }; return service; -}); +}]); 'use strict'; -var interceptorService = angular.module('oauth.interceptor', []); +var storageService = angular.module('oauth.storage', ['ngStorage']); -interceptorService.factory('ExpiredInterceptor', function ($rootScope, $q, $sessionStorage) { +storageService.factory('Storage', ['$rootScope', '$sessionStorage', '$localStorage', function($rootScope, $sessionStorage, $localStorage){ - var service = {}; + var service = { + storage: $sessionStorage // By default + }; - service.request = function(config) { - var token = $sessionStorage.token; + /** + * Deletes the item from storage, + * Returns the item's previous value + */ + service.delete = function (name) { + var stored = this.get(name); + delete this.storage[name]; + return stored; + }; - if (token && expired(token)) - $rootScope.$broadcast('oauth:expired', token); + /** + * Returns the item from storage + */ + service.get = function (name) { + return this.storage[name]; + }; - return config; + /** + * Sets the item in storage to the value specified + * Returns the item's value + */ + service.set = function (name, value) { + this.storage[name] = value; + return this.get(name); }; - var expired = function(token) { - return (token && token.expires_at && new Date(token.expires_at) < new Date()) + /** + * Change the storage service being used + */ + service.use = function (storage) { + if (storage === 'sessionStorage') { + this.storage = $sessionStorage; + } else if (storage === 'localStorage') { + this.storage = $localStorage; + } }; return service; -}); +}]); +'use strict'; +var oauthConfigurationService = angular.module('oauth.configuration', []); + +oauthConfigurationService.provider('OAuthConfiguration', function() { + var _config = {}; + + this.init = function(config, httpProvider) { + _config.protectedResources = config.protectedResources || []; + httpProvider.interceptors.push('AuthInterceptor'); + }; + + this.$get = function() { + return { + getConfig: function() { + return _config; + } + }; + }; +}) +.factory('AuthInterceptor', ['OAuthConfiguration', 'AccessToken', function(OAuthConfiguration, AccessToken) { + return { + 'request': function(config) { + OAuthConfiguration.getConfig().protectedResources.forEach(function(resource) { + // If the url is one of the protected resources, we want to see if there's a token and then + // add the token if it exists. + if (config.url.indexOf(resource) > -1) { + var token = AccessToken.get(); + if (token) { + config.headers.Authorization = 'Bearer ' + token.access_token; + } + } + }); + + return config; + } + }; +}]); 'use strict'; -var directives = angular.module('oauth.directive', []); +var interceptorService = angular.module('oauth.interceptor', []); -directives.directive('oauth', function(AccessToken, Endpoint, Profile, $location, $rootScope, $compile, $http, $templateCache) { - - var definition = { - restrict: 'AE', - replace: true, - scope: { - site: '@', // (required) set the oauth server host (e.g. http://oauth.example.com) - clientId: '@', // (required) client id - redirectUri: '@', // (required) client redirect uri - scope: '@', // (optional) scope - profileUri: '@', // (optional) user profile uri (e.g http://example.com/me) - template: '@', // (optional) template to render (e.g bower_components/oauth-ng/dist/views/templates/default.html) - text: '@', // (optional) login text - authorizePath: '@', // (optional) authorization url - state: '@' // (optional) An arbitrary unique string created by your app to guard against Cross-site Request Forgery - } - }; +interceptorService.factory('ExpiredInterceptor', ['Storage', '$rootScope', function (Storage, $rootScope) { - definition.link = function postLink(scope, element, attrs) { - scope.show = 'none'; + var service = {}; - scope.$watch('clientId', function(value) { init() }); + service.request = function(config) { + var token = Storage.get('token'); - var init = function() { - initAttributes(); // sets defaults - compile(); // compiles the desired layout - Endpoint.set(scope); // sets the oauth authorization url - AccessToken.set(scope); // sets the access token object (if existing, from fragment or session) - initProfile(scope); // gets the profile resource (if existing the access token) - initView(); // sets the view (logged in or out) - }; + if (token && expired(token)) { + $rootScope.$broadcast('oauth:expired', token); + } - var initAttributes = function() { - scope.authorizePath = scope.authorizePath || '/oauth/authorize'; - scope.tokenPath = scope.tokenPath || '/oauth/token'; - scope.template = scope.template || 'bower_components/oauth-ng/dist/views/templates/default.html'; - scope.text = scope.text || 'Sign In'; - scope.state = scope.state || undefined; - scope.scope = scope.scope || undefined; - }; + return config; + }; - var compile = function() { - $http.get(scope.template, { cache: $templateCache }).success(function(html) { - element.html(html); - $compile(element.contents())(scope); - }); - }; + var expired = function(token) { + return (token && token.expires_at && new Date(token.expires_at) < new Date()); + }; - var initProfile = function(scope) { - var token = AccessToken.get(); + return service; +}]); - if (token && token.access_token && scope.profileUri) { - Profile.find(scope.profileUri).success(function(response) { - scope.profile = response - }) - } - }; +'use strict'; - var initView = function() { - var token = AccessToken.get(); +var directives = angular.module('oauth.directive', []); - if (!token) { return loggedOut() } // without access token it's logged out - if (token.access_token) { return authorized() } // if there is the access token we are done - if (token.error) { return denied() } // if the request has been denied we fire the denied event +directives.directive('oauth', [ + 'IdToken', + 'AccessToken', + 'Endpoint', + 'Profile', + 'Storage', + 'OidcConfig', + '$location', + '$rootScope', + '$compile', + '$http', + '$templateCache', + '$timeout', + function(IdToken, AccessToken, Endpoint, Profile, Storage, OidcConfig, $location, $rootScope, $compile, $http, $templateCache, $timeout) { + + var definition = { + restrict: 'AE', + replace: true, + scope: { + site: '@', // (required) set the oauth server host (e.g. http://oauth.example.com) + clientId: '@', // (required) client id + redirectUri: '@', // (required) client redirect uri + responseType: '@', // (optional) response type, defaults to token (use 'token' for implicit flow and 'code' for authorization code flow + scope: '@', // (optional) scope + profileUri: '@', // (optional) user profile uri (e.g http://example.com/me) + template: '@', // (optional) template to render (e.g bower_components/oauth-ng/dist/views/templates/default.html) + text: '@', // (optional) login text + authorizePath: '@', // (optional) authorization url + state: '@', // (optional) An arbitrary unique string created by your app to guard against Cross-site Request Forgery + storage: '@', // (optional) Store token in 'sessionStorage' or 'localStorage', defaults to 'sessionStorage' + nonce: '@', // (optional) Send nonce on auth request + // OpenID Connect extras, more details in id-token.js: + issuer: '@', // (optional for OpenID Connect) issuer of the id_token, should match the 'iss' claim in id_token payload + subject: '@', // (optional for OpenID Connect) subject of the id_token, should match the 'sub' claim in id_token payload + pubKey: '@', // (optional for OpenID Connect) the public key(RSA public key or X509 certificate in PEM format) to verify the signature + wellKnown: '@', // (optional for OpenID Connect) whether to load public key according to .well-known/openid-configuration endpoint + logoutPath: '@', // (optional) A url to go to at logout + sessionPath: '@' // (optional) A url to use to check the validity of the current token. + } }; - scope.login = function() { - Endpoint.redirect(); - }; + definition.link = function postLink(scope, element) { + scope.show = 'none'; - scope.logout = function() { - AccessToken.destroy(scope); - loggedOut(); - }; + scope.$watch('clientId', function() { + init(); + }); - // user is authorized - var authorized = function() { - $rootScope.$broadcast('oauth:authorized', AccessToken.get()); - scope.show = 'logged-in'; - }; + var init = function() { + initAttributes(); // sets defaults + Storage.use(scope.storage);// set storage + compile(); // compiles the desired layout + Endpoint.set(scope); // sets the oauth authorization url + OidcConfig.load(scope) // loads OIDC configuration from .well-known/openid-configuration if necessary + .then(function() { + IdToken.set(scope); + AccessToken.set(scope); // sets the access token object (if existing, from fragment or session) + initProfile(scope); // gets the profile resource (if existing the access token) + initView(); // sets the view (logged in or out) + checkValidity(); // ensure the validity of the current token + }); + }; + + var initAttributes = function() { + scope.authorizePath = scope.authorizePath || '/oauth/authorize'; + scope.tokenPath = scope.tokenPath || '/oauth/token'; + scope.template = scope.template || 'bower_components/oauth-ng/dist/views/templates/default.html'; + scope.responseType = scope.responseType || 'token'; + scope.text = scope.text || 'Sign In'; + scope.state = scope.state || undefined; + scope.scope = scope.scope || undefined; + scope.storage = scope.storage || 'sessionStorage'; + }; + + var compile = function() { + $http.get(scope.template, { cache: $templateCache }).then(function(html) { + element.html(html.data); + $compile(element.contents())(scope); + }); + }; + + var initProfile = function(scope) { + var token = AccessToken.get(); + + if (token && token.access_token && scope.profileUri) { + Profile.find(scope.profileUri).then(function(response) { + scope.profile = response.data; + }); + } + }; + + var initView = function () { + var token = AccessToken.get(); + + if (!token) { + return scope.login(); + } // without access token it's logged out, so we attempt to log in + if (AccessToken.expired()) { + return expired(); + } // with a token, but it's expired + if (token.access_token) { + return authorized(); + } // if there is the access token we are done + if (token.error) { + return denied(); + } // if the request has been denied we fire the denied event + }; + + scope.login = function () { + Endpoint.redirect(); + }; + + scope.logout = function () { + Endpoint.logout(); + $rootScope.$broadcast('oauth:loggedOut'); + scope.show = 'logged-out'; + }; + + scope.$on('oauth:expired',expired); + + // user is authorized + var authorized = function() { + $rootScope.$broadcast('oauth:authorized', AccessToken.get()); + scope.show = 'logged-in'; + }; + + var expired = function() { + $rootScope.$broadcast('oauth:expired'); + scope.logout(); + }; + + // set the oauth directive to the denied status + var denied = function() { + scope.show = 'denied'; + $rootScope.$broadcast('oauth:denied'); + }; + + var checkValidity = function() { + Endpoint.checkValidity().then(function() { + $rootScope.$broadcast('oauth:valid'); + }).catch(function(message){ + $rootScope.$broadcast('oauth:invalid', message); + }); + }; + + var refreshDirective = function () { + scope.$apply(); + }; + + // Updates the template at runtime + scope.$on('oauth:template:update', function(event, template) { + scope.template = template; + compile(scope); + }); - // set the oauth directive to the logged-out status - var loggedOut = function() { - $rootScope.$broadcast('oauth:logout'); - scope.show = 'logged-out'; - }; + // Hack to update the directive content on logout + scope.$on('$routeChangeSuccess', function () { + $timeout(refreshDirective); + }); - // set the oauth directive to the denied status - var denied = function() { - scope.show = 'denied'; - $rootScope.$broadcast('oauth:denied'); + scope.$on('$stateChangeSuccess', function () { + $timeout(refreshDirective); + }); }; - // Updates the template at runtime - scope.$on('oauth:template:update', function(event, template) { - scope.template = template; - compile(scope); - }); - - // Hack to update the directive content on logout - // TODO think to a cleaner solution - scope.$on('$routeChangeSuccess', function () { - init(); - }); - }; - - return definition -}); + return definition; + } +]); diff --git a/karma.conf.js b/karma.conf.js index b66d4e9..5495d37 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -16,6 +16,7 @@ module.exports = function(config) { 'app/bower_components/angular-mocks/angular-mocks.js', 'app/bower_components/ngstorage/ngStorage.js', 'app/bower_components/timecop/timecop-0.1.1.js', + 'app/bower_components/jsrsasign/jsrsasign-latest-all-min.js', 'app/scripts/*.js', 'app/scripts/**/*.js', 'app/views/**/*.html', diff --git a/package.json b/package.json index 72e070d..8dea385 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,19 @@ { "name": "oauth-ng", - "version": "0.2.7", + "version": "0.4.10", "author": "Andrea Reginato ", "description": "AngularJS Directive for OAuth 2.0", "repository": { "type": "git", - "url": "https://github.com/andreareginato/oauth-ng" + "url": "https://github.com/angularjs-oauth/oauth-ng" }, "dependencies": {}, "devDependencies": { + "bower": "^1.3.12", "grunt": "~0.4.1", "grunt-autoprefixer": "~0.4.0", "grunt-bower-install": "~0.7.0", + "grunt-cli": "^0.1.13", "grunt-concurrent": "~0.4.1", "grunt-contrib-clean": "~0.5.0", "grunt-contrib-coffee": "~0.7.0", @@ -26,30 +28,32 @@ "grunt-contrib-uglify": "~0.2.0", "grunt-contrib-watch": "~0.5.2", "grunt-google-cdn": "~0.2.0", + "grunt-karma": "~0.8.0", "grunt-newer": "~0.5.4", "grunt-ngmin": "~0.0.2", + "grunt-protractor-runner": "~0.2.4", + "grunt-replace": "~0.7.7", "grunt-rev": "~0.1.0", + "grunt-string-replace": "~0.2.7", "grunt-svgmin": "~0.2.0", "grunt-usemin": "~2.0.0", "jshint-stylish": "~0.1.3", - "load-grunt-tasks": "~0.2.0", - "time-grunt": "~0.2.1", - "karma-ng-scenario": "~0.1.0", - "grunt-karma": "~0.8.0", "karma": "~0.12.0", - "karma-ng-html2js-preprocessor": "~0.1.0", - "karma-jasmine": "~0.2.2", - "karma-phantomjs-launcher": "~0.1.2", "karma-chrome-launcher": "~0.1.2", "karma-coverage": "~0.2.1", - "grunt-protractor-runner": "~0.2.4", - "grunt-replace": "~0.7.7", - "grunt-string-replace": "~0.2.7" + "karma-jasmine": "~0.2.2", + "karma-ng-html2js-preprocessor": "~0.1.0", + "karma-ng-scenario": "~0.1.0", + "karma-phantomjs-launcher": "~0.1.2", + "load-grunt-tasks": "~0.2.0", + "time-grunt": "~0.2.1" }, "engines": { "node": ">=0.8.0" }, "scripts": { - "test": "grunt test:unit" - } + "clean": "rm -rf node_modules dist app/bower_components", + "test": "grunt test && grunt build" + }, + "main": "dist/oauth-ng.js" } diff --git a/test/.jshintrc b/test/.jshintrc new file mode 100644 index 0000000..847dc88 --- /dev/null +++ b/test/.jshintrc @@ -0,0 +1,32 @@ +{ + "node": true, + "browser": true, + "esnext": true, + "bitwise": true, + "camelcase": false, + "curly": true, + "eqeqeq": true, + "immed": true, + "indent": 2, + "latedef": true, + "newcap": true, + "noarg": true, + "quotmark": "single", + "undef": true, + "unused": true, + "strict": true, + "trailing": true, + "smarttabs": true, + "globals": { + "angular": false, + "jasmine": false, + "Timecop": false, + "spyOn": false, + "describe": false, + "it": false, + "expect": false, + "beforeEach": false, + "afterEach": false, + "inject": false + } +} \ No newline at end of file diff --git a/test/spec/directives/oauth.js b/test/spec/directives/oauth.js index e5e34c8..13da42a 100644 --- a/test/spec/directives/oauth.js +++ b/test/spec/directives/oauth.js @@ -2,24 +2,27 @@ describe('oauth', function() { - var $rootScope, $location, $sessionStorage, $httpBackend, $compile, AccessToken, Endpoint, element, scope, result, callback; + var $rootScope, $location, Storage, $httpBackend, $compile, AccessToken, Endpoint, element, scope, result, callback, + validCallback, invalidCallback; - var uri = 'http://example.com/oauth/authorize?response_type=token&client_id=client-id&redirect_uri=http://example.com/redirect&scope=scope&state=/'; var fragment = 'access_token=token&token_type=bearer&expires_in=7200&state=/path'; - var denied = 'error=access_denied&error_description=error'; - var headers = { 'Accept': 'application/json, text/plain, */*', 'Authorization': 'Bearer token' } - var profile = { id: '1', full_name: 'Alice Wonderland', email: 'alice@example.com' }; + var denied = 'error=access_denied&error_description=error'; + var headers = { 'Accept': 'application/json, text/plain, */*', 'Authorization': 'Bearer token' }; + var profile = { id: '1', full_name: 'Alice Wonderland', email: 'alice@example.com' }; + var session = { valid: true , expires: ((new Date()).getTime() + 356*24*60*60) }; + var invalidSession = { valid: false }; + var invalidProfile = { valid: false }; beforeEach(module('oauth')); beforeEach(module('templates')); - beforeEach(inject(function($injector) { $rootScope = $injector.get('$rootScope') })); - beforeEach(inject(function($injector) { $compile = $injector.get('$compile') })); - beforeEach(inject(function($injector) { $location = $injector.get('$location') })); - beforeEach(inject(function($injector) { $sessionStorage = $injector.get('$sessionStorage') })); - beforeEach(inject(function($injector) { $httpBackend = $injector.get('$httpBackend') })); - beforeEach(inject(function($injector) { AccessToken = $injector.get('AccessToken') })); - beforeEach(inject(function($injector) { Endpoint = $injector.get('Endpoint') })); + beforeEach(inject(function($injector) { $rootScope = $injector.get('$rootScope'); })); + beforeEach(inject(function($injector) { $compile = $injector.get('$compile'); })); + beforeEach(inject(function($injector) { $location = $injector.get('$location'); })); + beforeEach(inject(function($injector) { Storage = $injector.get('Storage'); })); + beforeEach(inject(function($injector) { $httpBackend = $injector.get('$httpBackend'); })); + beforeEach(inject(function($injector) { AccessToken = $injector.get('AccessToken'); })); + beforeEach(inject(function($injector) { Endpoint = $injector.get('Endpoint'); })); beforeEach(function() { element = angular.element( @@ -28,7 +31,10 @@ describe('oauth', function() { 'client="client-id"' + 'redirect="http://example.com/redirect"' + 'scope="scope"' + - 'profile-uri="http://example.com/me">Sign In' + + 'profile-uri="http://example.com/me"' + + 'session-path="/session"' + + '>Sign In' + + '' + '' ); }); @@ -37,122 +43,155 @@ describe('oauth', function() { scope = $rootScope; $compile(element)(scope); scope.$digest(); - } - + }; describe('when logged in', function() { beforeEach(function() { callback = jasmine.createSpy('callback'); + validCallback = jasmine.createSpy('validCallback'); + $rootScope.$on('oauth:valid', validCallback); }); beforeEach(function() { $location.hash(fragment); }); - beforeEach(function() { - $httpBackend.whenGET('http://example.com/me', headers).respond(profile); - }); - - beforeEach(function() { - $rootScope.$on('oauth:authorized', callback); - }); - - beforeEach(function() { - $rootScope.$on('oauth:login', callback); - }); - - beforeEach(function() { - compile($rootScope, $compile); - }); - - it('shows the link "Logout #{profile.email}"', function() { - $rootScope.$apply(); - $httpBackend.flush(); - result = element.find('.logged-in').text(); - expect(result).toBe('Logout alice@example.com'); - }); - - it('removes the fragment', function() { - expect($location.hash()).toBe(''); - }); + describe('invalid session', function () { + beforeEach(function () { + invalidCallback = jasmine.createSpy('invalidCallback'); + $httpBackend.whenGET('http://example.com/me', headers).respond(invalidProfile); + $httpBackend.whenGET('http://example.com/session?token=token').respond(invalidSession); + $rootScope.$on('oauth:invalid', invalidCallback); + }); - it('shows the logout link', function() { - expect(element.find('.logged-out').attr('class')).toMatch('ng-hide'); - expect(element.find('.logged-in').attr('class')).not.toMatch('ng-hide'); - }); + beforeEach(function () { + compile($rootScope, $compile); + }); - it('fires the oauth:login and oauth:authorized event', function() { - var token = AccessToken.get(); - expect(callback.calls.count()).toBe(2); + it('hits the session and is invalid', function () { + $httpBackend.flush(); + expect(invalidCallback.calls.count()).toBe(1); + }); }); + describe('valid session', function () { - describe('when refreshes the page', function() { - - beforeEach(function() { - callback = jasmine.createSpy('callback'); + beforeEach(function () { + $httpBackend.whenGET('http://example.com/me', headers).respond(profile); + $httpBackend.whenGET('http://example.com/session?token=token').respond(session); }); - beforeEach(function() { + beforeEach(function () { $rootScope.$on('oauth:authorized', callback); }); - beforeEach(function() { - $location.path('/'); + beforeEach(function () { + $rootScope.$on('oauth:login', callback); }); - beforeEach(function() { + beforeEach(function () { compile($rootScope, $compile); }); - it('keeps being logged in', function() { + it('shows the link "Logout #{profile.email}"', function () { $rootScope.$apply(); $httpBackend.flush(); result = element.find('.logged-in').text(); expect(result).toBe('Logout alice@example.com'); }); - it('shows the logout link', function() { + it('removes the fragment', function () { + expect($location.hash()).toBe(''); + }); + + it('shows the logout link', function () { expect(element.find('.logged-out').attr('class')).toMatch('ng-hide'); expect(element.find('.logged-in').attr('class')).not.toMatch('ng-hide'); }); - it('fires the oauth:authorized event', function() { - var event = jasmine.any(Object); - var token = AccessToken.get(); - expect(callback).toHaveBeenCalledWith(event, token); + it('fires the oauth:login and oauth:authorized event', function () { + AccessToken.get(); + expect(callback.calls.count()).toBe(2); }); - it('does not fire the oauth:login event', function() { - var token = AccessToken.get(); - expect(callback.calls.count()).toBe(1); + it('hits the session and is valid', function () { + $httpBackend.flush(); + expect(validCallback.calls.count()).toBe(1); }); - }); - describe('when logs out', function() { + describe('when refreshes the page', function () { - beforeEach(function() { - callback = jasmine.createSpy('callback'); - }); + beforeEach(function () { + callback = jasmine.createSpy('callback'); + }); - beforeEach(function() { - $rootScope.$on('oauth:logout', callback); - }); + beforeEach(function () { + $rootScope.$on('oauth:authorized', callback); + }); - beforeEach(function() { - element.find('.logged-in').click(); - }); + beforeEach(function () { + $location.path('/'); + }); + + beforeEach(function () { + compile($rootScope, $compile); + }); - it('shows the login link', function() { - expect(element.find('.logged-out').attr('class')).not.toMatch('ng-hide'); - expect(element.find('.logged-in').attr('class')).toMatch('ng-hide'); + it('keeps being logged in', function () { + $rootScope.$apply(); + $httpBackend.flush(); + result = element.find('.logged-in').text(); + expect(result).toBe('Logout alice@example.com'); + }); + + it('shows the logout link', function () { + expect(element.find('.logged-out').attr('class')).toMatch('ng-hide'); + expect(element.find('.logged-in').attr('class')).not.toMatch('ng-hide'); + }); + + it('fires the oauth:authorized event', function () { + var event = jasmine.any(Object); + var token = AccessToken.get(); + expect(callback).toHaveBeenCalledWith(event, token); + }); + + it('does not fire the oauth:login event', function () { + AccessToken.get(); + expect(callback.calls.count()).toBe(1); + }); }); - it('fires the oauth:logout event', function() { - var event = jasmine.any(Object); - expect(callback).toHaveBeenCalledWith(event); + + describe('when logs out', function () { + + beforeEach(function () { + callback = jasmine.createSpy('callback'); + }); + + beforeEach(function () { + $rootScope.$on('oauth:logout', callback); + }); + + beforeEach(function () { + $rootScope.$on('oauth:loggedOut', callback); + }); + + beforeEach(function () { + element.find('.logged-in').click(); + }); + + it('shows the login link', function () { + expect(element.find('.logged-out').attr('class')).not.toMatch('ng-hide'); + expect(element.find('.logged-in').attr('class')).toMatch('ng-hide'); + }); + + it('fires the oauth:logout and oauth:loggedOut event', function () { + var event = jasmine.any(Object); + expect(callback).toHaveBeenCalledWith(event); + expect(callback.calls.count()).toBe(2); + }); }); }); }); @@ -162,10 +201,13 @@ describe('oauth', function() { beforeEach(function() { callback = jasmine.createSpy('callback'); + invalidCallback = jasmine.createSpy('invalidCallback'); + $httpBackend.whenGET('http://example.com/session?token=token').respond(invalidSession); + $rootScope.$on('oauth:invalid', invalidCallback); }); beforeEach(function() { - $rootScope.$on('oauth:logout', callback); + $rootScope.$on('oauth:loggedOut', callback); }); beforeEach(function() { @@ -173,7 +215,7 @@ describe('oauth', function() { }); beforeEach(function() { - compile($rootScope, $compile) + compile($rootScope, $compile); }); beforeEach(function() { @@ -195,10 +237,18 @@ describe('oauth', function() { expect(element.find('.logged-in').attr('class')).toMatch('ng-hide'); }); - it('fires the oauth:logout event', function() { + it('fires the oauth:loggedOut event', function() { var event = jasmine.any(Object); expect(callback).toHaveBeenCalledWith(event); }); + + it('does not fire the oauth:logout event', function() { + expect(callback.calls.count()).toBe(1); + }); + + it('fires the oauth:invalid event', function () { + expect(invalidCallback.calls.count()).toBe(1); + }); }); @@ -206,6 +256,7 @@ describe('oauth', function() { beforeEach(function() { callback = jasmine.createSpy('callback'); + $httpBackend.whenGET('http://example.com/session?token=undefined').respond(invalidSession); }); beforeEach(function() { @@ -217,7 +268,7 @@ describe('oauth', function() { }); beforeEach(function() { - compile($rootScope, $compile) + compile($rootScope, $compile); }); beforeEach(function() { @@ -254,7 +305,7 @@ describe('oauth', function() { }); beforeEach(function() { - compile($rootScope, $compile) + compile($rootScope, $compile); }); it('shows the default template', function() { @@ -270,7 +321,7 @@ describe('oauth', function() { }); beforeEach(function() { - compile($rootScope, $compile) + compile($rootScope, $compile); }); beforeEach(function() { @@ -301,7 +352,7 @@ describe('oauth', function() { }); beforeEach(function() { - compile($rootScope, $compile) + compile($rootScope, $compile); }); it('shows the text "Denied"', function() { diff --git a/test/spec/services/access-token.js b/test/spec/services/access-token.js index 67dda57..e8d9fcd 100644 --- a/test/spec/services/access-token.js +++ b/test/spec/services/access-token.js @@ -2,18 +2,24 @@ describe('AccessToken', function() { - var result, $location, $sessionStorage, AccessToken, date; + var result, $location, Storage, IdToken, AccessToken, date; - var fragment = 'access_token=token&token_type=bearer&expires_in=7200&state=/path'; + var fragment = 'access_token=token&token_type=bearer&expires_in=7200&state=/path&extra=stuff'; + var fragmentForever = 'access_token=token&token_type=bearer&state=/path&extra=stuff'; + var fragmentImmediate = 'access_token=token&token_type=bearer&expires_in=0&state=/path&extra=stuff'; + var fragmentWithIdToken = 'access_token=token&token_type=bearer'+ + '&id_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ&expires_in=3600'; var denied = 'error=access_denied&error_description=error'; var expires_at = '2014-08-17T17:38:37.584Z'; + var newExpiresIn = 9600; var token = { access_token: 'token', token_type: 'bearer', expires_in: 7200, state: '/path', expires_at: expires_at }; beforeEach(module('oauth')); - beforeEach(inject(function($injector) { $location = $injector.get('$location') })); - beforeEach(inject(function($injector) { $sessionStorage = $injector.get('$sessionStorage') })); - beforeEach(inject(function($injector) { AccessToken = $injector.get('AccessToken') })); + beforeEach(inject(function($injector) { $location = $injector.get('$location'); })); + beforeEach(inject(function($injector) { Storage = $injector.get('Storage'); })); + beforeEach(inject(function($injector) { IdToken = $injector.get('IdToken'); })); + beforeEach(inject(function($injector) { AccessToken = $injector.get('AccessToken'); })); describe('#set', function() { @@ -39,6 +45,38 @@ describe('AccessToken', function() { }); }); + describe('when sets the access token without an expiry', function() { + + beforeEach(function() { + $location.hash(fragmentForever); + }); + + beforeEach(function() { + result = AccessToken.set(); + }); + + it('sets #expires_at', function() { + expect(result.expires_at).toBe(null); + }); + }); + + describe('when sets the access token with an expiry of 0 (immediate)', function() { + + beforeEach(function() { + $location.hash(fragmentImmediate); + }); + + beforeEach(function() { + result = AccessToken.set(); + }); + + it('sets #expires_at', function() { + var expected_date = new Date(); + expected_date.setSeconds(expected_date.getSeconds() - 60); + expect(parseInt(result.expires_at/100)).toBe(parseInt(expected_date/100)); // 10 ms + }); + }); + describe('with the access token in the fragment URI', function() { beforeEach(function() { @@ -54,11 +92,11 @@ describe('AccessToken', function() { }); it('removes the fragment string', function() { - expect($location.hash()).toEqual(''); + expect($location.hash()).toEqual('extra=stuff'); }); it('stores the token in the session', function() { - var stored_token = $sessionStorage.token; + Storage.get('token'); expect(result.access_token).toEqual('token'); }); }); @@ -66,7 +104,7 @@ describe('AccessToken', function() { describe('with the access token stored in the session', function() { beforeEach(function() { - $sessionStorage.token = token; + Storage.set('token', token); }); beforeEach(function() { @@ -97,10 +135,75 @@ describe('AccessToken', function() { }); it('stores the error message in the session', function() { - var stored_token = $sessionStorage.token; + Storage.get('token'); expect(result.error).toBe('access_denied'); }); }); + + describe('with id token and access token in the fragment', function() { + beforeEach(function() { + $location.hash(fragmentWithIdToken); + }); + + describe('when all validation passes', function() { + beforeEach(function() { + //specific validation mechanism are tested in id-token spec + spyOn(IdToken, 'validateIdToken').and.returnValue(true); + spyOn(IdToken, 'validateAccessToken').and.returnValue(true); + result = AccessToken.set(); + }); + + it('sets the access token', function() { + expect(result.access_token).toEqual('token'); + }); + + it('sets the id token', function() { + expect(result.id_token).toEqual('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ'); + }); + + it('populates id token claims', function() { + expect(result.id_token_claims).toBeDefined(); + expect(result.id_token_claims.name).toEqual('John Doe'); + }); + }); + + describe('when id token validation fails', function(){ + beforeEach(function() { + //specific validation mechanism are tested in id-token spec + spyOn(IdToken, 'validateIdToken').and.returnValue(false); + spyOn(IdToken, 'validateAccessToken').and.returnValue(true); + result = AccessToken.set(); + }); + + it('erases access token and id token', function() { + expect(result.access_token).toEqual(null); + expect(result.id_token).toEqual(null); + }); + + it('sets the error', function() { + expect(result.error).toEqual('Failed to validate token:'); + }) + }); + + describe('when access token validation fails', function(){ + beforeEach(function() { + //specific validation mechanism are tested in id-token spec + spyOn(IdToken, 'validateIdToken').and.returnValue(true); + spyOn(IdToken, 'validateAccessToken').and.returnValue(false); + result = AccessToken.set(); + }); + + it('erases access token and id token', function() { + expect(result.access_token).toEqual(null); + expect(result.id_token).toEqual(null); + }); + + it('sets the error', function() { + expect(result.error).toEqual('Failed to validate token:'); + }) + }); + + }) }); @@ -143,7 +246,7 @@ describe('AccessToken', function() { }); it('reset the cache', function() { - expect($sessionStorage.token).toBeUndefined; + expect(Storage.get('token')).toBeUndefined(); }); }); @@ -193,21 +296,39 @@ describe('AccessToken', function() { expect(result).toBe(true); }); }); + }); - describe('with the access token stored in the session', function() { + describe('#updateExpiry', function () { + beforeEach(function () { + $location.hash(''); + Storage.set('token', token); + AccessToken.set(); + }); - beforeEach(function() { - $sessionStorage.token = token; - }); + + it('updates the expiry to a new time', function () { + AccessToken.updateExpiry(newExpiresIn); + expect(AccessToken.expired()).toBeFalsy(); + var newExpiresAt = new Date(); + newExpiresAt.setSeconds(newExpiresAt.getSeconds() + newExpiresIn - 60); + expect(AccessToken.get().expires_at).toEqual(newExpiresAt); + }); + }); + + describe('#sessionExpired', function() { + describe('with the access token stored in the session', function() { beforeEach(function() { + //It is an invalid test to have oAuth hash AND be getting token from session + //if hash is in URL it should always be used, cuz its coming from oAuth2 provider re-direct + $location.hash(''); + Storage.set('token', token); result = AccessToken.set().expires_at; }); it('rehydrates the expires_at value', function() { - expect(result).toEqual(new Date(expires_at)); + expect(result).toEqual(expires_at); }); }); - }); }); diff --git a/test/spec/services/endpoint.js b/test/spec/services/endpoint.js index 245237b..f763b30 100644 --- a/test/spec/services/endpoint.js +++ b/test/spec/services/endpoint.js @@ -2,17 +2,16 @@ describe('Endpoint', function() { - var result, $location, $sessionStorage, Endpoint; + var result, $location, Storage, Endpoint; - var fragment = 'access_token=token&token_type=bearer&expires_in=7200&state=/path'; - var params = { site: 'http://example.com/', clientId: 'client-id', redirectUri: 'http://example.com/redirect', scope: 'scope', authorizePath: '/oauth/authorize' }; + var params = { site: 'http://example.com/', clientId: 'client-id', redirectUri: 'http://example.com/redirect', scope: 'scope', authorizePath: '/oauth/authorize', responseType: 'token' }; var uri = 'http://example.com/oauth/authorize?response_type=token&client_id=client-id&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect&scope=scope&state='; beforeEach(module('oauth')); - beforeEach(inject(function($injector) { $location = $injector.get('$location') })); - beforeEach(inject(function($injector) { $sessionStorage = $injector.get('$sessionStorage') })); - beforeEach(inject(function($injector) { Endpoint = $injector.get('Endpoint') })); + beforeEach(inject(function($injector) { $location = $injector.get('$location'); })); + beforeEach(inject(function($injector) { Storage = $injector.get('Storage'); })); + beforeEach(inject(function($injector) { Endpoint = $injector.get('Endpoint'); })); describe('#set', function() { @@ -43,15 +42,15 @@ describe('Endpoint', function() { var paramsClone = JSON.parse(JSON.stringify(params)); beforeEach(function() { - paramsClone.state = 'test'; + paramsClone.state = 'test'; }); beforeEach(function() { - result = Endpoint.set(paramsClone); + result = Endpoint.set(paramsClone); }); it('uri should not be in state', function() { - expect(result).toEqual(uri + 'test'); + expect(result).toEqual(uri + 'test'); }); }); @@ -59,16 +58,33 @@ describe('Endpoint', function() { var paramsClone = JSON.parse(JSON.stringify(params)); beforeEach(function() { - paramsClone.authorizePath = '/oauth/authorize?google=doesthis'; + paramsClone.authorizePath = '/oauth/authorize?google=doesthis'; }); beforeEach(function() { - result = Endpoint.set(paramsClone); + result = Endpoint.set(paramsClone); }); it('uri should not be in state', function() { - var expectedUri = 'http://example.com/oauth/authorize?google=doesthis&response_type=token&client_id=client-id&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect&scope=scope&state='; - expect(result).toEqual(expectedUri); + var expectedUri = 'http://example.com/oauth/authorize?google=doesthis&response_type=token&client_id=client-id&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect&scope=scope&state='; + expect(result).toEqual(expectedUri); + }); + }); + + describe('authorizePath can be empty', function() { + var paramsClone = JSON.parse(JSON.stringify(params)); + + beforeEach(function() { + paramsClone.authorizePath = ''; + }); + + beforeEach(function() { + result = Endpoint.set(paramsClone); + }); + + it('uri should not be in state', function() { + var expectedUri = 'http://example.com/?response_type=token&client_id=client-id&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect&scope=scope&state='; + expect(result).toEqual(expectedUri); }); }); }); @@ -79,12 +95,102 @@ describe('Endpoint', function() { Endpoint.set(params); }); - beforeEach(function() { - result = Endpoint.get(); + describe( 'without overrides', function(){ + beforeEach(function() { + result = Endpoint.get(); + }); + + it('returns the oauth server endpoint', function() { + expect(result).toEqual(uri); + }); }); - it('returns the oauth server endpoint', function() { - expect(result).toEqual(uri); + describe( 'with state override', function(){ + it( 'injects the state correct', function(){ + var override = { state: 'testState' }; + var result = Endpoint.get( override ); + expect( result ).toEqual( uri + encodeURIComponent( override.state ) ); + }); + }); + + describe( 'with clientId override', function(){ + it( 'injects the override', function(){ + var override = { clientId: 'unicorn' }; + var result = Endpoint.get( override ); + var expectedUri = 'http://example.com/oauth/authorize?response_type=token&client_id=unicorn&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect&scope=scope&state='; + expect( result ).toEqual( expectedUri ); + }); + }); + + describe( 'with scope override', function(){ + it( 'injects the override', function(){ + var override = { scope: 'stars' }; + var result = Endpoint.get( override ); + var expectedUri = 'http://example.com/oauth/authorize?response_type=token&client_id=client-id&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect&scope=stars&state='; + expect( result ).toEqual( expectedUri ); + }); + }); + + describe( 'with state override', function(){ + it( 'injects the state correct', function(){ + var override = { state: 'testState' }; + var result = Endpoint.get( override ); + expect( result ).toEqual( uri + encodeURIComponent( override.state ) ); + }); + }); + + describe( 'with responseType override', function(){ + it( 'injects the correct repsonseType', function(){ + var override = { responseType: 'id_token' }; + var result = Endpoint.get( override ); + var expectedUri = 'http://example.com/oauth/authorize?response_type=id_token&client_id=client-id&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect&scope=scope&state='; + expect( result ).toEqual( expectedUri ); + }); + }); + + describe( 'with site override', function(){ + it( 'injects the correct site', function(){ + var override = { site: 'https://invincible.test/' }; + var result = Endpoint.get( override ); + var expectedUri = 'https://invincible.test/oauth/authorize?response_type=token&client_id=client-id&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect&scope=scope&state='; + expect( result ).toEqual( expectedUri ); + }); + }); + + describe( 'with authorize path overrides', function(){ + it( 'injects the correct authorize path', function(){ + var override = { authorizePath: '/end/here' }; + var result = Endpoint.get( override ); + var expectedUri = 'http://example.com/end/here?response_type=token&client_id=client-id&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect&scope=scope&state='; + expect( result ).toEqual( expectedUri ); + }); + }); + + describe( 'given scope with spaces', function(){ + it( 'correctly encodes the spaces', function(){ + var override = { scope: 'read write profile openid' }; + var result = Endpoint.get( override ); + var expectedUri = 'http://example.com/oauth/authorize?response_type=token&client_id=client-id&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect&scope=read%20write%20profile%20openid&state='; + expect( result ).toEqual( expectedUri ); + }); + }); + + describe( 'on repsonse type with spaces', function(){ + it( 'correctly encodes the spaces', function(){ + var override = { responseType: 'id_token token' }; + var result = Endpoint.get( override ); + var expectedUri = 'http://example.com/oauth/authorize?response_type=id_token%20token&client_id=client-id&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect&scope=scope&state='; + expect( result ).toEqual( expectedUri ); + }); + }); + + describe( 'when provided with a nonce', function(){ + it( 'adds the nonce parameter', function(){ + var override = { nonce: '987654321' }; + var result = Endpoint.get( override ); + var expectedUri = 'http://example.com/oauth/authorize?response_type=token&client_id=client-id&redirect_uri=http%3A%2F%2Fexample.com%2Fredirect&scope=scope&state=&nonce=987654321'; + expect( result ).toEqual( expectedUri ); + }); }); }); }); diff --git a/test/spec/services/id-token.js b/test/spec/services/id-token.js new file mode 100644 index 0000000..11d72c3 --- /dev/null +++ b/test/spec/services/id-token.js @@ -0,0 +1,248 @@ +describe('IdToken', function() { + + var Storage, IdToken; + + var publicKeyString; + var validIdToken, invalidIdToken; + var validAccessToken; + + beforeEach(module('oauth')); + + beforeEach(inject(function ($injector) { + Storage = $injector.get('Storage'); + })); + beforeEach(inject(function ($injector) { + IdToken = $injector.get('IdToken'); + })); + + beforeEach(function () { + /** + * http://kjur.github.io/jsjws/tool_jwt.html generated sample id_token, signed by default private key + * The public key is shown as below + */ + publicKeyString = + "-----BEGIN PUBLIC KEY-----\n" + + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA33TqqLR3eeUmDtHS89qF\n" + + "3p4MP7Wfqt2Zjj3lZjLjjCGDvwr9cJNlNDiuKboODgUiT4ZdPWbOiMAfDcDzlOxA\n" + + "04DDnEFGAf+kDQiNSe2ZtqC7bnIc8+KSG/qOGQIVaay4Ucr6ovDkykO5Hxn7OU7s\n" + + "Jp9TP9H0JH8zMQA6YzijYH9LsupTerrY3U6zyihVEDXXOv08vBHk50BMFJbE9iwF\n" + + "wnxCsU5+UZUZYw87Uu0n4LPFS9BT8tUIvAfnRXIEWCha3KbFWmdZQZlyrFw0buUE\n" + + "f0YN3/Q0auBkdbDR/ES2PbgKTJdkjc/rEeM0TxvOUf7HuUNOhrtAVEN1D5uuxE1W\n" + + "SwIDAQAB" + + "-----END PUBLIC KEY-----\n"; + }); + + describe('validate an id_token with both signature and claims', function() { + beforeEach(function () { + /* + Valid token with RS256, expires at 20251231235959Z UTC + https://jwt.io has a debugger that can help view the id_token's header and payload + + e.g. The header of following token is { "alg": "RS256", "typ": "JWT" } + The body of the following token is: + { + "iss": "oidc", + "sub": "oauth-ng-client", + "nbf": 1449267385, + "exp": 1767225599, + "iat": 1449267385, + "jti": "id123456", + "typ": "https://example.com/register" + } + */ + validIdToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' + + '.eyJpc3MiOiJvaWRjIiwic3ViIjoib2F1dGgtbmctY2xpZW50IiwibmJmIjoxNDQ5MjY3Mzg1LCJleHAiOjE3NjcyMjU1OTksImlhdCI6MTQ0OTI2NzM4NSwianRpIjoiaWQxMjM0NTYiLCJ0eXAiOiJodHRwczovL2V4YW1wbGUuY29tL3JlZ2lzdGVyIn0' + + '.MXBbWkr1Sf6KRn11IgEXyVg5g5VVUOSyLhTglgL8fI13aGf6SquVy0ZNn7ajTym5a_fJHPWLlgpvo-v98xuMBC9cLH_NN3ocrZAQkkW19G4AVY-LsOURp0t9JzVEb-pEe8Zps8O7Mumj0qSlr-4Dnyb3UMqdwZTcSgUTrbdyf6Qa7KHA0myANLDs2T8ctlSEptgVHPj8Zy9tk9UUlDZgsU4KoEpanDt7c1GzQJu7KEI3iJYlVEwDgMqu0EWn64aaP-w1OKZAyHbJWdMwun7i9edLonQ37M67Mb8ox6-cx8fxS3s3h6b3jRS5L0RACFVtB9lF4f_0yPVBwcTBhzYBOg'; + + IdToken.set({ + issuer: 'oidc', + clientId: 'oauth-ng-client', + pubKey: publicKeyString + }); + }); + + it('with success', function () { + expect(IdToken.validateIdToken(validIdToken)).toEqual(true); + }); + + }); + + describe('verify id_token signature with algorithm', function () { + + describe('RS256', function () { + + beforeEach(function () { + + //Valid token with RS256, expires at 20251231235959Z UTC + validIdToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' + + '.eyJpc3MiOiJvaWRjLXJzLTI1NiIsInN1YiI6Im9hdXRoLW5nLWNsaWVudCIsIm5iZiI6MTQ0OTQ0OTE0NCwiZXhwIjoxNzY3MjI1NTk5LCJpYXQiOjE0NDk0NDkxNDQsImp0aSI6ImlkMTIzNDU2IiwidHlwIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZWdpc3RlciJ9' + + '.lpxeRY_IqsvgWLj6H2ghre8dBBtsSF-bnjWHtVvhQIuerztMQOX20CCqtVFGScIZcI4gHxtEZGauF-sX3zwaLuqPtzORjaBiH0vV6C-3ZyqZrCU_n-TozKAwpSYyyHQpJ-xKdGRaOdd7_4vDtaFBWyHLXp1hbYvMftkPCvGjO25GppGQ7MjxCnd7IAPn0obXx2lZr1q0hHT7532O5dlmsPHTyrTvrSupTOVH3CZe3ZghM6R_mlagyfRh1Pf2cdRQkXJ0gEHf4GYpBbz-E3YfCyxcvQRPzfKnpLGH16M1_jM0mc3z5zVsegi62NNr79B8hExG5OtXfDMvws4LDfps2A'; + + IdToken.set({ + issuer: 'oidc-rs-256', + clientId: 'oauth-ng-client', + pubKey: publicKeyString + }); + }); + + it('validate token successfully', function () { + expect(IdToken.verifyIdTokenSig(validIdToken)).toEqual(true); + }); + + }); + + describe('RS384', function () { + + beforeEach(function() { + //Valid token with RS384, expires at 20251231235959Z UTC + validIdToken = 'eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9' + + '.eyJpc3MiOiJvaWRjLXJzLTM4NCIsInN1YiI6Im9hdXRoLW5nLWNsaWVudCIsIm5iZiI6MTQ0OTQ0NTM3NSwiZXhwIjoxNzY3MjI1NTk5LCJpYXQiOjE0NDk0NDUzNzUsImp0aSI6ImlkMTIzNDU2IiwidHlwIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZWdpc3RlciJ9' + + '.cDSfORFIZ28nUgvbdJ0wwe-JtZOy5J40--xYfJBPbwRJz02EscFlRD-xNqRB9eEsVVK4shW9AtF4yfp3Sa_jcjziGlQNwpRzFhnCqrADupMNNhK-z1SmuxgG_zfP7plXVAhg1IJ671w43I2PmXQw5wAKpAMwun4J-mxHP7ZV6__z_hxv4QclONHrk23_ebHJXi8W4q7B7n-amQQZ-kKQf8OblZIX9kAF58WIhyA5ZNqXGZ_hmcDKUVlBpgiurpD8u429NwrlauowHCQI_zMKlaEzJvH5qNhXLNbFgLmhrQFYo_VW48ZjHygmAkuuKt0jioR0dUeYirTGq-xEBcOpnw'; + + IdToken.set({ + issuer: 'oidc-rs-384', + clientId: 'oauth-ng-client', + pubKey: publicKeyString + }); + + }); + + it('validate token successfully', function () { + expect(IdToken.verifyIdTokenSig(validIdToken)).toEqual(true); + }); + + }); + + describe('RS512', function () { + + beforeEach(function() { + //Valid token with RS512, expires at 20251231235959Z UTC + validIdToken = 'eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9' + + '.eyJpc3MiOiJvaWRjLXJzLTUxMiIsInN1YiI6Im9hdXRoLW5nLWNsaWVudCIsIm5iZiI6MTQ0OTQ0NzQyMiwiZXhwIjoxNzY3MjI1NTk5LCJpYXQiOjE0NDk0NDc0MjIsImp0aSI6ImlkMTIzNDU2IiwidHlwIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZWdpc3RlciJ9' + + '.BAqggV91_YwQmnKty-qHzi61WgUpLue7gFWDe72VzHzcQhYu1STLduBxtr3qtsl6XzVDL6N0AmYfpFwMQQ8dRMaLLsnx6wg7Mi9nqCkDTL1Px5biL9AM4C3S32N6iJ4nFyJgUiFJ4RWG9f-78k4PG51xvSCkA-2TbODU1KsXRnc3o9SrQKw8pWnjmxNIfDtfzkxEdBlePWuknZGeaJBlR4hBRrxH1GnNDVW3aeuLJl4y1IOIbUxsnNW8HgAm6KpoCVAbPN7YzQPfDEIQgaNSS_i7Nkuq9Rno_6ivfqxs3QCiEqHJkAh8W2J3N8iPpRrCW03oQp2sGvmRTxxvxuxZbw' + + IdToken.set({ + issuer: 'oidc-rs-512', + clientId: 'oauth-ng-client', + pubKey: publicKeyString + }); + + }); + + it('validate token successfully', function () { + expect(IdToken.verifyIdTokenSig(validIdToken)).toEqual(true); + }); + + }); + + }); + + describe('verify id_token using JWK(Json Web Key) format key', function() { + beforeEach(function () { + //The same public key, but using a JWK format + var jwkString = '{"kty":"RSA","n":"33TqqLR3eeUmDtHS89qF3p4MP7Wfqt2Zjj3lZjLjjCGDvwr9cJNlNDiuKboODgUiT4ZdPWbOiMAfDcDzlOxA04DDnEFGAf-kDQiNSe2ZtqC7bnIc8-KSG_qOGQIVaay4Ucr6ovDkykO5Hxn7OU7sJp9TP9H0JH8zMQA6YzijYH9LsupTerrY3U6zyihVEDXXOv08vBHk50BMFJbE9iwFwnxCsU5-UZUZYw87Uu0n4LPFS9BT8tUIvAfnRXIEWCha3KbFWmdZQZlyrFw0buUEf0YN3_Q0auBkdbDR_ES2PbgKTJdkjc_rEeM0TxvOUf7HuUNOhrtAVEN1D5uuxE1WSw","e":"AQAB"}'; + //Valid token with RS256, expires at 20251231235959Z UTC + validIdToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' + + '.eyJpc3MiOiJvaWRjLXJzLTI1NiIsInN1YiI6Im9hdXRoLW5nLWNsaWVudCIsIm5iZiI6MTQ0OTQ0OTE0NCwiZXhwIjoxNzY3MjI1NTk5LCJpYXQiOjE0NDk0NDkxNDQsImp0aSI6ImlkMTIzNDU2IiwidHlwIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZWdpc3RlciJ9' + + '.lpxeRY_IqsvgWLj6H2ghre8dBBtsSF-bnjWHtVvhQIuerztMQOX20CCqtVFGScIZcI4gHxtEZGauF-sX3zwaLuqPtzORjaBiH0vV6C-3ZyqZrCU_n-TozKAwpSYyyHQpJ-xKdGRaOdd7_4vDtaFBWyHLXp1hbYvMftkPCvGjO25GppGQ7MjxCnd7IAPn0obXx2lZr1q0hHT7532O5dlmsPHTyrTvrSupTOVH3CZe3ZghM6R_mlagyfRh1Pf2cdRQkXJ0gEHf4GYpBbz-E3YfCyxcvQRPzfKnpLGH16M1_jM0mc3z5zVsegi62NNr79B8hExG5OtXfDMvws4LDfps2A'; + + IdToken.set({ + issuer: 'oidc-rs-256', + clientId: 'oauth-ng-client', + pubKey: jwkString + }); + }); + + it('validate token successfully', function () { + expect(IdToken.verifyIdTokenSig(validIdToken)).toEqual(true); + }); + + }); + + describe('validate access_token with id_token header information', function () { + + beforeEach(function() { + /* + Sample id_token and access_token pair (corresponds to response_type = 'id_token token' + Get more examples at google playground: https://developers.google.com/oauthplayground/ + */ + validIdToken = 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjJhODc0MjBlY2YxNGU5MzRmOWY5MDRhMDE0NzY4MTMyMDNiMzk5NGIifQ' + + '.eyJpc3MiOiJhY2NvdW50cy5nb29nbGUuY29tIiwic3ViIjoiMTEwMTY5NDg0NDc0Mzg2Mjc2MzM0IiwiYXpwIjoiNDA3NDA4NzE4MTkyLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiYXRfaGFzaCI6ImFVQWtKRy11Nng0UlRXdUlMV3ktQ0EiLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJpYXQiOjE0MzIwODI4NzgsImV4cCI6MTQzMjA4NjQ3OH0' + + '.xSwhf4KvEztFFhVj4YdgKFOC8aPEoLAAZcXDWIh6YBXpfjzfnwYhaQgsmCofzOl53yirpbj5h7Om5570yzlUziP5TYNIqrA3Nyaj60-ZyXY2JMIBWYYMr3SRyhXdW0Dp71tZ5IaxMFlS8fc0MhSx55ZNrCV-3qmkTLeTTY1_4Jc'; + validAccessToken = 'ya29.eQETFbFOkAs8nWHcmYXKwEi0Zz46NfsrUU_KuQLOLTwWS40y6Fb99aVzEXC0U14m61lcPMIr1hEIBA'; + }); + + it('should succeed', function() { + expect(IdToken.validateAccessToken(validIdToken, validAccessToken)).toEqual(true); + }) + }); + + + describe('detect false id_token with', function () { + + describe('wrong issuer', function () { + + beforeEach(function () { + //Id token with issuer as 'oidc-foo' + invalidIdToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' + + '.eyJpc3MiOiJvaWRjLWZvbyIsInN1YiI6Im9hdXRoLW5nLWNsaWVudCIsIm5iZiI6MTQ0OTQ0OTYyNCwiZXhwIjoxNzY3MjI1NTk5LCJpYXQiOjE0NDk0NDk2MjQsImp0aSI6ImlkMTIzNDU2IiwidHlwIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZWdpc3RlciJ9' + + '.UCDvdOtXzXeDReumQsj-WXEgUIs_PyTdVt94n52hZT9q3Rfs4jFkx64n2tGa0SAzAwkQz8UhBn9omYg7c_A9Q8eYwbOLzSq8QUcH6adXME80c7ychmHsy4T8wXRhKExbSThs37Rgq38Z6mkodqYxxdGJw4xoiR3yPij2bXwT6Knes6nXEWYnhPosiLxOhzIIH7-LRPRFVd3aad0cm9TRkNzkEyZ4j2QPtNsKur80sJ0qrEFp-unjoyg59GMNF8yatt8d1hgNgnWIMSuzwRq4U4Da2Q7QMKadhArqNY1mDZJl3duS8No57RMPYipq2y8DVEqKzE2ie-jNs1fmB67hqQ'; + + IdToken.set({ + issuer: 'oidc', + clientId: 'oauth-ng-client', + pubKey: publicKeyString + }); + }); + + it('should throw exception', function () { + expect(function(){ IdToken.verifyIdTokenInfo(invalidIdToken) }).toThrowError(/Invalid issuer/); + }); + + }); + + describe('future issue time', function () { + + beforeEach(function () { + //Id token issued in the future (20251231235959Z UTC) + invalidIdToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' + + '.eyJpc3MiOiJvaWRjLWZvbyIsInN1YiI6Im9hdXRoLW5nLWNsaWVudCIsIm5iZiI6MTQ0OTQ1MDk5NCwiZXhwIjoxNzY3MjI1NTk5LCJpYXQiOjE3NjcyMjU1OTksImp0aSI6ImlkMTIzNDU2IiwidHlwIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZWdpc3RlciJ9' + + '.d7NrttJitzksr_wJzfvNQJhKTUa2Lp0YIl3B-FNUROMZ4nEGqW385ZwcIZlv1A2BlwbiozZBZ7rpiHHb3yAyfUToL8JD8hzfVurboc63Vp3qpHEMNzLIuWD4AUcYeuBIGz_gIT2sNeltjqJTPFUNm5FPRIs4O-a5b-13rosxI5UhQ7m6MLCUJ_U7w5Jxl5Dei2MUM3dF9ugI5UC17YFsAqWeAnddT2m9TPQGvTS8G42iuEOKxLIBkqE9SCRhcpRy66DWKNi8yyroLMIM9UOiyh2ODrI2sBn1TVa9b6-XkDGwDZdlbc2AWiGLFD2KeoBFYKV03aHhoWL2J9UFs08O8Q'; + + IdToken.set({ + issuer: 'oidc', + clientId: 'oauth-ng-client', + pubKey: publicKeyString + }); + }); + + it('should throw exception', function () { + expect(function(){ IdToken.verifyIdTokenInfo(invalidIdToken) }).toThrowError(/issued time is later than current time/); + }); + + }); + + describe('expired token', function () { + + beforeEach(function () { + //Expired id token + invalidIdToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9' + + '.eyJpc3MiOiJvaWRjLWZvbyIsInN1YiI6Im9hdXRoLW5nLWNsaWVudCIsIm5iZiI6MTQ0OTQ1MTIxMywiZXhwIjoxNDQ5NDUxMjEzLCJpYXQiOjE0NDk0NTEyMTMsImp0aSI6ImlkMTIzNDU2IiwidHlwIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZWdpc3RlciJ9' + + '.PAUYJX7v7xDRAVaIUNME7VxK5j2GFDE19bMaa6I-jFa5Lw5f0WlXBOa2WXYqynPymtC0-UMTdUeZ4V_mA07ubTNKyyyqNelr-kpGvM3NzIZpHokEibQVF3JeK1pH_pqnC_MYHePZMiejCkSSPMvC1_lvPOMiMfEhqWvqh58aw7v8q9a9OQYsTlQU_q_rq4mTvDkv9gjU8qKqFInLKIU1TZn4tnslFroW70kvOndz8MHOmXCyQOLbyDW9NHgJXCCCxXwEmo00LjxDHQOSC5uMK9mkix513AqZ8Gaj2QB7-4m6rCK23TiffGgIIlLzPq2RPSBbHGv-K5S_lR_Qh8STGA'; + + IdToken.set({ + issuer: 'oidc', + clientId: 'oauth-ng-client', + pubKey: publicKeyString + }); + }); + + it('should throw exception', function () { + expect(function(){ IdToken.verifyIdTokenInfo(invalidIdToken) }).toThrowError(/ID Token expired/); + }); + + }); + }); + + +}); diff --git a/test/spec/services/oauth-configuration.js b/test/spec/services/oauth-configuration.js new file mode 100644 index 0000000..4ff6796 --- /dev/null +++ b/test/spec/services/oauth-configuration.js @@ -0,0 +1,70 @@ +'use strict'; + +describe('AuthInterceptor', function() { + var theOAuthConfigurationProvider, theOAuthConfiguration, OAuthConfiguration, AccessToken, AuthInterceptor, $location, result; + var protectedConfig, unprotectedConfig; + + beforeEach(module('oauth')); + + var expires_at = '2014-08-17T17:38:37.584Z'; + var fragment = 'access_token=token&token_type=bearer&expires_in=7200&state=/path&extra=stuff' + + + beforeEach(function() { + var fakeModule = angular.module('test.app.config', function(){}); + fakeModule.config(function(OAuthConfigurationProvider, $httpProvider) { + theOAuthConfigurationProvider = OAuthConfigurationProvider; + theOAuthConfigurationProvider.init({protectedResources:['http://api.protected/']}, $httpProvider); + theOAuthConfiguration = theOAuthConfigurationProvider.$get(); + }) + module('oauth', 'test.app.config'); + + inject(function() {}); + }) + + beforeEach(inject(function() { OAuthConfiguration = theOAuthConfiguration })); + beforeEach(inject(function($injector) { $location = $injector.get('$location'); })); + beforeEach(inject(function($injector) { AccessToken = $injector.get('AccessToken'); AccessToken.destroy(); } )); + beforeEach(inject(function($injector) { AuthInterceptor = $injector.get('AuthInterceptor'); })); + + beforeEach(function() { + protectedConfig = { url: 'http://api.protected/', headers: { 'Accept': 'application/json'} }; + unprotectedConfig = { url: 'http://api.unprotected/', headers: { 'Accept': 'application/json' } }; + }); + + describe('when the resource is protected', function() { + beforeEach(function() { + $location.hash(fragment); + AccessToken.set(); + result = AuthInterceptor.request(protectedConfig); + }); + + it('should have the token in the header', function() { + expect(result.headers.Authorization).toEqual('Bearer token'); + }); + }); + + describe('when the resource is unprotected', function() { + beforeEach(function() { + $location.hash(fragment); + AccessToken.set(); + result = AuthInterceptor.request(unprotectedConfig); + }); + + it('should not have the token in the header', function() { + expect(result.headers.Authorization).toBeUndefined(); + }); + }); + + describe('when the user is not logged in', function() { + beforeEach(function() { + AccessToken.destroy(); + result = AuthInterceptor.request(protectedConfig); + }); + + it('should not have anything in the authorization header', function() { + expect(result.headers.Authorization).toBeUndefined(); + }); + }); + +}) \ No newline at end of file diff --git a/test/spec/services/oidc-config.js b/test/spec/services/oidc-config.js new file mode 100644 index 0000000..22c76cc --- /dev/null +++ b/test/spec/services/oidc-config.js @@ -0,0 +1,87 @@ +describe('IdToken', function() { + + var IdToken, OidcConfig; + + var publicKeyModulus, publicKeyExponent; + var validIdToken; + var $httpBackend; + + beforeEach(module('oauth')); + + beforeEach(inject(function ($injector) { + IdToken = $injector.get('IdToken'); + })); + + beforeEach(function () { + /** + * http://kjur.github.io/jsjws/tool_jwt.html generated sample id_token, signed by default private key + * The public key modulus and exponent are as below. This is the same public key as in id-token.js test. + */ + publicKeyModulus = '33TqqLR3eeUmDtHS89qF3p4MP7Wfqt2Zjj3lZjLjjCGDvwr9cJNlN' + + 'DiuKboODgUiT4ZdPWbOiMAfDcDzlOxA04DDnEFGAf-kDQiNSe2Ztq' + + 'C7bnIc8-KSG_qOGQIVaay4Ucr6ovDkykO5Hxn7OU7sJp9TP9H0JH8' + + 'zMQA6YzijYH9LsupTerrY3U6zyihVEDXXOv08vBHk50BMFJbE9iwF' + + 'wnxCsU5-UZUZYw87Uu0n4LPFS9BT8tUIvAfnRXIEWCha3KbFWmdZQ' + + 'ZlyrFw0buUEf0YN3_Q0auBkdbDR_ES2PbgKTJdkjc_rEeM0TxvOUf' + + '7HuUNOhrtAVEN1D5uuxE1WSw'; + publicKeyExponent = 'AQAB'; + }); + + beforeEach(inject(function ($injector) { + OidcConfig = $injector.get('OidcConfig'); + $httpBackend = $injector.get('$httpBackend'); + $httpBackend.when('GET', 'oidc/.well-known/openid-configuration') + .respond({jwks_uri: "oidc/jwks_uri"}); + $httpBackend.when('GET', 'oidc/jwks_uri') + .respond({ + keys: [{ + kty: 'RSA', + n: publicKeyModulus, + e: publicKeyExponent, + kid: 'rsa1' + } + ] + }); + })); + + + describe('validate an id_token when pubkey is loaded from .well-known configuration', function() { + beforeEach(function () { + /* + Valid token with RS256, expires at 20251231235959Z UTC + https://jwt.io has a debugger that can help view the id_token's header and payload + + e.g. The header of following token is { "alg": "RS256", "typ": "JWT", "kid": "rsa1" } + The body of the following token is: + { + "iss": "oidc", + "sub": "oauth-ng-client", + "nbf": 1449267385, + "exp": 1767225599, + "iat": 1449267385, + "jti": "id123456", + "typ": "https://example.com/register" + } + */ + validIdToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InJzYTEifQ' + + '.eyJpc3MiOiJvaWRjIiwic3ViIjoib2F1dGgtbmctY2xpZW50IiwibmJmIjoxNDQ5MjY3Mzg1LCJleHAiOjE3NjcyMjU1OTksImlhdCI6MTQ0OTI2NzM4NSwianRpIjoiaWQxMjM0NTYiLCJ0eXAiOiJodHRwczovL2V4YW1wbGUuY29tL3JlZ2lzdGVyIn0' + + '.J39k8LK_lu7xYvW_eU-MAI3jtgQEdkpJOlx4ZX_WZ3TUyY-9GG0xLUusteDcy3UGIVxangwonaZ7311WKKz9OwjU1ePivMLXayiP2bL6srIUu8PvOOIcf8oPt8HGv-TLb1zPmYPx3XniekKUEnFAxMGedobcX0wg9tZnnkM11T4qQPcTjDhKB9bNlih9yRHR-6OkKZN4Q_by7EJAPJti22L0dTCW81A_9J5OMXoe0k6fScGfc0Wspsc7CpBN9ZAmTUdHGe8IP5L4leM0pOud6M0gzcIhixR1OMm6qj7ZyvJxgZ48h7Fln3CHyz3LGoHBTQWWDf3ufTzl3sGvippc1w'; + + var scope = { + issuer: 'oidc', + clientId: 'oauth-ng-client', + wellKnown: 'sync' + }; + + OidcConfig.load(scope).then(function(){ + IdToken.set(scope); + }) + $httpBackend.flush(); + }); + + it('with success', function () { + expect(IdToken.validateIdToken(validIdToken)).toEqual(true); + }); + + }); +}); diff --git a/test/spec/services/profile.js b/test/spec/services/profile.js index 3eedbe9..dea4bed 100644 --- a/test/spec/services/profile.js +++ b/test/spec/services/profile.js @@ -1,105 +1,120 @@ -//'use strict'; +'use strict'; -//describe('Profile', function() { - - //var $rootScope, $location, $httpBackend, $http, AccessToken, Profile; - //var result, date, callback; - - //var fragment = 'access_token=token&token_type=bearer&expires_in=7200&state=/path'; - //var headers = { 'Accept': 'application/json, text/plain, */*', 'Authorization': 'Bearer token' } - //var params = { site: 'http://example.com/', client: 'client-id', redirect: 'http://example.com/redirect', scope: 'scope', profileUri: 'http://example.com/me' }; - //var resource = { id: '1', name: 'Alice' }; - - //beforeEach(module('oauth')); - - //beforeEach(inject(function($injector) { $rootScope = $injector.get('$rootScope') })); - //beforeEach(inject(function($injector) { $location = $injector.get('$location') })); - //beforeEach(inject(function($injector) { $httpBackend = $injector.get('$httpBackend') })); - //beforeEach(inject(function($injector) { $http = $injector.get('$http') })); - //beforeEach(inject(function($injector) { AccessToken = $injector.get('AccessToken') })); - //beforeEach(inject(function($injector) { Profile = $injector.get('Profile') })); - - //beforeEach(function() { callback = jasmine.createSpy('callback') }); - - - //describe('.get', function() { - - //describe('when authenticated', function() { - - //beforeEach(function() { - //$location.hash(fragment); - //AccessToken.set(params); - //}); - - //beforeEach(function() { - //$httpBackend.whenGET('http://example.com/me', headers).respond(resource); - //}); - - //describe('when gets the profile', function() { - - //it('makes the request', function() { - //$httpBackend.expect('GET', 'http://example.com/me'); - //Profile.find(params.profileUri); - //$rootScope.$apply(); - //$httpBackend.flush(); - //}); - - //it('gets the resource', inject(function(Profile) { - //Profile.find(params.profileUri).success(function(response) { result = response }); - //$rootScope.$apply(); - //$httpBackend.flush(); - //expect(result.name).toEqual('Alice'); - //})); - - //it('caches the profile', function() { - //Profile.find(params.profileUri); - //$rootScope.$apply(); - //$httpBackend.flush(); - //expect(Profile.get().name).toEqual('Alice'); - //}); - - //describe('when expired', function() { - - //beforeEach(function() { - //$rootScope.$on('oauth:expired', callback); - //}); - - //beforeEach(function() { - //date = new Date(); - //date.setTime(date.getTime() + 86400000); - //}); - - //beforeEach(function() { - //Timecop.install(); - //Timecop.travel(date); // go one day in the future - //}); - - //afterEach(function() { - //Timecop.uninstall(); - //}); - - //it('fires the oauth:expired event', inject(function(Profile) { - //Profile.find(params.profileUri); - //$rootScope.$apply(); - //$httpBackend.flush(); - //var event = jasmine.any(Object); - //var token = jasmine.any(Object); - //expect(callback).toHaveBeenCalledWith(event, token); - //})); - //}); - //}); - - - //describe('when sets the profile', function() { - - //beforeEach(function() { - //Profile.set(resource); - //}); - - //it('caches the profile', function() { - //expect(Profile.get().name).toEqual('Alice'); - //}); - //}); - //}); - //}); -//}); +describe('Profile', function() { + + var $rootScope, $location, $httpBackend, $http, AccessToken, Profile; + var result, date, callback; + + var fragment = 'access_token=token&token_type=bearer&expires_in=7200&state=/path'; + var headers = { 'Accept': 'application/json, text/plain, */*', 'Authorization': 'Bearer token' }; + var params = { site: 'http://example.com/', client: 'client-id', redirect: 'http://example.com/redirect', scope: 'scope', profileUri: 'http://example.com/me' }; + var resource = { id: '1', name: 'Alice' }; + + beforeEach(module('oauth')); + + beforeEach(inject(function($injector) { $rootScope = $injector.get('$rootScope'); })); + beforeEach(inject(function($injector) { $location = $injector.get('$location'); })); + beforeEach(inject(function($injector) { $httpBackend = $injector.get('$httpBackend'); })); + beforeEach(inject(function($injector) { $http = $injector.get('$http'); })); + beforeEach(inject(function($injector) { AccessToken = $injector.get('AccessToken'); })); + beforeEach(inject(function($injector) { Profile = $injector.get('Profile'); })); + + beforeEach(function() { callback = jasmine.createSpy('callback'); }); + + + describe('.get', function() { + + describe('when authenticated', function() { + + beforeEach(function() { + $location.hash(fragment); + AccessToken.set(params); + }); + + beforeEach(function() { + $httpBackend.whenGET('http://example.com/me', headers).respond(resource); + }); + + describe('when gets the profile', function() { + + it('makes the request', function() { + $httpBackend.expect('GET', 'http://example.com/me'); + Profile.find(params.profileUri); + $rootScope.$apply(); + $httpBackend.flush(); + }); + + it('gets the resource', inject(function(Profile) { + Profile.find(params.profileUri).then(function(response) { result = response.data; }); + $rootScope.$apply(); + $httpBackend.flush(); + expect(result.name).toEqual('Alice'); + })); + + it('caches the profile', function() { + Profile.find(params.profileUri); + $rootScope.$apply(); + $httpBackend.flush(); + expect(Profile.get().name).toEqual('Alice'); + }); + + it('fires oauth:profile event', function(done) { + + $rootScope.$on('oauth:profile', function (event, profile) { + expect(typeof profile).toBe('object'); + done(); + }); + + Profile.find(params.profileUri); + $rootScope.$apply(); + $httpBackend.flush(); + + }); + + + describe('when expired', function() { + + beforeEach(function() { + $rootScope.$on('oauth:expired', callback); + }); + + beforeEach(function() { + date = new Date(); + date.setTime(date.getTime() + 86400000); + }); + + beforeEach(function() { + Timecop.install(); + Timecop.travel(date); // go one day in the future + }); + + afterEach(function() { + Timecop.uninstall(); + }); + + it('fires the oauth:expired event', inject(function(Profile) { + Profile.find(params.profileUri); + $rootScope.$apply(); + $httpBackend.flush(); + var event = jasmine.any(Object); + var token = jasmine.any(Object); + expect(callback).toHaveBeenCalledWith(event, token); + })); + }); + }); + + + describe('when sets the profile', function() { + + beforeEach(function() { + Profile.set(resource); + }); + + it('caches the profile', function() { + expect(Profile.get().name).toEqual('Alice'); + }); + + }); + }); + }); +}); diff --git a/test/spec/services/storage.js b/test/spec/services/storage.js new file mode 100644 index 0000000..a88fe53 --- /dev/null +++ b/test/spec/services/storage.js @@ -0,0 +1,103 @@ +'use strict'; + +describe('Storage', function() { + + var $sessionStorage, $localStorage, Storage; + var token = 'MOCK TOKEN'; + + beforeEach(module('oauth')); + + beforeEach(inject(function($injector) { $sessionStorage = $injector.get('$sessionStorage'); })); + beforeEach(inject(function($injector) { $localStorage = $injector.get('$localStorage'); })); + beforeEach(inject(function($injector) { Storage = $injector.get('Storage'); })); + + it('should use sessionStorage by default', function () { + expect(Storage.storage).toEqual($sessionStorage); + }); + + describe('#use', function () { + + beforeEach(function () { + $sessionStorage.$reset(); + $localStorage.$reset(); + Storage.storage = null; + }); + + it('should use sessionStorage when specified', function () { + Storage.use('sessionStorage'); + expect(Storage.storage).toEqual($sessionStorage); + }); + + it('should use localStorage when specified', function () { + Storage.use('localStorage'); + expect(Storage.storage).toEqual($localStorage); + }); + + }); + + describe('#set', function() { + + beforeEach(function () { + $sessionStorage.$reset(); + $localStorage.$reset(); + }); + + it('should set something in sessionStorage', function () { + Storage.storage = $sessionStorage; + Storage.set('token', token); + expect($sessionStorage.token).toEqual(token); + }); + + it('should set something in sessionStorage', function () { + Storage.storage = $localStorage; + Storage.set('token', token); + expect($localStorage.token).toEqual(token); + }); + + }); + + describe('#get', function() { + + beforeEach(function () { + $sessionStorage.$reset(); + $localStorage.$reset(); + }); + + it('should set something in sessionStorage', function () { + $sessionStorage.token = token; + Storage.storage = $sessionStorage; + expect(Storage.get('token')).toEqual(token); + }); + + it('should set something in sessionStorage', function () { + $localStorage.token = token; + Storage.storage = $localStorage; + expect(Storage.get('token')).toEqual(token); + }); + + }); + + describe('#delete', function() { + + beforeEach(function () { + $sessionStorage.$reset(); + $localStorage.$reset(); + }); + + it('should delete the token from the sessionStorage', function () { + $sessionStorage.token = token; + Storage.storage = $sessionStorage; + expect(Storage.delete('token')).toEqual(token); + expect($sessionStorage.token).not.toBeDefined(); + }); + + it('should delete the token from the sessionStorage', function () { + $localStorage.token = token; + Storage.storage = $localStorage; + expect(Storage.delete('token')).toEqual(token); + expect($localStorage.token).not.toBeDefined(); + }); + + }); + +});