mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
meta platform login
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user