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