mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
increase cardinality of platform accounts
This commit is contained in:
+42
-12
@@ -48,7 +48,8 @@ route without documenting it fails rather than silently shipping an incomplete s
|
|||||||
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
|
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
|
||||||
the account by `username` or numeric `account_id` and requires the matching password
|
the account by `username` or numeric `account_id` and requires the matching password
|
||||||
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
|
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
|
||||||
all, which is what closes id/username-only takeover.
|
all, which is what closes id/username-only takeover. When it also carries a verifying
|
||||||
|
`platform_auth`, that identity is **linked** to the account (see below).
|
||||||
|
|
||||||
Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `role`
|
Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `role`
|
||||||
claim, so developer/moderator powers refresh on every login and every refresh grant.
|
claim, so developer/moderator powers refresh on every login and every refresh grant.
|
||||||
@@ -72,16 +73,45 @@ asserts a platform) must be a platform we can verify. Two are:
|
|||||||
login at all without the app secret — an unset `META_APP_SECRET` answers 500 rather
|
login at all without the app secret — an unset `META_APP_SECRET` answers 500 rather
|
||||||
than falling back to trusting the client.
|
than falling back to trusting the client.
|
||||||
|
|
||||||
Everything else is refused. Whichever platform, the value written to an account's
|
Everything else is refused. Whichever platform, the identity that gets bound or linked
|
||||||
`platformId` is the verified one, never the raw `platform_id` field.
|
is the verified one, never the raw `platform_id` field.
|
||||||
|
|
||||||
|
### One account, many platform identities
|
||||||
|
|
||||||
|
An account can be reached from several platform identities — a player's PC and their
|
||||||
|
headset both open the same account, with no password after the first time. The links
|
||||||
|
live in the `platform_account` table (`src/platform-db.ts`, migration 0007), one row per
|
||||||
|
(platform, platform id, account).
|
||||||
|
|
||||||
|
That table is the **one source of truth** for both halves of a cached login: the picker
|
||||||
|
(`/cachedlogin/forplatformid`) lists the accounts an identity links to, and the
|
||||||
|
`cached_login` grant asks it whether the account it was handed is linked to the identity
|
||||||
|
just proven. They used to be two separate checks over the account blob's single
|
||||||
|
`platformId`, which could disagree — the client would be offered an account that then
|
||||||
|
answered "no linked account" forever.
|
||||||
|
|
||||||
|
A second device is linked by **logging in with a password there**: the client posts its
|
||||||
|
`platform_auth` alongside the password, and a proof that verifies becomes a link. Only a
|
||||||
|
verified identity is ever linked, since a link is a password-free way into the account.
|
||||||
|
A proof that doesn't verify never fails the login — it just leaves that device without a
|
||||||
|
cached login.
|
||||||
|
|
||||||
|
The account blob keeps `platform`/`platformId` as the account's **primary** identity
|
||||||
|
(the first one linked). It feeds the account DTO and a refreshed token's claims, and
|
||||||
|
nothing authorizes off it.
|
||||||
|
|
||||||
## Signup caps
|
## Signup caps
|
||||||
|
|
||||||
`create_account` is capped on two independent arms, per verified platform id and per
|
`create_account` is capped on two independent arms, per verified platform identity and
|
||||||
signup IP. The platform arm can't be spoofed or reset by changing networks; the IP arm
|
per signup IP. The platform arm can't be spoofed or reset by changing networks; the IP
|
||||||
is coarse and will produce false positives behind NAT, shared campus and mobile
|
arm is coarse and will produce false positives behind NAT, shared campus and mobile
|
||||||
networks. Both default to 3.
|
networks. Both default to 3.
|
||||||
|
|
||||||
|
The platform arm also caps **linking**, or it wouldn't be a cap: an identity at the
|
||||||
|
limit could otherwise have accounts created for it with a password and link its way into
|
||||||
|
all of them. Hitting it never fails a password login — the account just doesn't get a
|
||||||
|
cached login on that device.
|
||||||
|
|
||||||
Override per environment via the root `.env` (`RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID`,
|
Override per environment via the root `.env` (`RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID`,
|
||||||
`RECFLARE_MAX_ACCOUNTS_PER_IP`), injected at deploy time so tuning them never means
|
`RECFLARE_MAX_ACCOUNTS_PER_IP`), injected at deploy time so tuning them never means
|
||||||
editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a
|
editing a versioned file. Setting an arm to `0` disables it — worth reaching for on a
|
||||||
@@ -89,12 +119,12 @@ small private server, or when a shared network is being locked out.
|
|||||||
|
|
||||||
## Bindings
|
## Bindings
|
||||||
|
|
||||||
| Binding | Type | Notes |
|
| Binding | Type | Notes |
|
||||||
| -------------------- | ------------- | ------------------------------------------------------ |
|
| -------------------- | ------------- | ----------------------------------------------------------------------------------------------- |
|
||||||
| `DB` | D1 | Shared `recflare` database; this worker owns `account` |
|
| `DB` | D1 | Shared `recflare` database; this worker owns `account`, `refresh_tokens` and `platform_account` |
|
||||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
|
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
|
||||||
| `META_APP_SECRET` | Secrets Store | Meta app secret; only used to validate a login nonce |
|
| `META_APP_SECRET` | Secrets Store | Meta app secret; only used to validate a login nonce |
|
||||||
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
|
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
|
||||||
|
|
||||||
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
|
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
|
||||||
table, so they stay independent of the `rooms` worker's migrations on the same
|
table, so they stay independent of the `rooms` worker's migrations on the same
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Let one account be linked to MORE THAN ONE platform identity, so a player with a
|
||||||
|
-- PC and a headset gets a cached login on both. The account blob's single
|
||||||
|
-- `platformId`/`platform` pair could only hold one, so logging in on the second
|
||||||
|
-- device meant a password every time.
|
||||||
|
--
|
||||||
|
-- Links move into their own table, which becomes the one source of truth for both
|
||||||
|
-- halves of a cached login (the picker and the `cached_login` grant). The blob fields
|
||||||
|
-- stay as the account's *primary* identity — the first one linked — for the account
|
||||||
|
-- DTO and the refresh grant's claims; nothing authorizes off them any more. Kept in
|
||||||
|
-- sync with PLATFORM_SCHEMA_DDL in src/platform-db.ts.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS platform_account (
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
platform INTEGER NOT NULL,
|
||||||
|
platform_id TEXT NOT NULL,
|
||||||
|
linked_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (platform, platform_id, account_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id);
|
||||||
|
|
||||||
|
-- Backfill every identity already bound to an account. `platform` is COALESCEd to 0
|
||||||
|
-- because nothing ever defaulted that field: an account can carry a platformId with no
|
||||||
|
-- platform recorded, and back when Steam was the only verifiable platform an unset one
|
||||||
|
-- *was* Steam. Without the COALESCE those accounts would lose their cached login at
|
||||||
|
-- deploy. Mirrored as PLATFORM_BACKFILL_SQL in src/platform-db.ts, which is what the
|
||||||
|
-- tests run.
|
||||||
|
INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||||
|
SELECT
|
||||||
|
account_id,
|
||||||
|
COALESCE(json_extract(data, '$.platform'), 0),
|
||||||
|
platform_id,
|
||||||
|
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
|
||||||
|
FROM account
|
||||||
|
WHERE platform_id IS NOT NULL AND platform_id <> '';
|
||||||
+284
-138
@@ -3,13 +3,12 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
|||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
countAccountsByPlatformId,
|
|
||||||
countAccountsBySignupIp,
|
countAccountsBySignupIp,
|
||||||
createAccount,
|
createAccount,
|
||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
getAccount,
|
getAccount,
|
||||||
getAccountByUsername,
|
getAccountByUsername,
|
||||||
getAccountsByPlatformId,
|
getAccountsByIds,
|
||||||
getPasswordHash,
|
getPasswordHash,
|
||||||
getRoomById,
|
getRoomById,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
setPasswordHash,
|
setPasswordHash,
|
||||||
setPresence,
|
setPresence,
|
||||||
subRoomDataBlob,
|
subRoomDataBlob,
|
||||||
|
updateAccount,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
@@ -38,12 +38,20 @@ import {
|
|||||||
TokenRequest,
|
TokenRequest,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
} from './openapi'
|
} from './openapi'
|
||||||
|
import {
|
||||||
|
countAccountsForPlatformIdentity,
|
||||||
|
getLinksForPlatformId,
|
||||||
|
getLinksForPlatformIdentity,
|
||||||
|
isPlatformIdentityLinked,
|
||||||
|
linkPlatformIdentity,
|
||||||
|
} from './platform-db'
|
||||||
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||||
import { verifySteamTicket } from './steam-ticket'
|
import { verifySteamTicket } from './steam-ticket'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { Account } from '@repo/domain'
|
import type { Account } from '@repo/domain'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
import type { PlatformLink } from './platform-db'
|
||||||
|
|
||||||
/** OAuth scopes granted by `/connect/token`. */
|
/** OAuth scopes granted by `/connect/token`. */
|
||||||
const TOKEN_SCOPE =
|
const TOKEN_SCOPE =
|
||||||
@@ -165,42 +173,147 @@ function accountPlatform(account: Pick<Account, 'platform'>): number {
|
|||||||
return account.platform ?? 0
|
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
|
* 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
|
* entry on the login screen. The client posts the chosen `accountId` back as a
|
||||||
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
|
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
|
||||||
* (the verified `platform_auth`) is the credential for a cached login — no prompt.
|
* (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 {
|
return {
|
||||||
platform: accountPlatform(account),
|
platform: link.platform,
|
||||||
platformId: account.platformId ?? '',
|
platformId: link.platformId,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
lastLoginTime: account.lastLoginTime ?? account.createdAt,
|
lastLoginTime: account.lastLoginTime ?? account.createdAt,
|
||||||
requirePassword: false,
|
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>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -243,17 +356,18 @@ const app = new Hono<App>()
|
|||||||
tags: ['Cached login'],
|
tags: ['Cached login'],
|
||||||
summary: 'Accounts linked to a platform id',
|
summary: 'Accounts linked to a platform id',
|
||||||
description: [
|
description: [
|
||||||
'Accounts the client may offer on its login screen for this platform identity.',
|
'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',
|
'the links this identity has, so an entry here is always redeemable by a',
|
||||||
'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls',
|
'`cached_login` grant (both read the same table). An account linked to several',
|
||||||
'back to a fresh login or create_account.',
|
'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(' '),
|
].join(' '),
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: 'platform',
|
name: 'platform',
|
||||||
in: 'path',
|
in: 'path',
|
||||||
required: true,
|
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' },
|
schema: { type: 'string' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -272,15 +386,13 @@ const app = new Hono<App>()
|
|||||||
const { platform, id } = c.req.param()
|
const { platform, id } = c.req.param()
|
||||||
logger.info('cached login lookup', { platform, id })
|
logger.info('cached login lookup', { platform, id })
|
||||||
const platformInt = Number.parseInt(platform, 10)
|
const platformInt = Number.parseInt(platform, 10)
|
||||||
const accounts = await getAccountsByPlatformId(c.env.DB, id)
|
// Listed straight from the link table, which is also what the `cached_login`
|
||||||
// Offer only accounts the `cached_login` grant will actually accept — same check.
|
// grant authorizes against — so the picker can't offer an account the grant
|
||||||
return c.json(
|
// then refuses.
|
||||||
accounts
|
const links = Number.isNaN(platformInt)
|
||||||
.filter(
|
? await getLinksForPlatformId(c.env.DB, id)
|
||||||
(a) => Number.isNaN(platformInt) || isLinkedToPlatformIdentity(a, platformInt, id)
|
: await getLinksForPlatformIdentity(c.env.DB, platformInt, id)
|
||||||
)
|
return c.json(await toCachedLogins(c.env.DB, links))
|
||||||
.map(toCachedLogin)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -294,8 +406,8 @@ const app = new Hono<App>()
|
|||||||
description: [
|
description: [
|
||||||
'Resolves many platform ids at once. Results are flattened across all ids, so the',
|
'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',
|
'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',
|
'entry’s own `platformId`. No platform accompanies these ids, so each matches on',
|
||||||
'redeemable accounts. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
'any platform. Unknown ids contribute nothing; a body with no `id` yields `[]`.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
|
requestBody: form(PlatformIdsRequest, 'Repeated `id=` form fields'),
|
||||||
responses: { 200: json(CachedLogin.array(), 'Flattened accounts across every id') },
|
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 ids = (Array.isArray(raw) ? raw : raw != null ? [raw] : []).map(String)
|
||||||
const out: Array<ReturnType<typeof toCachedLogin>> = []
|
const out: Array<ReturnType<typeof toCachedLogin>> = []
|
||||||
for (const pid of ids) {
|
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)
|
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',
|
'**`cached_login`** — logs into an already-linked account using platform ownership as',
|
||||||
'the credential; no password. Requires a verifying `platform_auth`, and the posted',
|
'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',
|
'`account_id` must be LINKED to exactly the identity it proves. An account with no',
|
||||||
'with no stored platform identity cannot be cached-logged-into.',
|
'link for that identity cannot be cached-logged-into.',
|
||||||
'',
|
'',
|
||||||
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
'**`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.',
|
'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`) —',
|
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
||||||
'identifies the account by `username` or numeric `account_id` and requires the',
|
'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,',
|
'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',
|
'**Platform identity.** An account can be reached from several platform identities;',
|
||||||
'authenticating by platform identity must be one of them, and only a verified id is',
|
'the links are the one thing both the picker and `cached_login` consult, and only a',
|
||||||
'ever written to an account. Steam (`0`) posts a Steam-signed `platform_auth` ticket,',
|
'VERIFIED identity is ever linked. Two platforms can be verified. Steam (`0`) posts a',
|
||||||
'checked offline; the SteamID64 it carries replaces the client-supplied `platform_id`.',
|
'Steam-signed `platform_auth` ticket, checked offline; the SteamID64 it carries',
|
||||||
'Meta/Oculus (`1`) posts `platform_auth` as `{"Nonce":…,"AppId":…}`, which recflare',
|
'replaces the client-supplied `platform_id`. Meta/Oculus (`1`) posts `platform_auth`',
|
||||||
'sends to Meta together with the posted `platform_id` — validation is what binds the',
|
'as `{"Nonce":…,"AppId":…}`, which recflare sends to Meta together with the posted',
|
||||||
'nonce to that user id, so a spoofed id fails. Meta logins therefore need the app',
|
'`platform_id` — validation is what binds the nonce to that user id, so a spoofed id',
|
||||||
'secret (`META_APP_SECRET`) and answer 500 when it is unset. Password and refresh',
|
'fails. Meta logins therefore need the app secret (`META_APP_SECRET`) and answer 500',
|
||||||
'grants carry their own credential and are not gated this way.',
|
'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',
|
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||||
'powers refresh on every login and every refresh grant.',
|
'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
|
// 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,
|
// 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
|
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
|
||||||
// those logins must be one of those two:
|
// verifyPlatformProof). Only a verified identity is ever bound or linked.
|
||||||
// - 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.
|
|
||||||
//
|
//
|
||||||
// The two platforms prove the id in opposite directions, which is why they can't
|
// Two grants are GATED on it — they have no other credential, so an unverifiable
|
||||||
// share a code path: Steam's ticket *carries* a SteamID64 we read out and trust,
|
// platform is fatal:
|
||||||
// so the posted `platform_id` is discarded. Meta's nonce carries nothing — it is
|
// - cached_login authenticates purely by platform identity.
|
||||||
// validated *against* the posted `platform_id`, so that field is an input, and a
|
// - create_account that asserts a platform: we won't bind an identity we can't
|
||||||
// spoofed one fails validation rather than being ignored. Either way what lands in
|
// prove. (create_account with NO platform is the password-account path —
|
||||||
// `platformId` below is proven, never the raw client-supplied field.
|
// 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 verifiedPlatformId: string | null = null
|
||||||
let verifiedPlatform: number | null = null
|
let verifiedPlatform: number | null = null
|
||||||
const platformAsserted = !Number.isNaN(platformInt)
|
if (proof.status === 'verified') {
|
||||||
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
|
verifiedPlatform = proof.platform
|
||||||
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
|
verifiedPlatformId = proof.platformId
|
||||||
if (platformInt === PlatformType.Steam) {
|
} else if (proof.status !== 'none') {
|
||||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
// Log every failure, including the ones a password grant shrugs off: a player
|
||||||
if (!verified) {
|
// who silently never gets a cached login on their headset has no other symptom,
|
||||||
return c.json(
|
// and this line is where "Meta rejected the nonce" becomes visible.
|
||||||
{
|
logger.info('platform_auth not verified', {
|
||||||
error: 'invalid_grant',
|
platform: platformInt,
|
||||||
error_description: 'invalid or missing platform_auth ticket',
|
platformId,
|
||||||
},
|
grantType,
|
||||||
400
|
status: proof.status,
|
||||||
)
|
reason: proof.status === 'rejected' ? proof.reason : undefined,
|
||||||
}
|
})
|
||||||
verifiedPlatform = PlatformType.Steam
|
}
|
||||||
verifiedPlatformId = verified.steamId
|
|
||||||
} else if (platformInt === PlatformType.Oculus) {
|
if (gatedOnPlatform && proof.status !== 'verified') {
|
||||||
// Verifying a Meta login needs the app secret. Without it every Meta player is
|
if (proof.status === 'unsupported') {
|
||||||
// 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 {
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: 'invalid_grant',
|
error: 'invalid_grant',
|
||||||
@@ -496,9 +585,34 @@ const app = new Hono<App>()
|
|||||||
400
|
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:
|
// Resolve the account this token is for:
|
||||||
// - create_account: mint + persist a brand-new account (auto-assigned random
|
// - 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.
|
// username — players don't pick one initially); the token's `sub` is its id.
|
||||||
@@ -526,7 +640,11 @@ const app = new Hono<App>()
|
|||||||
if (
|
if (
|
||||||
maxPerPlatformId > 0 &&
|
maxPerPlatformId > 0 &&
|
||||||
verifiedPlatformId !== null &&
|
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', {
|
logger.info('signup rejected: platform account limit', {
|
||||||
platformId: verifiedPlatformId,
|
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
|
// 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
|
// a Meta-validated nonce). A password/anonymous create_account (no platform)
|
||||||
// is checked against, so only that platform user can log back into the account.
|
// binds nothing. The account blob keeps this first identity as its PRIMARY one
|
||||||
// A password/anonymous create_account (no platform) binds no platformId.
|
// (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, {
|
const account = await createAccount(c.env.DB, {
|
||||||
platforms: platformInt || 0,
|
platforms: platformInt || 0,
|
||||||
platform: verifiedPlatform ?? undefined,
|
platform: verifiedPlatform ?? undefined,
|
||||||
@@ -569,6 +688,14 @@ const app = new Hono<App>()
|
|||||||
lastLoginIp: clientIp || undefined,
|
lastLoginIp: clientIp || undefined,
|
||||||
})
|
})
|
||||||
accountId = String(account.accountId)
|
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).
|
// Establish the login password when one is posted (raw password never stored).
|
||||||
const password = typeof body.password === 'string' ? body.password : ''
|
const password = typeof body.password === 'string' ? body.password : ''
|
||||||
if (password !== '') {
|
if (password !== '') {
|
||||||
@@ -592,11 +719,14 @@ const app = new Hono<App>()
|
|||||||
} else if (grantType === 'cached_login') {
|
} else if (grantType === 'cached_login') {
|
||||||
// Platform-authenticated login into an already-linked account. The client posts
|
// Platform-authenticated login into an already-linked account. The client posts
|
||||||
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
// the `account_id` it got from /cachedlogin/forplatformid together with the
|
||||||
// `platform_id` its platform_auth ticket vouches for. Authorize ONLY when that
|
// `platform_id` its platform_auth vouches for. Authorize ONLY when the link
|
||||||
// account is linked to exactly this platform identity — this is the check that
|
// table says that account is linked to exactly this platform identity — this is
|
||||||
// keeps anyone but platform user `platform_id` out of the account (platform
|
// the check that keeps anyone but that platform user out of the account
|
||||||
// ownership is the credential; no password needed). An account with no stored
|
// (platform ownership is the credential; no password needed). An account with no
|
||||||
// platform identity can't be cached-logged-into and must use a fresh login.
|
// 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
|
// 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
|
// 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 postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||||
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
|
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(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: 'invalid_grant',
|
error: 'invalid_grant',
|
||||||
@@ -646,6 +779,19 @@ const app = new Hono<App>()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
accountId = String(resolvedId)
|
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 setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
|
||||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,19 +73,29 @@ export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
|
|||||||
* see the platform-auth notes on `POST /connect/token`.
|
* see the platform-auth notes on `POST /connect/token`.
|
||||||
*/
|
*/
|
||||||
export const PlatformTypeSchema = z
|
export const PlatformTypeSchema = z
|
||||||
.union([z.literal(-1), z.int().min(0).max(Math.max(...Object.values(PlatformType)))])
|
.union([
|
||||||
|
z.literal(-1),
|
||||||
|
z
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
|
.max(Math.max(...Object.values(PlatformType))),
|
||||||
|
])
|
||||||
.describe(
|
.describe(
|
||||||
Object.entries(PlatformType)
|
Object.entries(PlatformType)
|
||||||
.map(([name, value]) => `${value} ${name}`)
|
.map(([name, value]) => `${value} ${name}`)
|
||||||
.join(', ')
|
.join(', ')
|
||||||
)
|
)
|
||||||
|
|
||||||
/** One entry on the client's login screen, from `toCachedLogin`. */
|
/**
|
||||||
|
* One entry on the client's login screen, from `toCachedLogin` — an account ↔ platform
|
||||||
|
* identity LINK, not an account. An account linked to two platforms yields one entry in
|
||||||
|
* each of their pickers, each reporting the identity that picker was asked about.
|
||||||
|
*/
|
||||||
export const CachedLogin = z.object({
|
export const CachedLogin = z.object({
|
||||||
platform: PlatformTypeSchema,
|
platform: PlatformTypeSchema,
|
||||||
platformId: z
|
platformId: z
|
||||||
.string()
|
.string()
|
||||||
.describe('Platform-native id (a SteamID64 for Steam, a user id for Meta); "" if unlinked'),
|
.describe('The linked platform-native id — a SteamID64 for Steam, a user id for Meta'),
|
||||||
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
|
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"),
|
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
|
||||||
requirePassword: z
|
requirePassword: z
|
||||||
@@ -141,8 +151,9 @@ export const TokenRequest = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
'Platform proof, required for cached_login and platform create_account. Steam: ' +
|
'Platform proof, required for cached_login and platform create_account, and used to ' +
|
||||||
'`{"Ticket":"<hex>","AppId":…}`. Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
|
'link the identity on a password grant. Steam: `{"Ticket":"<hex>","AppId":…}`. ' +
|
||||||
|
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
|
||||||
),
|
),
|
||||||
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
|
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
|
||||||
device_id: z
|
device_id: z
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Platform identity links on the shared `recflare` D1 database (owned by the `auth`
|
||||||
|
* worker, migration 0007). One row per (platform, platform id, account): the Steam
|
||||||
|
* user 76561…211 is linked to account 42, the Meta user 27061… is linked to account
|
||||||
|
* 42 as well, and both let that player into that account without a password.
|
||||||
|
*
|
||||||
|
* This table replaced the single `platformId`/`platform` pair on the account blob as
|
||||||
|
* the thing logins are decided from, because that pair could only hold ONE identity —
|
||||||
|
* a player with a PC and a headset had to pick which device got a cached login. The
|
||||||
|
* blob fields are kept as the account's *primary* identity (the first one linked) for
|
||||||
|
* the account DTO and the refresh grant's claims; nothing authorizes off them.
|
||||||
|
*
|
||||||
|
* It is deliberately the ONE source of truth for both halves of a cached login: the
|
||||||
|
* picker (`/cachedlogin/forplatformid`) lists the accounts this table links to an
|
||||||
|
* identity, and the `cached_login` grant asks this table whether the account it was
|
||||||
|
* handed is linked to the identity that was proven. When those two disagreed the
|
||||||
|
* client was offered an account it could never log into — see the regression test.
|
||||||
|
*
|
||||||
|
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
|
||||||
|
* Meta-validated nonce). It is what turns "this platform user" into "may enter this
|
||||||
|
* account with no password", so an unproven `platform_id` must never reach it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
|
||||||
|
export const PLATFORM_SCHEMA_DDL: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS platform_account (
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
platform INTEGER NOT NULL,
|
||||||
|
platform_id TEXT NOT NULL,
|
||||||
|
linked_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (platform, platform_id, account_id)
|
||||||
|
)`,
|
||||||
|
// The picker's lookup: "which accounts does this identity open?". Covered by the
|
||||||
|
// primary key's leading columns, so no separate index is needed for it.
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_platform_account_account ON platform_account (account_id)`,
|
||||||
|
// Lookup by bare platform id, across platforms — the bulk (friends) route, which
|
||||||
|
// resolves ids it has no platform for.
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_platform_account_platform_id ON platform_account (platform_id)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-time backfill 0007 runs after creating the table: every identity already
|
||||||
|
* bound to an account becomes a link, so nobody loses their cached login at deploy.
|
||||||
|
* Exported so a test can run exactly the statement the migration does.
|
||||||
|
*
|
||||||
|
* `platform` is COALESCEd to 0 because nothing ever defaulted that field — an account
|
||||||
|
* can carry a platformId with no platform recorded, and back when Steam was the only
|
||||||
|
* verifiable platform an unset one *was* Steam.
|
||||||
|
*/
|
||||||
|
export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||||
|
SELECT
|
||||||
|
account_id,
|
||||||
|
COALESCE(json_extract(data, '$.platform'), 0),
|
||||||
|
platform_id,
|
||||||
|
COALESCE(json_extract(data, '$.createdAt'), '1970-01-01T00:00:00Z')
|
||||||
|
FROM account
|
||||||
|
WHERE platform_id IS NOT NULL AND platform_id <> ''`
|
||||||
|
|
||||||
|
/** One account ↔ platform identity link. */
|
||||||
|
export interface PlatformLink {
|
||||||
|
accountId: number
|
||||||
|
platform: number
|
||||||
|
platformId: string
|
||||||
|
/** ISO-8601 time the link was made. */
|
||||||
|
linkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkRow {
|
||||||
|
accountId: number
|
||||||
|
platform: number
|
||||||
|
platformId: string
|
||||||
|
linkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
|
||||||
|
linked_at AS linkedAt FROM platform_account`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a verified platform identity to an account. Idempotent — re-logging in on the
|
||||||
|
* same platform doesn't churn the row, and `linkedAt` keeps the time of the FIRST
|
||||||
|
* link. Returns true when this created a new link.
|
||||||
|
*
|
||||||
|
* Callers must pass an identity the platform itself proved. Nothing in here can tell
|
||||||
|
* a verified id from a spoofed one.
|
||||||
|
*/
|
||||||
|
export async function linkPlatformIdentity(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
platform: number,
|
||||||
|
platformId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (platformId === '') return false
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)`
|
||||||
|
)
|
||||||
|
.bind(accountId, platform, platformId, new Date().toISOString())
|
||||||
|
.run()
|
||||||
|
return res.meta.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accounts a platform identity opens — what the login-screen picker lists.
|
||||||
|
* Ordered oldest link first so the list is stable between launches (D1 row order
|
||||||
|
* isn't). Empty id yields nothing rather than matching every link.
|
||||||
|
*/
|
||||||
|
export async function getLinksForPlatformIdentity(
|
||||||
|
db: D1Database,
|
||||||
|
platform: number,
|
||||||
|
platformId: string
|
||||||
|
): Promise<PlatformLink[]> {
|
||||||
|
if (platformId === '') return []
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
||||||
|
)
|
||||||
|
.bind(platform, platformId)
|
||||||
|
.all<LinkRow>()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links for a bare platform id, whatever platform it belongs to. For the bulk
|
||||||
|
* (friends-resolution) lookup, which posts ids with no platform alongside them, and
|
||||||
|
* for the single-id route when the client sends a non-numeric platform.
|
||||||
|
*/
|
||||||
|
export async function getLinksForPlatformId(
|
||||||
|
db: D1Database,
|
||||||
|
platformId: string
|
||||||
|
): Promise<PlatformLink[]> {
|
||||||
|
if (platformId === '') return []
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
|
||||||
|
.bind(platformId)
|
||||||
|
.all<LinkRow>()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every platform identity linked to an account (a player's PC and headset, say). */
|
||||||
|
export async function getLinksForAccount(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number
|
||||||
|
): Promise<PlatformLink[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
|
||||||
|
.bind(accountId)
|
||||||
|
.all<LinkRow>()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this account is linked to this platform identity — the single check the
|
||||||
|
* `cached_login` grant authorizes on. An account with no link for the presented
|
||||||
|
* identity cannot be cached-logged-into and must use a password.
|
||||||
|
*/
|
||||||
|
export async function isPlatformIdentityLinked(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
platform: number,
|
||||||
|
platformId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (platformId === '') return false
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 AS ok FROM platform_account
|
||||||
|
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
|
||||||
|
)
|
||||||
|
.bind(accountId, platform, platformId)
|
||||||
|
.first<{ ok: number }>()
|
||||||
|
return row !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many accounts one platform identity already opens — the count both signup caps
|
||||||
|
* and link caps are enforced against, so an identity can't accumulate accounts by
|
||||||
|
* creating them under the cap and then linking more in.
|
||||||
|
*/
|
||||||
|
export async function countAccountsForPlatformIdentity(
|
||||||
|
db: D1Database,
|
||||||
|
platform: number,
|
||||||
|
platformId: string
|
||||||
|
): Promise<number> {
|
||||||
|
if (platformId === '') return 0
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT COUNT(*) AS n FROM platform_account WHERE platform = ?1 AND platform_id = ?2`)
|
||||||
|
.bind(platform, platformId)
|
||||||
|
.first<{ n: number }>()
|
||||||
|
return row?.n ?? 0
|
||||||
|
}
|
||||||
@@ -14,7 +14,12 @@ import {
|
|||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
import { isLinkedToPlatformIdentity } from '../../auth.app'
|
import {
|
||||||
|
getLinksForAccount,
|
||||||
|
linkPlatformIdentity,
|
||||||
|
PLATFORM_BACKFILL_SQL,
|
||||||
|
PLATFORM_SCHEMA_DDL,
|
||||||
|
} from '../../platform-db'
|
||||||
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
|
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
|
||||||
|
|
||||||
import type { Env } from '../../context'
|
import type { Env } from '../../context'
|
||||||
@@ -51,6 +56,9 @@ beforeAll(async () => {
|
|||||||
metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET)
|
metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET)
|
||||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
// Platform identity links — one account can hold several (a PC and a headset), and
|
||||||
|
// this table is what both the picker and the cached_login grant read.
|
||||||
|
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
|
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
|
||||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
@@ -137,6 +145,19 @@ async function metaLogin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET a JSON route on the worker and parse the body as `T`. */
|
||||||
|
async function getJson<T>(path: string): Promise<T> {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}${path}`)
|
||||||
|
return (await res.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picker entries a platform identity yields, as the client sees them. */
|
||||||
|
function cachedLogins(platform: number, id: string) {
|
||||||
|
return getJson<Array<Record<string, unknown> & { accountId: number; platform: number }>>(
|
||||||
|
`/cachedlogin/forplatformid/${platform}/${id}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** The `platform_auth` payload a Meta client posts, as observed from a live login. */
|
/** The `platform_auth` payload a Meta client posts, as observed from a live login. */
|
||||||
function metaPlatformAuth(): string {
|
function metaPlatformAuth(): string {
|
||||||
return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' })
|
return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' })
|
||||||
@@ -251,10 +272,7 @@ describe('auth worker routes', () => {
|
|||||||
// cached-login picker offer it, and the cached_login grant accept it.
|
// cached-login picker offer it, and the cached_login grant accept it.
|
||||||
const payload = decodePayload(res.json.access_token as string)
|
const payload = decodePayload(res.json.access_token as string)
|
||||||
const accountId = Number(payload.sub)
|
const accountId = Number(payload.sub)
|
||||||
const lookup = await exports.default.fetch(
|
const linked = await cachedLogins(1, META_USER_ID)
|
||||||
`${ORIGIN}/cachedlogin/forplatformid/1/${META_USER_ID}`
|
|
||||||
)
|
|
||||||
const linked = (await lookup.json()) as Array<Record<string, unknown>>
|
|
||||||
expect(linked).toContainEqual(
|
expect(linked).toContainEqual(
|
||||||
expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID })
|
expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID })
|
||||||
)
|
)
|
||||||
@@ -285,6 +303,7 @@ describe('auth worker routes', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
|
await linkPlatformIdentity(env.DB, 5150, 1, userId)
|
||||||
const res = await metaLogin(
|
const res = await metaLogin(
|
||||||
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` +
|
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` +
|
||||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||||
@@ -298,8 +317,8 @@ describe('auth worker routes', () => {
|
|||||||
|
|
||||||
test('a Meta user id cannot log into an account it is not linked to', async () => {
|
test('a Meta user id cannot log into an account it is not linked to', async () => {
|
||||||
// The Meta account seeded above, claimed by a different (but genuinely proven)
|
// The Meta account seeded above, claimed by a different (but genuinely proven)
|
||||||
// Meta user. Even with a nonce Meta vouches for, the identity has to match the
|
// Meta user. Even with a nonce Meta vouches for, the identity has to be one the
|
||||||
// account's stored one.
|
// account is actually linked to.
|
||||||
const res = await metaLogin(
|
const res = await metaLogin(
|
||||||
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` +
|
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` +
|
||||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||||
@@ -345,6 +364,7 @@ describe('auth worker routes', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
|
await linkPlatformIdentity(env.DB, 31380, 0, steamId)
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual([
|
expect(await res.json()).toEqual([
|
||||||
@@ -358,32 +378,69 @@ describe('auth worker routes', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a Steam-linked account with no stored `platform` field still cached-logs in', async () => {
|
test('one account, a Steam and a Meta identity: both pickers offer it', async () => {
|
||||||
// Regression: nothing defaults an account's `platform` (see defaultAccount), so a
|
// The point of the link table. The same account is reachable from the PC and from
|
||||||
// Steam-linked account can carry a platformId with no platform. The picker offered
|
// the headset, and each picker reports the identity IT was asked about — that's
|
||||||
// such an account (it treats a missing platform as Steam) while the cached_login
|
// what the client posts back on the cached_login grant.
|
||||||
// grant rejected it — "no linked account for this platform identity" forever.
|
const steamId = '76561197962463777'
|
||||||
// Both now run the same check.
|
const metaId = '27061366730207777'
|
||||||
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
accountId: 6200,
|
||||||
|
username: 'CrossPlatform',
|
||||||
|
platform: 0,
|
||||||
|
platformId: steamId,
|
||||||
|
lastLoginTime: '2026-08-01T10:00:00.000Z',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
await linkPlatformIdentity(env.DB, 6200, 0, steamId)
|
||||||
|
await linkPlatformIdentity(env.DB, 6200, 1, metaId)
|
||||||
|
|
||||||
|
const onSteam = await cachedLogins(0, steamId)
|
||||||
|
const onMeta = await cachedLogins(1, metaId)
|
||||||
|
|
||||||
|
expect(onSteam).toEqual([
|
||||||
|
expect.objectContaining({ accountId: 6200, platform: 0, platformId: steamId }),
|
||||||
|
])
|
||||||
|
expect(onMeta).toEqual([
|
||||||
|
expect.objectContaining({ accountId: 6200, platform: 1, platformId: metaId }),
|
||||||
|
])
|
||||||
|
|
||||||
|
// And the grant accepts both, without a password.
|
||||||
|
const viaMeta = await metaLogin(
|
||||||
|
`grant_type=cached_login&account_id=6200&platform=1&platform_id=${metaId}` +
|
||||||
|
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(viaMeta.status).toBe(200)
|
||||||
|
expect(decodePayload(viaMeta.json.access_token as string).sub).toBe('6200')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the picker and the cached_login grant read the same table', async () => {
|
||||||
|
// Regression: the picker used to derive links from the account blob (treating a
|
||||||
|
// missing `platform` as Steam) while the grant ran its own check, so the client
|
||||||
|
// could be handed an account_id that answered "no linked account" forever. Both
|
||||||
|
// now read platform_account, which is why an account with a stale blob identity
|
||||||
|
// is NOT offered — and, since it isn't offered, never rejected either.
|
||||||
const steamId = '76561197962463211'
|
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)')
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
|
.bind(JSON.stringify({ accountId: 8, username: 'SteamOnly', platformId: steamId }))
|
||||||
.run()
|
.run()
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/${steamId}`)
|
|
||||||
const offered = (await res.json()) as Array<{ accountId: number; platform: number }>
|
// No link row yet: not offered.
|
||||||
expect(offered.map((a) => a.accountId)).toContain(8)
|
const before = await cachedLogins(0, steamId)
|
||||||
expect(offered.find((a) => a.accountId === 8)?.platform).toBe(0)
|
expect(before.map((a) => a.accountId)).not.toContain(8)
|
||||||
|
|
||||||
|
// The 0007 backfill is what gives accounts like this one — bound before the link
|
||||||
|
// table existed, and carrying no `platform` field at all — their link.
|
||||||
|
await env.DB.prepare(PLATFORM_BACKFILL_SQL).run()
|
||||||
|
|
||||||
|
const after = await cachedLogins(0, steamId)
|
||||||
|
expect(after.map((a) => a.accountId)).toContain(8)
|
||||||
|
// COALESCEd to Steam, which is what an unset platform meant.
|
||||||
|
expect(after.find((a) => a.accountId === 8)?.platform).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
|
test('POST /connect/token issues a bearer token with role/scope claims', async () => {
|
||||||
@@ -720,6 +777,137 @@ describe('auth worker routes', () => {
|
|||||||
expect(payload.platform_id).toBe('steam-123')
|
expect(payload.platform_id).toBe('steam-123')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A password login is how a player who already has an account signs in on a NEW
|
||||||
|
// device. The client posts its platform proof alongside the password, and linking
|
||||||
|
// the two is what turns the next launch on that device into a cached login.
|
||||||
|
describe('password grant links the platform identity it proves', () => {
|
||||||
|
/** Seed an account with LOGIN_PASSWORD set and no platform identity at all. */
|
||||||
|
async function seedPasswordAccount(id: number, username: string) {
|
||||||
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
accountId: id,
|
||||||
|
username,
|
||||||
|
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a verified Meta login on an existing account links it, and cached login follows', async () => {
|
||||||
|
// Exactly the client's flow: an account made elsewhere, signed into on a headset
|
||||||
|
// with username + password, with the Meta nonce riding along.
|
||||||
|
await seedPasswordAccount(7100, 'djdevin')
|
||||||
|
const metaId = '27061366730201234'
|
||||||
|
const login = await metaLogin(
|
||||||
|
`grant_type=password&username=djdevin&password=${LOGIN_PASSWORD}` +
|
||||||
|
`&platform=1&platform_id=${metaId}` +
|
||||||
|
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(login.status).toBe(200)
|
||||||
|
expect(decodePayload(login.json.access_token as string).sub).toBe('7100')
|
||||||
|
// The nonce was validated against the id being linked — an unproven id is never
|
||||||
|
// linked, since a link is a password-free way into the account.
|
||||||
|
expect(login.graphCalls[0].get('user_id')).toBe(metaId)
|
||||||
|
|
||||||
|
// The headset now gets a cached login: offered by the picker…
|
||||||
|
const offered = await cachedLogins(1, metaId)
|
||||||
|
expect(offered.map((a) => a.accountId)).toContain(7100)
|
||||||
|
|
||||||
|
// …and accepted by the grant, with no password.
|
||||||
|
const cached = await metaLogin(
|
||||||
|
`grant_type=cached_login&account_id=7100&platform=1&platform_id=${metaId}` +
|
||||||
|
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(cached.status).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the first identity linked becomes the account primary; later ones just link', async () => {
|
||||||
|
await seedPasswordAccount(7101, 'multiplatform')
|
||||||
|
const metaId = '27061366730205678'
|
||||||
|
await metaLogin(
|
||||||
|
`grant_type=password&username=multiplatform&password=${LOGIN_PASSWORD}` +
|
||||||
|
`&platform=1&platform_id=${metaId}` +
|
||||||
|
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
// The blob's primary identity was empty, so the first link fills it in — this is
|
||||||
|
// what the account DTO and the refresh grant's claims report.
|
||||||
|
const account = (await env.DB.prepare(
|
||||||
|
'SELECT data FROM account WHERE account_id = 7101'
|
||||||
|
).first<{ data: string }>())!
|
||||||
|
expect(JSON.parse(account.data)).toMatchObject({ platform: 1, platformId: metaId })
|
||||||
|
|
||||||
|
// A second identity on another platform links without disturbing the primary.
|
||||||
|
await linkPlatformIdentity(env.DB, 7101, 0, '76561197962465678')
|
||||||
|
const links = await getLinksForAccount(env.DB, 7101)
|
||||||
|
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
|
||||||
|
[1, metaId],
|
||||||
|
[0, '76561197962465678'],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unverified platform_auth logs in but links nothing', async () => {
|
||||||
|
// The password already proved who this is, so the login stands — but a link is a
|
||||||
|
// password-free way in, and this identity was never proven, so none is written.
|
||||||
|
await seedPasswordAccount(7102, 'unproven')
|
||||||
|
const metaId = '27061366730209876'
|
||||||
|
const login = await metaLogin(
|
||||||
|
`grant_type=password&username=unproven&password=${LOGIN_PASSWORD}` +
|
||||||
|
`&platform=1&platform_id=${metaId}` +
|
||||||
|
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||||
|
false // Meta rejects the nonce
|
||||||
|
)
|
||||||
|
expect(login.status).toBe(200)
|
||||||
|
expect(await getLinksForAccount(env.DB, 7102)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a login with no platform_auth links nothing and asks Meta nothing', async () => {
|
||||||
|
await seedPasswordAccount(7103, 'noproof')
|
||||||
|
const login = await metaLogin(
|
||||||
|
`grant_type=password&username=noproof&password=${LOGIN_PASSWORD}` +
|
||||||
|
`&platform=1&platform_id=27061366730204321`,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(login.status).toBe(200)
|
||||||
|
expect(login.graphCalls).toHaveLength(0)
|
||||||
|
expect(await getLinksForAccount(env.DB, 7103)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('linking obeys the per-identity account cap, without failing the login', async () => {
|
||||||
|
// Otherwise the signup cap would be trivially bypassable: create accounts with a
|
||||||
|
// password, then link the capped identity into all of them.
|
||||||
|
const metaId = '27061366730203333'
|
||||||
|
for (let i = 0; i < 3; i++) await linkPlatformIdentity(env.DB, 8000 + i, 1, metaId)
|
||||||
|
|
||||||
|
await seedPasswordAccount(8100, 'overcap')
|
||||||
|
const login = await metaLogin(
|
||||||
|
`grant_type=password&username=overcap&password=${LOGIN_PASSWORD}` +
|
||||||
|
`&platform=1&platform_id=${metaId}` +
|
||||||
|
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
// The password was valid, so the player is logged in — they just don't get a
|
||||||
|
// cached login on this account.
|
||||||
|
expect(login.status).toBe(200)
|
||||||
|
expect(await getLinksForAccount(env.DB, 8100)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('re-logging in on the same device does not duplicate the link', async () => {
|
||||||
|
await seedPasswordAccount(7104, 'repeatlogin')
|
||||||
|
const metaId = '27061366730207654'
|
||||||
|
const body =
|
||||||
|
`grant_type=password&username=repeatlogin&password=${LOGIN_PASSWORD}` +
|
||||||
|
`&platform=1&platform_id=${metaId}` +
|
||||||
|
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`
|
||||||
|
await metaLogin(body, true)
|
||||||
|
await metaLogin(body, true)
|
||||||
|
expect(await getLinksForAccount(env.DB, 7104)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
|
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 login = await postToken(`account_id=77&platform=0&password=${LOGIN_PASSWORD}`)
|
||||||
const refreshToken = login.json.refresh_token as string
|
const refreshToken = login.json.refresh_token as string
|
||||||
|
|||||||
@@ -37,12 +37,14 @@ export interface Account {
|
|||||||
identityFlags: number
|
identityFlags: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
/**
|
/**
|
||||||
* The platform-native identity linked to this account (e.g. a SteamID64 for
|
* The account's PRIMARY platform identity — the first one linked (e.g. a SteamID64
|
||||||
* platform 0). Stored as a STRING on purpose — a SteamID64 exceeds 2^53 and
|
* for platform 0). Stored as a STRING on purpose: a SteamID64 exceeds 2^53 and
|
||||||
* would lose precision as a JS number. Set at account creation from the login's
|
* would lose precision as a JS number.
|
||||||
* `platform_id`. A cached login is authorized ONLY to the account whose stored
|
*
|
||||||
* `platformId` matches the (platform_auth-ticket-proven) platform id presented,
|
* An account can be reachable from SEVERAL platform identities (a PC and a headset),
|
||||||
* so no one but that platform user can log into the account.
|
* and those live in the `auth` worker's `platform_account` table — NOT here. Logins
|
||||||
|
* are authorized against that table alone; this pair is what the account DTO and a
|
||||||
|
* refreshed token's claims report, and it never gains a second value.
|
||||||
*/
|
*/
|
||||||
platformId?: string
|
platformId?: string
|
||||||
/** PlatformType int (0 = Steam) that `platformId` belongs to. */
|
/** PlatformType int (0 = Steam) that `platformId` belongs to. */
|
||||||
@@ -216,23 +218,6 @@ export async function searchAccounts(
|
|||||||
return parseAll(results)
|
return parseAll(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Accounts linked to a platform-native id (e.g. a SteamID64), for the cached-login
|
|
||||||
* account picker. Backed by the indexed `platform_id` generated column. Empty id
|
|
||||||
* yields no matches (avoids matching every account whose `platformId` is null).
|
|
||||||
*/
|
|
||||||
export async function getAccountsByPlatformId(
|
|
||||||
db: D1Database,
|
|
||||||
platformId: string
|
|
||||||
): Promise<Account[]> {
|
|
||||||
if (platformId === '') return []
|
|
||||||
const { results } = await db
|
|
||||||
.prepare('SELECT data FROM account WHERE platform_id = ?1')
|
|
||||||
.bind(platformId)
|
|
||||||
.all<AccountRow>()
|
|
||||||
return parseAll(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accounts last seen on a given device (the client-supplied `device_id` auth records
|
* Accounts last seen on a given device (the client-supplied `device_id` auth records
|
||||||
* at login). An empty id yields no matches (avoids matching every account with no
|
* at login). An empty id yields no matches (avoids matching every account with no
|
||||||
@@ -258,7 +243,9 @@ export async function getAccountsByDeviceId(db: D1Database, deviceId: string): P
|
|||||||
/** Record the account's most recent successful login time (ISO-8601). */
|
/** Record the account's most recent successful login time (ISO-8601). */
|
||||||
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
|
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.prepare("UPDATE account SET data = json_set(data, '$.lastLoginTime', ?2) WHERE account_id = ?1")
|
.prepare(
|
||||||
|
"UPDATE account SET data = json_set(data, '$.lastLoginTime', ?2) WHERE account_id = ?1"
|
||||||
|
)
|
||||||
.bind(id, time)
|
.bind(id, time)
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
@@ -295,9 +282,7 @@ export async function setLoginContext(
|
|||||||
}
|
}
|
||||||
if (sets.length === 0) return
|
if (sets.length === 0) return
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(`UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1`)
|
||||||
`UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1`
|
|
||||||
)
|
|
||||||
.bind(id, ...binds)
|
.bind(id, ...binds)
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
@@ -324,21 +309,6 @@ export async function countAccountsBySignupIp(db: D1Database, ip: string): Promi
|
|||||||
return row?.n ?? 0
|
return row?.n ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* How many accounts are linked to a platform-native id (e.g. one SteamID64) — the
|
|
||||||
* count a per-platform signup cap is enforced against. Backed by the indexed
|
|
||||||
* `platform_id` generated column. An empty id counts 0 (accounts with no platform
|
|
||||||
* identity aren't attributable to a platform user).
|
|
||||||
*/
|
|
||||||
export async function countAccountsByPlatformId(db: D1Database, platformId: string): Promise<number> {
|
|
||||||
if (platformId === '') return 0
|
|
||||||
const row = await db
|
|
||||||
.prepare('SELECT COUNT(*) AS n FROM account WHERE platform_id = ?1')
|
|
||||||
.bind(platformId)
|
|
||||||
.first<{ n: number }>()
|
|
||||||
return row?.n ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Look up multiple accounts by AccountId (order not guaranteed). */
|
/** Look up multiple accounts by AccountId (order not guaranteed). */
|
||||||
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
|
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
|
||||||
if (ids.length === 0) return []
|
if (ids.length === 0) return []
|
||||||
@@ -411,9 +381,7 @@ export async function getPasswordHash(db: D1Database, id: number): Promise<strin
|
|||||||
/** Persist the account's password hash. Returns false when no such account exists. */
|
/** Persist the account's password hash. Returns false when no such account exists. */
|
||||||
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
|
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
|
||||||
const { meta } = await db
|
const { meta } = await db
|
||||||
.prepare(
|
.prepare("UPDATE account SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1")
|
||||||
"UPDATE account SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1"
|
|
||||||
)
|
|
||||||
.bind(id, hash)
|
.bind(id, hash)
|
||||||
.run()
|
.run()
|
||||||
return meta.changes > 0
|
return meta.changes > 0
|
||||||
|
|||||||
Reference in New Issue
Block a user