fix(spec): rename auth routes to /cm-auth and /cm-passkeys

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/.
This commit is contained in:
yiekheng 2026-05-03 08:16:36 +08:00
parent fe26878b38
commit 43533c3485

View File

@ -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.