Skip to content

Commit ebc1bb4

Browse files
committed
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
1 parent c7b8e74 commit ebc1bb4

5 files changed

Lines changed: 578 additions & 1 deletion

File tree

libs/accounts/errors/src/oauth-error.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,18 @@ export class OauthError extends Error {
418418
{ clientId }
419419
);
420420
}
421+
422+
static unauthorizedTokenExchangeClient(clientId: string) {
423+
return new OauthError(
424+
{
425+
code: 400,
426+
error: 'Unauthorized Client',
427+
errno: OAUTH_ERRNO.UNAUTHORIZED,
428+
message: 'Client is not authorized for token exchange',
429+
},
430+
{ clientId }
431+
);
432+
}
421433
}
422434

423435
/**

packages/fxa-auth-server/config/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1381,6 +1381,20 @@ const convictConf = convict({
13811381
env: 'FXA_REFRESH_TOKEN_UPDATE_AFTER',
13821382
},
13831383
},
1384+
tokenExchange: {
1385+
allowedClientIds: {
1386+
doc: 'Client IDs allowed to perform token exchange (only Firefox mobile clients as of FXA-12925)',
1387+
format: Array,
1388+
default: ['1b1a3e44c54fbb58', '3332a18d142636cb', 'a2270f727f45f648'],
1389+
env: 'OAUTH_TOKEN_EXCHANGE_CLIENT_IDS',
1390+
},
1391+
allowedScopes: {
1392+
doc: 'Scopes that can be requested via token exchange grant type',
1393+
format: Array,
1394+
default: ['https://identity.mozilla.com/apps/relay'],
1395+
env: 'OAUTH_TOKEN_EXCHANGE_ALLOWED_SCOPES',
1396+
},
1397+
},
13841398
git: {
13851399
commit: {
13861400
doc: 'Commit SHA when in stage/production',

packages/fxa-auth-server/docs/swagger/shared/descriptions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,10 @@ const DESCRIPTIONS = {
244244
status:
245245
'The status of the product (e.g. `active`, `canceled`, `trialing`, `unpaid`, etc).',
246246
sub: 'The hex id of the user.',
247+
subjectToken:
248+
'The token to be exchanged. Used with `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` per RFC 8693.',
249+
subjectTokenType:
250+
'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.',
247251
subscriptionId:
248252
'A unique identifier for the Stripe [subscription](https://stripe.com/docs/api/subscriptions/object).',
249253
subscriptions: 'A list of all subscriptions (including web and IAP).',

packages/fxa-auth-server/lib/routes/oauth/token.js

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,24 @@ const GRANT_REFRESH_TOKEN = 'refresh_token';
6868
// FxA identity assertion rather than directly specifying a password.
6969
// [1] https://tools.ietf.org/html/rfc6749#section-1.3.3
7070
const GRANT_FXA_ASSERTION = 'fxa-credentials';
71+
// Token exchange grant type per RFC 8693
72+
// 2.1 https://www.rfc-editor.org/rfc/rfc8693.html
73+
const GRANT_TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange';
74+
const SUBJECT_TOKEN_TYPE_REFRESH =
75+
'urn:ietf:params:oauth:token-type:refresh_token';
7176

7277
const ACCESS_TYPE_ONLINE = 'online';
7378
const ACCESS_TYPE_OFFLINE = 'offline';
7479

7580
const DISABLED_CLIENTS = new Set(config.get('oauthServer.disabledClients'));
7681

82+
const TOKEN_EXCHANGE_ALLOWED_CLIENT_IDS = new Set(
83+
config.get('oauthServer.tokenExchange.allowedClientIds')
84+
);
85+
const TOKEN_EXCHANGE_ALLOWED_SCOPES = ScopeSet.fromArray(
86+
config.get('oauthServer.tokenExchange.allowedScopes')
87+
);
88+
7789
// These scopes are used to request a one-off exchange of claims or credentials,
7890
// but they don't make sense to use on an ongoing basis via refresh tokens.
7991
const SCOPES_TO_EXCLUDE_FROM_REFRESH_TOKEN_GRANTS = ScopeSet.fromArray([
@@ -100,6 +112,10 @@ const PAYLOAD_SCHEMA = Joi.object({
100112
is: GRANT_FXA_ASSERTION,
101113
then: Joi.optional(),
102114
})
115+
.when('grant_type', {
116+
is: GRANT_TOKEN_EXCHANGE,
117+
then: Joi.forbidden(),
118+
})
103119
.description(DESCRIPTION.clientSecret),
104120

105121
redirect_uri: validators.redirectUri
@@ -111,7 +127,12 @@ const PAYLOAD_SCHEMA = Joi.object({
111127
.description(DESCRIPTION.redirectUri),
112128

113129
grant_type: Joi.string()
114-
.valid(GRANT_AUTHORIZATION_CODE, GRANT_REFRESH_TOKEN, GRANT_FXA_ASSERTION)
130+
.valid(
131+
GRANT_AUTHORIZATION_CODE,
132+
GRANT_REFRESH_TOKEN,
133+
GRANT_FXA_ASSERTION,
134+
GRANT_TOKEN_EXCHANGE
135+
)
115136
.default(GRANT_AUTHORIZATION_CODE)
116137
.optional()
117138
.description(DESCRIPTION.grantTypeOauth),
@@ -130,6 +151,10 @@ const PAYLOAD_SCHEMA = Joi.object({
130151
.conditional('grant_type', {
131152
is: GRANT_FXA_ASSERTION,
132153
then: validators.scope.required(),
154+
})
155+
.conditional('grant_type', {
156+
is: GRANT_TOKEN_EXCHANGE,
157+
then: validators.scope.required(),
133158
otherwise: Joi.forbidden(),
134159
})
135160
.description(DESCRIPTION.scope),
@@ -177,6 +202,24 @@ const PAYLOAD_SCHEMA = Joi.object({
177202
})
178203
.description(DESCRIPTION.assertion),
179204

205+
// Token exchange fields (RFC 8693)
206+
subject_token: validators.token
207+
.when('grant_type', {
208+
is: GRANT_TOKEN_EXCHANGE,
209+
then: Joi.required(),
210+
otherwise: Joi.forbidden(),
211+
})
212+
.description(DESCRIPTION.subjectToken),
213+
214+
subject_token_type: Joi.string()
215+
.valid(SUBJECT_TOKEN_TYPE_REFRESH)
216+
.when('grant_type', {
217+
is: GRANT_TOKEN_EXCHANGE,
218+
then: Joi.required(),
219+
otherwise: Joi.forbidden(),
220+
})
221+
.description(DESCRIPTION.subjectTokenType),
222+
180223
ppid_seed: validators.ppidSeed.optional().description(DESCRIPTION.ppidSeed),
181224

182225
resource: validators.resourceUrl.optional().description(DESCRIPTION.resource),
@@ -195,6 +238,9 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => {
195238
case GRANT_FXA_ASSERTION:
196239
requestedGrant = await validateAssertionGrant(client, params);
197240
break;
241+
case GRANT_TOKEN_EXCHANGE:
242+
requestedGrant = await validateTokenExchangeGrant(client, params);
243+
break;
198244
default:
199245
// Joi validation means this should never happen.
200246
throw Error('unreachable');
@@ -349,6 +395,58 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => {
349395
return await validateRequestedGrant(claims, client, params);
350396
}
351397

398+
/**
399+
* Validate a token exchange grant (RFC 8693).
400+
* Allows exchanging a token for a new token with additional scopes.
401+
*
402+
* For now, this is only used for Mobile Relay to request a new refresh token
403+
* for already signed in users that have previously authorized the Relay scope.
404+
* This check happens on their side, and for now we will grant the request.
405+
* See FXA-12925
406+
*/
407+
async function validateTokenExchangeGrant(client, params) {
408+
const subjectToken = await oauthDB.getRefreshToken(
409+
encrypt.hash(params.subject_token)
410+
);
411+
if (!subjectToken) {
412+
log.debug('token_exchange.subject_token.notFound');
413+
throw OauthError.invalidToken();
414+
}
415+
416+
// Verify token belongs to an allowed Firefox client
417+
const originalClientId = hex(subjectToken.clientId);
418+
if (!TOKEN_EXCHANGE_ALLOWED_CLIENT_IDS.has(originalClientId)) {
419+
log.debug('token_exchange.unauthorized_client', {
420+
clientId: originalClientId,
421+
});
422+
throw OauthError.unauthorizedTokenExchangeClient(originalClientId);
423+
}
424+
425+
// Validate requested scope is in allowlist
426+
const requestedScope = params.scope;
427+
if (!TOKEN_EXCHANGE_ALLOWED_SCOPES.contains(requestedScope)) {
428+
log.debug('token_exchange.scope_not_allowed', {
429+
requested: requestedScope.toString(),
430+
allowed: TOKEN_EXCHANGE_ALLOWED_SCOPES.toString(),
431+
});
432+
// TODO future auth table checks, FXA-12937
433+
throw OauthError.forbidden();
434+
}
435+
436+
// Original scope plus requested scope, e.g. Sync + Relay
437+
const combinedScope = subjectToken.scope.union(requestedScope);
438+
439+
return {
440+
userId: subjectToken.userId,
441+
clientId: subjectToken.clientId,
442+
scope: combinedScope,
443+
offline: true,
444+
authAt: Math.floor(Date.now() / 1000),
445+
profileChangedAt: subjectToken.profileChangedAt,
446+
originalRefreshTokenId: subjectToken.tokenId, // for revocation after new token generation
447+
};
448+
}
449+
352450
/**
353451
* Generate a PKCE code_challenge
354452
* 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 }) => {
376474
}
377475
const grant = await validateGrantParameters(client, params);
378476
const tokens = await generateTokens(grant);
477+
478+
// For token exchange, revoke the original refresh token after successful generation
479+
if (
480+
params.grant_type === GRANT_TOKEN_EXCHANGE &&
481+
grant.originalRefreshTokenId
482+
) {
483+
try {
484+
await oauthDB.removeRefreshToken({
485+
tokenId: grant.originalRefreshTokenId,
486+
});
487+
log.info('token_exchange.original_token_revoked', {
488+
userId: hex(grant.userId),
489+
clientId: hex(grant.clientId),
490+
});
491+
} catch (err) {
492+
// Log but don't fail the request if revocation fails
493+
log.warn('token_exchange.revocation_failed', {
494+
userId: hex(grant.userId),
495+
clientId: hex(grant.clientId),
496+
error: err.message,
497+
});
498+
}
499+
}
500+
379501
const uid = hex(grant.userId);
380502
const oauthClientId = hex(grant.clientId);
381503

@@ -510,6 +632,17 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => {
510632
ttl: Joi.number().positive().optional(),
511633
resource: validators.resourceUrl.optional(),
512634
assertion: Joi.forbidden(),
635+
}),
636+
// token exchange (RFC 8693)
637+
Joi.object({
638+
grant_type: Joi.string().valid(GRANT_TOKEN_EXCHANGE).required(),
639+
subject_token: validators.refreshToken.required(),
640+
subject_token_type: Joi.string()
641+
.valid(SUBJECT_TOKEN_TYPE_REFRESH)
642+
.required(),
643+
scope: validators.scope.required(),
644+
ttl: Joi.number().positive().optional(),
645+
resource: validators.resourceUrl.optional(),
513646
})
514647
),
515648
},
@@ -555,6 +688,14 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => {
555688
auth_at: Joi.number().required(),
556689
token_type: Joi.string().valid('bearer').required(),
557690
expires_in: Joi.number().required(),
691+
}),
692+
// token exchange
693+
Joi.object({
694+
access_token: validators.accessToken.required(),
695+
refresh_token: validators.refreshToken.required(),
696+
scope: validators.scope.required(),
697+
token_type: Joi.string().valid('bearer').required(),
698+
expires_in: Joi.number().required(),
558699
})
559700
),
560701
},
@@ -586,6 +727,17 @@ module.exports = ({ log, oauthDB, db, mailer, devices, statsd, glean }) => {
586727
);
587728
grant = await tokenHandler(req);
588729
break;
730+
case GRANT_TOKEN_EXCHANGE:
731+
try {
732+
grant = await tokenHandler(req);
733+
} catch (err) {
734+
// TODO auth/oauth error reconciliation
735+
if (err.errno === 108) {
736+
throw AuthError.invalidToken();
737+
}
738+
throw err;
739+
}
740+
break;
589741
default:
590742
throw AuthError.internalValidationError();
591743
}

0 commit comments

Comments
 (0)