authentication improvements

This commit is contained in:
Devin Zuczek
2026-07-06 21:08:24 -04:00
parent fbc1aedfcd
commit baf43bfe31
57 changed files with 536 additions and 146 deletions
+7
View File
@@ -16,3 +16,10 @@ RECFLARE_DOMAIN=rec.example.com
# the committed wrangler.jsonc (which uses "local" placeholders) and spliced in at # the committed wrangler.jsonc (which uses "local" placeholders) and spliced in at
# deploy time. Required to deploy any worker with the matching KV binding. # deploy time. Required to deploy any worker with the matching KV binding.
# RECFLARE_KV='{"RECFLARE_MATCH_PRESENCE":"9f53f04b7dd244658d59f515a14748b6","RECFLARE_PLAYER_SETTINGS":"d33a90014e904b0eac720bddcbe0b036"}' # 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
+9
View File
@@ -194,10 +194,19 @@ nothing in version control needs editing. Authenticate wrangler first
wrangler d1 create recflare wrangler d1 create recflare
wrangler kv namespace create RECFLARE_MATCH_PRESENCE wrangler kv namespace create RECFLARE_MATCH_PRESENCE
wrangler kv namespace create RECFLARE_PLAYER_SETTINGS 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":"<id>","RECFLARE_PLAYER_SETTINGS":"<id>"}'`) Take the IDs output from the commands and put them into `.env`. (or with CI: `RECFLARE_KV='{"RECFLARE_MATCH_PRESENCE":"<id>","RECFLARE_PLAYER_SETTINGS":"<id>"}'`)
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 <store-id> --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! Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
```bash ```bash
+1 -1
View File
@@ -36,7 +36,7 @@ async function authedId(c: Context<App>): Promise<number | null> {
if (!authHeader.toLowerCase().startsWith('bearer ')) return null if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('Bearer '.length) 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 if (!accountId) return null
const id = Number.parseInt(accountId, 10) const id = Number.parseInt(accountId, 10)
+4
View File
@@ -5,6 +5,10 @@ import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/
import type { NotificationsHub } from '../../notify/src/notifications-hub' import type { NotificationsHub } from '../../notify/src/notifications-hub'
export type Env = SharedHonoEnv & { 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 // 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. // to look up accounts in bulk/by id and to create new accounts.
DB: D1Database DB: D1Database
+3 -4
View File
@@ -1,10 +1,9 @@
/** /**
* Minimal HS256 JWT validation. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets
* Swap both for a shared secret binding before this is used for anything real. * 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 { function base64urlToBytes(input: string): Uint8Array {
const padded = input.replace(/-/g, '+').replace(/_/g, '/') const padded = input.replace(/-/g, '+').replace(/_/g, '/')
@@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array {
*/ */
export async function validateAndGetAccountId( export async function validateAndGetAccountId(
token: string, token: string,
secret: string = DEV_SECRET secret: string
): Promise<string | null> { ): Promise<string | null> {
const parts = token.split('.') const parts = token.split('.')
if (parts.length !== 3) return null if (parts.length !== 3) return null
@@ -1,4 +1,4 @@
import { env } from 'cloudflare:test' import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers' import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest' 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 // 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). // into the test D1 (mirrors apps/auth/migrations/0001_accounts.sql).
beforeAll(async () => { 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 SCHEMA_DDL) await env.DB.prepare(stmt).run()
const insert = env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)') const insert = env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
await env.DB.batch([ 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 // accounts worker's validation accepts it. Kept inline to avoid a cross-package
// import. // import.
const DEV_SECRET = 'dev-insecure-signing-key-change-me' const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string { function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
@@ -44,7 +46,7 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
)}` )}`
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', 'raw',
new TextEncoder().encode(DEV_SECRET), new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, { name: 'HMAC', hash: 'SHA-256' },
false, false,
['sign'] ['sign']
+10
View File
@@ -27,6 +27,16 @@
] ]
}, },
"logpush": false, "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, "upload_source_maps": true,
"observability": { "observability": {
"logs": { "logs": {
+3 -6
View File
@@ -50,7 +50,7 @@ async function authedId(c: Context<App>): Promise<number | null> {
if (!authHeader.toLowerCase().startsWith('bearer ')) return null if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('Bearer '.length) 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 if (!accountId) return null
const id = Number.parseInt(accountId, 10) const id = Number.parseInt(accountId, 10)
@@ -599,12 +599,9 @@ const app = new Hono<App>({ strict: false })
if (room.CreatorAccountId === accountId) return c.json(true) if (room.CreatorAccountId === accountId) return c.json(true)
// Otherwise the caller needs a room role at least as high as requested. // Otherwise the caller needs a room role at least as high as requested.
const roles = Array.isArray(room.Roles) const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Record<string, unknown>>) : []
? (room.Roles as Array<Record<string, unknown>>)
: []
const hasRole = roles.some( const hasRole = roles.some(
(r) => (r) => r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
r.AccountId === accountId && typeof r.Role === 'number' && r.Role >= (role || 0)
) )
return c.json(hasRole) return c.json(hasRole)
}) })
+4
View File
@@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & { 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`. * 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` * Injected at deploy time via `--var DOMAIN`; defaults in `wrangler.jsonc`
+3 -4
View File
@@ -1,10 +1,9 @@
/** /**
* Minimal HS256 JWT validation. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets
* Swap both for a shared secret binding before this is used for anything real. * 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 { function base64urlToBytes(input: string): Uint8Array {
const padded = input.replace(/-/g, '+').replace(/_/g, '/') const padded = input.replace(/-/g, '+').replace(/_/g, '/')
@@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array {
*/ */
export async function validateAndGetAccountId( export async function validateAndGetAccountId(
token: string, token: string,
secret: string = DEV_SECRET secret: string
): Promise<string | null> { ): Promise<string | null> {
const parts = token.split('.') const parts = token.split('.')
if (parts.length !== 3) return null if (parts.length !== 3) return null
+16 -12
View File
@@ -1,4 +1,4 @@
import { env } from 'cloudflare:test' import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers' import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest' import { beforeAll, describe, expect, test } from 'vitest'
@@ -44,6 +44,8 @@ const TEST_ROOMS = [
] ]
beforeAll(async () => { 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( await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS rooms ( `CREATE TABLE IF NOT EXISTS rooms (
data TEXT NOT NULL, data TEXT NOT NULL,
@@ -75,9 +77,9 @@ beforeAll(async () => {
for (const stmt of IMAGES_SCHEMA_DDL) await env.DB.prepare(stmt).run() 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. // 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 { function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
@@ -93,7 +95,7 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
)}` )}`
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', 'raw',
new TextEncoder().encode(DEV_SECRET), new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, { name: 'HMAC', hash: 'SHA-256' },
false, false,
['sign'] ['sign']
@@ -325,10 +327,7 @@ describe('room server', () => {
}) })
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => { test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
const verify = async ( const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
fields: Record<string, string>,
sub?: string
): Promise<boolean> => {
const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/verifyRole`, { const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/verifyRole`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -618,7 +617,12 @@ describe('images', () => {
seed({ Id: 202, PlayerId: 700, CreatedAt: '2026-04-01T00:00:00.000Z' }), seed({ Id: 202, PlayerId: 700, CreatedAt: '2026-04-01T00:00:00.000Z' }),
seed({ Id: 203, PlayerId: 700, Accessibility: 0 }), // private → hidden seed({ Id: 203, PlayerId: 700, Accessibility: 0 }), // private → hidden
// Taken by someone else, but player 700 is tagged in it → feed only. // 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. // Unrelated to 700 → in neither.
seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }), seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }),
]) ])
@@ -642,9 +646,9 @@ describe('images', () => {
expect(feed.map((i) => i.Id)).toEqual([204, 202, 201]) expect(feed.map((i) => i.Id)).toEqual([204, 202, 201])
// A player with no photos → empty array on both. // 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( expect(
await (await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/424242`)).json() await (await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/424242`)).json()
).toEqual([]) ).toEqual([])
+10
View File
@@ -23,6 +23,16 @@
} }
], ],
"logpush": false, "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, "upload_source_maps": true,
"observability": { "observability": {
"logs": { "logs": {
+20 -6
View File
@@ -15,17 +15,31 @@ KV/D1/DO bindings yet.
## Signing key ## Signing key
Tokens are signed HS256 with the `JWT_SECRET` binding (see `src/jwt.ts`). It's a Tokens are signed HS256 with the `JWT_SECRET` binding (see `src/jwt.ts`), resolved
Cloudflare secret in deployed environments and read from `.dev.vars` locally at request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared
(gitignored) — never committed. `"keep_vars": true` in `wrangler.jsonc` keeps **Cloudflare Secrets Store** that every worker binds (so `auth`-signed tokens verify
deploys from clearing it. 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 ```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 <store-id> --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 <dev-key> --scopes workers
```
Rotating the store value invalidates all existing tokens (clients re-authenticate).
## Notes / TODO ## Notes / TODO
- `/eac/challenge` content is inlined in `src/auth.app.ts` (Workers have no - `/eac/challenge` content is inlined in `src/auth.app.ts` (Workers have no
@@ -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);
+45 -11
View File
@@ -6,6 +6,7 @@ import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { createAccount, getPasswordHash, setPasswordHash } from './accounts-db' import { createAccount, getPasswordHash, setPasswordHash } from './accounts-db'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from './jwt' import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from './jwt'
import { hashPassword, verifyPassword } from './password' import { hashPassword, verifyPassword } from './password'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { App } from './context' import type { App } from './context'
@@ -97,7 +98,10 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb
async function authedId(c: Context<App>): Promise<number | null> { async function authedId(c: Context<App>): Promise<number | null> {
const authHeader = c.req.header('Authorization') ?? '' const authHeader = c.req.header('Authorization') ?? ''
if (!authHeader.toLowerCase().startsWith('bearer ')) return null 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 const id = sub ? Number.parseInt(sub, 10) : Number.NaN
return Number.isNaN(id) ? null : id return Number.isNaN(id) ? null : id
} }
@@ -139,21 +143,38 @@ const app = new Hono<App>()
// form body. // form body.
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>) const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : '' 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"). // `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 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 // Resolve the account this token is for:
// auto-assigned random username — players don't choose one initially) and the // - create_account: mint + persist a brand-new account (auto-assigned random
// token's `sub` is its id. Otherwise the request MUST post a valid account_id — // username — players don't pick one initially); the token's `sub` is its id.
// never fall back to a stub account (issuing account 1 to anyone would be bad). // - 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 let accountId: string
if (grantType === 'create_account') { if (grantType === 'create_account') {
const account = await createAccount(c.env.DB, { platforms: platformInt || 0 }) const account = await createAccount(c.env.DB, { platforms: platformInt || 0 })
accountId = String(account.accountId) accountId = String(account.accountId)
// Place the new player in Orientation (they don't matchmake into it). // Place the new player in Orientation (they don't explicitly matchmake into it).
//await placeNewPlayerInOrientation(c.env, account.accountId) 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 { } else {
const posted = typeof body.account_id === 'string' ? body.account_id.trim() : '' const posted = typeof body.account_id === 'string' ? body.account_id.trim() : ''
if (!/^\d+$/.test(posted)) { if (!/^\d+$/.test(posted)) {
@@ -165,14 +186,27 @@ const app = new Hono<App>()
accountId = posted 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({ return c.json({
access_token: accessToken, access_token: accessToken,
expires_in: TOKEN_TTL_SECONDS, expires_in: TOKEN_TTL_SECONDS,
token_type: 'Bearer', token_type: 'Bearer',
refresh_token: `${crypto.randomUUID().replace(/-/g, '').toUpperCase()}-1`, refresh_token: refreshToken,
scope: TOKEN_SCOPE, scope: TOKEN_SCOPE,
// @kludge Why is this necessary? Who knows.
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=', key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
}) })
}) })
+5 -5
View File
@@ -9,11 +9,11 @@ export type Env = SharedHonoEnv & {
// the new player's presence is seeded to the Orientation room so the match // 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. // heartbeat keeps them there instead of bouncing them to the dorm.
RECFLARE_MATCH_PRESENCE: KVNamespace RECFLARE_MATCH_PRESENCE: KVNamespace
// HS256 signing key for issued access tokens. Set as a Cloudflare secret // Shared Secrets Store binding for the HS256 signing key. Resolve the value with
// (`wrangler secret put JWT_SECRET`) in deployed environments and via `.dev.vars` // `await env.JWT_SECRET.get()`. Every worker binds the same store, so tokens
// locally — never committed. `keep_vars` in wrangler.jsonc stops deploys from // signed here verify in all of them. Provisioned via `wrangler secrets-store`;
// clearing it. // the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
JWT_SECRET: string JWT_SECRET: SecretsStoreSecret
} }
/** Variables can be extended */ /** Variables can be extended */
+1 -1
View File
@@ -81,7 +81,7 @@ const TOKEN_SCOPES = [
] ]
/** Roles granted — the client needs `gameClient` to operate. */ /** 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( export async function generateToken(
accountId: string, accountId: string,
+84
View File
@@ -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<string> {
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<string> {
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<RefreshContext | null> {
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 }
}
+57 -1
View File
@@ -1,10 +1,11 @@
import { env } from 'cloudflare:test' import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers' import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest' import { beforeAll, describe, expect, test } from 'vitest'
import '../../auth.app' import '../../auth.app'
import { SCHEMA_DDL } from '../../accounts-db' import { SCHEMA_DDL } from '../../accounts-db'
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
import type { Env } from '../../context' 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 // and seed the Orientation room (owned by the rooms worker) so signup can place
// the new player there. // the new player there.
beforeAll(async () => { 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 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( await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS rooms ( `CREATE TABLE IF NOT EXISTS rooms (
data TEXT NOT NULL, data TEXT NOT NULL,
@@ -61,6 +65,16 @@ async function tokenFor(body: string): Promise<Record<string, unknown>> {
return decodePayload(await accessTokenFor(body)) 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<string, unknown> }> {
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<string, unknown> }
}
/** POST a form-urlencoded body to changepassword with an optional bearer token. */ /** POST a form-urlencoded body to changepassword with an optional bearer token. */
function changePassword(body: string, token?: string): Promise<Response> { function changePassword(body: string, token?: string): Promise<Response> {
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, { return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
@@ -170,6 +184,48 @@ describe('auth worker routes', () => {
expect(payload.platform).toBe('Steam') 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 () => { test('POST /cachedlogin/forplatformids returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformids`, { const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformids`, {
method: 'POST', method: 'POST',
-3
View File
@@ -8,9 +8,6 @@ export default defineConfig({
miniflare: { miniflare: {
bindings: { bindings: {
ENVIRONMENT: 'VITEST', 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',
}, },
}, },
}), }),
+10 -4
View File
@@ -26,11 +26,17 @@
"id": "local" "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, "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, "upload_source_maps": true,
"observability": { "observability": {
"logs": { "logs": {
+1 -1
View File
@@ -24,7 +24,7 @@ async function authedId(c: Context<App>): Promise<number | null> {
if (!authHeader.toLowerCase().startsWith('bearer ')) return null if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('Bearer '.length) 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 if (!accountId) return null
const id = Number.parseInt(accountId, 10) const id = Number.parseInt(accountId, 10)
+4
View File
@@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & { 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/<name>` and // R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
// room build data under `room/<name>`. // room build data under `room/<name>`.
CDN_ASSETS: R2Bucket CDN_ASSETS: R2Bucket
+3 -4
View File
@@ -1,10 +1,9 @@
/** /**
* Minimal HS256 JWT validation. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets
* Swap both for a shared secret binding before this is used for anything real. * 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 { function base64urlToBytes(input: string): Uint8Array {
const padded = input.replace(/-/g, '+').replace(/_/g, '/') const padded = input.replace(/-/g, '+').replace(/_/g, '/')
@@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array {
*/ */
export async function validateAndGetAccountId( export async function validateAndGetAccountId(
token: string, token: string,
secret: string = DEV_SECRET secret: string
): Promise<string | null> { ): Promise<string | null> {
const parts = token.split('.') const parts = token.split('.')
if (parts.length !== 3) return null if (parts.length !== 3) return null
+10 -5
View File
@@ -1,6 +1,6 @@
import { env } from 'cloudflare:test' import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers' import { exports } from 'cloudflare:workers'
import { describe, expect, test } from 'vitest' import { beforeAll, describe, expect, test } from 'vitest'
import '../../cdn.app' import '../../cdn.app'
@@ -12,8 +12,13 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com' const ORIGIN = 'https://example.com'
// Mint a token the way the `auth` worker does, using the same dev secret. beforeAll(async () => {
const DEV_SECRET = 'dev-insecure-signing-key-change-me' // 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 { function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
@@ -29,7 +34,7 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
)}` )}`
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', 'raw',
new TextEncoder().encode(DEV_SECRET), new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, { name: 'HMAC', hash: 'SHA-256' },
false, false,
['sign'] ['sign']
+10
View File
@@ -12,6 +12,16 @@
"bucket_name": "recflare-cdn" "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, "upload_source_maps": true,
"observability": { "observability": {
"logs": { "logs": {
+1 -1
View File
@@ -22,7 +22,7 @@ async function authedId(c: Context<App>): Promise<number | null> {
if (!authHeader.toLowerCase().startsWith('bearer ')) return null if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('Bearer '.length) 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 if (!accountId) return null
const id = Number.parseInt(accountId, 10) const id = Number.parseInt(accountId, 10)
+4
View File
@@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & { 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 // add additional Bindings here
} }
+3 -4
View File
@@ -1,10 +1,9 @@
/** /**
* Minimal HS256 JWT validation. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets
* Swap both for a shared secret binding before this is used for anything real. * 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 { function base64urlToBytes(input: string): Uint8Array {
const padded = input.replace(/-/g, '+').replace(/_/g, '/') const padded = input.replace(/-/g, '+').replace(/_/g, '/')
@@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array {
*/ */
export async function validateAndGetAccountId( export async function validateAndGetAccountId(
token: string, token: string,
secret: string = DEV_SECRET secret: string
): Promise<string | null> { ): Promise<string | null> {
const parts = token.split('.') const parts = token.split('.')
if (parts.length !== 3) return null if (parts.length !== 3) return null
+16 -4
View File
@@ -1,12 +1,24 @@
import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers' import { exports } from 'cloudflare:workers'
import { describe, expect, test } from 'vitest' import { beforeAll, describe, expect, test } from 'vitest'
import '../../clubs.app' import '../../clubs.app'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com' const ORIGIN = 'https://example.com'
// Mint a token the way the `auth` worker does, using the same dev secret. beforeAll(async () => {
const DEV_SECRET = 'dev-insecure-signing-key-change-me' // 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 { function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
@@ -22,7 +34,7 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
)}` )}`
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', 'raw',
new TextEncoder().encode(DEV_SECRET), new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, { name: 'HMAC', hash: 'SHA-256' },
false, false,
['sign'] ['sign']
+10
View File
@@ -5,6 +5,16 @@
"compatibility_date": "2025-09-20", "compatibility_date": "2025-09-20",
"compatibility_flags": ["nodejs_compat"], "compatibility_flags": ["nodejs_compat"],
"logpush": false, "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, "upload_source_maps": true,
"observability": { "observability": {
"logs": { "logs": {
+4
View File
@@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & { 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. */ /** Shared `recflare` D1 (accounts table) — stores the player's avatar. */
DB: D1Database DB: D1Database
} }
+2 -2
View File
@@ -11,8 +11,8 @@ import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db' import { getAvatar, setAvatar } from './avatar-db'
import { validateAndGetAccountId } from './jwt' import { validateAndGetAccountId } from './jwt'
import type { Avatar } from './avatar-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { Avatar } from './avatar-db'
import type { App } from './context' import type { App } from './context'
/** /**
@@ -32,7 +32,7 @@ async function authedId(c: Context<App>): Promise<number | null> {
if (!authHeader.toLowerCase().startsWith('bearer ')) return null if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('Bearer '.length) 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 if (!accountId) return null
const id = Number.parseInt(accountId, 10) const id = Number.parseInt(accountId, 10)
+3 -4
View File
@@ -1,10 +1,9 @@
/** /**
* Minimal HS256 JWT validation. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets
* Swap both for a shared secret binding before this is used for anything real. * 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 { function base64urlToBytes(input: string): Uint8Array {
const padded = input.replace(/-/g, '+').replace(/_/g, '/') const padded = input.replace(/-/g, '+').replace(/_/g, '/')
@@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array {
*/ */
export async function validateAndGetAccountId( export async function validateAndGetAccountId(
token: string, token: string,
secret: string = DEV_SECRET secret: string
): Promise<string | null> { ): Promise<string | null> {
const parts = token.split('.') const parts = token.split('.')
if (parts.length !== 3) return null if (parts.length !== 3) return null
+6 -4
View File
@@ -1,4 +1,4 @@
import { env } from 'cloudflare:test' import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers' import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest' 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) // 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. // so avatar reads/writes have a row to attach to.
beforeAll(async () => { 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 SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)') await env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' })) .bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
.run() .run()
}) })
// Mint a token the way the `auth` worker does, using the same dev secret. // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store.
const DEV_SECRET = 'dev-insecure-signing-key-change-me' const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string { function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
@@ -40,7 +42,7 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
)}` )}`
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', 'raw',
new TextEncoder().encode(DEV_SECRET), new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, { name: 'HMAC', hash: 'SHA-256' },
false, false,
['sign'] ['sign']
+10
View File
@@ -15,6 +15,16 @@
} }
], ],
"logpush": false, "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, "upload_source_maps": true,
"observability": { "observability": {
"logs": { "logs": {
+4
View File
@@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & { 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 // Per-player presence (the room instance they're currently in). Written by
// matchmake/goto, read by the heartbeat, cleared on login — mirrors the // matchmake/goto, read by the heartbeat, cleared on login — mirrors the
// reference server's HeartbeatDB. // reference server's HeartbeatDB.
+3 -4
View File
@@ -1,10 +1,9 @@
/** /**
* Minimal HS256 JWT validation. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets
* Swap both for a shared secret binding before this is used for anything real. * 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 { function base64urlToBytes(input: string): Uint8Array {
const padded = input.replace(/-/g, '+').replace(/_/g, '/') const padded = input.replace(/-/g, '+').replace(/_/g, '/')
@@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array {
*/ */
export async function validateAndGetAccountId( export async function validateAndGetAccountId(
token: string, token: string,
secret: string = DEV_SECRET secret: string
): Promise<string | null> { ): Promise<string | null> {
const parts = token.split('.') const parts = token.split('.')
if (parts.length !== 3) return null if (parts.length !== 3) return null
+2 -6
View File
@@ -4,11 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers' import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from './jwt' import { validateAndGetAccountId } from './jwt'
import { import { createRoomInstance, getJoinableInstance, getRoomInstancesByRoom } from './room-instance-db'
createRoomInstance,
getJoinableInstance,
getRoomInstancesByRoom,
} from './room-instance-db'
import { getOrCreateDormRoom, getRoomById, getRoomByName } from './rooms-db' import { getOrCreateDormRoom, getRoomById, getRoomByName } from './rooms-db'
import type { Context } from 'hono' import type { Context } from 'hono'
@@ -60,7 +56,7 @@ async function authedId(c: Context<App>): Promise<number | null> {
if (!authHeader.toLowerCase().startsWith('bearer ')) return null if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('Bearer '.length) 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 if (!accountId) return null
const id = Number.parseInt(accountId, 10) const id = Number.parseInt(accountId, 10)
+6 -4
View File
@@ -1,4 +1,4 @@
import { env } from 'cloudflare:test' import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers' import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest' import { beforeAll, describe, expect, test } from 'vitest'
@@ -35,6 +35,8 @@ const TEST_ROOMS = [
] ]
beforeAll(async () => { 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( await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS rooms ( `CREATE TABLE IF NOT EXISTS rooms (
data TEXT NOT NULL, 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 // match worker's validation accepts it. Kept inline to avoid a cross-package
// import. // import.
const DEV_SECRET = 'dev-insecure-signing-key-change-me' const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string { function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
@@ -83,7 +85,7 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
)}` )}`
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', 'raw',
new TextEncoder().encode(DEV_SECRET), new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, { name: 'HMAC', hash: 'SHA-256' },
false, false,
['sign'] ['sign']
+10
View File
@@ -25,6 +25,16 @@
} }
], ],
"logpush": false, "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, "upload_source_maps": true,
"observability": { "observability": {
"logs": { "logs": {
+4
View File
@@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & { 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:<id>` → JSON map of `{ key: value }`. */ /** Per-player settings store. Key `player:<id>` → JSON map of `{ key: value }`. */
RECFLARE_PLAYER_SETTINGS: KVNamespace RECFLARE_PLAYER_SETTINGS: KVNamespace
} }
+3 -4
View File
@@ -1,10 +1,9 @@
/** /**
* Minimal HS256 JWT validation. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets
* Swap both for a shared secret binding before this is used for anything real. * 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 { function base64urlToBytes(input: string): Uint8Array {
const padded = input.replace(/-/g, '+').replace(/_/g, '/') const padded = input.replace(/-/g, '+').replace(/_/g, '/')
@@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array {
*/ */
export async function validateAndGetAccountId( export async function validateAndGetAccountId(
token: string, token: string,
secret: string = DEV_SECRET secret: string
): Promise<string | null> { ): Promise<string | null> {
const parts = token.split('.') const parts = token.split('.')
if (parts.length !== 3) return null if (parts.length !== 3) return null
@@ -19,7 +19,7 @@ async function authedId(c: Context<App>): Promise<number | null> {
if (!authHeader.toLowerCase().startsWith('bearer ')) return null if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('Bearer '.length) 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 if (!accountId) return null
const id = Number.parseInt(accountId, 10) const id = Number.parseInt(accountId, 10)
@@ -1,5 +1,5 @@
import { env, SELF } from 'cloudflare:test' import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { describe, expect, it } from 'vitest' import { beforeAll, describe, expect, it } from 'vitest'
import '../../playersettings.app' import '../../playersettings.app'
@@ -11,8 +11,13 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com' const ORIGIN = 'https://example.com'
// Mint a token the way the `auth` worker does, using the same dev secret. beforeAll(async () => {
const DEV_SECRET = 'dev-insecure-signing-key-change-me' // 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 { function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
@@ -28,7 +33,7 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
)}` )}`
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', 'raw',
new TextEncoder().encode(DEV_SECRET), new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, { name: 'HMAC', hash: 'SHA-256' },
false, false,
['sign'] ['sign']
+10
View File
@@ -11,6 +11,16 @@
"id": "local" "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, "upload_source_maps": true,
"observability": { "observability": {
"logs": { "logs": {
+4
View File
@@ -5,6 +5,10 @@ import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/
import type { NotificationsHub } from '../../notify/src/notifications-hub' import type { NotificationsHub } from '../../notify/src/notifications-hub'
export type Env = SharedHonoEnv & { 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. // D1 database holding rooms (JSON blob + generated columns). See rooms-db.ts.
DB: D1Database DB: D1Database
// Shared player-presence KV (owned by the `match` worker). Read here to resolve // Shared player-presence KV (owned by the `match` worker). Read here to resolve
+3 -4
View File
@@ -1,10 +1,9 @@
/** /**
* Minimal HS256 JWT validation. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets
* Swap both for a shared secret binding before this is used for anything real. * 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 { function base64urlToBytes(input: string): Uint8Array {
const padded = input.replace(/-/g, '+').replace(/_/g, '/') const padded = input.replace(/-/g, '+').replace(/_/g, '/')
@@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array {
*/ */
export async function validateAndGetAccountId( export async function validateAndGetAccountId(
token: string, token: string,
secret: string = DEV_SECRET secret: string
): Promise<string | null> { ): Promise<string | null> {
const parts = token.split('.') const parts = token.split('.')
if (parts.length !== 3) return null if (parts.length !== 3) return null
+5 -2
View File
@@ -19,9 +19,9 @@ import {
getRoomsByCreator, getRoomsByCreator,
getRoomsByIds, getRoomsByIds,
getSimilarRooms, getSimilarRooms,
getVisitedRooms,
removeCheer, removeCheer,
removeFavorite, removeFavorite,
getVisitedRooms,
saveSubRoomData, saveSubRoomData,
searchRooms, searchRooms,
setRoomDescription, setRoomDescription,
@@ -132,7 +132,10 @@ async function handlePhotonAccessToken(c: Context<App>) {
async function authedAccountId(c: Context<App>): Promise<number | null> { async function authedAccountId(c: Context<App>): Promise<number | null> {
const authHeader = c.req.header('Authorization') ?? '' const authHeader = c.req.header('Authorization') ?? ''
if (!authHeader.toLowerCase().startsWith('bearer ')) return null 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 const id = sub ? Number.parseInt(sub, 10) : Number.NaN
return Number.isNaN(id) ? null : id return Number.isNaN(id) ? null : id
} }
+17 -14
View File
@@ -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 { beforeAll, describe, expect, it } from 'vitest'
import '../../rooms.app' import '../../rooms.app'
@@ -19,8 +19,8 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com' const ORIGIN = 'https://example.com'
// Mint a token the way the `auth` worker does, using the same dev secret. // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store.
const DEV_SECRET = 'dev-insecure-signing-key-change-me' const TEST_SECRET = 'test-signing-key'
function b64url(input: ArrayBuffer | string): string { function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
let binary = '' let binary = ''
@@ -34,7 +34,7 @@ async function bearer(sub: string): Promise<Record<string, string>> {
)}` )}`
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', 'raw',
new TextEncoder().encode(DEV_SECRET), new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, { name: 'HMAC', hash: 'SHA-256' },
false, false,
['sign'] ['sign']
@@ -45,6 +45,8 @@ async function bearer(sub: string): Promise<Record<string, string>> {
// Apply the schema + seed the imported rooms into the test D1 (mirrors the migrations). // Apply the schema + seed the imported rooms into the test D1 (mirrors the migrations).
beforeAll(async () => { 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 SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_INSTANCE_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)') 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 () => { it('GET /rooms/recommendations returns a bare array of public rooms (split-test params ignored)', async () => {
const res = await SELF.fetch( const res = await SELF.fetch(`${ORIGIN}/rooms/recommendations?splitTestId=1&splitTestValue=5`)
`${ORIGIN}/rooms/recommendations?splitTestId=1&splitTestValue=5`
)
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as Array<{ RoomId: number; IsDorm?: boolean }> const body = (await res.json()) as Array<{ RoomId: number; IsDorm?: boolean }>
expect(Array.isArray(body)).toBe(true) 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) expect(body.some((r) => r.RoomId === 1 || r.IsDorm === true)).toBe(false)
// The split-test params don't change the result. // The split-test params don't change the result.
const plain = (await ( const plain = (await (await SELF.fetch(`${ORIGIN}/rooms/recommendations`)).json()) as Array<{
await SELF.fetch(`${ORIGIN}/rooms/recommendations`) RoomId: number
).json()) as Array<{ RoomId: number }> }>
expect(plain.map((r) => r.RoomId)).toEqual(body.map((r) => r.RoomId)) expect(plain.map((r) => r.RoomId)).toEqual(body.map((r) => r.RoomId))
}) })
@@ -664,9 +664,11 @@ describe('rooms endpoints', () => {
Success: true, Success: true,
}) })
const tagsOf = async () => const tagsOf = async () =>
((await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { (
Tags: Array<{ Tag: string; Type: number }> (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
}).Tags Tags: Array<{ Tag: string; Type: number }>
}
).Tags
expect(await tagsOf()).toContainEqual({ Tag: 'quest', Type: 0 }) expect(await tagsOf()).toContainEqual({ Tag: 'quest', Type: 0 })
// Adding the same tag again (different case) is a no-op — no duplicate. // Adding the same tag again (different case) is a no-op — no duplicate.
@@ -866,7 +868,8 @@ describe('rooms endpoints', () => {
// No token → 401. // No token → 401.
expect( 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) ).toBe(401)
// Favorite + cheer on, then DELETE clears only the favorite (cheer untouched). // Favorite + cheer on, then DELETE clears only the favorite (cheer untouched).
+10
View File
@@ -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, "upload_source_maps": true,
"observability": { "observability": {
"logs": { "logs": {
+4
View File
@@ -2,6 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & { 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 // Shared CDN R2 bucket (`recflare-cdn`, owned by the `cdn` worker). Client
// uploads are written here under a per-FileType subfolder; the `cdn` worker // uploads are written here under a per-FileType subfolder; the `cdn` worker
// serves them back. // serves them back.
+3 -4
View File
@@ -1,10 +1,9 @@
/** /**
* Minimal HS256 JWT validation. * Minimal HS256 JWT validation.
* *
* Uses the same placeholder dev secret as the `auth` worker (`apps/auth/src/jwt.ts`). * The signing key is supplied by the caller from the shared `JWT_SECRET` Secrets
* Swap both for a shared secret binding before this is used for anything real. * 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 { function base64urlToBytes(input: string): Uint8Array {
const padded = input.replace(/-/g, '+').replace(/_/g, '/') const padded = input.replace(/-/g, '+').replace(/_/g, '/')
@@ -22,7 +21,7 @@ function base64urlToBytes(input: string): Uint8Array {
*/ */
export async function validateAndGetAccountId( export async function validateAndGetAccountId(
token: string, token: string,
secret: string = DEV_SECRET secret: string
): Promise<string | null> { ): Promise<string | null> {
const parts = token.split('.') const parts = token.split('.')
if (parts.length !== 3) return null if (parts.length !== 3) return null
+1 -1
View File
@@ -55,7 +55,7 @@ async function authedId(c: Context<App>): Promise<number | null> {
if (!authHeader.toLowerCase().startsWith('bearer ')) return null if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const token = authHeader.slice('Bearer '.length) 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 if (!accountId) return null
const id = Number.parseInt(accountId, 10) const id = Number.parseInt(accountId, 10)
+10 -5
View File
@@ -1,5 +1,5 @@
import { env, SELF } from 'cloudflare:test' import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { expect, it } from 'vitest' import { beforeAll, expect, it } from 'vitest'
import type { Env } from '../../context' import type { Env } from '../../context'
@@ -9,9 +9,14 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com' 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. // 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 { function b64url(input: ArrayBuffer | string): string {
const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
@@ -27,7 +32,7 @@ async function bearer(sub = '42'): Promise<Record<string, string>> {
)}` )}`
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey(
'raw', 'raw',
new TextEncoder().encode(DEV_SECRET), new TextEncoder().encode(TEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' }, { name: 'HMAC', hash: 'SHA-256' },
false, false,
['sign'] ['sign']
+10
View File
@@ -13,6 +13,16 @@
"bucket_name": "recflare-cdn" "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, "upload_source_maps": true,
"observability": { "observability": {
"logs": { "logs": {
+15 -1
View File
@@ -45,11 +45,14 @@ HOST="$SUBDOMAIN.$DOMAIN"
# D1 — RECFLARE_D1: a single id (all workers share the one `recflare` database). # 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 # KV — RECFLARE_KV: a JSON object keyed by binding name, since each KV namespace
# is distinct, e.g. {"RECFLARE_MATCH_PRESENCE":"…","RECFLARE_PLAYER_SETTINGS":"…"}. # 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" CONFIG="wrangler.jsonc"
NEEDS_D1=$(grep -q '"database_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true) 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_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" CONFIG="wrangler.generated.jsonc"
# Generated alongside the original so its relative paths (main, migrations_dir) # Generated alongside the original so its relative paths (main, migrations_dir)
# still resolve. Gitignored; removed on exit so `wrangler dev` is unaffected. # 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 ' wrangler.generated.jsonc >wrangler.generated.tmp || exit 1
mv wrangler.generated.tmp wrangler.generated.jsonc mv wrangler.generated.tmp wrangler.generated.jsonc
fi 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 fi
# Deploy with wrangler using the extracted values as binding variables # Deploy with wrangler using the extracted values as binding variables