From 3195b52dce487a773c612b36796e7a0657c73229 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Wed, 18 Jan 2017 10:25:13 -0500 Subject: [PATCH] add oauth3.js even though it has browser-specific code --- oauth3.js | 539 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 oauth3.js diff --git a/oauth3.js b/oauth3.js new file mode 100644 index 0000000..b4d74f2 --- /dev/null +++ b/oauth3.js @@ -0,0 +1,539 @@ +/* global Promise */ +(function (exports) { + 'use strict'; + + var oauth3 = {}; + var logins = {}; + + var core = exports.OAUTH3_CORE || require('./oauth3.core.js'); + + 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.discover(providerUri, opts).then(function (directive) { + var prequest = core.authorizationRedirect( + directive + , opts.authorizationRedirect + , opts + ); + + console.log('[DEBUG] [core] authorizationRedirect URL:', 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.discover(providerUri, opts).then(function (directive) { + var prequest = core.implicitGrant( + directive + // TODO OAuth3 provider should referer / origin as the appId? + , opts + ); + + console.log('[DEBUG] [core] implicitGrant URL', prequest); + + if (!prequest.state) { + throw new Error("[Devolper Error] [implicit grant] prequest.state is empty"); + } + + return oauth3.frameRequest(prequest.url, prequest.state, opts); + }); + }; + + oauth3.loginCode = function (providerUri, opts) { + return oauth3.discover(providerUri, opts).then(function (directive) { + var prequest = core.loginCode(directive, opts); + + console.log('[DEBUG] [core] loginCode URL', prequest); + + return oauth3.request(prequest).then(function (res) { + // result = { uuid, expiresAt } + console.log('[DEBUG] [core] loginCode result', res); + return { + otpUuid: res.data.uuid + , otpExpires: res.data.expiresAt + }; + }); + }); + }; + + logins.resourceOwnerPassword = function (providerUri, username, passphrase, opts) { + console.log('DEBUG logins.resourceOwnerPassword opts', opts); + //var scope = opts.scope; + //var appId = opts.appId; + return oauth3.discover(providerUri, opts).then(function (directive) { + var prequest = core.resourceOwnerPassword( + directive + , username + , passphrase + , opts // secret: proofstr || otpCode, totp: totpToken, otp: otpCode, otpUuid: otpUuuid + //, scope + //, appId + ); + + console.log('[DEBUG] [core] resourceOwnerPassword URL', prequest); + + return oauth3.request({ + url: prequest.url + , method: prequest.method + , data: prequest.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 || opts.otp)) { + 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 += '#' + core.querystringify(params); + + return oauth3.insertIframe(url, state, opts); + }; + + 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#' + core.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 + // TODO self-reference in directive for providerUri? + directives.provider_uri = providerUri; + 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; + }; + + exports.OAUTH3 = oauth3.oauth3 = oauth3.OAUTH3 = oauth3; + exports.oauth3 = exports.OAUTH3; + + if ('undefined' !== typeof module) { + module.exports = oauth3; + } +}('undefined' !== typeof exports ? exports : window));