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
14 changes: 14 additions & 0 deletions src/lib/return-to.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Sanitize a returnTo URL to prevent open redirect attacks.
* Only allows relative paths starting with `/`.
*/
export function sanitizeReturnTo(
value: string | null | undefined,
): string {
if (!value || typeof value !== "string") return "/";
// Must start with exactly one forward slash
if (!value.startsWith("/") || value.startsWith("//")) return "/";
// Block backslash (some browsers normalize \ to /)
if (value.includes("\\")) return "/";
return value;
}
1 change: 1 addition & 0 deletions src/routes/auth/hackerspub/-graphql-callback-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export const POST = async ({ request }: { request: Request }) => {
JSON.stringify({
ok: true,
user: { id: user.id, handle: user.fediverseHandle ?? user.handle },
returnTo: session.returnTo,
}),
{ status: 200, headers },
);
Expand Down
3 changes: 2 additions & 1 deletion src/routes/auth/hackerspub/-graphql-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export const GET = async ({ request }: { request: Request }) => {
const data = await response.json();

if (response.ok) {
window.location.href = '/';
var dest = data.returnTo;
window.location.href = (typeof dest === 'string' && dest.charAt(0) === '/' && dest.charAt(1) !== '/') ? dest : '/';
} else {
window.location.href = '/?error=' + (data.error || 'auth_failed');
}
Expand Down
3 changes: 2 additions & 1 deletion src/routes/auth/hackerspub/-graphql-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const POST = async ({ request }: { request: Request }) => {
const body = (await request.json().catch(() => null)) as {
instance?: string;
username?: string;
returnTo?: string;
} | null;

if (!body?.instance) {
Expand Down Expand Up @@ -36,7 +37,7 @@ export const POST = async ({ request }: { request: Request }) => {
);
const ttlSeconds =
isFinite(ttlSecondsEnv) && ttlSecondsEnv > 0 ? ttlSecondsEnv : 600; // 10 min default (email delivery can be slow)
createHackersPubSession(state, instance, username, ttlSeconds);
createHackersPubSession(state, instance, username, ttlSeconds, body.returnTo);

// Build verifyUrl template — HackersPub will substitute {token} and {code}
const callbackBase = `${env.baseUrl}/auth/hackerspub/callback`;
Expand Down
2 changes: 1 addition & 1 deletion src/routes/auth/mastodon/-oauth-callback-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export const POST = async ({ request }: { request: Request }) => {
);

return new Response(
JSON.stringify({ ok: true, user: { id: user.id, handle: user.fediverseHandle ?? user.handle } }),
JSON.stringify({ ok: true, user: { id: user.id, handle: user.fediverseHandle ?? user.handle }, returnTo: sessionVerification.session.returnTo }),
{ status: 200, headers },
);
} catch (error) {
Expand Down
3 changes: 2 additions & 1 deletion src/routes/auth/mastodon/-oauth-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export const GET = async ({ request }: { request: Request }) => {
const data = await response.json();

if (response.ok) {
window.location.href = '/';
var dest = data.returnTo;
window.location.href = (typeof dest === 'string' && dest.charAt(0) === '/' && dest.charAt(1) !== '/') ? dest : '/';
} else {
window.location.href = '/?error=' + (data.error || 'auth_failed');
}
Expand Down
4 changes: 2 additions & 2 deletions src/routes/auth/mastodon/-oauth-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { validateInstanceHostname } from "~/server/miauth-sessions";
import { getOrRegisterOAuthApp, createOAuthSession } from "~/server/mastodon-oauth-sessions";

export const POST = async ({ request }: { request: Request }) => {
const body = await request.json().catch(() => null) as { instance?: string } | null;
const body = await request.json().catch(() => null) as { instance?: string; returnTo?: string } | null;

if (!body?.instance) {
return Response.json({ error: "instance is required" }, { status: 400 });
Expand All @@ -27,7 +27,7 @@ export const POST = async ({ request }: { request: Request }) => {
const state = randomUUID();
const ttlSecondsEnv = parseInt(process.env.MASTODON_OAUTH_SESSION_TTL_SECONDS ?? "300", 10);
const ttlSeconds = isFinite(ttlSecondsEnv) && ttlSecondsEnv > 0 ? ttlSecondsEnv : 300;
createOAuthSession(state, instance, app.clientId, app.clientSecret, ttlSeconds);
createOAuthSession(state, instance, app.clientId, app.clientSecret, ttlSeconds, body.returnTo);

const redirectUri = `${env.baseUrl}/auth/mastodon/oauth-callback`;

Expand Down
2 changes: 1 addition & 1 deletion src/routes/auth/misskey/-miauth-callback-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export const POST = async ({ request }: { request: Request }) => {
);

return new Response(
JSON.stringify({ ok: true, user: { id: user.id, handle: user.fediverseHandle ?? user.handle } }),
JSON.stringify({ ok: true, user: { id: user.id, handle: user.fediverseHandle ?? user.handle }, returnTo: sessionVerification.returnTo }),
{ status: 200, headers }
);
} catch (error) {
Expand Down
3 changes: 2 additions & 1 deletion src/routes/auth/misskey/-miauth-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export const GET = async ({ request }: { request: Request }) => {
const data = await response.json();

if (response.ok) {
window.location.href = '/';
var dest = data.returnTo;
window.location.href = (typeof dest === 'string' && dest.charAt(0) === '/' && dest.charAt(1) !== '/') ? dest : '/';
} else {
window.location.href = '/?error=' + (data.error || 'auth_failed');
}
Expand Down
4 changes: 2 additions & 2 deletions src/routes/auth/misskey/-miauth-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { env } from "~/server/env";
import { createMiAuthSession, validateInstanceHostname } from "~/server/miauth-sessions";

export const POST = async ({ request }: { request: Request }) => {
const body = await request.json().catch(() => null) as { instance?: string } | null;
const body = await request.json().catch(() => null) as { instance?: string; returnTo?: string } | null;

if (!body?.instance) {
return Response.json({ error: "instance is required" }, { status: 400 });
Expand All @@ -21,7 +21,7 @@ export const POST = async ({ request }: { request: Request }) => {
// Store session server-side for verification during callback
const ttlSecondsEnv = parseInt(process.env.MIAUTH_SESSION_TTL_SECONDS ?? "300", 10);
const ttlSeconds = isFinite(ttlSecondsEnv) && ttlSecondsEnv > 0 ? ttlSecondsEnv : 300; // 5 min default
createMiAuthSession(sessionId, instance, ttlSeconds);
createMiAuthSession(sessionId, instance, ttlSeconds, body.returnTo);

const callbackUrl = `${env.baseUrl}/auth/misskey/miauth-callback?instance=${encodeURIComponent(instance)}&session=${encodeURIComponent(sessionId)}`;

Expand Down
60 changes: 41 additions & 19 deletions src/routes/auth/signin.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import { getSessionUser } from "~/server/auth";
import { useEffect, useRef, useState } from "react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
Expand All @@ -21,16 +24,34 @@ import {
} from "~/components/ui/dialog";
import { useAuth } from "~/routes/__root";
import { usePostHog } from "posthog-js/react";
import { sanitizeReturnTo } from "~/lib/return-to";

const checkAlreadySignedIn = createServerFn({ method: "GET" }).handler(
async () => {
const request = getRequest();
const user = await getSessionUser(request);
return { isSignedIn: !!user };
},
);

export const Route = createFileRoute("/auth/signin")({
component: SignInPage,
validateSearch: (
search: Record<string, unknown>,
): { from?: string; reason?: string; event?: string } => ({
): { from?: string; reason?: string; event?: string; returnTo?: string } => ({
from: typeof search.from === "string" ? search.from : undefined,
reason: typeof search.reason === "string" ? search.reason : undefined,
event: typeof search.event === "string" ? search.event : undefined,
returnTo: typeof search.returnTo === "string" ? search.returnTo : undefined,
}),
beforeLoad: async ({ search }) => {
const { isSignedIn } = await checkAlreadySignedIn();
if (isSignedIn) {
const returnTo = typeof search.returnTo === "string" ? search.returnTo : undefined;
const dest = sanitizeReturnTo(returnTo);
throw redirect({ href: dest });
}
},
});

// ─── Auth Provider Registry ─────────────────────────────────────────────────
Expand All @@ -46,7 +67,7 @@ type AuthProviderDef = {
id: string;
name: string;
icon: string;
DialogForm: React.ComponentType<{ onClose: () => void }>;
DialogForm: React.ComponentType<{ onClose: () => void; returnTo?: string }>;
};

const AUTH_PROVIDERS: AuthProviderDef[] = [
Expand Down Expand Up @@ -122,7 +143,7 @@ function StepIndicator({ phase }: { phase: Phase }) {

// ─── Provider Dialog Forms ──────────────────────────────────────────────────

function MastodonDialogForm({ onClose: _onClose }: { onClose: () => void }) {
function MastodonDialogForm({ onClose: _onClose, returnTo }: { onClose: () => void; returnTo?: string }) {
const [instance, setInstance] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
Expand All @@ -140,7 +161,7 @@ function MastodonDialogForm({ onClose: _onClose }: { onClose: () => void }) {
const res = await fetch("/api/auth/mastodon/oauth-start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instance: trimmed }),
body: JSON.stringify({ instance: trimmed, returnTo }),
});
const data = await res.json();
if (!res.ok) {
Expand Down Expand Up @@ -213,7 +234,7 @@ function MastodonDialogForm({ onClose: _onClose }: { onClose: () => void }) {
);
}

function MiAuthDialogForm({ onClose: _onClose }: { onClose: () => void }) {
function MiAuthDialogForm({ onClose: _onClose, returnTo }: { onClose: () => void; returnTo?: string }) {
const [instance, setInstance] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
Expand All @@ -231,7 +252,7 @@ function MiAuthDialogForm({ onClose: _onClose }: { onClose: () => void }) {
const res = await fetch("/api/auth/misskey/miauth-start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instance: trimmed }),
body: JSON.stringify({ instance: trimmed, returnTo }),
});
const data = await res.json();
if (!res.ok) {
Expand Down Expand Up @@ -306,7 +327,7 @@ function MiAuthDialogForm({ onClose: _onClose }: { onClose: () => void }) {

// ─── HackersPub GraphQL Auth ────────────────────────────────────────────────

function HackersPubDialogForm({ onClose: _onClose }: { onClose: () => void }) {
function HackersPubDialogForm({ onClose: _onClose, returnTo }: { onClose: () => void; returnTo?: string }) {
const [instance, setInstance] = useState("hackers.pub");
const [username, setUsername] = useState("");
const [error, setError] = useState("");
Expand All @@ -331,7 +352,7 @@ function HackersPubDialogForm({ onClose: _onClose }: { onClose: () => void }) {
const res = await fetch("/api/auth/hackerspub/graphql-start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instance: trimmedInstance, username: trimmedUsername }),
body: JSON.stringify({ instance: trimmedInstance, username: trimmedUsername, returnTo }),
});
const data = await res.json();
if (!res.ok) {
Expand Down Expand Up @@ -436,8 +457,8 @@ function HackersPubDialogForm({ onClose: _onClose }: { onClose: () => void }) {

function SignInPage() {
const navigate = useNavigate();
const { from, reason } = Route.useSearch();
const { user, setUser, loaded } = useAuth();
const { from, reason, returnTo } = Route.useSearch();
const { setUser } = useAuth();
const posthog = usePostHog();

// OTP state
Expand All @@ -453,13 +474,6 @@ function SignInPage() {
// Which provider dialog is open (by id), or null
const [openProviderId, setOpenProviderId] = useState<string | null>(null);

useEffect(() => {
if (loaded && user) {
navigate({ to: "/" });
}
}, [loaded, user, navigate]);

if (loaded && user) return null;

function normalizeHandle(h: string): string {
return h.startsWith("@") ? h.slice(1) : h;
Expand Down Expand Up @@ -514,7 +528,14 @@ function SignInPage() {
});
setPhase("success");
posthog?.capture("sign_in", { handle: normalized });
setTimeout(() => navigate({ to: "/" }), 2000);
const dest = sanitizeReturnTo(returnTo);
setTimeout(() => {
if (dest !== "/") {
window.location.href = dest;
} else {
navigate({ to: "/" });
}
}, 2000);
} else if (data.error === "challenge expired") {
setError("Challenge expired. Please try again.");
setPhase("error");
Expand Down Expand Up @@ -765,6 +786,7 @@ function SignInPage() {
</DialogHeader>
<provider.DialogForm
onClose={() => setOpenProviderId(null)}
returnTo={returnTo}
/>
</DialogContent>
</Dialog>
Expand Down
2 changes: 1 addition & 1 deletion src/routes/calendar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function MyCalendarPage() {
fetch("/api/users/settings")
.then((r) => {
if (r.status === 401) {
navigate({ to: "/auth/signin" });
navigate({ to: "/auth/signin", search: { returnTo: "/calendar" } });
return null;
}
if (!r.ok) throw new Error("Failed to load");
Expand Down
4 changes: 2 additions & 2 deletions src/routes/events/$eventId/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ function EventDetailPage() {
</Button>
) : (
<Button size="sm" asChild>
<Link to="/auth/signin" search={{ reason: "rsvp" }}><Trans id="Sign in to RSVP" message="Sign in to RSVP" /></Link>
<Link to="/auth/signin" search={{ reason: "rsvp", returnTo: `/events/${eventId}/register` }}><Trans id="Sign in to RSVP" message="Sign in to RSVP" /></Link>
</Button>
)
) : rsvpData.userRsvp ? (
Expand Down Expand Up @@ -501,7 +501,7 @@ function EventDetailPage() {
</Button>
) : (
<Button asChild className="w-full">
<Link to="/auth/signin" search={{ reason: "rsvp" }}><Trans id="Sign in to RSVP" message="Sign in to RSVP" /></Link>
<Link to="/auth/signin" search={{ reason: "rsvp", returnTo: `/events/${eventId}/register` }}><Trans id="Sign in to RSVP" message="Sign in to RSVP" /></Link>
</Button>
)
) : rsvpData.userRsvp ? (
Expand Down
2 changes: 1 addition & 1 deletion src/routes/events/$eventId/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function RegisterPage() {
if (rsvp.allowAnonymousRsvp) {
setIsAnonymousMode(true);
} else {
navigate({ to: "/auth/signin", search: { reason: "rsvp" } });
navigate({ to: "/auth/signin", search: { reason: "rsvp", returnTo: `/events/${eventId}/register` } });
}
} else if (!rsvp.isAuthenticated && rsvp.userRsvp) {
setIsAnonymousMode(true);
Expand Down
4 changes: 2 additions & 2 deletions src/routes/events/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ function CreateEventPage() {
.then((r) => r.json())
.then((data) => {
if (!data.user) {
navigate({ to: "/auth/signin" });
navigate({ to: "/auth/signin", search: { returnTo: "/events/create" } });
} else {
setAuthed(true);
}
})
.catch(() => navigate({ to: "/auth/signin" }));
.catch(() => navigate({ to: "/auth/signin", search: { returnTo: "/events/create" } }));
}, [navigate]);

// Groups the user can create events for
Expand Down
4 changes: 2 additions & 2 deletions src/routes/groups/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@ function CreateGroupPage() {
.then((r) => r.json())
.then((data) => {
if (!data.user) {
navigate({ to: "/auth/signin" });
navigate({ to: "/auth/signin", search: { returnTo: "/groups/create" } });
} else {
setAuthed(true);
}
})
.catch(() => navigate({ to: "/auth/signin" }));
.catch(() => navigate({ to: "/auth/signin", search: { returnTo: "/groups/create" } }));
}, [navigate]);

// Basic info
Expand Down
2 changes: 1 addition & 1 deletion src/routes/groups/my.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function MyGroupsPage() {
.then((r) => r.json())
.then((data) => {
if (!data.user) {
navigate({ to: "/auth/signin" });
navigate({ to: "/auth/signin", search: { returnTo: "/groups/my" } });
return;
}
return fetch("/api/me/groups");
Expand Down
2 changes: 1 addition & 1 deletion src/routes/settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function SettingsPage() {
fetch("/api/users/settings")
.then((r) => {
if (r.status === 401) {
navigate({ to: "/auth/signin" });
navigate({ to: "/auth/signin", search: { returnTo: "/settings" } });
return null;
}
if (!r.ok) throw new Error("Failed to load settings");
Expand Down
Loading
Loading