From 4fb1c901b4bc37cb7dedd025d3d3770bc15e1e5e Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 11 Aug 2026 13:05:58 -0400 Subject: [PATCH] [auth] grant screenshare, junior --- apps/auth/src/auth.app.ts | 38 +++++++++++++++++----- apps/auth/src/test/integration/api.test.ts | 26 ++++++++++++++- packages/jwt/src/jwt.ts | 8 ++++- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index d1e786e..dc587ea 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -195,18 +195,35 @@ async function authedId(c: Context): Promise { } /** - * The elevated role names for an account's token `role` claim, derived from its - * role flags. Base roles (gameClient) are added by generateToken — these are only - * the operator-granted extras. Order is stable so tokens are deterministic. + * The role names beyond `gameClient` for an account's token `role` claim. Base roles + * (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. */ -function accountRoles(account: Pick | null): string[] { - if (!account) return [] - const roles: string[] = [] +function accountRoles( + account: Pick | 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') return roles } +/** + * The account's token `rn.privilege` claim. Despite the scope-shaped name it is a CLAIM, + * read out of the same claims dictionary as `role` — it never belongs in `scope`. The + * client knows exactly two values, both chat restrictions, and both ride on a junior + * account: `BanVChat` (voice) and `BanRmChat` (room chat). Empty for everyone else, which + * drops the claim rather than sending a blank one. + */ +function accountPrivileges(account: Pick | null): string[] { + return account?.isJunior ? ['BanVChat', 'BanRmChat'] : [] +} + /** * The platform an account's `platformId` belongs to. Nothing defaults the `platform` * field (see defaultAccount), so an account can carry a platform identity with no @@ -565,7 +582,11 @@ const app = new Hono() 'succeeds; it simply links nothing, and the player types their password each launch.', '', '**Roles.** The token embeds a `role` claim from the account, so developer/moderator', - 'powers refresh on every login and every refresh grant.', + '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', + '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.', '', '**Bans.** Once the grant has resolved an account, a BANNED account is refused a', 'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a', @@ -1000,7 +1021,8 @@ const app = new Hono() platformId, platform, jwtSecret, - accountRoles(roleAccount) + accountRoles(roleAccount), + accountPrivileges(roleAccount) ) // Issue a fresh, persisted refresh token (single-use; the client redeems it via // grant_type=refresh_token). A refresh grant thus rotates its token. diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 7751a7d..c313736 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -515,9 +515,14 @@ describe('auth worker routes', () => { expect(payload.iss).toBe('https://auth.recflare.net') expect(payload.aud).toBe('https://auth.recflare.net') expect(payload.role).toContain('gameClient') - // A plain account carries only the base role — no elevated roles. + // screenshare is a feature gate, not a grant — every token carries it. + expect(payload.role).toContain('screenshare') + // A plain adult account carries nothing beyond those — no elevated roles. expect(payload.role).not.toContain('developer') expect(payload.role).not.toContain('moderator') + 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() expect(payload.scope).toContain('rn.api') }) @@ -537,6 +542,25 @@ describe('auth worker routes', () => { expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator'])) }) + 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( + JSON.stringify({ + accountId: 92, + username: 'JuniorPlayer', + passwordHash: await hashPassword(LOGIN_PASSWORD), + isJunior: true, + }) + ) + .run() + const payload = await tokenFor(`account_id=92&password=${LOGIN_PASSWORD}`) + expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'screenshare', 'junior'])) + expect(payload.role).not.toContain('developer') + // `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`. + expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat']) + expect(payload.scope).not.toContain('rn.privilege') + }) + test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => { const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' }) expect(res.status).toBe(400) diff --git a/packages/jwt/src/jwt.ts b/packages/jwt/src/jwt.ts index 93b5733..bbae3b8 100644 --- a/packages/jwt/src/jwt.ts +++ b/packages/jwt/src/jwt.ts @@ -113,7 +113,8 @@ export async function generateToken( platformId: string, platform: number, secret: string, - extraRoles: string[] = [] + extraRoles: string[] = [], + privileges: string[] = [] ): Promise { const now = Math.floor(Date.now() / 1000) // The client reads `role`/`scope` (and expects a well-formed iss/aud) to @@ -135,6 +136,11 @@ export async function generateToken( 'rn.ver': GAME_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 } : {}), scope: TOKEN_SCOPES, jti: crypto.randomUUID(), },