11# gh-store-app-oauth
22
3- Cloudflare Worker that runs the GitHub OAuth start /callback hop for the GitHub
4- Store mobile app. The Jekyll site continues to serve every other path on
5- ` github-store.org ` ; this Worker only owns ` /auth/* ` .
3+ Cloudflare Worker that runs the GitHub OAuth register /callback hop for the
4+ GitHub Store mobile app with full PKCE . The Jekyll site continues to serve
5+ every other path on ` github-store.org ` ; this Worker only owns ` /auth/* ` .
66
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
10+ ## Flow (PKCE Pattern A)
1111
1212```
13- App Worker (this) Backend
14- │ │ │
15- │ generate state (32B b64url) │ │
16- │ open https://github-store.org/auth/start?state=<S> │
17- │ ────────────────────────────▶│ │
18- │ │ KV.put oauth:state:<S> ttl=60s │
19- │ │ 302 → github.com/login/oauth/authorize
20- │ │ │
21- │ (user signs in on GitHub) │
13+ App Worker (this) Backend
14+ │ │ │
15+ │ generate locally: │
16+ │ state (32B → b64url, 43 chars) │
17+ │ code_verifier (43-128 char b64url) │
18+ │ code_challenge = b64url(SHA-256(verifier)) │
19+ │ │
20+ │ POST /auth/register │
21+ │ { state, code_verifier, code_challenge } │
22+ │ ────────────────────────────▶│ │
23+ │ │ validate formats │
24+ │ │ POST /v1/oauth/state { state, │
25+ │ │ code_challenge } │
26+ │ │ X-Oauth-Service-Token: <T> │
27+ │ │ ─────────────────────────────────────▶│
28+ │ │ ◀── 2xx │
29+ │ │ KV.put oauth:verifier:<S> = verifier │
30+ │ │ ttl=60s │
31+ │ ◀── 200 { auth_url } │ │
32+ │ │
33+ │ app opens auth_url in system browser │
34+ │ │ │
35+ │ (user signs in on GitHub) │
2236 │ │ ◀── GitHub 302 /auth/callback?code=<G>&state=<S>
23- │ │ KV.get + KV.delete (one-shot) │
24- │ │ POST /v1/oauth/exchange { code } │
25- │ │ X-Oauth-Service-Token: <T> │
26- │ │ ─────────────────────────────────▶
27- │ │ ◀── { handoff_id } │
28- │ │ render HTML with deep link │
29- │ ◀── githubstore://auth?handoff=<H>&state=<S> │
30- │ │ │
31- │ POST /v1/oauth/handoff/<H> │ │
32- │ ─────────────────────────────────────────────────────────────── ▶
33- │ ◀── { access_token } │
37+ │ │ KV.get oauth:verifier:<S> │
38+ │ │ miss → error page │
39+ │ │ KV.delete (one-shot) │
40+ │ │ POST /v1/oauth/exchange { code, │
41+ │ │ state, code_verifier } │
42+ │ │ X-Oauth-Service-Token: <T> │
43+ │ │ ─────────────────────────────────────▶│
44+ │ │ ◀── { handoff_id } │
45+ │ │ render HTML with deep link │
46+ │ ◀── githubstore://auth?handoff=<H>&state=<S> │
47+ │ │
48+ │ POST /v1/oauth/handoff/<H> │
49+ │ ────────────────────────────────────────────────────────────────────▶│
50+ │ ◀── { access_token } │
3451```
3552
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 ` .
56+
57+ ## Endpoints
58+
59+ ### ` POST /auth/register `
60+
61+ Called by the app (server-style; not browser-loaded). Expects JSON.
62+
63+ Request:
64+
65+ ``` json
66+ {
67+ "state" : " <43-256 char base64url>" ,
68+ "code_verifier" : " <43-128 char base64url>" ,
69+ "code_challenge" : " <43 char base64url, == base64url(SHA-256(verifier))>"
70+ }
71+ ```
72+
73+ Headers:
74+
75+ - ` Content-Type: application/json `
76+
77+ Responses:
78+
79+ | status | body | meaning |
80+ | -----: | ------------------------------------------ | ------------------------------------------------ |
81+ | 200 | ` { "auth_url": "https://github.com/…" } ` | App opens this URL in the system browser |
82+ | 400 | ` { "error": "invalid_state" } ` | ` state ` missing or not ` ^[A-Za-z0-9_-]{32,256}$ ` |
83+ | 400 | ` { "error": "invalid_verifier" } ` | ` code_verifier ` not ` ^[A-Za-z0-9_-]{43,128}$ ` |
84+ | 400 | ` { "error": "invalid_challenge" } ` | ` code_challenge ` not ` ^[A-Za-z0-9_-]{43}$ ` |
85+ | 400 | ` { "error": "invalid_json" } ` | Body unparseable or not an object |
86+ | 409 | ` { "error": "state_already_registered" } ` | This ` state ` is already pinned in KV |
87+ | 413 | ` { "error": "payload_too_large" } ` | Body exceeds 2 KiB |
88+ | 415 | ` { "error": "invalid_content_type" } ` | Missing/wrong ` Content-Type ` |
89+ | 429 | ` { "error": "rate_limited" } ` | Per-IP limit hit (see Rate limiting) |
90+ | 502 | ` { "error": "backend_unreachable" } ` | Worker → backend ` /v1/oauth/state ` failed (network) |
91+ | 502 | ` { "error": "backend_register_failed" } ` | Backend ` /v1/oauth/state ` returned non-2xx |
92+
93+ Notes:
94+
95+ - ` 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 ` .
96+ - Worker registers ` state → code_verifier ` in KV (TTL 60 s, single-use) ** only
97+ after** the backend confirms the ` state → code_challenge ` registration.
98+ - The app ** must not** open ` /auth/register ` in a browser. Browsers cache and
99+ history-log URLs; native HTTP clients do not.
100+
101+ ### ` GET /auth/callback `
102+
103+ Hit by the user's browser after GitHub completes the authorize step. Returns
104+ HTML that redirects via custom scheme to the app.
105+
106+ Query params (set by GitHub):
107+
108+ - ` state `
109+ - ` code ` (on success) ** or** ` error ` (on user-cancel / GitHub error)
110+
111+ Worker behavior:
112+
113+ 1 . Validate ` state ` format. KV-lookup ` oauth:verifier:<state> ` (miss → error page).
114+ 2 . ` KV.delete ` (single-use).
115+ 3 . If GitHub returned ` error ` , render error page passing the sanitized reason.
116+ 4 . Otherwise POST backend ` /v1/oauth/exchange ` with ` { code, state, code_verifier } ` .
117+ 5 . On 2xx → render success HTML with ` githubstore://auth?handoff=<H>&state=<S> ` .
118+ 6 . On any failure → render error HTML with ` githubstore://auth?error=<reason>&state=<S> ` .
119+
36120## Deep-link contract (app must honor)
37121
38122** Success:**
@@ -62,13 +146,48 @@ githubstore://auth?error=<reason>&state=<original-state>
62146The app should treat any unknown ` reason ` as a fatal failure and surface a
63147generic "sign-in failed" message to the user.
64148
149+ ## Client contract (app side)
150+
151+ The app generates three values locally before calling ` /auth/register ` :
152+
153+ ``` kotlin
154+ val random = SecureRandom ()
155+
156+ val stateBytes = ByteArray (32 ).also { random.nextBytes(it) }
157+ val state = Base64 .encodeToString(
158+ stateBytes,
159+ Base64 .URL_SAFE or Base64 .NO_PADDING or Base64 .NO_WRAP ,
160+ )
161+
162+ // PKCE: 32 random bytes → 43-char base64url verifier is the recommended
163+ // minimum. Worker accepts up to 128 chars.
164+ val verifierBytes = ByteArray (32 ).also { random.nextBytes(it) }
165+ val codeVerifier = Base64 .encodeToString(
166+ verifierBytes,
167+ Base64 .URL_SAFE or Base64 .NO_PADDING or Base64 .NO_WRAP ,
168+ )
169+
170+ val challengeBytes = MessageDigest .getInstance(" SHA-256" )
171+ .digest(codeVerifier.toByteArray(Charsets .US_ASCII ))
172+ val codeChallenge = Base64 .encodeToString(
173+ challengeBytes,
174+ Base64 .URL_SAFE or Base64 .NO_PADDING or Base64 .NO_WRAP ,
175+ )
176+ ```
177+
178+ All three values match ` ^[A-Za-z0-9_-]+$ ` . The challenge is the base64url
179+ encoding of the ** SHA-256 of the verifier's ASCII bytes** , per
180+ [ RFC 7636 §4.2] ( https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 ) .
181+ Anything else 400s at ` /auth/register ` .
182+
65183## Configuration
66184
67185### Vars (committed in ` wrangler.toml ` )
68186
69187| name | value |
70188| ---------------------- | -------------------------------------------------- |
71189| ` GITHUB_CLIENT_ID ` | ` Ov23linTY28VFpFjFiI9 ` — public per OAuth spec |
190+ | ` BACKEND_STATE_URL ` | ` https://api.github-store.org/v1/oauth/state ` |
72191| ` BACKEND_EXCHANGE_URL ` | ` https://api.github-store.org/v1/oauth/exchange ` |
73192| ` APP_SCHEME ` | ` githubstore ` |
74193
@@ -89,8 +208,8 @@ Bitwarden, Doppler — not Slack/email).
89208
90209### KV namespace
91210
92- ` OAUTH_STATE ` — holds ` oauth:state :<state> ` for 60 s. Free-tier 1000 ops/day is
93- plenty for current auth volume.
211+ ` OAUTH_STATE ` — holds ` oauth:verifier :<state> ` for 60 s. Free-tier 1000 ops/day
212+ is plenty for current auth volume.
94213
95214```
96215wrangler kv namespace create OAUTH_STATE
@@ -109,10 +228,20 @@ npm install
109228npm run dev
110229```
111230
112- The dev server runs at ` http://localhost:8787 ` . Hit
113- ` http://localhost:8787/auth/start?state=<32+ b64url chars> ` to exercise the
114- redirect path. The full flow requires the GitHub OAuth app callback to be
115- pointed at the dev URL — easier to test in staging.
231+ The dev server runs at ` http://localhost:8787 ` . Exercise registration with:
232+
233+ ```
234+ curl -sS -X POST http://localhost:8787/auth/register \
235+ -H 'Content-Type: application/json' \
236+ -d '{
237+ "state": "AAAA…43 chars…",
238+ "code_verifier": "BBBB…43 chars…",
239+ "code_challenge": "CCCC…43 chars…"
240+ }'
241+ ```
242+
243+ The full flow requires the GitHub OAuth app callback to be pointed at the dev
244+ URL — easier to test in staging.
116245
117246## Deploy
118247
@@ -140,24 +269,9 @@ GitHub OAuth app from `githubstore://callback` to
140269` https://github-store.org/auth/callback ` . The client secret stays on the
141270backend; this Worker never sees it.
142271
143- ## State contract (app must honor)
144-
145- The app generates ` state ` as ** 32 cryptographically random bytes** , encoded as
146- ** Base64URL without padding** (no ` = ` characters). That produces a 43-character
147- string matching ` ^[A-Za-z0-9_-]{32,256}$ ` . Anything shorter, padded, or with
148- non-URL-safe alphabet characters 400s at ` /auth/start ` .
149-
150- ``` kotlin
151- val bytes = ByteArray (32 ).also { SecureRandom ().nextBytes(it) }
152- val state = Base64 .encodeToString(
153- bytes,
154- Base64 .URL_SAFE or Base64 .NO_PADDING or Base64 .NO_WRAP ,
155- )
156- ```
157-
158272## Rate limiting
159273
160- ` /auth/start ` is rate-limited per client IP via the Workers Rate Limiting
274+ ` /auth/register ` is rate-limited per client IP via the Workers Rate Limiting
161275binding (` RATE_LIMITER ` ). Current limit: ** 2 requests per 60 s sliding window
162276per IP** — the closest expressible cap to the agreed 60/hr/IP intent given the
163277binding's 10 s / 60 s period constraint. Effective ceiling ≈ 120 reqs/hr/IP,
@@ -166,31 +280,38 @@ shared NAT (school/cafe Wi-Fi).
166280
167281If an exact hourly cap is required, layer a Cloudflare WAF rate-limiting rule
168282on the zone with ` period = 3600, limit = 60 ` matching `http.request.uri.path
169- eq "/auth/start "`. The WAF rule and the binding compose; both must allow the
170- request to pass.
283+ eq "/auth/register "`. The WAF rule and the binding compose; both must allow
284+ the request to pass.
171285
172286` /auth/callback ` is not rate-limited at the Worker layer because it is gated by
173- a 60 s single-use ` state ` token — abuse there self-throttles via KV misses.
287+ a 60 s single-use verifier in KV — abuse there self-throttles via KV misses.
174288
175289## Security notes
176290
177- - No tokens in URLs. Only ` state ` (CSRF) and ` handoff_id ` (opaque) cross the
178- wire on the redirect/deep-link path.
179- - ` state ` is strictly validated (` ^[A-Za-z0-9_-]{32,256}$ ` ) on both endpoints
180- before any KV access.
181- - KV is the only store this Worker uses; ` state ` TTL is 60 s and is deleted
182- immediately on first read (KV has no atomic GETDEL — the read + delete pair
183- is effectively single-use because subsequent reads will miss either due to
184- the prior delete or the TTL).
185- - The Worker never logs ` code ` , ` state ` , ` handoff_id ` , or ` OAUTH_SERVICE_TOKEN ` .
186- Status codes and durations only. ` wrangler tail ` will show no sensitive
187- values.
291+ - No tokens, codes, or verifiers in user-visible URLs. ` /auth/register ` is a
292+ JSON POST. The verifier moves only on server-to-server hops (app → Worker,
293+ Worker → backend) and is keyed in KV by the single-use ` state ` .
294+ - ` state ` , ` code_verifier ` , ` code_challenge ` , and ` handoff_id ` are each
295+ strictly format-validated before any KV access or backend call.
296+ - KV ` oauth:verifier:<state> ` TTL is 60 s and is deleted immediately on first
297+ read. KV has no atomic GETDEL — the read-then-delete pair is effectively
298+ single-use because subsequent reads will miss either due to the prior delete
299+ or the TTL.
300+ - Worker → backend calls send ` X-Oauth-Service-Token ` (shared secret). The
301+ ` Host ` header is ` api.github-store.org ` (derived from the request URL —
302+ Cloudflare Workers does not let you override Host, so the URL is the source
303+ of truth).
304+ - The Worker never logs ` code ` , ` state ` , ` code_verifier ` , ` code_challenge ` ,
305+ ` handoff_id ` , or ` OAUTH_SERVICE_TOKEN ` . Status codes and durations only.
306+ ` wrangler tail ` will show no sensitive values.
188307- Response pages set ` Cache-Control: no-store ` , ` Referrer-Policy: no-referrer ` ,
189308 ` X-Content-Type-Options: nosniff ` , ` X-Robots-Tag: noindex ` .
190309- The HTML deep-link page embeds the URL in a ` JSON.stringify ` d JS literal with
191310 an additional ` </script> ` escape, plus an attribute-escaped anchor fallback.
192311 ` state ` and ` handoff_id ` are pre-validated, so the embedding is a
193312 defense-in-depth measure.
313+ - The Worker never sees the GitHub ` client_secret ` . The backend holds it and
314+ performs the ` code → access_token ` exchange.
194315
195316## Files
196317
0 commit comments