enforce steam ticket validation

This commit is contained in:
Devin Zuczek
2026-07-10 17:40:49 -04:00
parent 2a1e9d5d0a
commit aad184181b
15 changed files with 540 additions and 43 deletions
@@ -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' })),
+1 -1
View File
@@ -179,7 +179,7 @@ async function getUsernames(db: D1Database, ids: number[]): Promise<Map<number,
const { results } = await db
.prepare(
`SELECT account_id AS id, json_extract(data, '$.username') AS username
FROM accounts WHERE account_id IN (${placeholders(ids.length)})`
FROM account WHERE account_id IN (${placeholders(ids.length)})`
)
.bind(...ids)
.all<{ id: number; username: string }>()
+1 -1
View File
@@ -73,7 +73,7 @@ export const imageRoutes = new Hono<App>({ 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()
+3 -3
View File
@@ -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)
@@ -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);
@@ -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;
+118 -10
View File
@@ -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<App>): Promise<number | null> {
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<App>()
.use(
'*',
@@ -122,19 +143,36 @@ const app = new Hono<App>()
// 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<string, unknown>)
const raw = body.id
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
const out: Array<ReturnType<typeof toCachedLogin>> = []
for (const pid of ids) {
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<App>()
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<App>()
// 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<App>()
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<App>()
)
}
accountId = String(resolvedId)
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
}
const accessToken = await generateToken(
+201
View File
@@ -0,0 +1,201 @@
/**
* Offline verification of a Steam `platform_auth` ticket, Worker-native.
*
* A Steam login posts `platform_auth = {"Ticket":"<hex>","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<CryptoKey> | null = null
function steamPublicKey(): Promise<CryptoKey> {
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<boolean> {
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<VerifiedSteamIdentity | null> {
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 }
}
+76 -3
View File
@@ -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()
@@ -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()
})
})
+4 -4
View File
@@ -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<Avatar | null> {
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<AvatarRow>()
return row?.avatar ? (JSON.parse(row.avatar) as Avatar) : null
@@ -46,7 +46,7 @@ export async function setAvatar(
avatar: Avatar
): Promise<boolean> {
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
+3 -3
View File
@@ -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 players 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({
+2 -2
View File
@@ -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' })),
+54 -14
View File
@@ -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<Account> = {}): Ac
/** Look up a single account by AccountId. */
export async function getAccount(db: D1Database, id: number): Promise<Account | null> {
return parseOne(
await db.prepare('SELECT data FROM accounts WHERE account_id = ?1').bind(id).first<AccountRow>()
await db.prepare('SELECT data FROM account WHERE account_id = ?1').bind(id).first<AccountRow>()
)
}
@@ -138,7 +153,7 @@ export async function getAccountByUsername(
): Promise<Account | null> {
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<AccountRow>()
)
@@ -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<AccountRow>()
return parseAll(results)
}
/**
* Accounts linked to a platform-native id (e.g. a SteamID64), for the cached-login
* account picker. Backed by the indexed `platform_id` generated column. Empty id
* yields no matches (avoids matching every account whose `platformId` is null).
*/
export async function getAccountsByPlatformId(
db: D1Database,
platformId: string
): Promise<Account[]> {
if (platformId === '') return []
const { results } = await db
.prepare('SELECT data FROM account WHERE platform_id = ?1')
.bind(platformId)
.all<AccountRow>()
return parseAll(results)
}
/** Record the account's most recent successful login time (ISO-8601). */
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
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<Account[]> {
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<AccountRow>()
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<Account> = {}
): Promise<Account> {
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<string | null> {
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<strin
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
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()
+1 -1
View File
@@ -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<string | null> {
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