diff --git a/docs/adr/0048-refresh-tokens-and-account-level-authorization.md b/docs/adr/0048-refresh-tokens-and-account-level-authorization.md new file mode 100644 index 00000000000..dd281e24a53 --- /dev/null +++ b/docs/adr/0048-refresh-tokens-and-account-level-authorization.md @@ -0,0 +1,135 @@ +# Refresh tokens for browser service authorization and account-level consent + +- Status: accepted +- Deciders: Lauren Zugai, Mark Hammond, Jonathan Almeida, Wil Clouser, Ben Dean-Kawamura, Ross Otto, Vijay Budhram, Luke Crouch, David Durst +- Date: 2026-01-16 + +Technical Story: FXA-12946 (Relay Mobile MVP), FXA-12541 (Relay post-MVP / other services), FXA-12940 (other FxA improvements) + +## Context and Problem Statement + +Firefox desktop browser services like Relay and Smart Window use session tokens to create OAuth access tokens on behalf of the user. This approach has several problems: + +- **No scope enforcement**: Session tokens are not bound to any scopes -- Firefox can use a session token to mint access tokens for any scope in Firefox's allowlist at any time via the `fxa-credentials` grant, regardless of what scopes were originally requested during sign-in. This enables scope expansion (e.g., Sync to Sync+Relay) without explicit user consent. +- **No user visibility or control**: There is no entry under Settings > Connected Services for these service grants. FxA collapses all session-token-derived grants into a single "Firefox" entry. Users cannot see which browser services they have opted into or revoke consent for individual services without destroying the session token entirely, which signs them out of the browser. +- **Conflation of authentication and authorization**: Session tokens prove authentication ("who you are"), while OAuth tokens prove authorization ("what you're allowed to do"). Using session tokens for service access repurposes an authentication credential for authorization delegation. + +Mobile already uses refresh tokens to create access tokens (unlike Desktop, which discards the refresh token generated during sign-in and uses the session token instead). The intent to move Desktop to refresh tokens had been discussed informally, but the Mobile team's development of a Relay browser integration using refresh tokens drove the full set of decisions captured here. Additionally, signed-in users who need access to a new service (e.g., a Sync user enabling Relay) need a mechanism to upgrade their token's scopes without re-entering their password, since key-bearing scopes like `oldsync` would trigger the scoped keys code path. + +This ADR covers four interrelated decisions: (1) switching from session tokens to refresh tokens for browser service authorization, (2) the migration strategy for existing Desktop users, (3) the consent model for account-level authorization, and (4) the token exchange mechanism for scope upgrades. + +## Decision Drivers + +- Users must feel in control of their account and the services they choose to use, with an easy way to view and revoke granted consent +- Refresh tokens appear as revocable grants in Connected Services; session token grants cannot be individually revoked +- OAuth best practices separate authentication (session tokens) from authorization (OAuth tokens with explicit scopes) +- Mobile Relay MVP needs to launch without requiring a major refactoring effort to bounce users back to FxA UI +- Users who have already consented to a service should not be re-prompted for consent on another device or platform, to reduce friction +- Scoped keys (Sync) require password-derived key material -- when adding a non-key-bearing scope (e.g., Relay) to a signed-in user's existing refresh token, the process must not trigger password entry +- RFC 6749 Section 6 prohibits scope expansion on standard refresh token grants, so a token exchange mechanism is needed +- Eventual migration to `node-oidc-provider` (ADR 0042) favors using a recognized grant type that the library supports via its `registerGrantType()` API + +## Considered Options + +### Token type for browser service authorization + +- **Session tokens** Firefox uses the session token to mint OAuth access tokens for browser services +- **Refresh tokens**: Firefox obtains a scoped refresh token per service context and uses it to create access tokens + +### Migration from session tokens to refresh tokens for existing desktop browser services + +- **Option 1: Re-authentication**: Users re-authenticate with FxA the next time they use a service, receiving a refresh token with the appropriate scopes +- **Option 2: Silent grant**: FxA grants a refresh token with requested scopes via an API endpoint, matching the existing trust model where Firefox already mints access tokens with any allowlisted scope + +### Consent model + +- **Option A: Implicit consent by granted tokens**: Consent is implicit in the existence of a refresh token with the relevant scopes. No persistent authorization record beyond the tokens themselves. +- **Option B: Explicit account-level authorization**: Consent is recorded at the account level in a new table in the FxA database. When signing in on a new device, FxA checks existing authorizations and issues tokens without re-prompting. + +### Token exchange mechanism for scope upgrades + +- **Option I: Use the session token with full scopes via `fxa-credentials` grant**: Reuse the existing custom grant type, passing a session token and the complete desired scope set with `access_type=offline` to obtain a new refresh token. This is already implemented in application-services (`create_refresh_token_using_session_token`). The client would then revoke the old refresh token and manage the device record. +- **Option II: RFC 8693 token exchange with full desired scope set**: Use the standard token exchange grant type, passing the existing refresh token and the complete desired scope set per RFC 8693 semantics +- **Option III: RFC 8693 token exchange with additional scopes only**: Use the standard token exchange grant type, passing the existing refresh token and only the new scopes needed; the server unions them with the original token's scopes +- **Option IV: Authorization code exchange with broader scopes**: Use the session token to silently obtain an authorization code with the full desired scope set (`authorize_code_using_session_token` in application-services), then exchange the code for a new refresh token (`create_refresh_token_using_authorization_code`). This is a two-step flow but does not require a browser redirect — both calls are server-to-server. + +## Decision Outcome + +### Use refresh tokens for browser service authorization + +Chosen because refresh tokens provide individual revocability in Connected Services, enforce explicit scopes, and properly separate authentication from authorization. Session tokens remain for authenticating with FxA itself but should not be used for ongoing service authorization. + +Mobile Relay will launch using a refresh token. Desktop Relay, Smart Window, and Sync will migrate to refresh tokens via silent grant. + +### Migrate existing users via silent grant (Option 2) + +Chosen because it avoids user friction. Since session tokens already grant OAuth access tokens with any allowlisted scope, granting a refresh token with the same scopes is not worse from a security or trust perspective -- it simply makes the existing implicit authorization explicit and revocable. Firefox can determine which scopes to request based on local signals (e.g., `signon.firefoxRelay.feature` is enabled, Sync data exists). + +Re-authentication (Option 1) was rejected because it would disrupt ~110k existing Relay Desktop users by forcing them to re-authenticate for a capability they already use. + +### Use explicit account-level authorization (Option B) + +Chosen because it provides a persistent audit trail, enables cross-device consent sharing, and decouples consent from token lifecycle. + +Account-level authorizations are stored in a new table in the FxA database. When a user authorizes a service on any device, FxA records it at the account level. When signing in on a new device, FxA checks whether the user has already authorized the requested service and scope -- if so, it issues the refresh token without re-prompting for consent. This follows a similar model to [Google's Cross-client Identity](https://developers.google.com/identity/protocols/oauth2/cross-client-identity), where multi-component apps (e.g., Relay web and Relay in the browser) fall under a single project umbrella and share authorization. + +Existing users with active refresh or access tokens will be backfilled into the authorizations table via a migration script. Users can revoke account-level authorization through a new section in Connected Services. + +Option A (implicit consent) was rejected because token-only consent does not persist across devices, has no audit trail, and makes cross-device consent sharing fragile. + +### Use RFC 8693 token exchange with additional scopes only (Option III) + +When a signed-in user needs an additional scope (e.g., a Sync user enabling Relay), the browser sends a token exchange request containing the existing refresh token as the `subject_token` and only the additional scopes needed in the `scope` parameter. The server unions the original token's scopes with the requested scopes to produce the new token's scope set, then revokes the original refresh token. + +Option I (`fxa-credentials` with session token) was rejected because: + +- **No server-side policy enforcement**: `fxa-credentials` grants whatever scopes the client requests within the client's allowlist. There is no opportunity for FxA to check authorization state — e.g., whether the user has actually consented to this service or whether an account-level authorization exists. The token exchange (Option III) provides a distinct server-side decision point where these checks naturally live, which is essential for the account-level authorization model (Option B above). +- **Client manages token lifecycle**: The client would need to request the full scope set, revoke the old refresh token, and handle the device record. Application-services already has this plumbing, but centralizing it server-side in the token exchange is simpler for the client and ensures consistency. + +Option II (RFC 8693 with full scope set) was rejected because: + +- **Client complexity**: The browser would need to introspect its existing refresh token to reconstruct the full scope list. Sending only additional scopes is less error-prone (cannot accidentally drop scopes) and aligns with the design philosophy in ADR 0049 where the server owns scope resolution for first-party clients. + +Option IV (authorization code exchange) was rejected because: + +- **Two-step flow with no additional benefit over Option I**: Like Option I, it uses the session token and goes through `validateRequestedGrant`, so it shares the same policy enforcement gap and sync scope caution concerns. The extra step of obtaining an authorization code before exchanging it for a refresh token adds complexity without providing a server-side authorization gate that Option I doesn't already have. +- **Client manages token lifecycle**: Same as Option I — the client must handle revoking the old refresh token and managing the device record. + +**Note on `node-oidc-provider` migration**: The `fxa-credentials` grant is used for bootstrapping refresh tokens during initial Firefox sign-in and will require custom registration via `registerGrantType()` in `node-oidc-provider` (ADR 0042) regardless. Option I reuses that grant and does not add a second custom grant type. Options II and III add RFC 8693 token exchange as a second custom grant type — it is a recognized grant type with a [documented example in `node-oidc-provider`](https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#custom-grant-types). Option IV uses the standard authorization code flow and does not add a custom grant type. + +**Note on sync scope caution**: Options I and IV go through `validateRequestedGrant`, which grants sync scopes to any verified session. Historically, FxA has been careful about granting sync scopes — requiring an email verification loop for most users on top of the password entry. Any verified session could request sync scopes through these paths without additional gates. Option III avoids this because it does not go through `validateRequestedGrant` — the existing refresh token already proves the user authorized those scopes through the original, more rigorous flow. + +**Note on RFC 8693 compliance**: RFC 8693 Section 2.1 defines `scope` as "the desired scope of the requested security token," which conventionally means the full desired scope set. Our implementation treats it as additional scopes only, which is a deliberate deviation. This is acceptable for a first-party-only flow between Firefox and FxA, and should be documented as an FxA-specific convention. The response correctly returns the combined scope set in the `scope` field. + +### Positive Consequences + +- Users gain visibility and control over browser service authorizations through Connected Services +- Cross-device consent sharing eliminates redundant re-authorization (e.g., authorizing Relay on web carries to mobile) +- Relay Mobile MVP can launch without bouncing users to FxA UI -- if the user authorized Relay on web, the token exchange succeeds silently +- Scope upgrades for non-key-bearing services do not trigger password entry +- The token exchange mechanism uses a recognized RFC grant type, easing eventual `node-oidc-provider` migration +- Silent grant migration avoids disrupting existing Relay Desktop users + +### Negative Consequences + +- The `scope` parameter semantics in the token exchange deviate from RFC 8693 -- this must be documented and understood by implementers +- Account-level authorization requires a new database table, migration script, and UI additions to Connected Services +- Two custom grant types (`fxa-credentials` and token exchange) must be maintained and eventually registered with `node-oidc-provider` + +## Note on per-service, per-platform client IDs + +Currently, all Firefox browser services (Relay, Smart Window, Sync) share Firefox's single OAuth client ID. A possible future direction is to assign each service its own client ID per platform (e.g., Relay-in-Desktop, Relay-in-iOS, Relay-in-Android). This maps directly to [Google's Cross-client Identity](https://developers.google.com/identity/protocols/oauth2/cross-client-identity) model, where multiple client IDs are grouped under a single project umbrella and share authorization at the account level. This would make browser service metrics consistent with how FxA already tracks third-party RPs by `client_id`, and scope allowlists could be managed per `client_id` using standard OAuth client registration rather than the novel `service`-based resolution described in ADR 0049. However, it's not necessarily better OAuth practice overall — Firefox is the actual client receiving the tokens, not the individual services. + +The decisions in this ADR are compatible with that migration. Account-level authorization would function like Google's project umbrella — applying across client IDs for the same service. The token exchange mechanism and silent grant migration path would not need to change. See also ADR 0049, which notes this as a decision driver for server-side scope resolution. + +## Links + +- Reference: [Decision Brief - Authorization in Non-Sync Firefox Login Flows](https://docs.google.com/document/d/1sR5t6GbmK6yjx5Dj8B_MSE9ckq_MbQFclUvEHnsBI2M/) +- Reference: [Moving Desktop Firefox to the FxA "Refresh Token"](https://docs.google.com/document/d/1sPLQHayKgmsRJ8fQ61u_yCjwt_o2hrAMaKYPfo2GKzI/) +- Related: [ADR 0042 - Use node-oidc-provider for OAuth](0042-use-node-oidc-for-oauth.md) +- Related: [ADR 0049 - Server-side scope resolution via service parameter](0049-service-driven-scope-resolution.md) +- Reference: [RFC 8693 - OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693.html) +- Reference: [RFC 6749 Section 6 - Refreshing an Access Token](https://www.rfc-editor.org/rfc/rfc6749.html#section-6) +- Reference: [RFC 6749 Section 1.3.3 - Resource Owner Password Credentials](https://www.rfc-editor.org/rfc/rfc6749.html#section-1.3.3) +- Reference: [Google Cross-client Identity](https://developers.google.com/identity/protocols/oauth2/cross-client-identity) +- Reference: [node-oidc-provider Custom Grant Types (including token exchange example)](https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#custom-grant-types) diff --git a/docs/adr/0049-service-driven-scope-resolution.md b/docs/adr/0049-service-driven-scope-resolution.md new file mode 100644 index 00000000000..63bb7d30025 --- /dev/null +++ b/docs/adr/0049-service-driven-scope-resolution.md @@ -0,0 +1,99 @@ +# Server-side scope resolution via service parameter for Firefox OAuth flows + +- Status: proposed +- Deciders: Lauren Zugai, Mark Hammond +- Date: 2026-04-15 + +## Context and Problem Statement + +Firefox is a first-party OAuth client of FxA. Across OAuth flows (sign-up, sign-in, and incremental scope authorization), Firefox currently must specify the exact `scope` query parameter in the authorization URL. (Note: today this applies to Firefox mobile clients, which use refresh tokens for ongoing access. The migration of Desktop from session-token-based access token creation to refresh tokens is covered in [ADR 0048](0048-refresh-tokens-and-account-level-authorization.md).) + +This creates a coupling problem. If FxA Product decisions change which services are offered during a flow (e.g., offering an "Enable Monitor in Firefox" checkbox during initial account creation), Firefox must ship a client-side update to change the requested scopes. More fundamentally, Firefox shouldn't need awareness of which optional services FxA might offer during a given flow -- and when options are involved, the granted scopes won't match the requested scopes regardless, since users may decline options. Either Firefox over-requests scopes speculatively (option 2), or the server must be able to resolve and grant the appropriate scopes independently of what the client requested. + +Meanwhile, FxA must maintain its own scope-to-service allowlist mapping regardless of approach to render the correct consent UI, validate that requested scopes are allowed, and enforce what gets granted. + +The `service` query parameter already identifies the product context. Since the server-side mapping is unavoidable, should FxA own scope resolution entirely for first-party clients based on `service`? + +## Goals + +- Decouple product decisions about which browser services are offered during Firefox auth flows from the Firefox desktop release cycle +- Allow FxA to offer optional services during auth flows (e.g., "Enable Monitor?") where granted scopes may differ from what was initially requested, without requiring client awareness of those options + +## Decision Drivers + +- This applies to all first-party Firefox-to-FxA OAuth flows: initial sign-up, sign-in, and incremental scope authorization for signed-in users +- Scoped keys (Sync) require password-derived key material -- scopes that need `keys_jwk` cannot be included in passwordless-entry flows regardless of approach +- FxA already maintains client registrations with `allowedScopes`, scope-to-key mappings, and consent logic server-side +- Product requirements change (e.g., "offer Monitor during sign-up") and should not require Firefox desktop release cycles +- RFC 9700 Section 2.3 (OAuth Security Best Current Practice) recommends least-privilege tokens but focuses on what is _granted_, not what is _requested_ -- the server is the enforcement point +- Firefox is a trusted first-party client, not a third-party app -- the traditional OAuth scope request model was designed for untrusted clients. Firefox is the special case because it's the only RP where Product can decide what browser services to offer during auth flows, and tying those decisions to the Firefox desktop release cycle is not ideal. Other RPs that need additional scopes can update their authorization URL or redirect users back to FxA with the additional scope requested. +- The approach should work and make sense regardless of whether Firefox browser services continue sharing Firefox's client ID or move to per-service, per-platform client IDs (e.g., Relay-in-Desktop, Relay-in-iOS) in the future + +## Considered Options + +- **Option 1**: Client specifies exact scopes -- Firefox sends `service=vpn&scope=https://identity.mozilla.com/apps/vpn` +- **Option 2**: Client requests all possible scopes, server narrows -- Firefox sends every scope it might ever need, FxA grants only what applies +- **Option 3**: Server resolves scopes from service -- Firefox sends `service=vpn`, FxA determines the required and optional scopes for that service context + +## Decision Outcome + +Chosen option: "Option 3 -- Server resolves scopes from service", because it eliminates client-server coupling for product decisions, supports flows where granted scopes differ from requested scopes (e.g., optional service checkboxes), avoids the scoped keys problem of option 2, and aligns with FxA already being the authority on scope-to-service mappings. + +When `service` is specified, FxA resolves the applicable scopes server-side — `service` is a more concise way of specifying scopes, and the `scope` parameter can be dropped. When `service` is not specified (e.g., normal RP redirects), `scope` continues to work as it does today. + +If both `service` and `scope` are specified, the behavior follows the current model: only the requested scope can be granted (unless it's Desktop using the session token, which can currently get any scope it wants). If one of the requested scopes is not in the allowlist, it will error with "invalid scope" as it does today. In the future, if we allow the user to deny certain requested scopes that are in the allowlist, the refresh token will only contain the subset of scopes the user approved, and Firefox will know the granted scopes via the `scopes` field in the OAuth WebChannel message. + +### Positive Consequences + +- Product changes to which services/scopes are offered during a flow require only a server-side configuration change, not a Firefox release +- The server can exclude key-bearing scopes (like `oldsync`) from passwordless flows, which is impossible if the client requests all scopes upfront (option 2) +- Eliminates redundancy where `service=vpn&scope=vpn` conveyed the same information twice for simple cases +- FxA can offer optional scopes (e.g., "Also enable Monitor in Firefox?") without the client needing awareness of those options +- Scopes implied by `service` can be derived from the same mapping used for consent rendering and token granting -- single source of truth + +### Negative Consequences + +- Non-standard OAuth pattern -- new developers familiar with OAuth must learn this FxA-specific convention +- The client loses the ability to express nuanced scope intent beyond the `service` identifier (e.g., two features that both use `service=vpn` but want different optional scopes offered) +- Scope resolution logic becomes a server-side responsibility that must be maintained and documented + +## Pros and Cons of the Options + +### Option 1: Client specifies exact scopes + +Standard OAuth approach where Firefox includes the `scope` parameter with the specific scopes it needs. + +- Good, because it follows the standard OAuth 2.0 protocol (RFC 6749 Section 3.3) +- Good, because the client explicitly communicates what access it needs +- Bad, because every product change to scope offerings requires a Firefox client update and release cycle +- Bad, because the server must still maintain its own scope-to-service mappings independently, so having the client also specify scopes creates two sources of truth that must stay in sync +- Bad, because if optional scopes are added to a flow (e.g., Monitor-in-Firefox option during sign-up, Relay checkbox on VPN page), the client must know about them in advance +- Good, because it is compatible with a possible future migration to per-service, per-platform client IDs + +### Option 2: Client requests all possible scopes, server narrows + +Firefox requests every scope it could ever need, and FxA grants only the relevant subset. + +- Good, because the client never needs updating when new scopes become available +- Bad, because including `oldsync` or other key-bearing scopes in the request triggers the scoped keys code path, forcing password entry -- this breaks the passwordless scope authorization flow +- Bad, because the server cannot distinguish intent ("I need VPN" vs "I need everything") +- Bad, because the consent page cannot meaningfully display what is being requested +- Good, because it is compatible with a possible future migration to per-service, per-platform client IDs + +### Option 3: Server resolves scopes from service + +Firefox sends only the `service` parameter instead of `scope`. FxA resolves the required and optional scopes, renders the consent page, and grants only what the user approves. + +- Good, because product scope changes are server-side configuration, no client release needed +- Good, because the server already maintains these mappings and is the enforcement point for grants +- Good, because it only requires a password entry when the Sync scope is actually needed +- Good, because it aligns with RFC 9700 Section 2.3 which places privilege restriction responsibility on the authorization server +- Neutral, because the client loses granular control over scopes -- if two features both use the same `service` value but need different scopes, the client has no way to express that distinction. This is mitigated by using a different `service` value for each distinct scope set, or by specifying `scope` directly (which takes precedence). Using other flow context like `entrypoint` to vary scope resolution would complicate the contract and is not recommended. +- Good, because it is compatible with per-service, per-platform client IDs -- scope resolution shifts from `service` to `client_id` with no change in approach +- Bad, because it deviates from standard OAuth scope request patterns + +## Links + +- Reference: [Moving Desktop Firefox to the FxA "Refresh Token"](https://docs.google.com/document/d/1sPLQHayKgmsRJ8fQ61u_yCjwt_o2hrAMaKYPfo2GKzI/) +- Reference: [RFC 9700 Section 2.3 - Access Token Privilege Restriction](https://www.rfc-editor.org/rfc/rfc9700.html#section-2.3) +- Reference: [RFC 6749 Section 3.3 - Access Token Scope](https://www.rfc-editor.org/rfc/rfc6749.html#section-3.3)