packages/fxa-content-server/app/scripts/models/reliers/oauth.js (362 lines of code) (raw):

/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * An OAuth Relier - holds OAuth information. */ import _ from 'underscore'; import Constants from '../../lib/constants'; import OAuthErrors from '../../lib/oauth-errors'; import OAuthPrompt from '../../lib/oauth-prompt'; import Relier from './relier'; import Transform from '../../lib/transform'; import Vat from '../../lib/vat'; const t = (msg) => msg; /*eslint-disable camelcase*/ const CLIENT_INFO_SCHEMA = { id: Vat.hex().required().renameTo('clientId'), image_uri: Vat.url().allow('').renameTo('imageUri'), name: Vat.string().required().min(1).renameTo('serviceName'), // This can be a single uri or comma separated list redirect_uri: Vat.redirectUri().renameTo('redirectUri'), trusted: Vat.boolean().required(), }; const SIGNIN_SIGNUP_QUERY_PARAM_SCHEMA = { access_type: Vat.accessType().renameTo('accessType'), acr_values: Vat.string().renameTo('acrValues'), action: Vat.action(), client_id: Vat.clientId().required().renameTo('clientId'), code_challenge: Vat.codeChallenge().renameTo('codeChallenge'), code_challenge_method: Vat.codeChallengeMethod().renameTo( 'codeChallengeMethod' ), keys_jwk: Vat.keysJwk().renameTo('keysJwk'), id_token_hint: Vat.idToken().renameTo('idTokenHint'), login_hint: Vat.email().renameTo('loginHint'), max_age: Vat.number().min(0).renameTo('maxAge'), prompt: Vat.prompt(), redirect_uri: Vat.url() .allow(Constants.DEVICE_PAIRING_AUTHORITY_REDIRECT_URI) .renameTo('redirectUri'), redirectTo: Vat.url(), return_on_error: Vat.boolean().renameTo('returnOnError'), scope: Vat.string().required().min(1), state: Vat.string(), }; const VERIFICATION_INFO_SCHEMA = { access_type: Vat.accessType().renameTo('accessType'), acr_values: Vat.string().renameTo('acrValues'), action: Vat.string().min(1), client_id: Vat.clientId().required().renameTo('clientId'), code_challenge: Vat.codeChallenge().renameTo('codeChallenge'), code_challenge_method: Vat.codeChallengeMethod().renameTo( 'codeChallengeMethod' ), prompt: Vat.prompt(), redirect_uri: Vat.url().renameTo('redirectUri'), // scopes are optional when verifying, user could be verifying in a 2nd browser scope: Vat.string().min(1), // `service` for OAuth verification is a clientId service: Vat.clientId(), state: Vat.string().min(1), }; /*eslint-enable camelcase*/ var OAuthRelier = Relier.extend({ defaults: _.extend({}, Relier.prototype.defaults, { accessType: null, acrValues: null, clientId: null, context: Constants.OAUTH_CONTEXT, name: 'oauth', keysJwk: null, // permissions are individual scopes permissions: null, // whether the permissions prompt will be shown to trusted reliers prompt: null, // redirectTo is for future use by the oauth flow. redirectTo // would have redirectUri as its base. redirectTo: null, // redirectUri is used by the oauth flow redirectUri: null, // a rollup of all the permissions scope: null, // standard oauth parameters. state: null, // Max age since last auth maxAge: null, }), initialize(attributes, options = {}) { Relier.prototype.initialize.call(this, attributes, options); this._config = options.config; this._oAuthClient = options.oAuthClient; this._session = options.session; this._wantsScopeThatHasKeys = false; }, fetch() { return Relier.prototype.fetch .call(this) .then(() => { if (this._isSuccessFlow()) { this._setupSuccessFlow(); } else if (this._isVerificationFlow()) { this._setupVerificationFlow(); } else { this._setupSignInSignUpFlow(); } if (!this.has('service')) { this.set('service', this.get('clientId')); } return this._setupOAuthRPInfo(); }) .then(() => { if (this.has('scope')) { // normalization depends on `trusted` field set in // setupOAuthRPInfo. this._normalizeScopesAndPermissions(); } }) .then(() => { this._validateKeyScopeRequest(); }); }, _normalizeScopesAndPermissions() { var permissions = this.scopeStrToArray(this.get('scope')); if (this.isTrusted()) { // We have to normalize `profile` into is expanded sub-scopes // in order to show the consent screen. if (this.wantsConsent()) { permissions = replaceItemInArray( permissions, Constants.OAUTH_TRUSTED_PROFILE_SCOPE, Constants.OAUTH_TRUSTED_PROFILE_SCOPE_EXPANSION ); } } else { permissions = sanitizeUntrustedPermissions(permissions); } if (!permissions.length) { throw OAuthErrors.toInvalidParameterError('scope'); } this.set('scope', permissions.join(' ')); this.set('permissions', permissions); // As a special case for UX purposes, any client requesting access to // the user's sync data must have a display name of "Firefox Sync". if (permissions.includes(Constants.OAUTH_OLDSYNC_SCOPE)) { this.set('serviceName', t(Constants.RELIER_SYNC_SERVICE_NAME)); } }, isOAuth() { return true; }, isSync() { return ( this.get('serviceName') === Constants.RELIER_SYNC_SERVICE_NAME && !this.isOAuthNativeRelay() ); }, isOAuthNativeRelay() { return this.get('service') === 'relay'; }, _isVerificationFlow() { return !!this.getSearchParam('code'); }, _isSuccessFlow() { return /oauth\/success/.test(this.window.location.pathname); }, _setupVerificationFlow() { var resumeObj = this._session.oauth; if (!resumeObj) { // The user is verifying in a second browser. `service` is // available in the link. Use it to populate the `service` // and `clientId` fields which will allow the user to // redirect back to the RP but not sign in. resumeObj = { client_id: this.getSearchParam('service'), //eslint-disable-line camelcase service: this.getSearchParam('service'), }; } else if (typeof resumeObj === 'string') { resumeObj = JSON.parse(resumeObj); } var result = Transform.transformUsingSchema( resumeObj, VERIFICATION_INFO_SCHEMA, OAuthErrors ); this.set(result); }, _setupSignInSignUpFlow() { // params listed in: // https://mozilla.github.io/ecosystem-platform/api#tag/OAuth-Server-API-Overview this.importSearchParamsUsingSchema( SIGNIN_SIGNUP_QUERY_PARAM_SCHEMA, OAuthErrors ); if (!this.get('email') && this.get('loginHint')) { this.set('email', this.get('loginHint')); } // OAuth reliers (at the moment, only oauth desktop) are only allowed to // specify 'sync' or 'relay' as the service. if ( this.getSearchParam('service') && this.getSearchParam('service') !== 'sync' && this.getSearchParam('service') !== 'relay' ) { throw OAuthErrors.toInvalidParameterError('service'); } }, _setupSuccessFlow() { const pathname = this.window.location.pathname.split('/'); const clientId = pathname[pathname.length - 1]; if (!clientId) { throw OAuthErrors.toError('INVALID_PARAMETER'); } this.set('clientId', clientId); }, _setupOAuthRPInfo() { const clientId = this.get('clientId'); return this._oAuthClient.getClientInfo(clientId).then( (serviceInfo) => { const result = Transform.transformUsingSchema( serviceInfo, CLIENT_INFO_SCHEMA, OAuthErrors ); /** * If redirect_uri was specified in the query we must validate it * Ref: https://tools.ietf.org/html/rfc6749#section-3.1.2 * * Verification (email) flows do not have a redirect uri, nothing to validate */ if (!isCorrectRedirect(this.get('redirectUri'), result)) { // if provided redirect uri doesn't match with any client redirectUri then throw throw OAuthErrors.toError('INCORRECT_REDIRECT'); } this.set(result); }, function (err) { // the server returns an invalid request parameter for an // invalid/unknown client_id if ( OAuthErrors.is(err, 'INVALID_PARAMETER') && err.validation && err.validation.keys && err.validation.keys[0] === 'client_id' ) { err = OAuthErrors.toError('UNKNOWN_CLIENT'); // used for logging the error on the server. err.client_id = clientId; //eslint-disable-line camelcase } throw err; } ); function isCorrectRedirect(queryRedirectUri, client) { // If RP doesn't specify redirectUri, we default to the first redirectUri // for the client const redirectUris = client.redirectUri.split(','); if (!queryRedirectUri) { client.redirectUri = redirectUris[0]; return true; } const hasRedirectUri = redirectUris.some((uri) => { if (queryRedirectUri === uri) { return true; } }); if (hasRedirectUri) { client.redirectUri = queryRedirectUri; return true; } // Pairing has a special redirectUri that deep links into the specific // mobile app if ( queryRedirectUri === Constants.DEVICE_PAIRING_AUTHORITY_REDIRECT_URI ) { return true; } return false; } }, isTrusted() { return this.get('trusted'); }, /** * Return `true` if the relier sets `prompt=consent` * * @returns {Boolean} `true` if relier asks for consent, false otw. */ wantsConsent() { return this.get('prompt') === OAuthPrompt.CONSENT; }, /** * Return `true` if the relier sets `prompt=login` or `maxAge=0`. Per OIDC spec, * specifying `maxAge=0` should act like `prompt=login`, however the RP needs to * verify the `auth_at` value in the id token to confirm that a re-authentication * occurred. * * @returns {Boolean} */ wantsLogin() { return this.get('prompt') === OAuthPrompt.LOGIN || this.get('maxAge') === 0; }, /** * Return `true` if the relier specified two step authentication * in its acrValues. * * @returns {Boolean} `true` if relier asks for two step authentication, false otw. */ wantsTwoStepAuthentication() { const acrValues = this.get('acrValues'); if (!acrValues) { return false; } const tokens = acrValues.split(' '); return tokens.includes(Constants.TWO_STEP_AUTHENTICATION_ACR); }, /** * Check if the relier wants access to the account encryption keys. * * @returns {Boolean} */ wantsKeys() { return !!( this._config && this._config.scopedKeysEnabled && this.has('keysJwk') && this._wantsScopeThatHasKeys ); }, /** * Perform additional validation for scopes that have encryption keys. * * If the relier is requesting keys, we check their redirect URI against * against an explicit allowlist and throw an error if it doesn't match. * * This provides an extra line of defence against us sending the keys * somewhere unintended as a result of e.g. a config error or a * badly-behaved server. * * @returns {boolean} * @private */ _validateKeyScopeRequest() { if (!this.has('keysJwk')) { return false; } // If they're not requesting any scopes, we're not going to given them any keys. if (!this.get('scope')) { return false; } const validation = this._config.scopedKeysValidation || {}; this.scopeStrToArray(this.get('scope')).forEach((scope) => { // eslint-disable-next-line no-prototype-builtins if (validation.hasOwnProperty(scope)) { if (validation[scope].redirectUris.includes(this.get('redirectUri'))) { this._wantsScopeThatHasKeys = true; } else { // Requesting keys, but trying to deliver them to an unexpected uri? Nope. throw new Error('Invalid redirect parameter'); } } }); return this._wantsScopeThatHasKeys; }, /** * Ensure the prompt=none can be used. * * @param {Account} account * @throws {OAuthError} if prompt=none cannot be used. * @returns {Promise<none>} rejects with an error if prompt=none cannot be used. */ validatePromptNoneRequest(account) { const requestedEmail = this.get('email'); return Promise.resolve() .then(() => { if (!this._config.isPromptNoneEnabled) { throw OAuthErrors.toError('PROMPT_NONE_NOT_ENABLED'); } // If the RP uses email, check they are allowed to use prompt=none. // This check is not necessary if the RP uses id_token_hint. // See the discussion issue: https://github.com/mozilla/fxa/issues/4963 if (requestedEmail && !this._config.isPromptNoneEnabledForClient) { throw OAuthErrors.toError('PROMPT_NONE_NOT_ENABLED_FOR_CLIENT'); } if (this.wantsKeys()) { throw OAuthErrors.toError('PROMPT_NONE_WITH_KEYS'); } if (account.isDefault() || !account.get('sessionToken')) { throw OAuthErrors.toError('PROMPT_NONE_NOT_SIGNED_IN'); } // If `id_token_hint` is present, ignore `login_hint` / `email`. const idTokenHint = this.get('idTokenHint'); if (idTokenHint) { const clientId = this.get('clientId'); return account .verifyIdToken( idTokenHint, clientId, Constants.ID_TOKEN_HINT_GRACE_PERIOD ) .catch((err) => { throw OAuthErrors.toError('PROMPT_NONE_INVALID_ID_TOKEN_HINT'); }) .then((claims) => { if (claims.sub !== account.get('uid')) { throw OAuthErrors.toError( 'PROMPT_NONE_DIFFERENT_USER_SIGNED_IN' ); } }); } if (requestedEmail && requestedEmail !== account.get('email')) { throw OAuthErrors.toError('PROMPT_NONE_DIFFERENT_USER_SIGNED_IN'); } return Promise.resolve(); }) .then(() => { // account has all the right bits associated with it, // now let's check to see whether the account and session // are verified. If session is no good, the promise will // reject with an INVALID_TOKEN error. return account.sessionVerificationStatus().then(({ verified }) => { if (!verified) { throw OAuthErrors.toError('PROMPT_NONE_UNVERIFIED'); } }); }); }, /** * Check whether additional permissions are requested from * the given account * * @param {Object} account * @returns {Boolean} `true` if additional permissions * are needed, false otw. */ accountNeedsPermissions(account) { if (this.isTrusted() && !this.wantsConsent()) { return false; } // only check permissions for which the account has a value. var applicableProfilePermissions = account.getPermissionsWithValues( this.get('permissions') ); return !account.hasSeenPermissions( this.get('clientId'), applicableProfilePermissions ); }, /** * Converts a whitespace OR a + separated list of scopes into an Array * @param {String} scopes * @returns {Array} */ scopeStrToArray: function scopeStrToArray(scopes) { if (!_.isString(scopes)) { return []; } const trimmedScopes = scopes.trim(); if (trimmedScopes.length) { // matches any whitespace character OR matches the character '+' literally return _.uniq(scopes.split(/\s+|\++/g)); } else { return []; } }, }); function replaceItemInArray(array, itemToReplace, replaceWith) { var without = _.without(array, itemToReplace); if (without.length !== array.length) { return _.union(without, replaceWith); } return array; } function sanitizeUntrustedPermissions(permissions) { return _.intersection( permissions, Constants.OAUTH_UNTRUSTED_ALLOWED_PERMISSIONS ); } export default OAuthRelier;