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