Skip to content

fix(auth): force account chooser on Google and GitHub sign-in - #1055

Open
thisisjoshford wants to merge 2 commits into
mainfrom
fix/oauth-select-account-prompt
Open

thisisjoshford wants to merge 2 commits into
mainfrom
fix/oauth-select-account-prompt

Conversation

@thisisjoshford

Copy link
Copy Markdown

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_url nor github_auth_url in crates/services/src/auth/oauth.rs set a prompt parameter. Without it, Google skips the chooser whenever the user has an active Google session and has already consented. The GET /v1/auth/google route only forwards frontend_callback, so the frontend had no way to work around it.

Changes

  • Add prompt=select_account to the Google authorization URL. This shows the chooser on every sign-in without re-prompting for consent.
  • Add the same parameter to the GitHub authorization URL. GitHub's authorize endpoint documents prompt=select_account as "forces the account picker to appear".
  • Add unit tests for both builders that parse the generated URL and assert prompt is present exactly once, that state in 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 state and the PKCE challenge, and the callback never reads it.

Verification

  • cargo fmt --all -- --check clean
  • cargo clippy -p services --lib --tests clean
  • cargo test -p services --lib auth::oauth::tests: 2 passed

Follow-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_states rows are never purged). Tracked separately in #1054.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CdMTHjAB7ePAvtTFwo97AU

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
Copilot AI lite review requested due to automatic review settings September 11, 2026 19:15
@thisisjoshford
thisisjoshford deployed to Cloud API test env September 11, 2026 19:15 — with GitHub Actions Active

Copilot AI left a comment

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.

🟢 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_account for 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.

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown

Code Review

Scope is one file, additive only: prompt=select_account on both authorize URLs plus two unit tests. I traced the full flow (google_login/github_login in crates/api/src/routes/auth.rs:257,305 -> oauth_states persistence -> handle_*_callback) and found no critical issues.

Verification notes

  • No token-exchange regression. The usual prompt footgun is losing a refresh token: with prompt=select_account (rather than consent) Google will not re-issue one. Not applicable here - neither access_type=offline nor a refresh token is requested, and handle_google_callback (crates/services/src/auth/oauth.rs:194-204) uses only access_token() for the one-shot userinfo fetch. Safe.
  • No state/PKCE interaction. add_extra_param appends independently of state and code_challenge, and the callback never reads prompt. The test asserting query_params("prompt") == ["select_account"] collects all values rather than first-match, so it correctly guards against a duplicate param if a second prompt is ever added.
  • No rolling-update / backward-compat risk. The parameter lives only in the outbound redirect. An old instance can serve a callback started by a new instance and vice versa, since oauth_states rows are unchanged.
  • Tests are sound. OAuthProviderConfig has public fields and no non_exhaustive attribute; url 2.5 is already a direct dependency of services; reqwest::Client::new() inside OAuthManager::new does not need a Tokio runtime with these features, so the sync test functions are fine.

Non-blocking notes

  • GitHub half may be a silent no-op. prompt=select_account is documented for GitHub App user authorization. github_auth_url uses client-id/secret plus the user:email scope, which is the classic OAuth App shape - and OAuth Apps ignore unrecognized authorize params rather than erroring. Harmless either way, but worth a manual check against the real GitHub app before claiming the GitHub path is fixed; if it is an OAuth App the picker will not appear and only the Google fix is live.
  • Unconditional chooser costs one click for every login, not just post-logout ones, including single-account users. The linked issue asks for exactly this, so it is a deliberate trade-off - just flagging that plumbing an optional prompt through OAuthInitQuery (which already carries frontend_callback) would let the frontend force the chooser only after an explicit sign-out, if the extra interstitial turns out to be unpopular.

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 oauth_states rows) are correctly kept out of this PR and tracked in #1054. I agree they are out of scope here, though the always-on chooser does make the cancel path meaningfully more reachable, so that issue is worth prioritizing.

I was unable to execute cargo test / cargo clippy in this environment, so the build and test results in the PR description are taken as reported rather than independently reproduced; the review above is static analysis.

OK to merge.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 OpenCodeReview found 2 issue(s) in this PR.

  • ✅ 2 posted as inline comment(s)
  • 📝 0 posted as summary

⚠️ 1 warning(s) occurred during review.

Comment on lines +96 to +98
// 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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

Suggested change
// 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();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

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.

Comment on lines +372 to +376
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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:

Suggested change
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());

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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>
@thisisjoshford
thisisjoshford deployed to Cloud API test env September 15, 2026 17:05 — with GitHub Actions Active
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.

Google sign-in skips account chooser after sign-out (missing prompt=select_account)

2 participants