From 35b09e7d0ead34c14d804035fb7dc9d42ad78774 Mon Sep 17 00:00:00 2001 From: Nathan Muir Date: Mon, 16 Oct 2023 15:43:39 +1300 Subject: [PATCH] rework `OAuth`, `Accounts.oauth` --- .../meteor-runtime/meteor-runtime-browser.js | 1 - .../meteor-runtime/meteor-runtime-node.js | 2 +- packages/accounts-oauth/oauth_client.js | 10 +- packages/accounts-oauth/oauth_common.js | 8 +- packages/accounts-oauth/oauth_server.js | 6 +- packages/oauth/oauth_browser.js | 4 +- packages/oauth/oauth_client.js | 44 ++-- packages/oauth/oauth_common.js | 4 +- packages/oauth/oauth_server.js | 222 +++++++++++------- packages/oauth/pending_credentials.js | 31 ++- rollup.config.mjs | 13 +- 11 files changed, 209 insertions(+), 136 deletions(-) diff --git a/_prototype/meteor-runtime/meteor-runtime-browser.js b/_prototype/meteor-runtime/meteor-runtime-browser.js index 513d368115e..12ed875e487 100644 --- a/_prototype/meteor-runtime/meteor-runtime-browser.js +++ b/_prototype/meteor-runtime/meteor-runtime-browser.js @@ -13,7 +13,6 @@ export { } from './src/browser/minimongo/minimongo_client.js'; export { Mongo } from './src/browser/mongo/client_main.js'; export { MongoID } from './src/browser/mongo-id/id.js'; -export { OAuth } from './src/browser/oauth/oauth_client.js'; // export { Promise } from './src/browser/promise/client.js'; export { Random } from './src/browser/random/main_client.js'; export { ReactiveDict } from './src/browser/reactive-dict/reactive-dict-client.js'; diff --git a/_prototype/meteor-runtime/meteor-runtime-node.js b/_prototype/meteor-runtime/meteor-runtime-node.js index e1fa31592c2..0a62e50a848 100644 --- a/_prototype/meteor-runtime/meteor-runtime-node.js +++ b/_prototype/meteor-runtime/meteor-runtime-node.js @@ -1,6 +1,7 @@ export { Accounts } from './src/node/accounts-base/server_main.js'; import './src/node/accounts-password/password_server.js'; import './src/node/accounts-oauth/oauth_server.js'; +import './src/node/accounts-google/google_server.js'; export { check, Match } from './src/node/check/match.js'; export { DDP } from './src/node/ddp-client/server/server.js'; export { DDPRateLimiter } from './src/node/ddp-rate-limiter/ddp-rate-limiter.js'; @@ -16,7 +17,6 @@ export { } from './src/node/minimongo/minimongo_server.js'; export { Mongo } from './src/node/mongo/server_main.js'; export { MongoID } from './src/node/mongo-id/id.js'; -export { OAuth } from './src/node/oauth/oauth_server.js'; import './src/node/oauth2/oauth2_server.js'; export { Promise } from './src/node/promise/server.js'; export { Random } from './src/node/random/main_server.js'; diff --git a/packages/accounts-oauth/oauth_client.js b/packages/accounts-oauth/oauth_client.js index d38126367a1..296ebbc3758 100644 --- a/packages/accounts-oauth/oauth_client.js +++ b/packages/accounts-oauth/oauth_client.js @@ -1,4 +1,4 @@ -import './oauth_common.js'; +export * from './oauth_common.js' // Documentation for Meteor.loginWithExternalService /** @@ -72,7 +72,7 @@ Meteor.startup(() => { // Send an OAuth login method to the server. If the user authorized // access in the popup this should log the user in, otherwise // nothing should happen. -Accounts.oauth.tryLoginAfterPopupClosed = ( +export const tryLoginAfterPopupClosed = ( credentialToken, callback, shouldRetry = true @@ -89,7 +89,7 @@ Accounts.oauth.tryLoginAfterPopupClosed = ( } Meteor.setTimeout( () => - Accounts.oauth.tryLoginAfterPopupClosed( + tryLoginAfterPopupClosed( credentialToken, callback, false @@ -105,12 +105,12 @@ Accounts.oauth.tryLoginAfterPopupClosed = ( }); }; -Accounts.oauth.credentialRequestCompleteHandler = callback => +export const credentialRequestCompleteHandler = callback => credentialTokenOrError => { if(credentialTokenOrError && credentialTokenOrError instanceof Error) { callback && callback(credentialTokenOrError); } else { - Accounts.oauth.tryLoginAfterPopupClosed(credentialTokenOrError, callback); + tryLoginAfterPopupClosed(credentialTokenOrError, callback); } } diff --git a/packages/accounts-oauth/oauth_common.js b/packages/accounts-oauth/oauth_common.js index 410bbdff812..c2fc032a300 100644 --- a/packages/accounts-oauth/oauth_common.js +++ b/packages/accounts-oauth/oauth_common.js @@ -17,14 +17,12 @@ const VALID_CONFIG_KEYS = [ 'tokenSequenceLength', ]; -Accounts.oauth = {}; - const services = {}; const hasOwn = Object.prototype.hasOwnProperty; // Helper for registering OAuth based accounts packages. // On the server, adds an index to the user collection. -Accounts.oauth.registerService = name => { +export const registerService = name => { if (hasOwn.call(services, name)) throw new Error(`Duplicate service: ${name}`); services[name] = true; @@ -43,13 +41,13 @@ Accounts.oauth.registerService = name => { // contain it. // It's worth noting that already logged in users will remain logged in unless // you manually expire their sessions. -Accounts.oauth.unregisterService = name => { +export const unregisterService = name => { if (!hasOwn.call(services, name)) throw new Error(`Service not found: ${name}`); delete services[name]; }; -Accounts.oauth.serviceNames = () => Object.keys(services); +export const serviceNames = () => Object.keys(services); // loginServiceConfiguration and ConfigError are maintained for backwards compatibility /*Meteor.startup(() => { diff --git a/packages/accounts-oauth/oauth_server.js b/packages/accounts-oauth/oauth_server.js index caf01f679f8..451cfc84f74 100644 --- a/packages/accounts-oauth/oauth_server.js +++ b/packages/accounts-oauth/oauth_server.js @@ -1,4 +1,6 @@ -import './oauth_common.js'; +import { serviceNames } from './oauth_common.js'; + +export * from './oauth_common.js'; // Listen to calls to `login` with an oauth option set. This is where // users actually get logged in to meteor via oauth. Accounts.registerLoginHandler(options => { @@ -43,7 +45,7 @@ Accounts.registerLoginHandler(options => { // to the user. throw result; else { - if (! Accounts.oauth.serviceNames().includes(result.serviceName)) { + if (! serviceNames().includes(result.serviceName)) { // serviceName was not found in the registered services list. // This could happen because the service never registered itself or // unregisterService was called on it. diff --git a/packages/oauth/oauth_browser.js b/packages/oauth/oauth_browser.js index 884e3b65357..3fe2cbeb707 100644 --- a/packages/oauth/oauth_browser.js +++ b/packages/oauth/oauth_browser.js @@ -8,7 +8,7 @@ // arguments. // @param dimensions {optional Object(width, height)} The dimensions of // the popup. If not passed defaults to something sane. -OAuth.showPopup = (url, callback, dimensions) => { +export const OAuth$showPopup = (url, callback, dimensions) => { // default dimensions that worked well for facebook and google const popup = openCenteredPopup( url, @@ -68,6 +68,6 @@ const openCenteredPopup = function(url, width, height) { if (newwindow.focus) newwindow.focus(); - + return newwindow; }; diff --git a/packages/oauth/oauth_client.js b/packages/oauth/oauth_client.js index fde76414567..7ec331e9bb7 100644 --- a/packages/oauth/oauth_client.js +++ b/packages/oauth/oauth_client.js @@ -1,18 +1,15 @@ +import { OAuth$showPopup } from "./oauth_browser.js"; +import { OAuth$_storageTokenPrefix, OAuth$_redirectUri } from "./oauth_common.js"; + // credentialToken -> credentialSecret. You must provide both the // credentialToken and the credentialSecret to retrieve an access token from // the _pendingCredentials collection. const credentialSecrets = {}; -export const OAuth = {}; - -OAuth.showPopup = (url, callback, dimensions) => { - throw new Error("OAuth.showPopup must be implemented on this arch."); -}; - // Determine the login style (popup or redirect) for this login flow. // // -OAuth._loginStyle = (service, config, options) => { +const OAuth$_loginStyle = (service, config, options) => { if (Meteor.isCordova) { return "popup"; @@ -38,7 +35,7 @@ OAuth._loginStyle = (service, config, options) => { return loginStyle; }; -OAuth._stateParam = (loginStyle, credentialToken, redirectUrl) => { +const OAuth$_stateParam = (loginStyle, credentialToken, redirectUrl) => { const state = { loginStyle, credentialToken, @@ -62,7 +59,7 @@ OAuth._stateParam = (loginStyle, credentialToken, redirectUrl) => { // the login service, save the credential token for this login attempt // in the reload migration data. // -OAuth.saveDataForRedirect = (loginService, credentialToken) => { +const OAuth$saveDataForRedirect = (loginService, credentialToken) => { Reload._onMigrate('oauth', () => [true, { loginService, credentialToken }]); Reload._migrate(null, {immediateMigration: true}); }; @@ -75,14 +72,14 @@ OAuth.saveDataForRedirect = (loginService, credentialToken) => { // application startup and we weren't just redirected at the end of // the login flow. // -OAuth.getDataAfterRedirect = () => { +const OAuth$getDataAfterRedirect = () => { const migrationData = Reload._migrationData('oauth'); if (! (migrationData && migrationData.credentialToken)) return null; const { credentialToken } = migrationData; - const key = OAuth._storageTokenPrefix + credentialToken; + const key = OAuth$_storageTokenPrefix + credentialToken; let credentialSecret; try { credentialSecret = sessionStorage.getItem(key); @@ -110,16 +107,16 @@ OAuth.getDataAfterRedirect = () => { // is closed and we have the credential from the login service. // credentialToken: our identifier for this login flow. // -OAuth.launchLogin = options => { +const OAuth$launchLogin = options => { if (! options.loginService) throw new Error('loginService required'); if (options.loginStyle === 'popup') { - OAuth.showPopup( + OAuth$showPopup( options.loginUrl, options.credentialRequestCompleteCallback.bind(null, options.credentialToken), options.popupOptions); } else if (options.loginStyle === 'redirect') { - OAuth.saveDataForRedirect(options.loginService, options.credentialToken); + OAuth$saveDataForRedirect(options.loginService, options.credentialToken); window.location = options.loginUrl; } else { throw new Error('invalid login style'); @@ -128,7 +125,7 @@ OAuth.launchLogin = options => { // Called by the popup when the OAuth flow is completed, right before // the popup closes. -OAuth._handleCredentialSecret = (credentialToken, secret) => { +const OAuth$_handleCredentialSecret = (credentialToken, secret) => { check(credentialToken, String); check(secret, String); if (! Object.prototype.hasOwnProperty.call(credentialSecrets, credentialToken)) { @@ -140,13 +137,13 @@ OAuth._handleCredentialSecret = (credentialToken, secret) => { // Used by accounts-oauth, which needs both a credentialToken and the // corresponding to credential secret to call the `login` method over DDP. -OAuth._retrieveCredentialSecret = credentialToken => { +const OAuth$_retrieveCredentialSecret = credentialToken => { // First check the secrets collected by OAuth._handleCredentialSecret, // then check localStorage. This matches what we do in // end_of_login_response.html. let secret = credentialSecrets[credentialToken]; if (! secret) { - const localStorageKey = OAuth._storageTokenPrefix + credentialToken; + const localStorageKey = OAuth$_storageTokenPrefix + credentialToken; secret = Meteor._localStorage.getItem(localStorageKey); Meteor._localStorage.removeItem(localStorageKey); } else { @@ -154,3 +151,16 @@ OAuth._retrieveCredentialSecret = credentialToken => { } return secret; }; + +export { + OAuth$showPopup as showPopup, + OAuth$_loginStyle as _loginStyle, + OAuth$_stateParam as _stateParam, + OAuth$saveDataForRedirect as saveDataForRedirect, + OAuth$getDataAfterRedirect as getDataAfterRedirect, + OAuth$launchLogin as launchLogin, + OAuth$_handleCredentialSecret as _handleCredentialSecret, + OAuth$_retrieveCredentialSecret as _retrieveCredentialSecret, + OAuth$_storageTokenPrefix as _storageTokenPrefix, + OAuth$_redirectUri as _redirectUri, +} diff --git a/packages/oauth/oauth_common.js b/packages/oauth/oauth_common.js index a36f30e8ee9..5efe1964b1b 100644 --- a/packages/oauth/oauth_common.js +++ b/packages/oauth/oauth_common.js @@ -1,8 +1,8 @@ import { constructUrl } from 'meteor/url'; -OAuth._storageTokenPrefix = "Meteor.oauth.credentialSecret-"; +export const OAuth$_storageTokenPrefix = "Meteor.oauth.credentialSecret-"; -OAuth._redirectUri = (serviceName, config, params, absoluteUrlOptions) => { +export const OAuth$_redirectUri = (serviceName, config, params, absoluteUrlOptions) => { // Clone because we're going to mutate 'params'. The 'cordova' and // 'android' parameters are only used for picking the host of the // redirect URL, and not actually included in the redirect URL itself. diff --git a/packages/oauth/oauth_server.js b/packages/oauth/oauth_server.js index c68fcff485e..727419eb369 100644 --- a/packages/oauth/oauth_server.js +++ b/packages/oauth/oauth_server.js @@ -1,7 +1,9 @@ +import crypto from 'crypto'; import bodyParser from 'body-parser'; +import { OAuth$_storageTokenPrefix, OAuth$_redirectUri } from "./oauth_common.js"; +import { _storePendingCredential, _retrievePendingCredential } from './pending_credentials.js'; -export const OAuth = {}; -export const OAuthTest = {}; +export * from './pending_credentials.js'; RoutePolicy.declare('/_oauth/', 'network'); @@ -10,7 +12,7 @@ const registeredServices = {}; // Internal: Maps from service version to handler function. The // 'oauth1' and 'oauth2' packages manipulate this directly to register // for callbacks. -OAuth._requestHandlers = {}; +const OAuth$_requestHandlers = {}; /** @@ -30,7 +32,7 @@ OAuth._requestHandlers = {}; /* up in the user's services[name] field /* - `null` if the user declined to give permissions */ -OAuth.registerService = (name, version, urls, handleOauthRequest) => { +const OAuth$registerService = (name, version, urls, handleOauthRequest) => { if (registeredServices[name]) throw new Error(`Already registered the ${name} OAuth service`); @@ -42,27 +44,18 @@ OAuth.registerService = (name, version, urls, handleOauthRequest) => { }; }; -// For test cleanup. -OAuthTest.unregisterService = name => { - delete registeredServices[name]; -}; - - -OAuth.retrieveCredential = (credentialToken, credentialSecret) => - OAuth._retrievePendingCredential(credentialToken, credentialSecret); - // The state parameter is normally generated on the client using // `btoa`, but for tests we need a version that runs on the server. // -OAuth._generateState = (loginStyle, credentialToken, redirectUrl) => { +const OAuth$_generateState = (loginStyle, credentialToken, redirectUrl) => { return Buffer.from(JSON.stringify({ loginStyle: loginStyle, credentialToken: credentialToken, redirectUrl: redirectUrl})).toString('base64'); }; -OAuth._stateFromQuery = query => { +const OAuth$_stateFromQuery = query => { let string; try { string = Buffer.from(query.state, 'base64').toString('binary'); @@ -79,13 +72,13 @@ OAuth._stateFromQuery = query => { } }; -OAuth._loginStyleFromQuery = query => { +const OAuth$_loginStyleFromQuery = query => { let style; // For backwards-compatibility for older clients, catch any errors // that result from parsing the state parameter. If we can't parse it, // set login style to popup by default. try { - style = OAuth._stateFromQuery(query).loginStyle; + style = OAuth$_stateFromQuery(query).loginStyle; } catch (err) { style = "popup"; } @@ -95,23 +88,23 @@ OAuth._loginStyleFromQuery = query => { return style; }; -OAuth._credentialTokenFromQuery = query => { +const OAuth$_credentialTokenFromQuery = query => { let state; // For backwards-compatibility for older clients, catch any errors // that result from parsing the state parameter. If we can't parse it, // assume that the state parameter's value is the credential token, as // it used to be for older clients. try { - state = OAuth._stateFromQuery(query); + state = OAuth$_stateFromQuery(query); } catch (err) { return query.state; } return state.credentialToken; }; -OAuth._isCordovaFromQuery = query => { +const OAuth$_isCordovaFromQuery = query => { try { - return !! OAuth._stateFromQuery(query).isCordova; + return !! OAuth$_stateFromQuery(query).isCordova; } catch (err) { // For backwards-compatibility for older clients, catch any errors // that result from parsing the state parameter. If we can't parse @@ -125,7 +118,7 @@ OAuth._isCordovaFromQuery = query => { // We export this function so that developers can override this // behavior to allow apps from external domains to login using the // redirect OAuth flow. -OAuth._checkRedirectUrlOrigin = redirectUrl => { +const OAuth$_checkRedirectUrlOrigin = redirectUrl => { const appHost = Meteor.absoluteUrl(); const appHostReplacedLocalhost = Meteor.absoluteUrl(undefined, { replaceLocalhost: true @@ -152,15 +145,25 @@ const middleware = async (req, res, next) => { const service = registeredServices[serviceName]; // Skip everything if there's no service set by the oauth middleware - if (!service) - throw new Error(`Unexpected OAuth service ${serviceName}`); + if (!service) { + res.writeHead(500, { + 'Content-Type': 'text/plain' + }); + res.end(`Unexpected OAuth service`) + return + } // Make sure we're configured ensureConfigured(serviceName); - const handler = OAuth._requestHandlers[service.version]; - if (!handler) - throw new Error(`Unexpected OAuth version ${service.version}`); + const handler = OAuth$_requestHandlers[service.version]; + if (!handler) { + res.writeHead(500, { + 'Content-Type': 'text/plain' + }); + res.end(`Unexpected OAuth version`) + return + } if (req.method === 'GET') { requestData = req.query; @@ -179,7 +182,7 @@ const middleware = async (req, res, next) => { // style the error or react to it in any way. if (requestData?.state && err instanceof Error) { try { // catch any exceptions to avoid crashing runner - OAuth._storePendingCredential(OAuth._credentialTokenFromQuery(requestData), err); + _storePendingCredential(OAuth$_credentialTokenFromQuery(requestData), err); } catch (err) { // Ignore the error and just give up. If we failed to store the // error, then the login will just fail with a generic error. @@ -193,9 +196,9 @@ const middleware = async (req, res, next) => { // think to check server logs (we hope?) // Catch errors because any exception here will crash the runner. try { - OAuth._endOfLoginResponse(res, { + OAuth$_endOfLoginResponse(res, { query: requestData, - loginStyle: OAuth._loginStyleFromQuery(requestData), + loginStyle: OAuth$_loginStyleFromQuery(requestData), error: err }); } catch (err) { @@ -210,8 +213,6 @@ WebApp.connectHandlers.use('/_oauth', bodyParser.json()); WebApp.connectHandlers.use('/_oauth', bodyParser.urlencoded({ extended: false })); WebApp.connectHandlers.use(middleware); -OAuthTest.middleware = middleware; - // Handle /_oauth/* paths and extract the service name. // // @returns {String|null} e.g. "facebook", or null if this isn't an @@ -251,7 +252,7 @@ const isSafe = value => { }; // Internal: used by the oauth1 and oauth2 packages -OAuth._renderOauthResults = (res, query, credentialSecret) => { +const OAuth$_renderOauthResults = (res, query, credentialSecret) => { // For tests, we support the `only_credential_secret_for_test` // parameter, which just returns the credential secret without any // surrounding HTML. (The test needs to be able to easily grab the @@ -267,12 +268,12 @@ OAuth._renderOauthResults = (res, query, credentialSecret) => { } else { const details = { query, - loginStyle: OAuth._loginStyleFromQuery(query) + loginStyle: OAuth$_loginStyleFromQuery(query) }; if (query.error) { details.error = query.error; } else { - const token = OAuth._credentialTokenFromQuery(query); + const token = OAuth$_credentialTokenFromQuery(query); const secret = credentialSecret; if (token && secret && isSafe(token) && isSafe(secret)) { @@ -282,16 +283,64 @@ OAuth._renderOauthResults = (res, query, credentialSecret) => { } } - OAuth._endOfLoginResponse(res, details); + OAuth$_endOfLoginResponse(res, details); } }; // This "template" (not a real Spacebars template, just an HTML file // with some ##PLACEHOLDER##s) communicates the credential secret back // to the main window and then closes the popup. -OAuth._endOfPopupResponseTemplate = ''; +const OAuth$_endOfPopupResponseTemplate = (config, nonce) => ` + + + + + + +` +const OAuth$_endOfRedirectResponseTemplate = (config, nonce) => ` + + + + + +`; // Renders the end of login response template into some HTML and JavaScript // that closes the popup or redirects at the end of the OAuth flow. @@ -304,51 +353,23 @@ OAuth._endOfRedirectResponseTemplate = ''; // - redirectUrl // - isCordova (boolean) // -const renderEndOfLoginResponse = options => { - // It would be nice to use Blaze here, but it's a little tricky - // because our mustaches would be inside a