From 06c9f8f4096aa994b522461f8aa37be3496c3697 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 13 Jul 2026 01:31:40 -0400 Subject: [PATCH] fix platform link login --- apps/auth/src/auth.app.ts | 53 ++++++++++++++++------ apps/auth/src/test/integration/api.test.ts | 37 ++++++++++++++- 2 files changed, 75 insertions(+), 15 deletions(-) diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 24cce15..860d0d1 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -19,8 +19,8 @@ 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 { Account } from '@repo/domain' import type { App } from './context' /** OAuth scopes granted by `/connect/token`. */ @@ -107,6 +107,35 @@ async function authedId(c: Context): Promise { return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } +/** + * The platform an account's `platformId` belongs to. Nothing defaults the `platform` + * field (see defaultAccount), so an account can carry a platform identity with no + * platform recorded — and Steam is the only platform whose identity we can prove, so + * an unset one *is* Steam. + */ +function accountPlatform(account: Pick): number { + return account.platform ?? 0 +} + +/** + * Whether an account is the one linked to a given platform identity — the single + * check behind both the cached-login picker and the `cached_login` grant. It lives in + * one place on purpose: if the picker offers an account the grant then rejects, the + * client is handed an `account_id` it can never log into ("no linked account for this + * platform identity" on every attempt). + * + * `platformId` must be the *proven* identity (the SteamID64 from a verified + * platform_auth ticket), never the client-supplied `platform_id` field. + */ +export function isLinkedToPlatformIdentity( + account: Pick, + platform: number, + platformId: string +): boolean { + if (!account.platformId || platformId === '') return false + return account.platformId === platformId && accountPlatform(account) === platform +} + /** * 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 @@ -115,7 +144,7 @@ async function authedId(c: Context): Promise { */ function toCachedLogin(account: Account) { return { - platform: account.platform ?? 0, + platform: accountPlatform(account), platformId: account.platformId ?? '', accountId: account.accountId, lastLoginTime: account.lastLoginTime ?? account.createdAt, @@ -149,9 +178,10 @@ const app = new Hono() logger.info('cached login lookup', { platform, id }) const platformInt = Number.parseInt(platform, 10) const accounts = await getAccountsByPlatformId(c.env.DB, id) + // Offer only accounts the `cached_login` grant will actually accept — same check. return c.json( accounts - .filter((a) => Number.isNaN(platformInt) || (a.platform ?? 0) === platformInt) + .filter((a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id)) .map(toCachedLogin) ) }) @@ -159,9 +189,7 @@ const app = new Hono() // 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 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> = [] @@ -273,16 +301,15 @@ const app = new Hono() // // 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 - ) { + if (!account || !isLinkedToPlatformIdentity(account, platformInt, platformId)) { return c.json( - { error: 'invalid_grant', error_description: 'no linked account for this platform identity' }, + { + error: 'invalid_grant', + error_description: 'no linked account for this platform identity', + }, 400 ) } diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index ed34c0d..d56f069 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -6,6 +6,7 @@ import '../../auth.app' import { PRESENCE_SCHEMA_DDL, SCHEMA_DDL } from '@repo/domain' +import { isLinkedToPlatformIdentity } from '../../auth.app' import { hashPassword } from '../../password' import { REFRESH_SCHEMA_DDL } from '../../refresh-db' @@ -148,7 +149,9 @@ describe('auth worker routes', () => { 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') + 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') @@ -191,6 +194,34 @@ describe('auth worker routes', () => { ]) }) + test('a Steam-linked account with no stored `platform` field still cached-logs in', async () => { + // Regression: nothing defaults an account's `platform` (see defaultAccount), so a + // Steam-linked account can carry a platformId with no platform. The picker offered + // such an account (it treats a missing platform as Steam) while the cached_login + // grant rejected it — "no linked account for this platform identity" forever. + // Both now run the same check. + const steamId = '76561197962463211' + const account = { platformId: steamId } // no `platform` field + + // The grant now accepts it — this is what was returning invalid_grant. + expect(isLinkedToPlatformIdentity(account, 0, steamId)).toBe(true) + + // The identity is still the credential: another SteamID, an account with no + // platform identity, and an account bound to a different platform are all refused. + expect(isLinkedToPlatformIdentity(account, 0, '76561197962463299')).toBe(false) + expect(isLinkedToPlatformIdentity({}, 0, steamId)).toBe(false) + expect(isLinkedToPlatformIdentity({ ...account, platform: 3 }, 0, steamId)).toBe(false) + + // And the picker offers exactly the accounts the grant accepts. + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId })) + .run() + const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`) + const offered = (await res.json()) as Array<{ accountId: number; platform: number }> + expect(offered.map((a) => a.accountId)).toContain(8) + expect(offered.find((a) => a.accountId === 8)?.platform).toBe(0) + }) + test('POST /connect/token issues a bearer token with role/scope claims', async () => { const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST', @@ -263,7 +294,9 @@ describe('auth worker routes', () => { }) test('POST /connect/token create_account can set a password used for later login', async () => { - const created = await postToken('grant_type=create_account&platform_id=steam-pw2&password=hunter2') + const created = await postToken( + 'grant_type=create_account&platform_id=steam-pw2&password=hunter2' + ) expect(created.status).toBe(200) const sub = decodePayload(created.json.access_token as string).sub as string