From 6bd8c01ff2a8f2a147dc771eb2c71f36c6119afb Mon Sep 17 00:00:00 2001 From: Lauren Zugai Date: Thu, 22 Jan 2026 18:49:14 -0600 Subject: [PATCH] feat(oauth): Add token exchange grant option to oauth/token Because: * We want to allow a refresh token exchange for Mobile, that grants Relay as an additional scope, as the users enrolled will already have signed into Relay web This commit: * Adds the new grant type, sets client IDs and allowed scopes to env vars, currently set to mobile IDs and only Relay scope closes FXA-12925 --- libs/accounts/errors/src/oauth-error.ts | 12 + packages/fxa-auth-server/config/index.ts | 14 + .../docs/swagger/shared/descriptions.ts | 4 + .../fxa-auth-server/lib/routes/oauth/token.js | 154 ++++++- .../test/oauth/routes/token.js | 395 ++++++++++++++++++ 5 files changed, 578 insertions(+), 1 deletion(-) diff --git a/libs/accounts/errors/src/oauth-error.ts b/libs/accounts/errors/src/oauth-error.ts index 864c8221e1a..da0c202ea1c 100644 --- a/libs/accounts/errors/src/oauth-error.ts +++ b/libs/accounts/errors/src/oauth-error.ts @@ -418,6 +418,18 @@ export class OauthError extends Error { { clientId } ); } + + static unauthorizedTokenExchangeClient(clientId: string) { + return new OauthError( + { + code: 400, + error: 'Unauthorized Client', + errno: OAUTH_ERRNO.UNAUTHORIZED, + message: 'Client is not authorized for token exchange', + }, + { clientId } + ); + } } /** diff --git a/packages/fxa-auth-server/config/index.ts b/packages/fxa-auth-server/config/index.ts index ed609079395..786c19deac0 100644 --- a/packages/fxa-auth-server/config/index.ts +++ b/packages/fxa-auth-server/config/index.ts @@ -1381,6 +1381,20 @@ const convictConf = convict({ env: 'FXA_REFRESH_TOKEN_UPDATE_AFTER', }, }, + tokenExchange: { + allowedClientIds: { + doc: 'Client IDs allowed to perform token exchange (only Firefox mobile clients as of FXA-12925)', + format: Array, + default: ['1b1a3e44c54fbb58', '3332a18d142636cb', 'a2270f727f45f648'], + env: 'OAUTH_TOKEN_EXCHANGE_CLIENT_IDS', + }, + allowedScopes: { + doc: 'Scopes that can be requested via token exchange grant type', + format: Array, + default: ['https://identity.mozilla.com/apps/relay'], + env: 'OAUTH_TOKEN_EXCHANGE_ALLOWED_SCOPES', + }, + }, git: { commit: { doc: 'Commit SHA when in stage/production', diff --git a/packages/fxa-auth-server/docs/swagger/shared/descriptions.ts b/packages/fxa-auth-server/docs/swagger/shared/descriptions.ts index 0d1ac9feca2..d9db076a258 100644 --- a/packages/fxa-auth-server/docs/swagger/shared/descriptions.ts +++ b/packages/fxa-auth-server/docs/swagger/shared/descriptions.ts @@ -244,6 +244,10 @@ const DESCRIPTIONS = { status: 'The status of the product (e.g. `active`, `canceled`, `trialing`, `unpaid`, etc).', sub: 'The hex id of the user.', + subjectToken: + 'The token to be exchanged. Used with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` per RFC 8693.', + subjectTokenType: + 'A URN identifying the type of subject_token. Must be `urn:ietf:params:oauth:token-type:refresh_token` to indicate the subject_token is a refresh token.', subscriptionId: 'A unique identifier for the Stripe [subscription](https://stripe.com/docs/api/subscriptions/object).', subscriptions: 'A list of all subscriptions (including web and IAP).', diff --git a/packages/fxa-auth-server/lib/routes/oauth/token.js b/packages/fxa-auth-server/lib/routes/oauth/token.js index 76744738a90..d2e2aa162e0 100644 --- a/packages/fxa-auth-server/lib/routes/oauth/token.js +++ b/packages/fxa-auth-server/lib/routes/oauth/token.js @@ -68,12 +68,24 @@ const GRANT_REFRESH_TOKEN = 'refresh_token'; // FxA identity assertion rather than directly specifying a password. // [1] https://tools.ietf.org/html/rfc6749#section-1.3.3 const GRANT_FXA_ASSERTION = 'fxa-credentials'; +// Token exchange grant type per RFC 8693 +// 2.1 https://www.rfc-editor.org/rfc/rfc8693.html +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'; const ACCESS_TYPE_ONLINE = 'online'; const ACCESS_TYPE_OFFLINE = 'offline'; 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. const SCOPES_TO_EXCLUDE_FROM_REFRESH_TOKEN_GRANTS = ScopeSet.fromArray([ @@ -100,6 +112,10 @@ const PAYLOAD_SCHEMA = Joi.object({ is: GRANT_FXA_ASSERTION, then: Joi.optional(), }) + .when('grant_type', { + is: GRANT_TOKEN_EXCHANGE, + then: Joi.forbidden(), + }) .description(DESCRIPTION.clientSecret), redirect_uri: validators.redirectUri @@ -111,7 +127,12 @@ const PAYLOAD_SCHEMA = Joi.object({ .description(DESCRIPTION.redirectUri), grant_type: Joi.string() - .valid(GRANT_AUTHORIZATION_CODE, GRANT_REFRESH_TOKEN, GRANT_FXA_ASSERTION) + .valid( + GRANT_AUTHORIZATION_CODE, + GRANT_REFRESH_TOKEN, + GRANT_FXA_ASSERTION, + GRANT_TOKEN_EXCHANGE + ) .default(GRANT_AUTHORIZATION_CODE) .optional() .description(DESCRIPTION.grantTypeOauth), @@ -130,6 +151,10 @@ const PAYLOAD_SCHEMA = Joi.object({ .conditional('grant_type', { is: GRANT_FXA_ASSERTION, then: validators.scope.required(), + }) + .conditional('grant_type', { + is: GRANT_TOKEN_EXCHANGE, + then: validators.scope.required(), otherwise: Joi.forbidden(), }) .description(DESCRIPTION.scope), @@ -177,6 +202,24 @@ const PAYLOAD_SCHEMA = Joi.object({ }) .description(DESCRIPTION.assertion), + // Token exchange fields (RFC 8693) + subject_token: validators.token + .when('grant_type', { + is: GRANT_TOKEN_EXCHANGE, + then: Joi.required(), + otherwise: Joi.forbidden(), + }) + .description(DESCRIPTION.subjectToken), + + subject_token_type: Joi.string() + .valid(SUBJECT_TOKEN_TYPE_REFRESH) + .when('grant_type', { + is: GRANT_TOKEN_EXCHANGE, + then: Joi.required(), + otherwise: Joi.forbidden(), + }) + .description(DESCRIPTION.subjectTokenType), + ppid_seed: validators.ppidSeed.optional().description(DESCRIPTION.ppidSeed), resource: validators.resourceUrl.optional().description(DESCRIPTION.resource), @@ -195,6 +238,9 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => { case GRANT_FXA_ASSERTION: requestedGrant = await validateAssertionGrant(client, params); break; + case GRANT_TOKEN_EXCHANGE: + requestedGrant = await validateTokenExchangeGrant(client, params); + break; default: // Joi validation means this should never happen. throw Error('unreachable'); @@ -349,6 +395,58 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => { return await validateRequestedGrant(claims, client, params); } + /** + * Validate a token exchange grant (RFC 8693). + * Allows exchanging a token for a new token with additional scopes. + * + * For now, this is only used for Mobile Relay to request a new refresh token + * for already signed in users that have previously authorized the Relay scope. + * This check happens on their side, and for now we will grant the request. + * See FXA-12925 + */ + async function validateTokenExchangeGrant(client, params) { + const subjectToken = await oauthDB.getRefreshToken( + encrypt.hash(params.subject_token) + ); + if (!subjectToken) { + log.debug('token_exchange.subject_token.notFound'); + throw OauthError.invalidToken(); + } + + // Verify token belongs to an allowed Firefox client + const originalClientId = hex(subjectToken.clientId); + if (!TOKEN_EXCHANGE_ALLOWED_CLIENT_IDS.has(originalClientId)) { + log.debug('token_exchange.unauthorized_client', { + clientId: originalClientId, + }); + throw OauthError.unauthorizedTokenExchangeClient(originalClientId); + } + + // Validate requested scope is in allowlist + 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(); + } + + // Original scope plus requested scope, e.g. Sync + Relay + const combinedScope = subjectToken.scope.union(requestedScope); + + return { + userId: subjectToken.userId, + clientId: subjectToken.clientId, + scope: combinedScope, + offline: true, + authAt: Math.floor(Date.now() / 1000), + profileChangedAt: subjectToken.profileChangedAt, + originalRefreshTokenId: subjectToken.tokenId, // for revocation after new token generation + }; + } + /** * Generate a PKCE code_challenge * See https://tools.ietf.org/html/rfc7636#section-4.6 for details @@ -376,6 +474,30 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => { } const grant = await validateGrantParameters(client, params); const tokens = await generateTokens(grant); + + // For token exchange, revoke the original refresh token after successful generation + if ( + params.grant_type === GRANT_TOKEN_EXCHANGE && + grant.originalRefreshTokenId + ) { + try { + await oauthDB.removeRefreshToken({ + tokenId: grant.originalRefreshTokenId, + }); + log.info('token_exchange.original_token_revoked', { + userId: hex(grant.userId), + clientId: hex(grant.clientId), + }); + } catch (err) { + // Log but don't fail the request if revocation fails + log.warn('token_exchange.revocation_failed', { + userId: hex(grant.userId), + clientId: hex(grant.clientId), + error: err.message, + }); + } + } + const uid = hex(grant.userId); const oauthClientId = hex(grant.clientId); @@ -510,6 +632,17 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => { ttl: Joi.number().positive().optional(), resource: validators.resourceUrl.optional(), assertion: Joi.forbidden(), + }), + // token exchange (RFC 8693) + Joi.object({ + grant_type: Joi.string().valid(GRANT_TOKEN_EXCHANGE).required(), + subject_token: validators.refreshToken.required(), + subject_token_type: Joi.string() + .valid(SUBJECT_TOKEN_TYPE_REFRESH) + .required(), + scope: validators.scope.required(), + ttl: Joi.number().positive().optional(), + resource: validators.resourceUrl.optional(), }) ), }, @@ -555,6 +688,14 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => { auth_at: Joi.number().required(), token_type: Joi.string().valid('bearer').required(), expires_in: Joi.number().required(), + }), + // token exchange + Joi.object({ + access_token: validators.accessToken.required(), + refresh_token: validators.refreshToken.required(), + scope: validators.scope.required(), + token_type: Joi.string().valid('bearer').required(), + expires_in: Joi.number().required(), }) ), }, @@ -586,6 +727,17 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => { ); grant = await tokenHandler(req); break; + case GRANT_TOKEN_EXCHANGE: + try { + grant = await tokenHandler(req); + } catch (err) { + // TODO auth/oauth error reconciliation + if (err.errno === 108) { + throw AuthError.invalidToken(); + } + throw err; + } + break; default: throw AuthError.internalValidationError(); } diff --git a/packages/fxa-auth-server/test/oauth/routes/token.js b/packages/fxa-auth-server/test/oauth/routes/token.js index d0ef1da1029..b5891509511 100644 --- a/packages/fxa-auth-server/test/oauth/routes/token.js +++ b/packages/fxa-auth-server/test/oauth/routes/token.js @@ -363,6 +363,334 @@ describe('/token POST', function () { }); }); +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'; +const FIREFOX_IOS_CLIENT_ID = '1b1a3e44c54fbb58'; +const RELAY_SCOPE = 'https://identity.mozilla.com/apps/relay'; +const SYNC_SCOPE = 'https://identity.mozilla.com/apps/oldsync'; + +describe('token exchange grant_type', function () { + const route = tokenRoutes[0]; + + describe('input validation', () => { + function v(req) { + const validationSchema = route.config.validate.payload; + return validationSchema.validate(req); + } + + it('requires subject_token when grant_type is token-exchange', () => { + const res = v({ + client_id: CLIENT_ID, + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope: RELAY_SCOPE, + }); + joiRequired(res.error, 'subject_token'); + }); + + it('requires subject_token_type when grant_type is token-exchange', () => { + const res = v({ + client_id: CLIENT_ID, + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + scope: RELAY_SCOPE, + }); + joiRequired(res.error, 'subject_token_type'); + }); + + it('requires scope when grant_type is token-exchange', () => { + const res = v({ + client_id: CLIENT_ID, + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + }); + joiRequired(res.error, 'scope'); + }); + + it('forbids subject_token for other grant types', () => { + const res = v({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + grant_type: 'authorization_code', + code: CODE, + subject_token: REFRESH_TOKEN, + }); + joiNotAllowed(res.error, 'subject_token'); + }); + + it('forbids subject_token_type for other grant types', () => { + const res = v({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + grant_type: 'authorization_code', + code: CODE, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + }); + joiNotAllowed(res.error, 'subject_token_type'); + }); + + it('forbids client_secret for token-exchange', () => { + const res = v({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope: RELAY_SCOPE, + }); + joiNotAllowed(res.error, 'client_secret'); + }); + + it('accepts valid token exchange request', () => { + const res = v({ + client_id: CLIENT_ID, + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope: RELAY_SCOPE, + }); + assert.equal(res.error, undefined); + }); + }); + + describe('validateTokenExchangeGrant', () => { + const ScopeSet = require('fxa-shared').oauth.scopes; + + it('rejects non-existent subject_token', async () => { + const routes = proxyquire(tokenRoutePath, { + ...tokenRoutesDepMocks, + })({ + ...tokenRoutesArgMocks, + oauthDB: { + ...tokenRoutesArgMocks.oauthDB, + async getRefreshToken() { + return null; + }, + }, + }); + const request = { + headers: {}, + payload: { + client_id: FIREFOX_IOS_CLIENT_ID, + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope: RELAY_SCOPE, + }, + emitMetricsEvent: () => {}, + }; + try { + await routes[0].config.handler(request); + assert.fail('should have errored'); + } catch (err) { + assert.equal(err.errno, 108); // Invalid token + } + }); + + it('rejects tokens from non-Firefox clients', async () => { + const NON_FIREIOS_FOX_CLIENT_ID = '123456789a'; + const routes = proxyquire(tokenRoutePath, { + ...tokenRoutesDepMocks, + })({ + ...tokenRoutesArgMocks, + oauthDB: { + ...tokenRoutesArgMocks.oauthDB, + async getRefreshToken() { + return { + userId: buf(UID), + clientId: buf(NON_FIREIOS_FOX_CLIENT_ID), + tokenId: buf('1234567890abcdef'), + scope: ScopeSet.fromString(SYNC_SCOPE), + profileChangedAt: Date.now(), + }; + }, + }, + }); + const request = { + headers: {}, + payload: { + client_id: NON_FIREIOS_FOX_CLIENT_ID, + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope: RELAY_SCOPE, + }, + emitMetricsEvent: () => {}, + }; + try { + await routes[0].config.handler(request); + assert.fail('should have errored'); + } catch (err) { + assert.equal(err.errno, 111); // Unauthorized + assert.include(err.message, 'not authorized for token exchange'); + } + }); + + it('rejects unauthorized scopes', async () => { + const UNAUTHORIZED_SCOPE = + 'https://identity.mozilla.com/apps/unauthorized'; + const routes = proxyquire(tokenRoutePath, { + ...tokenRoutesDepMocks, + })({ + ...tokenRoutesArgMocks, + oauthDB: { + ...tokenRoutesArgMocks.oauthDB, + async getRefreshToken() { + return { + userId: buf(UID), + clientId: buf(FIREFOX_IOS_CLIENT_ID), + tokenId: buf('1234567890abcdef'), + scope: ScopeSet.fromString(SYNC_SCOPE), + profileChangedAt: Date.now(), + }; + }, + }, + }); + const request = { + headers: {}, + payload: { + client_id: FIREFOX_IOS_CLIENT_ID, + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope: UNAUTHORIZED_SCOPE, + }, + emitMetricsEvent: () => {}, + }; + try { + await routes[0].config.handler(request); + assert.fail('should have errored'); + } catch (err) { + assert.equal(err.errno, 112); // Forbidden + } + }); + + it('returns combined scopes on success', async () => { + let removedTokenId = null; + const routes = proxyquire(tokenRoutePath, { + ...tokenRoutesDepMocks, + '../../oauth/grant': { + generateTokens: (grant) => { + // Verify combined scope is passed to token generation + assert.isTrue(grant.scope.contains(SYNC_SCOPE)); + assert.isTrue(grant.scope.contains(RELAY_SCOPE)); + return { + access_token: 'new_access_token', + token_type: 'bearer', + scope: grant.scope.toString(), + expires_in: 3600, + refresh_token: 'new_refresh_token', + }; + }, + validateRequestedGrant: () => ({ offline: true, scope: 'testo' }), + }, + })({ + ...tokenRoutesArgMocks, + log: { + debug: () => {}, + warn: () => {}, + info: () => {}, + }, + oauthDB: { + ...tokenRoutesArgMocks.oauthDB, + async getRefreshToken() { + return { + userId: buf(UID), + clientId: buf(FIREFOX_IOS_CLIENT_ID), + tokenId: buf('1234567890abcdef'), + scope: ScopeSet.fromString(SYNC_SCOPE), + profileChangedAt: Date.now(), + }; + }, + async removeRefreshToken({ tokenId }) { + removedTokenId = tokenId; + }, + }, + }); + + const request = { + headers: {}, + payload: { + client_id: FIREFOX_IOS_CLIENT_ID, + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope: RELAY_SCOPE, + }, + emitMetricsEvent: () => {}, + }; + + const result = await routes[0].config.handler(request); + + assert.equal(result.access_token, 'new_access_token'); + assert.equal(result.refresh_token, 'new_refresh_token'); + assert.include(result.scope, SYNC_SCOPE); + assert.include(result.scope, RELAY_SCOPE); + // Verify original token was revoked + assert.isNotNull(removedTokenId); + }); + + it('revokes original token after successful exchange', async () => { + let removedTokenId = null; + const originalTokenId = '1234567890abcdef'; + + const routes = proxyquire(tokenRoutePath, { + ...tokenRoutesDepMocks, + '../../oauth/grant': { + generateTokens: (grant) => ({ + access_token: 'new_access_token', + token_type: 'bearer', + scope: grant.scope.toString(), + expires_in: 3600, + refresh_token: 'new_refresh_token', + }), + validateRequestedGrant: () => ({ offline: true, scope: 'testo' }), + }, + })({ + ...tokenRoutesArgMocks, + log: { + debug: () => {}, + warn: () => {}, + info: () => {}, + }, + oauthDB: { + ...tokenRoutesArgMocks.oauthDB, + async getRefreshToken() { + return { + userId: buf(UID), + clientId: buf(FIREFOX_IOS_CLIENT_ID), + tokenId: buf(originalTokenId), + scope: ScopeSet.fromString(SYNC_SCOPE), + profileChangedAt: Date.now(), + }; + }, + async removeRefreshToken({ tokenId }) { + removedTokenId = hex(tokenId); + }, + }, + }); + + const request = { + headers: {}, + payload: { + client_id: FIREFOX_IOS_CLIENT_ID, + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope: RELAY_SCOPE, + }, + emitMetricsEvent: () => {}, + }; + + await routes[0].config.handler(request); + + assert.equal(removedTokenId, originalTokenId); + }); + }); +}); + describe('/oauth/token POST', function () { describe('update session last access time', async () => { const sessionToken = { uid: 'abc' }; @@ -419,4 +747,71 @@ describe('/oauth/token POST', function () { sinon.assert.notCalled(mockDb.touchSessionToken); }); }); + + describe('token exchange via /oauth/token', () => { + const ScopeSet = require('fxa-shared').oauth.scopes; + + it('handles token exchange with multiple existing scopes (sync + profile)', async () => { + const PROFILE_SCOPE = 'profile'; + const routes = proxyquire(tokenRoutePath, { + ...tokenRoutesDepMocks, + '../../oauth/grant': { + generateTokens: (grant) => ({ + access_token: 'new_access_token', + token_type: 'bearer', + scope: grant.scope.toString(), + expires_in: 3600, + refresh_token: 'new_refresh_token', + }), + validateRequestedGrant: () => ({ offline: true, scope: 'testo' }), + }, + '../utils/oauth': { + newTokenNotification: async () => {}, + }, + })({ + ...tokenRoutesArgMocks, + log: { + debug: () => {}, + warn: () => {}, + info: () => {}, + }, + oauthDB: { + ...tokenRoutesArgMocks.oauthDB, + async getRefreshToken() { + // Original token has both sync and profile scopes + return { + userId: buf(UID), + clientId: buf(FIREFOX_IOS_CLIENT_ID), + tokenId: buf('1234567890abcdef'), + scope: ScopeSet.fromString(`${SYNC_SCOPE} ${PROFILE_SCOPE}`), + profileChangedAt: Date.now(), + }; + }, + async removeRefreshToken() {}, + }, + }); + + const request = { + auth: { credentials: null }, + headers: {}, + payload: { + client_id: FIREFOX_IOS_CLIENT_ID, + grant_type: GRANT_TOKEN_EXCHANGE, + subject_token: REFRESH_TOKEN, + subject_token_type: SUBJECT_TOKEN_TYPE_REFRESH, + scope: RELAY_SCOPE, + }, + emitMetricsEvent: async () => {}, + }; + + const result = await routes[1].handler(request); + + assert.equal(result.access_token, 'new_access_token'); + assert.equal(result.refresh_token, 'new_refresh_token'); + // Should have all three scopes: sync, profile, and relay + assert.include(result.scope, SYNC_SCOPE); + assert.include(result.scope, PROFILE_SCOPE); + assert.include(result.scope, RELAY_SCOPE); + }); + }); });