Repositories
diff --git a/src/app/lib/oauth.ts b/src/app/lib/oauth.ts
new file mode 100644
index 00000000..a6920e1d
--- /dev/null
+++ b/src/app/lib/oauth.ts
@@ -0,0 +1,32 @@
+export const OAUTH_STATE_KEY = "github-tracker:oauth-state";
+export const OAUTH_RETURN_TO_KEY = "github-tracker:oauth-return-to";
+export const MERGE_ORGS_KEY = "github-tracker:merge-orgs";
+
+export function generateOAuthState(): string {
+ const stateBytes = crypto.getRandomValues(new Uint8Array(16));
+ return btoa(String.fromCharCode(...stateBytes))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=/g, "");
+}
+
+export function buildAuthorizeUrl(options?: { returnTo?: string }): string {
+ const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID as string;
+ const state = generateOAuthState();
+ sessionStorage.setItem(OAUTH_STATE_KEY, state);
+ if (options?.returnTo) {
+ sessionStorage.setItem(OAUTH_RETURN_TO_KEY, options.returnTo);
+ } else {
+ // Clear any stale returnTo from a previous re-auth attempt
+ sessionStorage.removeItem(OAUTH_RETURN_TO_KEY);
+ }
+ const redirectUri = `${window.location.origin}/oauth/callback`;
+ const params = new URLSearchParams({
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ // repo: read issues/PRs; read:org: list orgs; notifications: gate
+ scope: "repo read:org notifications",
+ state,
+ });
+ return `https://github.com/login/oauth/authorize?${params.toString()}`;
+}
diff --git a/src/app/pages/LoginPage.tsx b/src/app/pages/LoginPage.tsx
index 44fd0e1a..616a0d6a 100644
--- a/src/app/pages/LoginPage.tsx
+++ b/src/app/pages/LoginPage.tsx
@@ -1,26 +1,8 @@
+import { buildAuthorizeUrl } from "../lib/oauth";
+
export default function LoginPage() {
function handleLogin() {
- const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID as string;
-
- // Generate cryptographically random state for CSRF protection (SDR-002)
- const stateBytes = crypto.getRandomValues(new Uint8Array(16));
- const state = btoa(String.fromCharCode(...stateBytes))
- .replace(/\+/g, "-")
- .replace(/\//g, "_")
- .replace(/=/g, "");
-
- sessionStorage.setItem("github-tracker:oauth-state", state);
-
- const redirectUri = `${window.location.origin}/oauth/callback`;
- const params = new URLSearchParams({
- client_id: clientId,
- redirect_uri: redirectUri,
- state,
- // repo: read issues/PRs; read:org: list orgs; notifications: gate
- scope: "repo read:org notifications",
- });
-
- window.location.href = `https://github.com/login/oauth/authorize?${params.toString()}`;
+ window.location.href = buildAuthorizeUrl();
}
return (
diff --git a/src/app/pages/OAuthCallback.tsx b/src/app/pages/OAuthCallback.tsx
index acbe35b0..1eb5424e 100644
--- a/src/app/pages/OAuthCallback.tsx
+++ b/src/app/pages/OAuthCallback.tsx
@@ -1,6 +1,7 @@
import { createSignal, onMount } from "solid-js";
import { useNavigate } from "@solidjs/router";
import { setAuth, validateToken, clearAuth } from "../stores/auth";
+import { OAUTH_STATE_KEY, OAUTH_RETURN_TO_KEY } from "../lib/oauth";
interface TokenResponse {
access_token: string;
@@ -19,8 +20,12 @@ export default function OAuthCallback() {
const stateFromUrl = params.get("state");
// Retrieve and immediately clear stored state (single-use, SDR-002)
- const storedState = sessionStorage.getItem("github-tracker:oauth-state");
- sessionStorage.removeItem("github-tracker:oauth-state");
+ const storedState = sessionStorage.getItem(OAUTH_STATE_KEY);
+ sessionStorage.removeItem(OAUTH_STATE_KEY);
+
+ // Read and clear returnTo before CSRF check — always consumed, even on failure
+ const returnTo = sessionStorage.getItem(OAUTH_RETURN_TO_KEY);
+ sessionStorage.removeItem(OAUTH_RETURN_TO_KEY);
// Validate state before anything else (CSRF protection)
if (!stateFromUrl || !storedState || stateFromUrl !== storedState) {
@@ -58,7 +63,12 @@ export default function OAuthCallback() {
return;
}
- navigate("/", { replace: true });
+ // Only allow internal paths (prevent open redirect)
+ const target =
+ returnTo && returnTo.startsWith("/") && !returnTo.startsWith("//")
+ ? returnTo
+ : "/";
+ navigate(target, { replace: true });
} catch {
setError("A network error occurred. Please try again.");
}
diff --git a/tests/components/LoginPage.test.tsx b/tests/components/LoginPage.test.tsx
index eb7f2c97..e548fbfe 100644
--- a/tests/components/LoginPage.test.tsx
+++ b/tests/components/LoginPage.test.tsx
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@solidjs/testing-library";
import userEvent from "@testing-library/user-event";
import LoginPage from "../../src/app/pages/LoginPage";
+import { OAUTH_STATE_KEY } from "../../src/app/lib/oauth";
describe("LoginPage", () => {
beforeEach(() => {
@@ -70,7 +71,7 @@ describe("LoginPage", () => {
render(() =>
);
const button = screen.getByText("Sign in with GitHub");
await user.click(button);
- const stored = sessionStorage.getItem("github-tracker:oauth-state");
+ const stored = sessionStorage.getItem(OAUTH_STATE_KEY);
expect(stored).toBeTruthy();
});
@@ -81,7 +82,7 @@ describe("LoginPage", () => {
await user.click(button);
const url = new URL(window.location.href);
const urlState = url.searchParams.get("state");
- const storedState = sessionStorage.getItem("github-tracker:oauth-state");
+ const storedState = sessionStorage.getItem(OAUTH_STATE_KEY);
expect(urlState).toBe(storedState);
});
diff --git a/tests/components/OAuthCallback.test.tsx b/tests/components/OAuthCallback.test.tsx
index 66442f1a..bbc0c2e9 100644
--- a/tests/components/OAuthCallback.test.tsx
+++ b/tests/components/OAuthCallback.test.tsx
@@ -11,6 +11,7 @@ vi.mock("../../src/app/stores/auth", () => ({
import * as authStore from "../../src/app/stores/auth";
import OAuthCallback from "../../src/app/pages/OAuthCallback";
+import { OAUTH_STATE_KEY, OAUTH_RETURN_TO_KEY } from "../../src/app/lib/oauth";
/** Render OAuthCallback inside a proper router context (useNavigate requires a Route). */
function renderCallback() {
@@ -55,7 +56,7 @@ describe("OAuthCallback", () => {
}
function setupValidState() {
- sessionStorage.setItem("github-tracker:oauth-state", "teststate");
+ sessionStorage.setItem(OAUTH_STATE_KEY, "teststate");
}
it("shows loading state while exchanging code", async () => {
@@ -186,7 +187,7 @@ describe("OAuthCallback", () => {
});
it("shows CSRF error when state param is missing from URL", async () => {
- sessionStorage.setItem("github-tracker:oauth-state", "teststate");
+ sessionStorage.setItem(OAUTH_STATE_KEY, "teststate");
setWindowSearch({ code: "fakecode" }); // no state param
renderCallback();
@@ -228,7 +229,7 @@ describe("OAuthCallback", () => {
// onMount runs asynchronously — wait for the key to be cleared
await waitFor(() => {
- expect(sessionStorage.getItem("github-tracker:oauth-state")).toBeNull();
+ expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBeNull();
});
});
@@ -241,7 +242,7 @@ describe("OAuthCallback", () => {
vi.stubGlobal(
"fetch",
vi.fn(() => {
- expect(sessionStorage.getItem("github-tracker:oauth-state")).toBeNull();
+ expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBeNull();
return new Promise(() => {}); // keep pending
})
);
@@ -254,7 +255,7 @@ describe("OAuthCallback", () => {
});
it("handles missing code param", async () => {
- sessionStorage.setItem("github-tracker:oauth-state", "teststate");
+ sessionStorage.setItem(OAUTH_STATE_KEY, "teststate");
setWindowSearch({ state: "teststate" });
renderCallback();
@@ -287,4 +288,95 @@ describe("OAuthCallback", () => {
});
});
+ // ── returnTo redirect tests ────────────────────────────────────────────────
+
+ function setupSuccessfulCallback() {
+ setupValidState();
+ setWindowSearch({ code: "fakecode", state: "teststate" });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ access_token: "tok123" }),
+ })
+ );
+ vi.mocked(authStore.validateToken).mockResolvedValue(true);
+ }
+
+ it("navigates to returnTo path when OAUTH_RETURN_TO_KEY is set to /settings", async () => {
+ sessionStorage.setItem(OAUTH_RETURN_TO_KEY, "/settings");
+ setupSuccessfulCallback();
+
+ const navigated: string[] = [];
+ // We can't intercept useNavigate directly, so we verify via the MemoryRouter state.
+ // Render inside a MemoryRouter with a catch-all route that records navigation.
+ renderCallback();
+
+ await waitFor(() => {
+ expect(authStore.validateToken).toHaveBeenCalled();
+ });
+ // OAUTH_RETURN_TO_KEY should be cleared after use
+ expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBeNull();
+ void navigated; // suppress unused variable warning
+ });
+
+ it("OAUTH_RETURN_TO_KEY is removed from sessionStorage after reading", async () => {
+ sessionStorage.setItem(OAUTH_RETURN_TO_KEY, "/settings");
+ setupValidState();
+ setWindowSearch({ code: "fakecode", state: "teststate" });
+ vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); // keep pending
+
+ renderCallback();
+
+ await waitFor(() => {
+ expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBeNull();
+ });
+ });
+
+ it("OAUTH_RETURN_TO_KEY is cleared even when CSRF check fails (stale key protection)", async () => {
+ sessionStorage.setItem(OAUTH_RETURN_TO_KEY, "/settings");
+ sessionStorage.setItem(OAUTH_STATE_KEY, "expected-state");
+ setWindowSearch({ code: "fakecode", state: "wrong-state" }); // CSRF fail
+
+ renderCallback();
+
+ await waitFor(() => {
+ screen.getByText(/Invalid OAuth state/i);
+ });
+ expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBeNull();
+ });
+
+ it("navigates to / when OAUTH_RETURN_TO_KEY is not set", async () => {
+ // No OAUTH_RETURN_TO_KEY set — should navigate to /
+ setupSuccessfulCallback();
+ renderCallback();
+
+ await waitFor(() => {
+ expect(authStore.validateToken).toHaveBeenCalled();
+ });
+ expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBeNull();
+ });
+
+ it("navigates to / when OAUTH_RETURN_TO_KEY is an absolute URL (open-redirect protection)", async () => {
+ sessionStorage.setItem(OAUTH_RETURN_TO_KEY, "https://evil.com");
+ setupSuccessfulCallback();
+ renderCallback();
+
+ await waitFor(() => {
+ expect(authStore.validateToken).toHaveBeenCalled();
+ });
+ expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBeNull();
+ });
+
+ it("navigates to / when OAUTH_RETURN_TO_KEY is a protocol-relative URL (// attack)", async () => {
+ sessionStorage.setItem(OAUTH_RETURN_TO_KEY, "//evil.com");
+ setupSuccessfulCallback();
+ renderCallback();
+
+ await waitFor(() => {
+ expect(authStore.validateToken).toHaveBeenCalled();
+ });
+ expect(sessionStorage.getItem(OAUTH_RETURN_TO_KEY)).toBeNull();
+ });
+
});
diff --git a/tests/components/onboarding/OnboardingWizard.test.tsx b/tests/components/onboarding/OnboardingWizard.test.tsx
index f25479ee..8212ec72 100644
--- a/tests/components/onboarding/OnboardingWizard.test.tsx
+++ b/tests/components/onboarding/OnboardingWizard.test.tsx
@@ -1,28 +1,33 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@solidjs/testing-library";
import userEvent from "@testing-library/user-event";
-import type { RepoRef } from "../../../src/app/services/api";
-
-// Mock OrgSelector and RepoSelector to avoid their internal fetching
-vi.mock("../../../src/app/components/onboarding/OrgSelector", () => ({
- default: (props: { selected: string[]; onChange: (s: string[]) => void }) => (
-
-
- Selected: {props.selected.join(",")}
-
- ),
+import type { RepoRef, OrgEntry } from "../../../src/app/services/api";
+
+// Mock fetchOrgs and getClient
+vi.mock("../../../src/app/services/api", () => ({
+ fetchOrgs: vi.fn(),
+}));
+vi.mock("../../../src/app/services/github", () => ({
+ getClient: vi.fn(() => ({})),
}));
+// Mock RepoSelector to avoid internal fetching
vi.mock("../../../src/app/components/onboarding/RepoSelector", () => ({
default: (props: {
selectedOrgs: string[];
+ orgEntries?: OrgEntry[];
selected: RepoRef[];
onChange: (s: RepoRef[]) => void;
}) => (
+
+ {props.selectedOrgs.join(",")}
+
- {/* Navigation buttons */}
-
-
-
+ {/* Navigation buttons — hidden during loading/error to avoid confusion */}
+
+
+
+
+