Skip to content

Commit d490ec4

Browse files
authored
feat(auth): migrates from GitHub App to OAuth App (#4)
* refactor(worker): removes refresh and logout endpoints * refactor(auth): switches to localStorage token persistence for OAuth App * docs: updates documentation for OAuth App migration * fix: addresses review findings from security/QA/structural review * fix: adds scope comment and clearAuth reentrancy guard * fix(auth): wraps clearAuth reentrancy guard in try/finally * refactor: removes void wrapper, adds try/finally guard * refactor(poll): updates stale comments for OAuth App model * fix(e2e): adds missing fullName to config seed repos * fix: addresses PR review findings from code review - Adds read-only guard to Octokit client (blocks non-GET except POST /graphql) - Fixes _coordinator not nulled on DashboardPage unmount (polling died after navigation) - Validates token before navigating in OAuthCallback (clears auth on failure) - Deduplicates onCleanup/destroy in poll coordinator (onCleanup delegates to destroy) - Merges two onAuthCleared registrations into single callback in DashboardPage - Uses top-level import type for DashboardData instead of inline import() - Simplifies localStorage init guard in auth.ts (drops typeof, keeps optional chaining) - Adds tests: destroy(), reentrancy guard, validateToken=false, OPTIONS 404, cold-start * fix(test): adds onAuthCleared test, migrates to vi.doMock * fix(dashboard): preserves existing data during poll refresh * feat(dashboard): caches data in localStorage for instant reload * fix(poll): eagerly creates Octokit client for background refresh * fix(ui): restores refresh spinner and fades update label * fix(dashboard): calls destroy() on coordinator in onCleanup * fix: addresses domain review findings from quality gate * refactor: moves dashboard storage key to auth, dedupes client * fix: tightens read-only guard, removes dead fork fields * fix(test): adds DASHBOARD_STORAGE_KEY to auth mock * fix(test): uses workspace config for local and CI parity * fix(test): clears localStorage in beforeEach for CI parity
1 parent 64c3da7 commit d490ec4

33 files changed

Lines changed: 697 additions & 1015 deletions

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# GitHub App client ID — embedded into client-side bundle at build time by Vite.
1+
# GitHub OAuth App client ID — embedded into client-side bundle at build time by Vite.
22
# This is public information (visible in the OAuth authorize URL).
33
# Set this as a GitHub Actions variable (not a secret) for CI/CD.
4-
VITE_GITHUB_CLIENT_ID=your_github_app_client_id_here
4+
VITE_GITHUB_CLIENT_ID=your_oauth_app_client_id_here

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: CI
2+
on:
3+
pull_request:
4+
permissions:
5+
contents: read
6+
jobs:
7+
ci:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- uses: actions/checkout@v4
11+
- uses: pnpm/action-setup@v4
12+
- uses: actions/setup-node@v4
13+
with:
14+
node-version: 22
15+
cache: pnpm
16+
- run: pnpm install --frozen-lockfile
17+
- run: pnpm run typecheck
18+
- run: pnpm test
19+
- name: Verify CSP hash
20+
run: node scripts/verify-csp-hash.mjs
21+
- name: Install Playwright browsers
22+
run: npx playwright install chromium --with-deps
23+
- name: Run E2E tests
24+
run: pnpm test:e2e
25+
env:
26+
VITE_GITHUB_CLIENT_ID: ${{ vars.VITE_GITHUB_CLIENT_ID }}

.github/workflows/preview.yml

Lines changed: 0 additions & 101 deletions
This file was deleted.

DEPLOY.md

Lines changed: 52 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -20,63 +20,38 @@
2020
### Variables (GitHub repo → Settings → Secrets and variables → Actions → Variables)
2121

2222
**`VITE_GITHUB_CLIENT_ID`**
23-
- This is the GitHub App Client ID (not a secret — it is embedded in the built JS bundle)
23+
- This is the GitHub OAuth App Client ID (not a secret — it is embedded in the built JS bundle)
2424
- Add it as an Actions **variable** (not a secret)
25-
- See GitHub App setup below for how to obtain it
25+
- See OAuth App setup below for how to obtain it
2626

27-
## GitHub App Setup
27+
## GitHub OAuth App Setup
2828

29-
1. Go to GitHub → Settings → Developer settings → GitHub Apps → **New GitHub App**
30-
2. Fill in the basic details:
31-
- **App name**: your app name (e.g. `gh-tracker-yourname`)
32-
- **Description**: `Personal dashboard for tracking GitHub issues, PRs, and Actions runs across repos and orgs.`
29+
1. Go to GitHub → Settings → Developer settings → OAuth Apps → **New OAuth App**
30+
2. Fill in the details:
31+
- **Application name**: your app name (e.g. `gh-tracker-yourname`)
3332
- **Homepage URL**: `https://gh.gordoncode.dev`
34-
3. Under **Identifying and authorizing users**:
35-
- **Callback URLs** — register all three:
36-
- `https://gh.gordoncode.dev/oauth/callback` (production)
37-
- `https://github-tracker.<account>.workers.dev/oauth/callback` (preview — GitHub's subdomain matching should allow per-branch preview aliases like `alias.github-tracker.<account>.workers.dev` to work; verify after first preview deploy)
38-
- `http://localhost:5173/oauth/callback` (local dev)
39-
-**Expire user authorization tokens** — check this. The app uses short-lived access tokens (8hr) with HttpOnly cookie-based refresh token rotation.
40-
-**Request user authorization (OAuth) during installation** — check this. Streamlines the install + authorize flow into one step.
41-
4. Under **Post installation**:
42-
- Leave **Setup URL** blank
43-
- Leave **Redirect on update** unchecked
44-
5. Under **Webhook**:
45-
- ❌ Uncheck **Active** — the app polls; it does not use webhooks.
46-
6. Under **Permissions**:
47-
48-
**Repository permissions** (read-only):
49-
50-
| Permission | Access | Used for |
51-
|------------|--------|----------|
52-
| **Actions** | Read-only | `GET /repos/{owner}/{repo}/actions/runs` — workflow run list |
53-
| **Checks** | Read-only | `GET /repos/{owner}/{repo}/commits/{ref}/check-runs` — PR check status (REST fallback) |
54-
| **Commit statuses** | Read-only | `GET /repos/{owner}/{repo}/commits/{ref}/status` — legacy commit status (REST fallback) |
55-
| **Issues** | Read-only | `GET /search/issues?q=is:issue` — issue search |
56-
| **Metadata** | Read-only | Automatically granted when any repo permission is set. Required for basic repo info. |
57-
| **Pull requests** | Read-only | `GET /search/issues?q=is:pr`, `GET /repos/{owner}/{repo}/pulls/{pull_number}`, `/reviews` — PR search, detail, and reviews |
58-
59-
**Organization permissions:**
60-
61-
| Permission | Access | Used for |
62-
|------------|--------|----------|
63-
| **Members** | Read-only | `GET /user/orgs` — list user's organizations for the org selector |
64-
65-
**Account permissions:**
66-
67-
| Permission | Access | Used for |
68-
|------------|--------|----------|
69-
| _(none required)_ | | |
70-
71-
7. Under **Where can this GitHub App be installed?**:
72-
- **Any account** — the app uses OAuth authorization (not installation tokens), so any GitHub user needs to be able to authorize via the login flow
73-
8. Click **Create GitHub App**
74-
9. Note the **Client ID** — this is your `VITE_GITHUB_CLIENT_ID`
75-
10. Click **Generate a new client secret** and save it for the Worker secrets below
76-
77-
### Notifications API limitation
78-
79-
The GitHub Notifications API (`GET /notifications`) does not support GitHub App user access tokens — only classic personal access tokens. The app uses notifications as a polling optimization gate (skip full fetch when nothing changed). When the notifications endpoint returns 403, the gate **auto-disables** and the app falls back to time-based polling. No functionality is lost; polling is just slightly less efficient.
33+
- **Authorization callback URL**: `https://gh.gordoncode.dev/oauth/callback`
34+
3. Click **Register application**
35+
4. Note the **Client ID** — this is your `VITE_GITHUB_CLIENT_ID`
36+
5. Click **Generate a new client secret** and save it for the Worker secrets below
37+
38+
### Scopes
39+
40+
The login flow requests `scope=repo read:org notifications`:
41+
42+
| Scope | Used for |
43+
|-------|----------|
44+
| `repo` | Read issues, PRs, check runs, workflow runs (includes private repos) |
45+
| `read:org` | `GET /user/orgs` — list user's organizations for the org selector |
46+
| `notifications` | `GET /notifications` — polling optimization gate (304 = skip full fetch) |
47+
48+
**Note:** The `repo` scope grants write access to repositories, but this app never performs write operations (POST/PUT/PATCH/DELETE on repo endpoints). It is read-only by design.
49+
50+
### Local development OAuth App
51+
52+
Create a second OAuth App for local development:
53+
- **Authorization callback URL**: `http://localhost:5173/oauth/callback`
54+
- Set its Client ID and Secret in `.dev.vars` (see Local Development below)
8055

8156
## Cloudflare Worker Secrets
8257

@@ -91,41 +66,30 @@ wrangler secret put ALLOWED_ORIGIN
9166
```
9267

9368
- `GITHUB_CLIENT_ID`: same value as `VITE_GITHUB_CLIENT_ID`
94-
- `GITHUB_CLIENT_SECRET`: the Client Secret from your GitHub App
69+
- `GITHUB_CLIENT_SECRET`: the Client Secret from your GitHub OAuth App
9570
- `ALLOWED_ORIGIN`: `https://gh.gordoncode.dev`
9671

97-
### Preview versions
98-
99-
Preview deployments use `wrangler versions upload` (not a separate environment), so they inherit production secrets automatically. No additional secret configuration is needed.
100-
101-
CORS note: Preview URLs are same-origin (SPA and API share the same `*.workers.dev` host), so the `ALLOWED_ORIGIN` strict-equality check is irrelevant — browsers don't enforce CORS on same-origin requests.
102-
103-
**Migration note:** If you previously deployed with `wrangler deploy --env preview`, an orphaned `github-tracker-preview` worker may still exist. Delete it via `wrangler delete --name github-tracker-preview` or through the Cloudflare dashboard.
104-
10572
## Worker API Endpoints
10673

107-
| Endpoint | Method | Auth | Purpose |
108-
|----------|--------|------|---------|
109-
| `/api/oauth/token` | POST | none | Exchange OAuth code for access token. Refresh token set as HttpOnly cookie. |
110-
| `/api/oauth/refresh` | POST | cookie | Refresh expired access token. Reads `github_tracker_rt` HttpOnly cookie. Sets rotated cookie. |
111-
| `/api/oauth/logout` | POST | none | Clears the `github_tracker_rt` HttpOnly cookie (`Max-Age=0`). |
112-
| `/api/health` | GET | none | Health check. Returns `OK`. |
74+
| Endpoint | Method | Purpose |
75+
|----------|--------|---------|
76+
| `/api/oauth/token` | POST | Exchange OAuth authorization code for permanent access token. |
77+
| `/api/health` | GET | Health check. Returns `OK`. |
11378

114-
### Refresh Token Security
79+
### Token Storage Security
11580

116-
The refresh token (6-month lifetime) is stored as an **HttpOnly cookie** — never in `localStorage` or the response body. This protects the high-value long-lived credential from XSS:
81+
The OAuth App access token is a permanent credential (no expiry). It is stored in `localStorage` under the key `github-tracker:auth-token`:
11782

118-
- Production cookie: `__Host-github_tracker_rt` with `HttpOnly; Secure; SameSite=Strict; Path=/`
119-
- Local dev: `github_tracker_rt` with `HttpOnly; SameSite=Lax; Path=/` (no `Secure` — localhost is HTTP; no `__Host-` prefix — requires `Secure`)
120-
- The short-lived access token (8hr) is held in-memory only (never persisted to `localStorage`); on page reload, `refreshAccessToken()` obtains a fresh token via the cookie
121-
- On logout, the client calls `POST /api/oauth/logout` to clear the cookie
122-
- GitHub rotates the refresh token on each use; the Worker sets the new value as a cookie
83+
- **CSP protects against XSS token theft**: `script-src 'self'` prevents injection of unauthorized scripts that could read `localStorage`
84+
- On page load, `validateToken()` calls `GET /user` to verify the token is still valid
85+
- On 401, the app immediately clears auth and redirects to login (token is revoked, not expired)
86+
- On logout, the token is removed from `localStorage` and all local state is cleared
87+
- Transient network errors do NOT clear the token (permanent tokens survive connectivity issues)
12388

12489
### CORS
12590

12691
- `Access-Control-Allow-Origin`: exact match against `ALLOWED_ORIGIN` (no wildcards)
127-
- `Access-Control-Allow-Credentials: true`: enables cookie-based refresh for cross-origin preview deploys
128-
- Same-origin requests (production, local dev) send cookies automatically without CORS
92+
- No `Access-Control-Allow-Credentials` header (OAuth App uses no cookies)
12993

13094
## Local Development
13195

@@ -138,9 +102,15 @@ pnpm run build
138102
wrangler deploy
139103
```
140104

141-
For preview (uploads a version without promoting to production):
105+
## Migration from GitHub App
142106

143-
```sh
144-
pnpm run build
145-
wrangler versions upload --preview-alias my-feature
146-
```
107+
If you previously deployed with the GitHub App model (HttpOnly cookie refresh tokens), follow these steps:
108+
109+
1. **Update GitHub Actions variable**: change `VITE_GITHUB_CLIENT_ID` to your OAuth App's Client ID
110+
2. **Update Cloudflare secrets**: re-run `wrangler secret put GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` with OAuth App values
111+
3. **Update `ALLOWED_ORIGIN`** if it changed (usually unchanged)
112+
4. **Redeploy** the Worker: `pnpm run build && wrangler deploy`
113+
5. **Existing users** will be logged out on next page load (their refresh cookie is no longer valid; they will be prompted to log in again via the new OAuth App flow)
114+
6. **Delete the old GitHub App** (optional): GitHub → Settings → Developer settings → GitHub Apps → your app → Advanced → Delete
115+
116+
The old `POST /api/oauth/refresh` and `POST /api/oauth/logout` endpoints no longer exist and return 404.

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,14 @@ src/
5252
github.ts # Octokit client factory with ETag caching and rate limit tracking
5353
poll.ts # Poll coordinator with visibility-aware auto-refresh
5454
stores/
55-
auth.ts # OAuth token management with auto-refresh
55+
auth.ts # OAuth token management (localStorage persistence, validateToken)
5656
cache.ts # IndexedDB cache with TTL eviction and ETag support
5757
config.ts # Zod v4-validated config with localStorage persistence
5858
view.ts # View state (tabs, sorting, ignored items, filters)
5959
lib/
6060
notifications.ts # Desktop notification permission, detection, and dispatch
6161
worker/
62-
index.ts # OAuth token exchange/refresh endpoint, CORS, security headers
62+
index.ts # OAuth token exchange endpoint, CORS, security headers
6363
tests/
6464
fixtures/ # GitHub API response fixtures (orgs, repos, issues, PRs, runs)
6565
services/ # API service, Octokit client, and poll coordinator tests
@@ -74,10 +74,11 @@ tests/
7474
- Strict CSP: `script-src 'self'` (SHA-256 exception for dark mode script only)
7575
- OAuth CSRF protection via `crypto.getRandomValues` state parameter
7676
- CORS locked to exact origin (strict equality, no substring matching)
77-
- Access token in-memory only (never persisted); refresh token in `__Host-` HttpOnly cookie
78-
- Auto-refresh on 401 and on page load via HttpOnly cookie
77+
- Access token stored in `localStorage` under app-specific key; CSP prevents XSS token theft
78+
- Token validation on page load via `GET /user`; 401 clears auth immediately (no silent refresh)
7979
- All GitHub API strings auto-escaped by SolidJS JSX (no innerHTML)
80+
- `repo` scope granted (required for private repos) — app never performs write operations
8081

8182
## Deployment
8283

83-
See [DEPLOY.md](./DEPLOY.md) for Cloudflare, GitHub App, and CI/CD setup.
84+
See [DEPLOY.md](./DEPLOY.md) for Cloudflare, OAuth App, and CI/CD setup.

e2e/settings.spec.ts

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
11
import { test, expect, type Page } from "@playwright/test";
22

33
/**
4-
* Register API route interceptors and inject config BEFORE any navigation.
5-
* The app calls refreshAccessToken() on load, which POSTs to /api/oauth/refresh
6-
* (HttpOnly cookie-based). We intercept that to return a valid access token.
4+
* Register API route interceptors and inject auth + config into localStorage BEFORE navigation.
5+
* OAuth App uses permanent tokens stored in localStorage — no refresh endpoint needed.
6+
* The app calls validateToken() on load, which GETs /user to verify the token.
77
*/
88
async function setupAuth(page: Page) {
9-
await page.route("**/api/oauth/refresh", (route) =>
10-
route.fulfill({
11-
status: 200,
12-
json: { access_token: "ghu_fake", expires_in: 86400 },
13-
})
14-
);
159
await page.route("https://api.github.com/user", (route) =>
1610
route.fulfill({
1711
status: 200,
@@ -36,11 +30,12 @@ async function setupAuth(page: Page) {
3630
);
3731

3832
await page.addInitScript(() => {
33+
localStorage.setItem("github-tracker:auth-token", "ghu_fake");
3934
localStorage.setItem(
4035
"github-tracker:config",
4136
JSON.stringify({
4237
selectedOrgs: ["testorg"],
43-
selectedRepos: [{ owner: "testorg", name: "testrepo" }],
38+
selectedRepos: [{ owner: "testorg", name: "testrepo", fullName: "testorg/testrepo" }],
4439
onboardingComplete: true,
4540
})
4641
);
@@ -114,16 +109,17 @@ test("sign out clears auth and redirects to login", async ({ page }) => {
114109
const signOutBtn = page.getByRole("button", { name: /^sign out$/i });
115110
await expect(signOutBtn).toBeVisible();
116111

117-
// Intercept the logout cookie-clearing request
118-
await page.route("**/api/oauth/logout", (route) =>
119-
route.fulfill({ status: 200, json: { ok: true } })
120-
);
121-
122112
await signOutBtn.click();
123113

124-
// clearAuth() clears in-memory token and navigates to /login
114+
// clearAuth() removes the localStorage token and navigates to /login
125115
await expect(page).toHaveURL(/\/login/);
126116

117+
// Verify auth token was cleared from localStorage
118+
const authToken = await page.evaluate(() =>
119+
localStorage.getItem("github-tracker:auth-token")
120+
);
121+
expect(authToken).toBeNull();
122+
127123
// Verify config was reset (SDR-016 data leakage prevention).
128124
// The persistence effect may re-write defaults, so check that user-specific
129125
// data (selectedOrgs, onboardingComplete) was cleared rather than checking null.

0 commit comments

Comments
 (0)