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