oauth3.js/oauth3.browser.js

561 lines
19 KiB
JavaScript

;(function (exports) {
'use strict';
var OAUTH3 = exports.OAUTH3;
var OAUTH3_CORE = exports.OAUTH3_CORE;
function getDefaultAppUrl() {
console.warn('[deprecated] using window.location.{protocol, host, pathname} when opts.client_id should be used');
return window.location.protocol
+ '//' + window.location.host
+ (window.location.pathname).replace(/\/?$/, '')
;
}
var browser = exports.OAUTH3_BROWSER = {
window: window
, clientUri: function (location) {
return OAUTH3_CORE.normalizeUri(location.host + location.pathname);
}
, discover: function (providerUri, opts) {
if (!providerUri) {
throw new Error('oauth3.discover(providerUri, opts) received providerUri as ' + providerUri);
}
var directives = OAUTH3.hooks.getDirectives(providerUri);
if (directives && directives.issuer) {
return OAUTH3.PromiseA.resolve(directives);
}
return browser._discoverHelper(providerUri, opts).then(function (directives) {
directives.issuer = directives.issuer || OAUTH3_CORE.normalizeUrl(providerUri);
console.log('discoverHelper', directives);
return OAUTH3.hooks.setDirectives(providerUri, directives);
});
}
, _discoverHelper: function (providerUri, opts) {
opts = opts || {};
//opts.debug = true;
providerUri = OAUTH3_CORE.normalizeUrl(providerUri);
if (window.location.hostname.match(providerUri)) {
console.warn("It looks like you're a provider checking for your own directive,"
+ " so we we're just gonna use OAUTH3.request({ method: 'GET', url: '.well-known/oauth3/directive.json' })");
return OAUTH3.request({
method: 'GET'
, url: OAUTH3.core.normalizeUrl(providerUri) + '/.well-known/oauth3/directives.json'
});
}
if (!window.location.hostname.match(opts.client_id || opts.client_uri)) {
console.warn("It looks like your client_id doesn't match your current window... this probably won't end well");
console.warn(opts.client_id || opts.client_uri, window.location.hostname);
}
var discObj = OAUTH3_CORE.urls.discover(providerUri, { client_id: (opts.client_id || opts.client_uri || getDefaultAppUrl()), debug: opts.debug });
// TODO ability to reuse iframe instead of closing
return browser.insertIframe(discObj.url, discObj.state, opts).then(function (params) {
if (params.error) {
return OAUTH3_CORE.formatError(providerUri, params.error);
}
var directives = JSON.parse(atob(OAUTH3_CORE.utils.urlSafeBase64ToBase64(params.result || params.directives)));
return directives;
}, function (err) {
return OAUTH3.PromiseA.reject(err);
});
}
, discoverAuthorizationDialog: function(providerUri, opts) {
var discObj = OAUTH3.core.discover(providerUri, opts);
// hmm... we're gonna need a broker for this since switching windows is distracting,
// popups are obnoxious, iframes are sometimes blocked, and most servers don't implement CORS
// eventually it should be the browser (and postMessage may be a viable option now), but whatever...
// TODO allow postMessage from providerUri in addition to callback
var discWin = OAUTH3.openWindow(discObj.url, discObj.state, { reuseWindow: 'conquerer' });
return discWin.then(function (params) {
console.log('discwin params');
console.log(params);
// discWin.child
// TODO params should have response_type indicating json, binary, etc
var directives = JSON.parse(atob(OAUTH3.core.utils.urlSafeBase64ToBase64(params.result || params.directives)));
console.log('directives');
console.log(directives);
// Do some stuff
var authObj = OAUTH3.core.implicitGrant(
directives
, { redirect_uri: opts.redirect_uri
, debug: opts.debug
, client_id: opts.client_id || opts.client_uri
, client_uri: opts.client_uri || opts.client_id
}
);
if (params.debug) {
window.alert("DEBUG MODE: Pausing so you can look at logs and whatnot :) Fire at will!");
}
return new OAUTH3.PromiseA(function (resolve, reject) {
// TODO check if authObj.url is relative or full
discWin.child.location = OAUTH3.core.urls.resolve(providerUri, authObj.url);
if (params.debug) {
discWin.child.focus();
}
window['--oauth3-callback-' + authObj.state] = function (tokens) {
if (tokens.error) {
return reject(OAUTH3.core.formatError(tokens.error));
}
if (params.debug || tokens.debug) {
if (window.confirm("DEBUG MODE: okay to close oauth3 window?")) {
discWin.child.close();
}
}
else {
discWin.child.close();
}
resolve(tokens);
};
});
}).then(function (tokens) {
return OAUTH3.hooks.refreshSession(
opts.session || {
provider_uri: providerUri
, client_id: opts.client_id
, client_uri: opts.client_uri || opts.clientUri
}
, tokens
);
});
}
, frameRequest: function (url, state, opts) {
var promise;
if (!opts.windowType) {
opts.windowType = 'popup';
}
if ('background' === opts.windowType) {
promise = browser.insertIframe(url, state, opts);
} else if ('popup' === opts.windowType) {
promise = browser.openWindow(url, state, opts);
} else if ('inline' === opts.windowType) {
// callback function will never execute and would need to redirect back to current page
// rather than the callback.html
url += '&original_url=' + browser.window.location.href;
promise = browser.window.location = url;
} else {
throw new Error("login framing method options.windowType 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;
});
}
, insertIframe: function (url, state, opts) {
opts = opts || {};
if (opts.debug) {
opts.timeout = opts.timeout || 15 * 60 * 1000;
}
var promise = new OAUTH3.PromiseA(function (resolve, reject) {
var tok;
var iframeDiv;
function cleanup() {
delete window['--oauth3-callback-' + state];
iframeDiv.remove();
clearTimeout(tok);
tok = null;
}
window['--oauth3-callback-' + state] = function (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 || 15 * 1000);
// TODO hidden / non-hidden (via directive even)
var framesrc = '<iframe class="js-oauth3-iframe" src="' + url + '" ';
if (opts.debug) {
framesrc += ' width="800px" height="800px" style="opacity: 0.8;" frameborder="1"';
}
else {
framesrc += ' width="1px" height="1px" frameborder="0"';
}
framesrc += '></iframe>';
iframeDiv = window.document.createElement('div');
iframeDiv.innerHTML = framesrc;
window.document.body.appendChild(iframeDiv);
});
// TODO periodically garbage collect expired handlers from window object
return promise;
}
, openWindow: function (url, state, opts) {
if (opts.debug) {
opts.timeout = opts.timeout || 15 * 60 * 1000;
}
var promise = new OAUTH3.PromiseA(function (resolve, reject) {
var tok;
function cleanup() {
clearTimeout(tok);
tok = null;
delete window['--oauth3-callback-' + state];
// this is last in case the window self-closes synchronously
// (should never happen, but that's a negotiable implementation detail)
if (!opts.reuseWindow) {
promise.child.close();
}
}
window['--oauth3-callback-' + state] = function (params) {
console.log('YOLO!!');
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);
setTimeout(function () {
if (!promise.child) {
reject("TODO: open the iframe first and discover oauth3 directives before popup");
cleanup();
}
}, 0);
});
// TODO allow size changes (via directive even)
promise.child = window.open(
url
, 'oauth3-login-' + (opts.reuseWindow || state)
, 'height=' + (opts.height || 720) + ',width=' + (opts.width || 620)
);
// TODO periodically garbage collect expired handlers from window object
return promise;
}
//
// Logins
//
, authn: {
authorizationRedirect: function (providerUri, opts) {
// TODO get own directives
return OAUTH3.discover(providerUri, opts).then(function (directive) {
var prequest = OAUTH3_CORE.urls.authorizationRedirect(
directive
, opts
);
if (!prequest.state) {
throw new Error("[Devolper Error] [authorization redirect] prequest.state is empty");
}
return browser.frameRequest(prequest.url, prequest.state, opts);
}).then(function (tokens) {
return OAUTH3.hooks.refreshSession(
opts.session || {
provider_uri: providerUri
, client_id: opts.client_id
, client_uri: opts.client_uri || opts.clientUri
}
, tokens
);
});
}
, implicitGrant: function (providerUri, opts) {
// TODO let broker=true change behavior to open discover inline with frameRequest
// TODO OAuth3 provider should use the redirect URI as the appId?
return OAUTH3.discover(providerUri, opts).then(function (directive) {
var prequest = OAUTH3_CORE.urls.implicitGrant(
directive
// TODO OAuth3 provider should referrer / referer / origin as the appId?
, opts
);
if (!prequest.state) {
throw new Error("[Devolper Error] [implicit grant] prequest.state is empty");
}
return browser.frameRequest(prequest.url, prequest.state, opts);
}).then(function (tokens) {
return OAUTH3.hooks.refreshSession(
opts.session || {
provider_uri: providerUri
, client_id: opts.client_id
, client_uri: opts.client_uri || opts.clientUri
}
, tokens
);
});
}
, logout: function (providerUri, opts) {
opts = opts || {};
return OAUTH3.discover(providerUri, opts).then(function (directive) {
var prequest = OAUTH3_CORE.urls.logout(
directive
, opts
);
// Oauth3.init({ logout: function () {} });
//return Oauth3.logout();
var redirectUri = opts.redirect_uri || 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: prequest.state
, debug: opts.debug
};
if (prequest.url === params.redirect_uri) {
return OAUTH3.PromiseA.resolve();
}
prequest.url += '#' + OAUTH3_CORE.querystringify(params);
return OAUTH3.insertIframe(prequest.url, prequest.state, opts);
});
}
}
, isIframe: function isIframe () {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}
, parseUrl: function (url) {
var parser = document.createElement('a');
parser.href = url;
return parser;
}
, isRedirectHostSafe: function (referrerUrl, redirectUrl) {
var src = browser.parseUrl(referrerUrl);
var dst = browser.parseUrl(redirectUrl);
// TODO how should we handle subdomains?
// It should be safe for api.example.com to redirect to example.com
// But it may not be safe for to example.com to redirect to aj.example.com
// It is also probably not safe for sally.example.com to redirect to john.example.com
// The client should have a list of allowed URLs to choose from and perhaps a wildcard will do
//
// api.example.com.evil.com SHOULD NOT match example.com
return dst.hostname === src.hostname;
}
, checkRedirect: function (client, query) {
console.warn("[security] URL path checking not yet implemented");
var clientUrl = OAUTH3.core.normalizeUrl(client.url);
var redirectUrl = OAUTH3.core.normalizeUrl(query.redirect_uri);
// General rule:
// I can callback to a shorter domain (fewer subs) or a shorter path (on the same domain)
// but not a longer (more subs) or different domain or a longer path (on the same domain)
// We can callback to an explicitly listed domain (TODO and path)
if (browser.isRedirectHostSafe(clientUrl, redirectUrl)) {
return true;
}
return false;
}
/*
, redirect: function (redirect) {
if (parser.search) {
parser.search += '&';
} else {
parser.search += '?';
}
parser.search += 'error=E_NO_SESSION';
redirectUri = parser.href;
window.location.href = redirectUri;
}
*/
, hackFormSubmit: function (opts) {
opts = opts || {};
scope.authorizationDecisionUri = DaplieApiConfig.providerUri + '/api/org.oauth3.provider/authorization_decision';
scope.updateScope();
var redirectUri = scope.appQuery.redirect_uri.replace(/^https?:\/\//i, 'https://');
var separator;
// TODO check that we appropriately use '#' for implicit and '?' for code
// (server-side) in an OAuth2 backwards-compatible way
if ('token' === scope.appQuery.response_type) {
separator = '#';
}
else if ('code' === scope.appQuery.response_type) {
separator = '?';
}
else {
separator = '#';
}
if (scope.pendingScope.length && !opts.allow) {
redirectUri += separator + Oauth3.querystringify({
error: 'access_denied'
, error_description: 'None of the permissions were accepted'
, error_uri: 'https://oauth3.org/docs/errors#access_denied'
, state: scope.appQuery.state
});
window.location.href = redirectUri;
return;
}
// TODO move to Oauth3? or not?
// this could be implementation-specific,
// but it may still be nice to provide it as de-facto
var url = DaplieApiConfig.apiBaseUri + '/api/org.oauth3.provider/grants/:client_id/:account_id'
.replace(/:client_id/g, scope.appQuery.client_id || scope.appQuery.client_uri)
.replace(/:account_id/g, scope.selectedAccountId)
;
var account = scope.sessionAccount;
var session = { accessToken: account.token, refreshToken: account.refreshToken };
var preq = {
url: url
, method: 'POST'
, data: {
scope: updateAccepted()
, response_type: scope.appQuery.response_type
, referrer: document.referrer || document.referer || ''
, referer: document.referrer || document.referer || ''
, tenant_id: scope.appQuery.tenant_id
, client_id: scope.appQuery.client_id
, client_uri: scope.appQuery.client_uri
}
, session: session
};
preq.clientId = preq.appId = DaplieApiConfig.appId || DaplieApiConfig.clientId;
preq.clientUri = preq.appUri = DaplieApiConfig.appUri || DaplieApiConfig.clientUri;
// TODO need a way to have middleware in Oauth3.request for TherapySession et al
return Oauth3.request(preq).then(function (resp) {
var err;
var data = resp.data || {};
if (data.error) {
err = new Error(data.error.message || data.errorDescription);
err.message = data.error.message || data.errorDescription;
err.code = resp.data.error.code || resp.data.error;
err.uri = 'https://oauth3.org/docs/errors#' + (resp.data.error.code || resp.data.error);
return $q.reject(err);
}
if (!(data.code || data.accessToken)) {
err = new Error("No grant code");
return $q.reject(err);
}
return data;
}).then(function (data) {
redirectUri += separator + Oauth3.querystringify({
state: scope.appQuery.state
, code: data.code
, access_token: data.access_token
, expires_at: data.expires_at
, expires_in: data.expires_in
, scope: data.scope
, refresh_token: data.refresh_token
, refresh_expires_at: data.refresh_expires_at
, refresh_expires_in: data.refresh_expires_in
});
if ('token' === scope.appQuery.response_type) {
window.location.href = redirectUri;
return;
}
else if ('code' === scope.appQuery.response_type) {
scope.hackFormSubmitHelper(redirectUri);
return;
}
else {
console.warn("Grant Code NOT IMPLEMENTED for '" + scope.appQuery.response_type + "'");
console.warn(redirectUri);
throw new Error("Grant Code NOT IMPLEMENTED for '" + scope.appQuery.response_type + "'");
}
}, function (err) {
redirectUri += separator + Oauth3.querystringify({
error: err.code || 'server_error'
, error_description: err.message || "Server Error: It's not your fault"
, error_uri: err.uri || 'https://oauth3.org/docs/errors#server_error'
, state: scope.appQuery.state
});
console.error('Grant Code Error NOT IMPLEMENTED');
console.error(err);
console.error(redirectUri);
//window.location.href = redirectUri;
});
}
, hackFormSubmitHelper: function (uri) {
// TODO de-jQuerify
//window.location.href = redirectUri;
//return;
// the only way to do a POST that redirects the current window
window.jQuery('form.js-hack-hidden-form').attr('action', uri);
// give time for the apply to take place
window.setTimeout(function () {
window.jQuery('form.js-hack-hidden-form').submit();
}, 50);
}
};
browser.requests = browser.authn;
Object.keys(browser).forEach(function (key) {
if ('requests' === key) {
Object.keys(browser.requests).forEach(function (key) {
OAUTH3.requests[key] = browser.requests[key];
});
return;
}
OAUTH3[key] = browser[key];
});
}('undefined' !== typeof exports ? exports : window));