mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
increase cardinality of platform accounts
This commit is contained in:
+284
-138
@@ -3,13 +3,12 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
countAccountsByPlatformId,
|
||||
countAccountsBySignupIp,
|
||||
createAccount,
|
||||
GAME_VERSION,
|
||||
getAccount,
|
||||
getAccountByUsername,
|
||||
getAccountsByPlatformId,
|
||||
getAccountsByIds,
|
||||
getPasswordHash,
|
||||
getRoomById,
|
||||
hashPassword,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
setPasswordHash,
|
||||
setPresence,
|
||||
subRoomDataBlob,
|
||||
updateAccount,
|
||||
verifyPassword,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
@@ -38,12 +38,20 @@ import {
|
||||
TokenRequest,
|
||||
TokenResponse,
|
||||
} from './openapi'
|
||||
import {
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForPlatformId,
|
||||
getLinksForPlatformIdentity,
|
||||
isPlatformIdentityLinked,
|
||||
linkPlatformIdentity,
|
||||
} from './platform-db'
|
||||
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||
import { verifySteamTicket } from './steam-ticket'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Account } from '@repo/domain'
|
||||
import type { App } from './context'
|
||||
import type { PlatformLink } from './platform-db'
|
||||
|
||||
/** OAuth scopes granted by `/connect/token`. */
|
||||
const TOKEN_SCOPE =
|
||||
@@ -165,42 +173,147 @@ 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 read out of a verified
|
||||
* Steam ticket, or the Meta user id a validated nonce was issued to — never the raw
|
||||
* 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
|
||||
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
|
||||
* (the verified `platform_auth`) is the credential for a cached login — no prompt.
|
||||
*
|
||||
* The platform and id come from the LINK, not from the account: an account linked to
|
||||
* both a Steam and a Meta identity appears in both pickers, and each has to report the
|
||||
* identity that picker was asked about — that's what the client posts back, and what
|
||||
* the grant then checks the link against.
|
||||
*/
|
||||
function toCachedLogin(account: Account) {
|
||||
function toCachedLogin(account: Account, link: PlatformLink) {
|
||||
return {
|
||||
platform: accountPlatform(account),
|
||||
platformId: account.platformId ?? '',
|
||||
platform: link.platform,
|
||||
platformId: link.platformId,
|
||||
accountId: account.accountId,
|
||||
lastLoginTime: account.lastLoginTime ?? account.createdAt,
|
||||
requirePassword: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a set of links into picker entries, dropping any whose account no longer
|
||||
* exists. One batched account read rather than one per link.
|
||||
*
|
||||
* Order follows the links (oldest first), so the picker is stable between launches.
|
||||
*/
|
||||
async function toCachedLogins(db: D1Database, links: PlatformLink[]) {
|
||||
if (links.length === 0) return []
|
||||
const accounts = await getAccountsByIds(db, [...new Set(links.map((l) => l.accountId))])
|
||||
const byId = new Map(accounts.map((a) => [a.accountId, a]))
|
||||
return links.flatMap((link) => {
|
||||
const account = byId.get(link.accountId)
|
||||
return account ? [toCachedLogin(account, link)] : []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the platform identity a password login proved to the account it logged into,
|
||||
* so the next launch on that device is a cached login. Called only with a VERIFIED
|
||||
* identity — a link is a password-free way into the account.
|
||||
*
|
||||
* Already linked is the common case (every subsequent login on that device) and costs
|
||||
* one read and nothing else.
|
||||
*
|
||||
* The per-identity cap applies here as well as at signup, or it wouldn't be a cap:
|
||||
* an identity could otherwise sit at the limit, have accounts created for it with a
|
||||
* password, and link its way into all of them. Reaching it does NOT fail the login —
|
||||
* the password was valid — it just leaves the account without a cached login, so the
|
||||
* player types their password each time rather than being locked out.
|
||||
*
|
||||
* The first identity linked also becomes the account's primary (the blob's
|
||||
* `platform`/`platformId`), which is what the account DTO and the refresh grant's
|
||||
* claims report. Later platforms link without disturbing it.
|
||||
*/
|
||||
async function linkLoginIdentity(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string,
|
||||
maxAccountsPerIdentity: number
|
||||
): Promise<void> {
|
||||
if (await isPlatformIdentityLinked(db, accountId, platform, platformId)) return
|
||||
|
||||
if (
|
||||
maxAccountsPerIdentity > 0 &&
|
||||
(await countAccountsForPlatformIdentity(db, platform, platformId)) >= maxAccountsPerIdentity
|
||||
) {
|
||||
logger.info('platform link refused: account limit reached for this platform identity', {
|
||||
accountId,
|
||||
platform,
|
||||
platformId,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!(await linkPlatformIdentity(db, accountId, platform, platformId))) return
|
||||
logger.info('linked platform identity to account', { accountId, platform, platformId })
|
||||
|
||||
const account = await getAccount(db, accountId)
|
||||
if (account && !account.platformId) {
|
||||
await updateAccount(db, accountId, { platform, platformId })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a login's `platform_auth` proved, if anything. Failures are split because the
|
||||
* callers act on them differently: a grant that authenticates BY platform identity has
|
||||
* to refuse, while a password grant — which has already proven who it is — carries on
|
||||
* and just doesn't link.
|
||||
*
|
||||
* `unconfigured` is an operator problem (no META_APP_SECRET), not a bad credential,
|
||||
* and is the one case that warrants a 5xx.
|
||||
*/
|
||||
type PlatformProof =
|
||||
/** Nothing was checked — the login offered no proof, so there is nothing to report. */
|
||||
| { status: 'none' }
|
||||
| { status: 'verified'; platform: number; platformId: string }
|
||||
| { status: 'unsupported' }
|
||||
| { status: 'unconfigured' }
|
||||
| { status: 'rejected'; reason: string }
|
||||
|
||||
/**
|
||||
* Verify a login's `platform_auth` and return the identity it proves.
|
||||
*
|
||||
* The two verifiable platforms prove the id in opposite directions, which is why they
|
||||
* can't share a code path: Steam's ticket *carries* a SteamID64 we read out and trust,
|
||||
* so the posted `platform_id` is discarded. Meta's nonce carries nothing — it is
|
||||
* validated *against* the posted `platform_id`, so that field is an input, and a
|
||||
* spoofed one fails validation rather than being ignored. Either way the id that comes
|
||||
* back is proven, never the raw client-supplied field, and only a proven id is ever
|
||||
* written to an account or linked to one.
|
||||
*/
|
||||
async function verifyPlatformProof(
|
||||
env: App['Bindings'],
|
||||
platform: number,
|
||||
platformAuth: string,
|
||||
postedPlatformId: string
|
||||
): Promise<PlatformProof> {
|
||||
if (platform === PlatformType.Steam) {
|
||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
||||
if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' }
|
||||
return { status: 'verified', platform: PlatformType.Steam, platformId: verified.steamId }
|
||||
}
|
||||
if (platform === PlatformType.Oculus) {
|
||||
// `.get()` throws when the secret doesn't exist in the store at all (as opposed to
|
||||
// holding an empty/placeholder value) — the same misconfiguration from the player's
|
||||
// side, so it takes the same branch.
|
||||
const appSecret = await env.META_APP_SECRET.get().catch(() => '')
|
||||
if (appSecret === '') return { status: 'unconfigured' }
|
||||
const verified = await verifyMetaNonce(platformAuth, postedPlatformId, appSecret)
|
||||
if (!verified.ok) return { status: 'rejected', reason: verified.reason }
|
||||
return {
|
||||
status: 'verified',
|
||||
platform: PlatformType.Oculus,
|
||||
platformId: verified.identity.userId,
|
||||
}
|
||||
}
|
||||
return { status: 'unsupported' }
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -243,17 +356,18 @@ const app = new Hono<App>()
|
||||
tags: ['Cached login'],
|
||||
summary: 'Accounts linked to a platform id',
|
||||
description: [
|
||||
'Accounts the client may offer on its login screen for this platform identity.',
|
||||
'Filtered to those a `cached_login` grant would actually accept, so an entry here',
|
||||
'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls',
|
||||
'back to a fresh login or create_account.',
|
||||
'Accounts the client may offer on its login screen for this platform identity —',
|
||||
'the links this identity has, so an entry here is always redeemable by a',
|
||||
'`cached_login` grant (both read the same table). An account linked to several',
|
||||
'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)',
|
||||
'and the client falls back to a fresh login or create_account.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'platform',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'PlatformType integer. A non-numeric value disables the link filter.',
|
||||
description: 'PlatformType integer. A non-numeric value matches the id on any platform.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
@@ -272,15 +386,13 @@ const app = new Hono<App>()
|
||||
const { platform, id } = c.req.param()
|
||||
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) || isLinkedToPlatformIdentity(a, platformInt, id)
|
||||
)
|
||||
.map(toCachedLogin)
|
||||
)
|
||||
// Listed straight from the link table, which is also what the `cached_login`
|
||||
// grant authorizes against — so the picker can't offer an account the grant
|
||||
// then refuses.
|
||||
const links = Number.isNaN(platformInt)
|
||||
? await getLinksForPlatformId(c.env.DB, id)
|
||||
: await getLinksForPlatformIdentity(c.env.DB, platformInt, id)
|
||||
return c.json(await toCachedLogins(c.env.DB, links))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -294,8 +406,8 @@ const app = new Hono<App>()
|
||||
description: [
|
||||
'Resolves many platform ids at once. Results are flattened across all ids, so the',
|
||||
'response cannot be mapped back to a specific input id — the client uses each',
|
||||
'entry’s own `platformId`. Unlike the single-id route, results are NOT filtered to',
|
||||
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
||||
'entry’s own `platformId`. No platform accompanies these ids, so each matches on',
|
||||
'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
||||
].join(' '),
|
||||
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
|
||||
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
|
||||
@@ -306,7 +418,8 @@ const app = new Hono<App>()
|
||||
const ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
|
||||
const out: Array<ReturnType<typeof toCachedLogin>> = []
|
||||
for (const pid of ids) {
|
||||
out.push(...(await getAccountsByPlatformId(c.env.DB, pid)).map(toCachedLogin))
|
||||
// No platform accompanies these ids, so they match on any platform.
|
||||
out.push(...(await toCachedLogins(c.env.DB, await getLinksForPlatformId(c.env.DB, pid))))
|
||||
}
|
||||
return c.json(out)
|
||||
}
|
||||
@@ -332,8 +445,8 @@ const app = new Hono<App>()
|
||||
'',
|
||||
'**`cached_login`** — logs into an already-linked account using platform ownership as',
|
||||
'the credential; no password. Requires a verifying `platform_auth`, and the posted',
|
||||
'`account_id` must be linked to exactly the identity it proves. An account',
|
||||
'with no stored platform identity cannot be cached-logged-into.',
|
||||
'`account_id` must be LINKED to exactly the identity it proves. An account with no',
|
||||
'link for that identity cannot be cached-logged-into.',
|
||||
'',
|
||||
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
||||
'platform and platform id come from what was stored at issue time, not the body.',
|
||||
@@ -341,17 +454,22 @@ const app = new Hono<App>()
|
||||
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
||||
'identifies the account by `username` or numeric `account_id` and requires the',
|
||||
'matching `password`. An account with no stored hash cannot be logged into at all,',
|
||||
'which is what closes id/username-only takeover.',
|
||||
'which is what closes id/username-only takeover. When it also posts a `platform_auth`',
|
||||
'that verifies, that identity is LINKED to the account — this is how a player who',
|
||||
'signed up on one platform gets a cached login on a second device. The login is',
|
||||
'never failed over the link: an unverifiable proof (or one over the per-identity',
|
||||
'cap) just leaves the account without a cached login there.',
|
||||
'',
|
||||
'**Platform verification.** Two platforms can be verified, so any grant',
|
||||
'authenticating by platform identity must be one of them, and only a verified id is',
|
||||
'ever written to an account. Steam (`0`) posts a Steam-signed `platform_auth` ticket,',
|
||||
'checked offline; the SteamID64 it carries replaces the client-supplied `platform_id`.',
|
||||
'Meta/Oculus (`1`) posts `platform_auth` as `{"Nonce":…,"AppId":…}`, which recflare',
|
||||
'sends to Meta together with the posted `platform_id` — validation is what binds the',
|
||||
'nonce to that user id, so a spoofed id fails. Meta logins therefore need the app',
|
||||
'secret (`META_APP_SECRET`) and answer 500 when it is unset. Password and refresh',
|
||||
'grants carry their own credential and are not gated this way.',
|
||||
'**Platform identity.** An account can be reached from several platform identities;',
|
||||
'the links are the one thing both the picker and `cached_login` consult, and only a',
|
||||
'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a',
|
||||
'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries',
|
||||
'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`',
|
||||
'as `{"Nonce":…,"AppId":…}`, which recflare sends to Meta together with the posted',
|
||||
'`platform_id` — validation is what binds the nonce to that user id, so a spoofed id',
|
||||
'fails. Meta logins therefore need the app secret (`META_APP_SECRET`) and answer 500',
|
||||
'when it is unset. The first identity linked also becomes the account’s primary',
|
||||
'(what the account DTO and a refreshed token report); later ones only link.',
|
||||
'',
|
||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||
'powers refresh on every login and every refresh grant.',
|
||||
@@ -412,82 +530,53 @@ const app = new Hono<App>()
|
||||
|
||||
// A platform-authenticated login proves who you are with the platform itself, and
|
||||
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
|
||||
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth. So
|
||||
// those logins must be one of those two:
|
||||
// - cached_login authenticates purely by platform identity → always gated.
|
||||
// - create_account that asserts a platform is rejected unless we can verify that
|
||||
// platform, since we won't bind an identity we can't prove. (create_account
|
||||
// with NO platform is the password-account path — allowed, but binds no
|
||||
// platformId.)
|
||||
// The verified id is the ONLY value ever written to an account's `platformId`.
|
||||
// Credential (password) and refresh_token grants carry their own credential and
|
||||
// aren't gated here.
|
||||
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
|
||||
// verifyPlatformProof). Only a verified identity is ever bound or linked.
|
||||
//
|
||||
// The two platforms prove the id in opposite directions, which is why they can't
|
||||
// share a code path: Steam's ticket *carries* a SteamID64 we read out and trust,
|
||||
// so the posted `platform_id` is discarded. Meta's nonce carries nothing — it is
|
||||
// validated *against* the posted `platform_id`, so that field is an input, and a
|
||||
// spoofed one fails validation rather than being ignored. Either way what lands in
|
||||
// `platformId` below is proven, never the raw client-supplied field.
|
||||
// Two grants are GATED on it — they have no other credential, so an unverifiable
|
||||
// platform is fatal:
|
||||
// - cached_login authenticates purely by platform identity.
|
||||
// - create_account that asserts a platform: we won't bind an identity we can't
|
||||
// prove. (create_account with NO platform is the password-account path —
|
||||
// allowed, but binds no platformId.)
|
||||
//
|
||||
// A password grant is NOT gated: the password already proved who it is. It posts
|
||||
// its platform proof too, and if that verifies we LINK the identity to the account
|
||||
// (see below), which is how a player who created an account on Steam gets a cached
|
||||
// login on their headset. If it doesn't verify, the login still succeeds — it just
|
||||
// links nothing, because a link is a password-free way into the account and must
|
||||
// never rest on an unproven id.
|
||||
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
|
||||
const platformAsserted = !Number.isNaN(platformInt)
|
||||
const gatedOnPlatform =
|
||||
grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)
|
||||
// The password grant only spends a verification when the client actually offered
|
||||
// one; the rest of the time there is nothing to link.
|
||||
const proof: PlatformProof =
|
||||
gatedOnPlatform || (platformAsserted && platformAuth !== '')
|
||||
? await verifyPlatformProof(c.env, platformInt, platformAuth, platformId)
|
||||
: { status: 'none' }
|
||||
|
||||
let verifiedPlatformId: string | null = null
|
||||
let verifiedPlatform: number | null = null
|
||||
const platformAsserted = !Number.isNaN(platformInt)
|
||||
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
|
||||
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
|
||||
if (platformInt === PlatformType.Steam) {
|
||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
||||
if (!verified) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'invalid or missing platform_auth ticket',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
verifiedPlatform = PlatformType.Steam
|
||||
verifiedPlatformId = verified.steamId
|
||||
} else if (platformInt === PlatformType.Oculus) {
|
||||
// Verifying a Meta login needs the app secret. Without it every Meta player is
|
||||
// locked out, which is an operator misconfiguration and not the client's fault
|
||||
// — so it answers 500, the same way an unset JWT_SECRET does below, rather than
|
||||
// blaming the credential. (We never fall back to trusting the posted id: that
|
||||
// would let anyone log into any Meta-linked account by naming its user id.)
|
||||
// `.get()` throws when the secret doesn't exist in the store at all (as
|
||||
// opposed to holding an empty/placeholder value) — the same misconfiguration
|
||||
// from the player's side, so it takes the same branch rather than a 500 from
|
||||
// the error handler with nothing useful in it.
|
||||
const appSecret = await c.env.META_APP_SECRET.get().catch(() => '')
|
||||
if (appSecret === '') {
|
||||
logger.error('refusing a Meta login: META_APP_SECRET is empty')
|
||||
return c.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Meta platform verification is not configured',
|
||||
},
|
||||
500
|
||||
)
|
||||
}
|
||||
const verified = await verifyMetaNonce(platformAuth, platformId, appSecret)
|
||||
if (!verified.ok) {
|
||||
// The reason is for the operator; the client is told only that it was
|
||||
// rejected. A wrong app secret and a stale nonce look identical from the
|
||||
// client side, so this log is the only way to tell them apart.
|
||||
logger.info('meta nonce verification failed', {
|
||||
platformId,
|
||||
reason: verified.reason,
|
||||
})
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'invalid or missing platform_auth nonce',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
verifiedPlatform = PlatformType.Oculus
|
||||
verifiedPlatformId = verified.identity.userId
|
||||
} else {
|
||||
if (proof.status === 'verified') {
|
||||
verifiedPlatform = proof.platform
|
||||
verifiedPlatformId = proof.platformId
|
||||
} else if (proof.status !== 'none') {
|
||||
// Log every failure, including the ones a password grant shrugs off: a player
|
||||
// who silently never gets a cached login on their headset has no other symptom,
|
||||
// and this line is where "Meta rejected the nonce" becomes visible.
|
||||
logger.info('platform_auth not verified', {
|
||||
platform: platformInt,
|
||||
platformId,
|
||||
grantType,
|
||||
status: proof.status,
|
||||
reason: proof.status === 'rejected' ? proof.reason : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (gatedOnPlatform && proof.status !== 'verified') {
|
||||
if (proof.status === 'unsupported') {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
@@ -496,9 +585,34 @@ const app = new Hono<App>()
|
||||
400
|
||||
)
|
||||
}
|
||||
platformId = verifiedPlatformId
|
||||
if (proof.status === 'unconfigured') {
|
||||
// An operator misconfiguration, not the client's fault: without the app secret
|
||||
// every Meta player is locked out, so it answers 500 the way an unset
|
||||
// JWT_SECRET does below rather than blaming the credential. (We never fall
|
||||
// back to trusting the posted id — that would let anyone log into any
|
||||
// Meta-linked account by naming its user id.)
|
||||
logger.error('refusing a Meta login: META_APP_SECRET is empty')
|
||||
return c.json(
|
||||
{
|
||||
error: 'server_error',
|
||||
error_description: 'Meta platform verification is not configured',
|
||||
},
|
||||
500
|
||||
)
|
||||
}
|
||||
// The reason is for the operator; the client is told only that it was rejected.
|
||||
// A wrong app secret and a stale nonce look identical from the client side.
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: 'invalid or missing platform_auth' },
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// From here on `platformId` is the PROVEN identity wherever there is one — the
|
||||
// SteamID64 out of the ticket or the Meta user id the nonce validated against,
|
||||
// never the raw client-supplied field.
|
||||
if (verifiedPlatformId !== null) platformId = verifiedPlatformId
|
||||
|
||||
// Resolve the account this token is for:
|
||||
// - create_account: mint + persist a brand-new account (auto-assigned random
|
||||
// username — players don't pick one initially); the token's `sub` is its id.
|
||||
@@ -526,7 +640,11 @@ const app = new Hono<App>()
|
||||
if (
|
||||
maxPerPlatformId > 0 &&
|
||||
verifiedPlatformId !== null &&
|
||||
(await countAccountsByPlatformId(c.env.DB, verifiedPlatformId)) >= maxPerPlatformId
|
||||
(await countAccountsForPlatformIdentity(
|
||||
c.env.DB,
|
||||
verifiedPlatform ?? 0,
|
||||
verifiedPlatformId
|
||||
)) >= maxPerPlatformId
|
||||
) {
|
||||
logger.info('signup rejected: platform account limit', {
|
||||
platformId: verifiedPlatformId,
|
||||
@@ -555,9 +673,10 @@ const app = new Hono<App>()
|
||||
}
|
||||
|
||||
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
|
||||
// a Meta-validated nonce). That bound `platformId` is what a later cached login
|
||||
// is checked against, so only that platform user can log back into the account.
|
||||
// A password/anonymous create_account (no platform) binds no platformId.
|
||||
// a Meta-validated nonce). A password/anonymous create_account (no platform)
|
||||
// binds nothing. The account blob keeps this first identity as its PRIMARY one
|
||||
// (for the account DTO and the refresh grant's claims); the link written just
|
||||
// below is what a later cached login is actually authorized against.
|
||||
const account = await createAccount(c.env.DB, {
|
||||
platforms: platformInt || 0,
|
||||
platform: verifiedPlatform ?? undefined,
|
||||
@@ -569,6 +688,14 @@ const app = new Hono<App>()
|
||||
lastLoginIp: clientIp || undefined,
|
||||
})
|
||||
accountId = String(account.accountId)
|
||||
if (verifiedPlatformId !== null) {
|
||||
await linkPlatformIdentity(
|
||||
c.env.DB,
|
||||
account.accountId,
|
||||
verifiedPlatform ?? 0,
|
||||
verifiedPlatformId
|
||||
)
|
||||
}
|
||||
// Establish the login password when one is posted (raw password never stored).
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (password !== '') {
|
||||
@@ -592,11 +719,14 @@ const app = new Hono<App>()
|
||||
} 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
|
||||
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
|
||||
// account is linked to exactly this platform identity — this is the check that
|
||||
// keeps anyone but platform user `platform_id` out of the account (platform
|
||||
// ownership is the credential; no password needed). An account with no stored
|
||||
// platform identity can't be cached-logged-into and must use a fresh login.
|
||||
// `platform_id` its platform_auth vouches for. Authorize ONLY when the link
|
||||
// table says that account is linked to exactly this platform identity — this is
|
||||
// the check that keeps anyone but that platform user out of the account
|
||||
// (platform ownership is the credential; no password needed). An account with no
|
||||
// link for the presented identity must use a password.
|
||||
//
|
||||
// The picker lists straight from the same table, so it can only offer accounts
|
||||
// this check accepts.
|
||||
//
|
||||
// NB: `platform_id` here is the verified identity set above — the SteamID64 from
|
||||
// the ticket, or the Meta user id the nonce validated against — never the raw
|
||||
@@ -604,7 +734,10 @@ const app = new Hono<App>()
|
||||
//
|
||||
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 || !isLinkedToPlatformIdentity(account, platformInt, platformId)) {
|
||||
const linked =
|
||||
account !== null &&
|
||||
(await isPlatformIdentityLinked(c.env.DB, account.accountId, platformInt, platformId))
|
||||
if (!account || !linked) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
@@ -646,6 +779,19 @@ const app = new Hono<App>()
|
||||
)
|
||||
}
|
||||
accountId = String(resolvedId)
|
||||
// The password proved the account; the platform proof (when the client sent one
|
||||
// and it verified) proves the device's platform identity. Linking the two is
|
||||
// what gives a player who signed up on Steam a cached login on their headset —
|
||||
// they type their password once there, and never again.
|
||||
if (verifiedPlatformId !== null) {
|
||||
await linkLoginIdentity(
|
||||
c.env.DB,
|
||||
resolvedId,
|
||||
verifiedPlatform ?? 0,
|
||||
verifiedPlatformId,
|
||||
intVar(c.env.MAX_ACCOUNTS_PER_PLATFORM_ID, DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID)
|
||||
)
|
||||
}
|
||||
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
|
||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user