Skip to content

Commit 1a94f5e

Browse files
committed
Auth: switch Worker to full PKCE with POST /auth/register registering state+verifier and exchange sending code_verifier.
1 parent 97009aa commit 1a94f5e

3 files changed

Lines changed: 285 additions & 89 deletions

File tree

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

Lines changed: 181 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,122 @@
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

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
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>
62146
The app should treat any unknown `reason` as a fatal failure and surface a
63147
generic "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
```
96215
wrangler kv namespace create OAUTH_STATE
@@ -109,10 +228,20 @@ npm install
109228
npm 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
141270
backend; 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
161275
binding (`RATE_LIMITER`). Current limit: **2 requests per 60 s sliding window
162276
per IP** — the closest expressible cap to the agreed 60/hr/IP intent given the
163277
binding's 10 s / 60 s period constraint. Effective ceiling ≈ 120 reqs/hr/IP,
@@ -166,31 +280,38 @@ shared NAT (school/cafe Wi-Fi).
166280

167281
If an exact hourly cap is required, layer a Cloudflare WAF rate-limiting rule
168282
on 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

Comments
 (0)