From a46f6db9d701f6a56bf991a0faa7bce7882c44ce Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sun, 2 Aug 2026 01:57:01 -0400 Subject: [PATCH] meta platform login --- DEPLOYING.md | 18 ++ apps/auth/README.md | 41 +++- apps/auth/src/auth.app.ts | 192 ++++++++++------- apps/auth/src/context.ts | 7 + apps/auth/src/meta-nonce.ts | 172 +++++++++++++++ apps/auth/src/openapi.ts | 26 +-- apps/auth/src/test/integration/api.test.ts | 197 +++++++++++++++--- .../src/test/integration/meta-nonce.test.ts | 155 ++++++++++++++ apps/auth/wrangler.jsonc | 12 ++ apps/mono/src/context.ts | 3 + apps/mono/wrangler.jsonc | 11 +- apps/www/src/privacy.ts | 13 +- 12 files changed, 715 insertions(+), 132 deletions(-) create mode 100644 apps/auth/src/meta-nonce.ts create mode 100644 apps/auth/src/test/integration/meta-nonce.test.ts diff --git a/DEPLOYING.md b/DEPLOYING.md index 85ecb22..7ed3a7f 100644 --- a/DEPLOYING.md +++ b/DEPLOYING.md @@ -104,6 +104,24 @@ binds it so tokens signed by `auth` verify everywhere. Record its id in `.env` a wrangler secrets-store secret create --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 --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 diff --git a/apps/auth/README.md b/apps/auth/README.md index e46be8d..9ada603 100644 --- a/apps/auth/README.md +++ b/apps/auth/README.md @@ -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 --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 --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 --scopes workers +wrangler secrets-store secret create local --name META_APP_SECRET --value --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. diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index d6e620f..13f751e 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -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 | 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): number { return account.platform ?? 0 @@ -180,8 +172,9 @@ function accountPlatform(account: Pick): 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, @@ -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() '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() 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() '`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() '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() 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() // 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() 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() ) } - // 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() // 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 diff --git a/apps/auth/src/context.ts b/apps/auth/src/context.ts index cadde16..3af0d31 100644 --- a/apps/auth/src/context.ts +++ b/apps/auth/src/context.ts @@ -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 diff --git a/apps/auth/src/meta-nonce.ts b/apps/auth/src/meta-nonce.ts new file mode 100644 index 0000000..7ab498e --- /dev/null +++ b/apps/auth/src/meta-nonce.ts @@ -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 = + * + * 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 { + 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||` 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 } +} diff --git a/apps/auth/src/openapi.ts b/apps/auth/src/openapi.ts index f4e535a..6757527 100644 --- a/apps/auth/src/openapi.ts +++ b/apps/auth/src/openapi.ts @@ -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":"","AppId":…}`. Meta: `{"Nonce":…,"AppId":…,"Source":…}`' + ), refresh_token: z.string().optional().describe('Required on a refresh_token grant'), device_id: z .string() diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 75c8992..b26b799 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -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 } } +/** + * 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; 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 { 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> + 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. diff --git a/apps/auth/src/test/integration/meta-nonce.test.ts b/apps/auth/src/test/integration/meta-nonce.test.ts new file mode 100644 index 0000000..a36b26f --- /dev/null +++ b/apps/auth/src/test/integration/meta-nonce.test.ts @@ -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||`. + 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('502', { 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) + }) +}) diff --git a/apps/auth/wrangler.jsonc b/apps/auth/wrangler.jsonc index 4f910fb..f8dc089 100644 --- a/apps/auth/wrangler.jsonc +++ b/apps/auth/wrangler.jsonc @@ -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, diff --git a/apps/mono/src/context.ts b/apps/mono/src/context.ts index 19532a9..4ee09c1 100644 --- a/apps/mono/src/context.ts +++ b/apps/mono/src/context.ts @@ -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). diff --git a/apps/mono/wrangler.jsonc b/apps/mono/wrangler.jsonc index 4049140..4cbfda5 100644 --- a/apps/mono/wrangler.jsonc +++ b/apps/mono/wrangler.jsonc @@ -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, diff --git a/apps/www/src/privacy.ts b/apps/www/src/privacy.ts index f6e4edc..1023c56 100644 --- a/apps/www/src/privacy.ts +++ b/apps/www/src/privacy.ts @@ -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. */