feat(oauth): add account selection dialog for existing sessions#35
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- New page at /oauth/account-select showing current user email - Options to continue with current account or switch to a different account - Switching account destroys session and redirects to login - Modified /api/oauth/authorize to redirect to account-select when user is signed in - Added unit tests for switchAccount server action Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com>
Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com>
… simplify test mocks Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com>
|
@copilot screenshot for new account switcher screen |
There was a problem hiding this comment.
Pull request overview
Adds an OAuth “account selection” step when an authorization request is initiated while a session exists, allowing the user to proceed with the current account or sign out and use another account before consent/auto-approval.
Changes:
- Redirect
/api/oauth/authorizeto a new/oauth/account-selectpage unlessaccount_confirmedis present. - Introduce account-select UI (server page + client card) and a
switchAccountserver action to sign out and route to login preserving OAuth params. - Update OAuth E2E specs with a shared
handleAccountSelectionhelper, and add unit tests forswitchAccount.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| app/api/oauth/authorize/route.ts | Adds redirect to account selection step before continuing OAuth flow |
| app/oauth/account-select/page.tsx | New server page to validate params, require session, and fetch user/client data |
| components/auth/account-select-card.tsx | New client UI offering “continue” vs “use different account” actions |
| actions/oauth/account-select.ts | New server action to destroy session and redirect to login with preserved OAuth params |
| actions/oauth/account-select.test.ts | Unit tests for switchAccount behavior and ordering |
| e2e/fixtures/test-helpers.ts | Adds handleAccountSelection helper used by multiple OAuth E2E specs |
| e2e/oauth/consent.spec.ts | Updates tests to invoke account selection helper during flow |
| e2e/oauth/public-client.spec.ts | Updates tests to invoke account selection helper during flow |
| e2e/oauth/refresh-token.spec.ts | Updates tests to invoke account selection helper during flow |
| e2e/oauth/wildcard-redirect.spec.ts | Updates tests to invoke account selection helper during flow |
| e2e/oauth/user-id-consistency.spec.ts | Updates tests to invoke account selection helper during flow |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await destroySession(); | ||
|
|
||
| const searchParams = new URLSearchParams(oauthParams); | ||
| const loginUrl = `/login?redirect=/api/oauth/authorize?${searchParams.toString()}`; |
There was a problem hiding this comment.
switchAccount builds /login?redirect=/api/oauth/authorize?... without URL-encoding the nested redirect value. This will break parsing on /login (everything after the first & becomes separate top-level query params), causing OAuth params to be dropped/truncated. Build the login URL with URL/URLSearchParams (or encodeURIComponent the redirect value) so the entire /api/oauth/authorize?... string is preserved in the single redirect param.
| const loginUrl = `/login?redirect=/api/oauth/authorize?${searchParams.toString()}`; | |
| const redirectTarget = `/api/oauth/authorize?${searchParams.toString()}`; | |
| const loginUrl = `/login?redirect=${encodeURIComponent(redirectTarget)}`; |
| const redirectUrl = mockRedirect.mock.calls[0][0]; | ||
| expect(redirectUrl).toContain("/login?redirect="); | ||
| expect(redirectUrl).toContain("/api/oauth/authorize"); | ||
| expect(redirectUrl).toContain("client_id=test-client"); | ||
| expect(redirectUrl).toContain("scope=openid+profile"); | ||
| }); |
There was a problem hiding this comment.
These tests only assert that the redirect URL string contains certain substrings, so they won’t catch the broken nested query-string issue. Add assertions that redirect is properly URL-encoded (e.g. redirect=%2Fapi%2Foauth%2Fauthorize%3F...) and that all OAuth params survive inside the decoded redirect value.
| // If user is already signed in and hasn't confirmed their account, | ||
| // redirect to account selection page | ||
| const accountConfirmed = searchParams.get("account_confirmed"); | ||
| if (!accountConfirmed) { | ||
| const accountSelectUrl = new URL("/oauth/account-select", request.url); | ||
| accountSelectUrl.searchParams.set("client_id", client_id); | ||
| accountSelectUrl.searchParams.set("redirect_uri", redirect_uri); | ||
| accountSelectUrl.searchParams.set("response_type", "code"); | ||
| accountSelectUrl.searchParams.set("scope", scope); | ||
| if (state) accountSelectUrl.searchParams.set("state", state); | ||
| accountSelectUrl.searchParams.set("code_challenge", code_challenge); | ||
| accountSelectUrl.searchParams.set("code_challenge_method", code_challenge_method); | ||
| if (nonce) accountSelectUrl.searchParams.set("nonce", nonce); | ||
| return NextResponse.redirect(accountSelectUrl); | ||
| } | ||
|
|
||
| // Check sign-in permission | ||
| if (client.signInPermission === "none") { | ||
| const redirectUrl = new URL(redirect_uri); |
There was a problem hiding this comment.
Account selection redirect happens before the signInPermission === "none" check. For clients where sign-in is disabled, users will be sent to account-select (and can even “switch accounts”) even though no account can ever authorize. Consider handling the none case before redirecting to account selection, since switching accounts cannot change the outcome.
| // If user is already signed in and hasn't confirmed their account, | ||
| // redirect to account selection page | ||
| const accountConfirmed = searchParams.get("account_confirmed"); | ||
| if (!accountConfirmed) { | ||
| const accountSelectUrl = new URL("/oauth/account-select", request.url); |
There was a problem hiding this comment.
account_confirmed is read directly from the request query string, so an OAuth client can include it in the initial /api/oauth/authorize request to bypass the account selection step entirely. If account selection is meant to be enforced, store the confirmation server-side (e.g., in session) or require a server-generated/signed token tied to the account-select redirect rather than trusting a client-controlled query param.
| ); | ||
|
|
||
| // Should show consent page | ||
| // Should show account selection first, then consent page |
There was a problem hiding this comment.
This test comment says account selection should be shown first, but the test doesn’t assert that /oauth/account-select is actually reached (the helper is conditional). Consider asserting the account-select page is shown in at least one scenario where the user is already logged in, so a regression that skips account selection is caught.
| // Should show account selection first, then consent page | |
| // Should show account selection first, then consent page | |
| await expect(page).toHaveURL(/\/oauth\/account-select/); |
Here's the account switcher screen: It shows the current signed-in user's email with a "Continue with this account" option, and a "Use a different account" option that signs out and redirects to login. |
This comment has been minimized.
This comment has been minimized.
|
@copilot Found 4 test failures on Blacksmith runners: Failures Test View Logs These tests failed. Make sure you run e2e before commit |
… and update E2E tests Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com>
Fixed in Changes:
|
|
@copilot permission check should happened after account selection. May need to update tests accordingly |
Co-authored-by: sirily11 <32106111+sirily11@users.noreply.github.com>
Fixed in |

When a user is already signed in and an OAuth client initiates authorization, the flow now presents an account selection page before proceeding to consent/auto-approve.
Flow
Changes
app/api/oauth/authorize/route.ts— redirect signed-in users to/oauth/account-selectunlessaccount_confirmedis set; sign-in permission checks (none/whitelist) happen after account selection so users always see the account chooser firstapp/oauth/account-select/page.tsx— server component: validates params, fetches user email + client namecomponents/auth/account-select-card.tsx— client component with two options: continue with current account or sign out and switchactions/oauth/account-select.ts—switchAccountserver action: destroys session, redirects to login preserving OAuth paramshandleAccountSelectionhelper totest-helpers.ts, updated all 5 affected OAuth test files (consent, public-client, refresh-token, wildcard-redirect, user-id-consistency) and all 4 sign-in-permission OAuth flow testsswitchAccountaction (session destruction ordering, redirect URL construction)Screenshot
🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.