@@ -7,7 +7,7 @@ every other path on `github-store.org`; this Worker only owns `/auth/*`.
77This is ** separate** from the Decap CMS OAuth worker. Do not merge them — they
88have different scopes, different audiences, and different lifecycles.
99
10- ## Flow (PKCE Pattern A)
10+ ## Flow (PKCE Pattern A, stateless Worker )
1111
1212```
1313App 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
94106Notes:
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
107121Query 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
112126Worker 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 ) .
182202Anything 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```
204229openssl 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```
225268cp .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
228272npm install
229273npm run dev
@@ -249,9 +293,10 @@ URL — easier to test in staging.
249293### One-time
250294
2512951 . 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
284329eq "/auth/register"`. The WAF rule and the binding compose; both must allow
285330the 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