mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
fix platform link login
This commit is contained in:
+40
-13
@@ -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<App>): Promise<number | null> {
|
||||
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<Account, 'platform'>): 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<Account, 'platform' | 'platformId'>,
|
||||
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<App>): Promise<number | null> {
|
||||
*/
|
||||
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<App>()
|
||||
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<App>()
|
||||
// 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 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>> = []
|
||||
@@ -273,16 +301,15 @@ const app = new Hono<App>()
|
||||
//
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user