@@ -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
7070const 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
7277const ACCESS_TYPE_ONLINE = 'online' ;
7378const ACCESS_TYPE_OFFLINE = 'offline' ;
7479
7580const 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.
7991const 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