mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[plus] discord role verifier to grant RR plus
This commit is contained in:
@@ -1040,8 +1040,9 @@ const app = new Hono<App>()
|
||||
|
||||
// Stamp the account's elevated roles into the token's `role` claim so the client
|
||||
// authorizes developer/moderator powers from the token itself (not just the
|
||||
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
|
||||
// every login and every refresh_token grant.
|
||||
// /role/* lookups), and its Plus flag into `rn.plus`. One read of the just-resolved
|
||||
// account serves both; they thus refresh on every login and every refresh_token
|
||||
// grant.
|
||||
const roleAccount = await getAccount(c.env.DB, Number(accountId))
|
||||
// A refresh grant posts no platform of its own, so the identity comes off the
|
||||
// account — the same read, and the only place the bound identity is authoritative.
|
||||
@@ -1056,7 +1057,13 @@ const app = new Hono<App>()
|
||||
jwtSecret,
|
||||
accountRoles(roleAccount),
|
||||
accountPrivileges(roleAccount),
|
||||
version
|
||||
version,
|
||||
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
|
||||
// CampusCard and the subscriber discount from this claim alone, so it never has to
|
||||
// load the account. It therefore refreshes on every login and every refresh_token
|
||||
// grant, and only then: a player who claims Plus on the website keeps a token that
|
||||
// says otherwise until they sign in again.
|
||||
roleAccount?.hasPlus === true
|
||||
)
|
||||
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { PlatformType } from '@repo/domain/src/enums'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
@@ -49,24 +51,13 @@ export function form(schema: z.ZodType, description: string): OpenAPIV3_1.Reques
|
||||
}
|
||||
|
||||
/**
|
||||
* PlatformType, the client's platform enum. Declaration order is wire order, and is
|
||||
* the single source for the schema and description below. The `platform` form field
|
||||
* is posted as the integer; the token's `platform` claim carries the name.
|
||||
* PlatformType, the client's platform enum — the single source for the schema and
|
||||
* description below. It lives in `@repo/domain` rather than here because the link table
|
||||
* (`platform-db`) and the website's benefits claim both need the values, and neither has
|
||||
* any business importing this module's zod/hono-openapi dependencies. Re-exported so
|
||||
* `import { PlatformType } from './openapi'` keeps working alongside the schemas.
|
||||
*/
|
||||
export const PlatformType = {
|
||||
All: -1,
|
||||
Steam: 0,
|
||||
Oculus: 1,
|
||||
PlayStation: 2,
|
||||
Xbox: 3,
|
||||
RecNet: 4,
|
||||
IOS: 5,
|
||||
GooglePlay: 6,
|
||||
Standalone: 7,
|
||||
Pico: 8,
|
||||
} as const
|
||||
|
||||
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
|
||||
export { PlatformType } from '@repo/domain/src/enums'
|
||||
|
||||
/**
|
||||
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified —
|
||||
|
||||
@@ -19,8 +19,18 @@
|
||||
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
|
||||
* Meta-validated nonce). It is what turns "this platform user" into "may enter this
|
||||
* account with no password", so an unproven `platform_id` must never reach it.
|
||||
*
|
||||
* Not every link is a LOGIN, though. The table is the account's set of external
|
||||
* identities, and some are stored for what they entitle the player to rather than for
|
||||
* entry: `PlatformType.Discord` (101) is written by the website's benefits claim, from an
|
||||
* OAuth2 code exchange Discord itself vouched for. Nobody signs in with it —
|
||||
* `verifyPlatformProof` answers `unsupported` for anything but Steam and Meta, so a
|
||||
* `cached_login` naming platform 101 is refused — and the picker must not offer it
|
||||
* either. See {@link CACHED_LOGIN_PLATFORMS}, which is what keeps those two in step.
|
||||
*/
|
||||
|
||||
import { PlatformType } from '@repo/domain/src/enums'
|
||||
|
||||
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
|
||||
export const PLATFORM_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS platform_account (
|
||||
@@ -64,6 +74,38 @@ export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (ac
|
||||
WHERE json_extract(data, '$.platformId') IS NOT NULL
|
||||
AND json_extract(data, '$.platformId') <> ''`
|
||||
|
||||
/**
|
||||
* The platforms a cached login can actually be redeemed for — the ones
|
||||
* `verifyPlatformProof` can prove, which is Steam (a Steam-signed ticket) and Meta/Oculus
|
||||
* (a Meta-validated nonce).
|
||||
*
|
||||
* This is the picker's filter, and it exists to keep a promise the picker's own API
|
||||
* documentation makes: "an entry here is always redeemable by a `cached_login` grant
|
||||
* (both read the same table)". Once the table began holding identities that are NOT
|
||||
* credentials — Discord, from the website's benefits claim — listing every row would have
|
||||
* broken that promise in two ways at once. The client would be offered an account it can
|
||||
* never log into (the grant refuses platform 101 outright), and, worse, the picker is
|
||||
* PUBLIC and unauthenticated: `GET /cachedlogin/forplatformid/101/<snowflake>` would have
|
||||
* told anyone which RecFlare account a given Discord user owns, and the bulk route would
|
||||
* have done it for a list of them at once. A Discord id is trivially readable by anyone in
|
||||
* a shared server, so that is a deanonymisation of every player who claimed benefits.
|
||||
*
|
||||
* Adding a platform here means asserting `verifyPlatformProof` can prove it. Filtering
|
||||
* happens in the two picker reads only — {@link isPlatformIdentityLinked},
|
||||
* {@link countAccountsForPlatformIdentity} and {@link getLinksForAccount} deliberately see
|
||||
* every link, because they answer "is this identity taken / whose is it", which is exactly
|
||||
* the question the benefits claim's once-only guard asks about a Discord id.
|
||||
*/
|
||||
export const CACHED_LOGIN_PLATFORMS: readonly number[] = [PlatformType.Steam, PlatformType.Oculus]
|
||||
|
||||
/**
|
||||
* `IN (…)` fragment for the allowlist, so the filter is applied by the query rather than
|
||||
* in JS. Placeholders are numbered from ?2 because the one caller binds the platform id
|
||||
* as ?1 — explicit indices rather than bare `?`, which SQLite would number by position
|
||||
* and quietly renumber the moment another parameter is added ahead of it.
|
||||
*/
|
||||
const CACHED_LOGIN_FILTER = `platform IN (${CACHED_LOGIN_PLATFORMS.map((_, i) => `?${i + 2}`).join(', ')})`
|
||||
|
||||
/** One account ↔ platform identity link. */
|
||||
export interface PlatformLink {
|
||||
accountId: number
|
||||
@@ -119,6 +161,9 @@ export async function getLinksForPlatformIdentity(
|
||||
platformId: string
|
||||
): Promise<PlatformLink[]> {
|
||||
if (platformId === '') return []
|
||||
// Asking about a platform nobody can log in from yields nothing at all, rather than a
|
||||
// list the grant would refuse — see CACHED_LOGIN_PLATFORMS.
|
||||
if (!CACHED_LOGIN_PLATFORMS.includes(platform)) return []
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
||||
@@ -138,9 +183,15 @@ export async function getLinksForPlatformId(
|
||||
platformId: string
|
||||
): Promise<PlatformLink[]> {
|
||||
if (platformId === '') return []
|
||||
// Matches on any platform a cached login can be redeemed for — but only those. This is
|
||||
// the route a bare id takes, so without the filter a Discord snowflake posted here
|
||||
// would resolve its account even though naming platform 101 explicitly would not.
|
||||
const { results } = await db
|
||||
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
|
||||
.bind(platformId)
|
||||
.prepare(
|
||||
`${SELECT_LINK} WHERE platform_id = ?1 AND ${CACHED_LOGIN_FILTER}
|
||||
ORDER BY linked_at, account_id`
|
||||
)
|
||||
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
createReport,
|
||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||
} from '../../../../api/src/reports-db'
|
||||
import { PlatformType } from '../../openapi'
|
||||
import {
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForAccount,
|
||||
linkPlatformIdentity,
|
||||
PLATFORM_BACKFILL_SQL,
|
||||
@@ -421,6 +423,52 @@ describe('auth worker routes', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// A Discord link is an external identity, not a credential — `www`'s benefits claim
|
||||
// writes one so a claimed Discord user can't claim again on a second account. It must
|
||||
// stay invisible to BOTH picker routes, for two independent reasons:
|
||||
//
|
||||
// - Every entry the picker lists is promised to be redeemable by a `cached_login`
|
||||
// grant, and that grant refuses platform 101 outright (verifyPlatformProof answers
|
||||
// `unsupported`). Listing one offers the client an account it can never log into.
|
||||
// - These routes are PUBLIC and unauthenticated, and a Discord snowflake is readable by
|
||||
// anyone sharing a server with its owner. Answering here would turn the login picker
|
||||
// into a lookup from "Discord user" to "their RecFlare account", for every player who
|
||||
// ever claimed benefits.
|
||||
//
|
||||
// The bare-id route is checked too, and it is the easier one to miss: it matches on ANY
|
||||
// platform, so it would resolve the snowflake even though naming 101 explicitly did not.
|
||||
test('never lists a Discord link in either cached-login picker', async () => {
|
||||
const discordId = '308994132968210433'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 31399, username: 'DiscordClaimer', hasPlus: true }))
|
||||
.run()
|
||||
await linkPlatformIdentity(env.DB, 31399, PlatformType.Discord, discordId)
|
||||
|
||||
// Named explicitly…
|
||||
const named = await exports.default.fetch(
|
||||
`${ORIGIN}/cachedlogin/forplatformid/${PlatformType.Discord}/${discordId}`
|
||||
)
|
||||
expect(named.status).toBe(200)
|
||||
expect(await named.json()).toEqual([])
|
||||
|
||||
// …and via the bare id, which matches across platforms.
|
||||
const bare = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/any/${discordId}`)
|
||||
expect(await bare.json()).toEqual([])
|
||||
|
||||
// …and through the bulk friends-resolution route, which takes bare ids only.
|
||||
const bulk = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformids`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ id: discordId }).toString(),
|
||||
})
|
||||
expect(await bulk.json()).toEqual([])
|
||||
|
||||
// The link is still really there — this is a filtered READ, not a failed write.
|
||||
await expect(
|
||||
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId)
|
||||
).resolves.toBe(1)
|
||||
})
|
||||
|
||||
// The 20250424.01 build POSTs the picker lookup with a platform-attestation form body
|
||||
// instead of GETting it. Nothing reads that body yet, so both methods must answer the
|
||||
// same list — otherwise the newer client's login screen comes up empty.
|
||||
@@ -577,6 +625,9 @@ describe('auth worker routes', () => {
|
||||
expect(payload.role).not.toContain('junior')
|
||||
// No privileges to carry, so the claim is absent rather than an empty array.
|
||||
expect(payload['rn.privilege']).toBeUndefined()
|
||||
// Same for Plus: omitted rather than `false`, so a non-subscriber's token is
|
||||
// byte-for-byte what it was before `rn.plus` existed.
|
||||
expect(payload['rn.plus']).toBeUndefined()
|
||||
expect(payload.scope).toContain('rn.api')
|
||||
})
|
||||
|
||||
@@ -615,6 +666,64 @@ describe('auth worker routes', () => {
|
||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
|
||||
})
|
||||
|
||||
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
|
||||
// the website's Discord benefits claim sets. `econ` decides the CampusCard and the
|
||||
// subscriber discount from this claim ALONE and never reads the account, so if this
|
||||
// stops being stamped, Plus silently stops existing for everyone.
|
||||
//
|
||||
// It is a CLAIM, not a scope: `scope` is a fixed list the client parses, and this is
|
||||
// ours. And it is not a role — the `developer` role does not confer Plus.
|
||||
test('POST /connect/token stamps rn.plus for a hasPlus account', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 93,
|
||||
username: 'PlusPlayer',
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
hasPlus: true,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
const payload = await tokenFor(`account_id=93&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload['rn.plus']).toBe(true)
|
||||
expect(payload.scope).not.toContain('rn.plus')
|
||||
// Plus is not an elevated role, and does not come with one.
|
||||
expect(payload.role).not.toContain('developer')
|
||||
})
|
||||
|
||||
// The flag is read at LOGIN, so signing in again is what activates it — the website's
|
||||
// claim page and `runx admin grant-plus` both say so, and this is the mechanism behind it.
|
||||
//
|
||||
// This also pins that `hasPlus` stands ALONE: the flag is set here by raw SQL, exactly as
|
||||
// `runx admin grant-plus` sets it, with no Discord app configured, no OAuth exchange and
|
||||
// no `platform_account` link anywhere. An operator must be able to grant Plus outright.
|
||||
test('rn.plus refreshes on the next login after hasPlus is set, with no Discord link', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 94,
|
||||
username: 'LateClaimer',
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
})
|
||||
)
|
||||
.run()
|
||||
// Before claiming: no Plus.
|
||||
expect((await tokenFor(`account_id=94&password=${LOGIN_PASSWORD}`))['rn.plus']).toBeUndefined()
|
||||
|
||||
// The website's claim writes the flag…
|
||||
await env.DB.prepare(
|
||||
"UPDATE account SET data = json_set(data, '$.hasPlus', json('true')) WHERE account_id = 94"
|
||||
).run()
|
||||
|
||||
// …and the NEXT token carries it. The one already in the player's hands does not,
|
||||
// which is exactly why they have to sign in again.
|
||||
expect((await tokenFor(`account_id=94&password=${LOGIN_PASSWORD}`))['rn.plus']).toBe(true)
|
||||
|
||||
// Nothing linked a Discord identity to this account, and Plus does not care.
|
||||
const links = await getLinksForAccount(env.DB, 94)
|
||||
expect(links.filter((l) => l.platform === PlatformType.Discord)).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /connect/token stamps the junior role for an isJunior account', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
|
||||
Reference in New Issue
Block a user