Skip to content

Commit af342d9

Browse files
committed
Auth: replace KV verifier storage with AES-GCM-256 encrypted state, drop KV namespace.
1 parent 621d88d commit af342d9

4 files changed

Lines changed: 281 additions & 102 deletions

File tree

workers/gh-store-app-oauth/.dev.vars.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,7 @@
22
# In production these are set via `wrangler secret put <NAME>`.
33

44
OAUTH_SERVICE_TOKEN="replace-me-with-shared-secret"
5+
6+
# AES-GCM-256 key, base64 of 32 random bytes.
7+
# Generate locally with: `openssl rand 32 | base64`
8+
STATE_ENC_KEY="replace-me-with-base64-of-32-random-bytes"

workers/gh-store-app-oauth/README.md

Lines changed: 112 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ every other path on `github-store.org`; this Worker only owns `/auth/*`.
77
This is **separate** from the Decap CMS OAuth worker. Do not merge them — they
88
have different scopes, different audiences, and different lifecycles.
99

10-
## Flow (PKCE Pattern A)
10+
## Flow (PKCE Pattern A, stateless Worker)
1111

1212
```
1313
App Worker (this) Backend
@@ -21,38 +21,49 @@ App Worker (this) Backend
2121
│ { state, code_verifier, code_challenge } │
2222
│ ────────────────────────────▶│ │
2323
│ │ validate formats │
24+
│ │ verify PKCE challenge at the edge │
2425
│ │ POST /v1/oauth/state { state, │
2526
│ │ code_challenge } │
2627
│ │ X-Oauth-Service-Token: <T> │
2728
│ │ ─────────────────────────────────────▶│
28-
│ │ ◀── 2xx │
29-
│ │ KV.put oauth:verifier:<S> = verifier │
30-
│ │ ttl=60s │
29+
│ │ ◀── 2xx (or 409 if duplicate) │
30+
│ │ blob = AES-GCM(STATE_ENC_KEY, │
31+
│ │ {v: verifier, e: now+60s}) │
32+
│ │ combined = "<state>.<blob>" │
3133
│ ◀── 200 { auth_url } │ │
3234
│ │
3335
│ app opens auth_url in system browser │
3436
│ │ │
3537
│ (user signs in on GitHub) │
36-
│ │ ◀── GitHub 302 /auth/callback?code=<G>&state=<S>
37-
│ │ KV.get oauth:verifier:<S>
38-
│ │ miss → error page
39-
│ │ KV.delete (one-shot)
38+
│ │ ◀── GitHub 302 /auth/callback?code=<G>&state=<state>.<blob>
39+
│ │ split combined on "."
40+
│ │ decrypt blob → recover verifier, exp
41+
│ │ tamper / wrong key / expired → 4xx
4042
│ │ POST /v1/oauth/exchange { code, │
4143
│ │ state, code_verifier } │
4244
│ │ X-Oauth-Service-Token: <T> │
4345
│ │ ─────────────────────────────────────▶│
4446
│ │ ◀── { handoff_id } │
4547
│ │ render HTML with deep link │
46-
│ ◀── githubstore://auth?handoff=<H>&state=<S>
48+
│ ◀── githubstore://auth?handoff=<H>&state=<original-state>
4749
│ │
4850
│ POST /v1/oauth/handoff/<H> │
4951
│ ────────────────────────────────────────────────────────────────────▶│
5052
│ ◀── { access_token } │
5153
```
5254

53-
The `code_verifier` never leaves the Worker on a user-visible URL — it travels
54-
only on the server-to-server hop to the backend's exchange endpoint, keyed by
55-
the single-use `state`.
55+
The `code_verifier` never leaves the Worker on a user-visible URL **in
56+
plaintext**. It travels:
57+
58+
- App → Worker as JSON POST.
59+
- Worker → GitHub redirect URL **encrypted with AES-GCM-256** (key never leaves
60+
the Worker, decryption keyless to anyone else).
61+
- Worker → backend on the server-to-server exchange hop after Worker decrypts
62+
the blob locally.
63+
64+
There is **no KV, no Durable Object, no per-flow storage**. Replay protection
65+
and state uniqueness are owned by the backend (`/v1/oauth/state` returns 409 on
66+
duplicate; `/v1/oauth/exchange` atomically burns the state row on first read).
5667

5768
## Endpoints
5869

@@ -84,18 +95,21 @@ Responses:
8495
| 400 | `{ "error": "invalid_challenge" }` | `code_challenge` not `^[A-Za-z0-9_-]{43}$` |
8596
| 400 | `{ "error": "challenge_mismatch" }` | `code_challenge``base64url(SHA-256(code_verifier))` |
8697
| 400 | `{ "error": "invalid_json" }` | Body unparseable or not an object |
87-
| 409 | `{ "error": "state_already_registered" }` | This `state` is already pinned in KV |
98+
| 409 | `{ "error": "state_already_registered" }` | Backend rejected this `state` as a duplicate |
8899
| 413 | `{ "error": "payload_too_large" }` | Body exceeds 2 KiB |
89100
| 415 | `{ "error": "invalid_content_type" }` | Missing/wrong `Content-Type` |
90101
| 429 | `{ "error": "rate_limited" }` | Per-IP limit hit (see Rate limiting) |
102+
| 500 | `{ "error": "encryption_unavailable" }` | `STATE_ENC_KEY` missing or malformed |
91103
| 502 | `{ "error": "backend_unreachable" }` | Worker → backend `/v1/oauth/state` failed (network) |
92104
| 502 | `{ "error": "backend_register_failed" }` | Backend `/v1/oauth/state` returned non-2xx |
93105

94106
Notes:
95107

96-
- `auth_url` is `https://github.com/login/oauth/authorize?client_id=…&redirect_uri=https://github-store.org/auth/callback&state=<S>&scope=repo+read:user`.
97-
- Worker registers `state → code_verifier` in KV (TTL 60 s, single-use) **only
98-
after** the backend confirms the `state → code_challenge` registration.
108+
- `auth_url` is `https://github.com/login/oauth/authorize?client_id=…&redirect_uri=https://github-store.org/auth/callback&state=<state>.<blob>&scope=repo+read:user`.
109+
GitHub treats the whole `<state>.<blob>` value as an opaque round-trip token.
110+
- The `409` is produced by the backend's `INSERT … ON CONFLICT (namespace, key)
111+
DO NOTHING` constraint and surfaced verbatim by the Worker. The app contract
112+
is unchanged from the previous KV-based implementation.
99113
- The app **must not** open `/auth/register` in a browser. Browsers cache and
100114
history-log URLs; native HTTP clients do not.
101115

@@ -106,17 +120,22 @@ HTML that redirects via custom scheme to the app.
106120

107121
Query params (set by GitHub):
108122

109-
- `state`
110-
- `code` (on success) **or** `error` (on user-cancel / GitHub error)
123+
- `state` — full `<original-state>.<blob>` round-tripped from `/auth/register`.
124+
- `code` (on success) **or** `error` (on user-cancel / GitHub error).
111125

112126
Worker behavior:
113127

114-
1. Validate `state` format. KV-lookup `oauth:verifier:<state>` (miss → error page).
115-
2. `KV.delete` (single-use).
116-
3. If GitHub returned `error`, render error page passing the sanitized reason.
117-
4. Otherwise POST backend `/v1/oauth/exchange` with `{ code, state, code_verifier }`.
118-
5. On 2xx → render success HTML with `githubstore://auth?handoff=<H>&state=<S>`.
119-
6. On any failure → render error HTML with `githubstore://auth?error=<reason>&state=<S>`.
128+
1. Split `state` on `.`. Reject if not exactly two parts.
129+
2. Validate `<original-state>` matches `STATE_RE` and `<blob>` matches `BLOB_RE`.
130+
3. Decrypt `<blob>` with `STATE_ENC_KEY`. Tamper, wrong-key, or expired-TTL → error page.
131+
4. If GitHub returned `error`, render error page passing the sanitized reason.
132+
5. Otherwise POST backend `/v1/oauth/exchange` with `{ code, state, code_verifier }`.
133+
6. On 2xx → render success HTML with `githubstore://auth?handoff=<H>&state=<original-state>`.
134+
7. On any failure → render error HTML with `githubstore://auth?error=<reason>&state=<original-state>`.
135+
136+
The deep link returned to the app contains the **original** state value (without
137+
the encrypted blob), so the app's local state-matching code keeps comparing the
138+
same value it generated.
120139

121140
## Deep-link contract (app must honor)
122141

@@ -136,8 +155,9 @@ githubstore://auth?error=<reason>&state=<original-state>
136155

137156
| reason | meaning |
138157
| ---------------------------- | -------------------------------------------------------------------- |
139-
| `invalid_state` | `state` missing, malformed, expired, or already redeemed |
158+
| `invalid_state` | `state` missing, malformed, tampered, expired, or already redeemed |
140159
| `missing_code` | GitHub redirected without `code` and without `error` |
160+
| `encryption_unavailable` | Worker `STATE_ENC_KEY` is missing or malformed (operator error) |
141161
| `exchange_unreachable` | Worker could not reach `BACKEND_EXCHANGE_URL` |
142162
| `exchange_failed` | Backend `/v1/oauth/exchange` returned non-2xx |
143163
| `exchange_invalid_response` | Backend response missing/malformed `handoff_id` |
@@ -181,6 +201,10 @@ encoding of the **SHA-256 of the verifier's ASCII bytes**, per
181201
[RFC 7636 §4.2](https://datatracker.ietf.org/doc/html/rfc7636#section-4.2).
182202
Anything else 400s at `/auth/register`.
183203

204+
The client contract is **unchanged** from the previous KV-based implementation.
205+
The encrypted-state design is server-internal — already-installed apps keep
206+
working after the cutover without an update.
207+
184208
## Configuration
185209

186210
### Vars (committed in `wrangler.toml`)
@@ -197,33 +221,53 @@ Anything else 400s at `/auth/register`.
197221
| name | source |
198222
| --------------------- | ----------------------------------------------------------------------- |
199223
| `OAUTH_SERVICE_TOKEN` | Long random string shared with the backend. Same value in both places. |
224+
| `STATE_ENC_KEY` | AES-GCM-256 key, base64 of exactly 32 random bytes (44-char string). |
200225

201-
Generate:
226+
Generate `OAUTH_SERVICE_TOKEN`:
202227

203228
```
204229
openssl rand -base64 48 | tr -d '=' | tr '+/' '-_'
205230
```
206231

207-
Hand the value to the backend agent via your shared secret manager (1Password,
208-
Bitwarden, Doppler — not Slack/email).
209-
210-
### KV namespace
211-
212-
`OAUTH_STATE` — holds `oauth:verifier:<state>` for 60 s. Free-tier 1000 ops/day
213-
is plenty for current auth volume.
232+
Generate `STATE_ENC_KEY`:
214233

215234
```
216-
wrangler kv namespace create OAUTH_STATE
217-
wrangler kv namespace create OAUTH_STATE --preview
235+
openssl rand 32 | base64
218236
```
219237

220-
Paste the returned IDs into `wrangler.toml`.
238+
Hand `OAUTH_SERVICE_TOKEN` to the backend agent via your shared secret manager
239+
(1Password, Bitwarden, Doppler — not Slack/email). `STATE_ENC_KEY` is
240+
Worker-only — the backend never sees it and does not need it.
241+
242+
### State encryption key
243+
244+
`STATE_ENC_KEY` is the symmetric key the Worker uses to encrypt the PKCE
245+
`code_verifier` into the opaque blob carried inside the GitHub `state`
246+
parameter. AES-GCM-256, 12-byte random nonce per encryption, 16-byte GCM tag.
247+
248+
**Rotation:**
249+
250+
The current implementation supports a single key. Rotation requires a short
251+
maintenance window because in-flight callbacks (up to 60 s old) will fail
252+
decryption with the new key. Procedure:
253+
254+
1. Generate a new 32-byte key: `openssl rand 32 | base64`.
255+
2. `wrangler secret put STATE_ENC_KEY` and paste the new value.
256+
3. `npm run deploy`.
257+
4. In-flight `/auth/callback` requests started before the deploy will fail with
258+
`invalid_state` for up to 60 s; the app surfaces this as a normal sign-in
259+
failure and the user can retry. No persistent damage.
260+
261+
If zero-downtime rotation is required later, extend `getEncKey` /
262+
`decryptVerifier` to support a versioned ciphertext header that selects between
263+
`STATE_ENC_KEY_V1` and `STATE_ENC_KEY_V2` during the overlap window.
221264

222265
## Local development
223266

224267
```
225268
cp .dev.vars.example .dev.vars
226-
# edit .dev.vars and put a real OAUTH_SERVICE_TOKEN value
269+
# edit .dev.vars and set real values for both OAUTH_SERVICE_TOKEN and
270+
# STATE_ENC_KEY (use `openssl rand 32 | base64` for the latter)
227271
228272
npm install
229273
npm run dev
@@ -249,9 +293,10 @@ URL — easier to test in staging.
249293
### One-time
250294

251295
1. Authenticate: `wrangler login`
252-
2. Create KV namespaces (production + preview) and paste IDs into `wrangler.toml`.
253-
3. Set the secret: `wrangler secret put OAUTH_SERVICE_TOKEN`
254-
4. Make sure `github-store.org` zone is on this Cloudflare account.
296+
2. Set both secrets:
297+
- `wrangler secret put OAUTH_SERVICE_TOKEN`
298+
- `wrangler secret put STATE_ENC_KEY` (paste `openssl rand 32 | base64`)
299+
3. Make sure `github-store.org` zone is on this Cloudflare account.
255300

256301
### Each release
257302

@@ -284,34 +329,48 @@ on the zone with `period = 3600, limit = 60` matching `http.request.uri.path
284329
eq "/auth/register"`. The WAF rule and the binding compose; both must allow
285330
the request to pass.
286331

287-
`/auth/callback` is not rate-limited at the Worker layer because it is gated by
288-
a 60 s single-use verifier in KV — abuse there self-throttles via KV misses.
332+
`/auth/callback` is not rate-limited at the Worker layer. Abuse there is
333+
naturally throttled by two independent gates:
334+
335+
- The encrypted blob's 60 s `exp` field — any replay older than that returns
336+
`invalid_state`.
337+
- The backend's atomic single-use state burn in `/v1/oauth/exchange` — any
338+
second exchange for the same `state` returns `invalid_state`.
289339

290340
## Security notes
291341

292-
- No tokens, codes, or verifiers in user-visible URLs. `/auth/register` is a
293-
JSON POST. The verifier moves only on server-to-server hops (app → Worker,
294-
Worker → backend) and is keyed in KV by the single-use `state`.
342+
- **No plaintext tokens, codes, or verifiers in user-visible URLs.**
343+
`/auth/register` is a JSON POST. The verifier in the GitHub `state` parameter
344+
is AES-GCM-256 ciphertext; the symmetric key (`STATE_ENC_KEY`) is a Worker
345+
secret and never leaves the Worker.
295346
- `state`, `code_verifier`, `code_challenge`, and `handoff_id` are each
296-
strictly format-validated before any KV access or backend call.
347+
strictly format-validated before any crypto or backend call.
297348
- **PKCE challenge is verified at the edge** (defense in depth). After format
298349
validation, the Worker computes `base64url(SHA-256(code_verifier))` via
299350
`crypto.subtle.digest` and constant-time compares it with the supplied
300-
`code_challenge`. Mismatch → `400 challenge_mismatch` before any KV write or
351+
`code_challenge`. Mismatch → `400 challenge_mismatch` before any crypto or
301352
backend round-trip. This is independent of, and additional to, the backend's
302353
later verification at `/v1/oauth/exchange` — catches client bugs (and any
303354
pre-OAuth tampering) before the GitHub authorize hop, not after.
304-
- KV `oauth:verifier:<state>` TTL is 60 s and is deleted immediately on first
305-
read. KV has no atomic GETDEL — the read-then-delete pair is effectively
306-
single-use because subsequent reads will miss either due to the prior delete
307-
or the TTL.
355+
- **AES-GCM-256** via `crypto.subtle.encrypt`/`decrypt`. 12-byte random nonce
356+
per encryption (96-bit, safe for ~2^32 messages under one key). 16-byte GCM
357+
auth tag — any tampering of the blob fails decryption and is rejected as
358+
`invalid_state`. Plaintext payload is JSON `{ "v": verifier, "e": expiryMs }`
359+
so the TTL travels inside the ciphertext and the Worker cannot be fooled into
360+
accepting a stale verifier by URL manipulation.
361+
- **State uniqueness / replay protection** is owned by the backend, not the
362+
Worker. `/v1/oauth/state` enforces uniqueness via `INSERT … ON CONFLICT
363+
(namespace, key) DO NOTHING` (the Worker surfaces the resulting 409 as
364+
`state_already_registered`). `/v1/oauth/exchange` atomically burns the state
365+
row on first read via `getDel` (Postgres `DELETE … RETURNING`). GitHub also
366+
invalidates the `code` after first exchange as an independent third gate.
308367
- Worker → backend calls send `X-Oauth-Service-Token` (shared secret). The
309368
`Host` header is `api.github-store.org` (derived from the request URL —
310369
Cloudflare Workers does not let you override Host, so the URL is the source
311370
of truth).
312371
- The Worker never logs `code`, `state`, `code_verifier`, `code_challenge`,
313-
`handoff_id`, or `OAUTH_SERVICE_TOKEN`. Status codes and durations only.
314-
`wrangler tail` will show no sensitive values.
372+
`handoff_id`, `OAUTH_SERVICE_TOKEN`, or `STATE_ENC_KEY`. Status codes and
373+
durations only. `wrangler tail` will show no sensitive values.
315374
- Response pages set `Cache-Control: no-store`, `Referrer-Policy: no-referrer`,
316375
`X-Content-Type-Options: nosniff`, `X-Robots-Tag: noindex`.
317376
- The HTML deep-link page embeds the URL in a `JSON.stringify`d JS literal with

0 commit comments

Comments
 (0)