[auth] discord login

This commit is contained in:
Devin Zuczek
2026-10-24 00:28:10 -04:00
parent 09e40af6b7
commit d1e69bfd14
14 changed files with 964 additions and 41 deletions
+169 -3
View File
@@ -57,6 +57,12 @@ interface SiteConfig {
* benefits are off. The `state` nonce is appended here — see `startDiscordAuth`.
*/
discordAuthorizeUrl: string | null
/**
* The consent URL for "Sign in with Discord" — same app, `identify` scope only, and its own
* return leg (`/login/discord`). On the same switch as the claim, so null whenever
* `benefitsEnabled` is false. See `DiscordLoginPage`.
*/
discordLoginUrl: string | null
}
/** The private self DTO from `accounts` (`GET /account/me`). */
@@ -593,21 +599,52 @@ const claimBenefits = (code: string): Promise<ClaimResult> =>
*/
const OAUTH_STATE_KEY = 'rf_discord_state'
/**
* Where to land after a Discord SIGN-IN — the `?next=` the login page was opened with.
* Stashed beside the nonce because the round trip through Discord loses the query string,
* and read back by `DiscordLoginPage` exactly once.
*/
const OAUTH_NEXT_KEY = 'rf_discord_next'
/**
* Send the browser to Discord's consent screen.
*
* A real navigation, not a client-side route — Discord is another origin. The `state` is
* minted here and stashed for the return leg; `www` built everything else about the URL
* (see `/api/config`), so this only ever appends the one parameter it owns.
* (see `/api/config`), so this only ever appends the one parameter it owns. `next` is the
* sign-in's post-login destination; the claim passes none.
*/
function startDiscordAuth(authorizeUrl: string) {
function startDiscordAuth(authorizeUrl: string, next?: string) {
const state = crypto.randomUUID()
sessionStorage.setItem(OAUTH_STATE_KEY, state)
if (next) sessionStorage.setItem(OAUTH_NEXT_KEY, next)
else sessionStorage.removeItem(OAUTH_NEXT_KEY)
const url = new URL(authorizeUrl)
url.searchParams.set('state', state)
window.location.assign(url.toString())
}
/** What `/api/login/discord` answers: auth's token body, plus who signed in and whether it made them. */
interface DiscordSignIn {
access_token?: string
/** True when no account was linked to this Discord and `auth` created one just now. */
created?: boolean
/** The Discord username, for the confirmation line only. */
discordUsername?: string
}
/**
* Redeem the sign-in's authorization code. Goes through `www`, like signup and the claim,
* because the exchange needs the Discord client secret; `www` then has `auth` issue the
* session and relays the token body, which is stored exactly as a password sign-in's is.
*/
async function signInWithDiscord(code: string): Promise<DiscordSignIn> {
const data = await call<DiscordSignIn>('/api/login/discord', { json: { code } })
if (typeof data.access_token !== 'string') throw new Error(authUnreachable('login'))
setToken(data.access_token)
return data
}
/**
* Admin-only broadcasts. The token goes to `notify`, which enforces the admin-role gate
* — so a session without the role is rejected there (403) even though the UI shows no
@@ -2164,6 +2201,7 @@ export function App() {
turnstileSiteKey: null,
benefitsEnabled: false,
discordAuthorizeUrl: null,
discordLoginUrl: null,
})
setAccount(null)
})
@@ -2181,7 +2219,12 @@ export function App() {
return (
<>
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
{path === '/login' || path === '/signup' ? (
{path === '/login/discord' ? (
// Discord's registered redirect for a SIGN-IN (the claim's is `/claim`). Matched
// before `/login`, which it would otherwise never reach; a cold load falls through
// to the SPA shell like every other client-side route.
<DiscordLoginPage config={config} navigate={navigate} onAuthed={setAccount} />
) : path === '/login' || path === '/signup' ? (
// One page, two doors. `/signup` exists so the homepage's create-account link
// lands on that tab instead of dropping people on sign-in to find it — and so
// the URL is linkable. Unknown paths fall back to index.html (see the assets
@@ -2643,6 +2686,7 @@ function LoginPage({
}
const siteKey = config?.signupEnabled ? config.turnstileSiteKey : null
const discordLoginUrl = config?.discordLoginUrl ?? null
return (
<main className="shell">
@@ -2689,6 +2733,25 @@ function LoginPage({
your Steam ID — set a password on it and it signs in here too.
</p>
<LoginForm onAuthed={authed} />
{/* The other door. Shown on the server's say-so like the signup tab: the URL is
null until the Discord app is configured. `next` rides along through Discord in
sessionStorage, since the round trip drops the query. */}
{discordLoginUrl && (
<div className="alt-signin">
<p className="muted">or</p>
<button
type="button"
className="cta discord"
onClick={() => startDiscordAuth(discordLoginUrl, next)}
>
Sign in with Discord
</button>
<p className="muted">
Signs you into the account your Discord is linked to. No account yet? This makes
one — set a password on it afterwards and it signs in to the game too.
</p>
</div>
)}
{/* The tabs above already offer this; the line under the button is where
someone who just found out they have no account is actually looking.
Gated on the same key, so it can't point at a door that isn't there. */}
@@ -2707,6 +2770,109 @@ function LoginPage({
)
}
/**
* `/login/discord` — the page Discord redirects a SIGN-IN back to. Its own route rather than
* `/claim`, because that page wants a session before it does anything and this is how one
* gets made; it is the app's second registered `redirect_uri`, so like `/claim` it has to
* work on a cold load.
*
* The return leg mirrors the claim's: check the nonce this tab minted, strip the code off
* the URL before anything else, redeem it exactly once. What differs is the outcome. A
* sign-in to an existing account just goes where the login page was headed. A sign-in that
* CREATED an account (`created`) stops here first: that account has no password, and
* without one it can't sign in to the game — on a Quest without a Meta account, which is
* the whole point of making it this way — so the one thing to tell a brand-new player is
* where to set one.
*/
function DiscordLoginPage({
config,
navigate,
onAuthed,
}: {
config: SiteConfig | undefined
navigate: Navigate
onAuthed: (a: SelfAccount) => void
}) {
const [error, setError] = useState('')
const [created, setCreated] = useState<SelfAccount | null>(null)
// StrictMode runs effects twice in dev, and a Discord code is single-use — see BenefitsPanel.
const redeemed = useRef(false)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const state = params.get('state')
const expected = sessionStorage.getItem(OAUTH_STATE_KEY)
const next = safeNextPath(sessionStorage.getItem(OAUTH_NEXT_KEY)) ?? '/account'
if (code === null) {
// Opened directly, or Discord sent the player back with `?error=access_denied`
// because they cancelled. Nothing to redeem; back to the door.
navigate('/login')
return
}
window.history.replaceState(null, '', '/login/discord')
if (redeemed.current) return
redeemed.current = true
sessionStorage.removeItem(OAUTH_STATE_KEY)
sessionStorage.removeItem(OAUTH_NEXT_KEY)
if (state === null || expected === null || state !== expected) {
setError('That Discord sign-in did not match this browser. Please start again.')
return
}
signInWithDiscord(code)
.then(async (result) => {
const me = await fetchMe()
onAuthed(me)
if (result.created) setCreated(me)
else navigate(next)
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
}, [navigate, onAuthed])
if (created) {
return (
<main className="shell">
<section className="card">
<h2>Welcome to RecFlare</h2>
<p>
Your account is ready, linked to your Discord. Your username is{' '}
<strong>@{created.username}</strong> — you can change it on your account page.
</p>
<p className="muted">
It has no password yet. Set one on your account page: your username and that password
are how you sign in to the game — including on a Quest without a Meta account.
</p>
<Link to="/account" navigate={navigate} className="cta">
Go to my account
</Link>
</section>
</main>
)
}
return (
<main className="shell">
<section className="card">
<h2>Signing in with Discord</h2>
{error ? (
<>
<p className="error">{error}</p>
<Link to="/login" navigate={navigate} className="cta ghost">
Back to sign in
</Link>
</>
) : (
<p className="muted">{config === undefined ? 'Loading…' : 'One moment…'}</p>
)}
</section>
</main>
)
}
/** The signed-in account page. Redirects to sign-in when there's no session. */
function AccountPage({
account,
+16
View File
@@ -2074,3 +2074,19 @@ button[type='submit']:disabled {
border-radius: 3px;
border: 1px solid var(--line);
}
/* The login card's second door, under the password form: an "or" and the Discord button,
separated from the form by a rule so the two aren't read as one. */
.alt-signin {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--line);
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.alt-signin > .muted:first-child {
margin: 0;
}