mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
298 lines
13 KiB
TypeScript
298 lines
13 KiB
TypeScript
import { logger } from '@repo/hono-helpers'
|
|
|
|
import type { Env } from './context'
|
|
|
|
/**
|
|
* Discord OAuth2, the identity check behind the website's benefits claim.
|
|
*
|
|
* A player proves they hold one of the qualifying roles in the community Discord, and
|
|
* the claim grants them Rec Room Plus (`account.hasPlus`). The proof is a real OAuth2
|
|
* AUTHORIZATION CODE exchange, not a token the browser hands us: the SPA sends only the
|
|
* short-lived `code` Discord redirected it back with, and this worker swaps that for an
|
|
* access token using the client SECRET, which — like the Turnstile secret next door —
|
|
* can never ship to a page. The access token therefore never exists in the browser at
|
|
* all, and it is revoked here the moment the roles have been read.
|
|
*
|
|
* Roles come from `GET /users/@me/guilds/{guild}/member`, which needs no bot: the
|
|
* `guilds.members.read` scope lets the TOKEN'S OWNER read their own membership. That is
|
|
* the whole reason this shape was chosen over a bot token — nothing here has to be in
|
|
* the guild, and the worker holds no credential that could read anybody else's roles.
|
|
*
|
|
* The four settings (client id, client secret, guild, one or more roles) are the switch,
|
|
* exactly as the Turnstile keypair is for signup: with any of them missing the claim is CLOSED
|
|
* (`/api/config` says so and `/api/benefits/claim` refuses) rather than open and
|
|
* unverified. Nothing is ever inferred from the environment.
|
|
*/
|
|
|
|
/** Discord's API, pinned to v10 — the version the endpoints below are documented at. */
|
|
const API_BASE = 'https://discord.com/api/v10'
|
|
|
|
/**
|
|
* Where the browser is sent to consent. Deliberately NOT under `/api/v10`: the authorize
|
|
* page is a human-facing page on the main site, and the versioned path serves a redirect
|
|
* to it at best.
|
|
*/
|
|
export const AUTHORIZE_URL = 'https://discord.com/oauth2/authorize'
|
|
|
|
/**
|
|
* The scopes the claim asks for, in the order Discord shows them on the consent screen.
|
|
*
|
|
* - `identify` — the user's own id, which the claim stores as a `PlatformType.Discord`
|
|
* link on the account to keep itself once-only.
|
|
* - `guilds.members.read` — their member record (and so their ROLES) in one guild they
|
|
* are in. Narrower than `guilds`, which lists every server they belong to and is not
|
|
* needed: the claim asks about exactly one guild.
|
|
*
|
|
* A space-joined string because that is how the authorize URL takes them.
|
|
*/
|
|
export const SCOPES = 'identify guilds.members.read'
|
|
|
|
/** 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. */
|
|
clientId: string
|
|
/** The application's client secret. Never leaves this worker. */
|
|
clientSecret: string
|
|
/** The guild (server) whose membership is checked. */
|
|
guildId: string
|
|
/**
|
|
* The role ids within that guild that entitle a player to the benefits — Discord
|
|
* snowflakes, all digits. ANY one of them qualifies: they're alternatives (a supporter
|
|
* role, a booster role, staff…), not requirements, so this is a set to test membership
|
|
* against and never an ordered list. Always at least one entry — an empty list closes
|
|
* the claim (see `discordConfig`).
|
|
*/
|
|
roleIds: string[]
|
|
}
|
|
|
|
/**
|
|
* Parse the configured role ids — Discord snowflakes, so each one is ALL DIGITS (a role's
|
|
* display name is not an id and will never match anything). They stay strings rather than
|
|
* becoming numbers: a snowflake exceeds 2^53, and they are only ever compared, never done
|
|
* arithmetic on.
|
|
*
|
|
* Separated by commas and/or whitespace, so a value pasted out of Discord one id per line
|
|
* works as well as `1077000000000000001,1077000000000000002` does; blank entries are
|
|
* dropped, which is what makes a trailing comma harmless rather than a role id of `''`
|
|
* that nothing can ever match.
|
|
*
|
|
* The digits are not ENFORCED here, deliberately. A typo'd snowflake is indistinguishable
|
|
* from a real role nobody holds, and both correctly result in a claim being refused, so a
|
|
* format rule would buy nothing but a way to reject a valid id if Discord ever widens the
|
|
* format. Misconfiguration shows up as "nobody can claim", which is the safe direction.
|
|
*/
|
|
export const parseRoleIds = (raw: string): string[] =>
|
|
raw
|
|
.split(/[\s,]+/)
|
|
.map((id) => id.trim())
|
|
.filter((id) => id !== '')
|
|
|
|
/**
|
|
* The Discord settings, or null when the claim isn't configured — which is what CLOSES
|
|
* it. All four must be present, and the role list must parse to at least ONE id: a client
|
|
* id with no roles would authenticate a player and then have no question to ask about
|
|
* them, and treating that as "configured" would hand Plus to anyone with a Discord
|
|
* account.
|
|
*
|
|
* Which of the four is missing is logged (never their values) because a half-configured
|
|
* app is otherwise indistinguishable from an operator deliberately leaving benefits off.
|
|
*
|
|
* The id and secret come from the account-level Secrets Store the whole monorepo shares,
|
|
* so they're read per request rather than off `env` as strings; `.get()` caches per
|
|
* isolate, so changing either needs a `www` redeploy to take effect on a warm worker —
|
|
* the same caveat TURNSTILE_* and JWT_SECRET carry. The guild and roles are plain vars:
|
|
* they're server ids visible to every member, not credentials.
|
|
*/
|
|
export async function discordConfig(env: Env): Promise<DiscordConfig | null> {
|
|
const [clientId, clientSecret] = await Promise.all([
|
|
readSecret(env.DISCORD_CLIENT_ID, 'DISCORD_CLIENT_ID'),
|
|
readSecret(env.DISCORD_CLIENT_SECRET, 'DISCORD_CLIENT_SECRET'),
|
|
])
|
|
const guildId = env.DISCORD_GUILD_ID ?? ''
|
|
const roleIds = parseRoleIds(env.DISCORD_BENEFITS_ROLE_IDS ?? '')
|
|
|
|
if (clientId !== '' && clientSecret !== '' && guildId !== '' && roleIds.length > 0) {
|
|
return { clientId, clientSecret, guildId, roleIds }
|
|
}
|
|
if (clientId !== '' || clientSecret !== '' || guildId !== '' || roleIds.length > 0) {
|
|
logger.error('discord is half-configured, so benefit claims are closed', {
|
|
hasClientId: clientId !== '',
|
|
hasClientSecret: clientSecret !== '',
|
|
hasGuildId: guildId !== '',
|
|
// The COUNT, not the ids: a value that parsed to nothing (say, a stray comma) is
|
|
// indistinguishable from an unset one without it.
|
|
roleIdCount: roleIds.length,
|
|
})
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* One Secrets Store value as a string, or '' when it can't be read. The binding is
|
|
* declared in wrangler.jsonc so it's always on `env`; what varies is whether the store
|
|
* holds the secret — a missing one throws rather than resolving empty. Mirrors
|
|
* `turnstile.ts`'s reader, and for the same reason: a store this worker can't read must
|
|
* close the feature, not 500 the homepage.
|
|
*/
|
|
async function readSecret(secret: SecretsStoreSecret, name: string): Promise<string> {
|
|
try {
|
|
return (await secret.get()) ?? ''
|
|
} catch (err) {
|
|
logger.error('failed to read a discord credential from the secrets store', {
|
|
secret: name,
|
|
error: String(err),
|
|
})
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The URI Discord redirects back to after consent, derived from the request rather than
|
|
* configured.
|
|
*
|
|
* It must be byte-identical in three places — the authorize URL the browser opens, the
|
|
* token exchange below, and the app's registered redirect list — or Discord refuses the
|
|
* exchange. Deriving it from the incoming request's own origin is what keeps the first
|
|
* two in step across every environment (localhost in dev, the real domain in
|
|
* production) with nothing to configure, and it is also why the SPA does NOT get to
|
|
* supply it in the request body: an attacker-supplied redirect would turn this worker's
|
|
* client secret into a redemption oracle for codes issued to somebody else's app page.
|
|
*
|
|
* `/claim` is the SPA route that handles the return; see App.tsx.
|
|
*/
|
|
export const redirectUri = (request: Request): string => new URL('/claim', 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
|
|
* with a different redirect URI all land here, and none of them is worth telling the
|
|
* browser apart: the answer is the same, start the flow again.
|
|
*
|
|
* The credentials go in the BODY rather than a Basic auth header. Both are legal and
|
|
* Discord documents the body form.
|
|
*/
|
|
export async function exchangeCode(
|
|
config: DiscordConfig,
|
|
code: string,
|
|
redirect: string
|
|
): Promise<string | null> {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/oauth2/token`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
client_id: config.clientId,
|
|
client_secret: config.clientSecret,
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: redirect,
|
|
}).toString(),
|
|
})
|
|
if (!res.ok) {
|
|
// The body carries an OAuth error code (`invalid_grant`, `invalid_client`) — the
|
|
// last of which is a misconfiguration, not a player mistake, and this line is the
|
|
// only place it surfaces. Logged, never relayed: it tells a caller nothing.
|
|
logger.info('discord refused a code exchange', {
|
|
status: res.status,
|
|
body: await res.text().catch(() => ''),
|
|
})
|
|
return null
|
|
}
|
|
const token = (await res.json()) as { access_token?: unknown }
|
|
return typeof token.access_token === 'string' ? token.access_token : null
|
|
} catch (err) {
|
|
logger.error('could not reach discord to exchange a code', { error: String(err) })
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a member holds ANY of the qualifying roles. Both sides are snowflake id
|
|
* strings, compared exactly — Discord reports a member's roles as ids, never as names.
|
|
*
|
|
* The roles are alternatives (a supporter, a booster and a staff member all qualify), so
|
|
* this is an intersection test and not a subset one: requiring all of them would mean
|
|
* nobody ever claimed.
|
|
*/
|
|
export const qualifies = (memberRoles: string[], roleIds: string[]): boolean =>
|
|
memberRoles.some((role) => roleIds.includes(role))
|
|
|
|
/** Who claimed, and what they hold in the guild. */
|
|
export interface GuildMembership {
|
|
/** The Discord user's id (a snowflake, kept as a string — it exceeds 2^53). */
|
|
userId: string
|
|
/** Their Discord username, for the confirmation line. Display only, never stored. */
|
|
username: string
|
|
/** Their role ids in the guild. */
|
|
roles: string[]
|
|
}
|
|
|
|
/**
|
|
* The token owner's membership in the configured guild, or null when they aren't in it
|
|
* (Discord answers 404) or the call fails.
|
|
*
|
|
* `null` deliberately conflates "not a member" with "we couldn't ask". Both mean the same
|
|
* thing to the claim — no proof was obtained — and a claim that granted benefits when
|
|
* Discord was unreachable would be worse than one that asks the player to retry.
|
|
*/
|
|
export async function fetchGuildMembership(
|
|
accessToken: string,
|
|
guildId: string
|
|
): Promise<GuildMembership | null> {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/users/@me/guilds/${guildId}/member`, {
|
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
})
|
|
if (!res.ok) {
|
|
// 404 is the ordinary "they aren't in the server" answer, so it's info, not error.
|
|
logger.info('discord did not return a guild membership', { status: res.status })
|
|
return null
|
|
}
|
|
const member = (await res.json()) as {
|
|
user?: { id?: unknown; username?: unknown }
|
|
roles?: unknown
|
|
}
|
|
const userId = typeof member.user?.id === 'string' ? member.user.id : ''
|
|
if (userId === '') {
|
|
logger.error('discord returned a guild member with no user id')
|
|
return null
|
|
}
|
|
return {
|
|
userId,
|
|
username: typeof member.user?.username === 'string' ? member.user.username : '',
|
|
roles: Array.isArray(member.roles)
|
|
? member.roles.filter((r): r is string => typeof r === 'string')
|
|
: [],
|
|
}
|
|
} catch (err) {
|
|
logger.error('could not reach discord to read a guild membership', { error: String(err) })
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hand the access token back to Discord once the roles have been read.
|
|
*
|
|
* Best-effort and deliberately un-awaited-on by the caller's success path: the claim has
|
|
* already been decided by this point, so a failed revoke must not fail it. It's here
|
|
* because the token is useless to us after one read and a live token is a liability for
|
|
* however long it would otherwise last (a week) — this keeps the credential's lifetime
|
|
* about as long as the request that needed it.
|
|
*/
|
|
export async function revokeToken(config: DiscordConfig, accessToken: string): Promise<void> {
|
|
try {
|
|
await fetch(`${API_BASE}/oauth2/token/revoke`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
client_id: config.clientId,
|
|
client_secret: config.clientSecret,
|
|
token: accessToken,
|
|
token_type_hint: 'access_token',
|
|
}).toString(),
|
|
})
|
|
} catch (err) {
|
|
logger.info('could not revoke a discord access token', { error: String(err) })
|
|
}
|
|
}
|