diff --git a/apps/auth/README.md b/apps/auth/README.md index 9ada603..1a64c60 100644 --- a/apps/auth/README.md +++ b/apps/auth/README.md @@ -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 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 - 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` 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 than falling back to trusting the client. -Everything else is refused. Whichever platform, the value written to an account's -`platformId` is the verified one, never the raw `platform_id` field. +Everything else is refused. Whichever platform, the identity that gets bound or linked +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 -`create_account` is capped on two independent arms, per verified platform id and per -signup IP. The platform arm can't be spoofed or reset by changing networks; the IP arm -is coarse and will produce false positives behind NAT, shared campus and mobile +`create_account` is capped on two independent arms, per verified platform identity and +per signup IP. The platform arm can't be spoofed or reset by changing networks; the IP +arm is coarse and will produce false positives behind NAT, shared campus and mobile 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`, `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 @@ -89,12 +119,12 @@ small private server, or when a shared network is being locked out. ## Bindings -| Binding | Type | Notes | -| -------------------- | ------------- | ------------------------------------------------------ | -| `DB` | D1 | Shared `recflare` database; this worker owns `account` | -| `JWT_SECRET` | Secrets Store | Shared HS256 signing key | -| `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` | +| Binding | Type | Notes | +| -------------------- | ------------- | ----------------------------------------------------------------------------------------------- | +| `DB` | D1 | Shared `recflare` database; this worker owns `account`, `refresh_tokens` and `platform_account` | +| `JWT_SECRET` | Secrets Store | Shared HS256 signing key | +| `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` | 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 diff --git a/apps/auth/migrations/0007_platform_accounts.sql b/apps/auth/migrations/0007_platform_accounts.sql new file mode 100644 index 0000000..74a75b4 --- /dev/null +++ b/apps/auth/migrations/0007_platform_accounts.sql @@ -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 <> ''; diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 13f751e..f7b4efb 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -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): 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, - 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 { + 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 { + if (platform === PlatformType.Steam) { + const verified = platformAuth ? await verifySteamTicket(platformAuth) : null + if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' } + return { status: 'verified', platform: PlatformType.Steam, platformId: verified.steamId } + } + if (platform === PlatformType.Oculus) { + // `.get()` throws when the secret doesn't exist in the store at all (as opposed to + // holding an empty/placeholder value) — the same misconfiguration from the player's + // side, so it takes the same branch. + const appSecret = await env.META_APP_SECRET.get().catch(() => '') + if (appSecret === '') return { status: 'unconfigured' } + const verified = await verifyMetaNonce(platformAuth, postedPlatformId, appSecret) + if (!verified.ok) return { status: 'rejected', reason: verified.reason } + return { + status: 'verified', + platform: PlatformType.Oculus, + platformId: verified.identity.userId, + } + } + return { status: 'unsupported' } +} + const app = new Hono() .use( '*', @@ -243,17 +356,18 @@ const app = new Hono() 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() 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() description: [ 'Resolves many platform ids at once. Results are flattened across all ids, so the', 'response cannot be mapped back to a specific input id — the client uses each', - 'entry’s own `platformId`. Unlike the single-id route, results are NOT filtered to', - 'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.', + 'entry’s own `platformId`. No platform accompanies these ids, so each matches on', + 'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.', ].join(' '), requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'), responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') }, @@ -306,7 +418,8 @@ const app = new Hono() const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String) const out: Array> = [] 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() '', '**`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() '**`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 account’s 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() // 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() 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() 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() } // 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() 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() } 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() // 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() ) } 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 }) } diff --git a/apps/auth/src/openapi.ts b/apps/auth/src/openapi.ts index 6757527..79436de 100644 --- a/apps/auth/src/openapi.ts +++ b/apps/auth/src/openapi.ts @@ -73,19 +73,29 @@ export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType] * see the platform-auth notes on `POST /connect/token`. */ 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( Object.entries(PlatformType) .map(([name, value]) => `${value} ${name}`) .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({ platform: PlatformTypeSchema, platformId: z .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'), lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"), requirePassword: z @@ -141,8 +151,9 @@ export const TokenRequest = z.object({ .string() .optional() .describe( - 'Platform proof, required for cached_login and platform create_account. Steam: ' + - '`{"Ticket":"","AppId":…}`. Meta: `{"Nonce":…,"AppId":…,"Source":…}`' + 'Platform proof, required for cached_login and platform create_account, and used to ' + + 'link the identity on a password grant. Steam: `{"Ticket":"","AppId":…}`. ' + + 'Meta: `{"Nonce":…,"AppId":…,"Source":…}`' ), refresh_token: z.string().optional().describe('Required on a refresh_token grant'), device_id: z diff --git a/apps/auth/src/platform-db.ts b/apps/auth/src/platform-db.ts new file mode 100644 index 0000000..4fbcc30 --- /dev/null +++ b/apps/auth/src/platform-db.ts @@ -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 { + 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 { + 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() + 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 { + if (platformId === '') return [] + const { results } = await db + .prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`) + .bind(platformId) + .all() + 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 { + const { results } = await db + .prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`) + .bind(accountId) + .all() + 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 { + 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 { + 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 +} diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 94f1be2..a6e5658 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -14,7 +14,12 @@ import { SUBROOM_SCHEMA_DDL, } 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 type { Env } from '../../context' @@ -51,6 +56,9 @@ beforeAll(async () => { 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 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. 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(path: string): Promise { + 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 & { accountId: number; platform: number }>>( + `/cachedlogin/forplatformid/${platform}/${id}` + ) +} + /** The `platform_auth` payload a Meta client posts, as observed from a live login. */ function metaPlatformAuth(): string { 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. const payload = decodePayload(res.json.access_token as string) const accountId = Number(payload.sub) - const lookup = await exports.default.fetch( - `${ORIGIN}/cachedlogin/forplatformid/1/${META_USER_ID}` - ) - const linked = (await lookup.json()) as Array> + const linked = await cachedLogins(1, META_USER_ID) expect(linked).toContainEqual( expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID }) ) @@ -285,6 +303,7 @@ describe('auth worker routes', () => { }) ) .run() + await linkPlatformIdentity(env.DB, 5150, 1, userId) const res = await metaLogin( `grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` + `&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 () => { // 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 - // account's stored one. + // Meta user. Even with a nonce Meta vouches for, the identity has to be one the + // account is actually linked to. const res = await metaLogin( `grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` + `&platform_auth=${encodeURIComponent(metaPlatformAuth())}`, @@ -345,6 +364,7 @@ describe('auth worker routes', () => { }) ) .run() + await linkPlatformIdentity(env.DB, 31380, 0, steamId) const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`) expect(res.status).toBe(200) 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 () => { - // Regression: nothing defaults an account's `platform` (see defaultAccount), so a - // Steam-linked account can carry a platformId with no platform. The picker offered - // such an account (it treats a missing platform as Steam) while the cached_login - // grant rejected it — "no linked account for this platform identity" forever. - // Both now run the same check. + test('one account, a Steam and a Meta identity: both pickers offer it', async () => { + // The point of the link table. The same account is reachable from the PC and from + // the headset, and each picker reports the identity IT was asked about — that's + // what the client posts back on the cached_login grant. + const steamId = '76561197962463777' + 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 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)') .bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId })) .run() - const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`) - const offered = (await res.json()) as Array<{ accountId: number; platform: number }> - expect(offered.map((a) => a.accountId)).toContain(8) - expect(offered.find((a) => a.accountId === 8)?.platform).toBe(0) + + // No link row yet: not offered. + const before = await cachedLogins(0, steamId) + 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 () => { @@ -720,6 +777,137 @@ describe('auth worker routes', () => { 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 () => { const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`) const refreshToken = login.json.refresh_token as string diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 019b073..3173910 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -37,12 +37,14 @@ export interface Account { identityFlags: number createdAt: string /** - * The platform-native identity linked to this account (e.g. a SteamID64 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 - * `platform_id`. A cached login is authorized ONLY to the account whose stored - * `platformId` matches the (platform_auth-ticket-proven) platform id presented, - * so no one but that platform user can log into the account. + * The account's PRIMARY platform identity — the first one linked (e.g. a SteamID64 + * for platform 0). Stored as a STRING on purpose: a SteamID64 exceeds 2^53 and + * would lose precision as a JS number. + * + * An account can be reachable from SEVERAL platform identities (a PC and a headset), + * 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 /** PlatformType int (0 = Steam) that `platformId` belongs to. */ @@ -216,23 +218,6 @@ export async function searchAccounts( 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 { - if (platformId === '') return [] - const { results } = await db - .prepare('SELECT data FROM account WHERE platform_id = ?1') - .bind(platformId) - .all() - return parseAll(results) -} - /** * 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 @@ -258,7 +243,9 @@ export async function getAccountsByDeviceId(db: D1Database, deviceId: string): P /** Record the account's most recent successful login time (ISO-8601). */ export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise { 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) .run() } @@ -295,9 +282,7 @@ export async function setLoginContext( } if (sets.length === 0) return await db - .prepare( - `UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1` - ) + .prepare(`UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1`) .bind(id, ...binds) .run() } @@ -324,21 +309,6 @@ export async function countAccountsBySignupIp(db: D1Database, ip: string): Promi 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 { - 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). */ export async function getAccountsByIds(db: D1Database, ids: number[]): Promise { if (ids.length === 0) return [] @@ -411,9 +381,7 @@ export async function getPasswordHash(db: D1Database, id: number): Promise { const { meta } = await db - .prepare( - "UPDATE account SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1" - ) + .prepare("UPDATE account SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1") .bind(id, hash) .run() return meta.changes > 0