From 43533c348515f81f221affa39ee4c46af1b1f8f4 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 3 May 2026 08:16:36 +0800 Subject: [PATCH] fix(spec): rename auth routes to /cm-auth and /cm-passkeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoids the well-known /login path that scanners hit by default. The cm- prefix matches the rest of the project's namespacing (cm-web-next, cm-api, etc.) and isn't on standard scanner wordlists. Settings page moves to flat /cm-passkeys (was /settings/passkeys) to drop the simple 'settings' word — same scanner-noise reasoning. File paths follow: web/app/cm-auth/, web/app/cm-passkeys/. --- .../specs/2026-05-02-b-auth-design.md | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/specs/2026-05-02-b-auth-design.md b/docs/superpowers/specs/2026-05-02-b-auth-design.md index 27562ab..029f230 100644 --- a/docs/superpowers/specs/2026-05-02-b-auth-design.md +++ b/docs/superpowers/specs/2026-05-02-b-auth-design.md @@ -18,12 +18,12 @@ The existing `CM_AGENT_ID` / `CM_AGENT_PASSWORD` env vars already define an oper Add an in-app login flow to `cm-web-next`: -- A `/login` page that shows two options side-by-side: a "Sign in with passkey" button (preferred when one is enrolled on this device), and a username + password form (fallback). +- A `/cm-auth` page that shows two options side-by-side: a "Sign in with passkey" button (preferred when one is enrolled on this device), and a username + password form (fallback). - Password sign-in compares against the existing `CM_AGENT_ID` and `CM_AGENT_PASSWORD` env vars using a constant-time compare. - WebAuthn passkey enrollment (after first password sign-in, on a settings page) lets the operator add a Face ID / Touch ID / fingerprint credential bound to the device. Subsequent visits skip the password. - Session state: a signed `httpOnly` cookie via `iron-session`. 30-day rolling expiry; refreshes on activity. - All auth state lives in `cm-web-next` — no api-server changes, no mysql schema change. Passkeys are stored as JSON in a docker volume mounted into the container. -- Middleware gates every dashboard route except `/login` and the WebAuthn Server Actions, which are reachable while logged out. +- Middleware gates every dashboard route except `/cm-auth` and the WebAuthn Server Actions, which are reachable while logged out. ## Non-Goals @@ -45,8 +45,8 @@ When `CM_AGENT_ID` changes (rex-cm gets a new agent, say), all existing passkeys ### Login flow — password -1. Browser hits `/` → middleware sees no session cookie → 302 to `/login?next=/`. -2. `/login` page is a Server Component (form is a Client Component for state). +1. Browser hits `/` → middleware sees no session cookie → 302 to `/cm-auth?next=/`. +2. `/cm-auth` page is a Server Component (form is a Client Component for state). 3. User types `CM_AGENT_ID` and `CM_AGENT_PASSWORD`, submits. 4. Client calls `loginWithPassword(username, password)` Server Action. 5. Server Action: @@ -58,7 +58,7 @@ When `CM_AGENT_ID` changes (rex-cm gets a new agent, say), all existing passkeys ### Login flow — passkey -1. `/login` page detects (client-side) whether `PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()` returns true and whether at least one passkey is enrolled (server-supplied flag in the page payload). +1. `/cm-auth` page detects (client-side) whether `PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()` returns true and whether at least one passkey is enrolled (server-supplied flag in the page payload). 2. If both true: render a "Sign in with passkey" button as the primary CTA, password form below. 3. Click triggers `beginAuthentication()` Server Action → returns `PublicKeyCredentialRequestOptionsJSON` with a fresh server-generated challenge. 4. Client invokes `@simplewebauthn/browser`'s `startAuthentication()`, which prompts Face ID / fingerprint. @@ -68,7 +68,7 @@ When `CM_AGENT_ID` changes (rex-cm gets a new agent, say), all existing passkeys ### Passkey enrollment flow -1. Once authenticated (via password), user visits `/settings/passkeys`. +1. Once authenticated (via password), user visits `/cm-passkeys`. 2. "Add passkey" button → `beginRegistration()` Server Action returns `PublicKeyCredentialCreationOptionsJSON`. 3. Client invokes `@simplewebauthn/browser`'s `startRegistration()` — Face ID / fingerprint enrolls a new credential. 4. Client sends attestation to `finishRegistration(response, deviceName)` Server Action. @@ -137,7 +137,7 @@ The challenge for register/authenticate is stored in the session cookie (small, import { NextRequest, NextResponse } from "next/server"; import { getSessionFromCookie } from "@/lib/auth"; -const PUBLIC_PATHS = new Set(["/login"]); +const PUBLIC_PATHS = new Set(["/cm-auth"]); export async function middleware(req: NextRequest) { const path = req.nextUrl.pathname; @@ -146,7 +146,7 @@ export async function middleware(req: NextRequest) { const session = await getSessionFromCookie(req.cookies); if (!session) { const url = req.nextUrl.clone(); - url.pathname = "/login"; + url.pathname = "/cm-auth"; url.searchParams.set("next", path); return NextResponse.redirect(url); } @@ -169,10 +169,10 @@ Server Actions live OUTSIDE the matcher (Next.js routes them through a separate | `web/lib/auth.ts` | Create | Session create/read/destroy helpers (iron-session wrapper) | | `web/lib/auth-store.ts` | Create | JSON-file CRUD for passkeys with in-process write lock | | `web/app/auth-actions.ts` | Create | All Server Actions listed above | -| `web/app/login/page.tsx` | Create | Login UI (Server Component shell) | -| `web/app/login/login-form.tsx` | Create | Client Component for the form + passkey button | -| `web/app/settings/passkeys/page.tsx` | Create | Passkey list + add/remove (Server Component) | -| `web/app/settings/passkeys/passkey-list.tsx` | Create | Client Component handling enrollment + removal | +| `web/app/cm-auth/page.tsx` | Create | Login UI (Server Component shell) | +| `web/app/cm-auth/auth-form.tsx` | Create | Client Component for the form + passkey button | +| `web/app/cm-passkeys/page.tsx` | Create | Passkey list + add/remove (Server Component) | +| `web/app/cm-passkeys/passkey-list.tsx` | Create | Client Component handling enrollment + removal | | `web/components/nav.tsx` | Modify | Add Settings link + Sign-out button (account menu) | | `web/package.json` | Modify | Add `iron-session`, `@simplewebauthn/server`, `@simplewebauthn/browser` | | `docker-compose.yml` | Modify | Add `web-next-auth-data` named volume + mount in `web-next` service | @@ -270,22 +270,22 @@ frontend-design generates `login/page.tsx` shell + `login-form.tsx` client compo Add a small account menu on the right side (next to the existing Accounts/Users tab pills): - A subtle button showing `CM_AGENT_ID` (truncated if long). -- On click: dropdown with "Passkey settings" → `/settings/passkeys`, and "Sign out" → calls `logout()` Server Action → redirect to `/login`. +- On click: dropdown with "Passkey settings" → `/cm-passkeys`, and "Sign out" → calls `logout()` Server Action → redirect to `/cm-auth`. The dropdown uses the same modal/sheet primitive style — no new component primitive. ## Verification -1. **Cold start.** `bash scripts/dev.sh up`. Open `http://localhost:8010/`. Redirected to `/login?next=%2F`. +1. **Cold start.** `bash scripts/dev.sh up`. Open `http://localhost:8010/`. Redirected to `/cm-auth?next=%2F`. 2. **Password sign-in.** Type `CM_AGENT_ID` and `CM_AGENT_PASSWORD` from the dev `.env`. Submit. Redirect to `/`. Accounts table renders. 3. **Cookie set.** DevTools → Application → Cookies → `cm_auth` present, `httpOnly`, `secure` (in prod) / not (in dev because `NODE_ENV=development`), `sameSite=lax`, expires ~30 days. 4. **Wrong password.** Type wrong password. Form shows red "invalid credentials". No success toast. No cookie set. -5. **Sign out.** Click the user menu → Sign out. Redirected to `/login`. Cookie cleared. +5. **Sign out.** Click the user menu → Sign out. Redirected to `/cm-auth`. Cookie cleared. 6. **Passkey enrollment** (Chrome desktop with Touch ID, or iPhone). Sign in with password → settings/passkeys → Add passkey → name "MacBook" → Touch ID prompt → success toast → row appears in list. -7. **Passkey login.** Sign out. `/login` now shows "Sign in with passkey" as primary CTA. Click → Touch ID → redirect to `/`. +7. **Passkey login.** Sign out. `/cm-auth` now shows "Sign in with passkey" as primary CTA. Click → Touch ID → redirect to `/`. 8. **Passkey persistence.** `bash scripts/dev.sh down && bash scripts/dev.sh up`. Sign-in flow still recognizes the previously enrolled passkey (volume persisted). 9. **Passkey removal.** Sign in → settings/passkeys → Remove. Row disappears, JSON file no longer contains it. -10. **Middleware coverage.** While signed out: `/`, `/users/`, `/settings/passkeys` all redirect to `/login`. `/login` itself does not redirect. +10. **Middleware coverage.** While signed out: `/`, `/users/`, `/cm-passkeys` all redirect to `/cm-auth`. `/cm-auth` itself does not redirect. 11. **Server Actions auth.** Calling `removePasskey` from a client without a valid session returns an error (auth-action body checks `getSession()` and throws/returns 401-equivalent). 12. **Constant-time compare.** Manually inspect `loginWithPassword` source — uses `crypto.timingSafeEqual` over zero-padded buffers of equal length. (No timing-channel leak about which field is wrong.) 13. **Volume preserved across rebuild.** `sudo docker compose -f docker-compose.yml -f docker-compose.override.yml build --no-cache web-next` then `up`. Passkey JSON survives.