mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
meta platform login
This commit is contained in:
@@ -104,6 +104,24 @@ binds it so tokens signed by `auth` verify everywhere. Record its id in `.env` a
|
||||
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
|
||||
```
|
||||
|
||||
The same store also holds `META_APP_SECRET`, the app secret from your app's page in
|
||||
the Meta developer dashboard (developers.meta.com). Only the `auth` worker binds it,
|
||||
and only to authenticate itself to Meta when validating a headset login's nonce —
|
||||
unlike Steam's ticket, which verifies offline, a Meta login cannot be checked without
|
||||
it. Create it too:
|
||||
|
||||
```bash
|
||||
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
|
||||
```
|
||||
|
||||
> ⚠️ Both secrets must **exist** in the store or `just deploy` fails on the `auth`
|
||||
> worker — a binding to a missing secret is a deploy error. If you have no Meta app,
|
||||
> create `META_APP_SECRET` with any placeholder value: Meta sign-ins then fail with a
|
||||
> 500 ("Meta platform verification is not configured") and nothing else is affected.
|
||||
> Steam and password sign-ins are unaffected either way. Put the real value in later
|
||||
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
|
||||
> the secret per request.
|
||||
|
||||
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
|
||||
|
||||
```bash
|
||||
|
||||
+30
-11
@@ -42,7 +42,7 @@ route without documenting it fails rather than silently shipping an incomplete s
|
||||
without matchmaking. A posted `password` becomes the login credential.
|
||||
- **`cached_login`** — logs into an already-linked account using platform ownership as
|
||||
the credential; no password. The posted `account_id` must be linked to exactly the
|
||||
identity the Steam ticket proves.
|
||||
identity `platform_auth` proves.
|
||||
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
|
||||
30-day TTL; platform and platform id come from what was stored at issue time.
|
||||
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
|
||||
@@ -54,15 +54,26 @@ Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `
|
||||
claim, so developer/moderator powers refresh on every login and every refresh grant.
|
||||
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
|
||||
|
||||
### Steam is the only verifiable platform
|
||||
### Verifiable platforms: Steam and Meta
|
||||
|
||||
`platform_auth` tickets are verified **offline** — `src/steam-ticket.ts` parses the
|
||||
ticket and checks Steam's signature against Steam's system public key. No publisher
|
||||
Web API key, no network call. Steam (platform `0`) is therefore the only platform
|
||||
whose identity can be proven, so any grant that authenticates _by platform identity_
|
||||
(`cached_login`, and `create_account` when it asserts a platform) must be Steam. The
|
||||
verified SteamID64 replaces the client-supplied `platform_id` and is the only value
|
||||
ever written to an account's `platformId`.
|
||||
Only an identity we can _prove_ is ever bound to an account, so any grant that
|
||||
authenticates _by platform identity_ (`cached_login`, and `create_account` when it
|
||||
asserts a platform) must be a platform we can verify. Two are:
|
||||
|
||||
- **Steam (`0`)** — `src/steam-ticket.ts` parses the `platform_auth` ticket and checks
|
||||
Steam's signature against Steam's system public key. Verified **offline**: no
|
||||
publisher Web API key, no network call. The SteamID64 the ticket carries replaces the
|
||||
client-supplied `platform_id`.
|
||||
- **Meta / Oculus (`1`)** — `src/meta-nonce.ts` posts the nonce in `platform_auth` to
|
||||
`graph.oculus.com/user_nonce_validate`, authenticated as the app with
|
||||
`META_APP_SECRET`. Meta's nonce proves nothing by itself; validation is what binds it
|
||||
to a user id, so here the posted `platform_id` is an _input_ to the check and a
|
||||
spoofed one fails. This means an outbound request on every Meta login, and no Meta
|
||||
login at all without the app secret — an unset `META_APP_SECRET` answers 500 rather
|
||||
than falling back to trusting the client.
|
||||
|
||||
Everything else is refused. Whichever platform, the value written to an account's
|
||||
`platformId` is the verified one, never the raw `platform_id` field.
|
||||
|
||||
## Signup caps
|
||||
|
||||
@@ -82,6 +93,7 @@ small private server, or when a shared network is being locked out.
|
||||
| -------------------- | ------------- | ------------------------------------------------------ |
|
||||
| `DB` | D1 | Shared `recflare` database; this worker owns `account` |
|
||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
|
||||
| `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` |
|
||||
|
||||
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
|
||||
@@ -109,12 +121,19 @@ wrangler secrets-store store create recflare --scopes workers
|
||||
|
||||
# Set the shared signing key (prompted for the value)
|
||||
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
|
||||
|
||||
# Set the Meta app secret. Required for the deploy to succeed even with no Meta app —
|
||||
# a binding to a missing secret is a deploy error. Any placeholder will do; Meta
|
||||
# sign-ins then answer 500 until it holds the real value.
|
||||
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
|
||||
```
|
||||
|
||||
For local `wrangler dev`, seed a local value (omit `--remote`) so `.get()` resolves:
|
||||
For local `wrangler dev`, seed local values (omit `--remote`) so `.get()` resolves:
|
||||
|
||||
```sh
|
||||
wrangler secrets-store secret create local --name JWT_SECRET --value <dev-key> --scopes workers
|
||||
wrangler secrets-store secret create local --name META_APP_SECRET --value <app-secret> --scopes workers
|
||||
```
|
||||
|
||||
Rotating the store value invalidates all existing tokens (clients re-authenticate).
|
||||
Rotating the signing key invalidates all existing tokens (clients re-authenticate).
|
||||
The Meta secret is read per request, so updating it takes effect without a redeploy.
|
||||
|
||||
+119
-73
@@ -24,11 +24,11 @@ import {
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import { verifyMetaNonce } from './meta-nonce'
|
||||
import {
|
||||
CachedLogin,
|
||||
ChangePasswordRequest,
|
||||
ChangePasswordResponse,
|
||||
FakeCachedLogin,
|
||||
form,
|
||||
json,
|
||||
OAuthError,
|
||||
@@ -49,15 +49,6 @@ import type { App } from './context'
|
||||
const TOKEN_SCOPE =
|
||||
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
|
||||
|
||||
/** The canned entry served for any Oculus cached-login lookup. See the route below. */
|
||||
const FAKE_OCULUS_CACHED_LOGIN = {
|
||||
platform: PlatformType.Oculus,
|
||||
platformId: '1',
|
||||
accountId: 1,
|
||||
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
||||
requirePassword: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Signup caps, enforced on create_account only (never on login — an existing account
|
||||
* always stays reachable, however many accounts its owner has since accumulated).
|
||||
@@ -166,8 +157,9 @@ function accountRoles(account: Pick<Account, 'isDeveloper' | 'isModerator'> | nu
|
||||
/**
|
||||
* The platform an account's `platformId` belongs to. Nothing defaults the `platform`
|
||||
* field (see defaultAccount), so an account can carry a platform identity with no
|
||||
* platform recorded — and Steam is the only platform whose identity we can prove, so
|
||||
* an unset one *is* Steam.
|
||||
* platform recorded — and until Meta verification landed Steam was the only identity
|
||||
* we could prove, so an unset one *is* Steam. Every account bound since records its
|
||||
* platform explicitly; this default only covers those older rows.
|
||||
*/
|
||||
function accountPlatform(account: Pick<Account, 'platform'>): number {
|
||||
return account.platform ?? 0
|
||||
@@ -180,8 +172,9 @@ function accountPlatform(account: Pick<Account, 'platform'>): number {
|
||||
* client is handed an `account_id` it can never log into ("no linked account for this
|
||||
* platform identity" on every attempt).
|
||||
*
|
||||
* `platformId` must be the *proven* identity (the SteamID64 from a verified
|
||||
* platform_auth ticket), never the client-supplied `platform_id` field.
|
||||
* `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'>,
|
||||
@@ -196,7 +189,7 @@ export function isLinkedToPlatformIdentity(
|
||||
* 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 platform_auth ticket) is the credential for a cached login — no prompt.
|
||||
* (the verified `platform_auth`) is the credential for a cached login — no prompt.
|
||||
*/
|
||||
function toCachedLogin(account: Account) {
|
||||
return {
|
||||
@@ -254,8 +247,6 @@ const app = new Hono<App>()
|
||||
'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.',
|
||||
'EXCEPT platform 1 (Oculus), which is stubbed: it ignores the id and returns one',
|
||||
'canned, non-redeemable entry with `requirePassword: true`.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
@@ -269,27 +260,18 @@ const app = new Hono<App>()
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Platform-native id — a SteamID64 for Steam.',
|
||||
description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(
|
||||
CachedLogin.or(FakeCachedLogin).array(),
|
||||
'Matching accounts; `[]` if none. The canned entry for platform 1 (Oculus).'
|
||||
),
|
||||
200: json(CachedLogin.array(), 'Matching accounts; `[]` if none'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const { platform, id } = c.req.param()
|
||||
logger.info('cached login lookup', { platform, id })
|
||||
const platformInt = Number.parseInt(platform, 10)
|
||||
// Oculus has no identity flow yet, so there is nothing in the DB to look up and
|
||||
// the real path would always yield []. Hand back one canned entry instead, so the
|
||||
// Oculus client gets past its login screen. `requirePassword` is true — unlike a
|
||||
// genuine cached login there is no platform ticket behind this, so the client must
|
||||
// prompt. Delete this branch once Oculus platform auth lands.
|
||||
if (platformInt === PlatformType.Oculus) return c.json([FAKE_OCULUS_CACHED_LOGIN])
|
||||
const accounts = await getAccountsByPlatformId(c.env.DB, id)
|
||||
// Offer only accounts the `cached_login` grant will actually accept — same check.
|
||||
return c.json(
|
||||
@@ -345,11 +327,12 @@ const app = new Hono<App>()
|
||||
'`password` becomes the login credential. Subject to two independent signup caps,',
|
||||
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
|
||||
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
|
||||
'`platform`, that platform must be Steam and `platform_auth` must verify.',
|
||||
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
|
||||
'must verify.',
|
||||
'',
|
||||
'**`cached_login`** — logs into an already-linked account using platform ownership as',
|
||||
'the credential; no password. Requires a Steam `platform_auth` ticket, and the posted',
|
||||
'`account_id` must be linked to exactly the identity that ticket proves. An account',
|
||||
'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.',
|
||||
'',
|
||||
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
||||
@@ -360,10 +343,14 @@ const app = new Hono<App>()
|
||||
'matching `password`. An account with no stored hash cannot be logged into at all,',
|
||||
'which is what closes id/username-only takeover.',
|
||||
'',
|
||||
'**Platform verification.** Steam (platform `0`) is the only platform that can be',
|
||||
'verified, via its signed `platform_auth` ticket, so any grant authenticating by',
|
||||
'platform identity must be Steam. The verified SteamID64 replaces the client-supplied',
|
||||
'`platform_id` and is the only value ever written to an account. Password and refresh',
|
||||
'**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.',
|
||||
'',
|
||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||
@@ -378,13 +365,17 @@ const app = new Hono<App>()
|
||||
400: json(
|
||||
OAuthError,
|
||||
[
|
||||
'Unusable grant: bad credentials, an unverifiable or non-Steam platform, an',
|
||||
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
|
||||
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
|
||||
].join(' ')
|
||||
),
|
||||
500: json(
|
||||
OAuthError,
|
||||
'JWT_SECRET is unset — a token is refused rather than signed with an empty key'
|
||||
[
|
||||
'The server is missing a secret it cannot proceed without: JWT_SECRET (a token is',
|
||||
'refused rather than signed with an empty key) or, on a Meta login, META_APP_SECRET',
|
||||
'(no nonce can be validated without it).',
|
||||
].join(' ')
|
||||
),
|
||||
},
|
||||
}),
|
||||
@@ -419,41 +410,93 @@ const app = new Hono<App>()
|
||||
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
|
||||
// A platform-authenticated login proves who you are with the platform itself,
|
||||
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
|
||||
// ticket. So those logins must be Steam:
|
||||
// - cached_login authenticates purely by platform identity → always Steam-only.
|
||||
// - create_account that asserts a platform is rejected unless it's Steam, since
|
||||
// we won't bind an identity we can't prove. (create_account with NO platform
|
||||
// is the password-account path — allowed, but it binds no platformId.)
|
||||
// The verified SteamID64 replaces the unauthenticated `platform_id` field and 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.
|
||||
let verifiedSteamId: string | null = null
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
let verifiedPlatformId: string | null = null
|
||||
let verifiedPlatform: number | null = null
|
||||
const platformAsserted = !Number.isNaN(platformInt)
|
||||
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
|
||||
if (platformInt !== PlatformType.Steam) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'unsupported platform; only Steam can be verified',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
|
||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
||||
if (!verified) {
|
||||
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 {
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'invalid or missing platform_auth ticket',
|
||||
error_description: 'unsupported platform; only Steam and Meta can be verified',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
verifiedSteamId = verified.steamId
|
||||
platformId = verified.steamId
|
||||
platformId = verifiedPlatformId
|
||||
}
|
||||
|
||||
// Resolve the account this token is for:
|
||||
@@ -482,10 +525,12 @@ const app = new Hono<App>()
|
||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||
if (
|
||||
maxPerPlatformId > 0 &&
|
||||
verifiedSteamId !== null &&
|
||||
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
|
||||
verifiedPlatformId !== null &&
|
||||
(await countAccountsByPlatformId(c.env.DB, verifiedPlatformId)) >= maxPerPlatformId
|
||||
) {
|
||||
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
|
||||
logger.info('signup rejected: platform account limit', {
|
||||
platformId: verifiedPlatformId,
|
||||
})
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
@@ -509,14 +554,14 @@ const app = new Hono<App>()
|
||||
)
|
||||
}
|
||||
|
||||
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
|
||||
// `platformId` (the SteamID64) is what a later cached login is checked against,
|
||||
// so only this Steam user can log back into the account. A password/anonymous
|
||||
// create_account (no platform) binds no platformId.
|
||||
// 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.
|
||||
const account = await createAccount(c.env.DB, {
|
||||
platforms: platformInt || 0,
|
||||
platform: verifiedSteamId !== null ? 0 : undefined,
|
||||
platformId: verifiedSteamId ?? undefined,
|
||||
platform: verifiedPlatform ?? undefined,
|
||||
platformId: verifiedPlatformId ?? undefined,
|
||||
lastLoginTime: new Date().toISOString(),
|
||||
deviceId: deviceId || undefined,
|
||||
deviceClass: deviceId ? deviceClass : undefined,
|
||||
@@ -553,8 +598,9 @@ const app = new Hono<App>()
|
||||
// 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.
|
||||
//
|
||||
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
|
||||
// above), never the client-supplied field. See steam-ticket.ts.
|
||||
// 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
|
||||
// client-supplied field. See steam-ticket.ts and meta-nonce.ts.
|
||||
//
|
||||
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
|
||||
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
|
||||
|
||||
@@ -12,6 +12,13 @@ export type Env = SharedHonoEnv & {
|
||||
// signed here verify in all of them. Provisioned via `wrangler secrets-store`;
|
||||
// the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
// The Meta (Oculus) app secret, from the app's page in the Meta developer dashboard.
|
||||
// Bound from the same Secrets Store as JWT_SECRET; resolve it with `.get()`. Used
|
||||
// only to authenticate US to Meta's graph API when validating a login nonce (see
|
||||
// meta-nonce.ts) — it never leaves the worker. Unlike Steam, whose ticket verifies
|
||||
// offline, Meta logins are impossible without it, so an empty value fails those
|
||||
// logins with a 500 rather than silently trusting the client's platform_id.
|
||||
META_APP_SECRET: SecretsStoreSecret
|
||||
// Signup caps, both optional (see auth.app.ts for what each arm counts and why).
|
||||
// Unset falls back to the DEFAULT_MAX_ACCOUNTS_* constants there; 0 disables that arm.
|
||||
// Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Verification of a Meta (Oculus) `platform_auth` nonce, against Meta's graph API.
|
||||
*
|
||||
* Steam's ticket is signed by Steam, so we verify it offline with no network and no
|
||||
* credential (see steam-ticket.ts). Meta's user proof is the opposite: an opaque
|
||||
* nonce that means nothing on its own. The only way to know it is genuine is to ask
|
||||
* Meta — which is why this path makes an outbound request on every Meta login and
|
||||
* cannot work at all without the app secret.
|
||||
*
|
||||
* A Meta login posts
|
||||
*
|
||||
* platform_auth = {"Nonce":"<64 chars>","AppId":"1232175103309633","Source":"logged in user"}
|
||||
* platform_id = <the Meta user id>
|
||||
*
|
||||
* and validation is what BINDS those two together: `user_nonce_validate` answers
|
||||
* "was this nonce issued to this user, for this app?". So the posted `platform_id` is
|
||||
* an *input* here rather than something read out of a ticket, and a spoofed one fails
|
||||
* — a nonce Meta issued to user A does not validate as user B. The id is therefore
|
||||
* proven exactly as much as a Steam ticket's SteamID64 is, and is safe to bind to an
|
||||
* account. (It's an app-scoped id: it identifies the player within this app only.)
|
||||
*
|
||||
* The `AppId` comes from the payload rather than config because it must be the app the
|
||||
* nonce was issued for — a different one simply fails, since the access token below
|
||||
* pairs it with our secret. `Source` is informational and ignored.
|
||||
*
|
||||
* Shape and retry policy follow the reference Go server's utils/oculus.go.
|
||||
*/
|
||||
|
||||
/** Meta's nonce-validation endpoint. Takes a form body, answers `{"is_valid":true}`. */
|
||||
const NONCE_VALIDATE_URL = 'https://graph.oculus.com/user_nonce_validate'
|
||||
|
||||
/**
|
||||
* Graph error codes worth retrying — 1 (unknown) and 2 (service temporarily
|
||||
* unavailable) are Meta-side hiccups, not a verdict on the nonce. Anything else is a
|
||||
* real answer and retrying it just delays a login that is going to fail anyway.
|
||||
*/
|
||||
const TRANSIENT_ERROR_CODES = new Set([1, 2])
|
||||
|
||||
/**
|
||||
* Attempts per verification. A login is latency-sensitive and a nonce is single-use
|
||||
* with a short life, so this is deliberately small: two quick retries (250ms, 1s of
|
||||
* backoff) ride out a blip, and a longer outage fails the login rather than hanging
|
||||
* the client on a headset loading screen.
|
||||
*/
|
||||
const MAX_ATTEMPTS = 3
|
||||
|
||||
/** The trustworthy identity proven by a validated nonce. */
|
||||
export interface VerifiedMetaIdentity {
|
||||
/** The Meta user id the nonce was issued to — app-scoped, numeric. */
|
||||
userId: string
|
||||
/** The Meta app the nonce was issued for. */
|
||||
appId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcome of a verification. Failures carry a `reason` for the server log: the
|
||||
* client is told only that its platform_auth was rejected (it can't act on more), but
|
||||
* an operator debugging a headset that won't log in needs to know whether Meta said
|
||||
* "bad nonce", "bad access token" (the wrong app secret) or nothing at all.
|
||||
*/
|
||||
export type MetaVerification =
|
||||
{ ok: true; identity: VerifiedMetaIdentity } | { ok: false; reason: string }
|
||||
|
||||
/** The `{Nonce, AppId}` a Meta `platform_auth` payload carries. */
|
||||
export interface MetaPlatformAuth {
|
||||
nonce: string
|
||||
appId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Meta `platform_auth` payload, or null when it isn't one. The `AppId` must be
|
||||
* numeric — it is interpolated into the access token below, and this is what keeps a
|
||||
* client-supplied string out of that credential.
|
||||
*/
|
||||
export function parseMetaPlatformAuth(platformAuth: string): MetaPlatformAuth | null {
|
||||
let parsed: { Nonce?: unknown; AppId?: unknown }
|
||||
try {
|
||||
parsed = JSON.parse(platformAuth) as { Nonce?: unknown; AppId?: unknown }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const { Nonce: nonce, AppId: appId } = parsed
|
||||
if (typeof nonce !== 'string' || nonce === '') return null
|
||||
if (typeof appId !== 'string' || !/^\d+$/.test(appId)) return null
|
||||
return { nonce, appId }
|
||||
}
|
||||
|
||||
/** The graph response we care about; everything else in the body is ignored. */
|
||||
interface NonceValidateResponse {
|
||||
is_valid?: boolean
|
||||
error?: { message?: string; code?: number; type?: string; is_transient?: boolean }
|
||||
}
|
||||
|
||||
/** One validation round-trip. `retryable` says whether another attempt could differ. */
|
||||
async function validateOnce(
|
||||
form: URLSearchParams,
|
||||
fetcher: typeof fetch
|
||||
): Promise<{ ok: boolean; retryable: boolean; reason: string }> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetcher(NONCE_VALIDATE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: form.toString(),
|
||||
})
|
||||
} catch (err) {
|
||||
return { ok: false, retryable: true, reason: `request failed: ${String(err)}` }
|
||||
}
|
||||
|
||||
let body: NonceValidateResponse
|
||||
try {
|
||||
body = (await res.json()) as NonceValidateResponse
|
||||
} catch {
|
||||
// A non-JSON body is Meta's edge (a 5xx error page, a rate-limit page), not a
|
||||
// verdict — treat it the way a dropped connection is treated.
|
||||
return { ok: false, retryable: true, reason: `HTTP ${res.status} with a non-JSON body` }
|
||||
}
|
||||
|
||||
if (body.error) {
|
||||
const { code, message, is_transient } = body.error
|
||||
return {
|
||||
ok: false,
|
||||
retryable: is_transient === true || (code !== undefined && TRANSIENT_ERROR_CODES.has(code)),
|
||||
reason: `graph error ${code ?? '?'}: ${message ?? 'no message'}`,
|
||||
}
|
||||
}
|
||||
if (body.is_valid !== true) return { ok: false, retryable: false, reason: 'nonce rejected' }
|
||||
return { ok: true, retryable: false, reason: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Meta `platform_auth` payload against the `userId` it is claimed for, and
|
||||
* return the identity it proves. Only ever succeeds for a nonce Meta itself confirms
|
||||
* was issued to that user for that app.
|
||||
*
|
||||
* `appSecret` is the app's secret from the Meta developer dashboard; without it no
|
||||
* Meta login can be verified, so callers must treat an unset secret as a server
|
||||
* misconfiguration rather than a bad credential. `fetcher` is injectable so tests can
|
||||
* run the retry and response handling without reaching the network.
|
||||
*/
|
||||
export async function verifyMetaNonce(
|
||||
platformAuth: string,
|
||||
userId: string,
|
||||
appSecret: string,
|
||||
fetcher?: typeof fetch
|
||||
): Promise<MetaVerification> {
|
||||
if (appSecret === '') return { ok: false, reason: 'no app secret configured' }
|
||||
// The user id is what the nonce is checked against, so an absent or non-numeric one
|
||||
// can't be verified — reject before spending a round-trip on it.
|
||||
if (!/^\d+$/.test(userId)) return { ok: false, reason: 'missing or non-numeric platform_id' }
|
||||
const auth = parseMetaPlatformAuth(platformAuth)
|
||||
if (!auth) return { ok: false, reason: 'malformed platform_auth payload' }
|
||||
|
||||
// `OC|<app id>|<app secret>` is Meta's app access token — it authenticates the
|
||||
// *app*, which is why the secret never leaves the server.
|
||||
const form = new URLSearchParams({
|
||||
nonce: auth.nonce,
|
||||
user_id: userId,
|
||||
access_token: `OC|${auth.appId}|${appSecret}`,
|
||||
})
|
||||
|
||||
// Resolved per call, not at module load, so a test's stubbed global is honoured.
|
||||
const doFetch = fetcher ?? globalThis.fetch
|
||||
let last = { ok: false, retryable: false, reason: 'not attempted' }
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
last = await validateOnce(form, doFetch)
|
||||
if (last.ok) return { ok: true, identity: { userId, appId: auth.appId } }
|
||||
if (!last.retryable || attempt === MAX_ATTEMPTS) break
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * attempt * 250))
|
||||
}
|
||||
return { ok: false, reason: last.reason }
|
||||
}
|
||||
+13
-13
@@ -69,8 +69,8 @@ export const PlatformType = {
|
||||
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
|
||||
|
||||
/**
|
||||
* A PlatformType by value. Only Steam can actually be verified — see the
|
||||
* platform-auth notes on `POST /connect/token`.
|
||||
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
|
||||
* see the platform-auth notes on `POST /connect/token`.
|
||||
*/
|
||||
export const PlatformTypeSchema = z
|
||||
.union([z.literal(-1), z.int().min(0).max(Math.max(...Object.values(PlatformType)))])
|
||||
@@ -83,7 +83,9 @@ export const PlatformTypeSchema = z
|
||||
/** One entry on the client's login screen, from `toCachedLogin`. */
|
||||
export const CachedLogin = z.object({
|
||||
platform: PlatformTypeSchema,
|
||||
platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'),
|
||||
platformId: z
|
||||
.string()
|
||||
.describe('Platform-native id (a SteamID64 for Steam, a user id for Meta); "" if unlinked'),
|
||||
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
|
||||
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
|
||||
requirePassword: z
|
||||
@@ -91,14 +93,6 @@ export const CachedLogin = z.object({
|
||||
.describe('Always false — platform ownership is the credential for a cached login'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The stubbed Oculus cached login. Same shape as `CachedLogin`, but `requirePassword`
|
||||
* is true — nothing proves platform ownership, so the client has to prompt.
|
||||
*/
|
||||
export const FakeCachedLogin = CachedLogin.extend({
|
||||
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
|
||||
})
|
||||
|
||||
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
|
||||
export const OAuthError = z.object({
|
||||
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
|
||||
@@ -139,11 +133,17 @@ export const TokenRequest = z.object({
|
||||
platform_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Unverified; ignored in favour of the Steam-verified id where a ticket is required'),
|
||||
.describe(
|
||||
'On Steam, unverified and ignored in favour of the id the ticket carries. On Meta it is ' +
|
||||
'the id the nonce is validated against, so it must be the real (numeric) user id'
|
||||
),
|
||||
platform_auth: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Steam session ticket. Required for cached_login and platform create_account'),
|
||||
.describe(
|
||||
'Platform proof, required for cached_login and platform create_account. Steam: ' +
|
||||
'`{"Ticket":"<hex>","AppId":…}`. Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
|
||||
),
|
||||
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
|
||||
device_id: z
|
||||
.string()
|
||||
|
||||
@@ -31,12 +31,23 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
|
||||
// accounts the login tests authenticate as (42, 77).
|
||||
const LOGIN_PASSWORD = 'correct-horse'
|
||||
|
||||
// Meta (Oculus) logins verify their nonce by calling graph.oculus.com authenticated
|
||||
// as the app, so the tests seed an app secret and stub that call — see metaLogin.
|
||||
const META_APP_SECRET = 'test-meta-app-secret'
|
||||
const META_APP_ID = '1232175103309633'
|
||||
const META_USER_ID = '27061366730207360'
|
||||
const META_NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
|
||||
/** Set in beforeAll; needed to overwrite the secret in the not-configured test. */
|
||||
let metaSecretId: string
|
||||
|
||||
// Apply the accounts schema so create_account can persist (mirrors the migration),
|
||||
// and seed the Orientation room (owned by the rooms worker) so signup can place
|
||||
// the new player there.
|
||||
beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||
// The Meta app secret, likewise — a Meta login is refused outright without one.
|
||||
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 REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
|
||||
@@ -101,6 +112,38 @@ async function postToken(
|
||||
return { status: res.status, json: (await res.json()) as Record<string, unknown> }
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a Meta grant to /connect/token with graph.oculus.com stubbed to answer
|
||||
* `is_valid`. The worker runs in this isolate, so replacing the global fetch is what
|
||||
* stands in for Meta — `verifyMetaNonce` resolves `globalThis.fetch` per call for
|
||||
* exactly this reason. Returns the graph requests the worker made alongside the
|
||||
* response, so a test can assert WHICH user id the nonce was validated against.
|
||||
*/
|
||||
async function metaLogin(
|
||||
body: string,
|
||||
isValid: boolean
|
||||
): Promise<{ status: number; json: Record<string, unknown>; graphCalls: URLSearchParams[] }> {
|
||||
const graphCalls: URLSearchParams[] = []
|
||||
const realFetch = globalThis.fetch
|
||||
globalThis.fetch = (async (url: string, init?: { body?: string }) => {
|
||||
if (url.startsWith('https://graph.oculus.com/')) {
|
||||
graphCalls.push(new URLSearchParams(init?.body ?? ''))
|
||||
return Response.json({ is_valid: isValid })
|
||||
}
|
||||
return realFetch(url, init)
|
||||
}) as unknown as typeof fetch
|
||||
try {
|
||||
return { ...(await postToken(body)), graphCalls }
|
||||
} finally {
|
||||
globalThis.fetch = realFetch
|
||||
}
|
||||
}
|
||||
|
||||
/** The `platform_auth` payload a Meta client posts, as observed from a live login. */
|
||||
function metaPlatformAuth(): string {
|
||||
return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' })
|
||||
}
|
||||
|
||||
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
|
||||
function changePassword(body: string, token?: string): Promise<Response> {
|
||||
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
|
||||
@@ -122,32 +165,25 @@ describe('auth worker routes', () => {
|
||||
expect(await res.text()).toBe('"AA=="')
|
||||
})
|
||||
|
||||
// Platform 0 (Steam), not 1 — platform 1 is Oculus, which is stubbed below.
|
||||
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/abc123`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
test.each([
|
||||
['0 (Steam)', 0],
|
||||
['1 (Meta)', 1],
|
||||
])(
|
||||
'GET /cachedlogin/forplatformid/%s/:id returns [] for an unknown id',
|
||||
async (_label, platform) => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/cachedlogin/forplatformid/${platform}/abc123`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
// Oculus is stubbed: no DB lookup, one canned entry whatever the id.
|
||||
test('GET /cachedlogin/forplatformid/1/:id returns the canned Oculus entry', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/anything`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([
|
||||
{
|
||||
platform: 1,
|
||||
platformId: '1',
|
||||
accountId: 1,
|
||||
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
||||
requirePassword: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
// Only Steam (platform 0) can be verified (via its signed platform_auth ticket),
|
||||
// so every OTHER platform is rejected on the platform-authenticated grants — we
|
||||
// won't bind or authorize an identity we can't prove.
|
||||
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
|
||||
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
|
||||
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
|
||||
// on the platform-authenticated grants: we won't bind or authorize an identity we
|
||||
// can't prove.
|
||||
test.each([2, 3, 4, 5, 6, 7, 8])(
|
||||
'create_account rejects unverifiable platform %i',
|
||||
async (platform) => {
|
||||
const res = await postToken(
|
||||
@@ -155,11 +191,11 @@ describe('auth worker routes', () => {
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
expect(res.json.error_description).toContain('only Steam')
|
||||
expect(res.json.error_description).toContain('only Steam and Meta')
|
||||
}
|
||||
)
|
||||
|
||||
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
|
||||
test.each([2, 3, 4, 5, 6, 7, 8])(
|
||||
'cached_login rejects unverifiable platform %i',
|
||||
async (platform) => {
|
||||
const res = await postToken(
|
||||
@@ -167,7 +203,7 @@ describe('auth worker routes', () => {
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
expect(res.json.error_description).toContain('only Steam')
|
||||
expect(res.json.error_description).toContain('only Steam and Meta')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -191,6 +227,111 @@ describe('auth worker routes', () => {
|
||||
expect(res.json.error_description).toContain('platform_auth')
|
||||
})
|
||||
|
||||
test('Meta create_account requires a platform_auth nonce', async () => {
|
||||
// platform=1 with no nonce must not bind the spoofable platform_id field.
|
||||
const res = await postToken(`grant_type=create_account&platform=1&platform_id=${META_USER_ID}`)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
expect(res.json.error_description).toContain('platform_auth')
|
||||
})
|
||||
|
||||
test('Meta create_account binds the id Meta validated the nonce against', async () => {
|
||||
const res = await metaLogin(
|
||||
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}&device_id=meta-device`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// The nonce was validated against the posted user id, authenticated as the app.
|
||||
expect(res.graphCalls).toHaveLength(1)
|
||||
expect(res.graphCalls[0].get('nonce')).toBe(META_NONCE)
|
||||
expect(res.graphCalls[0].get('user_id')).toBe(META_USER_ID)
|
||||
expect(res.graphCalls[0].get('access_token')).toBe(`OC|${META_APP_ID}|${META_APP_SECRET}`)
|
||||
|
||||
// The account is bound to platform 1 with that id — which is what makes the
|
||||
// cached-login picker offer it, and the cached_login grant accept it.
|
||||
const payload = decodePayload(res.json.access_token as string)
|
||||
const accountId = Number(payload.sub)
|
||||
const lookup = await exports.default.fetch(
|
||||
`${ORIGIN}/cachedlogin/forplatformid/1/${META_USER_ID}`
|
||||
)
|
||||
const linked = (await lookup.json()) as Array<Record<string, unknown>>
|
||||
expect(linked).toContainEqual(
|
||||
expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID })
|
||||
)
|
||||
// Platform ownership is the credential, so the client is not asked for a password.
|
||||
expect(linked.every((a) => a.requirePassword === false)).toBe(true)
|
||||
})
|
||||
|
||||
test('Meta create_account is rejected when Meta does not vouch for the nonce', async () => {
|
||||
const res = await metaLogin(
|
||||
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
false
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error).toBe('invalid_grant')
|
||||
expect(res.json.error_description).toContain('platform_auth')
|
||||
})
|
||||
|
||||
test('Meta cached_login logs into the linked account with no password', async () => {
|
||||
const userId = '27061366730209999'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 5150,
|
||||
username: 'MetaPlayer',
|
||||
platform: 1,
|
||||
platformId: userId,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
const res = await metaLogin(
|
||||
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.graphCalls[0].get('user_id')).toBe(userId)
|
||||
const payload = decodePayload(res.json.access_token as string)
|
||||
expect(payload.sub).toBe('5150')
|
||||
})
|
||||
|
||||
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)
|
||||
// Meta user. Even with a nonce Meta vouches for, the identity has to match the
|
||||
// account's stored one.
|
||||
const res = await metaLogin(
|
||||
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toContain('no linked account')
|
||||
})
|
||||
|
||||
test('a Meta login is refused (500) when META_APP_SECRET is unset', async () => {
|
||||
// An operator misconfiguration, not a bad credential: without the secret no nonce
|
||||
// can be validated, and the alternative — trusting the posted platform_id — would
|
||||
// let anyone log into any Meta-linked account by naming its user id.
|
||||
const admin = adminSecretsStore(env.META_APP_SECRET)
|
||||
await admin.update('', metaSecretId)
|
||||
try {
|
||||
const res = await metaLogin(
|
||||
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(500)
|
||||
expect(res.json.error).toBe('server_error')
|
||||
// Nothing was asked of Meta, and nothing was trusted.
|
||||
expect(res.graphCalls).toHaveLength(0)
|
||||
} finally {
|
||||
await admin.update(META_APP_SECRET, metaSecretId)
|
||||
}
|
||||
})
|
||||
|
||||
test('cachedlogin/forplatformid returns the DTO for a bound (Steam) account', async () => {
|
||||
// Seed a Steam-linked account directly (a real create_account needs a live
|
||||
// ticket); assert the picker projects the CachedLogin DTO the client expects.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { parseMetaPlatformAuth, verifyMetaNonce } from '../../meta-nonce'
|
||||
|
||||
// The payload shape a real Meta login posts, captured from a live client. `Source`
|
||||
// is informational and ignored; the AppId is Rec Room's Meta app.
|
||||
const NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
|
||||
const APP_ID = '1232175103309633'
|
||||
const USER_ID = '27061366730207360'
|
||||
const PLATFORM_AUTH = JSON.stringify({ Nonce: NONCE, AppId: APP_ID, Source: 'logged in user' })
|
||||
const APP_SECRET = 'test-app-secret'
|
||||
|
||||
/**
|
||||
* A fetch stub answering with `bodies` (one body, or one per attempt), recording every
|
||||
* request it was handed. Typed to what `verifyMetaNonce` actually passes — a string URL
|
||||
* and a string body — rather than the whole of `fetch`, then cast at the boundary.
|
||||
*/
|
||||
function stubFetch(bodies: unknown, status = 200) {
|
||||
const queue = Array.isArray(bodies) ? [...(bodies as unknown[])] : [bodies]
|
||||
const calls: Array<{ url: string; form: URLSearchParams }> = []
|
||||
const fetcher = (async (url: string, init?: { body?: string }) => {
|
||||
calls.push({ url, form: new URLSearchParams(init?.body ?? '') })
|
||||
const body = queue.length > 1 ? queue.shift() : queue[0]
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as unknown as typeof fetch
|
||||
return { fetcher, calls }
|
||||
}
|
||||
|
||||
describe('meta-nonce', () => {
|
||||
test('parses the platform_auth payload the client posts', () => {
|
||||
expect(parseMetaPlatformAuth(PLATFORM_AUTH)).toEqual({ nonce: NONCE, appId: APP_ID })
|
||||
})
|
||||
|
||||
test.each([
|
||||
['not json', 'nonsense'],
|
||||
['no nonce', JSON.stringify({ AppId: APP_ID })],
|
||||
['empty nonce', JSON.stringify({ Nonce: '', AppId: APP_ID })],
|
||||
['no app id', JSON.stringify({ Nonce: NONCE })],
|
||||
// The app id is interpolated into the graph access token, so a non-numeric one
|
||||
// is refused rather than sent.
|
||||
['non-numeric app id', JSON.stringify({ Nonce: NONCE, AppId: 'OC|evil' })],
|
||||
])('rejects a malformed payload (%s)', (_label, payload) => {
|
||||
expect(parseMetaPlatformAuth(payload)).toBeNull()
|
||||
})
|
||||
|
||||
test('validates the nonce against the posted user id and returns the identity', async () => {
|
||||
const { fetcher, calls } = stubFetch({ is_valid: true })
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result).toEqual({ ok: true, identity: { userId: USER_ID, appId: APP_ID } })
|
||||
|
||||
// The request Meta actually sees: the nonce is bound to THIS user id, and the
|
||||
// app authenticates itself with `OC|<app id>|<secret>`.
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].url).toBe('https://graph.oculus.com/user_nonce_validate')
|
||||
expect(calls[0].form.get('nonce')).toBe(NONCE)
|
||||
expect(calls[0].form.get('user_id')).toBe(USER_ID)
|
||||
expect(calls[0].form.get('access_token')).toBe(`OC|${APP_ID}|${APP_SECRET}`)
|
||||
})
|
||||
|
||||
test('rejects a nonce Meta does not vouch for', async () => {
|
||||
const { fetcher } = stubFetch({ is_valid: false })
|
||||
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)).toEqual({
|
||||
ok: false,
|
||||
reason: 'nonce rejected',
|
||||
})
|
||||
})
|
||||
|
||||
// The whole point of validating against the posted id: a nonce genuinely issued to
|
||||
// one user does not authenticate another. Meta answers is_valid:false for the
|
||||
// mismatch, so nobody can log in by naming someone else's Meta user id.
|
||||
test('a nonce presented for the wrong user id fails', async () => {
|
||||
const { fetcher, calls } = stubFetch({ is_valid: false })
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, '99999999999999999', APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(calls[0].form.get('user_id')).toBe('99999999999999999')
|
||||
})
|
||||
|
||||
test.each([
|
||||
['missing', ''],
|
||||
['non-numeric', 'not-an-id'],
|
||||
])('refuses a %s user id without calling Meta', async (_label, userId) => {
|
||||
const { fetcher, calls } = stubFetch({ is_valid: true })
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, userId, APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('refuses to attempt verification with no app secret', async () => {
|
||||
const { fetcher, calls } = stubFetch({ is_valid: true })
|
||||
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, '', fetcher)).toEqual({
|
||||
ok: false,
|
||||
reason: 'no app secret configured',
|
||||
})
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('surfaces a graph error with its code, for the server log', async () => {
|
||||
const { fetcher } = stubFetch({
|
||||
error: { code: 100, message: 'Invalid OAuth access token', type: 'OAuthException' },
|
||||
})
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'graph error 100: Invalid OAuth access token',
|
||||
})
|
||||
})
|
||||
|
||||
test('a non-retryable graph error is not retried', async () => {
|
||||
const { fetcher, calls } = stubFetch({ error: { code: 100, message: 'bad token' } })
|
||||
await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('retries a transient graph error and succeeds on a later attempt', async () => {
|
||||
const { fetcher, calls } = stubFetch([
|
||||
{ error: { code: 2, message: 'service temporarily unavailable' } },
|
||||
{ is_valid: true },
|
||||
])
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('gives up after three attempts when Meta stays unavailable', async () => {
|
||||
const { fetcher, calls } = stubFetch({ error: { code: 1, message: 'unknown error' } })
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(calls).toHaveLength(3)
|
||||
})
|
||||
|
||||
test('treats a network failure as transient', async () => {
|
||||
let attempts = 0
|
||||
const fetcher = (async () => {
|
||||
attempts++
|
||||
throw new Error('connection reset')
|
||||
}) as unknown as typeof fetch
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result.ok).toBe(false)
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
test('treats a non-JSON body (an edge error page) as transient', async () => {
|
||||
let attempts = 0
|
||||
const fetcher = (async () => {
|
||||
attempts++
|
||||
return new Response('<html>502</html>', { status: 502 })
|
||||
}) as unknown as typeof fetch
|
||||
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
|
||||
expect(result).toEqual({ ok: false, reason: 'HTTP 502 with a non-JSON body' })
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -22,11 +22,23 @@
|
||||
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
|
||||
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
|
||||
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
|
||||
//
|
||||
// META_APP_SECRET is the Meta (Oculus) app secret, bound only by this worker: Meta
|
||||
// logins are verified by asking Meta to validate the login nonce, which requires
|
||||
// authenticating as the app (see src/meta-nonce.ts). Both secrets must EXIST in the
|
||||
// store or the deploy fails — an operator with no Meta app still has to create
|
||||
// META_APP_SECRET (any placeholder will do); Meta logins then fail with a 500 until
|
||||
// it holds the real value, and nothing else is affected. See DEPLOYING.md.
|
||||
"secrets_store_secrets": [
|
||||
{
|
||||
"binding": "JWT_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "JWT_SECRET"
|
||||
},
|
||||
{
|
||||
"binding": "META_APP_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "META_APP_SECRET"
|
||||
}
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
|
||||
@@ -12,6 +12,9 @@ import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/
|
||||
export type Env = SharedHonoEnv & {
|
||||
// HS256 JWT signing key (shared Secrets Store). Tokens signed by `auth` verify everywhere.
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
// Meta (Oculus) app secret, from the same store. Read only by `auth`, to validate a
|
||||
// headset login's nonce with Meta (see apps/auth/src/meta-nonce.ts).
|
||||
META_APP_SECRET: SecretsStoreSecret
|
||||
// Shared `recflare` database (accounts, auth, api, clubs, match, rooms, …).
|
||||
DB: D1Database
|
||||
// Image storage bucket (api, img).
|
||||
|
||||
@@ -50,13 +50,20 @@
|
||||
"crons": ["*/5 * * * *"]
|
||||
},
|
||||
"logpush": false,
|
||||
// Shared Secrets Store holding the HS256 JWT signing key. "local" store_id replaced
|
||||
// with RECFLARE_SECRETS_STORE at deploy.
|
||||
// Shared Secrets Store holding the HS256 JWT signing key, plus the Meta app secret
|
||||
// the mounted `auth` app needs to verify Oculus logins. "local" store_id replaced
|
||||
// with RECFLARE_SECRETS_STORE at deploy. Both must exist in the store or the deploy
|
||||
// fails — see DEPLOYING.md.
|
||||
"secrets_store_secrets": [
|
||||
{
|
||||
"binding": "JWT_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "JWT_SECRET"
|
||||
},
|
||||
{
|
||||
"binding": "META_APP_SECRET",
|
||||
"store_id": "local",
|
||||
"secret_name": "META_APP_SECRET"
|
||||
}
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
|
||||
@@ -23,11 +23,14 @@ import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL, SOURCE_REPO } from './links'
|
||||
* the claim Privacy.2 is judged on, and it goes stale the moment a worker stores
|
||||
* something new.
|
||||
*
|
||||
* "How you sign in" describes Meta SSO (PlatformType.Oculus), which the auth worker
|
||||
* still stubs — see the FAKE_OCULUS_CACHED_LOGIN branch in apps/auth/src/auth.app.ts.
|
||||
* When that lands, check the text still matches what the integration actually requests
|
||||
* from Meta: Privacy.2 asks for extra detail about platform features specifically, and
|
||||
* the same disclosure has to agree with the Data Use Checkup filed for the app.
|
||||
* "How you sign in" describes Meta SSO (PlatformType.Oculus), now implemented in
|
||||
* apps/auth/src/meta-nonce.ts. What that integration actually sends Meta is the login
|
||||
* nonce plus the user id it is claimed for, and all it gets back is valid/not valid —
|
||||
* so the disclosure's claim that Meta "learns that a sign-in happened" is right, but it
|
||||
* over-discloses on two points that should be squared with the Data Use Checkup filed
|
||||
* for the app: we do NOT retrieve a display name (only the user id is stored, see
|
||||
* accounts-db.ts), and nonce validation does not check app entitlement. Privacy.2 asks
|
||||
* for extra detail about platform features specifically, so keep this exact.
|
||||
*/
|
||||
|
||||
/** Last substantive revision, shown in the header. Bump when the text changes. */
|
||||
|
||||
Reference in New Issue
Block a user