diff --git a/.env.example b/.env.example index 922f61d..30bf144 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,10 @@ RECFLARE_DOMAIN=rec.example.com # the committed wrangler.jsonc (which uses "local" placeholders) and spliced in at # deploy time. Required to deploy any worker with the matching KV binding. # RECFLARE_KV='{"RECFLARE_MATCH_PRESENCE":"9f53f04b7dd244658d59f515a14748b6","RECFLARE_PLAYER_SETTINGS":"d33a90014e904b0eac720bddcbe0b036"}' + +# Id of the shared Secrets Store that holds the `JWT_SECRET` signing key (create it +# with `wrangler secrets-store store create recflare --scopes workers`). Every +# worker binds this one store as JWT_SECRET so auth-signed tokens verify everywhere. +# Kept out of the committed wrangler.jsonc (which uses a "local" placeholder) and +# spliced in at deploy time. Required to deploy any worker. +# RECFLARE_SECRETS_STORE=00000000-0000-0000-0000-000000000000 diff --git a/README.md b/README.md index d5fc015..ed90c36 100644 --- a/README.md +++ b/README.md @@ -194,10 +194,19 @@ nothing in version control needs editing. Authenticate wrangler first wrangler d1 create recflare wrangler kv namespace create RECFLARE_MATCH_PRESENCE wrangler kv namespace create RECFLARE_PLAYER_SETTINGS +wrangler secrets-store store create recflare --scopes workers ``` Take the IDs output from the commands and put them into `.env`. (or with CI: `RECFLARE_KV='{"RECFLARE_MATCH_PRESENCE":"","RECFLARE_PLAYER_SETTINGS":""}'`) +The secrets store holds the shared `JWT_SECRET` HS256 signing key — every worker +binds it so tokens signed by `auth` verify everywhere. Record its id in `.env` as +`RECFLARE_SECRETS_STORE`, then set the key value once (all workers share it): + +```bash +wrangler secrets-store secret create --name JWT_SECRET --scopes workers --remote +``` + Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful! ```bash diff --git a/apps/accounts/src/accounts.app.ts b/apps/accounts/src/accounts.app.ts index 050400e..4e21e74 100644 --- a/apps/accounts/src/accounts.app.ts +++ b/apps/accounts/src/accounts.app.ts @@ -36,7 +36,7 @@ async function authedId(c: Context): Promise { if (!authHeader.toLowerCase().startsWith('bearer ')) return null const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token) + const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) if (!accountId) return null const id = Number.parseInt(accountId, 10) diff --git a/apps/accounts/src/context.ts b/apps/accounts/src/context.ts index 2cb8e33..d6273a4 100644 --- a/apps/accounts/src/context.ts +++ b/apps/accounts/src/context.ts @@ -5,6 +5,10 @@ import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/ import type { NotificationsHub } from '../../notify/src/notifications-hub' export type Env = SharedHonoEnv & { + // Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value + // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens + // signed by `auth` verify here. + JWT_SECRET: SecretsStoreSecret // Shared rooms/accounts D1 database (schema owned by the `auth` worker). Used // to look up accounts in bulk/by id and to create new accounts. DB: D1Database diff --git a/apps/accounts/src/jwt.ts b/apps/accounts/src/jwt.ts index b105025..01ff98d 100644 --- a/apps/accounts/src/jwt.ts +++ b/apps/accounts/src/jwt.ts @@ -1,10 +1,9 @@ /** * Minimal HS256 JWT validation. * - * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). - * Swap both for a shared secret binding before this is used for anything real. + * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets + * Store binding (see context.ts) - the same key the `auth` worker signs with. */ -const DEV_SECRET = 'dev-insecure-signing-key-change-me' function base64urlToBytes(input: string): Uint8Array { const padded = input.replace(/-/g, '+').replace(/_/g, '/') @@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array { */ export async function validateAndGetAccountId( token: string, - secret: string = DEV_SECRET + secret: string ): Promise { const parts = token.split('.') if (parts.length !== 3) return null diff --git a/apps/accounts/src/test/integration/api.test.ts b/apps/accounts/src/test/integration/api.test.ts index bca1430..156009e 100644 --- a/apps/accounts/src/test/integration/api.test.ts +++ b/apps/accounts/src/test/integration/api.test.ts @@ -1,4 +1,4 @@ -import { env } from 'cloudflare:test' +import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' @@ -17,6 +17,8 @@ const ORIGIN = 'https://example.com' // Apply the accounts schema + seed the system (uid 0) and Coach (uid 1) accounts // into the test D1 (mirrors apps/auth/migrations/0001_accounts.sql). 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') for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() const insert = env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)') await env.DB.batch([ @@ -25,10 +27,10 @@ beforeAll(async () => { ]) }) -// Mint a token the way the `auth` worker does, using the same dev secret, so the +// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the // accounts worker's validation accepts it. Kept inline to avoid a cross-package // import. -const DEV_SECRET = 'dev-insecure-signing-key-change-me' +const TEST_SECRET = 'test-signing-key' function b64url(input: ArrayBuffer | string): string { const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) @@ -44,7 +46,7 @@ async function bearer(sub = '42'): Promise> { )}` const key = await crypto.subtle.importKey( 'raw', - new TextEncoder().encode(DEV_SECRET), + new TextEncoder().encode(TEST_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] diff --git a/apps/accounts/wrangler.jsonc b/apps/accounts/wrangler.jsonc index 7da7983..ad3a0c7 100644 --- a/apps/accounts/wrangler.jsonc +++ b/apps/accounts/wrangler.jsonc @@ -27,6 +27,16 @@ ] }, "logpush": false, + // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the + // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" + // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. + "secrets_store_secrets": [ + { + "binding": "JWT_SECRET", + "store_id": "local", + "secret_name": "JWT_SECRET" + } + ], "upload_source_maps": true, "observability": { "logs": { diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index acea956..fe6d5f3 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -50,7 +50,7 @@ async function authedId(c: Context): Promise { if (!authHeader.toLowerCase().startsWith('bearer ')) return null const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token) + const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) if (!accountId) return null const id = Number.parseInt(accountId, 10) @@ -599,12 +599,9 @@ const app = new Hono({ strict: false }) if (room.CreatorAccountId === accountId) return c.json(true) // Otherwise the caller needs a room role at least as high as requested. - const roles = Array.isArray(room.Roles) - ? (room.Roles as Array>) - : [] + const roles = Array.isArray(room.Roles) ? (room.Roles as Array>) : [] const hasRole = roles.some( - (r) => - r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0) + (r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0) ) return c.json(hasRole) }) diff --git a/apps/api/src/context.ts b/apps/api/src/context.ts index 7e4064c..c883cf8 100644 --- a/apps/api/src/context.ts +++ b/apps/api/src/context.ts @@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' export type Env = SharedHonoEnv & { + // Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value + // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens + // signed by `auth` verify here. + JWT_SECRET: SecretsStoreSecret /** * Base domain the share-link URL is derived from, e.g. `rec.example.com`. * Injected at deploy time via `--var DOMAIN`; defaults in `wrangler.jsonc` diff --git a/apps/api/src/jwt.ts b/apps/api/src/jwt.ts index b105025..01ff98d 100644 --- a/apps/api/src/jwt.ts +++ b/apps/api/src/jwt.ts @@ -1,10 +1,9 @@ /** * Minimal HS256 JWT validation. * - * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). - * Swap both for a shared secret binding before this is used for anything real. + * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets + * Store binding (see context.ts) - the same key the `auth` worker signs with. */ -const DEV_SECRET = 'dev-insecure-signing-key-change-me' function base64urlToBytes(input: string): Uint8Array { const padded = input.replace(/-/g, '+').replace(/_/g, '/') @@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array { */ export async function validateAndGetAccountId( token: string, - secret: string = DEV_SECRET + secret: string ): Promise { const parts = token.split('.') if (parts.length !== 3) return null diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index b91a925..f607186 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1,4 +1,4 @@ -import { env } from 'cloudflare:test' +import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' @@ -44,6 +44,8 @@ const TEST_ROOMS = [ ] 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') await env.DB.prepare( `CREATE TABLE IF NOT EXISTS rooms ( data TEXT NOT NULL, @@ -75,9 +77,9 @@ beforeAll(async () => { for (const stmt of IMAGES_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) -// Mint a token the way the `auth` worker does, using the same dev secret, so the +// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the // api worker's validation accepts it. Kept inline to avoid a cross-package import. -const DEV_SECRET = 'dev-insecure-signing-key-change-me' +const TEST_SECRET = 'test-signing-key' function b64url(input: ArrayBuffer | string): string { const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) @@ -93,7 +95,7 @@ async function bearer(sub = '42'): Promise> { )}` const key = await crypto.subtle.importKey( 'raw', - new TextEncoder().encode(DEV_SECRET), + new TextEncoder().encode(TEST_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] @@ -325,10 +327,7 @@ describe('room server', () => { }) test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => { - const verify = async ( - fields: Record, - sub?: string - ): Promise => { + const verify = async (fields: Record, sub?: string): Promise => { const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/verifyRole`, { method: 'POST', headers: { @@ -618,7 +617,12 @@ describe('images', () => { seed({ Id: 202, PlayerId: 700, CreatedAt: '2026-04-01T00:00:00.000Z' }), seed({ Id: 203, PlayerId: 700, Accessibility: 0 }), // private → hidden // Taken by someone else, but player 700 is tagged in it → feed only. - seed({ Id: 204, PlayerId: 999, TaggedPlayerIds: [700], CreatedAt: '2026-05-01T00:00:00.000Z' }), + seed({ + Id: 204, + PlayerId: 999, + TaggedPlayerIds: [700], + CreatedAt: '2026-05-01T00:00:00.000Z', + }), // Unrelated to 700 → in neither. seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }), ]) @@ -642,9 +646,9 @@ describe('images', () => { expect(feed.map((i) => i.Id)).toEqual([204, 202, 201]) // A player with no photos → empty array on both. - expect(await (await exports.default.fetch(`${ORIGIN}/api/images/v4/player/424242`)).json()).toEqual( - [] - ) + expect( + await (await exports.default.fetch(`${ORIGIN}/api/images/v4/player/424242`)).json() + ).toEqual([]) expect( await (await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/424242`)).json() ).toEqual([]) diff --git a/apps/api/wrangler.jsonc b/apps/api/wrangler.jsonc index c6b98fb..810777e 100644 --- a/apps/api/wrangler.jsonc +++ b/apps/api/wrangler.jsonc @@ -23,6 +23,16 @@ } ], "logpush": false, + // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the + // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" + // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. + "secrets_store_secrets": [ + { + "binding": "JWT_SECRET", + "store_id": "local", + "secret_name": "JWT_SECRET" + } + ], "upload_source_maps": true, "observability": { "logs": { diff --git a/apps/auth/README.md b/apps/auth/README.md index bf687c1..086fa9e 100644 --- a/apps/auth/README.md +++ b/apps/auth/README.md @@ -15,17 +15,31 @@ KV/D1/DO bindings yet. ## Signing key -Tokens are signed HS256 with the `JWT_SECRET` binding (see `src/jwt.ts`). It's a -Cloudflare secret in deployed environments and read from `.dev.vars` locally -(gitignored) — never committed. `"keep_vars": true` in `wrangler.jsonc` keeps -deploys from clearing it. +Tokens are signed HS256 with the `JWT_SECRET` binding (see `src/jwt.ts`), resolved +at request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared +**Cloudflare Secrets Store** that every worker binds (so `auth`-signed tokens verify +in `rooms`, `api`, `match`, etc.). The store id is kept out of source in the root +`.env` as `RECFLARE_SECRETS_STORE` and spliced into `wrangler.jsonc`'s `"local"` +`store_id` placeholder at deploy time (see `packages/tools/bin/run-wrangler-deploy`). -Set the deployed secret once (persists across deploys): +One-time setup (needs Cloudflare auth): ```sh -bunx wrangler secret put JWT_SECRET +# Create the store, then put the returned id in .env as RECFLARE_SECRETS_STORE +wrangler secrets-store store create recflare --scopes workers + +# Set the shared signing key (prompted for the value) +wrangler secrets-store secret create --name JWT_SECRET --scopes workers --remote ``` +For local `wrangler dev`, seed a local value (omit `--remote`) so `.get()` resolves: + +```sh +wrangler secrets-store secret create local --name JWT_SECRET --value --scopes workers +``` + +Rotating the store value invalidates all existing tokens (clients re-authenticate). + ## Notes / TODO - `/eac/challenge` content is inlined in `src/auth.app.ts` (Workers have no diff --git a/apps/auth/migrations/0003_refresh_tokens.sql b/apps/auth/migrations/0003_refresh_tokens.sql new file mode 100644 index 0000000..087f3b1 --- /dev/null +++ b/apps/auth/migrations/0003_refresh_tokens.sql @@ -0,0 +1,16 @@ +-- Refresh tokens (owned by the auth worker). Only a SHA-256 hash of each token is +-- stored, never the raw value. Single-use: redeeming deletes the row and a new +-- token is issued in its place (rotation). platform/platform_id are kept so the +-- access token can be re-minted on refresh. Kept in sync with REFRESH_SCHEMA_DDL +-- in src/refresh-db.ts. + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + token_hash TEXT PRIMARY KEY, + account_id INTEGER NOT NULL, + platform TEXT NOT NULL, + platform_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_account ON refresh_tokens (account_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at); diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 5dc5420..5ac3103 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -6,6 +6,7 @@ import { logger, withNotFound, withOnError } from '@repo/hono-helpers' import { createAccount, getPasswordHash, setPasswordHash } from './accounts-db' import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from './jwt' import { hashPassword, verifyPassword } from './password' +import { consumeRefreshToken, issueRefreshToken } from './refresh-db' import type { Context } from 'hono' import type { App } from './context' @@ -97,7 +98,10 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb 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), c.env.JWT_SECRET) + 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 } @@ -139,21 +143,38 @@ const app = new Hono() // form body. const body = await c.req.parseBody().catch(() => ({}) as Record) const grantType = typeof body.grant_type === 'string' ? body.grant_type : '' - const platformId = typeof body.platform_id === 'string' ? body.platform_id : '' + // `platform`/`platform_id` come from the body for a fresh login; a refresh + // grant overrides them below with what was stored when the token was issued. + let platformId = typeof body.platform_id === 'string' ? body.platform_id : '' // `platform` is the PlatformType int → its enum name (e.g. 0 → "Steam"). const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN - const platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '') + let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '') - // grant_type=create_account mints + persists a brand-new account (with an - // auto-assigned random username — players don't choose one initially) and the - // token's `sub` is its id. Otherwise the request MUST post a valid account_id — - // never fall back to a stub account (issuing account 1 to anyone would be bad). + // Resolve the account this token is for: + // - create_account: mint + persist a brand-new account (auto-assigned random + // username — players don't pick one initially); the token's `sub` is its id. + // - refresh_token: redeem a stored (single-use) refresh token for its account + + // platform, so an expiring session renews without re-login. + // - otherwise: the request MUST post a valid account_id — never fall back to a + // stub account (issuing account 1 to anyone would be bad). let accountId: string if (grantType === 'create_account') { const account = await createAccount(c.env.DB, { platforms: platformInt || 0 }) accountId = String(account.accountId) - // Place the new player in Orientation (they don't matchmake into it). - //await placeNewPlayerInOrientation(c.env, account.accountId) + // Place the new player in Orientation (they don't explicitly matchmake into it). + await placeNewPlayerInOrientation(c.env, account.accountId) + } else if (grantType === 'refresh_token') { + const presented = typeof body.refresh_token === 'string' ? body.refresh_token : '' + const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null + if (!refreshed) { + return c.json( + { error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' }, + 400 + ) + } + accountId = String(refreshed.accountId) + platform = refreshed.platform + platformId = refreshed.platformId } else { const posted = typeof body.account_id === 'string' ? body.account_id.trim() : '' if (!/^\d+$/.test(posted)) { @@ -165,14 +186,27 @@ const app = new Hono() accountId = posted } - const accessToken = await generateToken(accountId, platformId, platform, c.env.JWT_SECRET) + const accessToken = await generateToken( + accountId, + platformId, + platform, + await c.env.JWT_SECRET.get() + ) + // Issue a fresh, persisted refresh token (single-use; the client redeems it via + // grant_type=refresh_token). A refresh grant thus rotates its token. + const refreshToken = await issueRefreshToken(c.env.DB, { + accountId: Number(accountId), + platform, + platformId, + }) return c.json({ access_token: accessToken, expires_in: TOKEN_TTL_SECONDS, token_type: 'Bearer', - refresh_token: `${crypto.randomUUID().replace(/-/g, '').toUpperCase()}-1`, + refresh_token: refreshToken, scope: TOKEN_SCOPE, + // @kludge Why is this necessary? Who knows. key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=', }) }) diff --git a/apps/auth/src/context.ts b/apps/auth/src/context.ts index 47b9c4a..2844359 100644 --- a/apps/auth/src/context.ts +++ b/apps/auth/src/context.ts @@ -9,11 +9,11 @@ export type Env = SharedHonoEnv & { // the new player's presence is seeded to the Orientation room so the match // heartbeat keeps them there instead of bouncing them to the dorm. RECFLARE_MATCH_PRESENCE: KVNamespace - // HS256 signing key for issued access tokens. Set as a Cloudflare secret - // (`wrangler secret put JWT_SECRET`) in deployed environments and via `.dev.vars` - // locally — never committed. `keep_vars` in wrangler.jsonc stops deploys from - // clearing it. - JWT_SECRET: string + // Shared Secrets Store binding for the HS256 signing key. Resolve the value with + // `await env.JWT_SECRET.get()`. Every worker binds the same store, so tokens + // signed here verify in all of them. Provisioned via `wrangler secrets-store`; + // the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE). + JWT_SECRET: SecretsStoreSecret } /** Variables can be extended */ diff --git a/apps/auth/src/jwt.ts b/apps/auth/src/jwt.ts index 9cb595d..d445d1a 100644 --- a/apps/auth/src/jwt.ts +++ b/apps/auth/src/jwt.ts @@ -81,7 +81,7 @@ const TOKEN_SCOPES = [ ] /** Roles granted — the client needs `gameClient` to operate. */ -const TOKEN_ROLES = ['gameClient', 'developer', 'moderator'] +const TOKEN_ROLES = ['gameClient', /* 'developer', 'moderator', 'junior'*/]; export async function generateToken( accountId: string, diff --git a/apps/auth/src/refresh-db.ts b/apps/auth/src/refresh-db.ts new file mode 100644 index 0000000..2d8d127 --- /dev/null +++ b/apps/auth/src/refresh-db.ts @@ -0,0 +1,84 @@ +/** + * Refresh-token storage on the shared `recflare` D1 database (owned by the `auth` + * worker, migration 0003). Only a SHA-256 hash of each token is stored — never the + * raw value — alongside the account + platform needed to re-mint an access token, + * and an absolute expiry. Tokens are single-use: redeeming one deletes it, so a + * fresh token is issued each refresh (rotation) and a replayed token stops working. + */ + +/** Refresh tokens live this long (s) before the client must log in again. */ +export const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days + +/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql). */ +export const REFRESH_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS refresh_tokens ( + token_hash TEXT PRIMARY KEY, + account_id INTEGER NOT NULL, + platform TEXT NOT NULL, + platform_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_account ON refresh_tokens (account_id)`, + `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at)`, +] + +/** The login context needed to re-mint an access token from a refresh token. */ +export interface RefreshContext { + accountId: number + platform: string + platformId: string +} + +/** SHA-256 hex of the token. Tokens are high-entropy random, so no salt is needed. */ +async function hashToken(token: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)) + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') +} + +/** + * Mint and persist a new refresh token for the given login, returning the raw + * token — the only moment it exists in plaintext (only its hash is stored). The + * `-1` suffix mirrors the shape the client expects. + */ +export async function issueRefreshToken(db: D1Database, ctx: RefreshContext): Promise { + const token = `${crypto.randomUUID().replace(/-/g, '').toUpperCase()}-1` + const now = Math.floor(Date.now() / 1000) + await db + .prepare( + `INSERT INTO refresh_tokens (token_hash, account_id, platform, platform_id, created_at, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)` + ) + .bind( + await hashToken(token), + ctx.accountId, + ctx.platform, + ctx.platformId, + now, + now + REFRESH_TTL_SECONDS + ) + .run() + return token +} + +/** + * Redeem a refresh token: if it exists and hasn't expired, delete it (single-use + * rotation) and return its login context; otherwise return null. The delete is + * atomic (`DELETE ... RETURNING`), so a token can't be redeemed twice — a + * concurrent second attempt finds no row. An expired token is deleted and rejected. + */ +export async function consumeRefreshToken( + db: D1Database, + token: string +): Promise { + const now = Math.floor(Date.now() / 1000) + const row = await db + .prepare( + `DELETE FROM refresh_tokens WHERE token_hash = ?1 + RETURNING account_id AS accountId, platform, platform_id AS platformId, expires_at AS expiresAt` + ) + .bind(await hashToken(token)) + .first<{ accountId: number; platform: string; platformId: string; expiresAt: number }>() + if (!row || row.expiresAt < now) return null + return { accountId: row.accountId, platform: row.platform, platformId: row.platformId } +} diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 72074ee..b078d2b 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -1,10 +1,11 @@ -import { env } from 'cloudflare:test' +import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' import '../../auth.app' import { SCHEMA_DDL } from '../../accounts-db' +import { REFRESH_SCHEMA_DDL } from '../../refresh-db' import type { Env } from '../../context' @@ -21,7 +22,10 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8' // and seed the Orientation room (owned by the rooms worker) so signup can place // the new player there. 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') for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run() await env.DB.prepare( `CREATE TABLE IF NOT EXISTS rooms ( data TEXT NOT NULL, @@ -61,6 +65,16 @@ async function tokenFor(body: string): Promise> { return decodePayload(await accessTokenFor(body)) } +/** POST a form-urlencoded body to /connect/token, returning status + parsed JSON. */ +async function postToken(body: string): Promise<{ status: number; json: Record }> { + const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }) + return { status: res.status, json: (await res.json()) as Record } +} + /** POST a form-urlencoded body to changepassword with an optional bearer token. */ function changePassword(body: string, token?: string): Promise { return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, { @@ -170,6 +184,48 @@ describe('auth worker routes', () => { expect(payload.platform).toBe('Steam') }) + test('POST /connect/token returns a refresh_token that redeems for a new token', async () => { + const login = await postToken('account_id=42&platform=0&platform_id=steam-123') + expect(login.status).toBe(200) + const refreshToken = login.json.refresh_token as string + expect(typeof refreshToken).toBe('string') + expect(refreshToken.length).toBeGreaterThan(0) + + const refreshed = await postToken( + `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}` + ) + expect(refreshed.status).toBe(200) + // A fresh access token for the same account, carrying the stored platform. + const payload = decodePayload(refreshed.json.access_token as string) + expect(payload.sub).toBe('42') + expect(payload.platform).toBe('Steam') + expect(payload.platform_id).toBe('steam-123') + // The refresh token is rotated (single-use), so a new one is returned. + expect(refreshed.json.refresh_token).not.toBe(refreshToken) + }) + + test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => { + const login = await postToken('account_id=77&platform=0') + const refreshToken = login.json.refresh_token as string + + const first = await postToken( + `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}` + ) + expect(first.status).toBe(200) + // Redeeming the same token again fails — it was consumed (rotated) above. + const reuse = await postToken( + `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}` + ) + expect(reuse.status).toBe(400) + expect(reuse.json.error).toBe('invalid_grant') + }) + + test('POST /connect/token 400s on an unknown refresh_token', async () => { + const res = await postToken('grant_type=refresh_token&refresh_token=NOPE-1') + expect(res.status).toBe(400) + expect(res.json.error).toBe('invalid_grant') + }) + test('POST /cachedlogin/forplatformids returns []', async () => { const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformids`, { method: 'POST', diff --git a/apps/auth/vitest.config.ts b/apps/auth/vitest.config.ts index 50e2ba1..de0d903 100644 --- a/apps/auth/vitest.config.ts +++ b/apps/auth/vitest.config.ts @@ -8,9 +8,6 @@ export default defineConfig({ miniflare: { bindings: { ENVIRONMENT: 'VITEST', - // `.dev.vars` is gitignored, so provide a deterministic signing key - // for tests (and CI, which has no `.dev.vars`). - JWT_SECRET: 'test-signing-key', }, }, }), diff --git a/apps/auth/wrangler.jsonc b/apps/auth/wrangler.jsonc index a2a04ce..2294e0e 100644 --- a/apps/auth/wrangler.jsonc +++ b/apps/auth/wrangler.jsonc @@ -26,11 +26,17 @@ "id": "local" } ], - // Preserve environment variables and secrets already set in Cloudflare (e.g. - // JWT_SECRET, managed via `wrangler secret put`) instead of clearing them on - // deploy — keeps the signing key out of source. - "keep_vars": true, "logpush": false, + // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the + // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" + // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. + "secrets_store_secrets": [ + { + "binding": "JWT_SECRET", + "store_id": "local", + "secret_name": "JWT_SECRET" + } + ], "upload_source_maps": true, "observability": { "logs": { diff --git a/apps/cdn/src/cdn.app.ts b/apps/cdn/src/cdn.app.ts index 34ab3a0..c61d530 100644 --- a/apps/cdn/src/cdn.app.ts +++ b/apps/cdn/src/cdn.app.ts @@ -24,7 +24,7 @@ async function authedId(c: Context): Promise { if (!authHeader.toLowerCase().startsWith('bearer ')) return null const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token) + const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) if (!accountId) return null const id = Number.parseInt(accountId, 10) diff --git a/apps/cdn/src/context.ts b/apps/cdn/src/context.ts index 4d59f34..bb46474 100644 --- a/apps/cdn/src/context.ts +++ b/apps/cdn/src/context.ts @@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' export type Env = SharedHonoEnv & { + // Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value + // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens + // signed by `auth` verify here. + JWT_SECRET: SecretsStoreSecret // R2 bucket holding CDN binaries: signature blobs under `sigs/` and // room build data under `room/`. CDN_ASSETS: R2Bucket diff --git a/apps/cdn/src/jwt.ts b/apps/cdn/src/jwt.ts index b105025..01ff98d 100644 --- a/apps/cdn/src/jwt.ts +++ b/apps/cdn/src/jwt.ts @@ -1,10 +1,9 @@ /** * Minimal HS256 JWT validation. * - * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). - * Swap both for a shared secret binding before this is used for anything real. + * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets + * Store binding (see context.ts) - the same key the `auth` worker signs with. */ -const DEV_SECRET = 'dev-insecure-signing-key-change-me' function base64urlToBytes(input: string): Uint8Array { const padded = input.replace(/-/g, '+').replace(/_/g, '/') @@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array { */ export async function validateAndGetAccountId( token: string, - secret: string = DEV_SECRET + secret: string ): Promise { const parts = token.split('.') if (parts.length !== 3) return null diff --git a/apps/cdn/src/test/integration/api.test.ts b/apps/cdn/src/test/integration/api.test.ts index e8c2930..be7e7ae 100644 --- a/apps/cdn/src/test/integration/api.test.ts +++ b/apps/cdn/src/test/integration/api.test.ts @@ -1,6 +1,6 @@ -import { env } from 'cloudflare:test' +import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' -import { describe, expect, test } from 'vitest' +import { beforeAll, describe, expect, test } from 'vitest' import '../../cdn.app' @@ -12,8 +12,13 @@ declare module 'cloudflare:test' { const ORIGIN = 'https://example.com' -// Mint a token the way the `auth` worker does, using the same dev secret. -const DEV_SECRET = 'dev-insecure-signing-key-change-me' +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) @@ -29,7 +34,7 @@ async function bearer(sub = '42'): Promise> { )}` const key = await crypto.subtle.importKey( 'raw', - new TextEncoder().encode(DEV_SECRET), + new TextEncoder().encode(TEST_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] diff --git a/apps/cdn/wrangler.jsonc b/apps/cdn/wrangler.jsonc index 2a8034d..a62561b 100644 --- a/apps/cdn/wrangler.jsonc +++ b/apps/cdn/wrangler.jsonc @@ -12,6 +12,16 @@ "bucket_name": "recflare-cdn" } ], + // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the + // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" + // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. + "secrets_store_secrets": [ + { + "binding": "JWT_SECRET", + "store_id": "local", + "secret_name": "JWT_SECRET" + } + ], "upload_source_maps": true, "observability": { "logs": { diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index ddff3b6..d49ce7c 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -22,7 +22,7 @@ async function authedId(c: Context): Promise { if (!authHeader.toLowerCase().startsWith('bearer ')) return null const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token) + const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) if (!accountId) return null const id = Number.parseInt(accountId, 10) diff --git a/apps/clubs/src/context.ts b/apps/clubs/src/context.ts index 329ac40..5affecc 100644 --- a/apps/clubs/src/context.ts +++ b/apps/clubs/src/context.ts @@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' export type Env = SharedHonoEnv & { + // Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value + // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens + // signed by `auth` verify here. + JWT_SECRET: SecretsStoreSecret // add additional Bindings here } diff --git a/apps/clubs/src/jwt.ts b/apps/clubs/src/jwt.ts index b105025..01ff98d 100644 --- a/apps/clubs/src/jwt.ts +++ b/apps/clubs/src/jwt.ts @@ -1,10 +1,9 @@ /** * Minimal HS256 JWT validation. * - * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). - * Swap both for a shared secret binding before this is used for anything real. + * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets + * Store binding (see context.ts) - the same key the `auth` worker signs with. */ -const DEV_SECRET = 'dev-insecure-signing-key-change-me' function base64urlToBytes(input: string): Uint8Array { const padded = input.replace(/-/g, '+').replace(/_/g, '/') @@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array { */ export async function validateAndGetAccountId( token: string, - secret: string = DEV_SECRET + secret: string ): Promise { const parts = token.split('.') if (parts.length !== 3) return null diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index b18ccaa..294f7c6 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -1,12 +1,24 @@ +import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' -import { describe, expect, test } from 'vitest' +import { beforeAll, describe, expect, test } from 'vitest' import '../../clubs.app' +import type { Env } from '../../context' + +declare module 'cloudflare:test' { + interface ProvidedEnv extends Env {} +} + const ORIGIN = 'https://example.com' -// Mint a token the way the `auth` worker does, using the same dev secret. -const DEV_SECRET = 'dev-insecure-signing-key-change-me' +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) @@ -22,7 +34,7 @@ async function bearer(sub = '42'): Promise> { )}` const key = await crypto.subtle.importKey( 'raw', - new TextEncoder().encode(DEV_SECRET), + new TextEncoder().encode(TEST_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] diff --git a/apps/clubs/wrangler.jsonc b/apps/clubs/wrangler.jsonc index 0191509..649efcb 100644 --- a/apps/clubs/wrangler.jsonc +++ b/apps/clubs/wrangler.jsonc @@ -5,6 +5,16 @@ "compatibility_date": "2025-09-20", "compatibility_flags": ["nodejs_compat"], "logpush": false, + // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the + // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" + // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. + "secrets_store_secrets": [ + { + "binding": "JWT_SECRET", + "store_id": "local", + "secret_name": "JWT_SECRET" + } + ], "upload_source_maps": true, "observability": { "logs": { diff --git a/apps/econ/src/context.ts b/apps/econ/src/context.ts index 902068d..3a7a837 100644 --- a/apps/econ/src/context.ts +++ b/apps/econ/src/context.ts @@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' export type Env = SharedHonoEnv & { + // Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value + // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens + // signed by `auth` verify here. + JWT_SECRET: SecretsStoreSecret /** Shared `recflare` D1 (accounts table) — stores the player's avatar. */ DB: D1Database } diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 8fbd64d..6546ddc 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -11,8 +11,8 @@ import weeklyChallenge from '../static/weekly-challenge.json' import { getAvatar, setAvatar } from './avatar-db' import { validateAndGetAccountId } from './jwt' -import type { Avatar } from './avatar-db' import type { Context } from 'hono' +import type { Avatar } from './avatar-db' import type { App } from './context' /** @@ -32,7 +32,7 @@ async function authedId(c: Context): Promise { if (!authHeader.toLowerCase().startsWith('bearer ')) return null const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token) + const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) if (!accountId) return null const id = Number.parseInt(accountId, 10) diff --git a/apps/econ/src/jwt.ts b/apps/econ/src/jwt.ts index b105025..01ff98d 100644 --- a/apps/econ/src/jwt.ts +++ b/apps/econ/src/jwt.ts @@ -1,10 +1,9 @@ /** * Minimal HS256 JWT validation. * - * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). - * Swap both for a shared secret binding before this is used for anything real. + * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets + * Store binding (see context.ts) - the same key the `auth` worker signs with. */ -const DEV_SECRET = 'dev-insecure-signing-key-change-me' function base64urlToBytes(input: string): Uint8Array { const padded = input.replace(/-/g, '+').replace(/_/g, '/') @@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array { */ export async function validateAndGetAccountId( token: string, - secret: string = DEV_SECRET + secret: string ): Promise { const parts = token.split('.') if (parts.length !== 3) return null diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index a680139..6f5d93d 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1,4 +1,4 @@ -import { env } from 'cloudflare:test' +import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' @@ -17,14 +17,16 @@ const ORIGIN = 'https://example.com' // Build the accounts table and seed the test player (the default token's sub, 42) // so avatar reads/writes have a row to attach to. 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') for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)') .bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' })) .run() }) -// Mint a token the way the `auth` worker does, using the same dev secret. -const DEV_SECRET = 'dev-insecure-signing-key-change-me' +// 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) @@ -40,7 +42,7 @@ async function bearer(sub = '42'): Promise> { )}` const key = await crypto.subtle.importKey( 'raw', - new TextEncoder().encode(DEV_SECRET), + new TextEncoder().encode(TEST_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] diff --git a/apps/econ/wrangler.jsonc b/apps/econ/wrangler.jsonc index 463814d..696ee81 100644 --- a/apps/econ/wrangler.jsonc +++ b/apps/econ/wrangler.jsonc @@ -15,6 +15,16 @@ } ], "logpush": false, + // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the + // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" + // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. + "secrets_store_secrets": [ + { + "binding": "JWT_SECRET", + "store_id": "local", + "secret_name": "JWT_SECRET" + } + ], "upload_source_maps": true, "observability": { "logs": { diff --git a/apps/match/src/context.ts b/apps/match/src/context.ts index caa40a3..8b34674 100644 --- a/apps/match/src/context.ts +++ b/apps/match/src/context.ts @@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' export type Env = SharedHonoEnv & { + // Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value + // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens + // signed by `auth` verify here. + JWT_SECRET: SecretsStoreSecret // Per-player presence (the room instance they're currently in). Written by // matchmake/goto, read by the heartbeat, cleared on login — mirrors the // reference server's HeartbeatDB. diff --git a/apps/match/src/jwt.ts b/apps/match/src/jwt.ts index b105025..01ff98d 100644 --- a/apps/match/src/jwt.ts +++ b/apps/match/src/jwt.ts @@ -1,10 +1,9 @@ /** * Minimal HS256 JWT validation. * - * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). - * Swap both for a shared secret binding before this is used for anything real. + * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets + * Store binding (see context.ts) - the same key the `auth` worker signs with. */ -const DEV_SECRET = 'dev-insecure-signing-key-change-me' function base64urlToBytes(input: string): Uint8Array { const padded = input.replace(/-/g, '+').replace(/_/g, '/') @@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array { */ export async function validateAndGetAccountId( token: string, - secret: string = DEV_SECRET + secret: string ): Promise { const parts = token.split('.') if (parts.length !== 3) return null diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 0fe38af..3a0e6e2 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -4,11 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from './jwt' -import { - createRoomInstance, - getJoinableInstance, - getRoomInstancesByRoom, -} from './room-instance-db' +import { createRoomInstance, getJoinableInstance, getRoomInstancesByRoom } from './room-instance-db' import { getOrCreateDormRoom, getRoomById, getRoomByName } from './rooms-db' import type { Context } from 'hono' @@ -60,7 +56,7 @@ async function authedId(c: Context): Promise { if (!authHeader.toLowerCase().startsWith('bearer ')) return null const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token) + const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) if (!accountId) return null const id = Number.parseInt(accountId, 10) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 15e3d20..23aa169 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1,4 +1,4 @@ -import { env } from 'cloudflare:test' +import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' import { beforeAll, describe, expect, test } from 'vitest' @@ -35,6 +35,8 @@ const TEST_ROOMS = [ ] 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') await env.DB.prepare( `CREATE TABLE IF NOT EXISTS rooms ( data TEXT NOT NULL, @@ -64,10 +66,10 @@ beforeAll(async () => { ]) }) -// Mint a token the way the `auth` worker does, using the same dev secret, so the +// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the // match worker's validation accepts it. Kept inline to avoid a cross-package // import. -const DEV_SECRET = 'dev-insecure-signing-key-change-me' +const TEST_SECRET = 'test-signing-key' function b64url(input: ArrayBuffer | string): string { const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) @@ -83,7 +85,7 @@ async function bearer(sub = '42'): Promise> { )}` const key = await crypto.subtle.importKey( 'raw', - new TextEncoder().encode(DEV_SECRET), + new TextEncoder().encode(TEST_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] diff --git a/apps/match/wrangler.jsonc b/apps/match/wrangler.jsonc index 1912d75..125c112 100644 --- a/apps/match/wrangler.jsonc +++ b/apps/match/wrangler.jsonc @@ -25,6 +25,16 @@ } ], "logpush": false, + // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the + // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" + // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. + "secrets_store_secrets": [ + { + "binding": "JWT_SECRET", + "store_id": "local", + "secret_name": "JWT_SECRET" + } + ], "upload_source_maps": true, "observability": { "logs": { diff --git a/apps/playersettings/src/context.ts b/apps/playersettings/src/context.ts index 251448b..207a8b1 100644 --- a/apps/playersettings/src/context.ts +++ b/apps/playersettings/src/context.ts @@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' export type Env = SharedHonoEnv & { + // Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value + // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens + // signed by `auth` verify here. + JWT_SECRET: SecretsStoreSecret /** Per-player settings store. Key `player:` → JSON map of `{ key: value }`. */ RECFLARE_PLAYER_SETTINGS: KVNamespace } diff --git a/apps/playersettings/src/jwt.ts b/apps/playersettings/src/jwt.ts index b105025..01ff98d 100644 --- a/apps/playersettings/src/jwt.ts +++ b/apps/playersettings/src/jwt.ts @@ -1,10 +1,9 @@ /** * Minimal HS256 JWT validation. * - * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). - * Swap both for a shared secret binding before this is used for anything real. + * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets + * Store binding (see context.ts) - the same key the `auth` worker signs with. */ -const DEV_SECRET = 'dev-insecure-signing-key-change-me' function base64urlToBytes(input: string): Uint8Array { const padded = input.replace(/-/g, '+').replace(/_/g, '/') @@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array { */ export async function validateAndGetAccountId( token: string, - secret: string = DEV_SECRET + secret: string ): Promise { const parts = token.split('.') if (parts.length !== 3) return null diff --git a/apps/playersettings/src/playersettings.app.ts b/apps/playersettings/src/playersettings.app.ts index 5d1c26c..ecfe034 100644 --- a/apps/playersettings/src/playersettings.app.ts +++ b/apps/playersettings/src/playersettings.app.ts @@ -19,7 +19,7 @@ async function authedId(c: Context): Promise { if (!authHeader.toLowerCase().startsWith('bearer ')) return null const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token) + const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) if (!accountId) return null const id = Number.parseInt(accountId, 10) diff --git a/apps/playersettings/src/test/integration/api.test.ts b/apps/playersettings/src/test/integration/api.test.ts index 1a470f7..8ba1e5d 100644 --- a/apps/playersettings/src/test/integration/api.test.ts +++ b/apps/playersettings/src/test/integration/api.test.ts @@ -1,5 +1,5 @@ -import { env, SELF } from 'cloudflare:test' -import { describe, expect, it } from 'vitest' +import { adminSecretsStore, env, SELF } from 'cloudflare:test' +import { beforeAll, describe, expect, it } from 'vitest' import '../../playersettings.app' @@ -11,8 +11,13 @@ declare module 'cloudflare:test' { const ORIGIN = 'https://example.com' -// Mint a token the way the `auth` worker does, using the same dev secret. -const DEV_SECRET = 'dev-insecure-signing-key-change-me' +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) @@ -28,7 +33,7 @@ async function bearer(sub = '42'): Promise> { )}` const key = await crypto.subtle.importKey( 'raw', - new TextEncoder().encode(DEV_SECRET), + new TextEncoder().encode(TEST_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] diff --git a/apps/playersettings/wrangler.jsonc b/apps/playersettings/wrangler.jsonc index 7f75582..fcd6db6 100644 --- a/apps/playersettings/wrangler.jsonc +++ b/apps/playersettings/wrangler.jsonc @@ -11,6 +11,16 @@ "id": "local" } ], + // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the + // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" + // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. + "secrets_store_secrets": [ + { + "binding": "JWT_SECRET", + "store_id": "local", + "secret_name": "JWT_SECRET" + } + ], "upload_source_maps": true, "observability": { "logs": { diff --git a/apps/rooms/src/context.ts b/apps/rooms/src/context.ts index 30c0dad..0a6da1e 100644 --- a/apps/rooms/src/context.ts +++ b/apps/rooms/src/context.ts @@ -5,6 +5,10 @@ import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/ import type { NotificationsHub } from '../../notify/src/notifications-hub' export type Env = SharedHonoEnv & { + // Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value + // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens + // signed by `auth` verify here. + JWT_SECRET: SecretsStoreSecret // D1 database holding rooms (JSON blob + generated columns). See rooms-db.ts. DB: D1Database // Shared player-presence KV (owned by the `match` worker). Read here to resolve diff --git a/apps/rooms/src/jwt.ts b/apps/rooms/src/jwt.ts index b105025..01ff98d 100644 --- a/apps/rooms/src/jwt.ts +++ b/apps/rooms/src/jwt.ts @@ -1,10 +1,9 @@ /** * Minimal HS256 JWT validation. * - * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). - * Swap both for a shared secret binding before this is used for anything real. + * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets + * Store binding (see context.ts) - the same key the `auth` worker signs with. */ -const DEV_SECRET = 'dev-insecure-signing-key-change-me' function base64urlToBytes(input: string): Uint8Array { const padded = input.replace(/-/g, '+').replace(/_/g, '/') @@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array { */ export async function validateAndGetAccountId( token: string, - secret: string = DEV_SECRET + secret: string ): Promise { const parts = token.split('.') if (parts.length !== 3) return null diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 59679fc..7130d3a 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -19,9 +19,9 @@ import { getRoomsByCreator, getRoomsByIds, getSimilarRooms, + getVisitedRooms, removeCheer, removeFavorite, - getVisitedRooms, saveSubRoomData, searchRooms, setRoomDescription, @@ -132,7 +132,10 @@ async function handlePhotonAccessToken(c: Context) { 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)) + 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 } diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 679cef1..034ff5d 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -1,4 +1,4 @@ -import { env, SELF } from 'cloudflare:test' +import { adminSecretsStore, env, SELF } from 'cloudflare:test' import { beforeAll, describe, expect, it } from 'vitest' import '../../rooms.app' @@ -19,8 +19,8 @@ declare module 'cloudflare:test' { const ORIGIN = 'https://example.com' -// Mint a token the way the `auth` worker does, using the same dev secret. -const DEV_SECRET = 'dev-insecure-signing-key-change-me' +// 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 = '' @@ -34,7 +34,7 @@ async function bearer(sub: string): Promise> { )}` const key = await crypto.subtle.importKey( 'raw', - new TextEncoder().encode(DEV_SECRET), + new TextEncoder().encode(TEST_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] @@ -45,6 +45,8 @@ async function bearer(sub: string): Promise> { // Apply the schema + seed the imported rooms into the test D1 (mirrors the migrations). 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') for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() const insert = env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)') @@ -317,9 +319,7 @@ describe('rooms endpoints', () => { }) it('GET /rooms/recommendations returns a bare array of public rooms (split-test params ignored)', async () => { - const res = await SELF.fetch( - `${ORIGIN}/rooms/recommendations?splitTestId=1&splitTestValue=5` - ) + const res = await SELF.fetch(`${ORIGIN}/rooms/recommendations?splitTestId=1&splitTestValue=5`) expect(res.status).toBe(200) const body = (await res.json()) as Array<{ RoomId: number; IsDorm?: boolean }> expect(Array.isArray(body)).toBe(true) @@ -328,9 +328,9 @@ describe('rooms endpoints', () => { expect(body.some((r) => r.RoomId === 1 || r.IsDorm === true)).toBe(false) // The split-test params don't change the result. - const plain = (await ( - await SELF.fetch(`${ORIGIN}/rooms/recommendations`) - ).json()) as Array<{ RoomId: number }> + const plain = (await (await SELF.fetch(`${ORIGIN}/rooms/recommendations`)).json()) as Array<{ + RoomId: number + }> expect(plain.map((r) => r.RoomId)).toEqual(body.map((r) => r.RoomId)) }) @@ -664,9 +664,11 @@ describe('rooms endpoints', () => { Success: true, }) const tagsOf = async () => - ((await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { - Tags: Array<{ Tag: string; Type: number }> - }).Tags + ( + (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { + Tags: Array<{ Tag: string; Type: number }> + } + ).Tags expect(await tagsOf()).toContainEqual({ Tag: 'quest', Type: 0 }) // Adding the same tag again (different case) is a no-op — no duplicate. @@ -866,7 +868,8 @@ describe('rooms endpoints', () => { // No token → 401. expect( - (await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { method: 'DELETE' })).status + (await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { method: 'DELETE' })) + .status ).toBe(401) // Favorite + cheer on, then DELETE clears only the favorite (cheer untouched). diff --git a/apps/rooms/wrangler.jsonc b/apps/rooms/wrangler.jsonc index 0c6fe74..e4bc784 100644 --- a/apps/rooms/wrangler.jsonc +++ b/apps/rooms/wrangler.jsonc @@ -38,6 +38,16 @@ } ] }, + // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the + // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" + // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. + "secrets_store_secrets": [ + { + "binding": "JWT_SECRET", + "store_id": "local", + "secret_name": "JWT_SECRET" + } + ], "upload_source_maps": true, "observability": { "logs": { diff --git a/apps/storage/src/context.ts b/apps/storage/src/context.ts index 27a782c..71a544b 100644 --- a/apps/storage/src/context.ts +++ b/apps/storage/src/context.ts @@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' export type Env = SharedHonoEnv & { + // Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value + // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens + // signed by `auth` verify here. + JWT_SECRET: SecretsStoreSecret // Shared CDN R2 bucket (`recflare-cdn`, owned by the `cdn` worker). Client // uploads are written here under a per-FileType subfolder; the `cdn` worker // serves them back. diff --git a/apps/storage/src/jwt.ts b/apps/storage/src/jwt.ts index b105025..01ff98d 100644 --- a/apps/storage/src/jwt.ts +++ b/apps/storage/src/jwt.ts @@ -1,10 +1,9 @@ /** * Minimal HS256 JWT validation. * - * Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). - * Swap both for a shared secret binding before this is used for anything real. + * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets + * Store binding (see context.ts) - the same key the `auth` worker signs with. */ -const DEV_SECRET = 'dev-insecure-signing-key-change-me' function base64urlToBytes(input: string): Uint8Array { const padded = input.replace(/-/g, '+').replace(/_/g, '/') @@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array { */ export async function validateAndGetAccountId( token: string, - secret: string = DEV_SECRET + secret: string ): Promise { const parts = token.split('.') if (parts.length !== 3) return null diff --git a/apps/storage/src/storage.app.ts b/apps/storage/src/storage.app.ts index 5289158..f37f70b 100644 --- a/apps/storage/src/storage.app.ts +++ b/apps/storage/src/storage.app.ts @@ -55,7 +55,7 @@ async function authedId(c: Context): Promise { if (!authHeader.toLowerCase().startsWith('bearer ')) return null const token = authHeader.slice('Bearer '.length) - const accountId = await validateAndGetAccountId(token) + const accountId = await validateAndGetAccountId(token, await c.env.JWT_SECRET.get()) if (!accountId) return null const id = Number.parseInt(accountId, 10) diff --git a/apps/storage/src/test/integration/api.test.ts b/apps/storage/src/test/integration/api.test.ts index 65c877a..67be6a8 100644 --- a/apps/storage/src/test/integration/api.test.ts +++ b/apps/storage/src/test/integration/api.test.ts @@ -1,5 +1,5 @@ -import { env, SELF } from 'cloudflare:test' -import { expect, it } from 'vitest' +import { adminSecretsStore, env, SELF } from 'cloudflare:test' +import { beforeAll, expect, it } from 'vitest' import type { Env } from '../../context' @@ -9,9 +9,14 @@ declare module 'cloudflare:test' { const ORIGIN = 'https://example.com' -// Mint a token the way the `auth` worker does, using the same dev secret, so the +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, so the // storage worker's validation accepts it. -const DEV_SECRET = 'dev-insecure-signing-key-change-me' +const TEST_SECRET = 'test-signing-key' function b64url(input: ArrayBuffer | string): string { const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) @@ -27,7 +32,7 @@ async function bearer(sub = '42'): Promise> { )}` const key = await crypto.subtle.importKey( 'raw', - new TextEncoder().encode(DEV_SECRET), + new TextEncoder().encode(TEST_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] diff --git a/apps/storage/wrangler.jsonc b/apps/storage/wrangler.jsonc index cc4bc49..c23f744 100644 --- a/apps/storage/wrangler.jsonc +++ b/apps/storage/wrangler.jsonc @@ -13,6 +13,16 @@ "bucket_name": "recflare-cdn" } ], + // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the + // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" + // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. + "secrets_store_secrets": [ + { + "binding": "JWT_SECRET", + "store_id": "local", + "secret_name": "JWT_SECRET" + } + ], "upload_source_maps": true, "observability": { "logs": { diff --git a/packages/tools/bin/run-wrangler-deploy b/packages/tools/bin/run-wrangler-deploy index a6f434b..5a4b87d 100755 --- a/packages/tools/bin/run-wrangler-deploy +++ b/packages/tools/bin/run-wrangler-deploy @@ -45,11 +45,14 @@ HOST="$SUBDOMAIN.$DOMAIN" # D1 — RECFLARE_D1: a single id (all workers share the one `recflare` database). # KV — RECFLARE_KV: a JSON object keyed by binding name, since each KV namespace # is distinct, e.g. {"RECFLARE_MATCH_PRESENCE":"…","RECFLARE_PLAYER_SETTINGS":"…"}. +# Secrets Store — RECFLARE_SECRETS_STORE: a single store id (all workers bind the +# one shared store for the JWT signing key). CONFIG="wrangler.jsonc" NEEDS_D1=$(grep -q '"database_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true) NEEDS_KV=$(grep -q '"id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true) +NEEDS_STORE=$(grep -q '"store_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true) -if [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ]; then +if [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; then CONFIG="wrangler.generated.jsonc" # Generated alongside the original so its relative paths (main, migrations_dir) # still resolve. Gitignored; removed on exit so `wrangler dev` is unaffected. @@ -100,6 +103,17 @@ if [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ]; then ' wrangler.generated.jsonc >wrangler.generated.tmp || exit 1 mv wrangler.generated.tmp wrangler.generated.jsonc fi + + if [ -n "$NEEDS_STORE" ]; then + STORE_ID=${RECFLARE_SECRETS_STORE:-} + if [ -z "$STORE_ID" ]; then + echo "error: RECFLARE_SECRETS_STORE is not set — add the secrets store id to .env (see .env.example)" >&2 + exit 1 + fi + sed -E 's/("store_id"[[:space:]]*:[[:space:]]*")[^"]*(")/\1'"$STORE_ID"'\2/' \ + wrangler.generated.jsonc >wrangler.generated.tmp + mv wrangler.generated.tmp wrangler.generated.jsonc + fi fi # Deploy with wrangler using the extracted values as binding variables