474 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			474 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| ;(function (exports) {
 | |
|   'use strict';
 | |
| 
 | |
|   // NOTE: we assume that directive.provider_uri exists
 | |
| 
 | |
|   var core = {};
 | |
|   core.urls = core;
 | |
| 
 | |
|   function getDefaultAppApiBase() {
 | |
|     console.warn('[deprecated] using window.location.host when opts.appApiBase should be used');
 | |
|     return 'https://' + window.location.host;
 | |
|   }
 | |
| 
 | |
|   core.parsescope = function (scope) {
 | |
|     return (scope||'').split(/[+, ]/g);
 | |
|   };
 | |
|   core.stringifyscope = function (scope) {
 | |
|     if (Array.isArray(scope)) {
 | |
|       scope = scope.join(' ');
 | |
|     }
 | |
|     return scope;
 | |
|   };
 | |
| 
 | |
|   core.querystringify = function (params) {
 | |
|     var qs = [];
 | |
| 
 | |
|     Object.keys(params).forEach(function (key) {
 | |
|       // TODO nullify instead?
 | |
|       if ('undefined' === typeof params[key]) {
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       if ('scope' === key) {
 | |
|         params[key] = core.stringifyscope(params[key]);
 | |
|       }
 | |
| 
 | |
|       qs.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key]));
 | |
|     });
 | |
| 
 | |
|     return qs.join('&');
 | |
|   };
 | |
| 
 | |
|   // Modified from http://stackoverflow.com/a/7826782
 | |
|   core.queryparse = function (search) {
 | |
|     // parse a query or a hash
 | |
|     if (-1 !== ['#', '?'].indexOf(search[0])) {
 | |
|       search = search.substring(1);
 | |
|     }
 | |
|     // Solve for case of search within hash
 | |
|     // example: #/authorization_dialog/?state=...&redirect_uri=...
 | |
|     var queryIndex = search.indexOf('?');
 | |
|     if (-1 !== queryIndex) {
 | |
|       search = search.substr(queryIndex + 1);
 | |
|     }
 | |
| 
 | |
|     var args = search.split('&');
 | |
|     var argsParsed = {};
 | |
|     var i, arg, kvp, key, value;
 | |
| 
 | |
|     for (i = 0; i < args.length; i += 1) {
 | |
| 
 | |
|         arg = args[i];
 | |
| 
 | |
|         if (-1 === arg.indexOf('=')) {
 | |
| 
 | |
|           argsParsed[decodeURIComponent(arg).trim()] = true;
 | |
| 
 | |
|         }
 | |
|         else {
 | |
| 
 | |
|           kvp = arg.split('=');
 | |
|           key = decodeURIComponent(kvp[0]).trim();
 | |
|           value = decodeURIComponent(kvp[1]).trim();
 | |
|           argsParsed[key] = value;
 | |
| 
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     return argsParsed;
 | |
|   };
 | |
| 
 | |
|   core.formatError = function (providerUri, params) {
 | |
|     var err = new Error(params.error_description || params.error.message || "Unknown error when discoving provider '" + providerUri + "'");
 | |
|     err.uri = params.error_uri || params.error.uri;
 | |
|     err.code = params.error.code || params.error;
 | |
|     return err;
 | |
|   };
 | |
|   core.normalizePath = function (path) {
 | |
|     return path.replace(/^\//, '').replace(/\/$/, '');
 | |
|   };
 | |
|   core.normalizeUri = function (providerUri) {
 | |
|     // tested with
 | |
|     //   example.com
 | |
|     //   example.com/
 | |
|     //   http://example.com
 | |
|     //   https://example.com/
 | |
|     return providerUri
 | |
|       .replace(/^(https?:\/\/)?/i, '')
 | |
|       .replace(/\/?$/, '')
 | |
|       ;
 | |
|   };
 | |
|   core.normalizeUrl = function (providerUri) {
 | |
|     // tested with
 | |
|     //   example.com
 | |
|     //   example.com/
 | |
|     //   http://example.com
 | |
|     //   https://example.com/
 | |
|     return providerUri
 | |
|       .replace(/^(https?:\/\/)?/i, 'https://')
 | |
|       .replace(/\/?$/, '')
 | |
|       ;
 | |
|   };
 | |
| 
 | |
|   // these might not really belong in core... not sure
 | |
|   // there should be node.js- and browser-specific versions probably
 | |
|   core.utils = {
 | |
|     urlSafeBase64ToBase64: function (b64) {
 | |
|       // URL-safe Base64 to Base64
 | |
|       // https://en.wikipedia.org/wiki/Base64
 | |
|       // https://gist.github.com/catwell/3046205
 | |
|       var mod = b64.length % 4;
 | |
|       if (2 === mod) { b64 += '=='; }
 | |
|       if (3 === mod) { b64 += '='; }
 | |
|       b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
 | |
|       return b64;
 | |
|     }
 | |
|   , base64ToUrlSafeBase64: function (b64) {
 | |
|       // Base64 to URL-safe Base64
 | |
|       b64 = b64.replace(/\+/g, '-').replace(/\//g, '_');
 | |
|       b64 = b64.replace(/=+/g, '');
 | |
|       return b64;
 | |
|     }
 | |
|   , randomState: function () {
 | |
|       var i;
 | |
|       var ch;
 | |
|       var str;
 | |
| 
 | |
|       // TODO put in different file for browser vs node
 | |
|       try {
 | |
|         return Array.prototype.slice.call(window.crypto.getRandomValues(new Uint8Array(16))).map(function (ch) { return (ch).toString(16); }).join('');
 | |
|       } catch(e) {
 | |
|         // TODO use fisher-yates on 0..255 and select [0] 16 times
 | |
|         // [security] https://medium.com/@betable/tifu-by-using-math-random-f1c308c4fd9d#.5qx0bf95a
 | |
|         // https://github.com/v8/v8/blob/b0e4dce6091a8777bda80d962df76525dc6c5ea9/src/js/math.js#L135-L144
 | |
|         // Note: newer versions of v8 do not have this bug, but other engines may still
 | |
|         console.warn('[security] crypto.getRandomValues() failed, falling back to Math.random()');
 | |
|         str = '';
 | |
|         for (i = 0; i < 32; i += 1) {
 | |
|           ch = Math.round(Math.random() * 255).toString(16);
 | |
|           if (ch.length < 2) { ch = '0' + ch; }
 | |
|           str += ch;
 | |
|         }
 | |
|         return str;
 | |
|       }
 | |
|     }
 | |
|   };
 | |
|   core.jwt = {
 | |
|     // decode only (no verification)
 | |
|     decode: function (str) {
 | |
| 
 | |
|       // 'abc.qrs.xyz'
 | |
|       // [ 'abc', 'qrs', 'xyz' ]
 | |
|       // [ {}, {}, 'foo' ]
 | |
|       // { header: {}, payload: {}, signature: }
 | |
|       var parts = str.split(/\./g);
 | |
|       var jsons = parts.slice(0, 2).map(function (urlsafe64) {
 | |
|         var atob = exports.atob || require('atob');
 | |
|         var b64 = core.utils.urlSafeBase64ToBase64(urlsafe64);
 | |
|         return atob(b64);
 | |
|       });
 | |
| 
 | |
|       return {
 | |
|         header: JSON.parse(jsons[0])
 | |
|       , payload: JSON.parse(jsons[1])
 | |
|       , signature: parts[2] // should remain url-safe base64
 | |
|       };
 | |
|     }
 | |
|   , getFreshness: function (tokenMeta, staletime, now) {
 | |
|       staletime = staletime || (15 * 60);
 | |
|       now = now || Date.now();
 | |
|       var fresh = ((parseInt(tokenMeta.exp, 10) || 0) - Math.round(now / 1000));
 | |
| 
 | |
|       if (fresh >= staletime) {
 | |
|         return 'fresh';
 | |
|       }
 | |
| 
 | |
|       if (fresh <= 0) {
 | |
|         return 'expired';
 | |
|       }
 | |
| 
 | |
|       return 'stale';
 | |
|     }
 | |
|     // encode-only (no signature)
 | |
|   , encode: function (parts) {
 | |
|       parts.header = parts.header || { alg: 'none', typ: 'jwt' };
 | |
|       parts.signature = parts.signature || '';
 | |
| 
 | |
|       var btoa = exports.btoa || require('btoa');
 | |
|       var result = [
 | |
|         core.utils.base64ToUrlSafeBase64(btoa(JSON.stringify(parts.header, null)))
 | |
|       , core.utils.base64ToUrlSafeBase64(btoa(JSON.stringify(parts.payload, null)))
 | |
|       , parts.signature // should already be url-safe base64
 | |
|       ].join('.');
 | |
| 
 | |
|       return result;
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   core.urls.discover = function (providerUri, opts) {
 | |
|     if (!providerUri) {
 | |
|       throw new Error("cannot discover without providerUri");
 | |
|     }
 | |
|     if (!opts.client_id) {
 | |
|       throw new Error("cannot discover without options.client_id");
 | |
|     }
 | |
|     var clientId = core.normalizeUrl(opts.client_id || opts.client_uri);
 | |
|     providerUri = core.normalizeUrl(providerUri);
 | |
| 
 | |
|     var params = {
 | |
|       action: 'directives'
 | |
|     , state: core.utils.randomState()
 | |
|     , redirect_uri: clientId + (opts.client_callback_path || '/.well-known/oauth3/callback.html')
 | |
|     , response_type: 'rpc'
 | |
|     , _method: 'GET'
 | |
|     , _pathname: '.well-known/oauth3/directives.json'
 | |
|     , debug: opts.debug || undefined
 | |
|     };
 | |
| 
 | |
|     var result = {
 | |
|       url: providerUri + '/.well-known/oauth3/#/?' + core.querystringify(params)
 | |
|     , state: params.state
 | |
|     , method: 'GET'
 | |
|     , query: params
 | |
|     };
 | |
| 
 | |
|     return result;
 | |
|   };
 | |
|   core.urls.authorizationCode = function (/*directive, scope, redirectUri, clientId*/) {
 | |
|     //
 | |
|     // Example Authorization Code Request
 | |
|     // (not for use in the browser)
 | |
|     //
 | |
|     // GET https://example.com/api/org.oauth3.provider/authorization_dialog
 | |
|     //  ?response_type=code
 | |
|     //  &scope=`encodeURIComponent('profile.login profile.email')`
 | |
|     //  &state=`cryptoutil.random().toString('hex')`
 | |
|     //  &client_id=xxxxxxxxxxx
 | |
|     //  &redirect_uri=`encodeURIComponent('https://myapp.com/oauth3.html')`
 | |
|     //
 | |
|     // NOTE: `redirect_uri` itself may also contain URI-encoded components
 | |
|     //
 | |
|     // NOTE: This probably shouldn't be done in the browser because the server
 | |
|     //   needs to initiate the state. If it is done in a browser, the browser
 | |
|     //   should probably request 'state' from the server beforehand
 | |
|     //
 | |
| 
 | |
|     throw new Error("not implemented");
 | |
|   };
 | |
| 
 | |
|   core.urls.authorizationRedirect = function (directive, opts) {
 | |
|     //console.log('[authorizationRedirect]');
 | |
|     //
 | |
|     // Example Authorization Redirect - from Browser to Consumer API
 | |
|     // (for generating a session securely on your own server)
 | |
|     //
 | |
|     // i.e. GET https://<<CONSUMER>>.com/api/org.oauth3.consumer/authorization_redirect/<<PROVIDER>>.com
 | |
|     //
 | |
|     // GET https://myapp.com/api/org.oauth3.consumer/authorization_redirect/`encodeURIComponent('example.com')`
 | |
|     //  &scope=`encodeURIComponent('profile.login profile.email')`
 | |
|     //
 | |
|     // (optional)
 | |
|     //  &state=`cryptoutil.random().toString('hex')`
 | |
|     //  &redirect_uri=`encodeURIComponent('https://myapp.com/oauth3.html')`
 | |
|     //
 | |
|     // NOTE: This is not a request sent to the provider, but rather a request sent to the
 | |
|     // consumer (your own API) which then sets some state and redirects.
 | |
|     // This will initiate the `authorization_code` request on your server
 | |
|     //
 | |
|     opts = opts || {};
 | |
| 
 | |
|     var scope = opts.scope || directive.authn_scope;
 | |
|     var providerUri = directive.provider_uri;
 | |
|     var params = {
 | |
|       state: core.utils.randomState()
 | |
|     , debug: opts.debug || undefined
 | |
|     };
 | |
|     var slimProviderUri = encodeURIComponent(providerUri.replace(/^(https?|spdy):\/\//, ''));
 | |
|     var authorizationRedirect = opts.authorizationRedirect;
 | |
| 
 | |
|     if (scope) {
 | |
|       params.scope = scope;
 | |
|     }
 | |
|     if (opts.redirectUri) {
 | |
|       // this is really only for debugging
 | |
|       params.redirect_uri = opts.redirectUri;
 | |
|     }
 | |
|     // Note: the type check is necessary because we allow 'true'
 | |
|     // as an automatic mechanism when it isn't necessary to specify
 | |
|     if ('string' !== typeof authorizationRedirect) {
 | |
|       // TODO oauth3.json for self?
 | |
|       authorizationRedirect = (opts.appApiBase || getDefaultAppApiBase())
 | |
|         + '/api/org.oauth3.consumer/authorization_redirect/:provider_uri';
 | |
|     }
 | |
|     authorizationRedirect = authorizationRedirect
 | |
|       .replace(/!(provider_uri)/, slimProviderUri)
 | |
|       .replace(/:provider_uri/, slimProviderUri)
 | |
|       .replace(/#{provider_uri}/, slimProviderUri)
 | |
|       .replace(/{{provider_uri}}/, slimProviderUri)
 | |
|       ;
 | |
| 
 | |
|     return {
 | |
|       url: authorizationRedirect + '?' + core.querystringify(params)
 | |
|     , method: 'GET'
 | |
|     , state: params.state    // this becomes browser_state
 | |
|     , params: params  // includes scope, final redirect_uri?
 | |
|     };
 | |
|   };
 | |
| 
 | |
|   core.urls.implicitGrant = function (directive, opts) {
 | |
|     //console.log('[implicitGrant]');
 | |
|     //
 | |
|     // Example Implicit Grant Request
 | |
|     // (for generating a browser-only session, not a session on your server)
 | |
|     //
 | |
|     // GET https://example.com/api/org.oauth3.provider/authorization_dialog
 | |
|     //  ?response_type=token
 | |
|     //  &scope=`encodeURIComponent('profile.login profile.email')`
 | |
|     //  &state=`cryptoutil.random().toString('hex')`
 | |
|     //  &client_id=xxxxxxxxxxx
 | |
|     //  &redirect_uri=`encodeURIComponent('https://myapp.com/oauth3.html')`
 | |
|     //
 | |
|     // NOTE: `redirect_uri` itself may also contain URI-encoded components
 | |
|     //
 | |
| 
 | |
|     opts = opts || {};
 | |
|     var type = 'authorization_dialog';
 | |
|     var responseType = 'token';
 | |
| 
 | |
|     var redirectUri = opts.redirect_uri;
 | |
|     var scope = opts.scope || directive.authn_scope;
 | |
|     var args = directive[type];
 | |
|     var uri = args.url;
 | |
|     var state = core.utils.randomState();
 | |
|     var params = {
 | |
|       debug: opts.debug || undefined
 | |
|     , client_uri: opts.client_uri || opts.clientUri || undefined
 | |
|     , client_id: opts.client_id || opts.client_uri || undefined
 | |
|     };
 | |
|     var result;
 | |
| 
 | |
|     params.state = state;
 | |
|     params.response_type = responseType;
 | |
|     if (scope) {
 | |
|       params.scope = core.stringifyscope(scope);
 | |
|     }
 | |
|     if (!redirectUri) {
 | |
|       // TODO consider making this optional
 | |
|       console.error('missing redirect_uri');
 | |
|     }
 | |
|     params.redirect_uri = redirectUri;
 | |
| 
 | |
|     uri += '?' + core.querystringify(params);
 | |
| 
 | |
|     result = {
 | |
|       url: uri
 | |
|     , state: state
 | |
|     , method: args.method
 | |
|     , query: params
 | |
|     };
 | |
| 
 | |
|     return result;
 | |
|   };
 | |
| 
 | |
|   core.urls.resolve = function (base, next) {
 | |
|     if (/^https:\/\//i.test(next)) {
 | |
|       return next;
 | |
|     }
 | |
|     return core.normalizeUrl(base) + '/' + core.normalizePath(next);
 | |
|   };
 | |
| 
 | |
|   core.urls.refreshToken = function (directive, opts) {
 | |
|     // grant_type=refresh_token
 | |
| 
 | |
|     // Example Refresh Token Request
 | |
|     // (generally for 1st or 3rd party server-side, mobile, and desktop apps)
 | |
|     //
 | |
|     // POST https://example.com/api/oauth3/access_token
 | |
|     //    { "grant_type": "refresh_token", "client_id": "<<id>>", "scope": "<<scope>>"
 | |
|     //    , "username": "<<username>>", "password": "password" }
 | |
|     //
 | |
|     opts = opts || {};
 | |
|     var type = 'access_token';
 | |
|     var grantType = 'refresh_token';
 | |
| 
 | |
|     var scope = opts.scope || directive.authn_scope;
 | |
|     var clientSecret = opts.appSecret || opts.clientSecret;
 | |
|     var args = directive[type];
 | |
|     var params = {
 | |
|       "grant_type": grantType
 | |
|     , "refresh_token": opts.refresh_token || opts.refreshToken || (opts.session && opts.session.refresh_token)
 | |
|     , "response_type": 'token'
 | |
|     , "client_id": opts.appId || opts.app_id || opts.client_id || opts.clientId || opts.client_id || opts.clientId
 | |
|     , "client_uri": opts.client_uri || opts.clientUri
 | |
|     //, "scope": undefined
 | |
|     //, "client_secret": undefined
 | |
|     , debug: opts.debug || undefined
 | |
|     };
 | |
|     var uri = args.url;
 | |
|     var body;
 | |
| 
 | |
|     // TODO not allowed in the browser
 | |
|     if (clientSecret) {
 | |
|       params.client_secret = clientSecret;
 | |
|     }
 | |
| 
 | |
|     if (scope) {
 | |
|       params.scope = core.stringifyscope(scope);
 | |
|     }
 | |
| 
 | |
|     if ('GET' === args.method.toUpperCase()) {
 | |
|       uri += '?' + core.querystringify(params);
 | |
|     } else {
 | |
|       body = params;
 | |
|     }
 | |
| 
 | |
|     return {
 | |
|       url: uri
 | |
|     , method: args.method
 | |
|     , data: body
 | |
|     };
 | |
|   };
 | |
| 
 | |
|   core.urls.logout = function (directive, opts) {
 | |
|     opts = opts || {};
 | |
|     var type = 'logout';
 | |
|     var clientId = opts.appId || opts.clientId || opts.client_id;
 | |
|     var args = directive[type];
 | |
|     var params = {
 | |
|       client_id: opts.clientUri || opts.client_uri
 | |
|     , debug: opts.debug || undefined
 | |
|     };
 | |
|     var uri = args.url;
 | |
|     var body;
 | |
| 
 | |
|     if (opts.clientUri) {
 | |
|       params.client_uri = opts.clientUri;
 | |
|     }
 | |
| 
 | |
|     if (clientId) {
 | |
|       params.client_id = clientId;
 | |
|     }
 | |
| 
 | |
|     args.method = (args.method || 'GET').toUpperCase();
 | |
|     if ('GET' === args.method) {
 | |
|       uri += '?' + core.querystringify(params);
 | |
|     } else {
 | |
|       body = params;
 | |
|     }
 | |
| 
 | |
|     return {
 | |
|       url: uri
 | |
|     , method: args.method || 'GET'
 | |
|     , data: body
 | |
|     };
 | |
|   };
 | |
| 
 | |
|   exports.OAUTH3 = exports.OAUTH3 || { core: core };
 | |
|   exports.OAUTH3_CORE = core.OAUTH3_CORE = core;
 | |
| 
 | |
|   if ('undefined' !== typeof module) {
 | |
|     module.exports = core;
 | |
|   }
 | |
| }('undefined' !== typeof exports ? exports : window));
 |