From 43c3bd93bee0ced2bfd710dc692c4ee6cd303dbf Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 9 Jul 2026 23:28:04 -0400 Subject: [PATCH] clean up auth, some storage improvements --- apps/accounts/src/accounts.app.ts | 12 +- apps/api/src/http.ts | 10 +- apps/api/src/routes/images.ts | 9 +- apps/api/src/test/integration/api.test.ts | 4 +- apps/auth/src/auth.app.ts | 9 +- apps/auth/src/test/integration/api.test.ts | 4 +- apps/cdn/src/cdn.app.ts | 40 ------ apps/cdn/src/test/integration/api.test.ts | 62 +------- apps/clubs/src/clubs.app.ts | 10 +- apps/econ/src/econ.app.ts | 10 +- apps/match/src/match.app.ts | 10 +- apps/playersettings/src/playersettings.app.ts | 10 +- .../src/test/integration/api.test.ts | 2 +- apps/rooms/src/rooms.app.ts | 9 +- apps/storage/src/storage.app.ts | 19 +-- packages/jwt/README.md | 8 +- packages/jwt/package.json | 3 + packages/jwt/src/jwt.ts | 133 ++++++++---------- pnpm-lock.yaml | 4 + 19 files changed, 90 insertions(+), 278 deletions(-) diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index 5814dbd..dfd9fa0 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -32,15 +32,7 @@ import type { App } from './context' * the token is invalid, or the `sub` claim isn't an integer. */ async function authedId(c: Context): Promise { - const authHeader = c.req.header('Authorization') ?? '' - if (!authHeader.toLowerCase().startsWith('bearer ')) return null - - const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) - if (!accountId) return null - - const id = Number.parseInt(accountId, 10) - return Number.isNaN(id) ? null : id + return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } /** Results.Unauthorized() equivalent — 401 with empty body. */ @@ -49,7 +41,7 @@ function unauthorized(c: Context) { } /** Username changes a fresh account starts with (until one has been consumed). */ -const DEFAULT_USERNAME_CHANGES = 5 +const DEFAULT_USERNAME_CHANGES = 1 /** * Username-change result envelope: `{ success, error, value }`, always HTTP 200. diff --git a/apps/api/src/http.ts b/apps/api/src/http.ts index ca03ea2..693a766 100644 --- a/apps/api/src/http.ts +++ b/apps/api/src/http.ts @@ -9,15 +9,7 @@ import type { App } from './context' * the token is invalid, or the `sub` claim isn't an integer. */ export async function authedId(c: Context): Promise { - const authHeader = c.req.header('Authorization') ?? '' - if (!authHeader.toLowerCase().startsWith('bearer ')) return null - - const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) - if (!accountId) return null - - const id = Number.parseInt(accountId, 10) - return Number.isNaN(id) ? null : id + return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } /** Results.Unauthorized() equivalent — 401 with empty body. */ diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index 15a2128..ee761e1 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -59,9 +59,12 @@ export const imageRoutes = new Hono({ strict: false }) const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : '' const extension = valid.includes(ext) ? ext : '.jpg' - // Store the upload in the shared image bucket under a random key. The `img` - // worker serves it back by that key, which is the returned ImageName. - const name = crypto.randomUUID().replace(/-/g, '') + extension + // Store the upload in the shared image bucket under a random key, foldered by + // the upload date (e.g. `2026-06-15/`) so the bucket stays browsable over time. + // The `img` worker serves it back by that key (slashes and all), which is the + // returned ImageName. + const datePrefix = new Date().toISOString().slice(0, 10) + '/' + const name = datePrefix + crypto.randomUUID().replace(/-/g, '') + extension await c.env.IMAGES.put(name, await file.arrayBuffer(), { httpMetadata: { contentType: file.type || 'image/jpeg' }, }) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index ec1bd34..bfcbf2b 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -325,7 +325,7 @@ describe('images', () => { }) expect(res.status).toBe(200) const { ImageName } = (await res.json()) as { ImageName: string } - expect(ImageName).toMatch(/^[0-9a-f]+\.png$/) + expect(ImageName).toMatch(/^\d{4}-\d{2}-\d{2}\/[0-9a-f]+\.png$/) // The object is in the shared bucket under that key. const stored = await env.IMAGES.get(ImageName) @@ -470,7 +470,7 @@ describe('images', () => { }) expect(res.status).toBe(200) const { ImageName } = (await res.json()) as { ImageName: string } - expect(ImageName).toMatch(/^[0-9a-f]+\.jpg$/) + expect(ImageName).toMatch(/^\d{4}-\d{2}-\d{2}\/[0-9a-f]+\.jpg$/) // The account row now points its profileImage at the uploaded key. const row = await env.DB.prepare('SELECT data FROM accounts WHERE account_id = 42').first<{ diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 99394d5..7dde9a1 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -102,14 +102,7 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb /** The Bearer token's account id (`sub`), or null when there's no valid token. */ async function authedId(c: Context): Promise { - const authHeader = c.req.header('Authorization') ?? '' - if (!authHeader.toLowerCase().startsWith('bearer ')) return null - const sub = await validateAndGetAccountId( - authHeader.slice('Bearer '.length), - await c.env.JWT_SECRET.get() - ) - const id = sub ? Number.parseInt(sub, 10) : Number.NaN - return Number.isNaN(id) ? null : id + return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } const app = new Hono() diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index fdd5af9..781fa71 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -143,8 +143,8 @@ describe('auth worker routes', () => { ) ) as Record expect(payload.sub).toBe('42') // account_id from the body is honored - expect(payload.iss).toBe('https://auth.lapis.codes') - expect(payload.aud).toBe('https://auth.lapis.codes/resources') + expect(payload.iss).toBe('https://auth.recflare.net') + expect(payload.aud).toBe('https://auth.recflare.net') expect(payload.role).toContain('gameClient') expect(payload.scope).toContain('rn.api') }) diff --git a/apps/cdn/src/cdn.app.ts b/apps/cdn/src/cdn.app.ts index 8831695..3d8c5d2 100644 --- a/apps/cdn/src/cdn.app.ts +++ b/apps/cdn/src/cdn.app.ts @@ -2,7 +2,6 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' -import { validateAndGetAccountId } from '@repo/jwt' import loadingScreenTipData from '../static/loading-screen-tip-data.json' @@ -15,22 +14,6 @@ import type { App, Env } from './context' * yet and are stubbed. */ -/** - * Resolve the account id from a Bearer token. Returns `null` when the header is - * missing, the token is invalid, or the `sub` claim isn't an integer. - */ -async function authedId(c: Context): Promise { - const authHeader = c.req.header('Authorization') ?? '' - if (!authHeader.toLowerCase().startsWith('bearer ')) return null - - const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) - if (!accountId) return null - - const id = Number.parseInt(accountId, 10) - return Number.isNaN(id) ? null : id -} - /** Parse a single-range `Range: bytes=start-end` header into an R2 range. */ function parseRange(header: string | undefined): R2Range | undefined { if (!header) return undefined @@ -122,27 +105,4 @@ const app = new Hono() // load the room. Streamed from R2 under `room/`. .get('/room/:dataBlob', (c) => serveAsset(c, `room/${c.req.param('dataBlob')}`)) - // Image upload. Auth-gated; returns the saved filename. No storage - // binding yet, so we accept the file and return a synthesized filename without - // persisting it. TODO: write to an R2 bucket like the `img` worker. - .post('/upload', async (c) => { - const id = await authedId(c) - if (id === null) return c.body(null, 401) - - const body = await c.req.parseBody().catch(() => ({}) as Record) - const file = body.file - if (!(file instanceof File)) { - return c.json({ error: 'No file found in request' }, 400) - } - - const validExtensions = ['.png', '.jpg', '.jpeg'] - const dot = file.name.lastIndexOf('.') - const rawExt = dot >= 0 ? file.name.slice(dot).toLowerCase() : '' - const extension = validExtensions.includes(rawExt) ? rawExt : '.png' - - const filename = crypto.randomUUID().replace(/-/g, '') + extension - // TODO: persist `file` to an R2 bucket under `filename`. - return c.json({ filename }) - }) - export default app diff --git a/apps/cdn/src/test/integration/api.test.ts b/apps/cdn/src/test/integration/api.test.ts index be7e7ae..cd073bd 100644 --- a/apps/cdn/src/test/integration/api.test.ts +++ b/apps/cdn/src/test/integration/api.test.ts @@ -1,6 +1,6 @@ -import { adminSecretsStore, env } from 'cloudflare:test' +import { env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' -import { beforeAll, describe, expect, test } from 'vitest' +import { describe, expect, test } from 'vitest' import '../../cdn.app' @@ -12,37 +12,6 @@ declare module 'cloudflare:test' { const ORIGIN = 'https://example.com' -beforeAll(async () => { - // Seed the shared JWT signing key into the local Secrets Store so .get() resolves. - await adminSecretsStore(env.JWT_SECRET).create('test-signing-key') -}) - -// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store. -const TEST_SECRET = 'test-signing-key' - -function b64url(input: ArrayBuffer | string): string { - const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) - let binary = '' - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} - -async function bearer(sub = '42'): Promise> { - const now = Math.floor(Date.now() / 1000) - const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( - JSON.stringify({ sub, exp: now + 3600 }) - )}` - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(TEST_SECRET), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ) - const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput)) - return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` } -} - describe('cdn endpoints', () => { test('GET / reports service status', async () => { const res = await exports.default.fetch(`${ORIGIN}/`) @@ -95,31 +64,4 @@ describe('cdn endpoints', () => { const res = await exports.default.fetch(`${ORIGIN}/room/missing.room`) expect(res.status).toBe(404) }) - - test('POST /upload 401s without a token', async () => { - const res = await exports.default.fetch(`${ORIGIN}/upload`, { method: 'POST' }) - expect(res.status).toBe(401) - }) - - test('POST /upload 400s when no file is supplied', async () => { - const res = await exports.default.fetch(`${ORIGIN}/upload`, { - method: 'POST', - headers: await bearer(), - }) - expect(res.status).toBe(400) - expect(await res.json()).toEqual({ error: 'No file found in request' }) - }) - - test('POST /upload returns a saved filename for a valid file', async () => { - const form = new FormData() - form.append('file', new File([new Uint8Array([1, 2, 3])], 'photo.jpg', { type: 'image/jpeg' })) - const res = await exports.default.fetch(`${ORIGIN}/upload`, { - method: 'POST', - headers: await bearer(), - body: form, - }) - expect(res.status).toBe(200) - const body = (await res.json()) as { filename: string } - expect(body.filename).toMatch(/^[0-9a-f]{32}\.jpg$/) - }) }) diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index 24b96a2..60c93c6 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -21,15 +21,7 @@ import type { App } from './context' * missing, the token is invalid, or the `sub` claim isn't an integer. */ async function authedId(c: Context): Promise { - const authHeader = c.req.header('Authorization') ?? '' - if (!authHeader.toLowerCase().startsWith('bearer ')) return null - - const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) - if (!accountId) return null - - const id = Number.parseInt(accountId, 10) - return Number.isNaN(id) ? null : id + return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } const app = new Hono() diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index c9a3c8f..1427f2a 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -27,15 +27,7 @@ import type { App } from './context' * missing, the token is invalid, or the `sub` claim isn't an integer. */ async function authedId(c: Context): Promise { - const authHeader = c.req.header('Authorization') ?? '' - if (!authHeader.toLowerCase().startsWith('bearer ')) return null - - const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) - if (!accountId) return null - - const id = Number.parseInt(accountId, 10) - return Number.isNaN(id) ? null : id + return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } /** Results.Unauthorized() equivalent — 401 with empty body. */ diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 90fa75d..00dee88 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -59,15 +59,7 @@ interface HeartbeatRequest { * the token is invalid, or the `sub` claim isn't an integer. */ async function authedId(c: Context): Promise { - const authHeader = c.req.header('Authorization') ?? '' - if (!authHeader.toLowerCase().startsWith('bearer ')) return null - - const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) - if (!accountId) return null - - const id = Number.parseInt(accountId, 10) - return Number.isNaN(id) ? null : id + return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } /** Results.Unauthorized() equivalent — 401 with empty body. */ diff --git a/apps/playersettings/src/playersettings.app.ts b/apps/playersettings/src/playersettings.app.ts index 8edd1c4..f49a19d 100644 --- a/apps/playersettings/src/playersettings.app.ts +++ b/apps/playersettings/src/playersettings.app.ts @@ -15,15 +15,7 @@ import type { App } from './context' * claim isn't an integer. */ async function authedId(c: Context): Promise { - const authHeader = c.req.header('Authorization') ?? '' - if (!authHeader.toLowerCase().startsWith('bearer ')) return null - - const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) - if (!accountId) return null - - const id = Number.parseInt(accountId, 10) - return Number.isNaN(id) ? null : id + return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } /** Results.Unauthorized() equivalent — 401 with empty body. */ diff --git a/apps/playersettings/src/test/integration/api.test.ts b/apps/playersettings/src/test/integration/api.test.ts index 8ba1e5d..94b3543 100644 --- a/apps/playersettings/src/test/integration/api.test.ts +++ b/apps/playersettings/src/test/integration/api.test.ts @@ -72,7 +72,7 @@ describe('playersettings endpoints', () => { expect(settings.length).toBeGreaterThan(0) expect(settings.every((s) => s.PlayerId === 100)).toBe(true) expect(settings.find((s) => s.Key === 'Recroom.OOBE')?.Value).toBe('77') - expect(settings.find((s) => s.Key === 'PlayerSessionCount')?.Value).toBe('13') + expect(settings.find((s) => s.Key === 'TUTORIAL_COMPLETE_MASK')?.Value).toBe('11') // Defaults were persisted to KV. const stored = await env.RECFLARE_PLAYER_SETTINGS.get>( diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 0c12b33..e0b1207 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -132,14 +132,7 @@ async function handlePhotonAccessToken(c: Context) { /** The Bearer token's account id (`sub`), or null when there's no valid token. */ async function authedAccountId(c: Context): Promise { - const authHeader = c.req.header('Authorization') ?? '' - if (!authHeader.toLowerCase().startsWith('bearer ')) return null - const sub = await validateAndGetAccountId( - authHeader.slice('Bearer '.length), - await c.env.JWT_SECRET.get() - ) - const id = sub ? Number.parseInt(sub, 10) : Number.NaN - return Number.isNaN(id) ? null : id + return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } /** 401 for the auth-gated `*by/me` endpoints — no stub-account fallback. */ diff --git a/apps/storage/src/storage.app.ts b/apps/storage/src/storage.app.ts index 38eca2d..4164105 100644 --- a/apps/storage/src/storage.app.ts +++ b/apps/storage/src/storage.app.ts @@ -4,7 +4,6 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' -import type { Context } from 'hono' import type { App } from './context' /** @@ -45,22 +44,6 @@ function textField(body: Record, ...names: string[]): string | return undefined } -/** - * Resolve the account id from a Bearer token. Returns `null` when the header is - * missing, the token is invalid, or the `sub` claim isn't an integer. - */ -async function authedId(c: Context): Promise { - const authHeader = c.req.header('Authorization') ?? '' - if (!authHeader.toLowerCase().startsWith('bearer ')) return null - - const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) - if (!accountId) return null - - const id = Number.parseInt(accountId, 10) - return Number.isNaN(id) ? null : id -} - const app = new Hono() .use( '*', @@ -86,7 +69,7 @@ const app = new Hono() // references it by. Also accepts a name-only post (no binary) that just echoes // back an explicit `name`/`filename`/`imagename`. Mirrors the reference `Upload`. .post('/upload', async (c) => { - const id = await authedId(c) + const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) if (id === null) return c.body(null, 401) const body = await c.req.parseBody().catch(() => ({}) as Record) diff --git a/packages/jwt/README.md b/packages/jwt/README.md index 5eae785..6742e20 100644 --- a/packages/jwt/README.md +++ b/packages/jwt/README.md @@ -4,6 +4,8 @@ Shared HS256 JWT helpers for the recflare workers. Single source of truth for token validation and generation, so the same signing/verification code isn't re-copied per worker. -`auth` signs tokens (`generateToken`); every worker validates the incoming -Bearer token (`validateAndGetAccountId`). Both take the signing key from the -shared `JWT_SECRET` binding (see each worker's `context.ts`). +`auth` signs tokens (`generateToken`); every worker validates a request and +resolves the caller's integer account id (`validateAndGetAccountId`, which takes +the whole `Request` so how auth is carried can change in one place). Both take +the signing key from the shared `JWT_SECRET` binding (see each worker's +`context.ts`). diff --git a/packages/jwt/package.json b/packages/jwt/package.json index c9195a6..7ff85c6 100644 --- a/packages/jwt/package.json +++ b/packages/jwt/package.json @@ -9,6 +9,9 @@ "check:lint": "run-oxlint", "check:types": "run-tsc" }, + "dependencies": { + "hono": "4.12.27" + }, "devDependencies": { "@cloudflare/workers-types": "4.20260630.1", "@repo/tools": "workspace:*", diff --git a/packages/jwt/src/jwt.ts b/packages/jwt/src/jwt.ts index 1f83ab6..1448464 100644 --- a/packages/jwt/src/jwt.ts +++ b/packages/jwt/src/jwt.ts @@ -1,66 +1,54 @@ /** - * Minimal HS256 JWT generation and validation. + * 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' + /** Token lifetime in seconds (mirrored in the `expires_in` response field). */ export const TOKEN_TTL_SECONDS = 3600 -function base64url(input: ArrayBuffer | string): string { - const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) - let binary = '' - for (const byte of bytes) { - binary += String.fromCharCode(byte) - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} - -function base64urlToBytes(input: string): Uint8Array { - const padded = input.replace(/-/g, '+').replace(/_/g, '/') - const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) - return bytes -} - /** * 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. + * 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. */ -export async function validateAndGetAccountId( - token: string, - secret: string -): Promise { - const parts = token.split('.') - if (parts.length !== 3) return null - const [header, payload, signature] = parts - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['verify'] - ) - const valid = await crypto.subtle.verify( - 'HMAC', - key, - base64urlToBytes(signature), - new TextEncoder().encode(`${header}.${payload}`) - ) - if (!valid) return null - - let claims: { sub?: string; exp?: number } +async function getAccountIdFromToken(token: string, secret: string): Promise { try { - claims = JSON.parse(new TextDecoder().decode(base64urlToBytes(payload))) + const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature + return typeof payload.sub === 'string' ? payload.sub : null } catch { return null } - if (typeof claims.exp === 'number' && claims.exp < Math.floor(Date.now() / 1000)) return null - return claims.sub ?? 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 } /** Scopes stamped onto every token (as a claim array). */ @@ -91,39 +79,28 @@ export async function generateToken( secret: string ): Promise { const now = Math.floor(Date.now() / 1000) - const header = { alg: 'HS256', typ: 'JWT' } // 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. - const payload = { - iss: 'https://auth.lapis.codes', - aud: 'https://auth.lapis.codes/resources', - 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': '20210129', - 'rn.plat': '0', - role: TOKEN_ROLES, - scope: TOKEN_SCOPES, - jti: crypto.randomUUID().replace(/-/g, '').toUpperCase(), - } - - const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}` - - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] + 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': '20210129', + 'rn.plat': '0', + role: TOKEN_ROLES, + scope: TOKEN_SCOPES, + jti: crypto.randomUUID().replace(/-/g, '').toUpperCase(), + }, + secret ) - const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput)) - - return `${signingInput}.${base64url(signature)}` } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28ea027..10ae746 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -663,6 +663,10 @@ importers: version: 4.1.9(@types/node@26.0.1)(vite@6.4.3(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.29.2)(tsx@4.22.4)(yaml@2.9.0)) packages/jwt: + dependencies: + hono: + specifier: 4.12.27 + version: 4.12.27 devDependencies: '@cloudflare/workers-types': specifier: 4.20260630.1