[studio] beta studio (#114)

* rrs stuff

* studio service yay

* rrs whitelist

* moree studiooooooo fellowwww

* rec room studio room loading yay

* fixes yay

* fix 2 because idk  yayuh

* Update cdn.app.ts

* ccontent length fix?

* Preserve content-length and upgrade metadata

Return a real zero-filled byte array for CDN HEAD responses so the runtime preserves Content-Length while still sending no payload. In notify, re-create the Request with its original headers, set OWNER_HEADER from the validated token, and forward that Request to hub.fetch to avoid losing WebSocket upgrade metadata (which could trigger 500s in Durable Objects). These changes ensure the editor's size checks and WebSocket upgrades remain reliable.

* fix x524234

* shirt thing?

* [auth] remove grant table as we already have audit_log for now

* rename table to generic device_grant

* cleanup/lint

---------

Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
This commit is contained in:
Crasho
2026-10-05 19:39:22 +01:00
committed by GitHub
parent 0900bcac25
commit 26270e9c25
52 changed files with 18231 additions and 103 deletions
+23 -10
View File
@@ -5,16 +5,19 @@ authenticates players and issues the JWTs every other worker verifies.
## Routes
| Method | Path | Description |
| ------ | ------------------------------------------ | ------------------------------------------------------ |
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token |
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
| Method | Path | Description |
| ------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
| POST | `/connect/deviceauthorization` | Studio device login: mint a user code and point the browser at WWW `/device` |
| POST | `/connect/device/approve` | Website approves that code for the signed-in account |
| POST | `/connect/device/deny` | Website refuses it |
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token. Studio polls this with the device-code grant |
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
## API documentation
@@ -45,6 +48,10 @@ route without documenting it fails rather than silently shipping an incomplete s
identity `platform_auth` proves.
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
30-day TTL; platform and platform id come from what was stored at issue time.
- **`urn:ietf:params:oauth:grant-type:device_code`** — Rec Room Studio polling a
code from `POST /connect/deviceauthorization`. While the player has not approved
it on the website, the response is HTTP 400 `authorization_pending`. Studio
treats `expires_in` on the token as seconds.
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
the account by `username` or numeric `account_id` and requires the matching password
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
@@ -55,6 +62,12 @@ Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `
claim, so developer/moderator powers refresh on every login and every refresh grant.
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
`betastudio` is a separate claim. RecFlare Studio treats it as permission to upload,
and developer does not include it. It is stamped from the account's `hasStudio` flag.
Staff add and remove people at `/settings/recroomstudio` on the website (or
`runx admin grant-studio`). A player already signed into Studio keeps the old token
until the next sign-in or refresh.
### Verifiable platforms: Steam and Meta
Only an identity we can _prove_ is ever bound to an account, so any grant that
@@ -0,0 +1,19 @@
-- Device-login grants (owned by the auth worker). Named generically: Rec Room
-- Studio is the first device to use it, but any device-code login lands here. Studio
-- posts POST /connect/deviceauthorization here, then polls /connect/token with
-- grant_type=urn:ietf:params:oauth:grant-type:device_code. The browser page that
-- approves the code lives on www (`/device`); this table is only the handshake.
--
-- The device_code is a bearer secret until it is consumed, so only its SHA-256
-- is stored. The user_code is the short code the player sees and types, so it
-- has to be stored as itself. Kept in sync with STUDIO_DEVICE_SCHEMA_DDL in
-- src/studio-device.ts.
CREATE TABLE IF NOT EXISTS device_grant (
device_code_hash TEXT PRIMARY KEY,
user_code TEXT NOT NULL UNIQUE,
account_id INTEGER,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
+243 -5
View File
@@ -38,11 +38,16 @@ import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
import { buildEndpoints } from '../../ns/src/endpoints'
import { verifyMetaNonce } from './meta-nonce'
import {
CachedLogin,
ChangePasswordRequest,
ChangePasswordResponse,
DeviceAuthorizationRequest,
DeviceAuthorizationResponse,
DeviceDecisionRequest,
DeviceDecisionResponse,
FakeCachedLogin,
form,
json,
@@ -64,10 +69,18 @@ import {
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
import {
approveDeviceGrant,
beginDeviceGrant,
denyDeviceGrant,
DEVICE_CODE_GRANT,
pollDeviceGrant,
studioClientMatches,
} from './studio-device'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { App } from './context'
import type { App, Env } from './context'
import type { PlatformLink } from './platform-db'
/** OAuth scopes granted by `/connect/token`. */
@@ -278,17 +291,22 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
* client gates the screen-share feature on it and nothing grants it per-account, so it
* is unconditional (even with no account resolved). The rest are the operator-granted
* extras, plus `junior` off the account's own `isJunior` flag. Order is stable so
* tokens are deterministic.
* extras, plus `junior` off the account's own `isJunior` flag.
*
* `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it.
* RecFlare Studio treats that exact claim as Full access, which is what lets the editor
* upload; staff set the flag from the website. Order is stable so tokens are
* deterministic: screenshare, developer, moderator, junior, then betastudio.
*/
function accountRoles(
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior'> | null
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio'> | null
): string[] {
const roles = ['screenshare']
if (!account) return roles
if (account.isDeveloper) roles.push('developer')
if (account.isModerator) roles.push('moderator')
if (account.isJunior) roles.push('junior')
if (account.hasStudio) roles.push('betastudio')
return roles
}
@@ -314,6 +332,120 @@ function accountPlatform(account: Pick<Account, 'platform'>): number {
return account.platform ?? 0
}
/**
* The browser page Studio opens lives on WWW, not on this worker. `ns` is what
* told the editor where WWW is, so the URI has to be built from the same domain
* and the same subdomain override or the tab opens on a host the player doesn't have.
*/
function studioVerificationUri(env: Env, userCode: string): { uri: string; complete: string } {
const www = buildEndpoints(env.DOMAIN, env.SUBDOMAINS).WWW
const uri = `${www}/device`
return { uri, complete: `${uri}?user_code=${encodeURIComponent(userCode)}` }
}
function oauthError(
c: Context<App>,
error: string,
error_description: string,
status: 400 | 500 = 400
) {
return c.json({ error, error_description }, status)
}
/**
* Issue the same token body every other grant returns, for an account that has
* already been authenticated — here, because the player approved a Studio code
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`).
*/
async function issueSession(c: Context<App>, accountId: number, grantType: string) {
const clientIp = c.req.header('cf-connecting-ip') ?? ''
const ban = await resolveBan(c.env.DB, accountId, {
identity: { ip: clientIp },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (ban) {
logger.info('token issued to a blocked account', {
accountId,
grantType,
via: ban.via,
bannedAccountId: ban.bannedAccountId,
reportId: ban.ban.id,
})
}
const jwtSecret = await c.env.JWT_SECRET.get()
if (jwtSecret === '') {
logger.error('refusing to issue token: JWT_SECRET is empty')
return oauthError(c, 'server_error', 'token signing is not configured', 500)
}
const roleAccount = await getAccount(c.env.DB, accountId)
if (!roleAccount) return oauthError(c, 'invalid_grant', 'account no longer exists')
const accessToken = await generateToken(
String(accountId),
roleAccount.platformId ?? '',
accountPlatform(roleAccount),
jwtSecret,
accountRoles(roleAccount),
accountPrivileges(roleAccount),
GAME_VERSION,
roleAccount.hasPlus === true
)
const refreshToken = await issueRefreshToken(c.env.DB, accountId)
await setLastLoginTime(c.env.DB, accountId, new Date().toISOString())
await setLoginContext(c.env.DB, accountId, { ip: clientIp })
const staffRoles = (['developer', 'moderator'] as const).filter((role) =>
holdsRole(roleAccount, role)
)
if (staffRoles.length > 0) {
try {
await writeAuditLog(c.env.DB, {
playerId: accountId,
action: 'staff_login',
data: {
ip: clientIp,
roles: staffRoles,
grantType,
username: roleAccount.username ?? null,
deviceId: '',
deviceClass: 0,
version: null,
},
})
} catch (err) {
logger.error('could not write a staff_login audit log row', {
accountId,
grantType,
error: err instanceof Error ? err.message : String(err),
})
}
}
return c.json({
access_token: accessToken,
expires_in: TOKEN_TTL_SECONDS,
token_type: 'Bearer',
refresh_token: refreshToken,
scope: TOKEN_SCOPE,
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
})
}
async function decideDeviceCode(c: Context<App>, decide: typeof approveDeviceGrant) {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const userCode = typeof body.user_code === 'string' ? body.user_code : ''
const result = await decide(c.env.DB, userCode, id)
if (result.kind === 'ok') return c.json({ ok: true as const })
if (result.kind === 'expired')
return oauthError(c, 'expired_token', 'that code has expired. Start the login in Studio again.')
if (result.kind === 'used') return oauthError(c, 'invalid_grant', 'that code was already used.')
return oauthError(c, 'invalid_grant', 'that code was not found.')
}
/**
* 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
@@ -620,6 +752,83 @@ const app = new Hono<App>()
}
)
// Studio editor device login. The POST the editor makes is
// `https://auth.<domain>/connect/deviceauthorization` — Auth is service enum 0,
// and the path is the literal `connect/deviceauthorization`. The browser half
// of the handshake is the verification_uri, which points at WWW `/device`.
.post(
'/connect/deviceauthorization',
describeRoute({
tags: ['Token'],
summary: 'Start a Rec Room Studio device login',
description: [
'RFC 8628 device authorization, called by Rec Room Studio (not the game).',
'The editor POSTs `client_id=recroom.studio` and its embedded client secret',
'to the Auth host. The response `verification_uri` is the website (`WWW /device`);',
'`verification_uri_complete` is that page with `?user_code=` and is what Studio',
'opens in the browser. Studio then polls `POST /connect/token` with',
'`grant_type=urn:ietf:params:oauth:grant-type:device_code`.',
].join(' '),
requestBody: form(DeviceAuthorizationRequest, '`client_id` and `client_secret`'),
responses: {
200: json(DeviceAuthorizationResponse, 'Codes and the WWW page to open'),
400: json(OAuthError, 'client_id or client_secret was not the Studio editor'),
},
}),
async (c) => {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
if (!studioClientMatches(body)) {
return oauthError(c, 'invalid_client', 'unknown studio client')
}
const grant = await beginDeviceGrant(c.env.DB)
const { uri, complete } = studioVerificationUri(c.env, grant.userCode)
return c.json({
device_code: grant.deviceCode,
user_code: grant.userCode,
verification_uri: uri,
verification_uri_complete: complete,
expires_in: grant.expiresIn,
interval: grant.interval,
})
}
)
.post(
'/connect/device/approve',
describeRoute({
tags: ['Token'],
summary: 'Approve a Studio device code',
description:
'Called by the website (`WWW /device`) with the signed-in player’s bearer token and the `user_code` Studio is showing. The editor’s next poll of `/connect/token` then receives that account’s token.',
security: [{ bearerAuth: [] }],
requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'),
responses: {
200: json(DeviceDecisionResponse, 'The code is approved'),
400: json(OAuthError, 'Unknown, expired, or already used code'),
401: { description: 'Missing or invalid bearer token (empty body)' },
},
}),
(c) => decideDeviceCode(c, approveDeviceGrant)
)
.post(
'/connect/device/deny',
describeRoute({
tags: ['Token'],
summary: 'Deny a Studio device code',
description:
'Same caller as approve. Studio’s next poll fails with `access_denied` instead of signing in.',
security: [{ bearerAuth: [] }],
requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'),
responses: {
200: json(DeviceDecisionResponse, 'The code is denied'),
400: json(OAuthError, 'Unknown, expired, or already used code'),
401: { description: 'Missing or invalid bearer token (empty body)' },
},
}),
(c) => decideDeviceCode(c, denyDeviceGrant)
)
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
.post(
'/connect/token',
@@ -678,7 +887,10 @@ const app = new Hono<App>()
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant. `junior` rides along for an',
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
'gate the client reads, not a privilege anyone is granted. A junior also carries',
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped',
'from the account’s `hasStudio` flag (staff set it on the website). RecFlare Studio',
'treats that claim as permission to upload; `developer` does not confer it. A junior',
'also carries',
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
'client reads it as a claim beside `role`, and it is absent for everyone else.',
'',
@@ -731,6 +943,32 @@ const app = new Hono<App>()
// form body.
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
// Studio's poll. Handled before the password fallback, which would otherwise
// treat this grant_type as a username/password login and 400 it.
if (grantType === DEVICE_CODE_GRANT) {
if (!studioClientMatches(body)) {
return oauthError(c, 'invalid_client', 'unknown studio client')
}
const deviceCode = typeof body.device_code === 'string' ? body.device_code : ''
const polled = await pollDeviceGrant(c.env.DB, deviceCode)
if (polled.kind === 'pending') {
return oauthError(
c,
'authorization_pending',
'the player has not approved this login yet'
)
}
if (polled.kind === 'denied') {
return oauthError(c, 'access_denied', 'the player denied this login')
}
if (polled.kind === 'expired') {
return oauthError(c, 'expired_token', 'the device code expired')
}
if (polled.kind !== 'approved') {
return oauthError(c, 'invalid_grant', 'unknown device code')
}
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT)
}
// `platform`/`platform_id` come from the body for a fresh login; a refresh
// grant overrides them below with what was stored when the token was issued.
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
+11
View File
@@ -49,6 +49,17 @@ export type Env = SharedHonoEnv & {
* Read through `banEvasionMatch`; the `match` worker reads the same knob.
*/
BAN_EVASION_MATCH?: string
/**
* Base domain, e.g. `recflare.net`. Injected for every worker at deploy time
* (`--var DOMAIN`). Studio's device-login response points the browser at
* `www.<domain>/device`, so auth has to know the same domain `ns` advertises.
*/
DOMAIN: string
/**
* The same `SUBDOMAINS` JSON `ns` applies. A `www` override has to move the
* verification URI with the host the editor was told WWW lives on.
*/
SUBDOMAINS: string
}
/** Variables can be extended */
+57 -3
View File
@@ -105,7 +105,16 @@ export const FakeCachedLogin = CachedLogin.extend({
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
export const OAuthError = z.object({
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
error: z.enum([
'invalid_grant',
'invalid_request',
'invalid_client',
'server_error',
// RFC 8628. Studio polls until it sees something other than authorization_pending.
'authorization_pending',
'expired_token',
'access_denied',
]),
error_description: z.string(),
})
@@ -128,8 +137,17 @@ export const TokenResponse = z.object({
*/
export const TokenRequest = z.object({
grant_type: z
.enum(['create_account', 'cached_login', 'refresh_token', 'password'])
.describe('Anything unrecognised (including absent) is treated as a password grant'),
.enum([
'create_account',
'cached_login',
'refresh_token',
'password',
'urn:ietf:params:oauth:grant-type:device_code',
])
.describe(
'Anything unrecognised (including absent) is treated as a password grant, except the ' +
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization'
),
account_id: z.string().optional().describe('Numeric account id, as a string'),
username: z
.string()
@@ -156,6 +174,15 @@ export const TokenRequest = z.object({
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
),
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
client_id: z.string().optional().describe('Studio device grant: `recroom.studio`'),
client_secret: z
.string()
.optional()
.describe('Studio device grant: the secret embedded in the editor'),
device_code: z
.string()
.optional()
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'),
device_id: z
.string()
.optional()
@@ -171,6 +198,33 @@ export const TokenRequest = z.object({
),
})
/** `POST /connect/deviceauthorization` form body. Studio's editor sends exactly these two. */
export const DeviceAuthorizationRequest = z.object({
client_id: z.string().describe('`recroom.studio`'),
client_secret: z.string().describe('The secret compiled into Rec Room Studio'),
})
/** RFC 8628 response. Property names match Studio's `DeviceAuthorizationResponse`. */
export const DeviceAuthorizationResponse = z.object({
device_code: z.string(),
user_code: z.string().describe('Shown in the editor; also on verification_uri_complete'),
verification_uri: z.string().describe('WWW `/device`, without the code'),
verification_uri_complete: z
.string()
.describe('The URL Studio opens in the browser, `verification_uri?user_code=`'),
expires_in: z.int().describe('Seconds until the code dies. Studio treats this as seconds'),
interval: z.int().describe('Seconds Studio waits between polls'),
})
/** `POST /connect/device/approve` and `/connect/device/deny` form body. */
export const DeviceDecisionRequest = z.object({
user_code: z.string().describe('The code Studio is showing, spaces and hyphens ignored'),
})
export const DeviceDecisionResponse = z.object({
ok: z.literal(true),
})
/** `POST /account/me/changepassword` form body. */
export const ChangePasswordRequest = z.object({
newPassword: z.string().describe('Required; empty is rejected'),
+211
View File
@@ -0,0 +1,211 @@
/**
* Rec Room Studio's device-login handshake.
*
* The editor (RecRoom.Studio.Editor.Core, `MKLLKHBNNLP`) POSTs
* `connect/deviceauthorization` to the **Auth** service — service enum value 0,
* the host `ns` advertises as `Auth` — with `client_id=recroom.studio` and the
* client secret embedded in that assembly. It then opens `verification_uri_complete`
* in the browser and polls `POST connect/token` on the same Auth host with
* `grant_type=urn:ietf:params:oauth:grant-type:device_code` until the code is
* approved or `expires_in` seconds pass.
*
* `GET account/me` after a successful poll is a different service: enum 11,
* **Accounts**. This module does not serve that.
*
* The page the editor opens is **WWW** `/device`. Auth only mints the codes and,
* once that page has approved one, the token.
*/
/** What Studio posts as `client_id` on the device grant. */
export const STUDIO_CLIENT_ID = 'recroom.studio'
/**
* The matching `client_secret` compiled into the Studio editor. It is not a
* server secret — anyone with the editor binary has it — but checking it keeps
* a random POST from minting codes.
*/
export const STUDIO_CLIENT_SECRET = '54oc5C4BH7jpqp42KhChkNZOLP0U3yTx'
/** `grant_type` Studio polls with. RFC 8628. */
export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
/** How long the player has to approve, in seconds. Studio reads this as seconds. */
export const DEVICE_TTL_SECONDS = 10 * 60
/** Minimum poll spacing Studio is told to use, in seconds. */
export const DEVICE_POLL_INTERVAL_SECONDS = 5
/** Unambiguous alphabet. Studio pretty-prints a code only when it is exactly 6 chars. */
const USER_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const USER_CODE_LENGTH = 8
export const STUDIO_DEVICE_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS device_grant (
device_code_hash TEXT PRIMARY KEY,
user_code TEXT NOT NULL UNIQUE,
account_id INTEGER,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)`,
]
type GrantStatus = 'pending' | 'approved' | 'denied' | 'consumed'
export interface DeviceGrant {
userCode: string
deviceCode: string
expiresIn: number
interval: number
}
export type DevicePoll =
| { kind: 'pending' }
| { kind: 'denied' }
| { kind: 'expired' }
| { kind: 'invalid' }
| { kind: 'approved'; accountId: number }
interface GrantRow {
account_id: number | null
status: GrantStatus
expires_at: number
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000)
}
async function sha256(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
}
function randomUserCode(): string {
const bytes = crypto.getRandomValues(new Uint8Array(USER_CODE_LENGTH))
let code = ''
for (const byte of bytes) code += USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length]
return code
}
function randomDeviceCode(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
}
/** Strip spaces and hyphens and uppercase, so a code typed as Studio displays it still matches. */
export function normalizeUserCode(raw: string): string {
return raw.replace(/[\s-]/g, '').toUpperCase()
}
export function studioClientMatches(body: Record<string, unknown>): boolean {
return body.client_id === STUDIO_CLIENT_ID && body.client_secret === STUDIO_CLIENT_SECRET
}
export async function beginDeviceGrant(db: D1Database): Promise<DeviceGrant> {
const now = nowSeconds()
const deviceCode = randomDeviceCode()
const deviceCodeHash = await sha256(deviceCode)
for (let attempt = 0; attempt < 5; attempt++) {
const userCode = randomUserCode()
try {
await db
.prepare(
`INSERT INTO device_grant
(device_code_hash, user_code, account_id, status, created_at, expires_at)
VALUES (?1, ?2, NULL, 'pending', ?3, ?4)`
)
.bind(deviceCodeHash, userCode, now, now + DEVICE_TTL_SECONDS)
.run()
return {
userCode,
deviceCode,
expiresIn: DEVICE_TTL_SECONDS,
interval: DEVICE_POLL_INTERVAL_SECONDS,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!message.includes('UNIQUE')) throw err
}
}
throw new Error('could not allocate a studio device code')
}
async function readByHash(db: D1Database, deviceCode: string): Promise<GrantRow | null> {
return db
.prepare(`SELECT account_id, status, expires_at FROM device_grant WHERE device_code_hash = ?1`)
.bind(await sha256(deviceCode))
.first<GrantRow>()
}
/**
* One poll from Studio. An approved code is consumed exactly once: two polls
* racing each other cannot both walk away with a session.
*/
export async function pollDeviceGrant(db: D1Database, deviceCode: string): Promise<DevicePoll> {
const row = await readByHash(db, deviceCode)
if (!row) return { kind: 'invalid' }
if (row.expires_at <= nowSeconds()) return { kind: 'expired' }
if (row.status === 'pending') return { kind: 'pending' }
if (row.status === 'denied') return { kind: 'denied' }
if (row.status !== 'approved' || row.account_id == null) return { kind: 'invalid' }
const consumed = await db
.prepare(
`UPDATE device_grant SET status = 'consumed'
WHERE device_code_hash = ?1 AND status = 'approved' AND expires_at > ?2`
)
.bind(await sha256(deviceCode), nowSeconds())
.run()
if ((consumed.meta.changes ?? 0) < 1) return { kind: 'invalid' }
return { kind: 'approved', accountId: row.account_id }
}
export type DeviceDecision =
{ kind: 'ok' } | { kind: 'missing' } | { kind: 'expired' } | { kind: 'used' }
async function decide(
db: D1Database,
userCode: string,
accountId: number,
status: 'approved' | 'denied'
): Promise<DeviceDecision> {
const code = normalizeUserCode(userCode)
if (code === '') return { kind: 'missing' }
const updated = await db
.prepare(
`UPDATE device_grant
SET status = ?2, account_id = ?3
WHERE user_code = ?1 AND status = 'pending' AND expires_at > ?4`
)
.bind(code, status, accountId, nowSeconds())
.run()
if ((updated.meta.changes ?? 0) > 0) return { kind: 'ok' }
const row = await db
.prepare(`SELECT status, expires_at, account_id FROM device_grant WHERE user_code = ?1`)
.bind(code)
.first<{ status: GrantStatus; expires_at: number; account_id: number | null }>()
if (!row) return { kind: 'missing' }
if (row.expires_at <= nowSeconds()) return { kind: 'expired' }
// Approving again from the same account is the player double-clicking Allow.
if (status === 'approved' && row.status === 'approved' && row.account_id === accountId)
return { kind: 'ok' }
return { kind: 'used' }
}
export function approveDeviceGrant(
db: D1Database,
userCode: string,
accountId: number
): Promise<DeviceDecision> {
return decide(db, userCode, accountId, 'approved')
}
export function denyDeviceGrant(
db: D1Database,
userCode: string,
accountId: number
): Promise<DeviceDecision> {
return decide(db, userCode, accountId, 'denied')
}
+167
View File
@@ -14,6 +14,7 @@ import {
SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
updateAccount,
} from '@repo/domain'
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
@@ -31,6 +32,11 @@ import {
PLATFORM_SCHEMA_DDL,
} from '../../platform-db'
import { consumeRefreshToken, issueRefreshToken, REFRESH_SCHEMA_DDL } from '../../refresh-db'
import {
STUDIO_CLIENT_ID,
STUDIO_CLIENT_SECRET,
STUDIO_DEVICE_SCHEMA_DDL,
} from '../../studio-device'
import type { Env } from '../../context'
@@ -96,6 +102,7 @@ beforeAll(async () => {
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
// the token grant reads it for the evasion arms.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
/**
@@ -681,6 +688,8 @@ describe('auth worker routes', () => {
expect(payload.role).not.toContain('developer')
expect(payload.role).not.toContain('moderator')
expect(payload.role).not.toContain('junior')
// Studio upload access is a whitelist, not a default role.
expect(payload.role).not.toContain('betastudio')
// 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
@@ -722,6 +731,91 @@ describe('auth worker routes', () => {
.run()
const payload = await tokenFor(`account_id=91&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
// developer is a different grant. It does not set the account's hasStudio flag.
expect(payload.role).not.toContain('betastudio')
})
// RecFlare Studio treats the role claim `betastudio` as permission to upload.
// It is stamped from the account's hasStudio flag on every mint path — password,
// refresh, and the Studio device grant — and a refresh after a revoke drops it. A
// token that kept the role for the rest of the day would leave a removed player
// uploading.
test('POST /connect/token stamps betastudio only while the account has hasStudio', async () => {
// 9811/9812, not 9601–9605: those ids belong to the staff-login audit tests,
// which insert with OR IGNORE and then count `staff_login` rows.
await seedAccount(9811, 'StudioPlayer')
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).not.toContain(
'betastudio'
)
await updateAccount(env.DB, 9811, { hasStudio: true })
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient',
'screenshare',
'betastudio',
])
// developer and moderator stay in front of betastudio, so the claim order is stable.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 9812,
username: 'StudioStaff',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isDeveloper: true,
isModerator: true,
hasStudio: true,
})
)
.run()
expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient',
'screenshare',
'developer',
'moderator',
'betastudio',
])
const client = `client_id=${STUDIO_CLIENT_ID}&client_secret=${STUDIO_CLIENT_SECRET}`
const started = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: client,
})
expect(started.status).toBe(200)
const codes = (await started.json()) as { device_code: string; user_code: string }
const session = await postToken(
`grant_type=password&username=StudioPlayer&password=${LOGIN_PASSWORD}`
)
expect(session.status).toBe(200)
const approved = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${session.json.access_token as string}`,
},
body: `user_code=${codes.user_code}`,
})
expect(approved.status).toBe(200)
const signedIn = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(signedIn.status).toBe(200)
expect(decodePayload(signedIn.json.access_token as string).role).toEqual([
'gameClient',
'screenshare',
'betastudio',
])
// Revoke between issue and refresh. The refresh re-reads the account.
const login = await postToken(`account_id=9811&password=${LOGIN_PASSWORD}`)
await updateAccount(env.DB, 9811, { hasStudio: false })
await updateAccount(env.DB, 9812, { hasStudio: false })
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(login.json.refresh_token as string)}`
)
expect(refreshed.status).toBe(200)
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
})
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
@@ -1645,6 +1739,76 @@ describe('auth worker routes', () => {
expect(res.status).toBe(404)
})
test('Studio device login is approved on the website and polled on auth', async () => {
const client = `client_id=${STUDIO_CLIENT_ID}&client_secret=${STUDIO_CLIENT_SECRET}`
const bad = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'client_id=recroom.studio&client_secret=nope',
})
expect(bad.status).toBe(400)
const started = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: client,
})
expect(started.status).toBe(200)
const codes = (await started.json()) as {
device_code: string
user_code: string
verification_uri: string
verification_uri_complete: string
expires_in: number
interval: number
}
// The editor opens this in a browser. It is the website, not the auth API.
expect(codes.verification_uri).toBe('https://www.rec.example.com/device')
expect(codes.verification_uri_complete).toBe(
`https://www.rec.example.com/device?user_code=${codes.user_code}`
)
expect(codes.expires_in).toBeGreaterThan(0)
expect(codes.interval).toBeGreaterThan(0)
const pending = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(pending.status).toBe(400)
expect(pending.json.error).toBe('authorization_pending')
const session = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`
)
expect(session.status).toBe(200)
const approved = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${session.json.access_token as string}`,
},
body: `user_code=${codes.user_code}`,
})
expect(approved.status).toBe(200)
const signedIn = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(signedIn.status).toBe(200)
expect(typeof signedIn.json.access_token).toBe('string')
expect(signedIn.json.expires_in).toBe(TOKEN_TTL_SECONDS)
// Studio treats expires_in as seconds, same as the game client.
const devicePayload = decodePayload(signedIn.json.access_token as string)
expect(devicePayload.sub).toBe('42')
// Player42 has no hasStudio flag, so a Studio login is still Limited.
expect(devicePayload.role).not.toContain('betastudio')
const again = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(again.status).toBe(400)
expect(again.json.error).toBe('invalid_grant')
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -1677,6 +1841,9 @@ describe('auth worker routes', () => {
'POST /account/me/changepassword',
'POST /cachedlogin/forplatformid/{platform}/{id}',
'POST /cachedlogin/forplatformids',
'POST /connect/device/approve',
'POST /connect/device/deny',
'POST /connect/deviceauthorization',
'POST /connect/token',
])
+5 -1
View File
@@ -57,6 +57,10 @@
// constants in src/auth.app.ts.
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment
"SENTRY_RELEASE": "unknown", // overridden during deployment
// Overridden at deploy time, same as `ns`. The device-login response builds
// the browser URL from these (`https://www.<DOMAIN>/device`).
"DOMAIN": "rec.example.com",
"SUBDOMAINS": "{}"
}
}