diff --git a/apps/auth/migrations/0006_refresh_drop_platform.sql b/apps/auth/migrations/0006_refresh_drop_platform.sql new file mode 100644 index 0000000..1159c68 --- /dev/null +++ b/apps/auth/migrations/0006_refresh_drop_platform.sql @@ -0,0 +1,8 @@ +-- Drop the platform identity from refresh_tokens. A refreshed access token now takes +-- `platform`/`platform_id` from the account, which is where the bound identity lives — +-- the copy stored at issue time was redundant, and went stale if the account's +-- identity changed mid-session. Kept in sync with REFRESH_SCHEMA_DDL in +-- src/refresh-db.ts. + +ALTER TABLE refresh_tokens DROP COLUMN platform; +ALTER TABLE refresh_tokens DROP COLUMN platform_id; diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 8269621..ad61362 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -30,6 +30,7 @@ import { json, OAuthError, PlatformIdsRequest, + PlatformType, roleLookup, TokenRequest, TokenResponse, @@ -45,26 +46,9 @@ import type { App } from './context' const TOKEN_SCOPE = 'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage' -/** Platform-type enum names by value, used for the token's `platform` claim. */ -const PLATFORM_TYPES: Record = { - [-1]: 'All', - 0: 'Steam', - 1: 'Oculus', - 2: 'PlayStation', - 3: 'Xbox', - 4: 'RecNet', - 5: 'IOS', - 6: 'GooglePlay', - 7: 'Standalone', - 8: 'Pico', -} - -/** PlatformType for Oculus, which `/cachedlogin/forplatformid` currently stubs out. */ -const OCULUS_PLATFORM = 1 - /** The canned entry served for any Oculus cached-login lookup. See the route below. */ const FAKE_OCULUS_CACHED_LOGIN = { - platform: OCULUS_PLATFORM, + platform: PlatformType.Oculus, platformId: '1', accountId: 1, lastLoginTime: '2026-07-19T17:13:29.225Z', @@ -302,7 +286,7 @@ const app = new Hono() // Oculus client gets past its login screen. `requirePassword` is true — unlike a // genuine cached login there is no platform ticket behind this, so the client must // prompt. Delete this branch once Oculus platform auth lands. - if (platformInt === OCULUS_PLATFORM) return c.json([FAKE_OCULUS_CACHED_LOGIN]) + if (platformInt === PlatformType.Oculus) return c.json([FAKE_OCULUS_CACHED_LOGIN]) const accounts = await getAccountsByPlatformId(c.env.DB, id) // Offer only accounts the `cached_login` grant will actually accept — same check. return c.json( @@ -406,10 +390,12 @@ const app = new Hono() // `platform`/`platform_id` come from the body for a fresh login; a refresh // grant overrides them below with what was stored when the token was issued. let platformId = typeof body.platform_id === 'string' ? body.platform_id : '' - // `platform` is the PlatformType int → its enum name (e.g. 0 → "Steam"). const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN - let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '') + // The token's `platform` claim is the PlatformType int. A grant that asserts no + // platform (a password login) falls back to Steam/0, the same default the account + // itself carries — see `accountPlatform`. + let platform = Number.isNaN(platformInt) ? PlatformType.Steam : platformInt // The device this login came from. The client posts both on every grant; they're // unverified (client-picked) so they're recorded on the account, never trusted as @@ -440,7 +426,7 @@ const app = new Hono() let verifiedSteamId: string | null = null const platformAsserted = !Number.isNaN(platformInt) if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) { - if (platformInt !== 0) { + if (platformInt !== PlatformType.Steam) { return c.json( { error: 'invalid_grant', @@ -548,9 +534,10 @@ const app = new Hono() 400 ) } - accountId = String(refreshed.accountId) - platform = refreshed.platform - platformId = refreshed.platformId + // `platform`/`platform_id` aren't stored with the token — they're taken from + // the account below, so a refreshed token always reflects the identity the + // account is bound to now. + accountId = String(refreshed) } 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 @@ -629,6 +616,12 @@ const app = new Hono() // /role/* lookups). One read of the just-resolved account; roles thus refresh on // every login and every refresh_token grant. const roleAccount = await getAccount(c.env.DB, Number(accountId)) + // A refresh grant posts no platform of its own, so the identity comes off the + // account — the same read, and the only place the bound identity is authoritative. + if (grantType === 'refresh_token' && roleAccount) { + platform = accountPlatform(roleAccount) + platformId = roleAccount.platformId ?? '' + } const accessToken = await generateToken( accountId, platformId, @@ -638,11 +631,7 @@ const app = new Hono() ) // Issue a fresh, persisted refresh token (single-use; the client redeems it via // grant_type=refresh_token). A refresh grant thus rotates its token. - const refreshToken = await issueRefreshToken(c.env.DB, { - accountId: Number(accountId), - platform, - platformId, - }) + const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId)) return c.json({ access_token: accessToken, diff --git a/apps/auth/src/openapi.ts b/apps/auth/src/openapi.ts index c86399d..f4e535a 100644 --- a/apps/auth/src/openapi.ts +++ b/apps/auth/src/openapi.ts @@ -49,19 +49,40 @@ export function form(schema: z.ZodType, description: string): OpenAPIV3_1.Reques } /** - * PlatformType, by value. The `platform` form field is posted as the integer; the - * token's `platform` claim carries the name. Only Steam (0) can actually be - * verified — see the platform-auth notes on `POST /connect/token`. + * PlatformType, the client's platform enum. Declaration order is wire order, and is + * the single source for the schema and description below. The `platform` form field + * is posted as the integer; the token's `platform` claim carries the name. */ -export const PlatformType = z - .union([z.literal(-1), z.int().min(0).max(8)]) +export const PlatformType = { + All: -1, + Steam: 0, + Oculus: 1, + PlayStation: 2, + Xbox: 3, + RecNet: 4, + IOS: 5, + GooglePlay: 6, + Standalone: 7, + Pico: 8, +} as const + +export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType] + +/** + * A PlatformType by value. Only Steam can actually be verified — see the + * platform-auth notes on `POST /connect/token`. + */ +export const PlatformTypeSchema = z + .union([z.literal(-1), z.int().min(0).max(Math.max(...Object.values(PlatformType)))]) .describe( - '-1 All, 0 Steam, 1 Oculus, 2 PlayStation, 3 Xbox, 4 RecNet, 5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico' + Object.entries(PlatformType) + .map(([name, value]) => `${value} ${name}`) + .join(', ') ) /** One entry on the client's login screen, from `toCachedLogin`. */ export const CachedLogin = z.object({ - platform: PlatformType, + platform: PlatformTypeSchema, platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'), accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'), lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"), diff --git a/apps/auth/src/refresh-db.ts b/apps/auth/src/refresh-db.ts index ff6d2bf..811de40 100644 --- a/apps/auth/src/refresh-db.ts +++ b/apps/auth/src/refresh-db.ts @@ -1,21 +1,22 @@ /** * Refresh-token storage on the shared `recflare` D1 database (owned by the `auth` * worker, migration 0003). Only a SHA-256 hash of each token is stored — never the - * raw value — alongside the account + platform needed to re-mint an access token, - * and an absolute expiry. Tokens are single-use: redeeming one deletes it, so a - * fresh token is issued each refresh (rotation) and a replayed token stops working. + * raw value — alongside the account it logs in and an absolute expiry. Tokens are + * single-use: redeeming one deletes it, so a fresh token is issued each refresh + * (rotation) and a replayed token stops working. + * + * The platform identity is NOT kept here (dropped in 0006); a refreshed token takes + * it from the account, which is where the bound identity actually lives. */ /** Refresh tokens live this long (s) before the client must log in again. */ export const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days -/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql). */ +/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql + 0006). */ export const REFRESH_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS refresh_tokens ( token_hash TEXT PRIMARY KEY, account_id INTEGER NOT NULL, - platform TEXT NOT NULL, - platform_id TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL )`, @@ -23,13 +24,6 @@ export const REFRESH_SCHEMA_DDL: string[] = [ `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at)`, ] -/** The login context needed to re-mint an access token from a refresh token. */ -export interface RefreshContext { - accountId: number - platform: string - platformId: string -} - /** SHA-256 hex of the token. Tokens are high-entropy random, so no salt is needed. */ async function hashToken(token: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)) @@ -37,47 +31,40 @@ async function hashToken(token: string): Promise { } /** - * Mint and persist a new refresh token for the given login, returning the raw + * Mint and persist a new refresh token for the given account, returning the raw * token — the only moment it exists in plaintext (only its hash is stored). */ -export async function issueRefreshToken(db: D1Database, ctx: RefreshContext): Promise { +export async function issueRefreshToken(db: D1Database, accountId: number): Promise { const token = `${crypto.randomUUID()}` const now = Math.floor(Date.now() / 1000) await db .prepare( - `INSERT INTO refresh_tokens (token_hash, account_id, platform, platform_id, created_at, expires_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)` - ) - .bind( - await hashToken(token), - ctx.accountId, - ctx.platform, - ctx.platformId, - now, - now + REFRESH_TTL_SECONDS + `INSERT INTO refresh_tokens (token_hash, account_id, created_at, expires_at) + VALUES (?1, ?2, ?3, ?4)` ) + .bind(await hashToken(token), accountId, now, now + REFRESH_TTL_SECONDS) .run() return token } /** * Redeem a refresh token: if it exists and hasn't expired, delete it (single-use - * rotation) and return its login context; otherwise return null. The delete is + * rotation) and return the account it logs in; otherwise return null. The delete is * atomic (`DELETE ... RETURNING`), so a token can't be redeemed twice — a * concurrent second attempt finds no row. An expired token is deleted and rejected. */ export async function consumeRefreshToken( db: D1Database, token: string -): Promise { +): Promise { const now = Math.floor(Date.now() / 1000) const row = await db .prepare( `DELETE FROM refresh_tokens WHERE token_hash = ?1 - RETURNING account_id AS accountId, platform, platform_id AS platformId, expires_at AS expiresAt` + RETURNING account_id AS accountId, expires_at AS expiresAt` ) .bind(await hashToken(token)) - .first<{ accountId: number; platform: string; platformId: string; expiresAt: number }>() + .first<{ accountId: number; expiresAt: number }>() if (!row || row.expiresAt < now) return null - return { accountId: row.accountId, platform: row.platform, platformId: row.platformId } + return row.accountId } diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index be584e7..5c43fa1 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -512,9 +512,17 @@ describe('auth worker routes', () => { }) }) - test('POST /connect/token maps the platform int to its enum name', async () => { - const payload = await tokenFor(`account_id=42&platform=0&password=${LOGIN_PASSWORD}`) - expect(payload.platform).toBe('Steam') + test('POST /connect/token carries the platform int on the token', async () => { + const payload = await tokenFor(`account_id=42&platform=5&password=${LOGIN_PASSWORD}`) + expect(payload.platform).toBe(5) + // `rn.plat` is the same int, not a pinned 0. + expect(payload['rn.plat']).toBe(5) + }) + + test('POST /connect/token defaults the platform claim when none is posted', async () => { + const payload = await tokenFor(`account_id=42&password=${LOGIN_PASSWORD}`) + expect(payload.platform).toBe(0) + expect(payload['rn.plat']).toBe(0) }) test('POST /connect/token returns a refresh_token that redeems for a new token', async () => { @@ -530,15 +538,44 @@ describe('auth worker routes', () => { `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}` ) expect(refreshed.status).toBe(200) - // A fresh access token for the same account, carrying the stored platform. const payload = decodePayload(refreshed.json.access_token as string) expect(payload.sub).toBe('42') - expect(payload.platform).toBe('Steam') - expect(payload.platform_id).toBe('steam-123') + // The platform identity comes off the account, not the refresh token. Account 42 + // has none bound (the posted `platform_id` above was never Steam-verified, so it + // was never written), so the refreshed token carries no identity either. + expect(payload.platform).toBe(0) + expect(payload.platform_id).toBe('') // The refresh token is rotated (single-use), so a new one is returned. expect(refreshed.json.refresh_token).not.toBe(refreshToken) }) + test('a refreshed token carries the identity bound to the account', async () => { + // A Steam-bound account: only a verified ticket writes `platformId`, so seed it + // directly rather than posting an (unverified) platform_id on the login. + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId: 43, + username: 'Player43', + passwordHash: await hashPassword(LOGIN_PASSWORD), + platform: 0, + platformId: 'steam-123', + }) + ) + .run() + + const login = await postToken(`account_id=43&password=${LOGIN_PASSWORD}`) + expect(login.status).toBe(200) + const refreshed = await postToken( + `grant_type=refresh_token&refresh_token=${encodeURIComponent(login.json.refresh_token as string)}` + ) + expect(refreshed.status).toBe(200) + const payload = decodePayload(refreshed.json.access_token as string) + expect(payload.sub).toBe('43') + expect(payload.platform).toBe(0) + expect(payload.platform_id).toBe('steam-123') + }) + test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => { const login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`) const refreshToken = login.json.refresh_token as string