From 4b8015d53d44ea83a35b3aeb1f311859b23f5002 Mon Sep 17 00:00:00 2001 From: austinchan3678 Date: Wed, 19 Aug 2026 19:37:35 -0600 Subject: [PATCH 1/2] fix(web): persist oauth return path across discord login --- web/src/lib/auth.ts | 25 +++++++++++++++++++++++++ web/src/pages/auth/LoginDiscordPage.tsx | 5 ++--- web/src/pages/auth/LoginPage.tsx | 21 ++++++++++++++------- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index cf76f8d..58af0f7 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -3,6 +3,31 @@ import { useQuery, useQueryClient } from "@tanstack/react-query" import { api } from "@/lib/api" const SESSION_KEY = "sentinel_session" +const LOGIN_RETURN_TO_KEY = "sentinel_login_return_to" + +function isSafeReturnTo(path: string): boolean { + return path.startsWith("/") && !path.startsWith("//") +} + +// Persist the pre-login path across the Discord OAuth round-trip. Discord's +// `state` is a query param, so stuffing `/oauth/authorize?…&redirect_uri=…` +// into it loses the nested redirect_uri when Discord echoes state unencoded. +export function saveLoginReturnTo(path: string) { + if (!isSafeReturnTo(path) || path === "/") return + sessionStorage.setItem(LOGIN_RETURN_TO_KEY, path) +} + +export function peekLoginReturnTo(): string | null { + const raw = sessionStorage.getItem(LOGIN_RETURN_TO_KEY) + if (!raw || !isSafeReturnTo(raw)) return null + return raw +} + +export function consumeLoginReturnTo(): string { + const value = peekLoginReturnTo() ?? "/" + sessionStorage.removeItem(LOGIN_RETURN_TO_KEY) + return value +} export type Session = { accessToken: string diff --git a/web/src/pages/auth/LoginDiscordPage.tsx b/web/src/pages/auth/LoginDiscordPage.tsx index c7866ca..e3f3164 100644 --- a/web/src/pages/auth/LoginDiscordPage.tsx +++ b/web/src/pages/auth/LoginDiscordPage.tsx @@ -7,7 +7,7 @@ import { OutlineButton } from "@/components/OutlineButton" import { SuccessCheck } from "@/components/SuccessCheck" import { DiscordIcon } from "@/components/icons/socials" import { api } from "@/lib/api" -import { saveSession } from "@/lib/auth" +import { consumeLoginReturnTo, saveSession } from "@/lib/auth" import { DISCORD_INVITE_URL } from "@/lib/links" import { cn } from "@/lib/utils" @@ -52,8 +52,6 @@ export default function LoginDiscordPage() { navigate("/auth/login", { replace: true }) return } - const returnTo = params.get("state") || "/" - void (async () => { try { const res = await api.post( @@ -65,6 +63,7 @@ export default function LoginDiscordPage() { expiresIn: res.data.expires_in, entityId: res.data.entity_id, }) + const returnTo = consumeLoginReturnTo() setTransitioning(true) await new Promise((r) => setTimeout(r, CONVERGE_MS + CHECKMARK_DRAW_MS + HOLD_MS), diff --git a/web/src/pages/auth/LoginPage.tsx b/web/src/pages/auth/LoginPage.tsx index ca2d208..70a1061 100644 --- a/web/src/pages/auth/LoginPage.tsx +++ b/web/src/pages/auth/LoginPage.tsx @@ -1,6 +1,6 @@ import { Loader2 } from "lucide-react" import type { ComponentType, SVGProps } from "react" -import { useState } from "react" +import { useEffect, useState } from "react" import { useLocation, useNavigate, useSearchParams } from "react-router-dom" import { toast } from "sonner" @@ -11,7 +11,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { api } from "@/lib/api" -import { saveSession } from "@/lib/auth" +import { consumeLoginReturnTo, peekLoginReturnTo, saveLoginReturnTo, saveSession } from "@/lib/auth" import { DISCORD_INVITE_URL } from "@/lib/links" import { cn } from "@/lib/utils" @@ -69,15 +69,23 @@ export default function LoginPage() { from?: { pathname: string; search?: string; hash?: string } } | null )?.from - const from = fromLocation + const fromRouter = fromLocation ? `${fromLocation.pathname}${fromLocation.search ?? ""}${fromLocation.hash ?? ""}` - : "/" + : null + // Discord wipes location.state; sessionStorage is the fallback so a bounce + // back to this page (or email login after a failed Discord attempt) still + // returns to /oauth/authorize?…&redirect_uri=…. + const from = fromRouter && fromRouter !== "/" ? fromRouter : (peekLoginReturnTo() ?? "/") const [email, setEmail] = useState(params.get("email") ?? "") const [password, setPassword] = useState("") const [loading, setLoading] = useState(null) const [transitioning, setTransitioning] = useState(false) const isBusy = loading !== null || transitioning + useEffect(() => { + if (fromRouter) saveLoginReturnTo(fromRouter) + }, [fromRouter]) + async function handleSuccess() { setLoading(null) setTransitioning(true) @@ -85,6 +93,7 @@ export default function LoginPage() { await new Promise((resolve) => setTimeout(resolve, CONVERGE_MS + CHECKMARK_DRAW_MS + HOLD_MS), ) + consumeLoginReturnTo() if (document.startViewTransition) { document.startViewTransition(() => navigate(from, { replace: true })) } else { @@ -125,6 +134,7 @@ export default function LoginPage() { // The redirect_uri must byte-match what the oauth service uses at // token-exchange time. Building it from window.location.origin keeps // dev/prod aligned without another env var on the web side. + saveLoginReturnTo(from) const params = new URLSearchParams({ client_id: DISCORD_CLIENT_ID, response_type: "code", @@ -134,9 +144,6 @@ export default function LoginPage() { // still see it (Discord ignores prompt=none until consent is on file // for the requested scopes); subsequent sign-ins go straight back. prompt: "none", - // Round-trip the return path so the callback can land the user - // back where they were trying to go before being bounced to login. - state: from, }) window.location.href = `${DISCORD_AUTHORIZE_URL}?${params.toString()}` return From 2302b2823d192aad5d316f529dcd8e3224d46bc3 Mon Sep 17 00:00:00 2001 From: austinchan3678 Date: Thu, 20 Aug 2026 01:55:53 -0600 Subject: [PATCH 2/2] fix(web): persist authorize return path for all logins --- web/src/components/RequireAuth.tsx | 3 +- web/src/lib/auth.ts | 39 ++++++++++++++++++++++-- web/src/pages/auth/LoginDiscordPage.tsx | 8 ++--- web/src/pages/auth/LoginPage.tsx | 18 +++++------ web/src/pages/oauth/AuthorizePage.tsx | 3 +- web/src/pages/saml/SamlAuthorizePage.tsx | 3 +- 6 files changed, 54 insertions(+), 20 deletions(-) diff --git a/web/src/components/RequireAuth.tsx b/web/src/components/RequireAuth.tsx index d0143e9..1349d8a 100644 --- a/web/src/components/RequireAuth.tsx +++ b/web/src/components/RequireAuth.tsx @@ -1,11 +1,12 @@ import { Navigate, Outlet, useLocation } from "react-router-dom" -import { loadSession } from "@/lib/auth" +import { loadSession, saveLoginReturnFrom } from "@/lib/auth" export function RequireAuth() { const location = useLocation() const session = loadSession() if (!session) { + saveLoginReturnFrom(location) return } return diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 58af0f7..a24312a 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -9,14 +9,36 @@ function isSafeReturnTo(path: string): boolean { return path.startsWith("/") && !path.startsWith("//") } -// Persist the pre-login path across the Discord OAuth round-trip. Discord's -// `state` is a query param, so stuffing `/oauth/authorize?…&redirect_uri=…` -// into it loses the nested redirect_uri when Discord echoes state unencoded. +// The pre-login path (e.g. /oauth/authorize?…&redirect_uri=…) has to outlive +// the bounce to /auth/login. React Router location.state is dropped on the +// initial history.replace, a refresh, and the Discord round-trip — so both +// email and Discord login were landing back on a stripped authorize URL. export function saveLoginReturnTo(path: string) { if (!isSafeReturnTo(path) || path === "/") return + const existing = sessionStorage.getItem(LOGIN_RETURN_TO_KEY) + // Don't clobber a full authorize URL with a pathname-only bounce. + if (existing && existing.includes("?") && !path.includes("?")) return sessionStorage.setItem(LOGIN_RETURN_TO_KEY, path) } +export function saveLoginReturnFrom(location: { + pathname: string + search?: string + hash?: string +}) { + let path = `${location.pathname}${location.search ?? ""}${location.hash ?? ""}` + // On first load RR can report an empty search while the address bar still + // has ?client_id=…&redirect_uri=…. Prefer the bar when pathnames match. + if ( + !path.includes("?") && + window.location.search && + window.location.pathname === location.pathname + ) { + path = `${window.location.pathname}${window.location.search}${window.location.hash}` + } + saveLoginReturnTo(path) +} + export function peekLoginReturnTo(): string | null { const raw = sessionStorage.getItem(LOGIN_RETURN_TO_KEY) if (!raw || !isSafeReturnTo(raw)) return null @@ -29,6 +51,17 @@ export function consumeLoginReturnTo(): string { return value } +export type ReturnLocation = { pathname: string; search: string; hash: string } + +export function locationFromReturnPath(path: string): ReturnLocation { + const url = new URL(isSafeReturnTo(path) ? path : "/", window.location.origin) + return { pathname: url.pathname, search: url.search, hash: url.hash } +} + +export function consumeLoginReturnLocation(): ReturnLocation { + return locationFromReturnPath(consumeLoginReturnTo()) +} + export type Session = { accessToken: string refreshToken: string diff --git a/web/src/pages/auth/LoginDiscordPage.tsx b/web/src/pages/auth/LoginDiscordPage.tsx index e3f3164..1dfffd3 100644 --- a/web/src/pages/auth/LoginDiscordPage.tsx +++ b/web/src/pages/auth/LoginDiscordPage.tsx @@ -7,7 +7,7 @@ import { OutlineButton } from "@/components/OutlineButton" import { SuccessCheck } from "@/components/SuccessCheck" import { DiscordIcon } from "@/components/icons/socials" import { api } from "@/lib/api" -import { consumeLoginReturnTo, saveSession } from "@/lib/auth" +import { consumeLoginReturnLocation, saveSession } from "@/lib/auth" import { DISCORD_INVITE_URL } from "@/lib/links" import { cn } from "@/lib/utils" @@ -63,15 +63,15 @@ export default function LoginDiscordPage() { expiresIn: res.data.expires_in, entityId: res.data.entity_id, }) - const returnTo = consumeLoginReturnTo() + const dest = consumeLoginReturnLocation() setTransitioning(true) await new Promise((r) => setTimeout(r, CONVERGE_MS + CHECKMARK_DRAW_MS + HOLD_MS), ) if (document.startViewTransition) { - document.startViewTransition(() => navigate(returnTo, { replace: true })) + document.startViewTransition(() => navigate(dest, { replace: true })) } else { - navigate(returnTo, { replace: true }) + navigate(dest, { replace: true }) } } catch (err: unknown) { const body = diff --git a/web/src/pages/auth/LoginPage.tsx b/web/src/pages/auth/LoginPage.tsx index 70a1061..ceda4a2 100644 --- a/web/src/pages/auth/LoginPage.tsx +++ b/web/src/pages/auth/LoginPage.tsx @@ -11,7 +11,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { api } from "@/lib/api" -import { consumeLoginReturnTo, peekLoginReturnTo, saveLoginReturnTo, saveSession } from "@/lib/auth" +import { consumeLoginReturnTo, locationFromReturnPath, peekLoginReturnTo, saveLoginReturnTo, saveSession } from "@/lib/auth" import { DISCORD_INVITE_URL } from "@/lib/links" import { cn } from "@/lib/utils" @@ -61,9 +61,9 @@ export default function LoginPage() { const [params] = useSearchParams() const arrivedFromOnboarding = params.has("email") // Reconstruct the full pre-login URL — pathname alone drops OAuth query - // params (?client_id=…&redirect_uri=…&scope=…) and the hash, so a 3rd-party - // app that bounces an unauthenticated user through /auth/login was landing - // back at /oauth/authorize stripped down to "Invalid request." + // params (?client_id=…&redirect_uri=…&scope=…) and the hash. + // sessionStorage is the source of truth: location.state does not survive + // the unauthenticated bounce, a refresh, or Discord. const fromLocation = ( location.state as { from?: { pathname: string; search?: string; hash?: string } @@ -72,9 +72,6 @@ export default function LoginPage() { const fromRouter = fromLocation ? `${fromLocation.pathname}${fromLocation.search ?? ""}${fromLocation.hash ?? ""}` : null - // Discord wipes location.state; sessionStorage is the fallback so a bounce - // back to this page (or email login after a failed Discord attempt) still - // returns to /oauth/authorize?…&redirect_uri=…. const from = fromRouter && fromRouter !== "/" ? fromRouter : (peekLoginReturnTo() ?? "/") const [email, setEmail] = useState(params.get("email") ?? "") const [password, setPassword] = useState("") @@ -93,11 +90,12 @@ export default function LoginPage() { await new Promise((resolve) => setTimeout(resolve, CONVERGE_MS + CHECKMARK_DRAW_MS + HOLD_MS), ) - consumeLoginReturnTo() + const stored = consumeLoginReturnTo() + const dest = locationFromReturnPath(stored !== "/" ? stored : from) if (document.startViewTransition) { - document.startViewTransition(() => navigate(from, { replace: true })) + document.startViewTransition(() => navigate(dest, { replace: true })) } else { - navigate(from, { replace: true }) + navigate(dest, { replace: true }) } } diff --git a/web/src/pages/oauth/AuthorizePage.tsx b/web/src/pages/oauth/AuthorizePage.tsx index 885b73c..77dd8f0 100644 --- a/web/src/pages/oauth/AuthorizePage.tsx +++ b/web/src/pages/oauth/AuthorizePage.tsx @@ -8,7 +8,7 @@ import { SuccessCheck } from "@/components/SuccessCheck" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { api } from "@/lib/api" -import { loadSession, useAuth } from "@/lib/auth" +import { loadSession, saveLoginReturnFrom, useAuth } from "@/lib/auth" import { resolveScopes } from "@/lib/scopes" import { cn } from "@/lib/utils" @@ -189,6 +189,7 @@ export default function AuthorizePage() { }, [validate.data?.prompt]) if (!session) { + saveLoginReturnFrom(location) return } diff --git a/web/src/pages/saml/SamlAuthorizePage.tsx b/web/src/pages/saml/SamlAuthorizePage.tsx index a43785c..c7211a3 100644 --- a/web/src/pages/saml/SamlAuthorizePage.tsx +++ b/web/src/pages/saml/SamlAuthorizePage.tsx @@ -8,7 +8,7 @@ import { SuccessCheck } from "@/components/SuccessCheck" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { api } from "@/lib/api" -import { loadSession, useAuth } from "@/lib/auth" +import { loadSession, saveLoginReturnFrom, useAuth } from "@/lib/auth" import { cn } from "@/lib/utils" const CONVERGE_MS = 250 @@ -154,6 +154,7 @@ export default function SamlAuthorizePage() { } if (!session) { + saveLoginReturnFrom(location) return }