increase cardinality of platform accounts

This commit is contained in:
Devin Zuczek
2026-08-03 20:32:23 -04:00
parent af2a2a0683
commit 108b061019
7 changed files with 797 additions and 229 deletions
+284 -138
View File
@@ -3,13 +3,12 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
countAccountsByPlatformId,
countAccountsBySignupIp,
createAccount,
GAME_VERSION,
getAccount,
getAccountByUsername,
getAccountsByPlatformId,
getAccountsByIds,
getPasswordHash,
getRoomById,
hashPassword,
@@ -19,6 +18,7 @@ import {
setPasswordHash,
setPresence,
subRoomDataBlob,
updateAccount,
verifyPassword,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
@@ -38,12 +38,20 @@ import {
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 =
@@ -165,42 +173,147 @@ function accountPlatform(account: Pick<Account, 'platform'>): number {
return account.platform ?? 0
}
/**
* Whether an account is the one linked to a given platform identity — the single
* check behind both the cached-login picker and the `cached_login` grant. It lives in
* one place on purpose: if the picker offers an account the grant then rejects, the
* client is handed an `account_id` it can never log into ("no linked account for this
* platform identity" on every attempt).
*
* `platformId` must be the *proven* identity — the SteamID64 read out of a verified
* Steam ticket, or the Meta user id a validated nonce was issued to — never the raw
* client-supplied `platform_id` field.
*/
export function isLinkedToPlatformIdentity(
account: Pick<Account, 'platform' | 'platformId'>,
platform: number,
platformId: string
): boolean {
if (!account.platformId || platformId === '') return false
return account.platformId === platformId && accountPlatform(account) === platform
}
/**
* 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) {
function toCachedLogin(account: Account, link: PlatformLink) {
return {
platform: accountPlatform(account),
platformId: account.platformId ?? '',
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> {
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(
'*',
@@ -243,17 +356,18 @@ const app = new Hono<App>()
tags: ['Cached login'],
summary: 'Accounts linked to a platform id',
description: [
'Accounts the client may offer on its login screen for this platform identity.',
'Filtered to those a `cached_login` grant would actually accept, so an entry here',
'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls',
'back to a fresh login or create_account.',
'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.',
].join(' '),
parameters: [
{
name: 'platform',
in: 'path',
required: true,
description: 'PlatformType integer. A non-numeric value disables the link filter.',
description: 'PlatformType integer. A non-numeric value matches the id on any platform.',
schema: { type: 'string' },
},
{
@@ -272,15 +386,13 @@ const app = new Hono<App>()
const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id })
const platformInt = Number.parseInt(platform, 10)
const accounts = await getAccountsByPlatformId(c.env.DB, id)
// Offer only accounts the `cached_login` grant will actually accept — same check.
return c.json(
accounts
.filter(
(a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id)
)
.map(toCachedLogin)
)
// 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))
}
)
@@ -294,8 +406,8 @@ const app = new Hono<App>()
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`. Unlike the single-id route, results are NOT filtered to',
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
'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') },
@@ -306,7 +418,8 @@ const app = new Hono<App>()
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
const out: Array<ReturnType<typeof toCachedLogin>> = []
for (const pid of ids) {
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
// 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)
}
@@ -332,8 +445,8 @@ const app = new Hono<App>()
'',
'**`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 stored platform identity cannot be cached-logged-into.',
'`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.',
@@ -341,17 +454,22 @@ const app = new Hono<App>()
'**`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.',
'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 verification.** Two platforms can be verified, so any grant',
'authenticating by platform identity must be one of them, and only a verified id is',
'ever written to an account. 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. Password and refresh',
'grants carry their own credential and are not gated this way.',
'**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.',
'',
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant.',
@@ -412,82 +530,53 @@ const app = new Hono<App>()
// 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. So
// those logins must be one of those two:
// - cached_login authenticates purely by platform identity → always gated.
// - create_account that asserts a platform is rejected unless we can verify that
// platform, since 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.)
// The verified id is the ONLY value ever written to an account's `platformId`.
// Credential (password) and refresh_token grants carry their own credential and
// aren't gated here.
// 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.
//
// The two 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 what lands in
// `platformId` below is proven, never the raw client-supplied field.
// 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
const platformAsserted = !Number.isNaN(platformInt)
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
if (platformInt === PlatformType.Steam) {
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) {
return c.json(
{
error: 'invalid_grant',
error_description: 'invalid or missing platform_auth ticket',
},
400
)
}
verifiedPlatform = PlatformType.Steam
verifiedPlatformId = verified.steamId
} else if (platformInt === PlatformType.Oculus) {
// Verifying a Meta login needs the app secret. Without it every Meta player is
// locked out, which is an operator misconfiguration and not the client's fault
// — so it answers 500, the same 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.)
// `.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 rather than a 500 from
// the error handler with nothing useful in it.
const appSecret = await c.env.META_APP_SECRET.get().catch(() => '')
if (appSecret === '') {
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
)
}
const verified = await verifyMetaNonce(platformAuth, platformId, appSecret)
if (!verified.ok) {
// 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, so this log is the only way to tell them apart.
logger.info('meta nonce verification failed', {
platformId,
reason: verified.reason,
})
return c.json(
{
error: 'invalid_grant',
error_description: 'invalid or missing platform_auth nonce',
},
400
)
}
verifiedPlatform = PlatformType.Oculus
verifiedPlatformId = verified.identity.userId
} else {
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',
@@ -496,9 +585,34 @@ const app = new Hono<App>()
400
)
}
platformId = verifiedPlatformId
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.
@@ -526,7 +640,11 @@ const app = new Hono<App>()
if (
maxPerPlatformId > 0 &&
verifiedPlatformId !== null &&
(await countAccountsByPlatformId(c.env.DB, verifiedPlatformId)) >= maxPerPlatformId
(await countAccountsForPlatformIdentity(
c.env.DB,
verifiedPlatform ?? 0,
verifiedPlatformId
)) >= maxPerPlatformId
) {
logger.info('signup rejected: platform account limit', {
platformId: verifiedPlatformId,
@@ -555,9 +673,10 @@ const app = new Hono<App>()
}
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
// a Meta-validated nonce). That bound `platformId` is what a later cached login
// is checked against, so only that platform user can log back into the account.
// A password/anonymous create_account (no platform) binds no platformId.
// 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,
@@ -569,6 +688,14 @@ const app = new Hono<App>()
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 !== '') {
@@ -592,11 +719,14 @@ const app = new Hono<App>()
} 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 ticket vouches for. Authorize ONLY when that
// account is linked to exactly this platform identity — this is the check that
// keeps anyone but platform user `platform_id` out of the account (platform
// ownership is the credential; no password needed). An account with no stored
// platform identity can't be cached-logged-into and must use a fresh login.
// `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
@@ -604,7 +734,10 @@ const app = new Hono<App>()
//
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
const linked =
account !== null &&
(await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId))
if (!account || !linked) {
return c.json(
{
error: 'invalid_grant',
@@ -646,6 +779,19 @@ const app = new Hono<App>()
)
}
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 })
}