authentication improvements

This commit is contained in:
Devin Zuczek
2026-07-06 21:08:24 -04:00
parent fbc1aedfcd
commit baf43bfe31
57 changed files with 536 additions and 146 deletions
+20 -6
View File
@@ -15,17 +15,31 @@ KV/D1/DO bindings yet.
## Signing key
Tokens are signed HS256 with the `JWT_SECRET` binding (see `src/jwt.ts`). It's a
Cloudflare secret in deployed environments and read from `.dev.vars` locally
(gitignored) — never committed. `"keep_vars": true` in `wrangler.jsonc` keeps
deploys from clearing it.
Tokens are signed HS256 with the `JWT_SECRET` binding (see `src/jwt.ts`), resolved
at request time via `await c.env.JWT_SECRET.get()`. The key lives in a single shared
**Cloudflare Secrets Store** that every worker binds (so `auth`-signed tokens verify
in `rooms`, `api`, `match`, etc.). The store id is kept out of source in the root
`.env` as `RECFLARE_SECRETS_STORE` and spliced into `wrangler.jsonc`'s `"local"`
`store_id` placeholder at deploy time (see `packages/tools/bin/run-wrangler-deploy`).
Set the deployed secret once (persists across deploys):
One-time setup (needs Cloudflare auth):
```sh
bunx wrangler secret put JWT_SECRET
# Create the store, then put the returned id in .env as RECFLARE_SECRETS_STORE
wrangler secrets-store store create recflare --scopes workers
# Set the shared signing key (prompted for the value)
wrangler secrets-store secret create <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
- `/eac/challenge` content is inlined in `src/auth.app.ts` (Workers have no
@@ -0,0 +1,16 @@
-- Refresh tokens (owned by the auth worker). Only a SHA-256 hash of each token is
-- stored, never the raw value. Single-use: redeeming deletes the row and a new
-- token is issued in its place (rotation). platform/platform_id are kept so the
-- access token can be re-minted on refresh. Kept in sync with REFRESH_SCHEMA_DDL
-- in src/refresh-db.ts.
CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT PRIMARY KEY,
account_id INTEGER NOT NULL,
platform TEXT NOT NULL,
platform_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_account ON refresh_tokens (account_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at);
+45 -11
View File
@@ -6,6 +6,7 @@ import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
import { createAccount, getPasswordHash, setPasswordHash } from './accounts-db'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from './jwt'
import { hashPassword, verifyPassword } from './password'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import type { Context } from 'hono'
import type { App } from './context'
@@ -97,7 +98,10 @@ async function placeNewPlayerInOrientation(env: App['Bindings'], accountId: numb
async function authedId(c: Context<App>): Promise<number | null> {
const authHeader = c.req.header('Authorization') ?? ''
if (!authHeader.toLowerCase().startsWith('bearer ')) return null
const sub = await validateAndGetAccountId(authHeader.slice('Bearer '.length), c.env.JWT_SECRET)
const sub = await validateAndGetAccountId(
authHeader.slice('Bearer '.length),
await c.env.JWT_SECRET.get()
)
const id = sub ? Number.parseInt(sub, 10) : Number.NaN
return Number.isNaN(id) ? null : id
}
@@ -139,21 +143,38 @@ const app = new Hono<App>()
// form body.
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
const platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
// `platform`/`platform_id` come from the body for a fresh login; a refresh
// grant overrides them below with what was stored when the token was issued.
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
// `platform` is the PlatformType int → its enum name (e.g. 0 → "Steam").
const platformInt = typeof body.platform === 'string' ? Number.parseInt(body.platform, 10) : NaN
const platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '')
let platform = Number.isNaN(platformInt) ? '' : (PLATFORM_TYPES[platformInt] ?? '')
// grant_type=create_account mints + persists a brand-new account (with an
// auto-assigned random username — players don't choose one initially) and the
// token's `sub` is its id. Otherwise the request MUST post a valid account_id —
// never fall back to a stub account (issuing account 1 to anyone would be bad).
// Resolve the account this token is for:
// - create_account: mint + persist a brand-new account (auto-assigned random
// username — players don't pick one initially); the token's `sub` is its id.
// - refresh_token: redeem a stored (single-use) refresh token for its account +
// platform, so an expiring session renews without re-login.
// - otherwise: the request MUST post a valid account_id — never fall back to a
// stub account (issuing account 1 to anyone would be bad).
let accountId: string
if (grantType === 'create_account') {
const account = await createAccount(c.env.DB, { platforms: platformInt || 0 })
accountId = String(account.accountId)
// Place the new player in Orientation (they don't matchmake into it).
//await placeNewPlayerInOrientation(c.env, account.accountId)
// Place the new player in Orientation (they don't explicitly matchmake into it).
await placeNewPlayerInOrientation(c.env, account.accountId)
} else if (grantType === 'refresh_token') {
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
if (!refreshed) {
return c.json(
{ error: 'invalid_grant', error_description: 'refresh_token is invalid or expired' },
400
)
}
accountId = String(refreshed.accountId)
platform = refreshed.platform
platformId = refreshed.platformId
} else {
const posted = typeof body.account_id === 'string' ? body.account_id.trim() : ''
if (!/^\d+$/.test(posted)) {
@@ -165,14 +186,27 @@ const app = new Hono<App>()
accountId = posted
}
const accessToken = await generateToken(accountId, platformId, platform, c.env.JWT_SECRET)
const accessToken = await generateToken(
accountId,
platformId,
platform,
await c.env.JWT_SECRET.get()
)
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
// grant_type=refresh_token). A refresh grant thus rotates its token.
const refreshToken = await issueRefreshToken(c.env.DB, {
accountId: Number(accountId),
platform,
platformId,
})
return c.json({
access_token: accessToken,
expires_in: TOKEN_TTL_SECONDS,
token_type: 'Bearer',
refresh_token: `${crypto.randomUUID().replace(/-/g, '').toUpperCase()}-1`,
refresh_token: refreshToken,
scope: TOKEN_SCOPE,
// @kludge Why is this necessary? Who knows.
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
})
})
+5 -5
View File
@@ -9,11 +9,11 @@ export type Env = SharedHonoEnv & {
// the new player's presence is seeded to the Orientation room so the match
// heartbeat keeps them there instead of bouncing them to the dorm.
RECFLARE_MATCH_PRESENCE: KVNamespace
// HS256 signing key for issued access tokens. Set as a Cloudflare secret
// (`wrangler secret put JWT_SECRET`) in deployed environments and via `.dev.vars`
// locally — never committed. `keep_vars` in wrangler.jsonc stops deploys from
// clearing it.
JWT_SECRET: string
// Shared Secrets Store binding for the HS256 signing key. Resolve the value with
// `await env.JWT_SECRET.get()`. Every worker binds the same store, so tokens
// signed here verify in all of them. Provisioned via `wrangler secrets-store`;
// the store id is spliced into wrangler.jsonc at deploy time (RECFLARE_SECRETS_STORE).
JWT_SECRET: SecretsStoreSecret
}
/** Variables can be extended */
+1 -1
View File
@@ -81,7 +81,7 @@ const TOKEN_SCOPES = [
]
/** Roles granted — the client needs `gameClient` to operate. */
const TOKEN_ROLES = ['gameClient', 'developer', 'moderator']
const TOKEN_ROLES = ['gameClient', /* 'developer', 'moderator', 'junior'*/];
export async function generateToken(
accountId: string,
+84
View File
@@ -0,0 +1,84 @@
/**
* Refresh-token storage on the shared `recflare` D1 database (owned by the `auth`
* worker, migration 0003). Only a SHA-256 hash of each token is stored — never the
* raw value — alongside the account + platform needed to re-mint an access token,
* and an absolute expiry. Tokens are single-use: redeeming one deletes it, so a
* fresh token is issued each refresh (rotation) and a replayed token stops working.
*/
/** Refresh tokens live this long (s) before the client must log in again. */
export const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days
/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql). */
export const REFRESH_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT PRIMARY KEY,
account_id INTEGER NOT NULL,
platform TEXT NOT NULL,
platform_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_account ON refresh_tokens (account_id)`,
`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at)`,
]
/** The login context needed to re-mint an access token from a refresh token. */
export interface RefreshContext {
accountId: number
platform: string
platformId: string
}
/** SHA-256 hex of the token. Tokens are high-entropy random, so no salt is needed. */
async function hashToken(token: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
}
/**
* Mint and persist a new refresh token for the given login, returning the raw
* token — the only moment it exists in plaintext (only its hash is stored). The
* `-1` suffix mirrors the shape the client expects.
*/
export async function issueRefreshToken(db: D1Database, ctx: RefreshContext): Promise<string> {
const token = `${crypto.randomUUID().replace(/-/g, '').toUpperCase()}-1`
const now = Math.floor(Date.now() / 1000)
await db
.prepare(
`INSERT INTO refresh_tokens (token_hash, account_id, platform, platform_id, created_at, expires_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`
)
.bind(
await hashToken(token),
ctx.accountId,
ctx.platform,
ctx.platformId,
now,
now + REFRESH_TTL_SECONDS
)
.run()
return token
}
/**
* Redeem a refresh token: if it exists and hasn't expired, delete it (single-use
* rotation) and return its login context; otherwise return null. The delete is
* atomic (`DELETE ... RETURNING`), so a token can't be redeemed twice — a
* concurrent second attempt finds no row. An expired token is deleted and rejected.
*/
export async function consumeRefreshToken(
db: D1Database,
token: string
): Promise<RefreshContext | null> {
const now = Math.floor(Date.now() / 1000)
const row = await db
.prepare(
`DELETE FROM refresh_tokens WHERE token_hash = ?1
RETURNING account_id AS accountId, platform, platform_id AS platformId, expires_at AS expiresAt`
)
.bind(await hashToken(token))
.first<{ accountId: number; platform: string; platformId: string; expiresAt: number }>()
if (!row || row.expiresAt < now) return null
return { accountId: row.accountId, platform: row.platform, platformId: row.platformId }
}
+57 -1
View File
@@ -1,10 +1,11 @@
import { env } from 'cloudflare:test'
import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import '../../auth.app'
import { SCHEMA_DDL } from '../../accounts-db'
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
import type { Env } from '../../context'
@@ -21,7 +22,10 @@ const ORIENTATION_SCENE = 'c79709d8-a31b-48aa-9eb8-cc31ba9505e8'
// and seed the Orientation room (owned by the rooms worker) so signup can place
// the new player there.
beforeAll(async () => {
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of REFRESH_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS rooms (
data TEXT NOT NULL,
@@ -61,6 +65,16 @@ async function tokenFor(body: string): Promise<Record<string, unknown>> {
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. */
function changePassword(body: string, token?: string): Promise<Response> {
return exports.default.fetch(`${ORIGIN}/account/me/changepassword`, {
@@ -170,6 +184,48 @@ describe('auth worker routes', () => {
expect(payload.platform).toBe('Steam')
})
test('POST /connect/token returns a refresh_token that redeems for a new token', async () => {
const login = await postToken('account_id=42&platform=0&platform_id=steam-123')
expect(login.status).toBe(200)
const refreshToken = login.json.refresh_token as string
expect(typeof refreshToken).toBe('string')
expect(refreshToken.length).toBeGreaterThan(0)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(refreshed.status).toBe(200)
// A fresh access token for the same account, carrying the stored platform.
const payload = decodePayload(refreshed.json.access_token as string)
expect(payload.sub).toBe('42')
expect(payload.platform).toBe('Steam')
expect(payload.platform_id).toBe('steam-123')
// The refresh token is rotated (single-use), so a new one is returned.
expect(refreshed.json.refresh_token).not.toBe(refreshToken)
})
test('POST /connect/token refresh_token is single-use (rejected on reuse)', async () => {
const login = await postToken('account_id=77&platform=0')
const refreshToken = login.json.refresh_token as string
const first = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(first.status).toBe(200)
// Redeeming the same token again fails — it was consumed (rotated) above.
const reuse = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(reuse.status).toBe(400)
expect(reuse.json.error).toBe('invalid_grant')
})
test('POST /connect/token 400s on an unknown refresh_token', async () => {
const res = await postToken('grant_type=refresh_token&refresh_token=NOPE-1')
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
})
test('POST /cachedlogin/forplatformids returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformids`, {
method: 'POST',
-3
View File
@@ -8,9 +8,6 @@ export default defineConfig({
miniflare: {
bindings: {
ENVIRONMENT: 'VITEST',
// `.dev.vars` is gitignored, so provide a deterministic signing key
// for tests (and CI, which has no `.dev.vars`).
JWT_SECRET: 'test-signing-key',
},
},
}),
+10 -4
View File
@@ -26,11 +26,17 @@
"id": "local"
}
],
// Preserve environment variables and secrets already set in Cloudflare (e.g.
// JWT_SECRET, managed via `wrangler secret put`) instead of clearing them on
// deploy — keeps the signing key out of source.
"keep_vars": true,
"logpush": false,
// Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the
// same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"
// store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time.
"secrets_store_secrets": [
{
"binding": "JWT_SECRET",
"store_id": "local",
"secret_name": "JWT_SECRET"
}
],
"upload_source_maps": true,
"observability": {
"logs": {