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
64 changes: 29 additions & 35 deletions src/app/auth/callback/CallbackClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { takeReturnTo } from "@/lib/return-to";
import type { UserRole } from "@/types";

const ROLE_REDIRECT_MAP: Record<UserRole, string> = {
Expand Down Expand Up @@ -64,6 +65,25 @@ export function CallbackClient() {
const code = searchParams.get("code");
const token = searchParams.get("token");

async function completeSignIn(accessToken: string) {
const resolvedUser = await login(accessToken);
// A null user here means the token exchange did not resolve a
// profile. Landing on a dashboard would render that dashboard's
// demo/mock data as if it were real, so surface the failure instead.
if (!resolvedUser) {
setError("sign-in-failed");
return;
}
// A validated same-origin destination (e.g. the bounty the user was
// claiming) wins over the default landing pages.
router.replace(
takeReturnTo() ??
(shouldFinishConnect(resolvedUser.roles, resolvedUser.stellarAddress)
? "/connect"
: redirectForRoles(resolvedUser.roles)),
);
}

async function handleCallback() {
// Try code-based handoff first (backend redirects with ?code=)
if (code) {
Expand All @@ -85,57 +105,31 @@ export function CallbackClient() {
throw new Error("No access token in response");
}

await login(accessToken);
router.replace("/dashboard/contributor");
return;
await completeSignIn(accessToken);
} catch {
setError("Could not complete sign-in. Please try again.");
return;
setError("sign-in-failed");
}
return;
}

// Fallback: direct token auth
if (token) {
// Strip the JWT from the URL immediately so it doesn't persist in
// browser history, referrer headers, or server logs (#9).
window.history.replaceState({}, "", window.location.pathname);
try {
await login(token);
router.replace("/dashboard/contributor");
await completeSignIn(token);
} catch {
setError("Could not complete sign-in. Please try again.");
setError("sign-in-failed");
}
return;
}

// No code or token
setError("No token was returned by GitHub sign-in.");
}

void handleCallback();
if (!token) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setError("no-token");
return;
}

// Strip the JWT from the URL immediately so it doesn't persist in
// browser history, referrer headers, or server logs (#9).
window.history.replaceState({}, "", window.location.pathname);

login(token)
.then((resolvedUser) => {
// A null user here means the token exchange did not resolve a
// profile. Landing on a dashboard would render that dashboard's
// demo/mock data as if it were real, so surface the failure instead.
if (!resolvedUser) {
setError("sign-in-failed");
return;
}
router.replace(
shouldFinishConnect(resolvedUser.roles, resolvedUser.stellarAddress)
? "/connect"
: redirectForRoles(resolvedUser.roles),
);
})
.catch(() => setError("sign-in-failed"));
void handleCallback();
}, [searchParams, login, router]);

const copy =
Expand Down
6 changes: 6 additions & 0 deletions src/app/connect/ConnectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { GITHUB_OAUTH_URL } from "@/lib/config";
import { useAuth } from "@/context/AuthContext";
import { useWallet } from "@/context/WalletContext";
import { messages, t } from "@/lib/messages";
import { storeReturnTo } from "@/lib/return-to";

export function ConnectPanel() {
const { user, loading: authLoading } = useAuth();
Expand Down Expand Up @@ -137,6 +138,11 @@ export function ConnectPanel() {
announced as a link containing a button. */
<a
href={GITHUB_OAUTH_URL}
// Remember where the user was heading (/connect?returnTo=...) so
// the OAuth callback can send them back after sign-in.
onClick={() =>
storeReturnTo(new URLSearchParams(window.location.search).get("returnTo"))
}
className="mt-4 flex h-10 w-full items-center justify-center rounded-full bg-slate-900 text-sm font-medium text-white hover:bg-slate-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200"
>
{messages["connect.github.cta"]}
Expand Down
6 changes: 5 additions & 1 deletion src/components/bounty/ClaimButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ export function ClaimButton({
// from the JWT), so a signed-out click goes to /connect rather than
// firing a request that can only 401.
if (!user) {
router.push('/connect');
// Carry the current page through sign-in so the user lands back here.
const here = window.location.pathname + window.location.search;
router.push(
here === '/' ? '/connect' : `/connect?returnTo=${encodeURIComponent(here)}`,
);
return;
}
dispatch({ type: 'RESET' });
Expand Down
39 changes: 16 additions & 23 deletions src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import {
useRef,
useState,
} from "react";
import { getToken, setToken as persistToken, clearToken, TOKEN_KEY } from "@/lib/auth";
import {
getToken,
getTokenSubject,
setToken as persistToken,
clearToken,
TOKEN_KEY,
} from "@/lib/auth";
import { apiRequest, ApiRequestError } from "@/lib/api";
import { useCrossTabStorage } from "@/hooks/useCrossTabStorage";
import type { AuthUser } from "@/types";
Expand All @@ -29,7 +35,6 @@ interface AuthContextValue {
loading: boolean;
/** True when the auth backend is unreachable and the session state is uncertain (#587) */
degraded: boolean;
login: (token: string) => Promise<void>;
login: (token: string) => Promise<AuthUser | null>;
logout: () => void;
// Resolves with the session's user (null when there's no valid token, after
Expand Down Expand Up @@ -68,33 +73,22 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const token = getToken();
if (!token) {
setUser(null);
setLoading(false);
setDegraded(false);
return;
}
try {
const session = await apiRequest<{ userId: string; username: string }>(
"/auth/me",
);
const profile = await apiRequest<AuthUser>(`/users/${session.userId}`);
setUser(profile);
setDegraded(false);
} catch {
clearToken();
setUser(null);
setDegraded(true);
} finally {
setLoading(false);
setDegraded(false);
return null;
}
// `/auth/me` only returns `{ userId, username }`, so resolving a session
// through it costs two sequential requests. The JWT already carries the
// user id in `sub`; fetch the profile directly and keep `/auth/me` as the
// fallback for tokens whose subject can't be decoded.
const subject = getTokenSubject(token);
const MAX_RETRIES = 3;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const session = await apiRequest<{ userId: string; username: string }>(
"/auth/me",
);
const profile = await apiRequest<AuthUser>(`/users/${session.userId}`);
const userId =
subject ??
(await apiRequest<{ userId: string; username: string }>("/auth/me")).userId;
const profile = await apiRequest<AuthUser>(`/users/${userId}`);
setUser(profile);
setDegraded(false);
return profile;
Expand Down Expand Up @@ -178,7 +172,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);

return (
<AuthContext.Provider value={{ user, loading, degraded, login, logout, refresh }}>
<AuthContext.Provider value={{ user, loading, login, logout, refresh, degraded }}>
{children}
</AuthContext.Provider>
Expand Down
33 changes: 30 additions & 3 deletions src/context/WalletContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,33 @@ import { useCrossTabStorage } from "@/hooks/useCrossTabStorage";

const WALLET_KEY = "mergefi_wallet_address";

// Best-effort storage helpers, same policy as lib/auth.ts: storage access
// throws in some private modes / with blocked site data, and that must never
// break the mount effect, logout sync or wallet linking.
function readStoredWallet(): string | null {
try {
return window.localStorage.getItem(WALLET_KEY);
} catch {
return null;
}
}

function writeStoredWallet(address: string) {
try {
window.localStorage.setItem(WALLET_KEY, address);
} catch {
// In-memory state is still correct for this session.
}
}

function clearStoredWallet() {
try {
window.localStorage.removeItem(WALLET_KEY);
} catch {
// Ignore — the in-memory state is what the UI renders from.
}
}

/**
* Durability of the current wallet connection.
*
Expand Down Expand Up @@ -132,7 +159,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
// so this provider (mounted on every route) doesn't add to the
// critical path to interactivity for routes that don't need it yet.
const id = window.setTimeout(() => {
const stored = window.localStorage.getItem(WALLET_KEY);
const stored = readStoredWallet();
if (stored) {
setAddress(stored);
// This app only ever signs against the build's configured network,
Expand Down Expand Up @@ -205,7 +232,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (authLoading) return;
if (user === null && addressRef.current !== null) {
window.localStorage.removeItem(WALLET_KEY);
clearStoredWallet();
setNetwork(null);
setAddress(null);
setAddressMismatch(false);
Expand Down Expand Up @@ -287,7 +314,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) {
setNetwork(STELLAR_NETWORK);
setAddressMismatch(false);
setNetworkMismatch(false);
window.localStorage.setItem(WALLET_KEY, walletAddress);
writeStoredWallet(walletAddress);

if (user) {
try {
Expand Down
18 changes: 18 additions & 0 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,21 @@ export function clearToken() {
// Best-effort, same as setToken above.
}
}

/**
* Read the `sub` claim (the user id) out of a JWT without verifying it.
* This is only used to skip a round trip: the backend still authenticates the
* token on the `/users/:id` request. Returns null for anything malformed.
*/
export function getTokenSubject(token: string): string | null {
try {
const payload = token.split(".")[1];
if (!payload) return null;
const b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const json = atob(b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), "="));
const sub = (JSON.parse(json) as { sub?: unknown }).sub;
return typeof sub === "string" && sub.length > 0 ? sub : null;
} catch {
return null;
}
}
37 changes: 37 additions & 0 deletions src/lib/return-to.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { sanitizeReturnTo, storeReturnTo, takeReturnTo } from "./return-to";

describe("sanitizeReturnTo", () => {
it("accepts same-origin paths", () => {
expect(sanitizeReturnTo("/issues/42?tab=claims#top")).toBe("/issues/42?tab=claims#top");
});

it.each([
"https://evil.example",
"//evil.example",
"/\\evil.example",
"javascript:alert(1)",
"issues/1",
"/connect",
"/auth/callback",
"/a\nb",
"",
null,
])("rejects %p", (value) => {
expect(sanitizeReturnTo(value as string | null)).toBeNull();
});
});

describe("returnTo storage", () => {
afterEach(() => window.sessionStorage.clear());

it("round-trips once and then clears", () => {
storeReturnTo("/issues/7");
expect(takeReturnTo()).toBe("/issues/7");
expect(takeReturnTo()).toBeNull();
});

it("does not store an unsafe value", () => {
storeReturnTo("//evil.example");
expect(takeReturnTo()).toBeNull();
});
});
37 changes: 37 additions & 0 deletions src/lib/return-to.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const RETURN_TO_KEY = "mergefi_return_to";

/**
* Validate a post-sign-in destination. Only same-origin absolute paths are
* accepted (no scheme, no `//host`, no backslash tricks, no control chars), so
* a crafted `?returnTo=` can't turn the sign-in flow into an open redirect.
* Sign-in pages themselves are rejected to avoid redirect loops.
*/
export function sanitizeReturnTo(value: string | null | undefined): string | null {
if (!value || value.length > 2048) return null;
if (!value.startsWith("/") || value.startsWith("//")) return null;
if (/[\\\u0000-\u001f\u007f]/.test(value)) return null;
if (/^\/(connect|auth)(\/|\?|#|$)/.test(value)) return null;
return value;
}

/** Remember the destination across the cross-origin GitHub OAuth round trip. */
export function storeReturnTo(value: string | null | undefined) {
const safe = sanitizeReturnTo(value);
try {
if (safe) window.sessionStorage.setItem(RETURN_TO_KEY, safe);
else window.sessionStorage.removeItem(RETURN_TO_KEY);
} catch {
// Best-effort (storage can throw in private modes): sign-in still works.
}
}

/** Read and clear the remembered destination, re-validating it. */
export function takeReturnTo(): string | null {
try {
const value = window.sessionStorage.getItem(RETURN_TO_KEY);
window.sessionStorage.removeItem(RETURN_TO_KEY);
return sanitizeReturnTo(value);
} catch {
return null;
}
}
16 changes: 16 additions & 0 deletions src/lib/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,22 @@ describe("generateIdempotencyKey", () => {
const keys = new Set(Array.from({ length: 50 }, () => generateIdempotencyKey()));
expect(keys.size).toBe(50);
});

it("builds a v4 UUID from getRandomValues when randomUUID is unavailable", () => {
const original = crypto.randomUUID;
Object.defineProperty(crypto, "randomUUID", { value: undefined, configurable: true });
const spy = jest.spyOn(Math, "random");
try {
const key = generateIdempotencyKey();
expect(key).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
expect(spy).not.toHaveBeenCalled();
} finally {
spy.mockRestore();
Object.defineProperty(crypto, "randomUUID", { value: original, configurable: true });
}
});
});

// ─── toCents / toMajorUnits ────────────────────────────────────────────────
Expand Down
Loading