Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions libs/accounts/errors/src/oauth-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
);
}
}

/**
Expand Down
14 changes: 14 additions & 0 deletions packages/fxa-auth-server/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).',
Expand Down
154 changes: 153 additions & 1 deletion packages/fxa-auth-server/lib/routes/oauth/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand All @@ -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
Expand All @@ -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),
Expand All @@ -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),
Expand Down Expand Up @@ -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),
Expand All @@ -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');
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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(),
})
),
},
Expand Down Expand Up @@ -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(),
})
),
},
Expand Down Expand Up @@ -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();
}
Expand Down
Loading
Loading