[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
+34 -5
View File
@@ -220,11 +220,40 @@ won't pick up newly created secrets until it restarts.
which one is missing. `wrangler tail www` shows it.
In the [Discord developer portal](https://discord.com/developers/applications),
add `https://<your domain>/claim` to the app's **Redirects**. It has to match byte
for byte: `www` derives the redirect URI from the incoming request's own origin
(never from the request body, which would turn the client secret into a redemption
oracle for someone else's app), so add `http://localhost:5173/claim` too if you
want the flow to work under `pnpm turbo dev`.
add `https://<your domain>/claim` **and** `https://<your domain>/login/discord` to the
app's **Redirects**. Each has to match byte for byte: `www` derives the redirect URI
from the incoming request's own origin (never from the request body, which would turn
the client secret into a redemption oracle for someone else's app), so add the
`http://localhost:5173/…` pair too if you want the flows to work under `pnpm turbo dev`.
### Sign in with Discord
The same app also serves the login page's **Sign in with Discord** button, on the same
switch (`/api/config` serves `discordLoginUrl` beside `discordAuthorizeUrl`, both null
while the claim is closed). It asks Discord for the `identify` scope only and returns to
`/login/discord`, where the SPA posts the code to `POST /api/login/discord`. `www`
exchanges it, reads `GET /users/@me` for the Discord user id, revokes the token, and
mints an **identity assertion** — a one-minute HS256 token signed with the shared
`JWT_SECRET` naming platform `101` and that id — which it posts to `auth`'s
`identity_assertion` grant over the service binding. `auth` resolves the id through
`platform_account`:
- **One linked account** (a player who claimed benefits on it) — signed in; the ordinary
token body comes back and the SPA stores it like a password sign-in's.
- **No linked account** — `auth` **creates** one and links the Discord to it, under the
ban-evasion check and the per-IP / per-identity signup caps (but not `PASSWORD_SIGNUP`:
that switch exists because a password account has no identity but its address). The
body carries `created: true`, and the SPA stops to say so: the account has **no
password**, and setting one on the account page is what lets it sign in to the game —
on a sideloaded Quest without a Meta account, which is the case this is for. Its first
benefits claim still pays the welcome gift: the claim keys "first" on roles being
recorded on a Discord link, not on the link existing.
- **Several linked accounts** — refused; the claim never produces this.
`www` never chooses the account id: the assertion names an identity, and which account
that is (or that there is none) is `auth`'s decision from the same table `cached_login`
reads. The Discord snowflake stays off the new account's blob and token, as it does
after a claim.
### Discord role refresh (cron)
+8
View File
@@ -40,6 +40,14 @@ const AUTH_MESSAGES: Record<string, string> = {
'That platform cannot be verified — only Steam and Meta are supported.',
'no linked account for this platform identity':
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
// The website's Discord sign-in found the Discord user on several accounts — a state the
// benefits claim never produces, so this is for an operator to untangle, not the player.
'platform identity is linked to more than one account':
'That Discord account is linked to more than one RecFlare account. Sign in with your username and password instead.',
// www minted the assertion a moment ago, so this is a key mismatch between www and auth
// (or a clock a minute out), never something the player did.
'invalid or expired identity assertion':
'Sign-in is unavailable right now. This is a problem on our end — please try again later.',
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
// An account that shares a device or network with a BANNED one. Phrased for BOTH the
// person evading a ban and the housemate of one — the IP arm cannot tell them apart —
+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;
}
+51
View File
@@ -55,6 +55,15 @@ export const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize'
*/
export const SCOPES = 'identify guilds.members.read'
/**
* What "Sign in with Discord" asks for: the user's id and nothing else. The sign-in has no
* question to ask about the guild — it only needs to know WHO, so the account linked to that
* Discord user can be signed into (or made). Asking for the member read here would put a
* permission on the consent screen that the sign-in never uses; the benefits claim is where
* roles are read, with its own consent.
*/
export const LOGIN_SCOPES = 'identify'
/** Everything the claim needs configured. Resolved per request; see `discordConfig`. */
export interface DiscordConfig {
/** The application's client id. PUBLIC — it ships to the browser in the authorize URL. */
@@ -170,6 +179,15 @@ export async function readSecret(secret: SecretsStoreSecret, name: string): Prom
*/
export const redirectUri = (request: Request): string => new URL('/claim', request.url).toString()
/**
* The sign-in's return leg — its own registered redirect, so Discord sends a sign-in back to
* a page that knows it is one. `/claim` can't double for it: that page needs a session
* before it does anything, and a sign-in is how the session gets made. Same derivation,
* same byte-for-byte rule, same reason the SPA doesn't get to supply it. See App.tsx.
*/
export const loginRedirectUri = (request: Request): string =>
new URL('/login/discord', request.url).toString()
/**
* Swap an authorization code for an access token. Returns null on any refusal — a code
* that was already spent, expired (they live ~1 minute), issued to another app, or paired
@@ -278,6 +296,39 @@ export async function fetchGuildMembership(
}
}
/** Who a token belongs to — the `identify` scope's one answer. */
export interface DiscordUser {
/** The Discord user's id (a snowflake, kept as a string — it exceeds 2^53). */
id: string
/** Their Discord username, for the confirmation line. Display only, never stored. */
username: string
}
/**
* The token owner's own Discord user, or null when Discord won't say or can't be reached.
* The sign-in's one read: it needs the id, and `identify` grants exactly `GET /users/@me`.
*/
export async function fetchUser(accessToken: string): Promise<DiscordUser | null> {
try {
const res = await fetch(`${API_BASE}/users/@me`, {
headers: { authorization: `Bearer ${accessToken}` },
})
if (!res.ok) {
logger.info('discord did not return the token owner', { status: res.status })
return null
}
const user = (await res.json()) as { id?: unknown; username?: unknown }
if (typeof user.id !== 'string' || user.id === '') {
logger.error('discord returned a user with no id')
return null
}
return { id: user.id, username: typeof user.username === 'string' ? user.username : '' }
} catch (err) {
logger.error('could not reach discord to read the token owner', { error: String(err) })
return null
}
}
/**
* Hand the access token back to Discord once the roles have been read.
*
+74 -3
View File
@@ -44,9 +44,11 @@ import {
countAccountsForPlatformIdentity,
getLinksForAccount,
getLinksForPlatform,
hasRecordedRoles,
isPlatformIdentityLinked,
linkPlatformIdentity,
PLATFORM_SCHEMA_DDL,
recordFirstRoles,
} from '../../../../auth/src/platform-db'
import {
BALANCE_SCHEMA_DDL,
@@ -68,7 +70,13 @@ import {
INVENTORY_CUSTOM_SCHEMA_DDL,
ownedCustomAvatarItemIds,
} from '../../../../econ/src/inventory-custom-db'
import { discordConfig, parseRoleIds, qualifies } from '../../discord'
import {
discordConfig,
loginRedirectUri,
parseRoleIds,
qualifies,
redirectUri,
} from '../../discord'
import { fetchMemberRoles, refreshDiscordRoles } from '../../discord-roles'
import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
@@ -167,6 +175,8 @@ it('advertises signup and where the other workers live', async () => {
// even be started. Note the client id is NOT leaked by a closed config.
benefitsEnabled: false,
discordAuthorizeUrl: null,
// "Sign in with Discord" is on the same switch, and closed with it.
discordLoginUrl: null,
hosts: {
auth: 'https://auth.rec.example.com',
accounts: 'https://accounts.rec.example.com',
@@ -671,16 +681,77 @@ it('reports where an account stands on benefits', async () => {
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ hasPlus: false, linked: false })
// A bare link — what "Sign in with Discord" leaves on an account it just made — is NOT a
// claim: no roles were ever recorded, so the page must still offer the claim.
await updateAccount(env.DB, 4003, { hasPlus: true })
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001')
res = await SELF.fetch('https://example.com/api/benefits/status', {
headers: { authorization: `Bearer ${token}` },
})
// `linked` says THAT a Discord identity is attached, never which one — the id is of no
// use to the page, and an account's linked identities have no business on the wire.
expect(await res.json()).toEqual({ hasPlus: true, linked: false })
// Claimed (roles recorded, even none). `linked` says THAT a Discord identity has claimed,
// never which one — the id is of no use to the page, and an account's linked identities
// have no business on the wire.
await linkPlatformIdentity(env.DB, 4003, PlatformType.Discord, '99001', [])
res = await SELF.fetch('https://example.com/api/benefits/status', {
headers: { authorization: `Bearer ${token}` },
})
expect(await res.json()).toEqual({ hasPlus: true, linked: true })
})
// How the claim decides "first claim" now that a Discord link can predate it: a link made by
// "Sign in with Discord" has no roles, and writing the first roles onto it is a one-shot
// flip — the single-winner write the gift is gated on, which a fresh link gets from its
// INSERT instead. Exercised at the helpers, since the route runs through discord.com.
it('tells a sign-in’s bare link from a claim, and records the first roles once', async () => {
await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(false)
await linkPlatformIdentity(env.DB, 4010, PlatformType.Discord, '99010')
await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(false)
// Nothing to flip on a link that isn't there.
await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99011', ['r1'])).resolves.toBe(
false
)
// The first write wins; the second finds roles already recorded and writes nothing.
await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99010', ['r1'])).resolves.toBe(
true
)
await expect(recordFirstRoles(env.DB, 4010, PlatformType.Discord, '99010', ['r2'])).resolves.toBe(
false
)
await expect(hasRecordedRoles(env.DB, 4010, PlatformType.Discord)).resolves.toBe(true)
const [link] = await getLinksForAccount(env.DB, 4010)
expect(link.roles).toEqual(['r1'])
// A claim on a FRESH link records roles in the insert itself — an empty list included.
await linkPlatformIdentity(env.DB, 4011, PlatformType.Discord, '99012', [])
await expect(hasRecordedRoles(env.DB, 4011, PlatformType.Discord)).resolves.toBe(true)
})
// ---- Sign in with Discord ------------------------------------------------
// Closed on the same switch as the claim, and refused the same way: the half-configured
// app the tests run with has a client id and secret and nothing else.
it('refuses a discord sign-in while the discord app is only half configured', async () => {
const res = await SELF.fetch('https://example.com/api/login/discord', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: 'whatever' }),
})
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ error: 'Signing in with Discord is not available here.' })
})
// The two return legs are different registered redirects, both derived from the request's
// own origin (never the body). A sign-in can't come back to `/claim`: that page wants a
// session before it does anything, and a sign-in is how one gets made.
it('sends a sign-in back to its own redirect, derived from the request origin', () => {
const request = new Request('https://play.example.com/api/config')
expect(redirectUri(request)).toBe('https://play.example.com/claim')
expect(loginRedirectUri(request)).toBe('https://play.example.com/login/discord')
})
// The once-only guard the claim is built on. Without it, one Discord member holding the
// role could walk it around every RecFlare account they own; with it, the second claim is
// refused and the first account keeps the benefit. Exercised at the two lookups the route
+140 -21
View File
@@ -6,7 +6,7 @@ import { PlatformType } from '@repo/domain/src/enums'
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
import { getStatSeries } from '@repo/domain/src/stats-db'
import { intVar, logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { generateIdentityAssertion, validateAndGetAccountId } from '@repo/jwt'
// The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed
// Discord identity is stored there as a PlatformType.Discord link — it is an account ↔
@@ -16,9 +16,10 @@ import { validateAndGetAccountId } from '@repo/jwt'
// verify, and its picker lists only the platforms that can be (CACHED_LOGIN_PLATFORMS).
import {
countAccountsForPlatformIdentity,
getLinksForAccount,
hasRecordedRoles,
isPlatformIdentityLinked,
linkPlatformIdentity,
recordFirstRoles,
} from '../../auth/src/platform-db'
// The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is
// econ's cron paid on demand: the first time an account links its Discord, the claim hands
@@ -31,6 +32,9 @@ import {
discordConfig,
exchangeCode,
fetchGuildMembership,
fetchUser,
LOGIN_SCOPES,
loginRedirectUri,
qualifies,
redirectUri,
revokeToken,
@@ -132,14 +136,27 @@ const claimant = async (c: Context<App>): Promise<number | null> =>
* (see App.tsx). Everything else about the request is fixed by the server.
*/
function authorizeUrl(request: Request, clientId: string): string {
return consentUrl(clientId, SCOPES, redirectUri(request))
}
/**
* The consent URL for "Sign in with Discord": the same app, the narrower `identify` scope,
* and the sign-in's own return leg (`/login/discord`). Served beside the claim's URL so the
* SPA never assembles either.
*/
function loginAuthorizeUrl(request: Request, clientId: string): string {
return consentUrl(clientId, LOGIN_SCOPES, loginRedirectUri(request))
}
function consentUrl(clientId: string, scope: string, redirect: string): string {
const url = new URL(AUTHORIZE_URL)
url.search = new URLSearchParams({
client_id: clientId,
response_type: 'code',
scope: SCOPES,
redirect_uri: redirectUri(request),
scope,
redirect_uri: redirect,
// Skip Discord's "you've already authorized this app, continue?" interstitial on a
// repeat claim; the player has already pressed a button that says what this does.
// repeat visit; the player has already pressed a button that says what this does.
prompt: 'none',
}).toString()
return url.toString()
@@ -195,6 +212,10 @@ const app = new Hono<App>()
// ids never leave the worker, since it's the worker that asks Discord the question.
benefitsEnabled: discord !== null,
discordAuthorizeUrl: discord ? authorizeUrl(c.req.raw, discord.clientId) : null,
// "Sign in with Discord", on the SAME switch: the sign-in resolves (or makes) the
// account through the Discord link, and only the claim ever writes one with roles, so
// an operator who hasn't set the claim up has no reason to offer the door either.
discordLoginUrl: discord ? loginAuthorizeUrl(c.req.raw, discord.clientId) : null,
hosts: {
auth: authBase(c.env),
accounts: accountsBase(c.env),
@@ -344,6 +365,90 @@ const app = new Hono<App>()
return c.json(token)
})
// ---- Sign in with Discord -----------------------------------------------
/**
* Redeem a Discord authorization code from the SIGN-IN consent (`/login/discord`), prove
* who the Discord user is, and ask `auth` for a session on the account linked to them —
* or, when there is none, for a brand-new account linked to them.
*
* The browser sends ONLY the code, as it does for the claim, and for the same reason: the
* exchange needs the client secret. What is different is what comes after. The claim acts
* on a session the caller already has; this is how one GETS a session, so there is no
* bearer token and nothing here writes to the account. Instead the proven identity is
* signed into an ASSERTION with the shared JWT_SECRET and posted to `auth`'s
* `identity_assertion` grant, which resolves it through the link table exactly as
* `cached_login` resolves a Steam ticket, and answers the ordinary token body. Resolving
* the account — and minting one — stays `auth`'s: www never picks an account id.
*
* Posted over the service binding with the browser's real IP forwarded, like signup, so
* the per-IP cap on the sign-up path counts the browser and not Cloudflare. The token
* response is relayed verbatim; `created: true` on it is the SPA's cue to say an account
* was just made and that setting a password is what makes it usable in the game.
*/
.post('/api/login/discord', async (c) => {
const config = await discordConfig(c.env)
if (!config) return c.json({ error: 'Signing in with Discord is not available here.' }, 403)
type LoginBody = { code?: string }
const { code } = await c.req.json<LoginBody>().catch(() => ({}) as LoginBody)
if (!code) return c.json({ error: 'No Discord authorization code was provided.' }, 400)
const accessToken = await exchangeCode(config, code, loginRedirectUri(c.req.raw))
if (accessToken === null) {
return c.json(
{ error: 'That Discord sign-in could not be completed. Please try again.' },
400
)
}
const user = await fetchUser(accessToken)
await revokeToken(config, accessToken)
if (user === null) {
return c.json(
{ error: 'That Discord sign-in could not be completed. Please try again.' },
400
)
}
const jwtSecret = await c.env.JWT_SECRET.get().catch(() => '')
if (jwtSecret === '') {
logger.error('cannot sign an identity assertion: JWT_SECRET is unreadable')
return c.json({ error: authUnreachable('login') }, 502)
}
const assertion = await generateIdentityAssertion(
{ platform: PlatformType.Discord, platformId: user.id },
jwtSecret
)
const res = await postAuthForm(
c.env,
'/connect/token',
{ grant_type: 'identity_assertion', assertion },
{ clientIp: c.req.header('cf-connecting-ip') }
).catch(() => null)
if (res === null) {
logger.error('could not reach auth to redeem an identity assertion')
return c.json({ error: authUnreachable('login') }, 502)
}
if (!res.ok) {
const failure = await readAuthError(res, 'login')
logger.info('auth refused a discord sign-in', {
status: res.status,
upstream: failure.upstream,
})
return c.json({ error: failure.message }, failure.status)
}
const token = (await res.json().catch(() => null)) as {
access_token?: string
created?: boolean
} | null
if (!token?.access_token) {
logger.error('auth answered a discord sign-in with no access_token')
return c.json({ error: authUnreachable('login') }, 502)
}
logger.info(token.created ? 'created an account from a discord sign-in' : 'discord sign-in')
return c.json({ ...token, discordUsername: user.username })
})
// ---- Discord benefits claim ---------------------------------------------
/**
@@ -358,16 +463,18 @@ const app = new Hono<App>()
.get('/api/benefits/status', async (c) => {
const accountId = await claimant(c)
if (accountId === null) return c.body(null, 401)
const [account, links] = await Promise.all([
const [account, claimed] = await Promise.all([
getAccount(c.env.DB, accountId),
getLinksForAccount(c.env.DB, accountId),
hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord),
])
return c.json({
hasPlus: account?.hasPlus ?? false,
// Whether this account is already tied to a Discord identity — not WHICH one. The
// player knows their own Discord; the id is of no use to the page and every reason
// to keep an account's linked identities off the wire.
linked: links.some((link) => link.platform === PlatformType.Discord),
// Whether this account has CLAIMED through a Discord identity — a link with roles
// recorded — not WHICH one. The player knows their own Discord; the id is of no use to
// the page and every reason to keep an account's linked identities off the wire. A
// bare link, which "Sign in with Discord" leaves on a new account, reads as not yet
// claimed: the page then offers the claim, which is what that player still has to do.
linked: claimed,
})
})
@@ -456,12 +563,6 @@ const app = new Hono<App>()
PlatformType.Discord,
membership.userId
)
// Whether this claim is the account's FIRST Discord link — what the one-time gift below
// keys on. Decided before the write, from the table: an account re-claiming with the
// same Discord is not first (`alreadyMine`), and neither is one that already carries a
// DIFFERENT Discord identity, which the once-only guard doesn't stop. Only asked when
// it could be true, to spare the re-claim a query.
let firstLink = false
if (!alreadyMine) {
const claimedElsewhere = await countAccountsForPlatformIdentity(
c.env.DB,
@@ -477,9 +578,26 @@ const app = new Hono<App>()
409
)
}
const links = await getLinksForAccount(c.env.DB, accountId)
firstLink = !links.some((link) => link.platform === PlatformType.Discord)
}
// Whether this is the account's FIRST CLAIM — what the one-time gift below keys on.
// Decided before the write, from the table: a claim is recorded as roles on a Discord
// link, so an account with any Discord link carrying roles (this identity's, or a
// DIFFERENT one the once-only guard doesn't stop) has claimed before. A link with NO
// roles has not: that is what "Sign in with Discord" leaves on an account it just made,
// and that player's first claim is still their first.
const firstClaim = !(await hasRecordedRoles(c.env.DB, accountId, PlatformType.Discord))
// On a link that already exists without roles, writing them is the claim's one-shot
// write — the single-winner guard a fresh link gets from its INSERT below. Asked only
// when the link is the caller's: on anyone else's it would be refused above.
const recordedFirst =
alreadyMine &&
(await recordFirstRoles(
c.env.DB,
accountId,
PlatformType.Discord,
membership.userId,
membership.roles
))
// The link is written for EVERY guild member, qualifying role or not, and it is
// idempotent: INSERT OR IGNORE (so `linkedAt` keeps the FIRST claim's time), with the
@@ -519,13 +637,14 @@ const app = new Hono<App>()
}
// The one-time supporter gift: the box econ's cron would hand this member, paid now,
// once. Gated on the insert having happened too (`created`), so two claims racing on
// once. Gated on a single-winner write having happened too — the insert (`created`), or
// the first roles onto a sign-in's bare link (`recordedFirst`) — so two claims racing on
// one fresh account can't both pay. Best-effort AFTER Plus is granted and the link is
// written: a grant that fails partway is logged with what to check, as econ's cron
// logs its own, and the claim still answers — the player got what they came for, and
// a retry that also failed partway is how a balance gets credited twice.
let tokensAwarded: number | null = null
if (created && firstLink) {
if ((created || recordedFirst) && firstClaim) {
try {
const role = await grantDiscordRoleGift(
c.env,