oauth3.js/oauth3.core.js

591 lines
18 KiB
JavaScript
Raw Normal View History

2017-02-08 05:48:07 +00:00
;(function (exports) {
'use strict';
// NOTE: we assume that directive.provider_uri exists
var core = {};
2017-02-08 09:18:15 +00:00
core.urls = core;
2017-02-01 02:12:31 +00:00
function getDefaultAppApiBase() {
console.warn('[deprecated] using window.location.host when opts.appApiBase should be used');
return 'https://' + window.location.host;
}
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('&');
};
2017-01-23 19:51:34 +00:00
// Modified from http://stackoverflow.com/a/7826782
core.queryparse = function (search) {
2017-01-24 20:10:16 +00:00
// 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);
}
2017-01-23 19:51:34 +00:00
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('=')) {
2017-01-24 20:10:16 +00:00
argsParsed[decodeURIComponent(arg).trim()] = true;
2017-01-23 19:51:34 +00:00
}
else {
2017-01-24 20:10:16 +00:00
kvp = arg.split('=');
key = decodeURIComponent(kvp[0]).trim();
value = decodeURIComponent(kvp[1]).trim();
argsParsed[key] = value;
2017-01-23 19:51:34 +00:00
}
}
return argsParsed;
};
2017-02-08 05:48:07 +00:00
core.formatError = function (providerUri, params) {
2017-02-08 09:18:15 +00:00
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;
2017-02-08 05:48:07 +00:00
return err;
};
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(/\/?$/, '')
;
};
2017-02-08 09:18:15 +00:00
core.urls.discover = function (providerUri, opts) {
2017-02-01 02:12:31 +00:00
if (!providerUri) {
throw new Error("cannot discover without providerUri");
}
if (!opts.appUrl) {
throw new Error("cannot discover without opts.appUrl");
}
var params = {
action: 'directives'
, state: core.utils.randomState()
2017-02-01 02:12:31 +00:00
, redirect_uri: opts.appUrl + (opts.appCallbackPath || '/.well-known/oauth3/callback.html')
2017-02-06 21:26:59 +00:00
, 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
2017-02-01 02:12:31 +00:00
};
return result;
2017-02-01 02:12:31 +00:00
};
2017-01-24 20:16:21 +00:00
// these might not really belong in core... not sure
// there should be node.js- and browser-specific versions probably
2017-01-24 20:10:16 +00:00
core.utils = {
urlSafeBase64ToBase64: function (b64) {
// URL-safe Base64 to Base64
2017-02-07 23:31:05 +00:00
// 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 += '='; }
2017-01-24 20:10:16 +00:00
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;
}
}
2017-01-24 20:10:16 +00:00
};
core.jwt = {
// decode only (no verification)
decode: function (str) {
// 'abc.qrs.xyz'
// [ 'abc', 'qrs', 'xyz' ]
// [ {}, {}, 'foo' ]
// { header: {}, payload: {}, signature: }
var parts = str.split(/\./g);
2017-02-07 23:31:05 +00:00
var jsons = parts.slice(0, 2).map(function (urlsafe64) {
2017-01-24 20:10:16 +00:00
var atob = exports.atob || require('atob');
2017-02-07 23:31:05 +00:00
var b64 = core.utils.urlSafeBase64ToBase64(urlsafe64);
return atob(b64);
2017-01-24 20:10:16 +00:00
});
return {
header: JSON.parse(jsons[0])
, payload: JSON.parse(jsons[1])
2017-01-24 20:16:21 +00:00
, signature: parts[2] // should remain url-safe base64
2017-01-24 20:10:16 +00:00
};
}
2017-02-08 09:18:15 +00:00
, getFreshness: function (meta, staletime, now) {
staletime = staletime || (15 * 60);
now = now || Date.now();
2017-02-08 19:28:56 +00:00
var fresh = ((parseInt(meta.exp, 10) || 0) - Math.round(now / 1000));
2017-02-08 09:18:15 +00:00
if (fresh >= staletime) {
return 'fresh';
}
if (fresh <= 0) {
return 'expired';
}
return 'stale';
}
2017-01-24 20:10:16 +00:00
// encode-only (no signature)
, encode: function (parts) {
parts.header = parts.header || { alg: 'none', typ: 'jwt' };
parts.signature = parts.signature || '';
2017-01-24 20:16:21 +00:00
var btoa = exports.btoa || require('btoa');
2017-01-24 20:10:16 +00:00
var result = [
2017-01-24 20:16:21 +00:00
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
2017-01-24 20:10:16 +00:00
].join('.');
return result;
}
};
2017-02-08 09:18:15 +00:00
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");
};
2017-02-08 09:18:15 +00:00
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):\/\//, ''));
2017-02-07 17:23:30 +00:00
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?
2017-02-01 02:12:31 +00:00
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?
};
};
2017-02-08 09:18:15 +00:00
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.redirectUri;
var scope = opts.scope || directive.authn_scope;
2017-02-08 02:24:44 +00:00
var clientId = opts.appId || opts.clientId || opts.clientUri;
var args = directive[type];
var uri = args.url;
var state = core.utils.randomState();
var params = {
debug: opts.debug || undefined
2017-02-08 02:24:44 +00:00
, client_uri: opts.client_uri || opts.clientUri || undefined
};
var loc;
var result;
params.state = state;
params.response_type = responseType;
if (scope) {
params.scope = core.stringifyscope(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 += '?' + core.querystringify(params);
result = {
url: uri
, state: state
, method: args.method
, query: params
};
return result;
};
2017-02-08 09:18:15 +00:00
core.urls.loginCode = function (directive, opts) {
//
// Example Resource Owner Password Request
// (generally for 1st party and direct-partner mobile apps, and webapps)
//
// POST https://api.example.com/api/org.oauth3.provider/otp
// { "request_otp": true, "client_id": "<<id>>", "scope": "<<scope>>"
// , "username": "<<username>>" }
//
opts = opts || {};
var clientId = opts.appId || opts.clientId;
var args = directive.otp;
if (!directive.otp) {
console.log('[debug] loginCode directive:');
console.log(directive);
}
var params = {
"username": opts.id || opts.username
, "request_otp": true // opts.requestOtp || undefined
//, "jwt": opts.jwt // TODO sign a proof
, debug: opts.debug || undefined
};
var uri = args.url;
var body;
if (opts.clientUri) {
params.client_uri = opts.clientUri;
}
if (opts.clientAgreeTos) {
params.client_agree_tos = opts.clientAgreeTos;
}
if (clientId) {
params.client_id = clientId;
}
if ('GET' === args.method.toUpperCase()) {
uri += '?' + core.querystringify(params);
} else {
body = params;
}
return {
url: uri
, method: args.method
, data: body
};
};
2017-02-08 09:18:15 +00:00
core.urls.resourceOwnerPassword = function (directive, 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": "<<id>>", "scope": "<<scope>>"
// , "username": "<<username>>", "password": "password" }
//
opts = opts || {};
var type = 'access_token';
var grantType = 'password';
2017-02-07 17:23:30 +00:00
if (!opts.password) {
2017-01-18 09:31:00 +00:00
if (opts.otp) {
// for backwards compat
2017-02-07 17:23:30 +00:00
opts.password = opts.otp; // 'otp:' + opts.otpUuid + ':' + opts.otp;
2017-01-18 09:31:00 +00:00
}
}
var scope = opts.scope || directive.authn_scope;
2017-02-07 17:23:30 +00:00
var clientId = opts.appId || opts.clientId || opts.client_id;
var clientAgreeTos = opts.clientAgreeTos || opts.client_agree_tos;
var clientUri = opts.clientUri || opts.client_uri || opts.clientUrl || opts.client_url;
var args = directive[type];
2017-02-07 19:04:29 +00:00
var otpCode = opts.otp || opts.otpCode || opts.otp_code || opts.otpToken || opts.otp_token || undefined;
var params = {
"grant_type": grantType
2017-02-07 17:23:30 +00:00
, "username": opts.username
2017-02-07 19:04:29 +00:00
, "password": opts.password || otpCode || undefined
2017-02-07 17:23:30 +00:00
, "totp": opts.totp || opts.totpToken || opts.totp_token || undefined
2017-02-07 19:04:29 +00:00
, "otp": otpCode
, "otp_code": otpCode
2017-02-07 17:23:30 +00:00
, "otp_uuid": opts.otpUuid || opts.otp_uuid || undefined
, "user_agent": opts.userAgent || opts.useragent || opts.user_agent || undefined // AJ's Macbook
, "jwk": (opts.rememberDevice || opts.remember_device) && opts.jwk || undefined
//, "public_key": opts.rememberDevice && opts.publicKey || undefined
//, "public_key_type": opts.rememberDevice && opts.publicKeyType || undefined // RSA/ECDSA
//, "jwt": opts.jwt // TODO sign a proof with a previously loaded public_key
, debug: opts.debug || undefined
};
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) {
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
};
};
2017-02-08 09:18:15 +00:00
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
2017-02-08 09:18:15 +00:00
, "refresh_token": opts.refresh_token || opts.refreshToken || (opts.session && opts.session.refresh_token)
, "response_type": 'token'
2017-02-08 09:18:15 +00:00
, "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;
2017-02-08 09:18:15 +00:00
// 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
};
};
2017-02-08 09:18:15 +00:00
core.urls.logout = function (directive, opts) {
2017-02-08 05:48:07 +00:00
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));