mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
authentication improvements
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|
||||||
@@ -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([])
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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=',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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,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']
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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']
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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']
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user