diff --git a/apps/auth/README.md b/apps/auth/README.md index 0421ceb..bf687c1 100644 --- a/apps/auth/README.md +++ b/apps/auth/README.md @@ -13,10 +13,21 @@ KV/D1/DO bindings yet. | POST | `/connect/token` | OAuth token endpoint, issues a JWT | | GET | `/role/developer/:id` | Developer role lookup (TODO) | +## Signing key + +Tokens are signed HS256 with the `JWT_SECRET` binding (see `src/jwt.ts`). It's a +Cloudflare secret in deployed environments and read from `.dev.vars` locally +(gitignored) — never committed. `"keep_vars": true` in `wrangler.jsonc` keeps +deploys from clearing it. + +Set the deployed secret once (persists across deploys): + +```sh +bunx wrangler secret put JWT_SECRET +``` + ## Notes / TODO -- `JWT` is signed with a placeholder dev secret in `src/jwt.ts`. Move to a secret - binding before real use. - `/eac/challenge` content is inlined in `src/auth.app.ts` (Workers have no filesystem) — replace `EAC_CHALLENGE` with the real challenge text. - `/cachedlogin/...` and the `RoomInstance` cleanup in `/connect/token` need a DB diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 145cfc0..5dc5420 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -97,7 +97,7 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb async function authedId(c: Context): Promise { const authHeader = c.req.header('Authorization') ?? '' if (!authHeader.toLowerCase().startsWith('bearer ')) return null - const sub = await validateAndGetAccountId(authHeader.slice('Bearer '.length)) + const sub = await validateAndGetAccountId(authHeader.slice('Bearer '.length), c.env.JWT_SECRET) const id = sub ? Number.parseInt(sub, 10) : Number.NaN return Number.isNaN(id) ? null : id } @@ -153,7 +153,7 @@ const app = new Hono() const account = await createAccount(c.env.DB, { platforms: platformInt || 0 }) accountId = String(account.accountId) // Place the new player in Orientation (they don't matchmake into it). - await placeNewPlayerInOrientation(c.env, account.accountId) + //await placeNewPlayerInOrientation(c.env, account.accountId) } else { const posted = typeof body.account_id === 'string' ? body.account_id.trim() : '' if (!/^\d+$/.test(posted)) { @@ -165,10 +165,7 @@ const app = new Hono() accountId = posted } - const accessToken = await generateToken(accountId, platformId, platform) - - // TODO: also create the player's dorm on create_account, and remove any - // RoomInstance owned by accountId on login. + const accessToken = await generateToken(accountId, platformId, platform, c.env.JWT_SECRET) return c.json({ access_token: accessToken, diff --git a/apps/auth/src/context.ts b/apps/auth/src/context.ts index 6810518..47b9c4a 100644 --- a/apps/auth/src/context.ts +++ b/apps/auth/src/context.ts @@ -9,6 +9,11 @@ export type Env = SharedHonoEnv & { // the new player's presence is seeded to the Orientation room so the match // heartbeat keeps them there instead of bouncing them to the dorm. RECFLARE_MATCH_PRESENCE: KVNamespace + // HS256 signing key for issued access tokens. Set as a Cloudflare secret + // (`wrangler secret put JWT_SECRET`) in deployed environments and via `.dev.vars` + // locally — never committed. `keep_vars` in wrangler.jsonc stops deploys from + // clearing it. + JWT_SECRET: string } /** Variables can be extended */ diff --git a/apps/auth/src/jwt.ts b/apps/auth/src/jwt.ts index aa79409..9cb595d 100644 --- a/apps/auth/src/jwt.ts +++ b/apps/auth/src/jwt.ts @@ -1,10 +1,9 @@ /** * Minimal HS256 JWT generation. * - * No real signing-key binding yet — uses a placeholder dev secret. Swap this for - * a secret binding (e.g. `c.env.JWT_SECRET`) before this is used for anything real. + * The signing key is supplied by the caller from the `JWT_SECRET` binding + * (a Cloudflare secret in deployed envs, `.dev.vars` locally) — see context.ts. */ -const DEV_SECRET = 'dev-insecure-signing-key-change-me' /** Token lifetime in seconds (mirrored in the `expires_in` response field). */ export const TOKEN_TTL_SECONDS = 3600 @@ -32,7 +31,7 @@ function base64urlToBytes(input: string): Uint8Array { */ export async function validateAndGetAccountId( token: string, - secret: string = DEV_SECRET + secret: string ): Promise { const parts = token.split('.') if (parts.length !== 3) return null @@ -88,7 +87,7 @@ export async function generateToken( accountId: string, platformId: string, platform: string, - secret: string = DEV_SECRET + secret: string ): Promise { const now = Math.floor(Date.now() / 1000) const header = { alg: 'HS256', typ: 'JWT' } diff --git a/apps/auth/vitest.config.ts b/apps/auth/vitest.config.ts index de0d903..50e2ba1 100644 --- a/apps/auth/vitest.config.ts +++ b/apps/auth/vitest.config.ts @@ -8,6 +8,9 @@ export default defineConfig({ miniflare: { bindings: { ENVIRONMENT: 'VITEST', + // `.dev.vars` is gitignored, so provide a deterministic signing key + // for tests (and CI, which has no `.dev.vars`). + JWT_SECRET: 'test-signing-key', }, }, }), diff --git a/apps/auth/wrangler.jsonc b/apps/auth/wrangler.jsonc index 23037b6..a2a04ce 100644 --- a/apps/auth/wrangler.jsonc +++ b/apps/auth/wrangler.jsonc @@ -26,6 +26,10 @@ "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, "upload_source_maps": true, "observability": {