import { Hono } from 'hono' import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { z } from 'zod' import { countAccountsByDeviceId, countAccountsBySignupIp, createAccount, GAME_VERSION, getAccount, getAccountByUsername, getAccountsByIds, getPasswordHash, getRoomById, hashPassword, isRolloutGameVersion, RoomInstanceType, setLastLoginTime, setLoginContext, setPasswordHash, setPresence, subRoomDataBlob, updateAccount, verifyPassword, writeAuditLog, } from '@repo/domain' import { flagVar, intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError, } from '@repo/hono-helpers' import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt' // The account-wide ban lives on a `report` row, whose table the api worker owns; its db // module is plain D1 queries with no runtime deps, so it imports cleanly here. import { banEvasionMatch, resolveBan } from '../../api/src/bans-db' import { buildEndpoints } from '../../ns/src/endpoints' import { verifyMetaNonce } from './meta-nonce' import { CachedLogin, ChangePasswordRequest, ChangePasswordResponse, DeviceAuthorizationRequest, DeviceAuthorizationResponse, DeviceDecisionRequest, DeviceDecisionResponse, FakeCachedLogin, form, json, OAuthError, PlatformIdsRequest, PlatformType, RestrictionDto, roleFilter, roleLookup, TokenRequest, TokenResponse, } from './openapi' import { countAccountsForPlatformIdentity, getLinksForAccount, getLinksForPlatformId, getLinksForPlatformIdentity, isPlatformIdentityLinked, linkPlatformIdentity, } from './platform-db' import { consumeRefreshToken, issueRefreshToken } from './refresh-db' import { verifySteamTicket } from './steam-ticket' import { approveDeviceGrant, beginDeviceGrant, denyDeviceGrant, DEVICE_CODE_GRANT, pollDeviceGrant, studioClientMatches, } from './studio-device' import type { Context } from 'hono' import type { Account } from '@repo/domain' import type { App, Env } from './context' import type { PlatformLink } from './platform-db' /** OAuth scopes granted by `/connect/token`. */ const TOKEN_SCOPE = 'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage' /** * The `error_description` a SIGNUP is refused with when it shares an identity with a banned * account (see bans-db's linked arms). A fixed * sentence, because `www`'s shared auth-messages table keys on this exact string to put a * real sentence in front of a player — anything varying would fall through to the generic * "you could not be signed in". Keep the two in sync. Deliberately vague: the account * being refused may be an innocent housemate of a banned player, so telling them "this * account is banned" would be a lie, and naming the account we matched them to would hand * out somebody else's moderation record. * * Only `create_account` is refused this way. SIGNING IN is never refused for a ban, its own * or a linked one — see the token grant: the client needs a token to reach the block screen, * and `match` is what holds a blocked player to their dorm. */ const BLOCKED_DESCRIPTION = 'this device or network is blocked' /** * The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded * build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded * headset reports this same value. Two things follow, and both are enforced below: * - it is never verifiable (`verifyPlatformProof` refuses it outright), and * - it is therefore never LINKED to an account. A link is a password-free way in, so * one link on a shared id would open that account to every sideloaded build. * It exists only to get such a client onto the username/password login screen. */ const SIDELOAD_PLATFORM_ID = '1' /** * The canned entry served for the one Oculus cached-login lookup below — the sideloaded * APK's way onto the password login screen. Not backed by a link, an account or a * platform proof, hence `requirePassword: true`. */ const FAKE_OCULUS_CACHED_LOGIN = { platform: PlatformType.Oculus, platformId: SIDELOAD_PLATFORM_ID, accountId: 1, lastLoginTime: '2026-07-19T17:13:29.225Z', requirePassword: true, } as const /** * Signup caps, enforced on create_account only (never on login — an existing account * always stays reachable, however many accounts its owner has since accumulated). * * Three independent arms, because they fail in different ways: * - Per verified platform id (a Steam-proven SteamID64). The sharp one: it can't be * spoofed and can't be reset by changing networks. Only binds on a platform * create_account — the password/anonymous path has no platform identity to count, * so the other two arms are all that stands between it and bulk signup. * - Per signup IP. Coarse: households, NAT and shared campus/mobile networks put many * legitimate players behind one address, so this WILL be the arm that produces false * positives. It counts `signupIp` (immutable), so an abuser can't reset their own * count by hopping networks — but a VPN gives them a fresh counter per exit, which * is exactly how one player made 150 accounts, six per address, in a weekend. * - Per client device id. Narrower than an IP (a household shares an address, not an * install) and stable across VPN hops, which is the hole in the IP arm it covers. It * counts the last-seen `deviceId`, which a login moves; see the field's notes in * accounts-db for why an immutable copy isn't worth keeping. Client-chosen and * unverified, so it catches a stock client, not a script that fabricates ids. Skipped * when the signup posts no `device_id` (the website's don't). * * These are the defaults. An operator overrides any arm with the matching worker var * (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID` * in wrangler.jsonc `vars`), and setting one to 0 disables that arm entirely — which a * small private server that trusts everyone it invites will want, and which the IP arm in * particular is worth reaching for if a shared network is being locked out. */ const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3 const DEFAULT_MAX_ACCOUNTS_PER_IP = 3 const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 3 /** * Whether a `create_account` with no platform — a password account, which is what the * website mints — is accepted at all (`PASSWORD_SIGNUP`; see context.ts). Off by default: * such an account has no identity but the address it came from, so the caps above are * the only thing limiting it, and a VPN or Tor exit resets the IP arm every N accounts. * That is how one player made 150 accounts in a weekend. A game-client signup always * carries a Steam or Meta identity and is untouched by this switch. * * Refused as `invalid_grant` with this description, which `www` translates for the form * (see its auth-messages.ts) — keep the two in step. */ const DEFAULT_PASSWORD_SIGNUP = false const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled' /** * The rollout gate. A `/connect/token` grant posting a build under staged rollout as `ver` * (`ROLLOUT_GAME_VERSIONS` in @repo/domain — shared with `match`, which serves those builds * the sandbox Tachyon pool) is refused unless the account's Discord link holds a role named * in `DISCORD_ROLLOUT_ROLE_IDS` (see context.ts and `accountHoldsRolloutRole`). Every other * build, and a grant posting no `ver` (a refresh, the website), is untouched. * * `ver` is client-reported and unverified, like everything else about the build. That is * fine for what this is: a rollout gate, not a security boundary. A player who lies about * their build gets a token the 2023 server surfaces would have handed them anyway, and * `api`'s `/api/versioncheck/v4` is still what decides which builds are supported at all. * * Refused as `invalid_grant` with ROLLOUT_GATED_DESCRIPTION. `create_account` is refused * before anything is minted: a brand-new account has no Discord link to hold a role, so * the player makes their account on a released build, links Discord on the website, and * comes back. */ const ROLLOUT_GATED_DESCRIPTION = 'this build is limited to Discord supporters; link your Discord on the website first' /** * `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or * whitespace (the same grammar `www`'s parseRoleIds reads DISCORD_BENEFITS_ROLE_IDS with). * Unset or blank parses to an empty list, which `accountHoldsRolloutRole` treats as a * closed gate. */ function rolloutRoleIds(raw: string | undefined): string[] { return (raw ?? '') .split(/[\s,]+/) .map((id) => id.trim()) .filter((id) => id !== '') } /** * Whether an account may sign in on a rollout-gated build: its Discord link (platform 101, * written by the website's benefits claim and refreshed by `www`'s daily sweep) records at * least one of the configured roles. The roles are a SNAPSHOT (see PlatformLink.roles), so * a role granted in Discord today opens the gate once the player re-claims or the sweep * runs — not instantly. * * An empty role list is answered false without touching the database: with no supporter * role named, nobody is a supporter. */ async function accountHoldsRolloutRole( db: D1Database, accountId: number, roleIds: readonly string[] ): Promise { if (roleIds.length === 0) return false const links = await getLinksForAccount(db, accountId) return links.some( (link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r)) ) } /** New players start in the Orientation room (RoomId 13) — the new-user flow. */ const ORIENTATION_ROOM_ID = 13 /** * The client loads Orientation locally (no matchmake) and tags its instance with * the sentinel id -2. The heartbeat must echo that exact `roomInstanceId` or the * client treats presence as out-of-sync and bounces the player to the dorm. */ const ORIENTATION_INSTANCE_ID = -2 /** * Seed a freshly created account's match presence to the Orientation room. The * client is placed into Orientation by its new-user flow without a matchmake * call, so the match heartbeat would otherwise report no/stale (dorm) presence * and bounce the player out. We write the Orientation instance (built from the * shared rooms D1, matching the match worker's `roomInstanceFromRoom` shape) into * the shared `presence` table (see @repo/domain) so the heartbeat keeps them there. */ async function placeNewPlayerInOrientation( env: App['Bindings'], accountId: number, deviceClass: number ): Promise { // getRoomById hydrates the room's SubRooms from the subroom table (they no longer // live in the room blob), so the Orientation scene resolves the same way match does. const room = await getRoomById(env.DB, ORIENTATION_ROOM_ID) if (!room) return const subRooms = room.SubRooms const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as Record | undefined const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback) const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback) const roomInstance = { roomInstanceId: ORIENTATION_INSTANCE_ID, roomId: ORIENTATION_ROOM_ID, subRoomId: num(sub?.SubRoomId, 1), roomInstanceType: RoomInstanceType.Public, location: str(sub?.UnitySceneId), dataBlob: subRoomDataBlob(sub), eventId: 0, clubId: 0, roomCode: '', photonRegion: 'us', photonRegionId: 'us', photonRoomId: `rec.${ORIENTATION_ROOM_ID}`, name: `^${str(room.Name, 'Orientation')}`, maxCapacity: num(sub?.MaxPlayers, 4), isFull: false, isPrivate: false, isInProgress: false, EncryptVoiceChat: false, } await setPresence(env.DB, { accountId, roomInstance, statusVisibility: 0, deviceClass, vrMovementMode: 1, platform: 0, appVersion: GAME_VERSION, }) } /** The Bearer token's account id (`sub`), or null when there's no valid token. */ async function authedId(c: Context): Promise { return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } /** Whether one account row holds a role. Absent/false both mean no role. */ function holdsRole(account: Account, role: 'developer' | 'moderator'): boolean { return (role === 'developer' ? account.isDeveloper : account.isModerator) === true } /** * The ONE-player lookup (`/role/{role}/{id}`): a BARE JSON boolean — the client reads the body * as a bool, not as an object — or an empty 404 for a player this server has no account for, * mirroring the reference API. An id that is not a number is answered as unknown (404) rather * than as a 400: `/role/developer/nonsense` is just an id that matches nothing. */ async function roleAnswer(c: Context, role: 'developer' | 'moderator', id: string) { logger.info(`${role} role lookup`, { id }) const accountId = Number.parseInt(id, 10) const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId) if (!account) return c.body(null, 404) return c.json(holdsRole(account, role)) } /** * The BULK form (`/role/{role}?id=1&id=2`): WHICH of the players asked about hold the role, as * an array of ints. A different question from the single lookup and so a different shape — * never that route's boolean. * * A FILTER, so nothing here is an error: an id that names no account, or names one without the * role, is left out, and a query naming no ids answers `[]`. A 404 would be wrong — the * question "which of these are developers" has the answer "none of them", not "no such thing". * * Answered in the order the query asked, de-duplicated, so a caller can read the result * against its own list. One round trip however many ids are named: `getAccountsByIds` splits * the read across statements when it exceeds D1's bound-parameter limit. */ async function roleFilterAnswer(c: Context, role: 'developer' | 'moderator') { // `queries` and not `query`: the ids arrive as a REPEATED parameter, and `query` would // collapse them to the first and silently answer about one player out of the set. const asked = c.req.queries('id') ?? [] const ids = [...new Set(asked.map((v) => Number.parseInt(v, 10)).filter(Number.isInteger))] logger.info(`${role} role filter`, { asked: asked.length, ids: ids.length }) if (ids.length === 0) return c.json([]) const accounts = await getAccountsByIds(c.env.DB, ids) const holders = new Set(accounts.filter((a) => holdsRole(a, role)).map((a) => a.accountId)) return c.json(ids.filter((id) => holders.has(id))) } /** * The role names beyond `gameClient` for an account's token `role` claim. Base roles * (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the * client gates the screen-share feature on it and nothing grants it per-account, so it * is unconditional (even with no account resolved). The rest are the operator-granted * extras, plus `junior` off the account's own `isJunior` flag. * * `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it. * RecFlare Studio treats that exact claim as Full access, which is what lets the editor * upload; staff set the flag from the website. `influencer` rides on `isInfluencer` * (`runx admin grant-influencer`) and lives only in the token — no `/role/*` lookup * answers it. Order is stable so tokens are deterministic: screenshare, developer, * moderator, junior, betastudio, then influencer. */ function accountRoles( account: Pick< Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer' > | null ): string[] { const roles = ['screenshare'] if (!account) return roles if (account.isDeveloper) roles.push('developer') if (account.isModerator) roles.push('moderator') if (account.isJunior) roles.push('junior') if (account.hasStudio) roles.push('betastudio') if (account.isInfluencer) roles.push('influencer') return roles } /** * The account's token `rn.privilege` claim. Despite the scope-shaped name it is a CLAIM, * read out of the same claims dictionary as `role` — it never belongs in `scope`. The * client knows exactly two values, both chat restrictions, and both ride on a junior * account: `BanVChat` (voice) and `BanRmChat` (room chat). Empty for everyone else, which * drops the claim rather than sending a blank one. */ function accountPrivileges(account: Pick | null): string[] { return account?.isJunior ? ['BanVChat', 'BanRmChat'] : [] } /** * The platform an account's `platformId` belongs to. Nothing defaults the `platform` * field (see defaultAccount), so an account can carry a platform identity with no * platform recorded — and until Meta verification landed Steam was the only identity * we could prove, so an unset one *is* Steam. Every account bound since records its * platform explicitly; this default only covers those older rows. */ function accountPlatform(account: Pick): number { return account.platform ?? 0 } /** * The browser page Studio opens lives on WWW, not on this worker. `ns` is what * told the editor where WWW is, so the URI has to be built from the same domain * and the same subdomain override or the tab opens on a host the player doesn't have. */ function studioVerificationUri(env: Env, userCode: string): { uri: string; complete: string } { const www = buildEndpoints(env.DOMAIN, env.SUBDOMAINS).WWW const uri = `${www}/device` return { uri, complete: `${uri}?user_code=${encodeURIComponent(userCode)}` } } function oauthError( c: Context, error: string, error_description: string, status: 400 | 500 = 400 ) { return c.json({ error, error_description }, status) } /** * Issue the same token body every other grant returns, for an account that has * already been authenticated — here, because the player approved a Studio code * on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`). */ async function issueSession(c: Context, accountId: number, grantType: string) { const clientIp = c.req.header('cf-connecting-ip') ?? '' const ban = await resolveBan(c.env.DB, accountId, { identity: { ip: clientIp }, arms: banEvasionMatch(c.env.BAN_EVASION_MATCH), }) if (ban) { logger.info('token issued to a blocked account', { accountId, grantType, via: ban.via, bannedAccountId: ban.bannedAccountId, reportId: ban.ban.id, }) } const jwtSecret = await c.env.JWT_SECRET.get() if (jwtSecret === '') { logger.error('refusing to issue token: JWT_SECRET is empty') return oauthError(c, 'server_error', 'token signing is not configured', 500) } const roleAccount = await getAccount(c.env.DB, accountId) if (!roleAccount) return oauthError(c, 'invalid_grant', 'account no longer exists') const accessToken = await generateToken( String(accountId), roleAccount.platformId ?? '', accountPlatform(roleAccount), jwtSecret, accountRoles(roleAccount), accountPrivileges(roleAccount), GAME_VERSION, roleAccount.hasPlus === true ) const refreshToken = await issueRefreshToken(c.env.DB, accountId) await setLastLoginTime(c.env.DB, accountId, new Date().toISOString()) await setLoginContext(c.env.DB, accountId, { ip: clientIp }) const staffRoles = (['developer', 'moderator'] as const).filter((role) => holdsRole(roleAccount, role) ) if (staffRoles.length > 0) { try { await writeAuditLog(c.env.DB, { playerId: accountId, action: 'staff_login', data: { ip: clientIp, roles: staffRoles, grantType, username: roleAccount.username ?? null, deviceId: '', deviceClass: 0, version: null, }, }) } catch (err) { logger.error('could not write a staff_login audit log row', { accountId, grantType, error: err instanceof Error ? err.message : String(err), }) } } return c.json({ access_token: accessToken, expires_in: TOKEN_TTL_SECONDS, token_type: 'Bearer', refresh_token: refreshToken, scope: TOKEN_SCOPE, key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=', }) } async function decideDeviceCode(c: Context, decide: typeof approveDeviceGrant) { const id = await authedId(c) if (id === null) return c.body(null, 401) const body = await c.req.parseBody().catch(() => ({}) as Record) const userCode = typeof body.user_code === 'string' ? body.user_code : '' const result = await decide(c.env.DB, userCode, id) if (result.kind === 'ok') return c.json({ ok: true as const }) if (result.kind === 'expired') return oauthError(c, 'expired_token', 'that code has expired. Start the login in Studio again.') if (result.kind === 'used') return oauthError(c, 'invalid_grant', 'that code was already used.') return oauthError(c, 'invalid_grant', 'that code was not found.') } /** * Project a linked account into the client's CachedLogin DTO — the account-picker * entry on the login screen. The client posts the chosen `accountId` back as a * `grant_type=cached_login`. `requirePassword` is false because platform ownership * (the verified `platform_auth`) is the credential for a cached login — no prompt. * * The platform and id come from the LINK, not from the account: an account linked to * both a Steam and a Meta identity appears in both pickers, and each has to report the * identity that picker was asked about — that's what the client posts back, and what * the grant then checks the link against. */ function toCachedLogin(account: Account, link: PlatformLink) { return { platform: link.platform, platformId: link.platformId, accountId: account.accountId, lastLoginTime: account.lastLoginTime ?? account.createdAt, requirePassword: false, } } /** * Project a set of links into picker entries, dropping any whose account no longer * exists. One batched account read rather than one per link. * * Order follows the links (oldest first), so the picker is stable between launches. */ async function toCachedLogins(db: D1Database, links: PlatformLink[]) { if (links.length === 0) return [] const accounts = await getAccountsByIds(db, [...new Set(links.map((l) => l.accountId))]) const byId = new Map(accounts.map((a) => [a.accountId, a])) return links.flatMap((link) => { const account = byId.get(link.accountId) return account ? [toCachedLogin(account, link)] : [] }) } /** * Link the platform identity a password login proved to the account it logged into, * so the next launch on that device is a cached login. Called only with a VERIFIED * identity — a link is a password-free way into the account. * * Already linked is the common case (every subsequent login on that device) and costs * one read and nothing else. * * The per-identity cap applies here as well as at signup, or it wouldn't be a cap: * an identity could otherwise sit at the limit, have accounts created for it with a * password, and link its way into all of them. Reaching it does NOT fail the login — * the password was valid — it just leaves the account without a cached login, so the * player types their password each time rather than being locked out. * * The first identity linked also becomes the account's primary (the blob's * `platform`/`platformId`), which is what the account DTO and the refresh grant's * claims report. Later platforms link without disturbing it. */ async function linkLoginIdentity( db: D1Database, accountId: number, platform: number, platformId: string, maxAccountsPerIdentity: number ): Promise { if (await isPlatformIdentityLinked(db, accountId, platform, platformId)) return if ( maxAccountsPerIdentity > 0 && (await countAccountsForPlatformIdentity(db, platform, platformId)) >= maxAccountsPerIdentity ) { logger.info('platform link refused: account limit reached for this platform identity', { accountId, platform, platformId, }) return } if (!(await linkPlatformIdentity(db, accountId, platform, platformId))) return logger.info('linked platform identity to account', { accountId, platform, platformId }) const account = await getAccount(db, accountId) if (account && !account.platformId) { await updateAccount(db, accountId, { platform, platformId }) } } /** * What a login's `platform_auth` proved, if anything. Failures are split because the * callers act on them differently: a grant that authenticates BY platform identity has * to refuse, while a password grant — which has already proven who it is — carries on * and just doesn't link. * * `unconfigured` is an operator problem (no META_APP_SECRET), not a bad credential, * and is the one case that warrants a 5xx. */ type PlatformProof = /** Nothing was checked — the login offered no proof, so there is nothing to report. */ | { status: 'none' } | { status: 'verified'; platform: number; platformId: string } | { status: 'unsupported' } | { status: 'unconfigured' } | { status: 'rejected'; reason: string } /** * Verify a login's `platform_auth` and return the identity it proves. * * The two verifiable platforms prove the id in opposite directions, which is why they * can't share a code path: Steam's ticket *carries* a SteamID64 we read out and trust, * so the posted `platform_id` is discarded. Meta's nonce carries nothing — it is * validated *against* the posted `platform_id`, so that field is an input, and a * spoofed one fails validation rather than being ignored. Either way the id that comes * back is proven, never the raw client-supplied field, and only a proven id is ever * written to an account or linked to one. */ async function verifyPlatformProof( env: App['Bindings'], platform: number, platformAuth: string, postedPlatformId: string ): Promise { // A sideloaded APK reports the placeholder id (see SIDELOAD_PLATFORM_ID) because it // has no Meta SDK behind it. Refuse it here, before anything is asked of Meta, so no // caller downstream can treat it as an identity — above all `linkLoginIdentity` on the // password grant, which is the path such a client actually takes. Linking it would // hand every sideloaded headset a password-free login into that account, since they // all report this same id. // // Refusing costs a sideloaded player nothing: their password login still succeeds (a // password grant carries its own credential and only *links* on a verified proof), it // just never gets a cached login, so they type their password each launch. That is // the intended shape of the sideload flow. if (platform === PlatformType.Oculus && postedPlatformId === SIDELOAD_PLATFORM_ID) { return { status: 'rejected', reason: 'sideload placeholder platform id is never an identity' } } if (platform === PlatformType.Steam) { const verified = platformAuth ? await verifySteamTicket(platformAuth) : null if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' } return { status: 'verified', platform: PlatformType.Steam, platformId: verified.steamId } } if (platform === PlatformType.Oculus) { // `.get()` throws when the secret doesn't exist in the store at all (as opposed to // holding an empty/placeholder value) — the same misconfiguration from the player's // side, so it takes the same branch. const appSecret = await env.META_APP_SECRET.get().catch(() => '') if (appSecret === '') return { status: 'unconfigured' } const verified = await verifyMetaNonce(platformAuth, postedPlatformId, appSecret) if (!verified.ok) return { status: 'rejected', reason: verified.reason } return { status: 'verified', platform: PlatformType.Oculus, platformId: verified.identity.userId, } } return { status: 'unsupported' } } const app = new Hono() .use( '*', // middleware (c, next) => useWorkersLogger(c.env.NAME, { environment: c.env.ENVIRONMENT, release: c.env.SENTRY_RELEASE, })(c, next) ) // The website (`www`) is a browser origin calling these endpoints directly, the way // rec.net's own site called the game's API — so the responses need CORS headers or // the browser discards them. `origin: '*'` is deliberate and safe HERE because these // endpoints authenticate with a bearer token in the `Authorization` header, never a // cookie: a hostile page can't read another origin's stored token, so there is no // ambient credential for `*` to expose. Do not add cookie auth without narrowing it. .use('*', withDefaultCors()) .onError(withOnError()) .notFound(withNotFound()) // EAC challenge — a fresh GUID, JSON-quoted, served as plain text. .get( '/eac/challenge', describeRoute({ tags: ['EAC'], summary: 'Easy Anti-Cheat challenge', description: 'Returns a constant JSON-quoted string (`"AA=="`) as `text/plain`. Anti-cheat is not implemented; this exists so the client\'s EAC handshake succeeds.', responses: { 200: { description: 'The challenge, JSON-quoted, as text/plain', content: { 'text/plain': { schema: { type: 'string', example: '"AA=="' } } }, }, }, }), (c) => c.text(`"AA=="`) ) // Cached logins for a platform id — the accounts linked to this platform-native // id, so the client can offer them on the login screen (and post one back as a // cached_login grant). No linked account → [], and the client falls back to a // fresh login / create_account. // // GET or POST: the 2023 build asks with a GET, the 2025 build (20250424.01) POSTs // the same path with a form body — `deviceId`, `platformAuth` (a JSON blob holding // the platform's session ticket and app id) and `time`. The body is READ BY NOTHING // here; both methods answer the same list off the path params, so a newer client // gets its picker. Verifying that ticket is the eventual point of the POST. .on( ['GET', 'POST'], '/cachedlogin/forplatformid/:platform/:id', describeRoute({ tags: ['Cached login'], summary: 'Accounts linked to a platform id', description: [ 'Accounts the client may offer on its login screen for this platform identity —', 'the links this identity has, so an entry here is always redeemable by a', '`cached_login` grant (both read the same table). An account linked to several', 'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)', 'and the client falls back to a fresh login or create_account.', 'EXCEPT the exact identity `1/1` (Oculus, id `1`), which is stubbed for SIDELOADED', 'APKs: with no Meta SDK they have no real identity to ask about and stall on an', 'empty picker. It consults nothing and returns one canned, non-redeemable entry', 'with `requirePassword: true`, sending the build to username/password login.', 'Older clients GET this; the 20250424.01 build POSTs it with a', '`deviceId` / `platformAuth` / `time` form body attesting the platform session.', 'That body is accepted and ignored — both methods answer identically from the', 'path params.', ].join(' '), parameters: [ { name: 'platform', in: 'path', required: true, description: 'PlatformType integer. A non-numeric value matches the id on any platform.', schema: { type: 'string' }, }, { name: 'id', in: 'path', required: true, description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.', schema: { type: 'string' }, }, ], responses: { 200: json( CachedLogin.or(FakeCachedLogin).array(), 'Matching accounts; `[]` if none. The canned entry for `1/1`.' ), }, }), async (c) => { const { platform, id } = c.req.param() logger.info('cached login lookup', { platform, id }) const platformInt = Number.parseInt(platform, 10) // SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't // produce a real Meta identity or a nonce to prove one with — it asks about the // placeholder identity `1/1`, and an empty picker leaves it stuck on the platform // login screen with nothing to do. Hand back one canned entry to push it onto the // username/password login instead, which is the only flow such a build can finish. // `requirePassword` is true for exactly that reason: there's no platform proof here, // and the `cached_login` grant would (correctly) refuse this entry. // // Scoped to that ONE identity rather than to all of platform 1 — store builds do // real Meta logins, and shadowing the whole platform would hide genuine links from // their pickers. if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) { return c.json([FAKE_OCULUS_CACHED_LOGIN]) } // Listed straight from the link table, which is also what the `cached_login` // grant authorizes against — so the picker can't offer an account the grant // then refuses. const links = Number.isNaN(platformInt) ? await getLinksForPlatformId(c.env.DB, id) : await getLinksForPlatformIdentity(c.env.DB, platformInt, id) return c.json(await toCachedLogins(c.env.DB, links)) } ) // Bulk cached-login lookup by platform id (friends resolution). The client POSTs // repeated `id=` params on the auth host; resolve each to its linked accounts. .post( '/cachedlogin/forplatformids', describeRoute({ tags: ['Cached login'], summary: 'Bulk cached-login lookup (friends resolution)', description: [ 'Resolves many platform ids at once. Results are flattened across all ids, so the', 'response cannot be mapped back to a specific input id — the client uses each', 'entry’s own `platformId`. No platform accompanies these ids, so each matches on', 'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.', ].join(' '), requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'), responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') }, }), async (c) => { const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record) const raw = body.id const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String) const out: Array> = [] for (const pid of ids) { // No platform accompanies these ids, so they match on any platform. out.push(...(await toCachedLogins(c.env.DB, await getLinksForPlatformId(c.env.DB, pid)))) } return c.json(out) } ) // Studio editor device login. The POST the editor makes is // `https://auth./connect/deviceauthorization` — Auth is service enum 0, // and the path is the literal `connect/deviceauthorization`. The browser half // of the handshake is the verification_uri, which points at WWW `/device`. .post( '/connect/deviceauthorization', describeRoute({ tags: ['Token'], summary: 'Start a Rec Room Studio device login', description: [ 'RFC 8628 device authorization, called by Rec Room Studio (not the game).', 'The editor POSTs `client_id=recroom.studio` and its embedded client secret', 'to the Auth host. The response `verification_uri` is the website (`WWW /device`);', '`verification_uri_complete` is that page with `?user_code=` and is what Studio', 'opens in the browser. Studio then polls `POST /connect/token` with', '`grant_type=urn:ietf:params:oauth:grant-type:device_code`.', ].join(' '), requestBody: form(DeviceAuthorizationRequest, '`client_id` and `client_secret`'), responses: { 200: json(DeviceAuthorizationResponse, 'Codes and the WWW page to open'), 400: json(OAuthError, 'client_id or client_secret was not the Studio editor'), }, }), async (c) => { const body = await c.req.parseBody().catch(() => ({}) as Record) if (!studioClientMatches(body)) { return oauthError(c, 'invalid_client', 'unknown studio client') } const grant = await beginDeviceGrant(c.env.DB) const { uri, complete } = studioVerificationUri(c.env, grant.userCode) return c.json({ device_code: grant.deviceCode, user_code: grant.userCode, verification_uri: uri, verification_uri_complete: complete, expires_in: grant.expiresIn, interval: grant.interval, }) } ) .post( '/connect/device/approve', describeRoute({ tags: ['Token'], summary: 'Approve a Studio device code', description: 'Called by the website (`WWW /device`) with the signed-in player’s bearer token and the `user_code` Studio is showing. The editor’s next poll of `/connect/token` then receives that account’s token.', security: [{ bearerAuth: [] }], requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'), responses: { 200: json(DeviceDecisionResponse, 'The code is approved'), 400: json(OAuthError, 'Unknown, expired, or already used code'), 401: { description: 'Missing or invalid bearer token (empty body)' }, }, }), (c) => decideDeviceCode(c, approveDeviceGrant) ) .post( '/connect/device/deny', describeRoute({ tags: ['Token'], summary: 'Deny a Studio device code', description: 'Same caller as approve. Studio’s next poll fails with `access_denied` instead of signing in.', security: [{ bearerAuth: [] }], requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'), responses: { 200: json(DeviceDecisionResponse, 'The code is denied'), 400: json(OAuthError, 'Unknown, expired, or already used code'), 401: { description: 'Missing or invalid bearer token (empty body)' }, }, }), (c) => decideDeviceCode(c, denyDeviceGrant) ) // OAuth token endpoint — accepts a form-urlencoded body and issues a JWT. .post( '/connect/token', describeRoute({ tags: ['Token'], summary: 'OAuth token endpoint — issues a JWT', description: [ 'Issues an access token (plus a single-use refresh token) for one of four grants,', 'selected by `grant_type`. Every grant returns the same body on success.', '', '**`create_account`** — mints a new account with an auto-assigned random username', '(players do not pick one initially) and places it in the Orientation room. A posted', '`password` becomes the login credential. Without a `platform` this is a PASSWORD', 'account (what the website makes), accepted only while `PASSWORD_SIGNUP` is on —', 'off by default, refused as `invalid_grant` "password signup is disabled". Subject', 'to three independent signup caps,', 'per verified platform id, per signup IP and per client `device_id`', '(`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`;', 'any disabled by setting it to 0). If it asserts a', '`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`', 'must verify.', '', '**`cached_login`** — logs into an already-linked account using platform ownership as', 'the credential; no password. Requires a verifying `platform_auth`, and the posted', '`account_id` must be LINKED to exactly the identity it proves. An account with no', 'link for that identity cannot be cached-logged-into.', '', '**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The', 'platform and platform id come from what was stored at issue time, not the body.', '', '**`password`** (the fallback for any unrecognised or absent `grant_type`) —', 'identifies the account by `username` or numeric `account_id` and requires the', 'matching `password`. An account with no stored hash cannot be logged into at all,', 'which is what closes id/username-only takeover. When it also posts a `platform_auth`', 'that verifies, that identity is LINKED to the account — this is how a player who', 'signed up on one platform gets a cached login on a second device. The login is', 'never failed over the link: an unverifiable proof (or one over the per-identity', 'cap) just leaves the account without a cached login there.', '', '**Platform identity.** An account can be reached from several platform identities;', 'the links are the one thing both the picker and `cached_login` consult, and only a', 'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a', 'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries', 'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`', 'as `{"Nonce":…,"AppId":…}`, which recflare sends to Meta together with the posted', '`platform_id` — validation is what binds the nonce to that user id, so a spoofed id', 'fails. Meta logins therefore need the app secret (`META_APP_SECRET`) and answer 500', 'when it is unset. The first identity linked also becomes the account’s primary', '(what the account DTO and a refreshed token report); later ones only link.', '', 'The one platform id that is never verified and never linked is `1` on platform `1`', '— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such', 'build reports it, so it identifies nobody. A password login that carries it still', 'succeeds; it simply links nothing, and the player types their password each launch.', '', '**Roles.** The token embeds a `role` claim from the account, so developer/moderator', 'powers refresh on every login and every refresh grant. `junior` rides along for an', 'account flagged `isJunior`, and `screenshare` is on every token — it is a feature', 'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped', 'from the account’s `hasStudio` flag (staff set it on the website). RecFlare Studio', 'treats that claim as permission to upload; `developer` does not confer it. A junior', 'also carries', 'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the', 'client reads it as a claim beside `role`, and it is absent for everyone else.', '', '**Bans.** A BLOCKED account still gets a token — every grant, including a refresh,', 'and whether the ban is its own or reaches it through the evasion arms.', 'A ban is a `report` row with `banned` set (the `api` worker owns that table); it', 'lifts on its own when `ban_expires` passes, and never if that is null. The token', 'is what lets the client reach `api`’s `/api/PlayerReporting/v1/moderationBlockDetails`', 'and show the player the block screen that explains the ban; the ban itself is', 'enforced by `match`, which refuses every matchmake but the caller’s own dorm, so a', 'token gets them as far as that screen and no further.', '', 'Ban EVASION — an account sharing a PROVEN platform identity (a `platform_account`', 'link) or an IP (`signupIp`/`lastLoginIp`, or the address this request came from)', 'with a banned one — signs in the same way, and is stopped at matchmaking like any', 'other blocked account. Those two arms are the operator’s `BAN_EVASION_MATCH` knob', '(`ip`, `platform`, or `off`).', '', 'What IS refused here (`invalid_grant`) is a `create_account` carrying either link,', 'refused BEFORE it mints anything: making a NEW account to evade with is a different', 'act from signing into one. The description is deliberately vague — the signup', 'refused may belong to a housemate of the banned player rather than to them.', '', '**Rollout gate.** A grant whose `ver` names a build under staged rollout', '(`ROLLOUT_GAME_VERSIONS`, currently `20260323` — every point release of it) is refused as `invalid_grant` "this build is limited to', 'Discord supporters" unless the account’s Discord link (made by the website’s', 'benefits claim) holds one of the roles in the operator’s `DISCORD_ROLLOUT_ROLE_IDS`.', 'With none configured the gate is closed to everyone. `create_account` on such a', 'build is refused before it mints anything, since a new account has no Discord link.', 'Grants posting no `ver` (a refresh, the website) and every other build are untouched.', ].join('\n'), requestBody: form( TokenRequest, 'Union of all grants; see the description for per-grant requirements' ), responses: { 200: json(TokenResponse, 'Access token, refresh token and granted scopes'), 400: json( OAuthError, [ 'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an', 'invalid/expired refresh token, a missing account identifier, a signup cap reached,', 'or an account sharing a banned one’s device or network', ].join(' ') ), 500: json( OAuthError, [ 'The server is missing a secret it cannot proceed without: JWT_SECRET (a token is', 'refused rather than signed with an empty key) or, on a Meta login, META_APP_SECRET', '(no nonce can be validated without it).', ].join(' ') ), }, }), async (c) => { // Reads `grant_type`, `account_id`, `platform_id` and `platform` from the // form body. const body = await c.req.parseBody().catch(() => ({}) as Record) const grantType = typeof body.grant_type === 'string' ? body.grant_type : '' // Studio's poll. Handled before the password fallback, which would otherwise // treat this grant_type as a username/password login and 400 it. if (grantType === DEVICE_CODE_GRANT) { if (!studioClientMatches(body)) { return oauthError(c, 'invalid_client', 'unknown studio client') } const deviceCode = typeof body.device_code === 'string' ? body.device_code : '' const polled = await pollDeviceGrant(c.env.DB, deviceCode) if (polled.kind === 'pending') { return oauthError( c, 'authorization_pending', 'the player has not approved this login yet' ) } if (polled.kind === 'denied') { return oauthError(c, 'access_denied', 'the player denied this login') } if (polled.kind === 'expired') { return oauthError(c, 'expired_token', 'the device code expired') } if (polled.kind !== 'approved') { return oauthError(c, 'invalid_grant', 'unknown device code') } return issueSession(c, polled.accountId, DEVICE_CODE_GRANT) } // `platform`/`platform_id` come from the body for a fresh login; a refresh // grant overrides them below with what was stored when the token was issued. let platformId = typeof body.platform_id === 'string' ? body.platform_id : '' const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN // The token's `platform` claim is the PlatformType int. A grant that asserts no // platform (a password login) falls back to Steam/0, the same default the account // itself carries — see `accountPlatform`. let platform = Number.isNaN(platformInt) ? PlatformType.Steam : platformInt // The device this login came from. The client posts both on every grant; they're // unverified (client-picked) so they're recorded on the account, never trusted as // a credential. Stored on account creation AND refreshed on each successful login, // so the account's device tracks the player across devices — the raw material for // linking accounts that share a device later. const deviceId = typeof body.device_id === 'string' ? body.device_id : '' const deviceClassInt = typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt // The client's own build (`ver`, e.g. `20250718.01`), stamped into the token's // `rn.ver` claim so everything downstream reports the build the player is ACTUALLY // running rather than this server's GAME_VERSION — `match` reads it back off the // token when it writes presence. Unverified like the device fields, and only ever // echoed, never trusted for a decision (the version CHECK is `api`'s // `/api/versioncheck/v4`, against its own list). // // A grant that posts none — a refresh, or a caller that isn't the game — leaves it // undefined and generateToken falls back to GAME_VERSION. An empty string is // treated as absent for the same reason: presence must never carry an empty // version, which breaks the client's handling of it. const version = typeof body.ver === 'string' && body.ver !== '' ? body.ver : undefined // The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP — // the edge sets it — unlike X-Forwarded-For, which is why we don't read that). // Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every // login; both feed the per-IP signup cap. Absent (empty) outside the CF edge. const clientIp = c.req.header('cf-connecting-ip') ?? '' // A platform-authenticated login proves who you are with the platform itself, and // we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket, // and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see // verifyPlatformProof). Only a verified identity is ever bound or linked. // // Two grants are GATED on it — they have no other credential, so an unverifiable // platform is fatal: // - cached_login authenticates purely by platform identity. // - create_account that asserts a platform: we won't bind an identity we can't // prove. (create_account with NO platform is the password-account path — // binds no platformId, and is open only while PASSWORD_SIGNUP says so.) // // A password grant is NOT gated: the password already proved who it is. It posts // its platform proof too, and if that verifies we LINK the identity to the account // (see below), which is how a player who created an account on Steam gets a cached // login on their headset. If it doesn't verify, the login still succeeds — it just // links nothing, because a link is a password-free way into the account and must // never rest on an unproven id. const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : '' const platformAsserted = !Number.isNaN(platformInt) const gatedOnPlatform = grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted) // The password grant only spends a verification when the client actually offered // one; the rest of the time there is nothing to link. const proof: PlatformProof = gatedOnPlatform || (platformAsserted && platformAuth !== '') ? await verifyPlatformProof(c.env, platformInt, platformAuth, platformId) : { status: 'none' } let verifiedPlatformId: string | null = null let verifiedPlatform: number | null = null if (proof.status === 'verified') { verifiedPlatform = proof.platform verifiedPlatformId = proof.platformId } else if (proof.status !== 'none') { // Log every failure, including the ones a password grant shrugs off: a player // who silently never gets a cached login on their headset has no other symptom, // and this line is where "Meta rejected the nonce" becomes visible. logger.info('platform_auth not verified', { platform: platformInt, platformId, grantType, status: proof.status, reason: proof.status === 'rejected' ? proof.reason : undefined, }) } if (gatedOnPlatform && proof.status !== 'verified') { if (proof.status === 'unsupported') { return c.json( { error: 'invalid_grant', error_description: 'unsupported platform; only Steam and Meta can be verified', }, 400 ) } if (proof.status === 'unconfigured') { // An operator misconfiguration, not the client's fault: without the app secret // every Meta player is locked out, so it answers 500 the way an unset // JWT_SECRET does below rather than blaming the credential. (We never fall // back to trusting the posted id — that would let anyone log into any // Meta-linked account by naming its user id.) logger.error('refusing a Meta login: META_APP_SECRET is empty') return c.json( { error: 'server_error', error_description: 'Meta platform verification is not configured', }, 500 ) } // The reason is for the operator; the client is told only that it was rejected. // A wrong app secret and a stale nonce look identical from the client side. return c.json( { error: 'invalid_grant', error_description: 'invalid or missing platform_auth' }, 400 ) } // From here on `platformId` is the PROVEN identity wherever there is one — the // SteamID64 out of the ticket or the Meta user id the nonce validated against, // never the raw client-supplied field. if (verifiedPlatformId !== null) platformId = verifiedPlatformId // Resolve the account this token is for: // - create_account: mint + persist a brand-new account (auto-assigned random // username — players don't pick one initially); the token's `sub` is its id. // A `password` may be posted to establish the account's login credential. // - refresh_token: redeem a stored (single-use) refresh token for its account + // platform, so an expiring session renews without re-login. // - otherwise: a credential login. The request identifies the account by // `username` (RecRoom's password grant posts the username, not the id) or a // numeric `account_id`, and MUST post the account's correct `password`. An // account with no password set can't be logged into (no credential to verify) // — closing the id/username-only takeover. New accounts establish a password // via create_account or /account/me/changepassword. let accountId: string if (grantType === 'create_account') { // A rollout-gated build can't sign UP: the account it would mint has no Discord // link, so it could never hold the role the gate wants. Refused before anything // is read or minted, like the switch below — no row left behind, no cap slot // burned. if (isRolloutGameVersion(version)) { logger.info('signup refused: build is limited to Discord supporters', { version, ip: clientIp, }) return c.json( { error: 'invalid_grant', error_description: ROLLOUT_GATED_DESCRIPTION }, 400 ) } // The password-account path (no platform) is a switch, off by default: see // DEFAULT_PASSWORD_SIGNUP. Checked first, before anything is read or minted — // a closed door costs no D1 round trip and burns no cap slot. if (!platformAsserted && !flagVar(c.env.PASSWORD_SIGNUP, DEFAULT_PASSWORD_SIGNUP)) { logger.info('signup refused: password signup is disabled', { ip: clientIp }) return c.json( { error: 'invalid_grant', error_description: PASSWORD_SIGNUP_DISABLED_DESCRIPTION }, 400 ) } // A banned player's next move is a new account, so the evasion arms are checked // BEFORE one is minted — against the only identity a signup has, the IP it came // from and the platform identity it just proved. Refusing after the fact (as the // shared check below would) still refuses the token, but leaves the account // row behind and burns a slot off both signup caps, so the evader gets to keep // making them. // // Nothing here can match the account arm (there is no account yet), so this is // purely the linked matching, and BAN_EVASION_MATCH=off leaves signup open — // which is the honest default position: a server that won't accept the IP arm's // false positives is choosing to let evaders re-register. const blocked = await resolveBan(c.env.DB, null, { identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId, }, arms: banEvasionMatch(c.env.BAN_EVASION_MATCH), }) if (blocked) { logger.info('signup refused: player banned', { via: blocked.via, bannedAccountId: blocked.bannedAccountId, ip: clientIp, platformId: verifiedPlatformId, }) return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400) } // Signup caps. Checked before minting anything, so a rejected signup leaves no // account behind. Each arm is skipped when it's disabled (var <= 0) or when its // identity is unknown (no verified platform id / no client IP / no device id) — an unattributable // signup can't be counted against anyone, and lumping them together would lock out // real players. The disabled check comes first so a disabled arm costs no D1 read. const maxPerPlatformId = intVar( c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID ) const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP) const maxPerDeviceId = intVar( c.env.MAX_ACCOUNTS_PER_DEVICE_ID, DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID ) if ( maxPerPlatformId > 0 && verifiedPlatformId !== null && (await countAccountsForPlatformIdentity( c.env.DB, verifiedPlatform ?? 0, verifiedPlatformId )) >= maxPerPlatformId ) { logger.info('signup rejected: platform account limit', { platformId: verifiedPlatformId, }) return c.json( { error: 'invalid_grant', error_description: 'account limit reached for this platform account', }, 400 ) } if ( maxPerIp > 0 && clientIp !== '' && (await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp ) { logger.info('signup rejected: per-IP account limit', { ip: clientIp }) return c.json( { error: 'invalid_grant', error_description: 'too many accounts created from this network', }, 400 ) } if ( maxPerDeviceId > 0 && deviceId !== '' && (await countAccountsByDeviceId(c.env.DB, deviceId)) >= maxPerDeviceId ) { logger.info('signup rejected: per-device account limit', { deviceId, ip: clientIp }) return c.json( { error: 'invalid_grant', error_description: 'too many accounts created from this device', }, 400 ) } // Bind the platform identity ONLY when the platform proved it (a Steam ticket or // a Meta-validated nonce). A password/anonymous create_account (no platform) // binds nothing. The account blob keeps this first identity as its PRIMARY one // (for the account DTO and the refresh grant's claims); the link written just // below is what a later cached login is actually authorized against. const account = await createAccount(c.env.DB, { platforms: platformInt || 0, platform: verifiedPlatform ?? undefined, platformId: verifiedPlatformId ?? undefined, lastLoginTime: new Date().toISOString(), deviceId: deviceId || undefined, deviceClass: deviceId ? deviceClass : undefined, signupIp: clientIp || undefined, lastLoginIp: clientIp || undefined, }) accountId = String(account.accountId) if (verifiedPlatformId !== null) { await linkPlatformIdentity( c.env.DB, account.accountId, verifiedPlatform ?? 0, verifiedPlatformId ) } // Establish the login password when one is posted (raw password never stored). const password = typeof body.password === 'string' ? body.password : '' if (password !== '') { await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password)) } // Place the new player in Orientation (they don't explicitly matchmake into it). await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass) } else if (grantType === 'refresh_token') { const presented = typeof body.refresh_token === 'string' ? body.refresh_token : '' const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null if (!refreshed) { return c.json( { error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' }, 400 ) } // `platform`/`platform_id` aren't stored with the token — they're taken from // the account below, so a refreshed token always reflects the identity the // account is bound to now. accountId = String(refreshed) } else if (grantType === 'cached_login') { // Platform-authenticated login into an already-linked account. The client posts // the `account_id` it got from /cachedlogin/forplatformid together with the // `platform_id` its platform_auth vouches for. Authorize ONLY when the link // table says that account is linked to exactly this platform identity — this is // the check that keeps anyone but that platform user out of the account // (platform ownership is the credential; no password needed). An account with no // link for the presented identity must use a password. // // The picker lists straight from the same table, so it can only offer accounts // this check accepts. // // NB: `platform_id` here is the verified identity set above — the SteamID64 from // the ticket, or the Meta user id the nonce validated against — never the raw // client-supplied field. See steam-ticket.ts and meta-nonce.ts. // const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : '' const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null const linked = account !== null && (await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId)) if (!account || !linked) { return c.json( { error: 'invalid_grant', error_description: 'no linked account for this platform identity', }, 400 ) } accountId = String(account.accountId) await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString()) await setLoginContext(c.env.DB, account.accountId, { deviceId, deviceClass, ip: clientIp }) } else { // Resolve the account from a posted numeric `account_id` or, as RecRoom's // password grant sends, a `username` (case-insensitive; trailing whitespace // is trimmed off the posted value). const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : '' const postedUsername = typeof body.username === 'string' ? body.username.trim() : '' let resolvedId: number | null = null if (/^\d+$/.test(postedId)) { resolvedId = Number(postedId) } else if (postedUsername !== '') { resolvedId = (await getAccountByUsername(c.env.DB, postedUsername))?.accountId ?? null } if (resolvedId === null) { return c.json( { error: 'invalid_request', error_description: 'account_id or username is required' }, 400 ) } // The account's password MUST be presented and match. An account with no // stored hash has no credential to authenticate against, so login is refused // — this closes the id/username-only takeover. const storedHash = await getPasswordHash(c.env.DB, resolvedId) const password = typeof body.password === 'string' ? body.password : '' if (!storedHash || !(await verifyPassword(password, storedHash))) { return c.json( { error: 'invalid_grant', error_description: 'invalid account_id or password' }, 400 ) } accountId = String(resolvedId) // The password proved the account; the platform proof (when the client sent one // and it verified) proves the device's platform identity. Linking the two is // what gives a player who signed up on Steam a cached login on their headset — // they type their password once there, and never again. if (verifiedPlatformId !== null) { await linkLoginIdentity( c.env.DB, resolvedId, verifiedPlatform ?? 0, verifiedPlatformId, intVar(c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID) ) } await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString()) await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp }) } // The rollout gate: a build in ROLLOUT_GATED_VERSIONS is for Discord supporters // only, so the account signing in on it must hold one of the configured roles. // Deliberately AFTER the credential checks, like the ban lookup below — a wrong // password on a gated build is still "invalid account_id or password", so the // gate can't be used to probe whether an account exists. Unlike a ban, this IS a // refusal: there is no block screen to reach, and the client surfaces the // description on its sign-in failure. if (isRolloutGameVersion(version)) { const roleIds = rolloutRoleIds(c.env.DISCORD_ROLLOUT_ROLE_IDS) if (!(await accountHoldsRolloutRole(c.env.DB, Number(accountId), roleIds))) { logger.info('login refused: build is limited to Discord supporters', { accountId, grantType, version, // The COUNT, not the ids: zero here means the gate is closed because // nothing is configured, which reads very differently from "not a // supporter". roleIdCount: roleIds.length, }) return c.json( { error: 'invalid_grant', error_description: ROLLOUT_GATED_DESCRIPTION }, 400 ) } } // EVERY account gets its token, banned or not — a ban of its own, or a link to // somebody else's through the evasion arms. The client needs one to reach `api`'s // moderationBlockDetails, which is where a banned player is TOLD they are banned // (category, time left, "Rule violation"); refused here they would only ever see a // sign-in that fails, with nothing saying why. The ban is enforced by `match`, which // refuses every matchmake but the caller's own dorm, so a token gets them as far as // the block screen and no further. // // That includes ban EVASION, which this used to refuse. An evader signing in lands // in their dorm and can enter no room — the same place the ban leaves them — and // refusing the token instead only ever produced a client stuck on a failed login. // `create_account` is still refused before it mints anything (above): that is making // a NEW account to evade with, which is a different act from signing into one. // // Logged either way, and the arm is logged with it: "banned" and "shares a network // with somebody banned" are very different things to be reading in a log. // // Deliberately AFTER the credential checks — a wrong password is still "invalid // account_id or password", so this can't be used to probe whether an account exists // or is banned without knowing it. // // The request's own IP and proven identity are passed alongside the account, so a // ban also reaches an old, clean account logged into from the banned player's device // or network — the stored ips alone would only catch that on the SECOND login. const ban = await resolveBan(c.env.DB, Number(accountId), { identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId }, arms: banEvasionMatch(c.env.BAN_EVASION_MATCH), }) if (ban) { logger.info('token issued to a blocked account', { accountId, grantType, via: ban.via, bannedAccountId: ban.bannedAccountId, reportId: ban.ban.id, banExpires: ban.ban.ban_expires, }) } // Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing // binding) would still yield a well-formed token — but one signed with an empty // key, which every worker validates against, so anyone could forge it. Refuse to // issue a token at all rather than complete the login with a forgeable credential. const jwtSecret = await c.env.JWT_SECRET.get() if (jwtSecret === '') { logger.error('refusing to issue token: JWT_SECRET is empty') return c.json( { error: 'server_error', error_description: 'token signing is not configured' }, 500 ) } // Stamp the account's elevated roles into the token's `role` claim so the client // authorizes developer/moderator powers from the token itself (not just the // /role/* lookups), and its Plus flag into `rn.plus`. One read of the just-resolved // account serves both; they thus refresh on every login and every refresh_token // grant. const roleAccount = await getAccount(c.env.DB, Number(accountId)) // A refresh grant posts no platform of its own, so the identity comes off the // account — the same read, and the only place the bound identity is authoritative. if (grantType === 'refresh_token' && roleAccount) { platform = accountPlatform(roleAccount) platformId = roleAccount.platformId ?? '' } const accessToken = await generateToken( accountId, platformId, platform, jwtSecret, accountRoles(roleAccount), accountPrivileges(roleAccount), version, // Rec Room Plus, off the same account read as the roles above — `econ` decides the // CampusCard and the subscriber discount from this claim alone, so it never has to // load the account. It therefore refreshes on every login and every refresh_token // grant, and only then: a player who claims Plus on the website keeps a token that // says otherwise until they sign in again. roleAccount?.hasPlus === true ) // Issue a fresh, persisted refresh token (single-use; the client redeems it via // grant_type=refresh_token). A refresh grant thus rotates its token. const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId)) // A STAFF sign-in is an audit event in its own right. What these accounts then do is // recorded against them on the `audit_log` table (`notify` files every `/internal/*` // call there); this is the row saying the session those actions came from began — and // from what device, build and IP, which is the first thing anyone asks when an action // further down the log looks wrong. The flags themselves are the trigger, so an account // granted the role between logins starts being recorded the next time it signs in. // // A real sign-in only: `refresh_token` renews the SAME session every TOKEN_TTL_SECONDS, // and recording those would bury the logins under hourly repeats that say nothing new // about who came in or from where. // // Written after the token is minted, so the row means a login that actually happened, // and caught rather than awaited into the response: a failed audit insert must not turn // a staffer's successful sign-in into a 500. It leaves a loud line in the worker log // instead — the one thing a gap in an audit trail should never do quietly. const staffRoles = roleAccount && grantType !== 'refresh_token' ? (['developer', 'moderator'] as const).filter((role) => holdsRole(roleAccount, role)) : [] if (staffRoles.length > 0) { try { await writeAuditLog(c.env.DB, { playerId: Number(accountId), action: 'staff_login', // The IP leads: it is the first thing anyone asks of a staff sign-in, and the one // field here that says WHERE the session came from rather than what it could do. // Empty outside the Cloudflare edge — recorded as the empty string it is rather // than omitted, so a row can't be read as "we didn't record one". // // Then: which powers the session carries, HOW they signed in, and the same // unverified-but-recorded device/build context the account row keeps — kept here // too so the row stands on its own months later, after the account's // `lastLoginIp` and device have been overwritten by every login since. data: { ip: clientIp, roles: staffRoles, grantType, username: roleAccount?.username ?? null, deviceId, deviceClass, version: version ?? null, }, }) } catch (err) { logger.error('could not write a staff_login audit log row', { accountId, grantType, error: err instanceof Error ? err.message : String(err), }) } } return c.json({ access_token: accessToken, expires_in: TOKEN_TTL_SECONDS, token_type: 'Bearer', refresh_token: refreshToken, scope: TOKEN_SCOPE, // @kludge Why is this necessary? Who knows. key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=', }) } ) // Change the caller's password. Auth-gated. Stores a PBKDF2 hash on the account // row (the raw password is never persisted). When the account already has a // password, `oldPassword` must match; the first time it's set, `oldPassword` is // empty (as the client sends). .post( '/account/me/changepassword', describeRoute({ tags: ['Account'], summary: "Change the caller's password", description: [ 'Stores a PBKDF2 hash on the account row; the raw password is never persisted.', 'When the account already has a password, `oldPassword` must match. The first time', 'a password is set, `oldPassword` is empty — which is what the client sends.', ].join(' '), security: [{ bearerAuth: [] }], requestBody: form(ChangePasswordRequest, 'New password, plus the old one when one is set'), responses: { 200: json(ChangePasswordResponse, 'Password changed'), 400: json(ChangePasswordResponse, '`newPassword` was empty, or `oldPassword` was wrong'), 401: { description: 'Missing or invalid bearer token (empty body)' }, 404: { description: 'The account no longer exists (empty body)' }, }, }), async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) const body = await c.req.parseBody().catch(() => ({}) as Record) const oldPassword = typeof body.oldPassword === 'string' ? body.oldPassword : '' const newPassword = typeof body.newPassword === 'string' ? body.newPassword : '' if (newPassword === '') { return c.json({ success: false, error: 'You must enter a new password.' }, 400) } const currentHash = await getPasswordHash(c.env.DB, id) if (currentHash && !(await verifyPassword(oldPassword, currentHash))) { return c.json({ success: false, error: 'Your old password is incorrect.' }, 400) } const ok = await setPasswordHash(c.env.DB, id, await hashPassword(newPassword)) if (!ok) return c.body(null, 404) return c.json({ success: true }) } ) // The moderation restrictions on the caller's account — chat mutes and the like. STUB: // nothing here issues restrictions, so every caller is unrestricted and the list is // empty. An EMPTY ARRAY is the normal unrestricted answer, not null and not a 404: the // client clears and refills its list from this, and acts on a record simply being // present (plus its `EndDate`), so `[]` is a complete answer rather than a placeholder. // // When this is wired up, the display strings are free text — the client matches none of // them — so only `EndDate` and the record's presence need to be right. See // `RestrictionDto`. .get( '/privileges/me/restrictions', describeRoute({ tags: ['Account'], summary: 'The caller’s moderation restrictions', description: [ 'The restrictions in force on the caller’s account (a chat mute, say), as a bare array.', 'Always EMPTY here — nothing on this server issues restrictions — and an empty array is', 'the normal unrestricted answer, not null. The client refills its list from this and', 'acts on a record being present and its `EndDate`; the `Name`/`Description`/', '`DisplayReason` strings are display text it matches nothing against.', ].join(' '), security: [{ bearerAuth: [] }], responses: { 200: json(RestrictionDto.array(), 'The caller’s restrictions — always empty'), 401: { description: 'Missing or invalid bearer token (empty body)' }, }, }), async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) return c.json([]) } ) // Developer role lookup. Returns a bare JSON boolean (the client reads the body as // a bool), and 404s for an unknown player — mirroring the reference API. The role // is off by default and only an operator grants it (via `runx admin grant-developer`, // which sets the account's isDeveloper flag); it also rides in the token's `role` // claim (see accountRoles). .get('/role/developer/:id', describeRoute(roleLookup('developer')), (c) => roleAnswer(c, 'developer', c.req.param('id')) ) // The BULK form — `/role/developer?id=2&id=3` answers WHICH of those players are developers, // as an array of ints. A route of its own rather than an optional `:id?` segment, and a // different answer shape from the single lookup above: both are kept because the client asks // both ways (the path form is the 2023 one). .get('/role/developer', describeRoute(roleFilter('developer')), (c) => roleFilterAnswer(c, 'developer') ) // Moderator role lookup, mirroring developer (bare boolean, 404 for unknown player). // Operator-granted only (via `runx admin grant-moderator`); the flag also rides in // the token's `role` claim. .get('/role/moderator/:id', describeRoute(roleLookup('moderator')), (c) => roleAnswer(c, 'moderator', c.req.param('id')) ) // Moderator's bulk form, kept in step with developer's above. .get('/role/moderator', describeRoute(roleFilter('moderator')), (c) => roleFilterAnswer(c, 'moderator') ) // @guess Oculus nonce. The client asks for this before a Meta login; the exact shape // it expects hasn't been observed, so this mints a fresh 64-char hex nonce (the length // Meta's own `GetUserProof` nonces have) and answers it as a bare JSON string. Nothing // is stored — Meta's nonce validation (meta-nonce.ts) is what actually proves a login, // so this value is not security-relevant to the server. Revisit once the client's use // of it is seen. .get( '/oculus/nonce', describeRoute({ tags: ['Account'], summary: 'A fresh nonce for the Oculus login flow', description: [ 'Mints a random 64-char hex nonce and returns it as a bare JSON string. Not stored', 'and not verified later — a best guess at the shape the client wants.', ].join(' '), responses: { 200: json(z.string(), 'The nonce') }, }), (c) => { const bytes = crypto.getRandomValues(new Uint8Array(32)) const nonce = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') logger.info('oculus nonce issued') return c.json(nonce) } ) // The generated spec. Documentation only — no request is validated against it (see // openapi.ts). `hide: true` keeps this route out of its own output. app.get( '/openapi.json', describeRoute({ hide: true }), withCleanSpec( openAPIRouteHandler(app, { documentation: { info: { title: 'recflare auth', version: '1.0.0', description: [ 'Authentication and token issuance for recflare, a private-server reimplementation', 'of the Rec Room backend.', ].join('\n'), }, servers: [{ url: 'https://auth.recflare.net', description: 'Production' }], components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'An `access_token` from `POST /connect/token`.', }, }, }, }, }) ) ) export default app