Skip to content

Add the ability to start a new OAuth flow while authenticated, so you can authorize additional scopes [ci full]#7306

Open
mhammond wants to merge 1 commit intomozilla:mainfrom
mhammond:push-mkxvwknxqksu
Open

Add the ability to start a new OAuth flow while authenticated, so you can authorize additional scopes [ci full]#7306
mhammond wants to merge 1 commit intomozilla:mainfrom
mhammond:push-mkxvwknxqksu

Conversation

@mhammond
Copy link
Copy Markdown
Member

@mhammond mhammond commented Apr 7, 2026

As @jonalmeida and I were chatting about. @bendk does this look correct to you? @LZoog and @skhamis for some context for our slack messages.

This supports desktop-like flows, where a user is already signed in to (say) Sync, but not wants to authorize "vpn" or "smartwindow".

I used the term "Authorization" to try and reflect we aren't expected to "authenticate" here - we are already authenticated - we typically expect just a confirmation button. I'm not entirely convinced the distinction between "authorize" and "authenticate" is enough, so the names might be confusing - I started with something like "UpgradeScopes" etc. Regardless, better names welcome here.

I've tried to keep the names consistent, which made them verbose. Better names welcome here too.

Pull Request checklist

  • Breaking changes: This PR follows our breaking change policy
    • This PR follows the breaking change policy:
      • This PR has no breaking API changes, or
      • There are corresponding PRs for our consumer applications that resolve the breaking changes and have been approved
  • Quality: This PR builds and tests run cleanly
    • Note:
      • For changes that need extra cross-platform testing, consider adding [ci full] to the PR title.
      • If this pull request includes a breaking change, consider cutting a new release after merging.
  • Tests: This PR includes thorough tests or an explanation of why it does not
  • Changelog: This PR includes a changelog entry in CHANGELOG.md or an explanation of why it does not need one
    • Any breaking changes to Swift or Kotlin binding APIs are noted explicitly
  • Dependencies: This PR follows our dependency management guidelines
    • Any new dependencies are accompanied by a summary of the due diligence applied in selecting them.

Copy link
Copy Markdown
Contributor

@bendk bendk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks pretty much correct to me, except I think there's one more step to do.

My main takeaway is that these state machines feel overly complicated. It seems like you should be able to add an extra event, add code in a corresponding switch case, and that's it. When we were doing the Android migration and using the state machine tester to verify the code, we were getting some value in exchange. Now, more and more I'm feeling like we could rework this and simplify it.

FxaEvent::CheckAuthorizationStatus => Ok(CheckAuthorizationStatus),
FxaEvent::CallGetProfile => Ok(GetProfile),
FxaEvent::BeginOAuthScopeAuthorizationFlow { scopes, entrypoint } => {
Ok(BeginOAuthFlow { scopes, entrypoint })
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you'll need a new internal state for this since it should call begin_oauth_scope_authorization_flow() instead of begin_oauth_flow(). Ditto for the complete flow event.

state_machine/internal_machines/mod.rs should get some extra cases to map the new states to the right calls.

@mhammond mhammond force-pushed the push-mkxvwknxqksu branch 2 times, most recently from f2e57d0 to 735d3d7 Compare April 10, 2026 05:56
@mhammond
Copy link
Copy Markdown
Member Author

https://phabricator.services.mozilla.com/D293447 is the fenix side of this. I think I got the semantics correct for the new flow, but it's hard to know if it doesn't work for that reason or for an fxa reason - I know Lauren mentioned some work needs to be done on the fxa side for this to work. This PR however doesn't seem like a breaking change and should be safe even if it need tweaks - wdyt?

@mhammond mhammond force-pushed the push-mkxvwknxqksu branch from 735d3d7 to ad06176 Compare April 14, 2026 10:12
@mhammond
Copy link
Copy Markdown
Member Author

This looks pretty much correct to me, except I think there's one more step to do.

I agree, but not sure it should happen here? wdyt? Otherwise I think this seems ready to roll!

@mhammond
Copy link
Copy Markdown
Member Author

/taskcluster ci full

@mhammond mhammond changed the title Add new "Authorization" states and functions to authorize additional scopes. Add new "Authorization" states and functions to authorize additional scopes. [ci full] Apr 14, 2026
@mhammond mhammond requested review from jonalmeida April 14, 2026 10:27
@mhammond mhammond marked this pull request as ready for review April 14, 2026 10:28
Copy link
Copy Markdown
Contributor

@bendk bendk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this looks ready to roll to me. I had a couple questions/suggestions but I'm happy to merge as-is.

this.inner.completeOauthScopeAuthorizationFlow(code, state)
this.tryPersistState()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add these or is it better to force Android to use the state machine? I don't have strong feelings either way, just wanted to ask the question.

// On success, go directly to Connected — no InitializeDevice step needed since
// the device is already initialized.
(CompleteOAuthScopeAuthorizationFlow { .. }, CompleteOAuthFlowSuccess) => {
Complete(FxaState::Connected)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for another PR, but I think we should include the list of scopes as associated data for the Connect state. It's kind of weird that you went through a "state change" and ended up in the exact same state as before.

@mhammond mhammond force-pushed the push-mkxvwknxqksu branch from ad06176 to 80b7e80 Compare April 22, 2026 04:17
@mhammond mhammond changed the title Add new "Authorization" states and functions to authorize additional scopes. [ci full] Add the ability to start a new OAuth flow while authenticated, so you can authorize additional scopes [ci full] Apr 22, 2026
@mhammond
Copy link
Copy Markdown
Member Author

I decided to stay with the same entry-points, and by adding a little extra data to a couple of the states I also avoided needing any more states. It also seems to correctly implement the merging of scopes and managing the device record. @bendk do you mind having another look?

@mhammond mhammond requested a review from bendk April 22, 2026 04:23
… can authorize additional scopes.

This supports desktop-like flows, where a user is already signed in to (say) Sync,
but not wants to authorize "vpn" or "smartwindow".

Only the new scopes needed are requested rather than a union of the scopes. Therefore
the new refresh token only has those new scopes, so we need to then calculate the
full set and re-create a new refresh token.

This does not attempt to handle reauth correctly, where somehow we will need to know
the union of scopes we held before we entered the needs-reauth state and re-request them
all.

Also taking this opportunity to introduce the concept of "service" to the oauth
flows.
@mhammond mhammond force-pushed the push-mkxvwknxqksu branch from 80b7e80 to 0241cae Compare April 22, 2026 04:26
@mhammond
Copy link
Copy Markdown
Member Author

also has the quite cool ability to test most of this from the commandline - first log in with vpn (ie, not sync), then request access tokens for vpn and sync

cargo run --example fxa-client disconnect
...
cargo run --example fxa-client login --scope profile --scope https://identity.mozilla.com/apps/vpn --scope https://identity.mozilla.com/tokens/session
...
> Account is logged in and authorized by the server
cargo run --example fxa-client get-access-token https://identity.mozilla.com/apps/vpn
...
> Requesting access token with scope: https://identity.mozilla.com/apps/vpn
> Success: AccessTokenInfo { scope: "https://identity.mozilla.com/apps/vpn", token: "...", key: None, expires_at: 1776918561 }
cargo run --example fxa-client get-access-token https://identity.mozilla.com/apps/oldsync
...
> Requesting access token with scope: https://identity.mozilla.com/apps/oldsync
> 2026-04-22T04:30:10.705901Z  WARN error_support::handling: Remote server error: '403' '112' 'Forbidden' 'Forbidden' 'https://mozilla.github.io/ecosystem-platform/api#section/Response-format' tracing_support=true
> Error: forbidden

now upgrade to sync:

cargo run --example fxa-client login --scope profile --scope https://identity.mozilla.com/apps/oldsync
...
> Requesting access token with scope: https://identity.mozilla.com/apps/oldsync
> Success: AccessTokenInfo { scope: "https://identity.mozilla.com/apps/oldsync", token: "1fa52...", key: Some(ScopedKey { kty: "oct", scope: "https://identity.mozilla.com/apps/oldsync", kid: "1747756258989-9SHtUl0VJosxLZ_oUPu6rw" }), expires_at: 1776918743 }
cargo run --example fxa-client get-access-token https://identity.mozilla.com/apps/vpn
...
Requesting access token with scope: https://identity.mozilla.com/apps/vpn
Success: AccessTokenInfo { scope: "https://identity.mozilla.com/apps/vpn", token: "df1a8...", key: None, expires_at: 1776918839 }
> 

@mhammond
Copy link
Copy Markdown
Member Author

this looks like it's not a breaking change for iOS. But probably will be after @skhamis's patch lands (or if this lands first, it will break that patch)

Copy link
Copy Markdown
Contributor

@bendk bendk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense to me. I had a couple suggestions, but nothing needs re-review.


if let Some(ref old_refresh_token) = old_refresh_token {
// We now have a new refresh token, but only with new scopes we explicitly requested.
// We possibly an old refresh token with only the scopes we had before.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// We possibly an old refresh token with only the scopes we had before.
// We possibly had an old refresh token with only the scopes we had before.

self.state.config(),
session_token,
&scopes_slice,
)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC, this is a workaround to the fact that we don't want to re-request all the scopes when fetching the refresh token. In particular, re-requesting the sync scope creates a UI issue.

Are there downsides to this approach or is this okay to keep for the long term? The one downside I can see is that this maybe prevents us from revoking access to a scope, since the client can always get it back via the session token. Is that right?

I think the code is fine, but can you document what the current status and plan is?


pub struct AuthenticatingStateMachine;
pub struct AuthenticatingStateMachine {
pub is_connected: bool,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about using an enum to allow for more possibilities?

In fact, maybe we should add a variant for coming from the AuthIssues state so that we can go back to that state on cancellation. This would be a UX change though so that doesn't need to be in this PR, but I do like an enum in any case.

BeginOAuthFlow(sequence<string> scopes, string entrypoint);
BeginPairingFlow(string pairing_url, sequence<string> scopes, string entrypoint);
BeginOAuthFlow(string service, sequence<string> scopes, string entrypoint);
BeginPairingFlow(string pairing_url, string service, sequence<string> scopes, string entrypoint);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change since the clients are going to need to send us the new field. I'm fine with that, but let's call it out in the changelog.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants