diff --git a/packages/db-migrations/databases/fxa_oauth/patches/patch-033-034.sql b/packages/db-migrations/databases/fxa_oauth/patches/patch-033-034.sql new file mode 100644 index 00000000000..5ebbf3c78a8 --- /dev/null +++ b/packages/db-migrations/databases/fxa_oauth/patches/patch-033-034.sql @@ -0,0 +1,12 @@ +-- Recreates the accountAuthorizations table from the reverted level-34 +-- attempt so the forward chain is complete on environments still at 33. +-- Patch 34->35 drops the table; nothing in current code reads or writes it. +CREATE TABLE IF NOT EXISTS `accountAuthorizations` ( + `uid` BINARY(16) NOT NULL, + `scope` VARCHAR(512) NOT NULL, + `service` VARCHAR(64) NOT NULL, + `authorizedAt` BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (`uid`, `scope`, `service`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +UPDATE dbMetadata SET value = '34' WHERE name = 'schema-patch-level'; diff --git a/packages/db-migrations/databases/fxa_oauth/patches/patch-034-033.sql b/packages/db-migrations/databases/fxa_oauth/patches/patch-034-033.sql new file mode 100644 index 00000000000..dcc0079cb10 --- /dev/null +++ b/packages/db-migrations/databases/fxa_oauth/patches/patch-034-033.sql @@ -0,0 +1,3 @@ +-- Reverse of patch-033-034. Reverse patching is disabled in the runner. + +-- UPDATE dbMetadata SET value = '33' WHERE name = 'schema-patch-level'; diff --git a/packages/db-migrations/databases/fxa_oauth/patches/patch-034-035.sql b/packages/db-migrations/databases/fxa_oauth/patches/patch-034-035.sql new file mode 100644 index 00000000000..e35d409f1d8 --- /dev/null +++ b/packages/db-migrations/databases/fxa_oauth/patches/patch-034-035.sql @@ -0,0 +1,7 @@ +-- Drop the accountAuthorizations table left behind on environments that +-- ran the (since-reverted) level-34 migration. IF EXISTS keeps this safe +-- on environments where the table was never created. + +DROP TABLE IF EXISTS `accountAuthorizations`; + +UPDATE dbMetadata SET value = '35' WHERE name = 'schema-patch-level'; diff --git a/packages/db-migrations/databases/fxa_oauth/patches/patch-035-034.sql b/packages/db-migrations/databases/fxa_oauth/patches/patch-035-034.sql new file mode 100644 index 00000000000..2c68c39c1f9 --- /dev/null +++ b/packages/db-migrations/databases/fxa_oauth/patches/patch-035-034.sql @@ -0,0 +1,11 @@ +-- Reverse of patch-034-035. Reverse patching is disabled in the runner. + +-- CREATE TABLE IF NOT EXISTS `accountAuthorizations` ( +-- `uid` BINARY(16) NOT NULL, +-- `scope` VARCHAR(512) NOT NULL, +-- `service` VARCHAR(64) NOT NULL, +-- `authorizedAt` BIGINT UNSIGNED NOT NULL, +-- PRIMARY KEY (`uid`, `scope`, `service`) +-- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- UPDATE dbMetadata SET value = '34' WHERE name = 'schema-patch-level'; diff --git a/packages/db-migrations/databases/fxa_oauth/patches/patch-035-036.sql b/packages/db-migrations/databases/fxa_oauth/patches/patch-035-036.sql new file mode 100644 index 00000000000..aa776f59c1b --- /dev/null +++ b/packages/db-migrations/databases/fxa_oauth/patches/patch-035-036.sql @@ -0,0 +1,26 @@ +-- accountConsents: per-user OAuth consent ledger. One row per +-- (uid, scope, service, clientId). Written by the /authorization path on +-- consent acceptance (upserts, bumping lastAuthorizedTosAt). Read by the +-- /authorization pre-prompt check and by token-exchange. Cleared by +-- OauthDB.removeTokensAndCodes, which runs on account deletion and on +-- password reset. +-- +-- The scope-to-service mapping that drives token-exchange resolution +-- lives in auth-server Convict config (oauthServer.exchange.serviceScopes), +-- not on a column here. Keeping it out of the DB makes adding a new +-- browser service a config + deploy, with no schema change. +CREATE TABLE IF NOT EXISTS `accountConsents` ( + `uid` BINARY(16) NOT NULL, + `scope` VARCHAR(256) NOT NULL DEFAULT '', + `service` VARCHAR(64) NOT NULL DEFAULT '', + `clientId` BINARY(8) NOT NULL, + `firstAuthorizedTosAt` BIGINT UNSIGNED NOT NULL, + `lastAuthorizedTosAt` BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (`uid`, `scope`, `service`, `clientId`), + -- idx_uid_service supports the token-exchange lookup + -- (WHERE uid=? AND service=?). The PK's leading-uid prefix would + -- otherwise force a uid-wide scan filtered in memory by service. + INDEX `idx_uid_service` (`uid`, `service`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +UPDATE dbMetadata SET value = '36' WHERE name = 'schema-patch-level'; diff --git a/packages/db-migrations/databases/fxa_oauth/patches/patch-036-035.sql b/packages/db-migrations/databases/fxa_oauth/patches/patch-036-035.sql new file mode 100644 index 00000000000..d3095076891 --- /dev/null +++ b/packages/db-migrations/databases/fxa_oauth/patches/patch-036-035.sql @@ -0,0 +1,5 @@ +-- Reverse of patch-035-036. Reverse patching is disabled in the runner. + +-- DROP TABLE IF EXISTS `accountConsents`; + +-- UPDATE dbMetadata SET value = '35' WHERE name = 'schema-patch-level'; diff --git a/packages/db-migrations/databases/fxa_oauth/target-patch.json b/packages/db-migrations/databases/fxa_oauth/target-patch.json index 3c5e6f398a6..e80da579120 100644 --- a/packages/db-migrations/databases/fxa_oauth/target-patch.json +++ b/packages/db-migrations/databases/fxa_oauth/target-patch.json @@ -1,3 +1,3 @@ { - "level": 33 + "level": 36 } diff --git a/packages/fxa-auth-server/config/index.ts b/packages/fxa-auth-server/config/index.ts index 44322da6d9d..5e11ce6c2f3 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -1443,6 +1443,31 @@ const convictConf = convict({ env: 'OAUTH_TOKEN_EXCHANGE_ALLOWED_SCOPES', }, }, + exchange: { + serviceScopes: { + doc: 'Map from browser-service name to its authoritative scope URL. Drives token-exchange scope resolution, /authorization service= validation, and the per-RP ToS audit. Adding a new browser service is a config change plus deploy, with no schema migration required.', + format: Object, + default: { + sync: 'https://identity.mozilla.com/apps/oldsync', + relay: 'https://identity.mozilla.com/apps/relay', + smartwindow: 'https://identity.mozilla.com/apps/smartwindow', + vpn: 'https://identity.mozilla.com/apps/vpn', + }, + env: 'OAUTH_EXCHANGE_SERVICE_SCOPES', + }, + denySilentForServices: { + doc: 'Services for which silent token-exchange is always rejected, irrespective of accountConsents rows. Wins over bypass. Today: sync, the Sync refresh token is the keys-bearing master credential.', + format: Array, + default: ['sync'], + env: 'OAUTH_EXCHANGE_DENY_SILENT_FOR_SERVICES', + }, + bypassConsentForServices: { + doc: 'Services granted at token-exchange without consulting accountConsents. Today: relay, pending application-services landing a 4xx handler in iOS/Android Relay code. To be cleared once that ships.', + format: Array, + default: ['relay'], + env: 'OAUTH_EXCHANGE_BYPASS_CONSENT_FOR_SERVICES', + }, + }, git: { commit: { doc: 'Commit SHA when in stage/production', diff --git a/packages/fxa-auth-server/lib/account-delete.spec.ts b/packages/fxa-auth-server/lib/account-delete.spec.ts index ab90af89e0d..a5a6ec51bd4 100644 --- a/packages/fxa-auth-server/lib/account-delete.spec.ts +++ b/packages/fxa-auth-server/lib/account-delete.spec.ts @@ -132,6 +132,7 @@ describe('AccountDeleteManager', () => { mockOAuthDb = { removeTokensAndCodes: jest.fn().mockResolvedValue(undefined), removePublicAndCanGrantTokens: jest.fn().mockResolvedValue(undefined), + deleteAllConsentsForUser: jest.fn().mockResolvedValue(undefined), }; Container.set(StripeHelper, mockStripeHelper); @@ -210,6 +211,8 @@ describe('AccountDeleteManager', () => { expect(mockPushbox.deleteAccount).toHaveBeenCalledWith(uid); expect(mockOAuthDb.removeTokensAndCodes).toHaveBeenCalledTimes(1); expect(mockOAuthDb.removeTokensAndCodes).toHaveBeenCalledWith(uid); + expect(mockOAuthDb.deleteAllConsentsForUser).toHaveBeenCalledTimes(1); + expect(mockOAuthDb.deleteAllConsentsForUser).toHaveBeenCalledWith(uid); expect(mockLog.activityEvent).toHaveBeenCalledTimes(1); expect(mockLog.activityEvent).toHaveBeenCalledWith({ uid, @@ -309,6 +312,8 @@ describe('AccountDeleteManager', () => { ); expect(mockOAuthDb.removeTokensAndCodes).toHaveBeenCalledTimes(1); expect(mockOAuthDb.removeTokensAndCodes).toHaveBeenCalledWith(uid); + expect(mockOAuthDb.deleteAllConsentsForUser).toHaveBeenCalledTimes(1); + expect(mockOAuthDb.deleteAllConsentsForUser).toHaveBeenCalledWith(uid); }); it('should error if its not user requested', async () => { diff --git a/packages/fxa-auth-server/lib/account-delete.ts b/packages/fxa-auth-server/lib/account-delete.ts index 2bdfc7fef2c..5639d5e8bf6 100644 --- a/packages/fxa-auth-server/lib/account-delete.ts +++ b/packages/fxa-auth-server/lib/account-delete.ts @@ -33,7 +33,10 @@ import { requestForGlean, } from './inactive-accounts'; -type OAuthDbDeleteAccount = Pick & +type OAuthDbDeleteAccount = Pick< + typeof OAuthDb, + 'removeTokensAndCodes' | 'deleteAllConsentsForUser' +> & InactiveStatusOAuthDb; type PushboxDeleteAccount = Pick< ReturnType, @@ -150,7 +153,7 @@ export class AccountDeleteManager { } await this.deleteAccountFromDb(uid); - await this.deleteOAuthTokens(uid); + await this.deleteOAuthAccountData(uid); // data eng rely on this to delete the account data from BQ. // user self-deletes are logged when the client request was handled @@ -185,7 +188,7 @@ export class AccountDeleteManager { try { await this.deleteAccountFromDb(uid); - await this.deleteOAuthTokens(uid); + await this.deleteOAuthAccountData(uid); this.statsd.increment('account.destroy.quick-delete'); } catch (error) { // If the account wasn't fully deleted, we should log the error and @@ -236,12 +239,11 @@ export class AccountDeleteManager { return true; } - /** - * Delete the account from the OAuth database. This will remove all tokens and - * codes associated with the account. - */ - private async deleteOAuthTokens(uid: string) { + // Sweep tokens, codes, and consent. Consent is only swept on the + // account-delete path, never on password reset. + private async deleteOAuthAccountData(uid: string) { await this.oauthDb.removeTokensAndCodes(uid); + await this.oauthDb.deleteAllConsentsForUser(uid); } /** diff --git a/packages/fxa-auth-server/lib/oauth/db/index.js b/packages/fxa-auth-server/lib/oauth/db/index.js index 6378ce917a7..81be4856ec5 100644 --- a/packages/fxa-auth-server/lib/oauth/db/index.js +++ b/packages/fxa-auth-server/lib/oauth/db/index.js @@ -24,6 +24,26 @@ const REFRESH_LAST_USED_AT_UPDATE_AFTER_MS = config.get( 'oauthServer.refreshToken.updateAfter' ); +// Service map and policy flags for token-exchange consent gating. Read +// once at module load; updates require a process restart. +const EXCHANGE_SERVICE_SCOPES = config.get('oauthServer.exchange.serviceScopes'); +const EXCHANGE_KNOWN_SERVICES = new Set(Object.keys(EXCHANGE_SERVICE_SCOPES)); +// Inverse map: canonical scope URL -> service name. Used by the +// exchange path to resolve the requested scope to its owning service +// without touching the DB. +const EXCHANGE_SCOPE_TO_SERVICE = new Map( + Object.entries(EXCHANGE_SERVICE_SCOPES).map(([service, scope]) => [ + scope, + service, + ]) +); +const EXCHANGE_DENY_SILENT_FOR_SERVICES = new Set( + config.get('oauthServer.exchange.denySilentForServices') +); +const EXCHANGE_BYPASS_CONSENT_FOR_SERVICES = new Set( + config.get('oauthServer.exchange.bypassConsentForServices') +); + class OauthDB extends ConnectedServicesDb { get mysql() { return this.db; @@ -229,6 +249,8 @@ class OauthDB extends ConnectedServicesDb { return ok; } + // Called from both account deletion AND password reset, so this must + // not touch the consent ledger — consent survives credential rotation. async removeTokensAndCodes(uid) { await this.ready(); await this.redis.removeAccessTokensForUser(uid); @@ -241,6 +263,78 @@ class OauthDB extends ConnectedServicesDb { ttlInMs || config.get('oauthServer.expiration.code') ); } + + // Upserts a consent row for the (uid, scope, service, clientId) + // tuple. First write seeds both timestamps to `now`; subsequent + // writes preserve firstAuthorizedTosAt and bump lastAuthorizedTosAt. + async recordSignInConsent({ uid, scope, service, clientId, now }) { + await this.ready(); + return this.mysql._upsertAccountConsent( + uid, + scope, + service, + clientId, + now || Date.now() + ); + } + + // True when a consent row exists for the exact (uid, scope, service). + // Used by the /authorization pre-prompt check. + async hasConsentForSignIn(uid, scope, service) { + await this.ready(); + const row = await this.mysql._findAccountConsentForSignIn( + uid, + scope, + service + ); + return !!row; + } + + // Applies the token-exchange decision matrix. Returns one of: + // { result: 'allowed', service } + // { result: 'bypass', service } + // { result: 'denied', service, reason: 'silent-disallowed' | 'no-consent' } + // { result: 'fall-through' } + // Scope -> service resolution comes from the oauthServer.exchange.serviceScopes + // config map; unmapped scopes fall through to clients.allowedScopes. + async hasConsentForExchange(uid, scope) { + const service = EXCHANGE_SCOPE_TO_SERVICE.get(scope); + if (!service) { + return { result: 'fall-through' }; + } + if (EXCHANGE_DENY_SILENT_FOR_SERVICES.has(service)) { + return { result: 'denied', service, reason: 'silent-disallowed' }; + } + if (EXCHANGE_BYPASS_CONSENT_FOR_SERVICES.has(service)) { + return { result: 'bypass', service }; + } + await this.ready(); + const hasConsent = await this.mysql._hasConsentForService(uid, service); + if (hasConsent) { + return { result: 'allowed', service }; + } + return { result: 'denied', service, reason: 'no-consent' }; + } + + async deleteAllConsentsForUser(uid) { + await this.ready(); + return this.mysql._deleteAllAccountConsentsForUser(uid); + } + + async listAccountConsentsByUid(uid) { + await this.ready(); + return this.mysql._listAccountConsentsByUid(uid); + } + + // True iff serviceName appears in the oauthServer.exchange.serviceScopes + // config map. Used by the /authorization writer to validate the URL's + // service= param before persisting it; unknown values are dropped to ''. + isKnownService(serviceName) { + if (!serviceName) { + return false; + } + return EXCHANGE_KNOWN_SERVICES.has(serviceName); + } } // Helper functions diff --git a/packages/fxa-auth-server/lib/oauth/db/mysql/index.js b/packages/fxa-auth-server/lib/oauth/db/mysql/index.js index b1ccfd95d6f..7b26aaa1d33 100644 --- a/packages/fxa-auth-server/lib/oauth/db/mysql/index.js +++ b/packages/fxa-auth-server/lib/oauth/db/mysql/index.js @@ -176,6 +176,29 @@ const DELETE_REFRESH_TOKEN_WITH_CLIENT_AND_UID = const PRUNE_AUTHZ_CODES = 'DELETE FROM codes WHERE TIMESTAMPDIFF(SECOND, createdAt, NOW()) > ? LIMIT 10000'; +// First insert sets both timestamps to now. Subsequent /authorization +// completions for the same PK preserve firstAuthorizedTosAt and bump +// lastAuthorizedTosAt only, using GREATEST to guard against clock skew +// or reordered writes producing a backwards-moving lastAuthorizedTosAt. +const QUERY_ACCOUNT_CONSENT_UPSERT = + 'INSERT INTO accountConsents ' + + '(uid, scope, service, clientId, firstAuthorizedTosAt, lastAuthorizedTosAt) ' + + 'VALUES (?, ?, ?, ?, ?, ?) ' + + 'ON DUPLICATE KEY UPDATE ' + + 'lastAuthorizedTosAt = GREATEST(lastAuthorizedTosAt, VALUES(lastAuthorizedTosAt))'; +const QUERY_ACCOUNT_CONSENT_FIND_SIGNIN = + 'SELECT uid, scope, service, clientId, firstAuthorizedTosAt, lastAuthorizedTosAt ' + + 'FROM accountConsents WHERE uid=? AND scope=? AND service=?'; +// Direct lookup for the token-exchange gate after the caller has +// resolved scope -> service via config. Uses idx_uid_service. +const QUERY_HAS_CONSENT_FOR_SERVICE = + 'SELECT 1 FROM accountConsents WHERE uid=? AND service=? LIMIT 1'; +const QUERY_ACCOUNT_CONSENT_DELETE_BY_UID = + 'DELETE FROM accountConsents WHERE uid=?'; +const QUERY_ACCOUNT_CONSENT_LIST_BY_UID = + 'SELECT uid, scope, service, clientId, firstAuthorizedTosAt, lastAuthorizedTosAt ' + + 'FROM accountConsents WHERE uid=?'; + // Scope queries const QUERY_SCOPE_FIND = 'SELECT * ' + 'FROM scopes ' + 'WHERE scopes.scope=?;'; const QUERY_SCOPES_INSERT = @@ -607,6 +630,45 @@ class MysqlStore extends MysqlOAuthShared { return this._write(QUERY_REFRESH_TOKEN_DELETE, [buf(id)]); } + _upsertAccountConsent(uid, scope, service, clientId, now) { + return this._write(QUERY_ACCOUNT_CONSENT_UPSERT, [ + buf(uid), + scope, + service, + buf(clientId), + now, + now, + ]); + } + + async _findAccountConsentForSignIn(uid, scope, service) { + const rows = await this._read(QUERY_ACCOUNT_CONSENT_FIND_SIGNIN, [ + buf(uid), + scope, + service, + ]); + return firstRow(rows) || null; + } + + // True iff at least one consent row exists for (uid, service). Used + // by the exchange gate after the caller has resolved scope to service + // via config. + async _hasConsentForService(uid, service) { + const rows = await this._read(QUERY_HAS_CONSENT_FOR_SERVICE, [ + buf(uid), + service, + ]); + return rows.length > 0; + } + + _deleteAllAccountConsentsForUser(uid) { + return this._write(QUERY_ACCOUNT_CONSENT_DELETE_BY_UID, [buf(uid)]); + } + + _listAccountConsentsByUid(uid) { + return this._read(QUERY_ACCOUNT_CONSENT_LIST_BY_UID, [buf(uid)]); + } + getEncodingInfo() { var info = {}; diff --git a/packages/fxa-auth-server/lib/oauth/validators.js b/packages/fxa-auth-server/lib/oauth/validators.js index 02ead6ce2d4..5c17a9b4700 100644 --- a/packages/fxa-auth-server/lib/oauth/validators.js +++ b/packages/fxa-auth-server/lib/oauth/validators.js @@ -91,3 +91,4 @@ exports.refreshToken = authServerValidators.refreshToken; exports.authorizationCode = authServerValidators.authorizationCode; exports.url = authServerValidators.url; exports.hexString = authServerValidators.hexString; +exports.service = authServerValidators.service; diff --git a/packages/fxa-auth-server/lib/routes/oauth/authorization.js b/packages/fxa-auth-server/lib/routes/oauth/authorization.js index 956d4e94038..51a0c666cb5 100644 --- a/packages/fxa-auth-server/lib/routes/oauth/authorization.js +++ b/packages/fxa-auth-server/lib/routes/oauth/authorization.js @@ -31,7 +31,7 @@ function isLocalHost(url) { return host === 'localhost' || host === 'localhost'; } -module.exports = ({ log, oauthDB, config }) => { +module.exports = ({ log, oauthDB, config, statsd }) => { if (!config) { config = require('../../../config').default.getProperties(); } @@ -174,6 +174,43 @@ module.exports = ({ log, oauthDB, config }) => { } validateClientDetails(client, req.payload); const grant = await validateRequestedGrant(claims, client, req.payload); + // Record consent here, not at /oauth/token, so service= from the URL + // is available. Fires on every successful /authorization regardless + // of access_type. Errors are swallowed; bookkeeping cannot break sign-in. + try { + const clientIdHex = hex(grant.clientId); + const uidHex = hex(grant.userId); + const serviceParam = req.payload.service; + const scopeValues = + typeof grant.scope === 'string' + ? grant.scope.split(/\s+/).filter(Boolean) + : grant.scope.getScopeValues(); + if (scopeValues.length > 0) { + const serviceValue = oauthDB.isKnownService(serviceParam) + ? serviceParam + : ''; + const now = Date.now(); + await Promise.all( + scopeValues.map((scope) => + oauthDB.recordSignInConsent({ + uid: uidHex, + scope, + service: serviceValue, + clientId: clientIdHex, + now, + }) + ) + ); + statsd?.increment('accountConsent.recorded', { + service: serviceValue || 'unset', + access_type: grant.offline ? 'offline' : 'online', + count: scopeValues.length, + }); + } + } catch (err) { + statsd?.increment('accountConsent.write_failed'); + log.warn('accountConsent.write_failed', { err: err.message }); + } switch (req.payload.response_type) { case RESPONSE_TYPE_CODE: return await generateAuthorizationCode(client, req.payload, grant); @@ -303,6 +340,9 @@ module.exports = ({ log, oauthDB, config }) => { otherwise: Joi.forbidden(), }) .description(DESCRIPTION.resource + DESCRIPTION.resourceOauth), + service: validators.service + .optional() + .description(DESCRIPTION.service), }), }, response: { @@ -389,6 +429,9 @@ module.exports = ({ log, oauthDB, config }) => { .description(DESCRIPTION.acrValues), assertion: Joi.forbidden(), resource: Joi.forbidden(), + service: validators.service + .optional() + .description(DESCRIPTION.service), }).and('code_challenge', 'code_challenge_method'), }, response: { diff --git a/packages/fxa-auth-server/lib/routes/oauth/token.js b/packages/fxa-auth-server/lib/routes/oauth/token.js index 002fb59a27a..5b6e85b2b59 100644 --- a/packages/fxa-auth-server/lib/routes/oauth/token.js +++ b/packages/fxa-auth-server/lib/routes/oauth/token.js @@ -87,9 +87,6 @@ const DISABLED_CLIENTS = new Set(config.get('oauthServer.disabledClients')); const TOKEN_EXCHANGE_ALLOWED_CLIENT_IDS = new Set( config.get('oauthServer.tokenExchange.allowedClientIds') ); -const TOKEN_EXCHANGE_ALLOWED_SCOPES = ScopeSet.fromArray( - config.get('oauthServer.tokenExchange.allowedScopes') -); // These scopes are used to request a one-off exchange of claims or credentials, // but they don't make sense to use on an ongoing basis via refresh tokens. @@ -430,15 +427,91 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => { throw OauthError.unauthorizedTokenExchangeClient(originalClientId); } - // Validate requested scope is in allowlist + // Authorize scope-by-scope. The decision matrix lives in + // OauthDB.hasConsentForExchange, which resolves scope to service via + // the oauthServer.exchange.serviceScopes config map and applies the + // deny/bypass policy flags before consulting accountConsents. const requestedScope = params.scope; - if (!TOKEN_EXCHANGE_ALLOWED_SCOPES.contains(requestedScope)) { - log.debug('token_exchange.scope_not_allowed', { - requested: requestedScope.toString(), - allowed: TOKEN_EXCHANGE_ALLOWED_SCOPES.toString(), - }); - // TODO future auth table checks, FXA-12937 - throw OauthError.forbidden(); + const requestedValues = + typeof requestedScope === 'string' + ? requestedScope.split(/\s+/).filter(Boolean) + : requestedScope.getScopeValues(); + + function recordOutcome(service, outcome) { + statsd.increment('oauth.token_exchange.resolution', { service, outcome }); + } + + // Lazily loaded for fall-through scopes; null until first use, then a + // ScopeSet of the subject_token's client.allowedScopes. + let subjectClientAllowedScopes = null; + async function getSubjectClientAllowedScopes() { + if (subjectClientAllowedScopes === null) { + const subjectClient = await oauthDB.getClient(subjectToken.clientId); + subjectClientAllowedScopes = ScopeSet.fromString( + subjectClient?.allowedScopes || '' + ); + } + return subjectClientAllowedScopes; + } + + const seenServices = new Set(); + for (const value of requestedValues) { + const decision = await oauthDB.hasConsentForExchange( + subjectToken.userId, + value + ); + if (decision.result === 'fall-through') { + // Unmapped scope: defer to the subject_token client's allowedScopes + // so callers can't silently inflate a token's scope beyond what the + // issuing client was ever granted. + const allowed = await getSubjectClientAllowedScopes(); + if (!allowed.contains(value)) { + log.debug('token_exchange.fall_through_denied', { scope: value }); + recordOutcome('legacy', 'rejected_not_in_allowed_scopes'); + throw OauthError.forbidden(); + } + recordOutcome('legacy', 'granted_fall_through'); + continue; + } + // Dedupe metric/log emissions when several scopes share a service. + if (seenServices.has(decision.service)) { + continue; + } + seenServices.add(decision.service); + + switch (decision.result) { + case 'allowed': + recordOutcome(decision.service, 'granted'); + break; + case 'bypass': + log.info('token_exchange.relay_bypass', { + service: decision.service, + }); + recordOutcome(decision.service, 'granted_relay_bypass'); + break; + case 'denied': { + const outcome = + decision.reason === 'silent-disallowed' + ? 'rejected_silent_disallowed' + : 'rejected_no_row'; + log.debug('token_exchange.denied', { + service: decision.service, + reason: decision.reason, + scope: value, + }); + recordOutcome(decision.service, outcome); + throw OauthError.forbidden(); + } + default: + // Fail closed on any unknown decision shape so a future variant + // cannot silently grant the exchange. + log.error('token_exchange.unknown_decision', { decision }); + recordOutcome( + decision.service || 'unknown', + 'rejected_unknown_decision' + ); + throw OauthError.forbidden(); + } } // Original scope plus requested scope, e.g. Sync + Relay @@ -520,6 +593,12 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => { } } + + // Consent rows are written exclusively at /authorization, where + // service= is the trustworthy intent signal. /oauth/token paths + // (fxa-credentials, authorization_code, token-exchange) do not + // write consent rows. + const uid = hex(grant.userId); const oauthClientId = hex(grant.clientId); diff --git a/packages/fxa-auth-server/lib/routes/oauth/token.spec.ts b/packages/fxa-auth-server/lib/routes/oauth/token.spec.ts index 7285657faf4..ec54a4daabd 100644 --- a/packages/fxa-auth-server/lib/routes/oauth/token.spec.ts +++ b/packages/fxa-auth-server/lib/routes/oauth/token.spec.ts @@ -35,7 +35,7 @@ const SUBJECT_TOKEN_TYPE_REFRESH = const FIREFOX_IOS_CLIENT_ID = '1b1a3e44c54fbb58'; const noop = () => {}; -const mockLog = { debug: noop, warn: noop, info: noop }; +const mockLog = { debug: noop, warn: noop, info: noop, error: noop }; const mockDb = { touchSessionToken: jest.fn() }; const mockStatsD = { increment: jest.fn() }; const mockGlean = { oauth: { tokenCreated: jest.fn() } }; @@ -108,6 +108,20 @@ const tokenRoutesArgMocks = { async removeCode() { return null; }, + // Default unit-test stub: every exchanged scope falls through to + // clients.allowedScopes. Tests that need a different decision (deny, + // bypass, allowed) override on the per-test mock. + async hasConsentForExchange() { + return { result: 'fall-through' }; + }, + // Permissive subject_token client used by the fall-through gate. + // Tests that need a restrictive client override on the per-test mock. + async getClient() { + return { + allowedScopes: + 'profile https://identity.mozilla.com/apps/relay https://identity.mozilla.com/apps/oldsync', + }; + }, }, db: mockDb, mailer: {}, @@ -472,8 +486,6 @@ describe('token exchange grant_type', () => { }); describe('validateTokenExchangeGrant', () => { - const ScopeSet = require('fxa-shared').oauth.scopes; - it('rejects non-existent subject_token', async () => { resetAndMockDeps(); const routes = require('./token')({ @@ -534,40 +546,6 @@ describe('token exchange grant_type', () => { }); }); - it('rejects unauthorized scopes', async () => { - const UNAUTHORIZED_SCOPE = - 'https://identity.mozilla.com/apps/unauthorized'; - resetAndMockDeps(); - const routes = require('./token')({ - ...tokenRoutesArgMocks, - oauthDB: { - ...tokenRoutesArgMocks.oauthDB, - async getRefreshToken() { - return { - userId: buf(UID), - clientId: buf(FIREFOX_IOS_CLIENT_ID), - tokenId: buf('1234567890abcdef'), - scope: ScopeSet.fromString(OAUTH_SCOPE_OLD_SYNC), - profileChangedAt: Date.now(), - }; - }, - }, - }); - const request = { - headers: {}, - payload: { - grant_type: GRANT_TOKEN_EXCHANGE, - subject_token: REFRESH_TOKEN, - subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, - scope: UNAUTHORIZED_SCOPE, - }, - emitMetricsEvent: () => {}, - }; - await expect(routes[0].config.handler(request)).rejects.toMatchObject({ - errno: 112, // Forbidden - }); - }); - it('returns combined scopes on success', async () => { let removedTokenId: any = null; jest.resetModules(); @@ -893,6 +871,345 @@ describe('/oauth/token POST', () => { }); }); + describe('validateTokenExchangeGrant decision matrix', () => { + // Builds a token-exchange route whose oauthDB.hasConsentForExchange + // returns a caller-provided decision per scope value, then invokes the + // POST handler. The subject refresh token is a Firefox iOS client so + // TOKEN_EXCHANGE_ALLOWED_CLIENT_IDS lets the request past the + // pre-check and into the decision loop. + async function runExchange(decisions: Record, scope: string) { + mockStatsD.increment.mockClear(); + jest.resetModules(); + jest.doMock('../../oauth/assertion', () => async () => true); + jest.doMock( + '../../oauth/client', + () => tokenRoutesDepMocks['../../oauth/client'] + ); + jest.doMock('../../oauth/grant', () => ({ + generateTokens: (grant: any) => ({ + access_token: 'at', + token_type: 'bearer', + scope: grant.scope.toString(), + expires_in: 3600, + refresh_token: 'rt', + }), + validateRequestedGrant: () => ({ offline: true, scope: 'testo' }), + })); + jest.doMock( + '../../oauth/util', + () => tokenRoutesDepMocks['../../oauth/util'] + ); + jest.doMock('../utils/oauth', () => ({ + newTokenNotification: async () => {}, + })); + jest.doMock('../../oauth/token', () => ({ + verify: jest.fn().mockResolvedValue({ user: UID }), + })); + + const routes = require('./token')({ + ...tokenRoutesArgMocks, + db: { + ...tokenRoutesArgMocks.db, + async deviceFromRefreshTokenId() { + return null; + }, + }, + oauthDB: { + ...tokenRoutesArgMocks.oauthDB, + async getRefreshToken() { + return { + userId: buf(UID), + clientId: buf(FIREFOX_IOS_CLIENT_ID), + tokenId: buf('1234567890abcdef'), + scope: ScopeSet.fromString(scope), + profileChangedAt: Date.now(), + }; + }, + async removeRefreshToken() {}, + async hasConsentForExchange(_uid: any, value: string) { + return decisions[value] ?? { result: 'fall-through' }; + }, + }, + }); + + const request = { + auth: { credentials: null }, + headers: {}, + payload: { + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope, + }, + emitMetricsEvent: async () => {}, + }; + return routes[1].handler(request); + } + + function resolutionTagsFor(service: string) { + return mockStatsD.increment.mock.calls.filter( + ([metric, tags]: any) => + metric === 'oauth.token_exchange.resolution' && + tags?.service === service + ); + } + + it('grants and records "granted" when decision is allowed', async () => { + const result = await runExchange( + { [OAUTH_SCOPE_RELAY]: { result: 'allowed', service: 'relay' } }, + OAUTH_SCOPE_RELAY + ); + expect(result.access_token).toBe('at'); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'oauth.token_exchange.resolution', + { service: 'relay', outcome: 'granted' } + ); + }); + + it('grants and records "granted_relay_bypass" when decision is bypass', async () => { + const result = await runExchange( + { [OAUTH_SCOPE_RELAY]: { result: 'bypass', service: 'relay' } }, + OAUTH_SCOPE_RELAY + ); + expect(result.access_token).toBe('at'); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'oauth.token_exchange.resolution', + { service: 'relay', outcome: 'granted_relay_bypass' } + ); + }); + + it('rejects with "rejected_silent_disallowed" when service is deny-listed', async () => { + await expect( + runExchange( + { + [OAUTH_SCOPE_OLD_SYNC]: { + result: 'denied', + service: 'sync', + reason: 'silent-disallowed', + }, + }, + OAUTH_SCOPE_OLD_SYNC + ) + ).rejects.toMatchObject({ output: { statusCode: 403 } }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'oauth.token_exchange.resolution', + { service: 'sync', outcome: 'rejected_silent_disallowed' } + ); + }); + + it('rejects with "rejected_no_row" when mapped service has no consent row', async () => { + const SMARTWINDOW = 'https://identity.mozilla.com/apps/smartwindow'; + await expect( + runExchange( + { + [SMARTWINDOW]: { + result: 'denied', + service: 'smartwindow', + reason: 'no-row', + }, + }, + SMARTWINDOW + ) + ).rejects.toMatchObject({ output: { statusCode: 403 } }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'oauth.token_exchange.resolution', + { service: 'smartwindow', outcome: 'rejected_no_row' } + ); + }); + + it('grants and records "granted_fall_through" when scope is unmapped', async () => { + const PROFILE_SCOPE = 'profile'; + const result = await runExchange( + { [PROFILE_SCOPE]: { result: 'fall-through' } }, + PROFILE_SCOPE + ); + expect(result.access_token).toBe('at'); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'oauth.token_exchange.resolution', + { service: 'legacy', outcome: 'granted_fall_through' } + ); + }); + + it('rejects fall-through scopes that are not in the subject_token client allowedScopes', async () => { + const UNCONFIGURED = 'https://identity.mozilla.com/apps/never-seen'; + mockStatsD.increment.mockClear(); + jest.resetModules(); + jest.doMock('../../oauth/assertion', () => async () => true); + jest.doMock( + '../../oauth/client', + () => tokenRoutesDepMocks['../../oauth/client'] + ); + jest.doMock('../../oauth/grant', () => ({ + generateTokens: (grant: any) => ({ + access_token: 'at', + scope: grant.scope.toString(), + }), + validateRequestedGrant: () => ({ offline: true, scope: 'testo' }), + })); + jest.doMock( + '../../oauth/util', + () => tokenRoutesDepMocks['../../oauth/util'] + ); + jest.doMock('../utils/oauth', () => ({ + newTokenNotification: async () => {}, + })); + jest.doMock('../../oauth/token', () => ({ + verify: jest.fn().mockResolvedValue({ user: UID }), + })); + + const routes = require('./token')({ + ...tokenRoutesArgMocks, + db: { + ...tokenRoutesArgMocks.db, + async deviceFromRefreshTokenId() { + return null; + }, + }, + oauthDB: { + ...tokenRoutesArgMocks.oauthDB, + async getRefreshToken() { + return { + userId: buf(UID), + clientId: buf(FIREFOX_IOS_CLIENT_ID), + tokenId: buf('1234567890abcdef'), + scope: ScopeSet.fromString(UNCONFIGURED), + profileChangedAt: Date.now(), + }; + }, + async removeRefreshToken() {}, + async hasConsentForExchange() { + return { result: 'fall-through' }; + }, + // Restrictive client: only oldsync is permitted. + async getClient() { + return { + allowedScopes: 'https://identity.mozilla.com/apps/oldsync', + }; + }, + }, + }); + + await expect( + routes[1].handler({ + auth: { credentials: null }, + headers: {}, + payload: { + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope: UNCONFIGURED, + }, + emitMetricsEvent: async () => {}, + }) + ).rejects.toMatchObject({ output: { statusCode: 403 } }); + + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'oauth.token_exchange.resolution', + { service: 'legacy', outcome: 'rejected_not_in_allowed_scopes' } + ); + }); + + it('fails closed with "rejected_unknown_decision" on an unknown result shape', async () => { + await expect( + runExchange( + { [OAUTH_SCOPE_RELAY]: { result: 'wat', service: 'relay' } }, + OAUTH_SCOPE_RELAY + ) + ).rejects.toMatchObject({ output: { statusCode: 403 } }); + expect(mockStatsD.increment).toHaveBeenCalledWith( + 'oauth.token_exchange.resolution', + { service: 'relay', outcome: 'rejected_unknown_decision' } + ); + }); + + it('emits one resolution metric per service even when several scopes share it', async () => { + // Two unrelated mozilla.com scopes that the test stub claims both + // resolve to the same service. payload.scope is passed as a + // ScopeSet here because validateTokenExchangeGrant calls + // subjectToken.scope.union(requestedScope) directly, and the + // ScopeSet coerce path does not split a raw whitespace-separated + // string. In production the route's Joi schema does the coercion. + const scopeSet = ScopeSet.fromString( + `${OAUTH_SCOPE_RELAY} ${OAUTH_SCOPE_OLD_SYNC}` + ); + mockStatsD.increment.mockClear(); + jest.resetModules(); + jest.doMock('../../oauth/assertion', () => async () => true); + jest.doMock( + '../../oauth/client', + () => tokenRoutesDepMocks['../../oauth/client'] + ); + jest.doMock('../../oauth/grant', () => ({ + generateTokens: (grant: any) => ({ + access_token: 'at', + token_type: 'bearer', + scope: grant.scope.toString(), + }), + validateRequestedGrant: () => ({ offline: true, scope: 'testo' }), + })); + jest.doMock( + '../../oauth/util', + () => tokenRoutesDepMocks['../../oauth/util'] + ); + jest.doMock('../utils/oauth', () => ({ + newTokenNotification: async () => {}, + })); + jest.doMock('../../oauth/token', () => ({ + verify: jest.fn().mockResolvedValue({ user: UID }), + })); + + const routes = require('./token')({ + ...tokenRoutesArgMocks, + db: { + ...tokenRoutesArgMocks.db, + async deviceFromRefreshTokenId() { + return null; + }, + }, + oauthDB: { + ...tokenRoutesArgMocks.oauthDB, + async getRefreshToken() { + return { + userId: buf(UID), + clientId: buf(FIREFOX_IOS_CLIENT_ID), + tokenId: buf('1234567890abcdef'), + scope: scopeSet, + profileChangedAt: Date.now(), + }; + }, + async removeRefreshToken() {}, + async hasConsentForExchange() { + return { result: 'allowed', service: 'relay' }; + }, + }, + }); + + await routes[1].handler({ + auth: { credentials: null }, + headers: {}, + payload: { + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope: scopeSet, + }, + emitMetricsEvent: async () => {}, + }); + + expect(resolutionTagsFor('relay')).toHaveLength(1); + }); + + it('passes through to the rest of the handler when all scopes are granted', async () => { + // fall-through for an unmapped scope must not short-circuit; the + // request still returns a real token from generateTokens. + const result = await runExchange( + { [OAUTH_SCOPE_RELAY]: { result: 'fall-through' } }, + OAUTH_SCOPE_RELAY + ); + expect(result.refresh_token).toBe('rt'); + }); + }); + describe('fxa-credentials with reason=token_migration', () => { it('calls newTokenNotification with skipEmail: true when reason is token_migration', async () => { const newTokenNotificationStub = jest.fn().mockResolvedValue(); diff --git a/packages/fxa-auth-server/test/remote/account_consents.in.spec.ts b/packages/fxa-auth-server/test/remote/account_consents.in.spec.ts new file mode 100644 index 00000000000..2883e394f70 --- /dev/null +++ b/packages/fxa-auth-server/test/remote/account_consents.in.spec.ts @@ -0,0 +1,310 @@ +/* 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/. */ + +import crypto from 'crypto'; +import { + getSharedTestServer, + TestServerInstance, +} from '../support/helpers/test-server'; +import clientFactory from '../client'; +import db from '../../lib/oauth/db'; + +const Client = clientFactory(); + +const RELAY_SCOPE = 'https://identity.mozilla.com/apps/relay'; +const SMARTWINDOW_SCOPE = 'https://identity.mozilla.com/apps/smartwindow'; +const VPN_SCOPE = 'https://identity.mozilla.com/apps/vpn'; +const OLDSYNC_SCOPE = 'https://identity.mozilla.com/apps/oldsync'; +const PROFILE_SCOPE = 'profile'; +const UNKNOWN_SCOPE = 'https://identity.mozilla.com/apps/never-seen'; + +const DESKTOP = '5882386c6d801776'; +const IOS = '1b1a3e44c54fbb58'; +const E2E_PUBLIC_CLIENT_ID = '3c49430b43dfba77'; +const PKCE_CODE_CHALLENGE = 'YPhkZqm08uTfwjNSiYcx80-NPT9Zn94kHboQW97KyV0'; + +const newUid = () => crypto.randomBytes(16).toString('hex'); + +let server: TestServerInstance; +const dirtyUids: string[] = []; + +beforeAll(async () => { + await db.ready(); + server = await getSharedTestServer(); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +afterEach(async () => { + for (const id of dirtyUids.splice(0)) { + await db.deleteAllConsentsForUser(id); + } +}); + +function track(id: string) { + dirtyUids.push(id); + return id; +} + +async function seed(opts: { + uid: string; + scope: string; + service: string; + clientId?: string; + now?: number; +}) { + await db.recordSignInConsent({ + clientId: DESKTOP, + now: Date.now(), + ...opts, + }); +} + +describe('isKnownService (config-driven)', () => { + it.each([ + ['sync', true], + ['relay', true], + ['smartwindow', true], + ['vpn', true], + ['not-a-real-service', false], + ['', false], + // 16-char hex clientId, to confirm it's not silently matched as a service name + ['5882386c6d801776', false], + ])('%s -> %s', (name, expected) => { + expect(db.isKnownService(name)).toBe(expected); + }); + + it('falsy non-string input returns false', () => { + // @ts-expect-error - defensive falsy handling + expect(db.isKnownService(undefined)).toBe(false); + }); +}); + +describe('accountConsents repository', () => { + it('round-trips upsert + find + delete', async () => { + const id = track(newUid()); + await seed({ uid: id, scope: RELAY_SCOPE, service: 'relay' }); + expect(await db.hasConsentForSignIn(id, RELAY_SCOPE, 'relay')).toBe(true); + await db.deleteAllConsentsForUser(id); + expect(await db.hasConsentForSignIn(id, RELAY_SCOPE, 'relay')).toBe(false); + }); + + it('returns false for unknown (uid, scope, service)', async () => { + expect(await db.hasConsentForSignIn(newUid(), RELAY_SCOPE, 'relay')).toBe( + false + ); + }); + + it('upsert preserves firstAuthorizedTosAt, bumps lastAuthorizedTosAt', async () => { + const id = track(newUid()); + const t0 = Date.now(); + const t1 = t0 + 86_400_000; + await seed({ + uid: id, + scope: SMARTWINDOW_SCOPE, + service: 'smartwindow', + now: t0, + }); + await seed({ + uid: id, + scope: SMARTWINDOW_SCOPE, + service: 'smartwindow', + now: t1, + }); + const [row] = await db.listAccountConsentsByUid(id); + expect(Number(row.firstAuthorizedTosAt)).toBe(t0); + expect(Number(row.lastAuthorizedTosAt)).toBe(t1); + }); + + it('different clientIds for the same (scope, service) are distinct rows', async () => { + const id = track(newUid()); + await seed({ + uid: id, + scope: OLDSYNC_SCOPE, + service: 'sync', + clientId: DESKTOP, + }); + await seed({ + uid: id, + scope: OLDSYNC_SCOPE, + service: 'sync', + clientId: IOS, + }); + expect(await db.listAccountConsentsByUid(id)).toHaveLength(2); + }); + + it('cross-device: hasConsentForSignIn matches across clientIds', async () => { + const id = track(newUid()); + await seed({ + uid: id, + scope: OLDSYNC_SCOPE, + service: 'sync', + clientId: DESKTOP, + }); + expect(await db.hasConsentForSignIn(id, OLDSYNC_SCOPE, 'sync')).toBe(true); + }); + + it('deleteAllConsentsForUser scopes to one user', async () => { + const a = track(newUid()); + const b = track(newUid()); + await seed({ uid: a, scope: RELAY_SCOPE, service: 'relay' }); + await seed({ uid: b, scope: RELAY_SCOPE, service: 'relay' }); + await db.deleteAllConsentsForUser(a); + expect(await db.listAccountConsentsByUid(a)).toHaveLength(0); + expect(await db.listAccountConsentsByUid(b)).toHaveLength(1); + }); + + it('removeTokensAndCodes preserves consent rows (password reset path)', async () => { + const id = track(newUid()); + await seed({ uid: id, scope: SMARTWINDOW_SCOPE, service: 'smartwindow' }); + expect(await db.listAccountConsentsByUid(id)).toHaveLength(1); + await db.removeTokensAndCodes(id); + expect(await db.listAccountConsentsByUid(id)).toHaveLength(1); + }); +}); + +describe('hasConsentForExchange decision matrix', () => { + it('Sync deny wins even with a service=sync consent row', async () => { + const id = track(newUid()); + await seed({ uid: id, scope: OLDSYNC_SCOPE, service: 'sync' }); + expect(await db.hasConsentForExchange(id, OLDSYNC_SCOPE)).toMatchObject({ + result: 'denied', + service: 'sync', + reason: 'silent-disallowed', + }); + }); + + it('Relay scope bypasses the consent check', async () => { + const id = track(newUid()); + expect(await db.hasConsentForExchange(id, RELAY_SCOPE)).toMatchObject({ + result: 'bypass', + service: 'relay', + }); + }); + + it('mapped scope with consent returns allowed', async () => { + const id = track(newUid()); + await seed({ uid: id, scope: SMARTWINDOW_SCOPE, service: 'smartwindow' }); + expect(await db.hasConsentForExchange(id, SMARTWINDOW_SCOPE)).toMatchObject( + { + result: 'allowed', + service: 'smartwindow', + } + ); + }); + + it('mapped scope without consent returns denied no-consent', async () => { + const id = track(newUid()); + expect(await db.hasConsentForExchange(id, SMARTWINDOW_SCOPE)).toMatchObject( + { + result: 'denied', + service: 'smartwindow', + reason: 'no-consent', + } + ); + }); + + it('cross-flow: service=vpn consent grants apps/vpn exchange', async () => { + // Desktop signs in for VPN with service=vpn but oldsync+profile scopes. + // The row's service is what counts; later VPN exchanges resolve through it. + const id = track(newUid()); + await seed({ uid: id, scope: OLDSYNC_SCOPE, service: 'vpn' }); + expect(await db.hasConsentForExchange(id, VPN_SCOPE)).toMatchObject({ + result: 'allowed', + service: 'vpn', + }); + }); + + it('Sync-only consent does not authorize VPN exchange', async () => { + const id = track(newUid()); + await seed({ uid: id, scope: OLDSYNC_SCOPE, service: 'sync' }); + expect(await db.hasConsentForExchange(id, VPN_SCOPE)).toMatchObject({ + result: 'denied', + service: 'vpn', + reason: 'no-consent', + }); + }); + + it.each([ + ['profile (unmapped)', PROFILE_SCOPE], + ['unknown URL (no mapping)', UNKNOWN_SCOPE], + ])('%s falls through', async (_label, scope) => { + const id = track(newUid()); + expect(await db.hasConsentForExchange(id, scope)).toMatchObject({ + result: 'fall-through', + }); + }); +}); + +describe('#integration - /authorization writes accountConsents rows', () => { + let testClient: any; + + beforeEach(async () => { + testClient = await Client.createAndVerify( + server.publicUrl, + server.uniqueEmail(), + 'test password', + server.mailbox, + { version: '' } + ); + track(testClient.uid); + }); + + function authParams(overrides: Record = {}) { + return { + client_id: E2E_PUBLIC_CLIENT_ID, + scope: OLDSYNC_SCOPE, + state: 'xyz', + access_type: 'offline', + code_challenge: PKCE_CODE_CHALLENGE, + code_challenge_method: 'S256', + ...overrides, + }; + } + + it('writes a row with service= when the service is configured', async () => { + await testClient.createAuthorizationCode(authParams({ service: 'sync' })); + const rows = await db.listAccountConsentsByUid(testClient.uid); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ scope: OLDSYNC_SCOPE, service: 'sync' }); + expect(rows[0].clientId.toString('hex')).toBe(E2E_PUBLIC_CLIENT_ID); + }); + + it.each([ + ['unrecognised service=', { service: 'fake-svc' }], + ['no service= on URL', {}], + ])('drops %s to empty string', async (_label, overrides) => { + await testClient.createAuthorizationCode(authParams(overrides)); + const rows = await db.listAccountConsentsByUid(testClient.uid); + expect(rows).toHaveLength(1); + expect(rows[0].service).toBe(''); + }); + + it('preserves firstAuthorizedTosAt and bumps lastAuthorizedTosAt on re-grant', async () => { + await testClient.createAuthorizationCode(authParams({ service: 'sync' })); + const [first] = await db.listAccountConsentsByUid(testClient.uid); + const firstAt = Number(first.firstAuthorizedTosAt); + const lastAt = Number(first.lastAuthorizedTosAt); + + await new Promise((r) => setTimeout(r, 5)); + await testClient.createAuthorizationCode( + authParams({ service: 'sync', state: 'abc' }) + ); + + const [after] = await db.listAccountConsentsByUid(testClient.uid); + expect(Number(after.firstAuthorizedTosAt)).toBe(firstAt); + expect(Number(after.lastAuthorizedTosAt)).toBeGreaterThanOrEqual(lastAt); + }); + + it('online grants also write consent rows', async () => { + await testClient.createAuthorizationCode( + authParams({ service: 'sync', access_type: 'online' }) + ); + const rows = await db.listAccountConsentsByUid(testClient.uid); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ scope: OLDSYNC_SCOPE, service: 'sync' }); + }); +}); diff --git a/packages/fxa-auth-server/test/remote/account_consents_exchange.in.spec.ts b/packages/fxa-auth-server/test/remote/account_consents_exchange.in.spec.ts new file mode 100644 index 00000000000..a11555f49bd --- /dev/null +++ b/packages/fxa-auth-server/test/remote/account_consents_exchange.in.spec.ts @@ -0,0 +1,134 @@ +/* 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/. */ + +import { OAUTH_SCOPE_OLD_SYNC } from 'fxa-shared/oauth/constants'; +import { + getSharedTestServer, + TestServerInstance, +} from '../support/helpers/test-server'; +import clientFactory from '../client'; +import db from '../../lib/oauth/db'; + +const Client = clientFactory(); + +const IOS = '1b1a3e44c54fbb58'; +const RELAY_SCOPE = 'https://identity.mozilla.com/apps/relay'; +const SMARTWINDOW_SCOPE = 'https://identity.mozilla.com/apps/smartwindow'; +const VPN_SCOPE = 'https://identity.mozilla.com/apps/vpn'; +const UNCONFIGURED_SCOPE = 'https://identity.mozilla.com/apps/never-seen'; + +const GRANT_TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange'; +const SUBJECT_TOKEN_TYPE_REFRESH = + 'urn:ietf:params:oauth:token-type:refresh_token'; + +let server: TestServerInstance; + +beforeAll(async () => { + await db.ready(); + server = await getSharedTestServer(); +}, 120000); + +afterAll(async () => { + await server.stop(); +}); + +describe('#integration - /oauth/token exchange honors accountConsents', () => { + let client: any; + let refreshToken: string; + + beforeEach(async () => { + client = await Client.createAndVerify( + server.publicUrl, + server.uniqueEmail(), + 'test password', + server.mailbox, + { version: '' } + ); + await db.deleteAllConsentsForUser(client.uid); + const tokens = await client.grantOAuthTokensFromSessionToken({ + grant_type: 'fxa-credentials', + client_id: IOS, + access_type: 'offline', + scope: OAUTH_SCOPE_OLD_SYNC, + }); + refreshToken = tokens.refresh_token; + }); + + afterEach(async () => { + if (client?.uid) { + await db.deleteAllConsentsForUser(client.uid); + } + }); + + async function seed(scope: string, service: string) { + await db.recordSignInConsent({ + uid: client.uid, + scope, + service, + clientId: IOS, + now: Date.now(), + }); + } + + function exchange(scope: string) { + return client.grantOAuthTokens({ + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: refreshToken, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope, + }); + } + + it('Relay bypass grants without a consent row and writes none', async () => { + const result = await exchange(RELAY_SCOPE); + expect(result.access_token).toBeTruthy(); + expect(result.scope).toContain(RELAY_SCOPE); + const rows = await db.listAccountConsentsByUid(client.uid); + expect(rows.find((r: any) => r.service === 'relay')).toBeUndefined(); + }); + + it('SmartWindow without consent rejects', async () => { + await expect(exchange(SMARTWINDOW_SCOPE)).rejects.toMatchObject({ + errno: 112, + }); + }); + + it('SmartWindow with matching consent grants', async () => { + await seed(SMARTWINDOW_SCOPE, 'smartwindow'); + const result = await exchange(SMARTWINDOW_SCOPE); + expect(result.access_token).toBeTruthy(); + expect(result.scope).toContain(SMARTWINDOW_SCOPE); + }); + + it('cross-flow: service=vpn consent grants apps/vpn exchange', async () => { + // Desktop sign-in for VPN carries service=vpn with oldsync+profile + // scopes; the row's `service` is what later VPN exchanges resolve to. + await seed(OAUTH_SCOPE_OLD_SYNC, 'vpn'); + const result = await exchange(VPN_SCOPE); + expect(result.access_token).toBeTruthy(); + expect(result.scope).toContain(VPN_SCOPE); + }); + + it('Sync-only consent does not authorize VPN exchange', async () => { + await seed(OAUTH_SCOPE_OLD_SYNC, 'sync'); + await expect(exchange(VPN_SCOPE)).rejects.toMatchObject({ errno: 112 }); + }); + + it('Sync deny wins even with a service=sync consent row', async () => { + await seed(OAUTH_SCOPE_OLD_SYNC, 'sync'); + await expect(exchange(OAUTH_SCOPE_OLD_SYNC)).rejects.toMatchObject({ + errno: 112, + }); + }); + + it('multi-scope including Sync is rejected (deny wins)', async () => { + await expect( + exchange(`${RELAY_SCOPE} ${OAUTH_SCOPE_OLD_SYNC}`) + ).rejects.toMatchObject({ errno: 112 }); + }); + + it('unconfigured scope falls through to clients.allowedScopes', async () => { + await expect(exchange(UNCONFIGURED_SCOPE)).rejects.toBeTruthy(); + }); +});