mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
enforce steam ticket validation
This commit is contained in:
@@ -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' })),
|
||||
|
||||
@@ -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 }>()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' })),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user