/** * 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). */ export const TOKEN_TTL_SECONDS = 3600 /** * 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 { 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 { 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 const id = Number.parseInt(accountId, 10) return Number.isNaN(id) ? null : id } /** * 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 { 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 } } /** 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'] export async function generateToken( accountId: string, platformId: string, platform: number, secret: string, extraRoles: string[] = [] ): Promise { 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, 'rn.ver': GAME_VERSION, 'rn.plat': platform, role: [...BASE_ROLES, ...extraRoles], scope: TOKEN_SCOPES, jti: crypto.randomUUID(), }, secret ) }