diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 2626120..dfefcdd 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -864,6 +864,74 @@ describe('auth worker routes', () => { expect(row!.data).not.toContain('2.0') }) + // The grant the live client actually logs in with, and the one path whose device + // recording had no test — `create_account` and the password grant were both pinned, + // cached_login only happened to work. The body is the shape the 2025 client posts (see + // the capture the device id below comes from): device_id alongside the platform + // attestation, on every sign-in. + // + // Nothing verifies a device id — the client picks it — so it is recorded and never + // authorized on. It is stored for ban EVASION to read later: an account logging in from + // the same install as a banned one. See `getAccountsByDeviceId`. + test('POST /connect/token cached_login stores the login device on the account', async () => { + const metaId = '27061366730201234' + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId: 5151, + username: 'CachedDevicePlayer', + platform: 1, + platformId: metaId, + }) + ) + .run() + await linkPlatformIdentity(env.DB, 5151, 1, metaId) + + const deviceId = '69640e6ae1b54ae5b0ca8eeb4a8872ec6cf8fd88' + const res = await metaLogin( + `grant_type=cached_login&account_id=5151&platform=1&platform_id=${metaId}` + + `&platform_auth=${encodeURIComponent(metaPlatformAuth())}` + + `&device_id=${deviceId}&device_class=2&isInitialLogin=true&locale=en`, + true + ) + expect(res.status).toBe(200) + + const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1') + .bind(5151) + .first<{ data: string }>() + const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number } + expect(account.deviceId).toBe(deviceId) + expect(account.deviceClass).toBe(2) + // And findable by it, which is the whole reason it is kept. + expect((await getAccountsByDeviceId(env.DB, deviceId)).map((a) => a.accountId)).toContain(5151) + }) + + // A refresh is NOT a login: the client redeems a stored token and re-attests nothing, + // so there is no device to believe and the grant records none. Pinned because the + // alternative is worse than it looks — trusting a device_id posted alongside a refresh + // would let a client move an account's recorded device without ever proving anything, + // which is exactly the signal ban evasion is meant to read. + test('POST /connect/token refresh_token leaves the recorded device alone', async () => { + const first = await postToken( + `grant_type=password&username=Player77&password=${LOGIN_PASSWORD}` + + '&device_id=dev-77-real&device_class=2' + ) + expect(first.status).toBe(200) + + const refreshed = await postToken( + `grant_type=refresh_token&refresh_token=${first.json.refresh_token as string}` + + '&device_id=dev-77-spoofed&device_class=9' + ) + expect(refreshed.status).toBe(200) + + const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1') + .bind(77) + .first<{ data: string }>() + const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number } + expect(account.deviceId).toBe('dev-77-real') + expect(account.deviceClass).toBe(2) + }) + test('POST /connect/token refreshes the stored device on a credential login', async () => { // Account 42 was seeded with no device; a later login records the one it came from. const res = await postToken( diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 4f1c879..f81fe7f 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -67,10 +67,23 @@ export interface Account { /** ISO-8601 time of the account's most recent successful login. */ lastLoginTime?: string /** - * The client's `device_id` from its most recent login (a stable per-install hash - * the client sends on every /connect/token). Not a credential — the client picks - * it and nothing verifies it — so never authorize on it alone. Kept so accounts - * sharing a device can be found later, e.g. for account linkup. + * The client's `device_id` from its most recent login — a stable per-install hash the + * client sends on every /connect/token, on the account-creating grant and on both + * login grants (but NOT on a refresh, which re-attests nothing). + * + * Not a credential. The client picks it and nothing verifies it, so never authorize on + * it alone; it is a SIGNAL, recorded and read later. + * + * Kept for ban EVASION: an account logging in from the same install as a banned one is + * the sharpest of the linked arms after a proven platform identity, and much narrower + * than the IP arm (a household shares an address, not an install). Nothing consumes it + * yet — see `getAccountsByDeviceId`, and the arms in the api worker's bans-db, which + * this is not one of yet. + * + * ONE value, overwritten each login: this is the account's LAST-SEEN device, not a + * history of every device it has used. So evasion matching on it will catch a player + * who evades from the install they were last banned on, and miss one who has since + * logged the banned account in somewhere else. */ deviceId?: string /** DeviceClass int (2 = PC/standalone) that `deviceId` was last seen on. */