Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions actions/oauth/account-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export async function switchAccount(
await destroySession();

const searchParams = new URLSearchParams(oauthParams);
searchParams.set("fresh_login", "true");
const loginUrl = `/login?redirect=/api/oauth/authorize?${searchParams.toString()}`;
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

The redirect query param here is not URL-encoded, so the & characters from searchParams.toString() will be parsed as separate /login query params and redirect will be truncated. Build the login URL with new URL() / URLSearchParams or at least encodeURIComponent(/api/oauth/authorize?${...}) when setting redirect.

Suggested change
const loginUrl = `/login?redirect=/api/oauth/authorize?${searchParams.toString()}`;
const redirectUrl = `/api/oauth/authorize?${searchParams.toString()}`;
const loginParams = new URLSearchParams({ redirect: redirectUrl });
const loginUrl = `/login?${loginParams.toString()}`;

Copilot uses AI. Check for mistakes.
redirect(loginUrl);
}
11 changes: 8 additions & 3 deletions app/api/oauth/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,23 @@ export async function GET(request: NextRequest) {
const session = await getSession();
if (!session.isLoggedIn || !session.userId) {
// Store OAuth params in session and redirect to login
// Add fresh_login flag so we skip account selection after login
const loginUrl = new URL("/login", request.url);
const redirectParams = new URLSearchParams(searchParams.toString());
redirectParams.set("fresh_login", "true");
loginUrl.searchParams.set(
"redirect",
`/api/oauth/authorize?${searchParams.toString()}`
`/api/oauth/authorize?${redirectParams.toString()}`
);
return NextResponse.redirect(loginUrl);
}

// If user is already signed in and hasn't confirmed their account,
// redirect to account selection page
// redirect to account selection page.
// Skip account selection if user just logged in (fresh_login=true)
const accountConfirmed = searchParams.get("account_confirmed");
if (!accountConfirmed) {
const freshLogin = searchParams.get("fresh_login");
if (!accountConfirmed && !freshLogin) {
Comment on lines 117 to +119
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

fresh_login/account_confirmed are treated as truthy based on presence, so fresh_login=false (or any non-empty value) will still skip account selection. Consider checking for explicit values (e.g. === "true") and/or removing/ignoring untrusted fresh_login values to prevent clients/users from bypassing the account confirmation step by adding the param themselves.

Copilot uses AI. Check for mistakes.
const accountSelectUrl = new URL("/oauth/account-select", request.url);
accountSelectUrl.searchParams.set("client_id", client_id);
accountSelectUrl.searchParams.set("redirect_uri", redirect_uri);
Expand Down
124 changes: 124 additions & 0 deletions e2e/oauth/account-select.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { test, expect } from "@playwright/test";

const ADMIN_PASSWORD = "e2e-test-admin-password";

test.describe("OAuth Account Selection", () => {
let clientId: string;

test.beforeAll(async ({ browser }, testInfo) => {
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

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

testInfo is accepted by this beforeAll callback but never used. With the Next.js TypeScript ESLint config, unused variables can fail lint; consider removing testInfo (or prefixing with _ if you intend to keep it).

Suggested change
test.beforeAll(async ({ browser }, testInfo) => {
test.beforeAll(async ({ browser }) => {

Copilot uses AI. Check for mistakes.
const context = await browser.newContext();
const page = await context.newPage();

// Login as admin to create OAuth client
await page.goto("/admin");
await page.getByLabel("Admin Password").fill(ADMIN_PASSWORD);
await page.getByRole("button", { name: "Sign in with Password" }).click();
await expect(page).toHaveURL("/admin/dashboard");

// Create OAuth client
await page.goto("/admin/dashboard/clients/new");
await page.getByLabel("Application Name").fill("Account Select Test App");
await page.getByTestId("redirect-uri-0").fill("http://localhost:3001/callback");
await page.getByTestId("profile").click();
await page.getByTestId("email").click();

await page.getByRole("button", { name: "Create Application" }).click();
await expect(page.getByText("Client Created Successfully")).toBeVisible();

clientId = await page.getByTestId("client-id-display").inputValue();

await page.close();
await context.close();
});

function buildAuthorizeUrl(clientId: string, state?: string) {
const codeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
return (
`/api/oauth/authorize?` +
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent("http://localhost:3001/callback")}&` +
`response_type=code&` +
`scope=openid%20profile%20email&` +
`state=${state || "test-state"}&` +
`code_challenge=${codeChallenge}&` +
`code_challenge_method=S256`
);
}

test("should NOT show account selection on first-time sign-in", async ({
page,
}, testInfo) => {
// Create a new user for this test
const user = {
email: `acct-sel-first-${Date.now()}-${testInfo.parallelIndex}@example.com`,
password: "TestPassword123!",
displayName: "First Sign In User",
};

// Register the user
await page.goto("/register");
await page.getByLabel("Display Name").fill(user.displayName);
await page.getByLabel("Email").fill(user.email);
await page.getByLabel("Password").fill(user.password);
await page.getByRole("button", { name: "Create account" }).click();
await expect(page).toHaveURL("/account");

// Logout and wait for redirect to login page
await page.getByRole("button", { name: "Avatar" }).click();
await page.getByRole("button", { name: /sign out/i }).click();
await expect(page).toHaveURL("/login");

// Now start OAuth flow without being logged in
await page.goto(buildAuthorizeUrl(clientId));

// Should redirect to login page
await expect(page).toHaveURL(/\/login/);

// Login
await page.getByLabel("Email").fill(user.email);
await page.getByLabel("Password").fill(user.password);
await page.getByRole("button", { name: "Sign in", exact: true }).click();

// Should go directly to consent page WITHOUT showing account selection
await expect(page).toHaveURL(/\/oauth\/authorize/);
// Verify it's the consent page, not the account select page
expect(page.url()).not.toContain("/oauth/account-select");
await expect(
page.getByRole("heading", { name: "Account Select Test App" })
).toBeVisible();
});

test("should show account selection when user is already signed in", async ({
page,
}, testInfo) => {
// Create a new user for this test
const user = {
email: `acct-sel-existing-${Date.now()}-${testInfo.parallelIndex}@example.com`,
password: "TestPassword123!",
displayName: "Already Signed In User",
};

// Register the user (auto-logs in)
await page.goto("/register");
await page.getByLabel("Display Name").fill(user.displayName);
await page.getByLabel("Email").fill(user.email);
await page.getByLabel("Password").fill(user.password);
await page.getByRole("button", { name: "Create account" }).click();
await expect(page).toHaveURL("/account");

// Now start OAuth flow while already logged in
await page.goto(buildAuthorizeUrl(clientId));

// Should show account selection page
await expect(page).toHaveURL(/\/oauth\/account-select/);
await expect(page.getByText("Choose an account")).toBeVisible();
await expect(page.getByText(user.email)).toBeVisible();

// Click continue with current account
await page.getByTestId("continue-as-current").click();

// Should proceed to consent page
await expect(page).toHaveURL(/\/oauth\/authorize/);
expect(page.url()).not.toContain("/oauth/account-select");
});
});
Loading