From 9448ffea6fc57f5050d2ff6ab4439e3feafc256c Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Thu, 9 Feb 2017 21:51:22 -0500 Subject: [PATCH] WIP provider separation, grant flow --- oauth3.browser.js | 16 ++-- oauth3.core.js | 181 +++++++------------------------------ oauth3.core.provider.js | 196 ++++++++++++++++++++++++++++++++++++++++ oauth3.js | 20 ++-- oauth3.scope-check.js | 24 +++++ 5 files changed, 273 insertions(+), 164 deletions(-) create mode 100644 oauth3.core.provider.js create mode 100644 oauth3.scope-check.js diff --git a/oauth3.browser.js b/oauth3.browser.js index 8ba8f02..0704115 100644 --- a/oauth3.browser.js +++ b/oauth3.browser.js @@ -63,11 +63,11 @@ opts = opts || {}; var promise = new OAUTH3.PromiseA(function (resolve, reject) { var tok; - var $iframe; + var iframeDiv; function cleanup() { delete window['--oauth3-callback-' + state]; - $iframe.remove(); + iframeDiv.remove(); clearTimeout(tok); tok = null; } @@ -94,7 +94,9 @@ } framesrc += '>'; - $('body').append(framesrc); + iframeDiv = window.document.createElement('div'); + iframeDiv.innerHTML = framesrc; + window.document.body.appendChild(iframeDiv); }); // TODO periodically garbage collect expired handlers from window object @@ -299,7 +301,7 @@ , error_uri: 'https://oauth3.org/docs/errors#access_denied' , state: scope.appQuery.state }); - $window.location.href = redirectUri; + window.location.href = redirectUri; return; } @@ -366,7 +368,7 @@ }); if ('token' === scope.appQuery.response_type) { - $window.location.href = redirectUri; + window.location.href = redirectUri; return; } else if ('code' === scope.appQuery.response_type) { @@ -389,13 +391,13 @@ console.error('Grant Code Error NOT IMPLEMENTED'); console.error(err); console.error(redirectUri); - //$window.location.href = redirectUri; + //window.location.href = redirectUri; }); } , hackFormSubmitHelper: function (uri) { // TODO de-jQuerify - //$window.location.href = redirectUri; + //window.location.href = redirectUri; //return; // the only way to do a POST that redirects the current window diff --git a/oauth3.core.js b/oauth3.core.js index 1e8c276..0eedf01 100644 --- a/oauth3.core.js +++ b/oauth3.core.js @@ -82,6 +82,9 @@ 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 @@ -105,34 +108,6 @@ ; }; - core.urls.discover = function (providerUri, opts) { - 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() - , redirect_uri: opts.appUrl + (opts.appCallbackPath || '/.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; - }; - // these might not really belong in core... not sure // there should be node.js- and browser-specific versions probably core.utils = { @@ -228,6 +203,33 @@ } }; + core.urls.discover = function (providerUri, opts) { + 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() + , redirect_uri: opts.appUrl + (opts.appCallbackPath || '/.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 @@ -373,126 +375,11 @@ return result; }; - 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": "<>", "scope": "<>" - // , "username": "<>" } - // - opts = opts || {}; - var clientId = opts.appId || opts.clientId; - - var args = directive.otp; - if (!directive.otp) { - console.log('[debug] loginCode directive:'); - console.log(directive); + core.urls.resolve = function (base, next) { + if (/^https:\/\//i.test(next)) { + return next; } - 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 - }; - }; - - 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": "<>", "scope": "<>" - // , "username": "<>", "password": "password" } - // - opts = opts || {}; - var type = 'access_token'; - var grantType = 'password'; - - if (!opts.password) { - if (opts.otp) { - // for backwards compat - opts.password = opts.otp; // 'otp:' + opts.otpUuid + ':' + opts.otp; - } - } - - var scope = opts.scope || directive.authn_scope; - 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]; - var otpCode = opts.otp || opts.otpCode || opts.otp_code || opts.otpToken || opts.otp_token || undefined; - var params = { - "grant_type": grantType - , "username": opts.username - , "password": opts.password || otpCode || undefined - , "totp": opts.totp || opts.totpToken || opts.totp_token || undefined - , "otp": otpCode - , "otp_code": otpCode - , "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 - }; + return core.normalizeUrl(base) + '/' + core.normalizePath(next); }; core.urls.refreshToken = function (directive, opts) { diff --git a/oauth3.core.provider.js b/oauth3.core.provider.js new file mode 100644 index 0000000..7cc3be9 --- /dev/null +++ b/oauth3.core.provider.js @@ -0,0 +1,196 @@ +;(function (exports) { + 'use strict'; + + var core = window.OAUTH3_CORE; + + // Provider-Only + 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": "<>", "scope": "<>" + // , "username": "<>" } + // + opts = opts || {}; + var clientId = opts.appId || opts.clientId; + + var args = directive.credential_otp; + if (!directive.credential_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 + }; + }; + + 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": "<>", "scope": "<>" + // , "username": "<>", "password": "password" } + // + opts = opts || {}; + var type = 'access_token'; + var grantType = 'password'; + + if (!opts.password) { + if (opts.otp) { + // for backwards compat + opts.password = opts.otp; // 'otp:' + opts.otpUuid + ':' + opts.otp; + } + } + + var scope = opts.scope || directive.authn_scope; + 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]; + var otpCode = opts.otp || opts.otpCode || opts.otp_code || opts.otpToken || opts.otp_token || undefined; + var params = { + "grant_type": grantType + , "username": opts.username + , "password": opts.password || otpCode || undefined + , "totp": opts.totp || opts.totpToken || opts.totp_token || undefined + , "otp": otpCode + , "password_type": otpCode && 'otp' + , "otp_code": otpCode + , "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 + }; + }; + + + core.urls.grants = function (directive, opts) { + // directive = { issuer, authorization_decision } + // opts = { response_type, scopes{ granted, requested, pending, accepted } } + + if (!opts) { + throw new Error("You must supply a directive and an options object."); + } + if (!opts.client_id) { + throw new Error("You must supply options.client_id."); + } + if (!opts.session) { + throw new Error("You must supply options.session."); + } + if (!opts.referrer) { + console.warn("You should supply options.referrer"); + } + if (!opts.method) { + console.warn("You must supply options.method as either 'GET', or 'POST'"); + } + if ('POST' === opts.method && !opts.scope) { + console.warn("You must supply options.scope as a space-delimited string of scopes"); + } + + var url = core.urls.resolve(directive.issuer, directive.grants.url) + .replace(/(:azp|:client_id)/g, opts.client_id || opts.client_uri) + .replace(/(:sub|:account_id)/g, opts.session.meta.sub) + ; + var data = { + client_id: opts.client_id + , client_uri: opts.client_uri + , referrer: opts.referrer + , response_type: opts.response_type + , scope: opts.scope + , tenant_id: opts.tenant_id + }; + var body; + + if ('GET' === opts.method) { + url += '?' + core.querystringify(data); + } + else { + body = data; + } + + return { + method: opts.method + , url: url + , data: body + , session: opts.session + }; + }; + core.urls.authorizationDecision = function (directive, opts) { + var url = core.urls.resolve(directive.issuer, directive.authorization_decision.url); + if (!opts) { + throw new Error("You must supply a directive and an options object"); + } + console.info(url); + throw new Error("NOT IMPLEMENTED authorization_decision"); + }; + + exports.OAUTH3_CORE_PROVIDER = core; + + if ('undefined' !== typeof module) { + module.exports = core; + } +}('undefined' !== typeof exports ? exports : window)); diff --git a/oauth3.js b/oauth3.js index 3089181..f2b8487 100644 --- a/oauth3.js +++ b/oauth3.js @@ -151,6 +151,8 @@ function lintAndRequest(preq) { function goGetHer() { if (preq.session) { + // TODO check session.meta.aud against preq.url to make sure they match + console.warn("[security] session audience checking has not been implemented yet (it's up to you to check)"); preq.headers = preq.headers || {}; preq.headers.Authorization = 'Bearer ' + (preq.session.access_token || preq.session.accessToken); } @@ -198,6 +200,12 @@ */ }; + oauth3.requests.grants = function (providerUri, opts) { + return oauth3.discover(providerUri, opts).then(function (directive) { + console.log('core.urls.grants(directive, opts)', core.urls.grants(directive, opts)); + return oauth3.request(core.urls.grants(directive, opts)); + }); + }; oauth3.requests.loginCode = function (providerUri, opts) { return oauth3.discover(providerUri, opts).then(function (directive) { var prequest = core.urls.loginCode(directive, opts); @@ -219,11 +227,7 @@ return oauth3.discover(providerUri, opts).then(function (directive) { var prequest = core.urls.resourceOwnerPassword(directive, opts); - return oauth3.request({ - url: prequest.url - , method: prequest.method - , data: prequest.data - }).then(function (req) { + return oauth3.request(prequest).then(function (req) { var data = (req.originalData || req.data); data.provider_uri = providerUri; if (data.error) { @@ -243,11 +247,7 @@ return oauth3.discover(providerUri, opts).then(function (directive) { var prequest = core.urls.refreshToken(directive, opts); - return oauth3.request({ - url: prequest.url - , method: prequest.method - , data: prequest.data - }).then(function (req) { + return oauth3.request(prequest).then(function (req) { var data = (req.originalData || req.data); data.provider_uri = providerUri; if (data.error) { diff --git a/oauth3.scope-check.js b/oauth3.scope-check.js new file mode 100644 index 0000000..257cfe5 --- /dev/null +++ b/oauth3.scope-check.js @@ -0,0 +1,24 @@ + 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; + }