Files
recflare/apps/auth/src/auth.app.ts
T
Devin Zuczek 11b037a2f1 more stubs
2026-08-15 13:54:34 -04:00

1172 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
countAccountsBySignupIp,
createAccount,
GAME_VERSION,
getAccount,
getAccountByUsername,
getAccountsByIds,
getPasswordHash,
getRoomById,
hashPassword,
RoomInstanceType,
setLastLoginTime,
setLoginContext,
setPasswordHash,
setPresence,
subRoomDataBlob,
updateAccount,
verifyPassword,
} from '@repo/domain'
import { 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 { verifyMetaNonce } from './meta-nonce'
import {
CachedLogin,
ChangePasswordRequest,
ChangePasswordResponse,
FakeCachedLogin,
form,
json,
OAuthError,
PlatformIdsRequest,
PlatformType,
roleLookup,
TokenRequest,
TokenResponse,
} from './openapi'
import {
countAccountsForPlatformIdentity,
getLinksForPlatformId,
getLinksForPlatformIdentity,
isPlatformIdentityLinked,
linkPlatformIdentity,
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App } 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 banned account's grant is refused with. A fixed sentence,
* never interpolated with the expiry, 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.
*/
const BANNED_DESCRIPTION = 'this account is banned'
/**
* The refusal when it is not THIS account that is banned but one it shares an identity
* with (see bans-db's linked arms). Deliberately a different, vaguer sentence: 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.
*/
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).
*
* Two independent arms, because they fail in opposite 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 IP arm is the only thing standing 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.
*
* These are the defaults. An operator overrides either arm with the matching worker var
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` 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
/** 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<void> {
// 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<string, unknown> | 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<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* 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. Order is stable so
* tokens are deterministic.
*/
function accountRoles(
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior'> | 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')
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<Account, 'isJunior'> | 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<Account, 'platform'>): number {
return account.platform ?? 0
}
/**
* 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<void> {
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<PlatformProof> {
// 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<App>()
.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',
'entrys 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<string, unknown>)
const raw = body.id
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
const out: Array<ReturnType<typeof toCachedLogin>> = []
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)
}
)
// 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. Subject to two independent signup caps,',
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
'`MAX_ACCOUNTS_PER_IP`; either 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 accounts 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. 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.** Once the grant has resolved an account, a BANNED account is refused a',
'token at all (`invalid_grant`) — every grant, including a refresh. 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 refusal follows the player, not just the account: it also catches an account',
'that shares a PROVEN platform identity (a `platform_account` link) or an IP',
'(`signupIp`/`lastLoginIp`, or the address this request came from) with a banned',
'one, and a `create_account` carrying either is refused BEFORE it mints anything.',
'Those two arms are the operators `BAN_EVASION_MATCH` knob (`ip`, `platform`, or',
'`off`); the ban on the account itself is always enforced. A linked match answers a',
'deliberately vaguer description than a direct one — the account refused may belong',
'to a housemate of the banned player rather than to them.',
].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 a banned account',
].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<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
// `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 —
// allowed, but binds no platformId.)
//
// 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 banned player's next move is a new account, so the ban is 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) — 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)
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
)
}
// 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 })
}
// A banned player gets no token — and with no token every other worker is shut to
// them, so this is the outer wall of a ban; matchmaking's refusal is the inner
// one, which still has to exist because a token issued before the ban stays valid
// until it expires.
//
// Checked once here, after the grant has resolved an account, so it covers every
// grant: password, cached_login and a refresh_token redeemed by a client that has
// been running since before the ban. 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. create_account was already refused before it minted anything (above);
// this still runs for it, so a signup that raced one is refused too.
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 refused: player banned', {
accountId,
grantType,
via: ban.via,
bannedAccountId: ban.bannedAccountId,
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
return c.json(
{
error: 'invalid_grant',
error_description: ban.via === 'account' ? BANNED_DESCRIPTION : BLOCKED_DESCRIPTION,
},
400
)
}
// 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). One read of the just-resolved account; roles 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
)
// 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))
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<string, unknown>)
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 })
}
)
// 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')), async (c) => {
const { id } = c.req.param()
logger.info('developer 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(account.isDeveloper === true)
})
// 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')), async (c) => {
const { id } = c.req.param()
logger.info('moderator 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(account.isModerator === true)
})
// 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