Skip to content

Commit b2a6aa9

Browse files
Rio517claude
andcommitted
fix: privateMode logic, secure cookie clearing, stale docs
- Derive _privateMode from both admin and viewer keys being set - Pass secure flag to clearAdminCookie/clearViewerCookie in logout - Update README and auth plan to reflect unified /auth/login endpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 80fbe47 commit b2a6aa9

4 files changed

Lines changed: 28 additions & 27 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,11 @@ DerbyTimer supports three auth modes controlled by environment variables. **By d
140140
```bash
141141
# Test admin-protected mode
142142
DERBY_ADMIN_KEY=secret bun start
143-
# Then POST to /admin/login with { "password": "secret" }
143+
# Then POST to /auth/login with { "password": "secret" }
144144

145145
# Test fully private mode
146146
DERBY_ADMIN_KEY=secret DERBY_VIEWER_KEY=viewer bun start
147-
# All pages require login. Use /admin/login or /viewer/login.
147+
# All pages require login. POST to /auth/login with either password.
148148

149149
# Auto-generated admin key (persisted to .derby_admin_key file)
150150
DERBY_ADMIN_KEY=auto bun start

docs/plans/auth.md

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,10 @@ Admin-only pages (Registration, Race Control, Batch Certificates) show a centere
136136
- `isPublicMode()`returns true when no admin key is configured
137137
- `isPrivateMode()`returns true when viewer key is configured
138138
2. Add routes:
139-
- `GET /admin/login?token=...`validates token, sets admin cookie, `302` redirects to `/`
140-
- `POST /admin/login`accepts `{ password }` JSON body, validates, sets admin cookie
139+
- `POST /auth/login`unified login: accepts `{ password }`, checks admin key then viewer key, sets appropriate cookie
140+
- `GET /admin/login?token=...`validates HMAC token, sets admin cookie, `302` redirects to `/`
141141
- `POST /admin/logout`clears admin cookie
142-
- `POST /viewer/login`accepts `{ password }` JSON body, validates, sets viewer cookie
142+
- `POST /viewer/logout`clears viewer cookie
143143
- `GET /admin/status`returns `{ admin: true/false, viewer: true/false, publicMode: true/false, privateMode: true/false }`
144144
3. Wrap all `POST`/`PATCH`/`DELETE` routes with `adminOnly()` except explicitly public ones
145145
4. When `DERBY_VIEWER_KEY` is set, wrap all `GET` routes with `viewerRequired()` except login and healthcheck
@@ -149,7 +149,7 @@ Admin-only pages (Registration, Race Control, Batch Certificates) show a centere
149149

150150
1. Add `isAdmin`, `isViewer`, `isPublicMode`, and `isPrivateMode` to `AppContext`
151151
2. On app load, call `GET /admin/status` to determine auth state
152-
3. If `privateMode && !isViewer && !isAdmin`, redirect to `/viewer/login`
152+
3. If `privateMode && !isViewer && !isAdmin`, show in-app `PrivateLoginGate` (full-screen login form)
153153
4. No URL param handling neededthe cookie flow is entirely server-side
154154

155155
### Phase 3: Frontend UI Updates
@@ -158,14 +158,14 @@ Admin-only pages (Registration, Race Control, Batch Certificates) show a centere
158158
2. Show "Admin access required" banner on admin-only views when `!isAdmin && !isPublicMode`
159159
3. In public mode (no key set), everything works as it does todayno banners, no restrictions
160160

161-
### Phase 4: Admin Login Page
161+
### Phase 4: Admin Login UI
162162

163-
1. Create a simple `/admin` page:
164-
- If `isPublicMode`: show "Public Mode — all users have admin access"
165-
- If `!isAdmin`: show a password input form (POST to `/admin/login`)
166-
- If `isAdmin`: show QR code for other volunteers, local IP, "Revoke All Sessions" button
167-
2. Local mode: QR code is the primary login method
168-
3. Cloud mode: password form is the primary login method
163+
1. Login dialog in the app shell (no separate `/admin` page):
164+
- "Admin Login" button in the nav bar opens a password dialog
165+
- Dialog POSTs to `/auth/login`the unified endpoint checks admin key then viewer key
166+
- On success, the app refreshes auth state and shows admin controls
167+
2. Local mode: QR code at `/admin/login?token=HMAC` is the primary login method for volunteers
168+
3. Cloud mode: the nav bar password dialog is the primary login method
169169

170170
## 5. Cloud Deployment Considerations
171171

@@ -231,24 +231,25 @@ All test scripts set both keys and include the appropriate cookies in requests:
231231
* `POST /api/heats/:id/start` with valid admin cookie`200`
232232
* `GET /admin/login?token=wrong``401`
233233
* `GET /admin/login?token=correct``302` + `Set-Cookie` with `HttpOnly`
234-
* `POST /admin/login` with correct password`200` + `Set-Cookie`
234+
* `POST /auth/login` with admin password`200` + `Set-Cookie` (`derby_admin`) + `role: "admin"`
235+
* `POST /auth/login` with viewer password`200` + `Set-Cookie` (`derby_viewer`) + `role: "viewer"`
235236
* `GET /api/events` without cookie (no viewer key) → `200` (public read)
236237
* `GET /api/events` without cookie (viewer key set) → `401`
237238
* `GET /api/events` with viewer cookie (viewer key set) → `200`
238239
* `GET /api/events` with admin cookie (viewer key set) → `200` (admin implies viewer)
239240
* `POST /viewer/login` with correct password`200` + `Set-Cookie` (`derby_viewer`)
240241
* `POST /viewer/login` with wrong password`401`
241-
* Rate limiting: 11 rapid login attempts`429` (applies to both `/admin/login` and `/viewer/login`)
242+
* Rate limiting: 11 rapid login attempts`429` (applies to all login endpoints)
242243
* Cookie has `Secure` flag when request includes `X-Forwarded-Proto: https`
243244

244245
### E2E Tests (`e2e/auth.playwright.ts`)
245246

246247
* **Guest Path**: Navigate to `/register`. See read-only view with "Admin required" banner.
247-
* **Admin Path**: Hit `/admin/login?token=test-secret`. Verify cookie is set, redirected to `/`, all actions available.
248-
* **Password Login**: Submit password on `/admin` form. Verify cookie is set.
248+
* **Admin Path**: Hit `/admin/login?token=HMAC`. Verify cookie is set, redirected to `/`, all actions available.
249+
* **Password Login**: Use "Admin Login" button in nav, submit password. Verify cookie is set via `/auth/login`.
249250
* **Logout**: Call `/admin/logout`. Verify admin actions disappear.
250251
* **Public Mode**: Start server without `DERBY_ADMIN_KEY`. Verify no banners, full access.
251-
* **Private Mode**: Start server with `DERBY_VIEWER_KEY=test-viewer`. Navigate to `/standings`redirected to viewer login. Enter password, verify standings load.
252+
* **Private Mode**: Start server with `DERBY_VIEWER_KEY=test-viewer`. Navigate to `/`see `PrivateLoginGate`. Enter viewer password, verify events page loads.
252253
* **Private Mode Admin**: In private mode, admin cookie grants full access without needing viewer password.
253254

254255
## 7. Related Plans

src/auth.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const resolveAdminKey = (): string | null => {
3737
const _adminKey = resolveAdminKey();
3838
const _viewerKey = Bun.env.DERBY_VIEWER_KEY || null;
3939
const _publicMode = _adminKey === null;
40-
const _privateMode = _viewerKey !== null;
40+
const _privateMode = _adminKey !== null && _viewerKey !== null;
4141

4242
export const getAdminKey = (): string | null => _adminKey;
4343
export const getViewerKey = (): string | null => _viewerKey;
@@ -146,12 +146,12 @@ export const setViewerCookie = (headers: Headers, secure: boolean = false): void
146146
setCookie(headers, VIEWER_COOKIE, _expectedViewerHmac, COOKIE_MAX_AGE, secure);
147147
};
148148

149-
export const clearAdminCookie = (headers: Headers): void => {
150-
setCookie(headers, ADMIN_COOKIE, "", 0);
149+
export const clearAdminCookie = (headers: Headers, secure: boolean = false): void => {
150+
setCookie(headers, ADMIN_COOKIE, "", 0, secure);
151151
};
152152

153-
export const clearViewerCookie = (headers: Headers): void => {
154-
setCookie(headers, VIEWER_COOKIE, "", 0);
153+
export const clearViewerCookie = (headers: Headers, secure: boolean = false): void => {
154+
setCookie(headers, VIEWER_COOKIE, "", 0, secure);
155155
};
156156

157157
// ===== COOKIE VALIDATION =====

src/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,9 +1176,9 @@ const server = Bun.serve({
11761176
},
11771177

11781178
"/admin/logout": {
1179-
POST: (_req) => {
1179+
POST: (req) => {
11801180
const headers = new Headers({ "Content-Type": "application/json" });
1181-
clearAdminCookie(headers);
1181+
clearAdminCookie(headers, isSecureRequest(req));
11821182
return new Response(JSON.stringify({ success: true }), { status: 200, headers });
11831183
},
11841184
},
@@ -1200,9 +1200,9 @@ const server = Bun.serve({
12001200
},
12011201

12021202
"/viewer/logout": {
1203-
POST: (_req) => {
1203+
POST: (req) => {
12041204
const headers = new Headers({ "Content-Type": "application/json" });
1205-
clearViewerCookie(headers);
1205+
clearViewerCookie(headers, isSecureRequest(req));
12061206
return new Response(JSON.stringify({ success: true }), { status: 200, headers });
12071207
},
12081208
},

0 commit comments

Comments
 (0)