fix(auth): force account chooser on Google and GitHub sign-in - #1055
thisisjoshford wants to merge 2 commits into
Conversation
Neither OAuth authorization URL set a `prompt` parameter, so once a user had an active provider session and prior consent, Google (and GitHub) silently re-selected the previous account on sign-in. After signing out of NEAR AI Cloud there was no way to switch accounts without also signing out of the provider. Add `prompt=select_account` to both `google_auth_url` and `github_auth_url`. This shows the account chooser on every sign-in without re-prompting for consent. Add unit tests that parse the generated URLs and assert the parameter is present exactly once alongside the existing state and PKCE parameters. Fixes #1053 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdMTHjAB7ePAvtTFwo97AU
There was a problem hiding this comment.
🟢 Approval recommended
No unresolved review issues were identified.
Pull request overview
Updates Google and GitHub OAuth authorization URLs to force account selection during sign-in.
Changes:
- Adds
prompt=select_accountfor both providers. - Adds tests covering prompt, state, and PKCE parameters.
File summaries
| File | Description |
|---|---|
crates/services/src/auth/oauth.rs |
Updates OAuth URL builders and adds focused tests. |
Review details
- Files reviewed: 1/1 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Code Review Scope is one file, additive only: Verification notes
Non-blocking notes
The three pre-existing callback-path issues noted in the PR description (raw 400 on cancelled login, JSON instead of a frontend redirect on failure arms, unpurged I was unable to execute OK to merge. |
| // Always show the account chooser so users can switch accounts | ||
| // after signing out, instead of being silently re-signed-in. | ||
| .add_extra_param("prompt", "select_account") |
There was a problem hiding this comment.
GitHub's authorize endpoint does not support a prompt parameter (it only accepts client_id, redirect_uri, login, scope, state, allow_signup); unknown parameters are silently ignored, and GitHub has no account-chooser equivalent — it auto-approves previously authorized apps on re-auth. So this parameter is a no-op, and the comment (and the test name github_auth_url_forces_account_chooser) mislead maintainers into believing the silent re-sign-in issue is actually fixed for GitHub, while the passing test cements the false assumption. Suggest removing the extra param for GitHub and stating the limitation honestly (or, if the goal is post-logout account switching, GitHub offers no lever here beyond revoking the grant).
Suggestion:
| // Always show the account chooser so users can switch accounts | |
| // after signing out, instead of being silently re-signed-in. | |
| .add_extra_param("prompt", "select_account") | |
| // NOTE: GitHub's authorize endpoint has no `prompt` parameter and | |
| // no account-chooser equivalent; unknown params are ignored and | |
| // previously authorized apps are auto-approved on re-auth. | |
| .url(); |
There was a problem hiding this comment.
Keeping this one. GitHub's authorize endpoint does document prompt, for both OAuth Apps and GitHub Apps:
prompt (optional): Forces the account picker to appear if set to
select_account. The account picker will also appear if the application has a non-HTTP redirect URI or if the user has multiple accounts signed in.
- OAuth Apps: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#parameters
- GitHub Apps: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-web-application-flow-to-generate-a-user-access-token
The account picker was added alongside GitHub's multi-account switching, so the parameter list quoted in the comment is out of date. Added a comment citing the docs next to the builder in ecc551e so this doesn't come up again.
| assert_eq!(query_params(&auth_url, "code_challenge_method"), ["S256"]); | ||
| // The returned tuple is positional; make sure state and verifier are | ||
| // not swapped or empty, since the route persists them by key. | ||
| assert_eq!(query_params(&auth_url, "state"), [state]); | ||
| assert!(!verifier.is_empty()); |
There was a problem hiding this comment.
The test pins code_challenge_method=S256 and only checks the verifier is non-empty, but never asserts that the code_challenge sent in the URL derives from the returned verifier (SHA-256, base64url, no padding). A regression that swaps, truncates, or mis-encodes the challenge/verifier pairing would still pass. Both sha2 and base64 are already dependencies of this crate, so the derivation check is cheap to add.
Suggestion:
| assert_eq!(query_params(&auth_url, "code_challenge_method"), ["S256"]); | |
| // The returned tuple is positional; make sure state and verifier are | |
| // not swapped or empty, since the route persists them by key. | |
| assert_eq!(query_params(&auth_url, "state"), [state]); | |
| assert!(!verifier.is_empty()); | |
| assert_eq!(query_params(&auth_url, "code_challenge_method"), ["S256"]); | |
| // The challenge must actually derive from the returned verifier | |
| // (SHA-256, base64url without padding) for the token exchange to work. | |
| let expected_challenge = | |
| URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); | |
| assert_eq!(query_params(&auth_url, "code_challenge"), [expected_challenge]); | |
| // The returned tuple is positional; make sure state and verifier are | |
| // not swapped or empty, since the route persists them by key. | |
| assert_eq!(query_params(&auth_url, "state"), [state]); | |
| assert!(!verifier.is_empty()); |
There was a problem hiding this comment.
Good catch, applied in ecc551e. The test now recomputes the expected code_challenge from the returned verifier (SHA-256, base64url without padding) and asserts the URL carries exactly that value.
Address review feedback on #1055: - The Google auth URL test now recomputes the expected code_challenge (SHA-256, base64url without padding) from the returned verifier and asserts the URL carries exactly that value, so a swapped, truncated, or mis-encoded challenge/verifier pairing can no longer pass. - Cite the GitHub docs for `prompt=select_account` next to the GitHub builder. GitHub documents the parameter for its authorize endpoint for both OAuth Apps and GitHub Apps, so the parameter is kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Summary
Fixes #1053.
After signing out of NEAR AI Cloud and signing back in with Google, users were not shown the Google account chooser. Google silently re-selected the previously used account, so there was no way to switch accounts without also signing out of Google.
Root cause: neither
google_auth_urlnorgithub_auth_urlincrates/services/src/auth/oauth.rsset apromptparameter. Without it, Google skips the chooser whenever the user has an active Google session and has already consented. TheGET /v1/auth/googleroute only forwardsfrontend_callback, so the frontend had no way to work around it.Changes
prompt=select_accountto the Google authorization URL. This shows the chooser on every sign-in without re-prompting for consent.prompt=select_accountas "forces the account picker to appear".promptis present exactly once, thatstatein the URL matches the returned value, and that the Google PKCE method and verifier survived.No changes to the callback path. The extra query parameter is appended independently of
stateand the PKCE challenge, and the callback never reads it.Verification
cargo fmt --all -- --checkcleancargo clippy -p services --lib --testscleancargo test -p services --lib auth::oauth::tests: 2 passedFollow-up
Code review surfaced three pre-existing callback-path issues that the always-on chooser makes more reachable (cancelled logins land on a raw 400 on the API origin, failure arms return JSON instead of redirecting to the frontend, and abandoned
oauth_statesrows are never purged). Tracked separately in #1054.🤖 Generated with Claude Code
https://claude.ai/code/session_01CdMTHjAB7ePAvtTFwo97AU