Files
recflare/packages/jwt/src/jwt.ts
T
Nexi 222547ee13 [api] audit fixes: account tests, dependencies and security hardening (#54)
* test(accounts): cover three username changes

* chore(deps): update vulnerable runtime dependencies

* fix(security): bound uploads and validate token subjects strictly

---------

Co-authored-by: Nexi (CWN) <communityshieldofficial@gmail.com>
2026-09-08 23:47:58 -04:00

272 lines
11 KiB
TypeScript

/**
* HS256 JWT generation and validation, built on Hono's `hono/jwt` helpers (Web
* Crypto under the hood) so we don't hand-roll signing, base64url, or claim
* (exp/nbf) checks.
*
* The signing key is supplied by the caller from the shared `JWT_SECRET` binding
* (a Cloudflare secret in deployed envs, `.dev.vars` locally) — see each worker's
* context.ts. `auth` signs tokens; every worker validates them with the same key.
*/
import { sign, verify } from 'hono/jwt'
import { GAME_VERSION } from '@repo/domain'
// Token lifetime in seconds (mirrored in the `expires_in` response field).
// @todo Allegedly, the game is supposed to refresh tokens every 3600 seconds, but it doesn't.
// It's possible our refresh_token implementation is broken, but for now we just make the token
// last a day so the client doesn't have to refresh it.
export const TOKEN_TTL_SECONDS = 86400
/**
* Validate an HS256 token and return its `sub` (account id) claim, or `null` when
* the token is malformed, has a bad signature, or is expired/not-yet-valid.
* `verify` throws on all of those, so a rejection just means "no valid id".
* Internal — callers use {@link validateAndGetAccountId}, which takes the request.
*/
async function getAccountIdFromToken(token: string, secret: string): Promise<string | null> {
try {
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
return typeof payload.sub === 'string' ? payload.sub : null
} catch {
return null
}
}
/**
* Validate a request's auth and return the caller's integer account id, or `null`
* when it carries no valid credential. Today that means the `sub` claim of a
* bearer token in the `Authorization` header; taking the whole `Request` (rather
* than a pre-extracted header) keeps that detail here, so if how we carry auth
* changes (a cookie, a different header) callers don't. Returns `null` when there
* is no valid bearer token, the token is invalid/expired, or `sub` isn't an integer.
*/
export async function validateAndGetAccountId(
request: Request,
secret: string
): Promise<number | null> {
const authHeader = request.headers.get('Authorization')
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('bearer '.length)
const accountId = await getAccountIdFromToken(token, secret)
if (!accountId) return null
// `parseInt("42junk", 10)` is 42, which lets a malformed subject select a real
// account. Token subjects are canonical positive base-10 account ids: reject
// partial parses, signs, decimals, leading zeroes and values outside JS's safe range.
if (!/^[1-9]\d*$/.test(accountId)) return null
const id = Number(accountId)
return Number.isSafeInteger(id) ? id : null
}
/**
* Validate a request's bearer token and return its `role` claim — the array of role
* strings stamped by {@link generateToken} (e.g. `['gameClient', 'moderator']`) — or
* `null` when the request carries no valid token (missing/malformed/expired). A valid
* token with no `role` claim yields `[]`. Callers gate privileged actions on a specific
* role being present; the shape mirrors {@link validateAndGetAccountId} so a handler can
* ask for the id or the roles the same way.
*/
export async function validateAndGetRoles(
request: Request,
secret: string
): Promise<string[] | null> {
const authHeader = request.headers.get('Authorization')
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('bearer '.length)
try {
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
return Array.isArray(payload.role)
? payload.role.filter((r): r is string => typeof r === 'string')
: []
} catch {
return null
}
}
/**
* Whether a request's bearer token says the caller has Rec Room Plus — the `rn.plus`
* claim stamped by {@link generateToken} from `account.hasPlus`. This is the ONE way Plus
* is decided (see `econ`'s `isSubscriber`); nothing re-reads the account for it, which is
* why a freshly-claimed player must sign in again before it applies.
*
* False for a missing, malformed or expired token, and false for a valid token that
* simply carries no claim — the two are not worth telling apart, since neither is a
* subscriber. Only a literal `true` counts, so a token carrying some other value in that
* key can't read as Plus.
*/
export async function validateAndGetPlus(request: Request, secret: string): Promise<boolean> {
const authHeader = request.headers.get('Authorization')
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) return false
const token = authHeader.slice('bearer '.length)
try {
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
return payload['rn.plus'] === true
} catch {
return false
}
}
/**
* Validate a request's bearer token and return its `rn.ver` claim — the game build the
* caller posted to `/connect/token`, stamped by {@link generateToken}. `null` when the
* request carries no valid token, and `null` too when a valid token has no `rn.ver` (an
* older token, issued before the claim carried the client's own value): callers fall back
* to what they stored or to GAME_VERSION rather than writing an empty version, which
* breaks the client's presence handling.
*/
export async function validateAndGetVersion(
request: Request,
secret: string
): Promise<string | null> {
const authHeader = request.headers.get('Authorization')
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('bearer '.length)
try {
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
const version = payload['rn.ver']
return typeof version === 'string' && version !== '' ? version : null
} catch {
return null
}
}
/** Scopes stamped onto every token (as a claim array). */
const TOKEN_SCOPES = [
'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',
]
/**
* Base roles every token carries — the client needs `gameClient` to operate.
* Elevated roles (e.g. `developer`, `moderator`) are NOT baked in here; the auth
* worker passes them per-account as `extraRoles` from the account's role flags, so
* a plain player's token stays `['gameClient']` and only granted accounts get more.
*/
const BASE_ROLES = ['gameClient']
/**
* The claims the Photon auth token carries beyond `sub`/`exp`/`aud`, describing who
* (and on what) is connecting. All of them go on the wire as STRINGS, including the
* numeric ones — that's how the real token encodes them.
*/
export interface PhotonAuthClaims {
/** The platform-native id (e.g. a SteamID64) — `rn.platid`. */
platformId: string
/** PlatformType int (0 = Steam) — `rn.plat`. */
platform: number
/** DeviceClass int (2 = PC/standalone) — `rn.deviceclass`. */
deviceClass: number
/** The Photon application the token is for — the `aud` claim. */
audience: string
}
/**
* Mint the short-lived HS256 token the client hands to Photon as its custom auth
* credential (`photonAuthToken` on `GET /player/connection-info`). The claim set
* mirrors the real one — `sub`, `rn.platid`, `rn.plat`, `rn.deviceclass`, `rn.env`,
* `exp`, `aud` — rather than being a second copy of the login token: it identifies
* the connecting player to the realtime server and nothing else, so none of the
* scopes or roles from {@link generateToken} belong on it.
*
* Signed with the same shared `JWT_SECRET` as every other token here. A real Photon
* Cloud application would verify this against a secret configured in its dashboard;
* self-hosted, nothing verifies it yet — so treat it as identifying, not authorizing.
* `rn.env` is `prod` because that's what the client is built against, regardless of
* which environment this worker is running in.
*/
export async function generatePhotonAuthToken(
accountId: number,
claims: PhotonAuthClaims,
secret: string
): Promise<string> {
return sign(
{
sub: String(accountId),
'rn.platid': claims.platformId,
'rn.plat': String(claims.platform),
'rn.deviceclass': String(claims.deviceClass),
'rn.env': 'prod',
exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
aud: claims.audience,
},
secret
)
}
export async function generateToken(
accountId: string,
platformId: string,
platform: number,
secret: string,
extraRoles: string[] = [],
privileges: string[] = [],
version: string = GAME_VERSION,
hasPlus = false
): Promise<string> {
const now = Math.floor(Date.now() / 1000)
// The client reads `role`/`scope` (and expects a well-formed iss/aud) to
// authorize itself; a token with only `sub` is rejected before login finishes.
return sign(
{
iss: 'https://auth.recflare.net',
aud: 'https://auth.recflare.net',
nbf: now,
iat: now,
exp: now + TOKEN_TTL_SECONDS,
auth_time: now,
amr: 'cached_login',
client_id: 'recroom',
sub: accountId,
idp: 'local',
platform,
platform_id: platformId,
// The CLIENT's build, as it posted it to /connect/token (`ver`) — not this
// server's GAME_VERSION, which is only the fallback for a grant that names none
// (a refresh, or a caller that isn't the game). Presence reads it back off the
// token, so a player's reported version is the build they are actually running.
'rn.ver': version,
'rn.plat': platform,
role: [...BASE_ROLES, ...extraRoles],
// `rn.privilege` LOOKS like a scope but is a claim: the client reads it out of
// the same claims dictionary it reads `role` from, and it never appears in
// `scope`. Omitted entirely when empty, so an unrestricted token is byte-for-byte
// what it was before privileges existed.
...(privileges.length > 0 ? { 'rn.privilege': privileges } : {}),
// Whether the account has Rec Room Plus (`account.hasPlus`) — a CLAIM, like
// `rn.privilege` and for the same reason: it is ours, the client has never heard of
// it, and `scope` is a fixed list the client parses. `econ` reads it to answer the
// CampusCard lookup and to price the subscriber discount, which is the whole point
// of carrying it here: those calls then need no database read at all.
//
// Omitted when false, so a non-subscriber's token is byte-for-byte what it was
// before Plus existed, and `validateAndGetPlus` reads an absent claim as "no Plus".
//
// STAMPED AT LOGIN, so it is only as fresh as the token: a player who claims Plus on
// the website has to sign in again (and restart the game) before it takes effect.
// Tokens last a day and the client does not refresh them — see TOKEN_TTL_SECONDS —
// so that wait is real, and it is the accepted trade for making the check free.
...(hasPlus ? { 'rn.plus': true } : {}),
scope: TOKEN_SCOPES,
jti: crypto.randomUUID(),
},
secret
)
}