mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[auth] grant screenshare, junior
This commit is contained in:
@@ -195,18 +195,35 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The elevated role names for an account's token `role` claim, derived from its
|
* The role names beyond `gameClient` for an account's token `role` claim. Base roles
|
||||||
* role flags. Base roles (gameClient) are added by generateToken — these are only
|
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
|
||||||
* the operator-granted extras. Order is stable so tokens are deterministic.
|
* 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<Account, 'isDeveloper' | 'isModerator'> | null): string[] {
|
function accountRoles(
|
||||||
if (!account) return []
|
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior'> | null
|
||||||
const roles: string[] = []
|
): string[] {
|
||||||
|
const roles = ['screenshare']
|
||||||
|
if (!account) return roles
|
||||||
if (account.isDeveloper) roles.push('developer')
|
if (account.isDeveloper) roles.push('developer')
|
||||||
if (account.isModerator) roles.push('moderator')
|
if (account.isModerator) roles.push('moderator')
|
||||||
|
if (account.isJunior) roles.push('junior')
|
||||||
return roles
|
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<Account, 'isJunior'> | null): string[] {
|
||||||
|
return account?.isJunior ? ['BanVChat', 'BanRmChat'] : []
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The platform an account's `platformId` belongs to. Nothing defaults the `platform`
|
* 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
|
* field (see defaultAccount), so an account can carry a platform identity with no
|
||||||
@@ -565,7 +582,11 @@ const app = new Hono<App>()
|
|||||||
'succeeds; it simply links nothing, and the player types their password each launch.',
|
'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',
|
'**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',
|
'**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',
|
'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a',
|
||||||
@@ -1000,7 +1021,8 @@ const app = new Hono<App>()
|
|||||||
platformId,
|
platformId,
|
||||||
platform,
|
platform,
|
||||||
jwtSecret,
|
jwtSecret,
|
||||||
accountRoles(roleAccount)
|
accountRoles(roleAccount),
|
||||||
|
accountPrivileges(roleAccount)
|
||||||
)
|
)
|
||||||
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
||||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||||
|
|||||||
@@ -515,9 +515,14 @@ describe('auth worker routes', () => {
|
|||||||
expect(payload.iss).toBe('https://auth.recflare.net')
|
expect(payload.iss).toBe('https://auth.recflare.net')
|
||||||
expect(payload.aud).toBe('https://auth.recflare.net')
|
expect(payload.aud).toBe('https://auth.recflare.net')
|
||||||
expect(payload.role).toContain('gameClient')
|
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('developer')
|
||||||
expect(payload.role).not.toContain('moderator')
|
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')
|
expect(payload.scope).toContain('rn.api')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -537,6 +542,25 @@ describe('auth worker routes', () => {
|
|||||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
|
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 () => {
|
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' })
|
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ export async function generateToken(
|
|||||||
platformId: string,
|
platformId: string,
|
||||||
platform: number,
|
platform: number,
|
||||||
secret: string,
|
secret: string,
|
||||||
extraRoles: string[] = []
|
extraRoles: string[] = [],
|
||||||
|
privileges: string[] = []
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
// The client reads `role`/`scope` (and expects a well-formed iss/aud) to
|
// 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.ver': GAME_VERSION,
|
||||||
'rn.plat': platform,
|
'rn.plat': platform,
|
||||||
role: [...BASE_ROLES, ...extraRoles],
|
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,
|
scope: TOKEN_SCOPES,
|
||||||
jti: crypto.randomUUID(),
|
jti: crypto.randomUUID(),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user