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:
parent
fe26878b38
commit
43533c3485
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user