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
+42 -12
View File
@@ -48,7 +48,8 @@ route without documenting it fails rather than silently shipping an incomplete s
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies - **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
the account by `username` or numeric `account_id` and requires the matching password the account by `username` or numeric `account_id` and requires the matching password
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at (PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
all, which is what closes id/username-only takeover. all, which is what closes id/username-only takeover. When it also carries a verifying
`platform_auth`, that identity is **linked** to the account (see below).
Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `role` Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `role`
claim, so developer/moderator powers refresh on every login and every refresh grant. claim, so developer/moderator powers refresh on every login and every refresh grant.
@@ -72,16 +73,45 @@ asserts a platform) must be a platform we can verify. Two are:
login at all without the app secret — an unset `META_APP_SECRET` answers 500 rather login at all without the app secret — an unset `META_APP_SECRET` answers 500 rather
than falling back to trusting the client. than falling back to trusting the client.
Everything else is refused. Whichever platform, the value written to an account's Everything else is refused. Whichever platform, the identity that gets bound or linked
`platformId` is the verified one, never the raw `platform_id` field. is the verified one, never the raw `platform_id` field.
### One account, many platform identities
An account can be reached from several platform identities — a player's PC and their
headset both open the same account, with no password after the first time. The links
live in the `platform_account` table (`src/platform-db.ts`, migration 0007), one row per
(platform, platform id, account).
That table is the **one source of truth** for both halves of a cached login: the picker
(`/cachedlogin/forplatformid`) lists the accounts an identity links to, and the
`cached_login` grant asks it whether the account it was handed is linked to the identity
just proven. They used to be two separate checks over the account blob's single
`platformId`, which could disagree — the client would be offered an account that then
answered "no linked account" forever.
A second device is linked by **logging in with a password there**: the client posts its
`platform_auth` alongside the password, and a proof that verifies becomes a link. Only a
verified identity is ever linked, since a link is a password-free way into the account.
A proof that doesn't verify never fails the login — it just leaves that device without a
cached login.
The account blob keeps `platform`/`platformId` as the account's **primary** identity
(the first one linked). It feeds the account DTO and a refreshed token's claims, and
nothing authorizes off it.
## Signup caps ## Signup caps
`create_account` is capped on two independent arms, per verified platform id and per `create_account` is capped on two independent arms, per verified platform identity and
signup IP. The platform arm can't be spoofed or reset by changing networks; the IP arm per signup IP. The platform arm can't be spoofed or reset by changing networks; the IP
is coarse and will produce false positives behind NAT, shared campus and mobile arm is coarse and will produce false positives behind NAT, shared campus and mobile
networks. Both default to 3. networks. Both default to 3.
The platform arm also caps **linking**, or it wouldn't be a cap: an identity at the
limit could otherwise have accounts created for it with a password and link its way into
all of them. Hitting it never fails a password login — the account just doesn't get a
cached login on that device.
Override per environment via the root `.env` (`RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID`, Override per environment via the root `.env` (`RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID`,
`RECFLARE_MAX_ACCOUNTS_PER_IP`), injected at deploy time so tuning them never means `RECFLARE_MAX_ACCOUNTS_PER_IP`), injected at deploy time so tuning them never means
editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a
@@ -89,12 +119,12 @@ small private server, or when a shared network is being locked out.
## Bindings ## Bindings
| Binding | Type | Notes | | Binding | Type | Notes |
| -------------------- | ------------- | ------------------------------------------------------ | | -------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
| `DB` | D1 | Shared `recflare` database; this worker owns `account` | | `DB` | D1 | Shared `recflare` database; this worker owns `account`, `refresh_tokens` and `platform_account` |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key | | `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
| `META_APP_SECRET` | Secrets Store | Meta app secret; only used to validate a login nonce | | `META_APP_SECRET` | Secrets Store | Meta app secret; only used to validate a login nonce |
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` | | `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth` Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
table, so they stay independent of the `rooms` worker's migrations on the same table, so they stay independent of the `rooms` worker's migrations on the same
@@ -0,0 +1,35 @@
-- Let one account be linked to MORE THAN ONE platform identity, so a player with a
-- PC and a headset gets a cached login on both. The account blob's single
-- `platformId`/`platform` pair could only hold one, so logging in on the second
-- device meant a password every time.
--
-- Links move into their own table, which becomes the one source of truth for both
-- halves of a cached login (the picker and the `cached_login` grant). The blob fields
-- stay as the account's *primary* identity — the first one linked — for the account
-- DTO and the refresh grant's claims; nothing authorizes off them any more. Kept in
-- sync with PLATFORM_SCHEMA_DDL in src/platform-db.ts.
CREATE TABLE IF NOT EXISTS platform_account (
account_id INTEGER NOT NULL,
platform INTEGER NOT NULL,
platform_id TEXT NOT NULL,
linked_at TEXT NOT NULL,
PRIMARY KEY (platform, platform_id, account_id)
);
CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id);
CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id);
-- Backfill every identity already bound to an account. `platform` is COALESCEd to 0
-- because nothing ever defaulted that field: an account can carry a platformId with no
-- platform recorded, and back when Steam was the only verifiable platform an unset one
-- *was* Steam. Without the COALESCE those accounts would lose their cached login at
-- deploy. Mirrored as PLATFORM_BACKFILL_SQL in src/platform-db.ts, which is what the
-- tests run.
INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
SELECT
account_id,
COALESCE(json_extract(data, '$.platform'), 0),
platform_id,
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
FROM account
WHERE platform_id IS NOT NULL AND platform_id <> '';
+284 -138
View File
@@ -3,13 +3,12 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { import {
countAccountsByPlatformId,
countAccountsBySignupIp, countAccountsBySignupIp,
createAccount, createAccount,
GAME_VERSION, GAME_VERSION,
getAccount, getAccount,
getAccountByUsername, getAccountByUsername,
getAccountsByPlatformId, getAccountsByIds,
getPasswordHash, getPasswordHash,
getRoomById, getRoomById,
hashPassword, hashPassword,
@@ -19,6 +18,7 @@ import {
setPasswordHash, setPasswordHash,
setPresence, setPresence,
subRoomDataBlob, subRoomDataBlob,
updateAccount,
verifyPassword, verifyPassword,
} from '@repo/domain' } from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
@@ -38,12 +38,20 @@ import {
TokenRequest, TokenRequest,
TokenResponse, TokenResponse,
} from './openapi' } from './openapi'
import {
countAccountsForPlatformIdentity,
getLinksForPlatformId,
getLinksForPlatformIdentity,
isPlatformIdentityLinked,
linkPlatformIdentity,
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db' import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket' import { verifySteamTicket } from './steam-ticket'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { Account } from '@repo/domain' import type { Account } from '@repo/domain'
import type { App } from './context' import type { App } from './context'
import type { PlatformLink } from './platform-db'
/** OAuth scopes granted by `/connect/token`. */ /** OAuth scopes granted by `/connect/token`. */
const TOKEN_SCOPE = const TOKEN_SCOPE =
@@ -165,42 +173,147 @@ function accountPlatform(account: Pick<Account, 'platform'>): number {
return account.platform ?? 0 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 * 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 * entry on the login screen. The client posts the chosen `accountId` back as a
* `grant_type=cached_login`. `requirePassword` is false because platform ownership * `grant_type=cached_login`. `requirePassword` is false because platform ownership
* (the verified `platform_auth`) is the credential for a cached login — no prompt. * (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 { return {
platform: accountPlatform(account), platform: link.platform,
platformId: account.platformId ?? '', platformId: link.platformId,
accountId: account.accountId, accountId: account.accountId,
lastLoginTime: account.lastLoginTime ?? account.createdAt, lastLoginTime: account.lastLoginTime ?? account.createdAt,
requirePassword: false, 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>() const app = new Hono<App>()
.use( .use(
'*', '*',
@@ -243,17 +356,18 @@ const app = new Hono<App>()
tags: ['Cached login'], tags: ['Cached login'],
summary: 'Accounts linked to a platform id', summary: 'Accounts linked to a platform id',
description: [ description: [
'Accounts the client may offer on its login screen for this platform identity.', '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', 'the links this identity has, so an entry here is always redeemable by a',
'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls', '`cached_login` grant (both read the same table). An account linked to several',
'back to a fresh login or create_account.', '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(' '), ].join(' '),
parameters: [ parameters: [
{ {
name: 'platform', name: 'platform',
in: 'path', in: 'path',
required: true, 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' }, schema: { type: 'string' },
}, },
{ {
@@ -272,15 +386,13 @@ const app = new Hono<App>()
const { platform, id } = c.req.param() const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id }) logger.info('cached login lookup', { platform, id })
const platformInt = Number.parseInt(platform, 10) const platformInt = Number.parseInt(platform, 10)
const accounts = await getAccountsByPlatformId(c.env.DB, id) // Listed straight from the link table, which is also what the `cached_login`
// Offer only accounts the `cached_login` grant will actually accept — same check. // grant authorizes against — so the picker can't offer an account the grant
return c.json( // then refuses.
accounts const links = Number.isNaN(platformInt)
.filter( ? await getLinksForPlatformId(c.env.DB, id)
(a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id) : await getLinksForPlatformIdentity(c.env.DB, platformInt, id)
) return c.json(await toCachedLogins(c.env.DB, links))
.map(toCachedLogin)
)
} }
) )
@@ -294,8 +406,8 @@ const app = new Hono<App>()
description: [ description: [
'Resolves many platform ids at once. Results are flattened across all ids, so the', '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', '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', 'entrys own `platformId`. No platform accompanies these ids, so each matches on',
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.', 'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
].join(' '), ].join(' '),
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'), requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') }, 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 ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
const out: Array<ReturnType<typeof toCachedLogin>> = [] const out: Array<ReturnType<typeof toCachedLogin>> = []
for (const pid of ids) { 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) 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', '**`cached_login`** — logs into an already-linked account using platform ownership as',
'the credential; no password. Requires a verifying `platform_auth`, and the posted', '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', '`account_id` must be LINKED to exactly the identity it proves. An account with no',
'with no stored platform identity cannot be cached-logged-into.', 'link for that identity cannot be cached-logged-into.',
'', '',
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The', '**`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.', '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`) —', '**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
'identifies the account by `username` or numeric `account_id` and requires the', '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,', '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', '**Platform identity.** An account can be reached from several platform identities;',
'authenticating by platform identity must be one of them, and only a verified id is', 'the links are the one thing both the picker and `cached_login` consult, and only a',
'ever written to an account. Steam (`0`) posts a Steam-signed `platform_auth` ticket,', 'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a',
'checked offline; the SteamID64 it carries replaces the client-supplied `platform_id`.', 'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries',
'Meta/Oculus (`1`) posts `platform_auth` as `{"Nonce":…,"AppId":…}`, which recflare', 'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`',
'sends to Meta together with the posted `platform_id` — validation is what binds the', 'as `{"Nonce":…,"AppId":…}`, which recflare sends to Meta together with the posted',
'nonce to that user id, so a spoofed id fails. Meta logins therefore need the app', '`platform_id` — validation is what binds the nonce to that user id, so a spoofed id',
'secret (`META_APP_SECRET`) and answer 500 when it is unset. Password and refresh', 'fails. Meta logins therefore need the app secret (`META_APP_SECRET`) and answer 500',
'grants carry their own credential and are not gated this way.', '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', '**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant.', '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 // 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, // 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 // and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
// those logins must be one of those two: // verifyPlatformProof). Only a verified identity is ever bound or linked.
// - 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.
// //
// The two platforms prove the id in opposite directions, which is why they can't // Two grants are GATED on it — they have no other credential, so an unverifiable
// share a code path: Steam's ticket *carries* a SteamID64 we read out and trust, // platform is fatal:
// so the posted `platform_id` is discarded. Meta's nonce carries nothing — it is // - cached_login authenticates purely by platform identity.
// validated *against* the posted `platform_id`, so that field is an input, and a // - create_account that asserts a platform: we won't bind an identity we can't
// spoofed one fails validation rather than being ignored. Either way what lands in // prove. (create_account with NO platform is the password-account path —
// `platformId` below is proven, never the raw client-supplied field. // 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 verifiedPlatformId: string | null = null
let verifiedPlatform: number | null = null let verifiedPlatform: number | null = null
const platformAsserted = !Number.isNaN(platformInt) if (proof.status === 'verified') {
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) { verifiedPlatform = proof.platform
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : '' verifiedPlatformId = proof.platformId
if (platformInt === PlatformType.Steam) { } else if (proof.status !== 'none') {
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null // Log every failure, including the ones a password grant shrugs off: a player
if (!verified) { // who silently never gets a cached login on their headset has no other symptom,
return c.json( // and this line is where "Meta rejected the nonce" becomes visible.
{ logger.info('platform_auth not verified', {
error: 'invalid_grant', platform: platformInt,
error_description: 'invalid or missing platform_auth ticket', platformId,
}, grantType,
400 status: proof.status,
) reason: proof.status === 'rejected' ? proof.reason : undefined,
} })
verifiedPlatform = PlatformType.Steam }
verifiedPlatformId = verified.steamId
} else if (platformInt === PlatformType.Oculus) { if (gatedOnPlatform && proof.status !== 'verified') {
// Verifying a Meta login needs the app secret. Without it every Meta player is if (proof.status === 'unsupported') {
// 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 {
return c.json( return c.json(
{ {
error: 'invalid_grant', error: 'invalid_grant',
@@ -496,9 +585,34 @@ const app = new Hono<App>()
400 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: // Resolve the account this token is for:
// - create_account: mint + persist a brand-new account (auto-assigned random // - 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. // username — players don't pick one initially); the token's `sub` is its id.
@@ -526,7 +640,11 @@ const app = new Hono<App>()
if ( if (
maxPerPlatformId > 0 && maxPerPlatformId > 0 &&
verifiedPlatformId !== null && 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', { logger.info('signup rejected: platform account limit', {
platformId: verifiedPlatformId, 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 // 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 // a Meta-validated nonce). A password/anonymous create_account (no platform)
// is checked against, so only that platform user can log back into the account. // binds nothing. The account blob keeps this first identity as its PRIMARY one
// A password/anonymous create_account (no platform) binds no platformId. // (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, { const account = await createAccount(c.env.DB, {
platforms: platformInt || 0, platforms: platformInt || 0,
platform: verifiedPlatform ?? undefined, platform: verifiedPlatform ?? undefined,
@@ -569,6 +688,14 @@ const app = new Hono<App>()
lastLoginIp: clientIp || undefined, lastLoginIp: clientIp || undefined,
}) })
accountId = String(account.accountId) 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). // Establish the login password when one is posted (raw password never stored).
const password = typeof body.password === 'string' ? body.password : '' const password = typeof body.password === 'string' ? body.password : ''
if (password !== '') { if (password !== '') {
@@ -592,11 +719,14 @@ const app = new Hono<App>()
} else if (grantType === 'cached_login') { } else if (grantType === 'cached_login') {
// Platform-authenticated login into an already-linked account. The client posts // Platform-authenticated login into an already-linked account. The client posts
// the `account_id` it got from /cachedlogin/forplatformid together with the // the `account_id` it got from /cachedlogin/forplatformid together with the
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that // `platform_id` its platform_auth vouches for. Authorize ONLY when the link
// account is linked to exactly this platform identity — this is the check that // table says that account is linked to exactly this platform identity — this is
// keeps anyone but platform user `platform_id` out of the account (platform // the check that keeps anyone but that platform user out of the account
// ownership is the credential; no password needed). An account with no stored // (platform ownership is the credential; no password needed). An account with no
// platform identity can't be cached-logged-into and must use a fresh login. // 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 // 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 // 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 postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null 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( return c.json(
{ {
error: 'invalid_grant', error: 'invalid_grant',
@@ -646,6 +779,19 @@ const app = new Hono<App>()
) )
} }
accountId = String(resolvedId) 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 setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp }) await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
} }
+16 -5
View File
@@ -73,19 +73,29 @@ export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
* see the platform-auth notes on `POST /connect/token`. * see the platform-auth notes on `POST /connect/token`.
*/ */
export const PlatformTypeSchema = z export const PlatformTypeSchema = z
.union([z.literal(-1), z.int().min(0).max(Math.max(...Object.values(PlatformType)))]) .union([
z.literal(-1),
z
.int()
.min(0)
.max(Math.max(...Object.values(PlatformType))),
])
.describe( .describe(
Object.entries(PlatformType) Object.entries(PlatformType)
.map(([name, value]) => `${value} ${name}`) .map(([name, value]) => `${value} ${name}`)
.join(', ') .join(', ')
) )
/** One entry on the client's login screen, from `toCachedLogin`. */ /**
* One entry on the client's login screen, from `toCachedLogin` — an account ↔ platform
* identity LINK, not an account. An account linked to two platforms yields one entry in
* each of their pickers, each reporting the identity that picker was asked about.
*/
export const CachedLogin = z.object({ export const CachedLogin = z.object({
platform: PlatformTypeSchema, platform: PlatformTypeSchema,
platformId: z platformId: z
.string() .string()
.describe('Platform-native id (a SteamID64 for Steam, a user id for Meta); "" if unlinked'), .describe('The linked platform-native id a SteamID64 for Steam, a user id for Meta'),
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'), accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"), lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
requirePassword: z requirePassword: z
@@ -141,8 +151,9 @@ export const TokenRequest = z.object({
.string() .string()
.optional() .optional()
.describe( .describe(
'Platform proof, required for cached_login and platform create_account. Steam: ' + 'Platform proof, required for cached_login and platform create_account, and used to ' +
'`{"Ticket":"<hex>","AppId":…}`. Meta: `{"Nonce":…,"AppId":…,"Source":…}`' 'link the identity on a password grant. Steam: `{"Ticket":"<hex>","AppId":…}`. ' +
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
), ),
refresh_token: z.string().optional().describe('Required on a refresh_token grant'), refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
device_id: z device_id: z
+190
View File
@@ -0,0 +1,190 @@
/**
* Platform identity links on the shared `recflare` D1 database (owned by the `auth`
* worker, migration 0007). One row per (platform, platform id, account): the Steam
* user 76561…211 is linked to account 42, the Meta user 27061… is linked to account
* 42 as well, and both let that player into that account without a password.
*
* This table replaced the single `platformId`/`platform` pair on the account blob as
* the thing logins are decided from, because that pair could only hold ONE identity —
* a player with a PC and a headset had to pick which device got a cached login. The
* blob fields are kept as the account's *primary* identity (the first one linked) for
* the account DTO and the refresh grant's claims; nothing authorizes off them.
*
* It is deliberately the ONE source of truth for both halves of a cached login: the
* picker (`/cachedlogin/forplatformid`) lists the accounts this table links to an
* identity, and the `cached_login` grant asks this table whether the account it was
* handed is linked to the identity that was proven. When those two disagreed the
* client was offered an account it could never log into — see the regression test.
*
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
* Meta-validated nonce). It is what turns "this platform user" into "may enter this
* account with no password", so an unproven `platform_id` must never reach it.
*/
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
export const PLATFORM_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS platform_account (
account_id INTEGER NOT NULL,
platform INTEGER NOT NULL,
platform_id TEXT NOT NULL,
linked_at TEXT NOT NULL,
PRIMARY KEY (platform, platform_id, account_id)
)`,
// The picker's lookup: "which accounts does this identity open?". Covered by the
// primary key's leading columns, so no separate index is needed for it.
`CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id)`,
// Lookup by bare platform id, across platforms — the bulk (friends) route, which
// resolves ids it has no platform for.
`CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id)`,
]
/**
* The one-time backfill 0007 runs after creating the table: every identity already
* bound to an account becomes a link, so nobody loses their cached login at deploy.
* Exported so a test can run exactly the statement the migration does.
*
* `platform` is COALESCEd to 0 because nothing ever defaulted that field — an account
* can carry a platformId with no platform recorded, and back when Steam was the only
* verifiable platform an unset one *was* Steam.
*/
export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
SELECT
account_id,
COALESCE(json_extract(data, '$.platform'), 0),
platform_id,
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
FROM account
WHERE platform_id IS NOT NULL AND platform_id <> ''`
/** One account ↔ platform identity link. */
export interface PlatformLink {
accountId: number
platform: number
platformId: string
/** ISO-8601 time the link was made. */
linkedAt: string
}
interface LinkRow {
accountId: number
platform: number
platformId: string
linkedAt: string
}
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
linked_at AS linkedAt FROM platform_account`
/**
* Link a verified platform identity to an account. Idempotent — re-logging in on the
* same platform doesn't churn the row, and `linkedAt` keeps the time of the FIRST
* link. Returns true when this created a new link.
*
* Callers must pass an identity the platform itself proved. Nothing in here can tell
* a verified id from a spoofed one.
*/
export async function linkPlatformIdentity(
db: D1Database,
accountId: number,
platform: number,
platformId: string
): Promise<boolean> {
if (platformId === '') return false
const res = await db
.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(accountId, platform, platformId, new Date().toISOString())
.run()
return res.meta.changes > 0
}
/**
* The accounts a platform identity opens — what the login-screen picker lists.
* Ordered oldest link first so the list is stable between launches (D1 row order
* isn't). Empty id yields nothing rather than matching every link.
*/
export async function getLinksForPlatformIdentity(
db: D1Database,
platform: number,
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
const { results } = await db
.prepare(
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
)
.bind(platform, platformId)
.all<LinkRow>()
return results
}
/**
* Links for a bare platform id, whatever platform it belongs to. For the bulk
* (friends-resolution) lookup, which posts ids with no platform alongside them, and
* for the single-id route when the client sends a non-numeric platform.
*/
export async function getLinksForPlatformId(
db: D1Database,
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
const { results } = await db
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
.bind(platformId)
.all<LinkRow>()
return results
}
/** Every platform identity linked to an account (a player's PC and headset, say). */
export async function getLinksForAccount(
db: D1Database,
accountId: number
): Promise<PlatformLink[]> {
const { results } = await db
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
.bind(accountId)
.all<LinkRow>()
return results
}
/**
* Whether this account is linked to this platform identity — the single check the
* `cached_login` grant authorizes on. An account with no link for the presented
* identity cannot be cached-logged-into and must use a password.
*/
export async function isPlatformIdentityLinked(
db: D1Database,
accountId: number,
platform: number,
platformId: string
): Promise<boolean> {
if (platformId === '') return false
const row = await db
.prepare(
`SELECT 1 AS ok FROM platform_account
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
)
.bind(accountId, platform, platformId)
.first<{ ok: number }>()
return row !== null
}
/**
* How many accounts one platform identity already opens — the count both signup caps
* and link caps are enforced against, so an identity can't accumulate accounts by
* creating them under the cap and then linking more in.
*/
export async function countAccountsForPlatformIdentity(
db: D1Database,
platform: number,
platformId: string
): Promise<number> {
if (platformId === '') return 0
const row = await db
.prepare(`SELECT COUNT(*) AS n FROM platform_account WHERE platform = ?1 AND platform_id = ?2`)
.bind(platform, platformId)
.first<{ n: number }>()
return row?.n ?? 0
}
+217 -29
View File
@@ -14,7 +14,12 @@ import {
SUBROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL,
} from '@repo/domain' } from '@repo/domain'
import { isLinkedToPlatformIdentity } from '../../auth.app' import {
getLinksForAccount,
linkPlatformIdentity,
PLATFORM_BACKFILL_SQL,
PLATFORM_SCHEMA_DDL,
} from '../../platform-db'
import { REFRESH_SCHEMA_DDL } from '../../refresh-db' import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
import type { Env } from '../../context' import type { Env } from '../../context'
@@ -51,6 +56,9 @@ beforeAll(async () => {
metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET) metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET)
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Platform identity links — one account can hold several (a PC and a headset), and
// this table is what both the picker and the cached_login grant read.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (owned by the rooms worker) — signup seeds the Orientation row. // Presence table (owned by the rooms worker) — signup seeds the Orientation row.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -137,6 +145,19 @@ async function metaLogin(
} }
} }
/** GET a JSON route on the worker and parse the body as `T`. */
async function getJson<T>(path: string): Promise<T> {
const res = await exports.default.fetch(`${ORIGIN}${path}`)
return (await res.json()) as T
}
/** The picker entries a platform identity yields, as the client sees them. */
function cachedLogins(platform: number, id: string) {
return getJson<Array<Record<string, unknown> & { accountId: number; platform: number }>>(
`/cachedlogin/forplatformid/${platform}/${id}`
)
}
/** The `platform_auth` payload a Meta client posts, as observed from a live login. */ /** The `platform_auth` payload a Meta client posts, as observed from a live login. */
function metaPlatformAuth(): string { function metaPlatformAuth(): string {
return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' }) return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' })
@@ -251,10 +272,7 @@ describe('auth worker routes', () => {
// cached-login picker offer it, and the cached_login grant accept it. // cached-login picker offer it, and the cached_login grant accept it.
const payload = decodePayload(res.json.access_token as string) const payload = decodePayload(res.json.access_token as string)
const accountId = Number(payload.sub) const accountId = Number(payload.sub)
const lookup = await exports.default.fetch( const linked = await cachedLogins(1, META_USER_ID)
`${ORIGIN}/cachedlogin/forplatformid/1/${META_USER_ID}`
)
const linked = (await lookup.json()) as Array<Record<string, unknown>>
expect(linked).toContainEqual( expect(linked).toContainEqual(
expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID }) expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID })
) )
@@ -285,6 +303,7 @@ describe('auth worker routes', () => {
}) })
) )
.run() .run()
await linkPlatformIdentity(env.DB, 5150, 1, userId)
const res = await metaLogin( const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` + `grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`, `&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
@@ -298,8 +317,8 @@ describe('auth worker routes', () => {
test('a Meta user id cannot log into an account it is not linked to', async () => { test('a Meta user id cannot log into an account it is not linked to', async () => {
// The Meta account seeded above, claimed by a different (but genuinely proven) // The Meta account seeded above, claimed by a different (but genuinely proven)
// Meta user. Even with a nonce Meta vouches for, the identity has to match the // Meta user. Even with a nonce Meta vouches for, the identity has to be one the
// account's stored one. // account is actually linked to.
const res = await metaLogin( const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` + `grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`, `&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
@@ -345,6 +364,7 @@ describe('auth worker routes', () => {
}) })
) )
.run() .run()
await linkPlatformIdentity(env.DB, 31380, 0, steamId)
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`) const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual([ expect(await res.json()).toEqual([
@@ -358,32 +378,69 @@ describe('auth worker routes', () => {
]) ])
}) })
test('a Steam-linked account with no stored `platform` field still cached-logs in', async () => { test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
// Regression: nothing defaults an account's `platform` (see defaultAccount), so a // The point of the link table. The same account is reachable from the PC and from
// Steam-linked account can carry a platformId with no platform. The picker offered // the headset, and each picker reports the identity IT was asked about — that's
// such an account (it treats a missing platform as Steam) while the cached_login // what the client posts back on the cached_login grant.
// grant rejected it — "no linked account for this platform identity" forever. const steamId = '76561197962463777'
// Both now run the same check. const metaId = '27061366730207777'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 6200,
username: 'CrossPlatform',
platform: 0,
platformId: steamId,
lastLoginTime: '2026-08-01T10:00:00.000Z',
})
)
.run()
await linkPlatformIdentity(env.DB, 6200, 0, steamId)
await linkPlatformIdentity(env.DB, 6200, 1, metaId)
const onSteam = await cachedLogins(0, steamId)
const onMeta = await cachedLogins(1, metaId)
expect(onSteam).toEqual([
expect.objectContaining({ accountId: 6200, platform: 0, platformId: steamId }),
])
expect(onMeta).toEqual([
expect.objectContaining({ accountId: 6200, platform: 1, platformId: metaId }),
])
// And the grant accepts both, without a password.
const viaMeta = await metaLogin(
`grant_type=cached_login&account_id=6200&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(viaMeta.status).toBe(200)
expect(decodePayload(viaMeta.json.access_token as string).sub).toBe('6200')
})
test('the picker and the cached_login grant read the same table', async () => {
// Regression: the picker used to derive links from the account blob (treating a
// missing `platform` as Steam) while the grant ran its own check, so the client
// could be handed an account_id that answered "no linked account" forever. Both
// now read platform_account, which is why an account with a stale blob identity
// is NOT offered — and, since it isn't offered, never rejected either.
const steamId = '76561197962463211' const steamId = '76561197962463211'
const account = { platformId: steamId } // no `platform` field
// The grant now accepts it — this is what was returning invalid_grant.
expect(isLinkedToPlatformIdentity(account, 0, steamId)).toBe(true)
// The identity is still the credential: another SteamID, an account with no
// platform identity, and an account bound to a different platform are all refused.
expect(isLinkedToPlatformIdentity(account, 0, '76561197962463299')).toBe(false)
expect(isLinkedToPlatformIdentity({}, 0, steamId)).toBe(false)
expect(isLinkedToPlatformIdentity({ ...account, platform: 3 }, 0, steamId)).toBe(false)
// And the picker offers exactly the accounts the grant accepts.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId })) .bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
.run() .run()
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
const offered = (await res.json()) as Array<{ accountId: number; platform: number }> // No link row yet: not offered.
expect(offered.map((a) => a.accountId)).toContain(8) const before = await cachedLogins(0, steamId)
expect(offered.find((a) => a.accountId === 8)?.platform).toBe(0) expect(before.map((a) => a.accountId)).not.toContain(8)
// The 0007 backfill is what gives accounts like this one — bound before the link
// table existed, and carrying no `platform` field at all — their link.
await env.DB.prepare(PLATFORM_BACKFILL_SQL).run()
const after = await cachedLogins(0, steamId)
expect(after.map((a) => a.accountId)).toContain(8)
// COALESCEd to Steam, which is what an unset platform meant.
expect(after.find((a) => a.accountId === 8)?.platform).toBe(0)
}) })
test('POST /connect/token issues a bearer token with role/scope claims', async () => { test('POST /connect/token issues a bearer token with role/scope claims', async () => {
@@ -720,6 +777,137 @@ describe('auth worker routes', () => {
expect(payload.platform_id).toBe('steam-123') expect(payload.platform_id).toBe('steam-123')
}) })
// A password login is how a player who already has an account signs in on a NEW
// device. The client posts its platform proof alongside the password, and linking
// the two is what turns the next launch on that device into a cached login.
describe('password grant links the platform identity it proves', () => {
/** Seed an account with LOGIN_PASSWORD set and no platform identity at all. */
async function seedPasswordAccount(id: number, username: string) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username,
passwordHash: await hashPassword(LOGIN_PASSWORD),
})
)
.run()
}
test('a verified Meta login on an existing account links it, and cached login follows', async () => {
// Exactly the client's flow: an account made elsewhere, signed into on a headset
// with username + password, with the Meta nonce riding along.
await seedPasswordAccount(7100, 'djdevin')
const metaId = '27061366730201234'
const login = await metaLogin(
`grant_type=password&username=djdevin&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(login.status).toBe(200)
expect(decodePayload(login.json.access_token as string).sub).toBe('7100')
// The nonce was validated against the id being linked — an unproven id is never
// linked, since a link is a password-free way into the account.
expect(login.graphCalls[0].get('user_id')).toBe(metaId)
// The headset now gets a cached login: offered by the picker…
const offered = await cachedLogins(1, metaId)
expect(offered.map((a) => a.accountId)).toContain(7100)
// …and accepted by the grant, with no password.
const cached = await metaLogin(
`grant_type=cached_login&account_id=7100&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(cached.status).toBe(200)
})
test('the first identity linked becomes the account primary; later ones just link', async () => {
await seedPasswordAccount(7101, 'multiplatform')
const metaId = '27061366730205678'
await metaLogin(
`grant_type=password&username=multiplatform&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
// The blob's primary identity was empty, so the first link fills it in — this is
// what the account DTO and the refresh grant's claims report.
const account = (await env.DB.prepare(
'SELECT data FROM account WHERE account_id = 7101'
).first<{ data: string }>())!
expect(JSON.parse(account.data)).toMatchObject({ platform: 1, platformId: metaId })
// A second identity on another platform links without disturbing the primary.
await linkPlatformIdentity(env.DB, 7101, 0, '76561197962465678')
const links = await getLinksForAccount(env.DB, 7101)
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
[1, metaId],
[0, '76561197962465678'],
])
})
test('an unverified platform_auth logs in but links nothing', async () => {
// The password already proved who this is, so the login stands — but a link is a
// password-free way in, and this identity was never proven, so none is written.
await seedPasswordAccount(7102, 'unproven')
const metaId = '27061366730209876'
const login = await metaLogin(
`grant_type=password&username=unproven&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
false // Meta rejects the nonce
)
expect(login.status).toBe(200)
expect(await getLinksForAccount(env.DB, 7102)).toEqual([])
})
test('a login with no platform_auth links nothing and asks Meta nothing', async () => {
await seedPasswordAccount(7103, 'noproof')
const login = await metaLogin(
`grant_type=password&username=noproof&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=27061366730204321`,
true
)
expect(login.status).toBe(200)
expect(login.graphCalls).toHaveLength(0)
expect(await getLinksForAccount(env.DB, 7103)).toEqual([])
})
test('linking obeys the per-identity account cap, without failing the login', async () => {
// Otherwise the signup cap would be trivially bypassable: create accounts with a
// password, then link the capped identity into all of them.
const metaId = '27061366730203333'
for (let i = 0; i < 3; i++) await linkPlatformIdentity(env.DB, 8000 + i, 1, metaId)
await seedPasswordAccount(8100, 'overcap')
const login = await metaLogin(
`grant_type=password&username=overcap&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
// The password was valid, so the player is logged in — they just don't get a
// cached login on this account.
expect(login.status).toBe(200)
expect(await getLinksForAccount(env.DB, 8100)).toEqual([])
})
test('re-logging in on the same device does not duplicate the link', async () => {
await seedPasswordAccount(7104, 'repeatlogin')
const metaId = '27061366730207654'
const body =
`grant_type=password&username=repeatlogin&password=${LOGIN_PASSWORD}` +
`&platform=1&platform_id=${metaId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`
await metaLogin(body, true)
await metaLogin(body, true)
expect(await getLinksForAccount(env.DB, 7104)).toHaveLength(1)
})
})
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => { test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`) const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
const refreshToken = login.json.refresh_token as string const refreshToken = login.json.refresh_token as string
+13 -45
View File
@@ -37,12 +37,14 @@ export interface Account {
identityFlags: number identityFlags: number
createdAt: string createdAt: string
/** /**
* The platform-native identity linked to this account (e.g. a SteamID64 for * The account's PRIMARY platform identity — the first one linked (e.g. a SteamID64
* platform 0). Stored as a STRING on purpose a SteamID64 exceeds 2^53 and * for platform 0). Stored as a STRING on purpose: a SteamID64 exceeds 2^53 and
* would lose precision as a JS number. Set at account creation from the login's * would lose precision as a JS number.
* `platform_id`. A cached login is authorized ONLY to the account whose stored *
* `platformId` matches the (platform_auth-ticket-proven) platform id presented, * An account can be reachable from SEVERAL platform identities (a PC and a headset),
* so no one but that platform user can log into the account. * and those live in the `auth` worker's `platform_account` table — NOT here. Logins
* are authorized against that table alone; this pair is what the account DTO and a
* refreshed token's claims report, and it never gains a second value.
*/ */
platformId?: string platformId?: string
/** PlatformType int (0 = Steam) that `platformId` belongs to. */ /** PlatformType int (0 = Steam) that `platformId` belongs to. */
@@ -216,23 +218,6 @@ export async function searchAccounts(
return parseAll(results) return parseAll(results)
} }
/**
* Accounts linked to a platform-native id (e.g. a SteamID64), for the cached-login
* account picker. Backed by the indexed `platform_id` generated column. Empty id
* yields no matches (avoids matching every account whose `platformId` is null).
*/
export async function getAccountsByPlatformId(
db: D1Database,
platformId: string
): Promise<Account[]> {
if (platformId === '') return []
const { results } = await db
.prepare('SELECT data FROM account WHERE platform_id = ?1')
.bind(platformId)
.all<AccountRow>()
return parseAll(results)
}
/** /**
* Accounts last seen on a given device (the client-supplied `device_id` auth records * Accounts last seen on a given device (the client-supplied `device_id` auth records
* at login). An empty id yields no matches (avoids matching every account with no * at login). An empty id yields no matches (avoids matching every account with no
@@ -258,7 +243,9 @@ export async function getAccountsByDeviceId(db: D1Database, deviceId: string): P
/** Record the account's most recent successful login time (ISO-8601). */ /** Record the account's most recent successful login time (ISO-8601). */
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> { export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
await db await db
.prepare("UPDATE account SET data = json_set(data, '$.lastLoginTime', ?2) WHERE account_id = ?1") .prepare(
"UPDATE account SET data = json_set(data, '$.lastLoginTime', ?2) WHERE account_id = ?1"
)
.bind(id, time) .bind(id, time)
.run() .run()
} }
@@ -295,9 +282,7 @@ export async function setLoginContext(
} }
if (sets.length === 0) return if (sets.length === 0) return
await db await db
.prepare( .prepare(`UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1`)
`UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1`
)
.bind(id, ...binds) .bind(id, ...binds)
.run() .run()
} }
@@ -324,21 +309,6 @@ export async function countAccountsBySignupIp(db: D1Database, ip: string): Promi
return row?.n ?? 0 return row?.n ?? 0
} }
/**
* How many accounts are linked to a platform-native id (e.g. one SteamID64) — the
* count a per-platform signup cap is enforced against. Backed by the indexed
* `platform_id` generated column. An empty id counts 0 (accounts with no platform
* identity aren't attributable to a platform user).
*/
export async function countAccountsByPlatformId(db: D1Database, platformId: string): Promise<number> {
if (platformId === '') return 0
const row = await db
.prepare('SELECT COUNT(*) AS n FROM account WHERE platform_id = ?1')
.bind(platformId)
.first<{ n: number }>()
return row?.n ?? 0
}
/** Look up multiple accounts by AccountId (order not guaranteed). */ /** Look up multiple accounts by AccountId (order not guaranteed). */
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> { export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
if (ids.length === 0) return [] if (ids.length === 0) return []
@@ -411,9 +381,7 @@ export async function getPasswordHash(db: D1Database, id: number): Promise<strin
/** Persist the account's password hash. Returns false when no such account exists. */ /** Persist the account's password hash. Returns false when no such account exists. */
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> { export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
const { meta } = await db const { meta } = await db
.prepare( .prepare("UPDATE account SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1")
"UPDATE account SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1"
)
.bind(id, hash) .bind(id, hash)
.run() .run()
return meta.changes > 0 return meta.changes > 0