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
+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.
- **`cached_login`** — logs into an already-linked account using platform ownership as
the credential; no password. The posted `account_id` must be linked to exactly the
identity the Steam ticket proves.
identity `platform_auth` proves.
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
30-day TTL; platform and platform id come from what was stored at issue time.
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
@@ -54,15 +54,26 @@ Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `
claim, so developer/moderator powers refresh on every login and every refresh grant.
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
### Steam is the only verifiable platform
### Verifiable platforms: Steam and Meta
`platform_auth` tickets are verified **offline**`src/steam-ticket.ts` parses the
ticket and checks Steam's signature against Steam's system public key. No publisher
Web API key, no network call. Steam (platform `0`) is therefore the only platform
whose identity can be proven, so any grant that authenticates _by platform identity_
(`cached_login`, and `create_account` when it asserts a platform) must be Steam. The
verified SteamID64 replaces the client-supplied `platform_id` and is the only value
ever written to an account's `platformId`.
Only an identity we can _prove_ is ever bound to an account, so any grant that
authenticates _by platform identity_ (`cached_login`, and `create_account` when it
asserts a platform) must be a platform we can verify. Two are:
- **Steam (`0`)** — `src/steam-ticket.ts` parses the `platform_auth` ticket and checks
Steam's signature against Steam's system public key. Verified **offline**: no
publisher Web API key, no network call. The SteamID64 the ticket carries replaces the
client-supplied `platform_id`.
- **Meta / Oculus (`1`)** — `src/meta-nonce.ts` posts the nonce in `platform_auth` to
`graph.oculus.com/user_nonce_validate`, authenticated as the app with
`META_APP_SECRET`. Meta's nonce proves nothing by itself; validation is what binds it
to a user id, so here the posted `platform_id` is an _input_ to the check and a
spoofed one fails. This means an outbound request on every Meta login, and no Meta
login at all without the app secret — an unset `META_APP_SECRET` answers 500 rather
than falling back to trusting the client.
Everything else is refused. Whichever platform, the value written to an account's
`platformId` is the verified one, never the raw `platform_id` field.
## Signup caps
@@ -82,6 +93,7 @@ small private server, or when a shared network is being locked out.
| -------------------- | ------------- | ------------------------------------------------------ |
| `DB` | D1 | Shared `recflare` database; this worker owns `account` |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key |
| `META_APP_SECRET` | Secrets Store | Meta app secret; only used to validate a login nonce |
| `MAX_ACCOUNTS_PER_*` | vars | Optional signup caps; read via `intVar` |
Migrations live in `migrations/` and are tracked in their own `d1_migrations_auth`
@@ -109,12 +121,19 @@ wrangler secrets-store store create recflare --scopes workers
# Set the shared signing key (prompted for the value)
wrangler secrets-store secret create <store-id> --name JWT_SECRET --scopes workers --remote
# Set the Meta app secret. Required for the deploy to succeed even with no Meta app —
# a binding to a missing secret is a deploy error. Any placeholder will do; Meta
# sign-ins then answer 500 until it holds the real value.
wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes workers --remote
```
For local `wrangler dev`, seed a local value (omit `--remote`) so `.get()` resolves:
For local `wrangler dev`, seed local values (omit `--remote`) so `.get()` resolves:
```sh
wrangler secrets-store secret create local --name JWT_SECRET --value <dev-key> --scopes workers
wrangler secrets-store secret create local --name META_APP_SECRET --value <app-secret> --scopes workers
```
Rotating the store value invalidates all existing tokens (clients re-authenticate).
Rotating the signing key invalidates all existing tokens (clients re-authenticate).
The Meta secret is read per request, so updating it takes effect without a redeploy.
+119 -73
View File
@@ -24,11 +24,11 @@ import {
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import { verifyMetaNonce } from './meta-nonce'
import {
CachedLogin,
ChangePasswordRequest,
ChangePasswordResponse,
FakeCachedLogin,
form,
json,
OAuthError,
@@ -49,15 +49,6 @@ import type { App } from './context'
const TOKEN_SCOPE =
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
/** The canned entry served for any Oculus cached-login lookup. See the route below. */
const FAKE_OCULUS_CACHED_LOGIN = {
platform: PlatformType.Oculus,
platformId: '1',
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
} as const
/**
* Signup caps, enforced on create_account only (never on login — an existing account
* always stays reachable, however many accounts its owner has since accumulated).
@@ -166,8 +157,9 @@ function accountRoles(account: Pick<Account, 'isDeveloper' | 'isModerator'> | nu
/**
* The platform an account's `platformId` belongs to. Nothing defaults the `platform`
* field (see defaultAccount), so an account can carry a platform identity with no
* platform recorded — and Steam is the only platform whose identity we can prove, so
* an unset one *is* Steam.
* platform recorded — and until Meta verification landed Steam was the only identity
* we could prove, so an unset one *is* Steam. Every account bound since records its
* platform explicitly; this default only covers those older rows.
*/
function accountPlatform(account: Pick<Account, 'platform'>): number {
return account.platform ?? 0
@@ -180,8 +172,9 @@ function accountPlatform(account: Pick<Account, 'platform'>): number {
* client is handed an `account_id` it can never log into ("no linked account for this
* platform identity" on every attempt).
*
* `platformId` must be the *proven* identity (the SteamID64 from a verified
* platform_auth ticket), never the client-supplied `platform_id` field.
* `platformId` must be the *proven* identity the SteamID64 read out of a verified
* Steam ticket, or the Meta user id a validated nonce was issued to — never the raw
* client-supplied `platform_id` field.
*/
export function isLinkedToPlatformIdentity(
account: Pick<Account, 'platform' | 'platformId'>,
@@ -196,7 +189,7 @@ export function isLinkedToPlatformIdentity(
* Project a linked account into the client's CachedLogin DTO — the account-picker
* entry on the login screen. The client posts the chosen `accountId` back as a
* `grant_type=cached_login`. `requirePassword` is false because platform ownership
* (the platform_auth ticket) is the credential for a cached login — no prompt.
* (the verified `platform_auth`) is the credential for a cached login — no prompt.
*/
function toCachedLogin(account: Account) {
return {
@@ -254,8 +247,6 @@ const app = new Hono<App>()
'Filtered to those a `cached_login` grant would actually accept, so an entry here',
'is always redeemable. An unknown id yields `[]` (not a 404) and the client falls',
'back to a fresh login or create_account.',
'EXCEPT platform 1 (Oculus), which is stubbed: it ignores the id and returns one',
'canned, non-redeemable entry with `requirePassword: true`.',
].join(' '),
parameters: [
{
@@ -269,27 +260,18 @@ const app = new Hono<App>()
name: 'id',
in: 'path',
required: true,
description: 'Platform-native id — a SteamID64 for Steam.',
description: 'Platform-native id — a SteamID64 for Steam, a user id for Meta.',
schema: { type: 'string' },
},
],
responses: {
200: json(
CachedLogin.or(FakeCachedLogin).array(),
'Matching accounts; `[]` if none. The canned entry for platform 1 (Oculus).'
),
200: json(CachedLogin.array(), 'Matching accounts; `[]` if none'),
},
}),
async (c) => {
const { platform, id } = c.req.param()
logger.info('cached login lookup', { platform, id })
const platformInt = Number.parseInt(platform, 10)
// Oculus has no identity flow yet, so there is nothing in the DB to look up and
// the real path would always yield []. Hand back one canned entry instead, so the
// Oculus client gets past its login screen. `requirePassword` is true — unlike a
// genuine cached login there is no platform ticket behind this, so the client must
// prompt. Delete this branch once Oculus platform auth lands.
if (platformInt === PlatformType.Oculus) return c.json([FAKE_OCULUS_CACHED_LOGIN])
const accounts = await getAccountsByPlatformId(c.env.DB, id)
// Offer only accounts the `cached_login` grant will actually accept — same check.
return c.json(
@@ -345,11 +327,12 @@ const app = new Hono<App>()
'`password` becomes the login credential. Subject to two independent signup caps,',
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
'`platform`, that platform must be Steam and `platform_auth` must verify.',
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
'must verify.',
'',
'**`cached_login`** — logs into an already-linked account using platform ownership as',
'the credential; no password. Requires a Steam `platform_auth` ticket, and the posted',
'`account_id` must be linked to exactly the identity that ticket proves. An account',
'the credential; no password. Requires a verifying `platform_auth`, and the posted',
'`account_id` must be linked to exactly the identity it proves. An account',
'with no stored platform identity cannot be cached-logged-into.',
'',
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
@@ -360,10 +343,14 @@ const app = new Hono<App>()
'matching `password`. An account with no stored hash cannot be logged into at all,',
'which is what closes id/username-only takeover.',
'',
'**Platform verification.** Steam (platform `0`) is the only platform that can be',
'verified, via its signed `platform_auth` ticket, so any grant authenticating by',
'platform identity must be Steam. The verified SteamID64 replaces the client-supplied',
'`platform_id` and is the only value ever written to an account. Password and refresh',
'**Platform verification.** Two platforms can be verified, so any grant',
'authenticating by platform identity must be one of them, and only a verified id is',
'ever written to an account. Steam (`0`) posts a Steam-signed `platform_auth` ticket,',
'checked offline; the SteamID64 it carries replaces the client-supplied `platform_id`.',
'Meta/Oculus (`1`) posts `platform_auth` as `{"Nonce":…,"AppId":…}`, which recflare',
'sends to Meta together with the posted `platform_id` — validation is what binds the',
'nonce to that user id, so a spoofed id fails. Meta logins therefore need the app',
'secret (`META_APP_SECRET`) and answer 500 when it is unset. Password and refresh',
'grants carry their own credential and are not gated this way.',
'',
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
@@ -378,13 +365,17 @@ const app = new Hono<App>()
400: json(
OAuthError,
[
'Unusable grant: bad credentials, an unverifiable or non-Steam platform, an',
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
].join(' ')
),
500: json(
OAuthError,
'JWT_SECRET is unset — a token is refused rather than signed with an empty key'
[
'The server is missing a secret it cannot proceed without: JWT_SECRET (a token is',
'refused rather than signed with an empty key) or, on a Meta login, META_APP_SECRET',
'(no nonce can be validated without it).',
].join(' ')
),
},
}),
@@ -419,41 +410,93 @@ const app = new Hono<App>()
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
// A platform-authenticated login proves who you are with the platform itself,
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
// ticket. So those logins must be Steam:
// - cached_login authenticates purely by platform identity → always Steam-only.
// - create_account that asserts a platform is rejected unless it's Steam, since
// we won't bind an identity we can't prove. (create_account with NO platform
// is the password-account path — allowed, but it binds no platformId.)
// The verified SteamID64 replaces the unauthenticated `platform_id` field and is
// the ONLY value ever written to an account's `platformId`. Credential (password)
// and refresh_token grants carry their own credential and aren't gated here.
let verifiedSteamId: string | null = null
// A platform-authenticated login proves who you are with the platform itself, and
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth. So
// those logins must be one of those two:
// - cached_login authenticates purely by platform identity → always gated.
// - create_account that asserts a platform is rejected unless we can verify that
// platform, since we won't bind an identity we can't prove. (create_account
// with NO platform is the password-account path — allowed, but binds no
// platformId.)
// The verified id is the ONLY value ever written to an account's `platformId`.
// Credential (password) and refresh_token grants carry their own credential and
// aren't gated here.
//
// The two platforms prove the id in opposite directions, which is why they can't
// share a code path: Steam's ticket *carries* a SteamID64 we read out and trust,
// so the posted `platform_id` is discarded. Meta's nonce carries nothing — it is
// validated *against* the posted `platform_id`, so that field is an input, and a
// spoofed one fails validation rather than being ignored. Either way what lands in
// `platformId` below is proven, never the raw client-supplied field.
let verifiedPlatformId: string | null = null
let verifiedPlatform: number | null = null
const platformAsserted = !Number.isNaN(platformInt)
if (grantType === 'cached_login' || (grantType === 'create_account' && platformAsserted)) {
if (platformInt !== PlatformType.Steam) {
return c.json(
{
error: 'invalid_grant',
error_description: 'unsupported platform; only Steam can be verified',
},
400
)
}
const platformAuth = typeof body.platform_auth === 'string' ? body.platform_auth : ''
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) {
if (platformInt === PlatformType.Steam) {
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
if (!verified) {
return c.json(
{
error: 'invalid_grant',
error_description: 'invalid or missing platform_auth ticket',
},
400
)
}
verifiedPlatform = PlatformType.Steam
verifiedPlatformId = verified.steamId
} else if (platformInt === PlatformType.Oculus) {
// Verifying a Meta login needs the app secret. Without it every Meta player is
// locked out, which is an operator misconfiguration and not the client's fault
// — so it answers 500, the same way an unset JWT_SECRET does below, rather than
// blaming the credential. (We never fall back to trusting the posted id: that
// would let anyone log into any Meta-linked account by naming its user id.)
// `.get()` throws when the secret doesn't exist in the store at all (as
// opposed to holding an empty/placeholder value) — the same misconfiguration
// from the player's side, so it takes the same branch rather than a 500 from
// the error handler with nothing useful in it.
const appSecret = await c.env.META_APP_SECRET.get().catch(() => '')
if (appSecret === '') {
logger.error('refusing a Meta login: META_APP_SECRET is empty')
return c.json(
{
error: 'server_error',
error_description: 'Meta platform verification is not configured',
},
500
)
}
const verified = await verifyMetaNonce(platformAuth, platformId, appSecret)
if (!verified.ok) {
// The reason is for the operator; the client is told only that it was
// rejected. A wrong app secret and a stale nonce look identical from the
// client side, so this log is the only way to tell them apart.
logger.info('meta nonce verification failed', {
platformId,
reason: verified.reason,
})
return c.json(
{
error: 'invalid_grant',
error_description: 'invalid or missing platform_auth nonce',
},
400
)
}
verifiedPlatform = PlatformType.Oculus
verifiedPlatformId = verified.identity.userId
} else {
return c.json(
{
error: 'invalid_grant',
error_description: 'invalid or missing platform_auth ticket',
error_description: 'unsupported platform; only Steam and Meta can be verified',
},
400
)
}
verifiedSteamId = verified.steamId
platformId = verified.steamId
platformId = verifiedPlatformId
}
// Resolve the account this token is for:
@@ -482,10 +525,12 @@ const app = new Hono<App>()
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if (
maxPerPlatformId > 0 &&
verifiedSteamId !== null &&
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= maxPerPlatformId
verifiedPlatformId !== null &&
(await countAccountsByPlatformId(c.env.DB, verifiedPlatformId)) >= maxPerPlatformId
) {
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
logger.info('signup rejected: platform account limit', {
platformId: verifiedPlatformId,
})
return c.json(
{
error: 'invalid_grant',
@@ -509,14 +554,14 @@ const app = new Hono<App>()
)
}
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
// `platformId` (the SteamID64) is what a later cached login is checked against,
// so only this Steam user can log back into the account. A password/anonymous
// create_account (no platform) binds no platformId.
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
// a Meta-validated nonce). That bound `platformId` is what a later cached login
// is checked against, so only that platform user can log back into the account.
// A password/anonymous create_account (no platform) binds no platformId.
const account = await createAccount(c.env.DB, {
platforms: platformInt || 0,
platform: verifiedSteamId !== null ? 0 : undefined,
platformId: verifiedSteamId ?? undefined,
platform: verifiedPlatform ?? undefined,
platformId: verifiedPlatformId ?? undefined,
lastLoginTime: new Date().toISOString(),
deviceId: deviceId || undefined,
deviceClass: deviceId ? deviceClass : undefined,
@@ -553,8 +598,9 @@ const app = new Hono<App>()
// ownership is the credential; no password needed). An account with no stored
// platform identity can't be cached-logged-into and must use a fresh login.
//
// NB: `platform_id` here is the Steam-verified SteamID64 (set from the ticket
// above), never the client-supplied field. See steam-ticket.ts.
// NB: `platform_id` here is the verified identity set above — the SteamID64 from
// the ticket, or the Meta user id the nonce validated against — never the raw
// client-supplied field. See steam-ticket.ts and meta-nonce.ts.
//
const postedId = typeof body.account_id === 'string' ? body.account_id.trim() : ''
const account = /^\d+$/.test(postedId) ? await getAccount(c.env.DB, Number(postedId)) : null
+7
View File
@@ -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
+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]
/**
* A PlatformType by value. Only Steam can actually be verified — see the
* platform-auth notes on `POST /connect/token`.
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
* see the platform-auth notes on `POST /connect/token`.
*/
export const PlatformTypeSchema = z
.union([z.literal(-1), z.int().min(0).max(Math.max(...Object.values(PlatformType)))])
@@ -83,7 +83,9 @@ export const PlatformTypeSchema = z
/** One entry on the client's login screen, from `toCachedLogin`. */
export const CachedLogin = z.object({
platform: PlatformTypeSchema,
platformId: z.string().describe('Platform-native id (a SteamID64 for Steam); "" if unlinked'),
platformId: z
.string()
.describe('Platform-native id (a SteamID64 for Steam, a user id for Meta); "" if unlinked'),
accountId: z.int().describe('Post this back as `account_id` on a cached_login grant'),
lastLoginTime: z.iso.datetime().describe("Falls back to the account's createdAt"),
requirePassword: z
@@ -91,14 +93,6 @@ export const CachedLogin = z.object({
.describe('Always false — platform ownership is the credential for a cached login'),
})
/**
* The stubbed Oculus cached login. Same shape as `CachedLogin`, but `requirePassword`
* is true — nothing proves platform ownership, so the client has to prompt.
*/
export const FakeCachedLogin = CachedLogin.extend({
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
})
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
export const OAuthError = z.object({
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
@@ -139,11 +133,17 @@ export const TokenRequest = z.object({
platform_id: z
.string()
.optional()
.describe('Unverified; ignored in favour of the Steam-verified id where a ticket is required'),
.describe(
'On Steam, unverified and ignored in favour of the id the ticket carries. On Meta it is ' +
'the id the nonce is validated against, so it must be the real (numeric) user id'
),
platform_auth: z
.string()
.optional()
.describe('Steam session ticket. Required for cached_login and platform create_account'),
.describe(
'Platform proof, required for cached_login and platform create_account. Steam: ' +
'`{"Ticket":"<hex>","AppId":…}`. Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
),
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
device_id: z
.string()
+169 -28
View File
@@ -31,12 +31,23 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
// accounts the login tests authenticate as (42, 77).
const LOGIN_PASSWORD = 'correct-horse'
// Meta (Oculus) logins verify their nonce by calling graph.oculus.com authenticated
// as the app, so the tests seed an app secret and stub that call — see metaLogin.
const META_APP_SECRET = 'test-meta-app-secret'
const META_APP_ID = '1232175103309633'
const META_USER_ID = '27061366730207360'
const META_NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
/** Set in beforeAll; needed to overwrite the secret in the not-configured test. */
let metaSecretId: string
// Apply the accounts schema so create_account can persist (mirrors the migration),
// and seed the Orientation room (owned by the rooms worker) so signup can place
// the new player there.
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
// The Meta app secret, likewise — a Meta login is refused outright without one.
metaSecretId = await adminSecretsStore(env.META_APP_SECRET).create(META_APP_SECRET)
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
@@ -101,6 +112,38 @@ async function postToken(
return { status: res.status, json: (await res.json()) as Record<string, unknown> }
}
/**
* POST a Meta grant to /connect/token with graph.oculus.com stubbed to answer
* `is_valid`. The worker runs in this isolate, so replacing the global fetch is what
* stands in for Meta — `verifyMetaNonce` resolves `globalThis.fetch` per call for
* exactly this reason. Returns the graph requests the worker made alongside the
* response, so a test can assert WHICH user id the nonce was validated against.
*/
async function metaLogin(
body: string,
isValid: boolean
): Promise<{ status: number; json: Record<string, unknown>; graphCalls: URLSearchParams[] }> {
const graphCalls: URLSearchParams[] = []
const realFetch = globalThis.fetch
globalThis.fetch = (async (url: string, init?: { body?: string }) => {
if (url.startsWith('https://graph.oculus.com/')) {
graphCalls.push(new URLSearchParams(init?.body ?? ''))
return Response.json({ is_valid: isValid })
}
return realFetch(url, init)
}) as unknown as typeof fetch
try {
return { ...(await postToken(body)), graphCalls }
} finally {
globalThis.fetch = realFetch
}
}
/** The `platform_auth` payload a Meta client posts, as observed from a live login. */
function metaPlatformAuth(): string {
return JSON.stringify({ Nonce: META_NONCE, AppId: META_APP_ID, Source: 'logged in user' })
}
/** POST a form-urlencoded body to changepassword with an optional bearer token. */
function changePassword(body: string, token?: string): Promise<Response> {
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
@@ -122,32 +165,25 @@ describe('auth worker routes', () => {
expect(await res.text()).toBe('"AA=="')
})
// Platform 0 (Steam), not 1 — platform 1 is Oculus, which is stubbed below.
test('GET /cachedlogin/forplatformid/:platform/:id returns [] (no cached login)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/0/abc123`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test.each([
['0 (Steam)', 0],
['1 (Meta)', 1],
])(
'GET /cachedlogin/forplatformid/%s/:id returns [] for an unknown id',
async (_label, platform) => {
const res = await exports.default.fetch(
`${ORIGIN}/cachedlogin/forplatformid/${platform}/abc123`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
}
)
// Oculus is stubbed: no DB lookup, one canned entry whatever the id.
test('GET /cachedlogin/forplatformid/1/:id returns the canned Oculus entry', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/anything`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
platform: 1,
platformId: '1',
accountId: 1,
lastLoginTime: '2026-07-19T17:13:29.225Z',
requirePassword: true,
},
])
})
// Only Steam (platform 0) can be verified (via its signed platform_auth ticket),
// so every OTHER platform is rejected on the platform-authenticated grants — we
// won't bind or authorize an identity we can't prove.
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
// on the platform-authenticated grants: we won't bind or authorize an identity we
// can't prove.
test.each([2, 3, 4, 5, 6, 7, 8])(
'create_account rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
@@ -155,11 +191,11 @@ describe('auth worker routes', () => {
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam')
expect(res.json.error_description).toContain('only Steam and Meta')
}
)
test.each([1, 2, 3, 4, 5, 6, 7, 8])(
test.each([2, 3, 4, 5, 6, 7, 8])(
'cached_login rejects unverifiable platform %i',
async (platform) => {
const res = await postToken(
@@ -167,7 +203,7 @@ describe('auth worker routes', () => {
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('only Steam')
expect(res.json.error_description).toContain('only Steam and Meta')
}
)
@@ -191,6 +227,111 @@ describe('auth worker routes', () => {
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta create_account requires a platform_auth nonce', async () => {
// platform=1 with no nonce must not bind the spoofable platform_id field.
const res = await postToken(`grant_type=create_account&platform=1&platform_id=${META_USER_ID}`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta create_account binds the id Meta validated the nonce against', async () => {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}&device_id=meta-device`,
true
)
expect(res.status).toBe(200)
// The nonce was validated against the posted user id, authenticated as the app.
expect(res.graphCalls).toHaveLength(1)
expect(res.graphCalls[0].get('nonce')).toBe(META_NONCE)
expect(res.graphCalls[0].get('user_id')).toBe(META_USER_ID)
expect(res.graphCalls[0].get('access_token')).toBe(`OC|${META_APP_ID}|${META_APP_SECRET}`)
// The account is bound to platform 1 with that id — which is what makes the
// cached-login picker offer it, and the cached_login grant accept it.
const payload = decodePayload(res.json.access_token as string)
const accountId = Number(payload.sub)
const lookup = await exports.default.fetch(
`${ORIGIN}/cachedlogin/forplatformid/1/${META_USER_ID}`
)
const linked = (await lookup.json()) as Array<Record<string, unknown>>
expect(linked).toContainEqual(
expect.objectContaining({ accountId, platform: 1, platformId: META_USER_ID })
)
// Platform ownership is the credential, so the client is not asked for a password.
expect(linked.every((a) => a.requirePassword === false)).toBe(true)
})
test('Meta create_account is rejected when Meta does not vouch for the nonce', async () => {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
false
)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
expect(res.json.error_description).toContain('platform_auth')
})
test('Meta cached_login logs into the linked account with no password', async () => {
const userId = '27061366730209999'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 5150,
username: 'MetaPlayer',
platform: 1,
platformId: userId,
})
)
.run()
const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${userId}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(200)
expect(res.graphCalls[0].get('user_id')).toBe(userId)
const payload = decodePayload(res.json.access_token as string)
expect(payload.sub).toBe('5150')
})
test('a Meta user id cannot log into an account it is not linked to', async () => {
// The Meta account seeded above, claimed by a different (but genuinely proven)
// Meta user. Even with a nonce Meta vouches for, the identity has to match the
// account's stored one.
const res = await metaLogin(
`grant_type=cached_login&account_id=5150&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(400)
expect(res.json.error_description).toContain('no linked account')
})
test('a Meta login is refused (500) when META_APP_SECRET is unset', async () => {
// An operator misconfiguration, not a bad credential: without the secret no nonce
// can be validated, and the alternative — trusting the posted platform_id — would
// let anyone log into any Meta-linked account by naming its user id.
const admin = adminSecretsStore(env.META_APP_SECRET)
await admin.update('', metaSecretId)
try {
const res = await metaLogin(
`grant_type=create_account&platform=1&platform_id=${META_USER_ID}` +
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
true
)
expect(res.status).toBe(500)
expect(res.json.error).toBe('server_error')
// Nothing was asked of Meta, and nothing was trusted.
expect(res.graphCalls).toHaveLength(0)
} finally {
await admin.update(META_APP_SECRET, metaSecretId)
}
})
test('cachedlogin/forplatformid returns the DTO for a bound (Steam) account', async () => {
// Seed a Steam-linked account directly (a real create_account needs a live
// ticket); assert the picker projects the CachedLogin DTO the client expects.
@@ -0,0 +1,155 @@
import { describe, expect, test } from 'vitest'
import { parseMetaPlatformAuth, verifyMetaNonce } from '../../meta-nonce'
// The payload shape a real Meta login posts, captured from a live client. `Source`
// is informational and ignored; the AppId is Rec Room's Meta app.
const NONCE = 'xOUoGXJtC2N31BRDtoWJqBNo81o3DwfbQC57i9ApaiBIqkgmyMOgMYIng7c5jL5I'
const APP_ID = '1232175103309633'
const USER_ID = '27061366730207360'
const PLATFORM_AUTH = JSON.stringify({ Nonce: NONCE, AppId: APP_ID, Source: 'logged in user' })
const APP_SECRET = 'test-app-secret'
/**
* A fetch stub answering with `bodies` (one body, or one per attempt), recording every
* request it was handed. Typed to what `verifyMetaNonce` actually passes — a string URL
* and a string body — rather than the whole of `fetch`, then cast at the boundary.
*/
function stubFetch(bodies: unknown, status = 200) {
const queue = Array.isArray(bodies) ? [...(bodies as unknown[])] : [bodies]
const calls: Array<{ url: string; form: URLSearchParams }> = []
const fetcher = (async (url: string, init?: { body?: string }) => {
calls.push({ url, form: new URLSearchParams(init?.body ?? '') })
const body = queue.length > 1 ? queue.shift() : queue[0]
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}) as unknown as typeof fetch
return { fetcher, calls }
}
describe('meta-nonce', () => {
test('parses the platform_auth payload the client posts', () => {
expect(parseMetaPlatformAuth(PLATFORM_AUTH)).toEqual({ nonce: NONCE, appId: APP_ID })
})
test.each([
['not json', 'nonsense'],
['no nonce', JSON.stringify({ AppId: APP_ID })],
['empty nonce', JSON.stringify({ Nonce: '', AppId: APP_ID })],
['no app id', JSON.stringify({ Nonce: NONCE })],
// The app id is interpolated into the graph access token, so a non-numeric one
// is refused rather than sent.
['non-numeric app id', JSON.stringify({ Nonce: NONCE, AppId: 'OC|evil' })],
])('rejects a malformed payload (%s)', (_label, payload) => {
expect(parseMetaPlatformAuth(payload)).toBeNull()
})
test('validates the nonce against the posted user id and returns the identity', async () => {
const { fetcher, calls } = stubFetch({ is_valid: true })
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({ ok: true, identity: { userId: USER_ID, appId: APP_ID } })
// The request Meta actually sees: the nonce is bound to THIS user id, and the
// app authenticates itself with `OC|<app id>|<secret>`.
expect(calls).toHaveLength(1)
expect(calls[0].url).toBe('https://graph.oculus.com/user_nonce_validate')
expect(calls[0].form.get('nonce')).toBe(NONCE)
expect(calls[0].form.get('user_id')).toBe(USER_ID)
expect(calls[0].form.get('access_token')).toBe(`OC|${APP_ID}|${APP_SECRET}`)
})
test('rejects a nonce Meta does not vouch for', async () => {
const { fetcher } = stubFetch({ is_valid: false })
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)).toEqual({
ok: false,
reason: 'nonce rejected',
})
})
// The whole point of validating against the posted id: a nonce genuinely issued to
// one user does not authenticate another. Meta answers is_valid:false for the
// mismatch, so nobody can log in by naming someone else's Meta user id.
test('a nonce presented for the wrong user id fails', async () => {
const { fetcher, calls } = stubFetch({ is_valid: false })
const result = await verifyMetaNonce(PLATFORM_AUTH, '99999999999999999', APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls[0].form.get('user_id')).toBe('99999999999999999')
})
test.each([
['missing', ''],
['non-numeric', 'not-an-id'],
])('refuses a %s user id without calling Meta', async (_label, userId) => {
const { fetcher, calls } = stubFetch({ is_valid: true })
const result = await verifyMetaNonce(PLATFORM_AUTH, userId, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls).toHaveLength(0)
})
test('refuses to attempt verification with no app secret', async () => {
const { fetcher, calls } = stubFetch({ is_valid: true })
expect(await verifyMetaNonce(PLATFORM_AUTH, USER_ID, '', fetcher)).toEqual({
ok: false,
reason: 'no app secret configured',
})
expect(calls).toHaveLength(0)
})
test('surfaces a graph error with its code, for the server log', async () => {
const { fetcher } = stubFetch({
error: { code: 100, message: 'Invalid OAuth access token', type: 'OAuthException' },
})
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({
ok: false,
reason: 'graph error 100: Invalid OAuth access token',
})
})
test('a non-retryable graph error is not retried', async () => {
const { fetcher, calls } = stubFetch({ error: { code: 100, message: 'bad token' } })
await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(calls).toHaveLength(1)
})
test('retries a transient graph error and succeeds on a later attempt', async () => {
const { fetcher, calls } = stubFetch([
{ error: { code: 2, message: 'service temporarily unavailable' } },
{ is_valid: true },
])
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(true)
expect(calls).toHaveLength(2)
})
test('gives up after three attempts when Meta stays unavailable', async () => {
const { fetcher, calls } = stubFetch({ error: { code: 1, message: 'unknown error' } })
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(calls).toHaveLength(3)
})
test('treats a network failure as transient', async () => {
let attempts = 0
const fetcher = (async () => {
attempts++
throw new Error('connection reset')
}) as unknown as typeof fetch
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result.ok).toBe(false)
expect(attempts).toBe(3)
})
test('treats a non-JSON body (an edge error page) as transient', async () => {
let attempts = 0
const fetcher = (async () => {
attempts++
return new Response('<html>502</html>', { status: 502 })
}) as unknown as typeof fetch
const result = await verifyMetaNonce(PLATFORM_AUTH, USER_ID, APP_SECRET, fetcher)
expect(result).toEqual({ ok: false, reason: 'HTTP 502 with a non-JSON body' })
expect(attempts).toBe(3)
})
})
+12
View File
@@ -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,
+3
View File
@@ -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).
+9 -2
View File
@@ -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,
+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
* 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. */