diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index b34c4c3..76742ff 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -20,7 +20,7 @@ beforeAll(async () => { // Seed the shared JWT signing key into the local Secrets Store so .get() resolves. await adminSecretsStore(env.JWT_SECRET).create('test-signing-key') for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() - const insert = env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)') + const insert = env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') await env.DB.batch([ insert.bind(JSON.stringify({ accountId: 0, username: 'RecRoom', displayName: 'Rec Room' })), insert.bind(JSON.stringify({ accountId: 1, username: 'Coach', displayName: 'Coach' })), diff --git a/apps/api/src/images-db.ts b/apps/api/src/images-db.ts index eb7eb53..ea47d81 100644 --- a/apps/api/src/images-db.ts +++ b/apps/api/src/images-db.ts @@ -179,7 +179,7 @@ async function getUsernames(db: D1Database, ids: number[]): Promise() diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index fc5628f..95e7a31 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -73,7 +73,7 @@ export const imageRoutes = new Hono({ strict: false }) // account row (a JSON blob in the shared accounts table) so it sticks. if (savedImageType === SavedImageType.ProfileThumbnail) { await c.env.DB.prepare( - "UPDATE accounts SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1" + "UPDATE account SET data = json_set(data, '$.profileImage', ?2) WHERE account_id = ?1" ) .bind(id, name) .run() diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 94b50b1..aa64fa8 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -55,13 +55,13 @@ beforeAll(async () => { // profile thumbnails on the account row. Seed the account the test token (sub // 42) authenticates as. await env.DB.prepare( - `CREATE TABLE IF NOT EXISTS accounts ( + `CREATE TABLE IF NOT EXISTS account ( data TEXT NOT NULL, account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL, username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL )` ).run() - await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)') + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') .bind( JSON.stringify({ accountId: 42, username: 'Tester', profileImage: 'DefaultProfileImage.jpg' }) ) @@ -477,7 +477,7 @@ describe('images', () => { ) // The account row now points its profileImage at the uploaded key. - const row = await env.DB.prepare('SELECT data FROM accounts WHERE account_id = 42').first<{ + const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = 42').first<{ data: string }>() expect(JSON.parse(row!.data).profileImage).toBe(ImageName) diff --git a/apps/auth/migrations/0004_platform_id.sql b/apps/auth/migrations/0004_platform_id.sql new file mode 100644 index 0000000..04abdd7 --- /dev/null +++ b/apps/auth/migrations/0004_platform_id.sql @@ -0,0 +1,8 @@ +-- Link accounts to their platform-native identity (e.g. a SteamID64 for platform 0) +-- and index it so /cachedlogin/forplatformid can look accounts up by platform id. +-- platformId lives in the JSON blob (stored as a string — a SteamID64 exceeds 2^53 +-- and would lose precision as a number); this exposes it as an indexed generated +-- column. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts. + +ALTER TABLE accounts ADD COLUMN platform_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.platformId')) VIRTUAL; +CREATE INDEX IF NOT EXISTS idx_accounts_platform_id ON accounts (platform_id); diff --git a/apps/auth/migrations/0005_rename_account.sql b/apps/auth/migrations/0005_rename_account.sql new file mode 100644 index 0000000..c795fa1 --- /dev/null +++ b/apps/auth/migrations/0005_rename_account.sql @@ -0,0 +1,7 @@ +-- Rename the `accounts` table to `account`, matching the singular naming of the +-- other tables (`room`, `interaction`, `room_instance`, `image`, `club`). SQLite +-- carries the generated columns and the idx_accounts_* indexes over to the renamed +-- table automatically, so this is the whole change. SCHEMA_DDL in @repo/domain's +-- accounts-db.ts already creates `account`. + +ALTER TABLE accounts RENAME TO account; diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 7dde9a1..0e4cf8d 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -3,9 +3,12 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { createAccount, + getAccount, getAccountByUsername, + getAccountsByPlatformId, getPasswordHash, RoomInstanceType, + setLastLoginTime, setPasswordHash, } from '@repo/domain' import { logger, withNotFound, withOnError } from '@repo/hono-helpers' @@ -13,7 +16,9 @@ import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo import { hashPassword, verifyPassword } from './password' import { consumeRefreshToken, issueRefreshToken } from './refresh-db' +import { verifySteamTicket } from './steam-ticket' +import type { Account } from '@repo/domain' import type { Context } from 'hono' import type { App } from './context' @@ -105,6 +110,22 @@ async function authedId(c: Context): Promise { return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } +/** + * 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 platform_auth ticket) is the credential for a cached login — no prompt. + */ +function toCachedLogin(account: Account) { + return { + platform: account.platform ?? 0, + platformId: account.platformId ?? '', + accountId: account.accountId, + lastLoginTime: account.lastLoginTime ?? account.createdAt, + requirePassword: false, + } +} + const app = new Hono() .use( '*', @@ -122,19 +143,36 @@ const app = new Hono() // EAC challenge — a fresh GUID, JSON-quoted, served as plain text. .get('/eac/challenge', (c) => c.text(`"AA=="`)) - // Cached logins for a platform id. No CachedLogins storage yet, so there's never - // a cached account — return []. The client then goes through a fresh login / - // create_account instead of auto-logging into a stub account. - .get('/cachedlogin/forplatformid/:platform/:id', (c) => { + // Cached logins for a platform id — the accounts linked to this platform-native + // id, so the client can offer them on the login screen (and post one back as a + // cached_login grant). No linked account → [], and the client falls back to a + // fresh login / create_account. + .get('/cachedlogin/forplatformid/:platform/:id', async (c) => { const { platform, id } = c.req.param() logger.info('cached login lookup', { platform, id }) - // TODO: query CachedLogins once they're persisted. - return c.json([]) + const platformInt = Number.parseInt(platform, 10) + const accounts = await getAccountsByPlatformId(c.env.DB, id) + return c.json( + accounts + .filter((a) => Number.isNaN(platformInt) || (a.platform ?? 0) === platformInt) + .map(toCachedLogin) + ) }) - // Bulk cached-login lookup by platform id (friends resolution). The client - // POSTs repeated `id=` params on the auth host; no DB → no matches → []. - .post('/cachedlogin/forplatformids', (c) => c.json([])) + // Bulk cached-login lookup by platform id (friends resolution). The client POSTs + // repeated `id=` params on the auth host; resolve each to its linked accounts. + .post('/cachedlogin/forplatformids', async (c) => { + const body = await c.req + .parseBody({ all: true }) + .catch(() => ({}) as Record) + const raw = body.id + const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String) + const out: Array> = [] + for (const pid of ids) { + out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin)) + } + return c.json(out) + }) // OAuth token endpoint — accepts a form-urlencoded body and issues a JWT. .post('/connect/token', async (c) => { @@ -149,6 +187,40 @@ const app = new Hono() const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '') + // A platform-authenticated login proves who you are with the platform itself, + // and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth + // ticket. So those logins must be Steam: + // - cached_login authenticates purely by platform identity → always Steam-only. + // - create_account that asserts a platform is rejected unless it's Steam, since + // we won't bind an identity we can't prove. (create_account with NO platform + // is the password-account path — allowed, but it binds no platformId.) + // The verified SteamID64 replaces the unauthenticated `platform_id` field and 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. + let verifiedSteamId: string | null = null + const platformAsserted = !Number.isNaN(platformInt) + if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) { + if (platformInt !== 0) { + return c.json( + { + error: 'invalid_grant', + error_description: 'unsupported platform; only Steam can be verified', + }, + 400 + ) + } + const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : '' + const verified = platformAuth ? await verifySteamTicket(platformAuth) : null + if (!verified) { + return c.json( + { error: 'invalid_grant', error_description: 'invalid or missing platform_auth ticket' }, + 400 + ) + } + verifiedSteamId = verified.steamId + platformId = verified.steamId + } + // 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. @@ -163,7 +235,16 @@ const app = new Hono() // via create_account or /account/me/changepassword. let accountId: string if (grantType === 'create_account') { - const account = await createAccount(c.env.DB, { platforms: platformInt || 0 }) + // Bind the platform identity ONLY when a Steam ticket proved it. That bound + // `platformId` (the SteamID64) is what a later cached login is checked against, + // so only this Steam user can log back into the account. A password/anonymous + // create_account (no platform) binds no platformId. + const account = await createAccount(c.env.DB, { + platforms: platformInt || 0, + platform: verifiedSteamId !== null ? 0 : undefined, + platformId: verifiedSteamId ?? undefined, + lastLoginTime: new Date().toISOString(), + }) accountId = String(account.accountId) // Establish the login password when one is posted (raw password never stored). const password = typeof body.password === 'string' ? body.password : '' @@ -184,6 +265,32 @@ const app = new Hono() accountId = String(refreshed.accountId) platform = refreshed.platform platformId = refreshed.platformId + } 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. + // + // NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket + // above), never the client-supplied field. See steam-ticket.ts. + const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : '' + const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null + if ( + !account || + !account.platformId || + account.platformId !== platformId || + account.platform !== platformInt + ) { + return c.json( + { error: 'invalid_grant', error_description: 'no linked account for this platform identity' }, + 400 + ) + } + accountId = String(account.accountId) + await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString()) } else { // Resolve the account from a posted numeric `account_id` or, as RecRoom's // password grant sends, a `username` (case-insensitive; trailing whitespace @@ -214,6 +321,7 @@ const app = new Hono() ) } accountId = String(resolvedId) + await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString()) } const accessToken = await generateToken( diff --git a/apps/auth/src/steam-ticket.ts b/apps/auth/src/steam-ticket.ts new file mode 100644 index 0000000..27987cc --- /dev/null +++ b/apps/auth/src/steam-ticket.ts @@ -0,0 +1,201 @@ +/** + * Offline verification of a Steam `platform_auth` ticket, Worker-native. + * + * A Steam login posts `platform_auth = {"Ticket":"","AppId":"471710"}`. The + * ticket's ownership section is signed by Steam's "System" RSA key (RSA-SHA1), so + * we can verify it OFFLINE — no publisher Web API key, no network — and trust the + * SteamID64 it carries. The auth worker binds/authorizes accounts against THAT + * SteamID rather than the unauthenticated client-supplied `platform_id` field, so + * only the Steam user who owns the account can log into it. + * + * The byte layout and signed-region boundaries follow DoctorMcKay's steam-appticket + * (github.com/DoctorMcKay/node-steam-appticket). We reimplement the parse + verify + * here because that package reads its key via `fs` and verifies via `node:crypto`, + * neither of which is available in workerd — we use `crypto.subtle` instead. + */ + +/** + * Steam "System" public RSA key (SPKI DER, base64), from @doctormckay/steam-crypto's + * `system.pem`. Steam signs every app-ownership ticket with the matching private key. + */ +const STEAM_SYSTEM_PUBLIC_KEY_SPKI = + 'MIGdMA0GCSqGSIb3DQEBAQUAA4GLADCBhwKBgQDf7BrWLBBmLBc1OhSwfFkRf53T' + + '2Ct64+AVzRkeRuh7h3SiGEYxqQMUeYKO6UWiSRKpI2hzic9pobFhRr3Bvr/WARvY' + + 'gdTckPv+T1JzZsuVcNfFjrocejN1oWI0Rrtgt4Bo+hOneoo3S57G9F1fOpn5nsQ6' + + '6WOiu4gZKODnFMBCiQIBEQ==' + +let cachedKey: Promise | null = null +function steamPublicKey(): Promise { + cachedKey ??= crypto.subtle.importKey( + 'spki', + Uint8Array.from(atob(STEAM_SYSTEM_PUBLIC_KEY_SPKI), (ch) => ch.charCodeAt(0)), + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-1' }, + false, + ['verify'] + ) + return cachedKey +} + +/** Decode a hex string to bytes, or null when it isn't valid hex. */ +function hexToBytes(hex: string): Uint8Array | null { + if (hex.length === 0 || hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) return null + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + return out +} + +/** Little-endian cursor over a ticket buffer. */ +class Reader { + private pos = 0 + constructor(private readonly view: DataView) {} + get offset(): number { + return this.pos + } + skip(n: number): void { + this.pos += n + } + u16(): number { + const v = this.view.getUint16(this.pos, true) + this.pos += 2 + return v + } + u32(): number { + const v = this.view.getUint32(this.pos, true) + this.pos += 4 + return v + } + u64(): bigint { + const v = this.view.getBigUint64(this.pos, true) + this.pos += 8 + return v + } +} + +/** Parsed fields plus the byte range covered by the RSA signature. */ +export interface SteamTicket { + steamId: string + appId: number + /** Ownership-ticket expiry, ms since epoch (0 when absent). */ + expiresAt: number + /** Signed region `[start, end)` and the 128-byte signature over it. */ + signedStart: number + signedEnd: number + signature: Uint8Array +} + +/** + * Parse a Steam app/session ticket into its fields WITHOUT verifying the signature. + * Returns null when the buffer isn't a well-formed, signed ticket. + */ +export function parseSteamTicket(buf: Uint8Array): SteamTicket | null { + const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength) + const r = new Reader(view) + const limit = buf.byteLength + try { + const initialLength = r.u32() + if (initialLength === 20) { + // Full ticket: GC token + session header precede the ownership ticket. + r.skip(8) // gcToken + r.skip(8) // steamID (read from the ownership section below instead) + r.u32() // tokenGenerated + if (r.u32() !== 24) return null // session header length + r.skip(8) // unknown1, unknown2 + r.u32() // session external IP + r.skip(4) // filler + r.u32() // client connection time + r.u32() // client connection count + if (r.u32() + r.offset !== limit) return null // ownership-section length check + } else { + r.skip(-4) // bare ownership ticket — rewind the length we just read + } + + const ownershipTicketOffset = r.offset + const ownershipTicketLength = r.u32() // includes itself + if ( + ownershipTicketOffset + ownershipTicketLength !== limit && + ownershipTicketOffset + ownershipTicketLength + 128 !== limit + ) { + return null + } + + r.u32() // version + const steamId = r.u64().toString() + const appId = r.u32() + r.u32() // ownership external IP + r.u32() // ownership internal IP + r.u32() // flags + r.u32() // generated + const expiresAt = r.u32() * 1000 + + const licenseCount = r.u16() + for (let i = 0; i < licenseCount; i++) r.u32() + const dlcCount = r.u16() + for (let i = 0; i < dlcCount; i++) { + r.u32() // dlc appID + const dlcLicenseCount = r.u16() + for (let j = 0; j < dlcLicenseCount; j++) r.u32() + } + r.u16() // reserved + + if (r.offset + 128 !== limit) return null // require a signature + return { + steamId, + appId, + expiresAt, + signedStart: ownershipTicketOffset, + signedEnd: ownershipTicketOffset + ownershipTicketLength, + signature: buf.subarray(r.offset, r.offset + 128), + } + } catch { + return null // ran off the end / malformed + } +} + +/** Verify a parsed ticket's ownership signature against Steam's System public key. */ +export async function verifySteamTicketSignature( + buf: Uint8Array, + ticket: SteamTicket +): Promise { + return crypto.subtle.verify( + 'RSASSA-PKCS1-v1_5', + await steamPublicKey(), + ticket.signature, + buf.subarray(ticket.signedStart, ticket.signedEnd) + ) +} + +/** The trustworthy identity proven by a verified Steam ticket. */ +export interface VerifiedSteamIdentity { + steamId: string + appId: number +} + +/** + * Verify a Steam `platform_auth` payload and return the SteamID64 it proves, or + * null when the payload is missing/malformed, expired, or its signature doesn't + * verify. Only ever returns a SteamID that Steam itself signed. `now` (ms since + * epoch, defaulting to the current time) is the instant expiry is checked against; + * it's a parameter so tests can pin it to a captured ticket's validity window. + */ +export async function verifySteamTicket( + platformAuth: string, + now: number = Date.now() +): Promise { + let ticketHex: string + try { + const parsed = JSON.parse(platformAuth) as { Ticket?: unknown } + if (typeof parsed.Ticket !== 'string') return null + ticketHex = parsed.Ticket + } catch { + return null + } + + const buf = hexToBytes(ticketHex) + if (!buf) return null + const ticket = parseSteamTicket(buf) + if (!ticket) return null + if (ticket.expiresAt !== 0 && ticket.expiresAt < now) return null // expired + if (!(await verifySteamTicketSignature(buf, ticket))) return null + + return { steamId: ticket.steamId, appId: ticket.appId } +} diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 781fa71..f11d443 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -36,7 +36,7 @@ beforeAll(async () => { // Seed the accounts the credential-login tests use, each with LOGIN_PASSWORD set. const hash = await hashPassword(LOGIN_PASSWORD) for (const id of [42, 77]) { - await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)') + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') .bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash })) .run() } @@ -116,6 +116,79 @@ describe('auth worker routes', () => { expect(await res.json()).toEqual([]) }) + // Only Steam (platform 0) can be verified (via its signed platform_auth ticket), + // so every OTHER platform is rejected on the platform-authenticated grants — we + // won't bind or authorize an identity we can't prove. + test.each([1, 2, 3, 4, 5, 6, 7, 8])( + 'create_account rejects unverifiable platform %i', + async (platform) => { + const res = await postToken( + `grant_type=create_account&platform=${platform}&platform_id=whoever` + ) + expect(res.status).toBe(400) + expect(res.json.error).toBe('invalid_grant') + expect(res.json.error_description).toContain('only Steam') + } + ) + + test.each([1, 2, 3, 4, 5, 6, 7, 8])( + 'cached_login rejects unverifiable platform %i', + async (platform) => { + const res = await postToken( + `grant_type=cached_login&account_id=42&platform=${platform}&platform_id=whoever` + ) + expect(res.status).toBe(400) + expect(res.json.error).toBe('invalid_grant') + expect(res.json.error_description).toContain('only Steam') + } + ) + + test('Steam create_account requires a valid platform_auth ticket', async () => { + // platform=0 (Steam) with no verifiable ticket must not bind the spoofable + // platform_id field — it's rejected outright. + const res = await postToken('grant_type=create_account&platform=0&platform_id=76561197962463211') + expect(res.status).toBe(400) + expect(res.json.error).toBe('invalid_grant') + expect(res.json.error_description).toContain('platform_auth') + }) + + test('Steam cached_login requires a valid platform_auth ticket', async () => { + const res = await postToken( + 'grant_type=cached_login&account_id=42&platform=0&platform_id=76561197962463211' + ) + expect(res.status).toBe(400) + expect(res.json.error).toBe('invalid_grant') + expect(res.json.error_description).toContain('platform_auth') + }) + + test('cachedlogin/forplatformid returns the DTO for a bound (Steam) account', async () => { + // Seed a Steam-linked account directly (a real create_account needs a live + // ticket); assert the picker projects the CachedLogin DTO the client expects. + const steamId = '76561197962463299' + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId: 31380, + username: 'SteamPlayer', + platform: 0, + platformId: steamId, + lastLoginTime: '2026-07-09T21:20:31.419Z', + }) + ) + .run() + const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([ + { + platform: 0, + platformId: steamId, + accountId: 31380, + lastLoginTime: '2026-07-09T21:20:31.419Z', + requirePassword: false, + }, + ]) + }) + test('POST /connect/token issues a bearer token with role/scope claims', async () => { const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST', @@ -179,7 +252,7 @@ describe('auth worker routes', () => { test('POST /connect/token refuses login to an account with no password set', async () => { // Account 999 exists but never set a password — it has no credential to verify, // so login by id alone is refused (this is the closed takeover hole). - await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)') + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') .bind(JSON.stringify({ accountId: 999, username: 'NoPass' })) .run() const res = await postToken('account_id=999&password=anything') @@ -228,7 +301,7 @@ describe('auth worker routes', () => { const sub = Number.parseInt(payload.sub as string, 10) expect(sub).toBeGreaterThanOrEqual(2) // The account exists in the DB with an auto-assigned (non-default) username. - const row = await env.DB.prepare('SELECT data FROM accounts WHERE account_id = ?1') + const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1') .bind(sub) .first<{ data: string }>() expect(row).not.toBeNull() diff --git a/apps/auth/src/test/integration/steam-ticket.test.ts b/apps/auth/src/test/integration/steam-ticket.test.ts new file mode 100644 index 0000000..a533438 --- /dev/null +++ b/apps/auth/src/test/integration/steam-ticket.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'vitest' + +import { + parseSteamTicket, + verifySteamTicket, + verifySteamTicketSignature, +} from '../../steam-ticket' + +// A real Steam session ticket captured from a live login: ownership section signed +// by Steam, steamID64 76561197962463211, appID 471710 (Rec Room on Steam). +const TICKET_HEX = + '14000000D6BCAD355A77A1C1EB872100010010012522516A1800000001000000020000005FD1B15ADC400B3B069BB24D80010000B20000003200000004000000EB872100010010019E3207002239346C2101A8C00000000008CE4B6A887D676A0100DF97010000000000596381B1BB1AA2197EF13223E62CCE95AAEC0BB48EF50FF74AE88A4D50CF17BEF363A35307C917E3B4173B54B293D3BD8A270DF25C7713E4FB5AF170FBC531DBE76D86DF1BBE8F7EE91D2A357AA7AAEDBFA4A0E5BC6F1F541C98C5C682E685357722CB82C70BEB6F4152A2CD142541BF130CFD6601D75B1418BE58E5B3DA2CCE' + +const hex = (s: string) => Uint8Array.from(s.match(/../g)!.map((b) => Number.parseInt(b, 16))) + +describe('steam-ticket', () => { + test('parses the ticket fields', () => { + const t = parseSteamTicket(hex(TICKET_HEX)) + expect(t).not.toBeNull() + expect(t!.steamId).toBe('76561197962463211') + expect(t!.appId).toBe(471710) + expect(t!.signature).toHaveLength(128) + }) + + test('verifies the Steam-signed ownership signature (real key, WebCrypto)', async () => { + const buf = hex(TICKET_HEX) + const t = parseSteamTicket(buf)! + expect(await verifySteamTicketSignature(buf, t)).toBe(true) + }) + + test('rejects a ticket whose signed bytes were tampered with', async () => { + const buf = hex(TICKET_HEX) + const t = parseSteamTicket(buf)! + buf[t.signedStart + 4] ^= 0xff // flip a byte inside the signed region + expect(await verifySteamTicketSignature(buf, t)).toBe(false) + }) + + test('verifySteamTicket returns the proven identity for a valid, unexpired ticket', async () => { + const { expiresAt } = parseSteamTicket(hex(TICKET_HEX))! + const payload = JSON.stringify({ Ticket: TICKET_HEX, AppId: '471710' }) + // Pin `now` to just inside the ticket's validity window so the test is durable. + expect(await verifySteamTicket(payload, expiresAt - 1000)).toEqual({ + steamId: '76561197962463211', + appId: 471710, + }) + }) + + test('verifySteamTicket rejects an expired ticket', async () => { + const { expiresAt } = parseSteamTicket(hex(TICKET_HEX))! + const payload = JSON.stringify({ Ticket: TICKET_HEX, AppId: '471710' }) + expect(await verifySteamTicket(payload, expiresAt + 1000)).toBeNull() + }) + + test('verifySteamTicket returns null for malformed payloads', async () => { + expect(await verifySteamTicket('not json')).toBeNull() + expect(await verifySteamTicket('{}')).toBeNull() + expect(await verifySteamTicket(JSON.stringify({ Ticket: 'zzzz' }))).toBeNull() + expect(await verifySteamTicket(JSON.stringify({ Ticket: '1400' }))).toBeNull() + }) +}) diff --git a/apps/econ/src/avatar-db.ts b/apps/econ/src/avatar-db.ts index eb3d5b0..d672fe6 100644 --- a/apps/econ/src/avatar-db.ts +++ b/apps/econ/src/avatar-db.ts @@ -11,13 +11,13 @@ /** Schema DDL for tests — the accounts table including the avatar column. */ export const SCHEMA_DDL: string[] = [ - `CREATE TABLE IF NOT EXISTS accounts ( + `CREATE TABLE IF NOT EXISTS account ( data TEXT NOT NULL, avatar TEXT, account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL, username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL )`, - `CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON accounts (account_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`, ] /** The stored avatar payload — opaque JSON the client sets and reads back. */ @@ -30,7 +30,7 @@ interface AvatarRow { /** Read the player's stored avatar, or null when they have none yet. */ export async function getAvatar(db: D1Database, accountId: number): Promise { const row = await db - .prepare('SELECT avatar FROM accounts WHERE account_id = ?1') + .prepare('SELECT avatar FROM account WHERE account_id = ?1') .bind(accountId) .first() return row?.avatar ? (JSON.parse(row.avatar) as Avatar) : null @@ -46,7 +46,7 @@ export async function setAvatar( avatar: Avatar ): Promise { const { meta } = await db - .prepare('UPDATE accounts SET avatar = ?2 WHERE account_id = ?1') + .prepare('UPDATE account SET avatar = ?2 WHERE account_id = ?1') .bind(accountId, JSON.stringify(avatar)) .run() return meta.changes > 0 diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 6f5d93d..a8e24cf 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -20,7 +20,7 @@ beforeAll(async () => { // Seed the shared JWT signing key into the local Secrets Store so .get() resolves. await adminSecretsStore(env.JWT_SECRET).create('test-signing-key') for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() - await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)') + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') .bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' })) .run() }) @@ -161,10 +161,10 @@ describe('econ endpoints', () => { test('GET /api/avatar/v2/:id returns another player’s saved avatar, projected', async () => { // Seed account 314 with a full avatar blob (superset of the projection). - await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)') + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') .bind(JSON.stringify({ accountId: 314, username: 'Pi', displayName: 'Pi' })) .run() - await env.DB.prepare('UPDATE accounts SET avatar = ?2 WHERE account_id = ?1') + await env.DB.prepare('UPDATE account SET avatar = ?2 WHERE account_id = ?1') .bind( 314, JSON.stringify({ diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index c83ada7..cddab8e 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -62,12 +62,12 @@ beforeAll(async () => { // Accounts table (owned by the auth worker) — dorm creation reads the username // to name the room. Seed the players the dorm tests authenticate as. await env.DB.prepare( - `CREATE TABLE IF NOT EXISTS accounts ( + `CREATE TABLE IF NOT EXISTS account ( data TEXT NOT NULL, account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL )` ).run() - const insertAccount = env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)') + const insertAccount = env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') await env.DB.batch([ insertAccount.bind(JSON.stringify({ accountId: 42, username: 'Tester' })), insertAccount.bind(JSON.stringify({ accountId: 43, username: 'Roomie' })), diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 2424de4..c65310c 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -13,14 +13,16 @@ /** Schema DDL (mirror of migrations 0001_accounts + 0002_avatar, sans seed INSERTs). */ export const SCHEMA_DDL: string[] = [ - `CREATE TABLE IF NOT EXISTS accounts ( + `CREATE TABLE IF NOT EXISTS account ( data TEXT NOT NULL, avatar TEXT, account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL, - username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL + username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL, + platform_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.platformId')) VIRTUAL )`, - `CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON accounts (account_id)`, - `CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON accounts (username_lower)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`, + `CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON account (username_lower)`, + `CREATE INDEX IF NOT EXISTS idx_accounts_platform_id ON account (platform_id)`, ] /** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */ @@ -34,6 +36,19 @@ export interface Account { personalPronouns: number 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. + */ + platformId?: string + /** PlatformType int (0 = Steam) that `platformId` belongs to. */ + platform?: number + /** ISO-8601 time of the account's most recent successful login. */ + lastLoginTime?: string /** Set via POST /account/me/email; absent until the player provides one. */ email?: string /** Set via POST /account/me/phone; absent until the player provides one. */ @@ -127,7 +142,7 @@ export function defaultAccount(id: number, overrides: Partial = {}): Ac /** Look up a single account by AccountId. */ export async function getAccount(db: D1Database, id: number): Promise { return parseOne( - await db.prepare('SELECT data FROM accounts WHERE account_id = ?1').bind(id).first() + await db.prepare('SELECT data FROM account WHERE account_id = ?1').bind(id).first() ) } @@ -138,7 +153,7 @@ export async function getAccountByUsername( ): Promise { return parseOne( await db - .prepare('SELECT data FROM accounts WHERE username_lower = ?1') + .prepare('SELECT data FROM account WHERE username_lower = ?1') .bind(username.toLowerCase()) .first() ) @@ -164,19 +179,44 @@ export async function searchAccounts( if (q === '') return [] const { results } = await db .prepare( - `SELECT data FROM accounts WHERE username_lower LIKE ?1 ESCAPE '\\' ORDER BY username_lower LIMIT ?2` + `SELECT data FROM account WHERE username_lower LIKE ?1 ESCAPE '\\' ORDER BY username_lower LIMIT ?2` ) .bind(`${escapeLike(q)}%`, limit) .all() 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) +} + +/** 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") + .bind(id, time) + .run() +} + /** Look up multiple accounts by AccountId (order not guaranteed). */ export async function getAccountsByIds(db: D1Database, ids: number[]): Promise { if (ids.length === 0) return [] const placeholders = ids.map((_, i) => `?${i + 1}`).join(',') const { results } = await db - .prepare(`SELECT data FROM accounts WHERE account_id IN (${placeholders})`) + .prepare(`SELECT data FROM account WHERE account_id IN (${placeholders})`) .bind(...ids) .all() return parseAll(results) @@ -197,11 +237,11 @@ export async function updateAccount( const updated: Account = { ...current, ...overrides, accountId: id } const data = JSON.stringify(updated) const res = await db - .prepare('UPDATE accounts SET data = ?2 WHERE account_id = ?1') + .prepare('UPDATE account SET data = ?2 WHERE account_id = ?1') .bind(id, data) .run() if (!res.meta.changes) { - await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(data).run() + await db.prepare('INSERT INTO account (data) VALUES (?1)').bind(data).run() } return updated } @@ -216,12 +256,12 @@ export async function createAccount( overrides: Partial = {} ): Promise { const row = await db - .prepare('SELECT COALESCE(MAX(account_id), 1) + 1 AS next FROM accounts') + .prepare('SELECT COALESCE(MAX(account_id), 1) + 1 AS next FROM account') .first<{ next: number }>() const id = row?.next ?? 2 const username = overrides.username ?? randomUsername() const account = defaultAccount(id, { username, displayName: username, ...overrides }) - await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(JSON.stringify(account)).run() + await db.prepare('INSERT INTO account (data) VALUES (?1)').bind(JSON.stringify(account)).run() return account } @@ -233,7 +273,7 @@ export async function createAccount( export async function getPasswordHash(db: D1Database, id: number): Promise { const row = await db .prepare( - "SELECT json_extract(data, '$.passwordHash') AS hash FROM accounts WHERE account_id = ?1" + "SELECT json_extract(data, '$.passwordHash') AS hash FROM account WHERE account_id = ?1" ) .bind(id) .first<{ hash: string | null }>() @@ -244,7 +284,7 @@ export async function getPasswordHash(db: D1Database, id: number): Promise { const { meta } = await db .prepare( - "UPDATE accounts 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) .run() diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 71574a3..cd131ac 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -757,7 +757,7 @@ const DORM_TEMPLATE_ROOM_ID = 1 /** A player's username from the shared accounts table (for naming their dorm), or null. */ export async function getUsername(db: D1Database, accountId: number): Promise { const row = await db - .prepare('SELECT data FROM accounts WHERE account_id = ?1') + .prepare('SELECT data FROM account WHERE account_id = ?1') .bind(accountId) .first<{ data: string }>() if (!row) return null