(function (exports) { 'use strict'; var oauth3 = {}; var logins = {}; oauth3.requests = logins; if ('undefined' !== typeof Promise) { oauth3.PromiseA = Promise; } else { console.warn("[oauth3.js] Remember to call oauth3.providePromise(Promise) with a proper Promise implementation"); } // TODO move to a test / lint suite? oauth3._testPromise = function (PromiseA) { var promise; var x = 1; // tests that this promise has all of the necessary api promise = new PromiseA(function (resolve, reject) { if (x === 1) { throw new Error("bad promise, create not asynchronous"); } PromiseA.resolve().then(function () { var promise2; if (x === 1 || x === 2) { throw new Error("bad promise, resolve not asynchronous"); } promise2 = PromiseA.reject().then(reject, function () { if (x === 1 || x === 2 || x === 3) { throw new Error("bad promise, reject not asynchronous"); } if ('undefined' === typeof angular) { throw new Error("[NOT AN ERROR] Dear angular users: ignore this error-handling test"); } else { return PromiseA.reject(new Error("[NOT AN ERROR] ignore this error-handling test")); } }); x = 4; return promise2; }).catch(function (e) { if (e.message.match('NOT AN ERROR')) { resolve({ success: true }); } else { reject(e); } }); x = 3; }); x = 2; return promise; }; oauth3.providePromise = function (PromiseA) { oauth3.PromiseA = PromiseA; return oauth3._testPromise(PromiseA).then(function () { oauth3.PromiseA = PromiseA; }); }; oauth3.provideRequest = function (request, opts) { opts = opts || {}; var Recase = exports.Recase || require('recase'); // TODO make insensitive to providing exceptions var recase = Recase.create({ exceptions: {} }); if (opts.rawCase) { oauth3.request = request; return; } // Wrap oauth3 api calls in snake_case / camelCase conversion oauth3.request = function (req, opts) { //console.log('[D] [oauth3 req.url]', req.url); opts = opts || {}; if (opts.rawCase) { return request(req); } // convert JavaScript camelCase to oauth3 snake_case if (req.data && 'object' === typeof req.data) { req.originalData = req.data; req.data = recase.snakeCopy(req.data); } //console.log('[F] [oauth3 req.url]', req.url); return request(req).then(function (resp) { // convert oauth3 snake_case to JavaScript camelCase if (resp.data && 'object' === typeof resp.data) { resp.originalData = resp.data; resp.data = recase.camelCopy(resp.data); } return resp; }); }; /* return oauth3._testRequest(request).then(function () { oauth3.request = request; }); */ }; logins.authorizationRedirect = function (providerUri, opts) { // TODO get own directives return oauth3.authorizationRedirect( providerUri , opts.authorizationRedirect , opts ).then(function (prequest) { if (!prequest.state) { throw new Error("[Devolper Error] [authorization redirect] prequest.state is empty"); } return oauth3.frameRequest(prequest.url, prequest.state, opts); }); }; logins.implicitGrant = function (providerUri, opts) { // TODO OAuth3 provider should use the redirect URI as the appId? return oauth3.implicitGrant( providerUri // TODO OAuth3 provider should referer / origin as the appId? , opts ).then(function (prequest) { // console.log('[debug] prequest', prequest); if (!prequest.state) { throw new Error("[Devolper Error] [implicit grant] prequest.state is empty"); } return oauth3.frameRequest(prequest.url, prequest.state, opts); }); }; logins.resourceOwnerPassword = function (providerUri, username, passphrase, opts) { console.log('DEBUG logins.resourceOwnerPassword opts', opts); //var scope = opts.scope; //var appId = opts.appId; return oauth3.resourceOwnerPassword( providerUri , username , passphrase , opts //, scope //, appId ).then(function (request) { console.log('DEBUG oauth3.resourceOwnerPassword', request); return oauth3.request({ url: request.url , method: request.method , data: request.data }); }); }; oauth3.frameRequest = function (url, state, opts) { var promise; if ('background' === opts.type) { promise = oauth3.insertIframe(url, state, opts); } else if ('popup' === opts.type) { promise = oauth3.openWindow(url, state, opts); } else { throw new Error("login framing method not specified or not type yet implemented"); } return promise.then(function (params) { var err; if (params.error || params.error_description) { err = new Error(params.error_description || "Unknown response error"); err.code = params.error || "E_UKNOWN_ERROR"; err.params = params; return oauth3.PromiseA.reject(err); } return params; }); }; oauth3.login = function (providerUri, opts) { console.log('##### DEBUG oauth3.login providerUri, opts'); console.log(providerUri); console.log(opts); // Four styles of login: // * background (hidden iframe) // * iframe (visible iframe, needs border color and width x height params) // * popup (needs width x height and positioning? params) // * window (params?) // Two strategies // * authorization_redirect (to server authorization code) // * implicit_grant (default, browser-only) // If both are selected, implicit happens first and then the other happens in background var promise; if (opts.username || opts.password) { /* jshint ignore:start */ // ingore "confusing use of !" if (!opts.username !== !opts.password) { throw new Error("you did not specify both username and password"); } /* jshint ignore:end */ var username = opts.username; var password = opts.password; delete opts.username; delete opts.password; return logins.resourceOwnerPassword(providerUri, username, password, opts).then(function (resp) { if (!resp || !resp.data) { var err = new Error("bad response"); err.response = resp; err.data = resp && resp.data || undefined; return oauth3.PromiseA.reject(err); } return resp.data; }); } // TODO support dual-strategy login // by default, always get implicitGrant (for client) // and optionally do authorizationCode (for server session) if ('background' === opts.type || opts.background) { opts.type = 'background'; opts.background = true; } else { opts.type = 'popup'; opts.popup = true; } if (opts.authorizationRedirect) { promise = logins.authorizationRedirect(providerUri, opts); } else { promise = logins.implicitGrant(providerUri, opts); } return promise; }; oauth3.backgroundLogin = function (providerUri, opts) { opts = opts || {}; opts.type = 'background'; return oauth3.login(providerUri, opts); }; oauth3.insertIframe = function (url, state, opts) { opts = opts || {}; var promise = new oauth3.PromiseA(function (resolve, reject) { var tok; var $iframe; function cleanup() { delete window['__oauth3_' + state]; $iframe.remove(); clearTimeout(tok); tok = null; } window['__oauth3_' + state] = function (params) { //console.info('[iframe] complete', params); resolve(params); cleanup(); }; tok = setTimeout(function () { var err = new Error("the iframe request did not complete within 15 seconds"); err.code = "E_TIMEOUT"; reject(err); cleanup(); }, opts.timeout || 15000); // TODO hidden / non-hidden (via directive even) $iframe = $( '' + '" width="1px" height="1px" frameborder="0">' ); $('body').append($iframe); }); // TODO periodically garbage collect expired handlers from window object return promise; }; oauth3.openWindow = function (url, state, opts) { var promise = new oauth3.PromiseA(function (resolve, reject) { var winref; var tok; function cleanup() { delete window['__oauth3_' + state]; clearTimeout(tok); tok = null; // this is last in case the window self-closes synchronously // (should never happen, but that's a negotiable implementation detail) //winref.close(); } window['__oauth3_' + state] = function (params) { //console.info('[popup] (or window) complete', params); resolve(params); cleanup(); }; tok = setTimeout(function () { var err = new Error("the windowed request did not complete within 3 minutes"); err.code = "E_TIMEOUT"; reject(err); cleanup(); }, opts.timeout || 3 * 60 * 1000); // TODO allow size changes (via directive even) winref = window.open(url, 'oauth3-login-' + state, 'height=720,width=620'); if (!winref) { reject("TODO: open the iframe first and discover oauth3 directives before popup"); cleanup(); } }); // TODO periodically garbage collect expired handlers from window object return promise; }; oauth3.logout = function (providerUri, opts) { opts = opts || {}; // Oauth3.init({ logout: function () {} }); //return Oauth3.logout(); var state = parseInt(Math.random().toString().replace('0.', ''), 10).toString('36'); var url = providerUri.replace(/\/$/, '') + (opts.providerOauth3Html || '/oauth3.html'); var redirectUri = opts.redirectUri || (window.location.protocol + '//' + (window.location.host + window.location.pathname) + 'oauth3.html') ; var params = { // logout=true for all logins/accounts // logout=app-scoped-login-id for a single login action: 'logout' // TODO specify specific accounts / logins to delete from session , accounts: true , logins: true , redirect_uri: redirectUri , state: state }; //console.log('DEBUG oauth3.logout NIX insertIframe'); //console.log(url, params.redirect_uri); //console.log(state); //console.log(params); // redirect_uri //console.log(opts); if (url === params.redirect_uri) { return oauth3.PromiseA.resolve(); } url += '#' + oauth3.querystringify(params); return oauth3.insertIframe(url, state, opts); }; oauth3.stringifyscope = function (scope) { if (Array.isArray(scope)) { scope = scope.join(' '); } return scope; }; oauth3.querystringify = function (params) { var qs = []; Object.keys(params).forEach(function (key) { if ('scope' === key) { params[key] = oauth3.stringifyscope(params[key]); } qs.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key])); }); return qs.join('&'); }; oauth3.createState = function () { // TODO mo' betta' random function // maybe gather some entropy from mouse / keyboard events? // (probably not, just use webCrypto or be sucky) return parseInt(Math.random().toString().replace('0.', ''), 10).toString('36'); }; oauth3.normalizeProviderUri = function (providerUri) { // tested with // example.com // example.com/ // http://example.com // https://example.com/ providerUri = providerUri .replace(/^(https?:\/\/)?/, 'https://') .replace(/\/?$/, '') ; return providerUri; }; oauth3._discoverHelper = function (providerUri, opts) { opts = opts || {}; var state = oauth3.createState(); var params; var url; params = { action: 'directives' , state: state // TODO this should be configurable (i.e. I want a dev vs production oauth3.html) , redirect_uri: window.location.protocol + '//' + window.location.host + window.location.pathname + 'oauth3.html' }; url = providerUri + '/oauth3.html#' + oauth3.querystringify(params); return oauth3.insertIframe(url, state, opts).then(function (directives) { return directives; }, function (err) { return oauth3.PromiseA.reject(err); }); }; oauth3.discover = function (providerUri, opts) { opts = opts || {}; console.log('DEBUG oauth3.discover', providerUri); console.log(opts); if (opts.directives) { return oauth3.PromiseA.resolve(opts.directives); } var promise; var promise2; var directives; var updatedAt; var fresh; providerUri = oauth3.normalizeProviderUri(providerUri); try { directives = JSON.parse(localStorage.getItem('oauth3.' + providerUri + '.directives')); console.log('DEBUG oauth3.discover cache', directives); updatedAt = localStorage.getItem('oauth3.' + providerUri + '.directives.updated_at'); console.log('DEBUG oauth3.discover updatedAt', updatedAt); updatedAt = new Date(updatedAt).valueOf(); console.log('DEBUG oauth3.discover updatedAt', updatedAt); } catch(e) { // ignore } fresh = (Date.now() - updatedAt) < (24 * 60 * 60 * 1000); if (directives) { promise = oauth3.PromiseA.resolve(directives); if (fresh) { //console.log('[local] [fresh directives]', directives); return promise; } } promise2 = oauth3._discoverHelper(providerUri, opts).then(function (params) { console.log('DEBUG oauth3._discoverHelper', params); var err; if (!params.directives) { err = new Error(params.error_description || "Unknown error when discoving provider '" + providerUri + "'"); err.code = params.error || "E_UNKNOWN_ERROR"; return oauth3.PromiseA.reject(err); } try { directives = JSON.parse(atob(params.directives)); console.log('DEBUG oauth3._discoverHelper directives', directives); } catch(e) { err = new Error(params.error_description || "could not parse directives for provider '" + providerUri + "'"); err.code = params.error || "E_PARSE_DIRECTIVE"; return oauth3.PromiseA.reject(err); } if ( (directives.authorization_dialog && directives.authorization_dialog.url) || (directives.access_token && directives.access_token.url) ) { // TODO lint directives localStorage.setItem('oauth3.' + providerUri + '.directives', JSON.stringify(directives)); localStorage.setItem('oauth3.' + providerUri + '.directives.updated_at', new Date().toISOString()); return oauth3.PromiseA.resolve(directives); } else { // ignore console.error("the directives provided by '" + providerUri + "' were invalid."); params.error = params.error || "E_INVALID_DIRECTIVE"; params.error_description = params.error_description || "directives did not include authorization_dialog.url"; err = new Error(params.error_description || "Unknown error when discoving provider '" + providerUri + "'"); err.code = params.error; return oauth3.PromiseA.reject(err); } }); return promise || promise2; }; oauth3.authorizationRedirect = function (providerUri, authorizationRedirect, 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://<>.com/api/org.oauth3.consumer/authorization_redirect/<>.com // // GET https://myapp.com/api/org.oauth3.consumer/authorization_redirect/`encodeURIComponent('example.com')` // &scope=`encodeURIComponent('profile.login profile.email')` // // (optional) // &state=`Math.random()` // &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 || {}; return oauth3.discover(providerUri, opts).then(function (directive) { if (!directive) { throw new Error("Developer Error: directive should exist when discovery is successful"); } var scope = opts.scope || directive.authn_scope; var state = Math.random().toString().replace(/^0\./, ''); var params = {}; var slimProviderUri = encodeURIComponent(providerUri.replace(/^(https?|spdy):\/\//, '')); params.state = state; 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 = 'https://' + window.location.host + '/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 oauth3.PromiseA.resolve({ url: authorizationRedirect + '?' + oauth3.querystringify(params) , method: 'GET' , state: state // this becomes browser_state , params: params // includes scope, final redirect_uri? }); }); }; oauth3.authorizationCode = function (/*providerUri, 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=`Math.random()` // &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"); }; oauth3.implicitGrant = function (providerUri, 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=`Math.random()` // &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'; return oauth3.discover(providerUri, opts).then(function (directive) { var redirectUri = opts.redirectUri; var scope = opts.scope || directive.authn_scope; var clientId = opts.appId; var args = directive[type]; var uri = args.url; var state = Math.random().toString().replace(/^0\./, ''); var params = {}; var loc; var result; params.state = state; params.response_type = responseType; if (scope) { if (Array.isArray(scope)) { scope = scope.join(' '); } params.scope = scope; } if (clientId) { // In OAuth3 client_id is optional for implicit grant params.client_id = clientId; } if (!redirectUri) { loc = window.location; redirectUri = loc.protocol + '//' + loc.host + loc.pathname; if ('/' !== redirectUri[redirectUri.length - 1]) { redirectUri += '/'; } redirectUri += 'oauth3.html'; } params.redirect_uri = redirectUri; uri += '?' + oauth3.querystringify(params); result = { url: uri , state: state , method: args.method , query: params }; return oauth3.PromiseA.resolve(result); }); }; oauth3.resourceOwnerPassword = function (providerUri, username, passphrase, opts) { // // Example Resource Owner Password Request // (generally for 1st party and direct-partner mobile apps, and webapps) // // POST https://example.com/api/org.oauth3.provider/access_token // { "grant_type": "password", "client_id": "<>", "scope": "<>" // , "username": "<>", "password": "password" } // opts = opts || {}; var type = 'access_token'; var grantType = 'password'; return oauth3.discover(providerUri, opts).then(function (directive) { var scope = opts.scope || directive.authn_scope; var clientId = opts.appId; var clientAgreeTos = opts.clientAgreeTos; var clientUri = opts.clientUri; var args = directive[type]; var params = { "grant_type": grantType , "username": username , "password": passphrase //, "totp": opts.totp }; var uri = args.url; var body; if (opts.totp) { params.totp = opts.totp; } if (clientId) { params.clientId = clientId; } if (clientUri) { params.clientUri = clientUri; params.clientAgreeTos = clientAgreeTos; if (!clientAgreeTos) { throw new Error('Developer Error: missing clientAgreeTos uri'); } } if (scope) { if (Array.isArray(scope)) { scope = scope.join(' '); } params.scope = scope; } if ('GET' === args.method.toUpperCase()) { uri += '?' + oauth3.querystringify(params); } else { body = params; } return { url: uri , method: args.method , data: body }; }); }; exports.OAUTH3 = oauth3.oauth3 = oauth3.OAUTH3 = oauth3; exports.oauth3 = exports.OAUTH3; if ('undefined' !== typeof module) { module.exports = oauth3; } }('undefined' !== typeof exports ? exports : window));