meta platform login

This commit is contained in:
Devin Zuczek
2026-08-02 01:57:01 -04:00
parent b3f1d04823
commit a46f6db9d7
12 changed files with 715 additions and 132 deletions
+18
View File
@@ -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 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! Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
```bash ```bash
+30 -11
View File
@@ -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. without matchmaking. A posted `password` becomes the login credential.
- **`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. The posted `account_id` must be linked to exactly the 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. - **`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. 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 - **`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. claim, so developer/moderator powers refresh on every login and every refresh grant.
Grant those flags with `runx admin grant-developer` / `grant-moderator`. 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 Only an identity we can _prove_ is ever bound to an account, so any grant that
ticket and checks Steam's signature against Steam's system public key. No publisher authenticates _by platform identity_ (`cached_login`, and `create_account` when it
Web API key, no network call. Steam (platform `0`) is therefore the only platform asserts a platform) must be a platform we can verify. Two are:
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 - **Steam (`0`)** — `src/steam-ticket.ts` parses the `platform_auth` ticket and checks
verified SteamID64 replaces the client-supplied `platform_id` and is the only value Steam's signature against Steam's system public key. Verified **offline**: no
ever written to an account's `platformId`. 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 ## 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` | | `DB` | D1 | Shared `recflare` database; this worker owns `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 |
| `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`
@@ -109,12 +121,19 @@ wrangler secrets-store store create recflare --scopes workers
# Set the shared signing key (prompted for the value) # Set the shared signing key (prompted for the value)
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote 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 ```sh
wrangler secrets-store secret create local --name JWT_SECRET --value <dev-key> --scopes workers 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
View File
@@ -24,11 +24,11 @@ import {
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt' import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import { verifyMetaNonce } from './meta-nonce'
import { import {
CachedLogin, CachedLogin,
ChangePasswordRequest, ChangePasswordRequest,
ChangePasswordResponse, ChangePasswordResponse,
FakeCachedLogin,
form, form,
json, json,
OAuthError, OAuthError,
@@ -49,15 +49,6 @@ import type { App } from './context'
const TOKEN_SCOPE = 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' '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 * Signup caps, enforced on create_account only (never on login — an existing account
* always stays reachable, however many accounts its owner has since accumulated). * 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` * 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 * 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 * platform recorded — and until Meta verification landed Steam was the only identity
* an unset one *is* Steam. * 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 { function accountPlatform(account: Pick<Account, 'platform'>): number {
return account.platform ?? 0 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 * client is handed an `account_id` it can never log into ("no linked account for this
* platform identity" on every attempt). * platform identity" on every attempt).
* *
* `platformId` must be the *proven* identity (the SteamID64 from a verified * `platformId` must be the *proven* identity the SteamID64 read out of a verified
* platform_auth ticket), never the client-supplied `platform_id` field. * Steam ticket, or the Meta user id a validated nonce was issued to — never the raw
* client-supplied `platform_id` field.
*/ */
export function isLinkedToPlatformIdentity( export function isLinkedToPlatformIdentity(
account: Pick<Account, 'platform' | 'platformId'>, 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 * 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 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) { function toCachedLogin(account: Account) {
return { return {
@@ -254,8 +247,6 @@ const app = new Hono<App>()
'Filtered to those a `cached_login` grant would actually accept, so an entry here', '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', 'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls',
'back to a fresh login or create_account.', '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(' '), ].join(' '),
parameters: [ parameters: [
{ {
@@ -269,27 +260,18 @@ const app = new Hono<App>()
name: 'id', name: 'id',
in: 'path', in: 'path',
required: true, 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' }, schema: { type: 'string' },
}, },
], ],
responses: { responses: {
200: json( 200: json(CachedLogin.array(), 'Matching accounts; `[]` if none'),
CachedLogin.or(FakeCachedLogin).array(),
'Matching accounts; `[]` if none. The canned entry for platform 1 (Oculus).'
),
}, },
}), }),
async (c) => { async (c) => {
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)
// 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) const accounts = await getAccountsByPlatformId(c.env.DB, id)
// Offer only accounts the `cached_login` grant will actually accept — same check. // Offer only accounts the `cached_login` grant will actually accept — same check.
return c.json( return c.json(
@@ -345,11 +327,12 @@ const app = new Hono<App>()
'`password` becomes the login credential. Subject to two independent signup caps,', '`password` becomes the login credential. Subject to two independent signup caps,',
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /', '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', '`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', '**`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', 'the credential; no password. Requires a verifying `platform_auth`, and the posted',
'`account_id` must be linked to exactly the identity that ticket proves. An account', '`account_id` must be linked to exactly the identity it proves. An account',
'with no stored platform identity cannot be cached-logged-into.', 'with no stored platform 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',
@@ -360,10 +343,14 @@ const app = new Hono<App>()
'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.',
'', '',
'**Platform verification.** Steam (platform `0`) is the only platform that can be', '**Platform verification.** Two platforms can be verified, so any grant',
'verified, via its signed `platform_auth` ticket, so any grant authenticating by', 'authenticating by platform identity must be one of them, and only a verified id is',
'platform identity must be Steam. The verified SteamID64 replaces the client-supplied', 'ever written to an account. Steam (`0`) posts a Steam-signed `platform_auth` ticket,',
'`platform_id` and is the only value ever written to an account. Password and refresh', '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.', 'grants carry their own credential and are not gated this way.',
'', '',
'**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',
@@ -378,13 +365,17 @@ const app = new Hono<App>()
400: json( 400: json(
OAuthError, 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', 'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
].join(' ') ].join(' ')
), ),
500: json( 500: json(
OAuthError, 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. // login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? '' const clientIp = c.req.header('cf-connecting-ip') ?? ''
// A platform-authenticated login proves who you are with the platform itself, // A platform-authenticated login proves who you are with the platform itself, and
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth // we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
// ticket. So those logins must be Steam: // and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth. So
// - cached_login authenticates purely by platform identity → always Steam-only. // those logins must be one of those two:
// - create_account that asserts a platform is rejected unless it's Steam, since // - cached_login authenticates purely by platform identity → always gated.
// we won't bind an identity we can't prove. (create_account with NO platform // - create_account that asserts a platform is rejected unless we can verify that
// is the password-account path — allowed, but it binds no platformId.) // platform, since we won't bind an identity we can't prove. (create_account
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is // with NO platform is the password-account path — allowed, but binds no
// the ONLY value ever written to an account's `platformId`. Credential (password) // platformId.)
// and refresh_token grants carry their own credential and aren't gated here. // The verified id is the ONLY value ever written to an account's `platformId`.
let verifiedSteamId: string | null = null // 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) const platformAsserted = !Number.isNaN(platformInt)
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) { 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 platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null if (platformInt === PlatformType.Steam) {
if (!verified) { 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( return c.json(
{ {
error: 'invalid_grant', error: 'invalid_grant',
error_description: 'invalid or missing platform_auth ticket', error_description: 'unsupported platform; only Steam and Meta can be verified',
}, },
400 400
) )
} }
verifiedSteamId = verified.steamId platformId = verifiedPlatformId
platformId = verified.steamId
} }
// Resolve the account this token is for: // 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) const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if ( if (
maxPerPlatformId > 0 && maxPerPlatformId > 0 &&
verifiedSteamId !== null && verifiedPlatformId !== null &&
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId (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( return c.json(
{ {
error: 'invalid_grant', 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 // Bind the platform identity ONLY when the platform proved it (a Steam ticket or
// `platformId` (the SteamID64) is what a later cached login is checked against, // a Meta-validated nonce). That bound `platformId` is what a later cached login
// so only this Steam user can log back into the account. A password/anonymous // is checked against, so only that platform user can log back into the account.
// create_account (no platform) binds no platformId. // A password/anonymous create_account (no platform) binds no platformId.
const account = await createAccount(c.env.DB, { const account = await createAccount(c.env.DB, {
platforms: platformInt || 0, platforms: platformInt || 0,
platform: verifiedSteamId !== null ? 0 : undefined, platform: verifiedPlatform ?? undefined,
platformId: verifiedSteamId ?? undefined, platformId: verifiedPlatformId ?? undefined,
lastLoginTime: new Date().toISOString(), lastLoginTime: new Date().toISOString(),
deviceId: deviceId || undefined, deviceId: deviceId || undefined,
deviceClass: deviceId ? deviceClass : 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 // ownership is the credential; no password needed). An account with no stored
// platform identity can't be cached-logged-into and must use a fresh login. // platform 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 // NB: `platform_id` here is the verified identity set above — the SteamID64 from
// above), never the client-supplied field. See steam-ticket.ts. // 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 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
+7
View File
@@ -12,6 +12,13 @@ export type Env = SharedHonoEnv & {
// signed here verify in all of them. Provisioned via `wrangler secrets-store`; // 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). // the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
JWT_SECRET: SecretsStoreSecret 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). // 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. // 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 // Typed `string | number` because a var declared in wrangler.jsonc `vars` arrives as a
+172
View File
@@ -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
View File
@@ -69,8 +69,8 @@ export const PlatformType = {
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType] export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
/** /**
* A PlatformType by value. Only Steam can actually be verified — see the * A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
* 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)))])
@@ -83,7 +83,9 @@ export const PlatformTypeSchema = z
/** One entry on the client's login screen, from `toCachedLogin`. */ /** One entry on the client's login screen, from `toCachedLogin`. */
export const CachedLogin = z.object({ export const CachedLogin = z.object({
platform: PlatformTypeSchema, 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'), 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
@@ -91,14 +93,6 @@ export const CachedLogin = z.object({
.describe('Always false — platform ownership is the credential for a cached login'), .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). */ /** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
export const OAuthError = z.object({ export const OAuthError = z.object({
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']), error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
@@ -139,11 +133,17 @@ export const TokenRequest = z.object({
platform_id: z platform_id: z
.string() .string()
.optional() .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 platform_auth: z
.string() .string()
.optional() .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'), refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
device_id: z device_id: z
.string() .string()
+169 -28
View File
@@ -31,12 +31,23 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
// accounts the login tests authenticate as (42, 77). // accounts the login tests authenticate as (42, 77).
const LOGIN_PASSWORD = 'correct-horse' 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), // 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 // and seed the Orientation room (owned by the rooms worker) so signup can place
// the new player there. // the new player there.
beforeAll(async () => { beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves. // Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key') 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 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()
// Presence table (owned by the rooms worker) — signup seeds the Orientation row. // 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> } 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. */ /** POST a form-urlencoded body to changepassword with an optional bearer token. */
function changePassword(body: string, token?: string): Promise<Response> { function changePassword(body: string, token?: string): Promise<Response> {
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, { return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
@@ -122,32 +165,25 @@ describe('auth worker routes', () => {
expect(await res.text()).toBe('"AA=="') expect(await res.text()).toBe('"AA=="')
}) })
// Platform 0 (Steam), not 1 — platform 1 is Oculus, which is stubbed below. test.each([
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => { ['0 (Steam)', 0],
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/abc123`) ['1 (Meta)', 1],
expect(res.status).toBe(200) ])(
expect(await res.json()).toEqual([]) '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. // Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
test('GET /cachedlogin/forplatformid/1/:id returns the canned Oculus entry', async () => { // ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/anything`) // on the platform-authenticated grants: we won't bind or authorize an identity we
expect(res.status).toBe(200) // can't prove.
expect(await res.json()).toEqual([ test.each([2, 3, 4, 5, 6, 7, 8])(
{
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])(
'create_account rejects unverifiable platform %i', 'create_account rejects unverifiable platform %i',
async (platform) => { async (platform) => {
const res = await postToken( const res = await postToken(
@@ -155,11 +191,11 @@ describe('auth worker routes', () => {
) )
expect(res.status).toBe(400) expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant') 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', 'cached_login rejects unverifiable platform %i',
async (platform) => { async (platform) => {
const res = await postToken( const res = await postToken(
@@ -167,7 +203,7 @@ describe('auth worker routes', () => {
) )
expect(res.status).toBe(400) expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant') 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') 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 () => { 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 // Seed a Steam-linked account directly (a real create_account needs a live
// ticket); assert the picker projects the CachedLogin DTO the client expects. // 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)
})
})
+12
View File
@@ -22,11 +22,23 @@
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the // 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" // 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. // 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": [ "secrets_store_secrets": [
{ {
"binding": "JWT_SECRET", "binding": "JWT_SECRET",
"store_id": "local", "store_id": "local",
"secret_name": "JWT_SECRET" "secret_name": "JWT_SECRET"
},
{
"binding": "META_APP_SECRET",
"store_id": "local",
"secret_name": "META_APP_SECRET"
} }
], ],
"upload_source_maps": true, "upload_source_maps": true,
+3
View File
@@ -12,6 +12,9 @@ import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/
export type Env = SharedHonoEnv & { export type Env = SharedHonoEnv & {
// HS256 JWT signing key (shared Secrets Store). Tokens signed by `auth` verify everywhere. // HS256 JWT signing key (shared Secrets Store). Tokens signed by `auth` verify everywhere.
JWT_SECRET: SecretsStoreSecret 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, …). // Shared `recflare` database (accounts, auth, api, clubs, match, rooms, …).
DB: D1Database DB: D1Database
// Image storage bucket (api, img). // Image storage bucket (api, img).
+9 -2
View File
@@ -50,13 +50,20 @@
"crons": ["*/5 * * * *"] "crons": ["*/5 * * * *"]
}, },
"logpush": false, "logpush": false,
// Shared Secrets Store holding the HS256 JWT signing key. "local" store_id replaced // Shared Secrets Store holding the HS256 JWT signing key, plus the Meta app secret
// with RECFLARE_SECRETS_STORE at deploy. // 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": [ "secrets_store_secrets": [
{ {
"binding": "JWT_SECRET", "binding": "JWT_SECRET",
"store_id": "local", "store_id": "local",
"secret_name": "JWT_SECRET" "secret_name": "JWT_SECRET"
},
{
"binding": "META_APP_SECRET",
"store_id": "local",
"secret_name": "META_APP_SECRET"
} }
], ],
"upload_source_maps": true, "upload_source_maps": true,
+8 -5
View File
@@ -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 * the claim Privacy.2 is judged on, and it goes stale the moment a worker stores
* something new. * something new.
* *
* "How you sign in" describes Meta SSO (PlatformType.Oculus), which the auth worker * "How you sign in" describes Meta SSO (PlatformType.Oculus), now implemented in
* still stubs — see the FAKE_OCULUS_CACHED_LOGIN branch in apps/auth/src/auth.app.ts. * apps/auth/src/meta-nonce.ts. What that integration actually sends Meta is the login
* When that lands, check the text still matches what the integration actually requests * nonce plus the user id it is claimed for, and all it gets back is valid/not valid —
* from Meta: Privacy.2 asks for extra detail about platform features specifically, and * so the disclosure's claim that Meta "learns that a sign-in happened" is right, but it
* the same disclosure has to agree with the Data Use Checkup filed for the app. * 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. */ /** Last substantive revision, shown in the header. Bump when the text changes. */