docs(19): capture phase context
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
# Phase 19: Local Auth (No-OIDC Mode) - Context
|
||||
|
||||
**Gathered:** 2026-06-16
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Let an operator run FamilySync entirely on **local DB username/password accounts with no OIDC/Authelia required**, while keeping OIDC available as an opt-in, generic (RFC-compliant, not Authelia-specific) provider that can be wired in later from the admin UI. Builds directly on the Phase-12 pre-OIDC local-user foundation (nullable `users.oidc_iss`/`oidc_sub`, the `claimed` marker, and the first-login-claims merge in `upsertUser`).
|
||||
|
||||
**In scope:** local credential storage (scrypt) + local login flow; a new local login UI in the PWA; a stateless local-session cookie + middleware; coexistence with the existing OIDC middleware; admin-managed local account creation + password set/change/reset; per-user OIDC-link (replacing local for that user); de-Authelia-izing OIDC config/copy to a generic OIDC provider; a lockout/break-glass recovery mechanism; reworking dev-bypass + the Phase 7/8 Playwright harness to cover the new login UI.
|
||||
|
||||
**Out of scope:** a full pluggable multi-auth-provider framework (LDAP, magic-link, multiple OIDC) — that is the auth-layer counterpart of backlog 999.1, a future phase. Email-based password reset (email is out of project scope). BYO-CalDAV provider abstraction (999.1, separate).
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Mode & Coexistence
|
||||
- **D-01:** Local auth is the **default and always available**. OIDC is **opt-in/additive**, never a replacement for the local path at the system level.
|
||||
- **D-02:** OIDC is configured from the **admin UI** (extends the Phase-12 config that already lands in `app_config`: `oidc_issuer`, `oidc_client_id`, `app_external_url`). When OIDC is configured, **both methods are offered and the user chooses at login** (local username/password OR "Login with OIDC").
|
||||
- **D-03:** This must **not break the existing live OIDC deployment**. The two current household members already authenticate via Authelia (`oidc_iss`/`oidc_sub` set, `claimed=true`); they continue as OIDC users. Local auth is layered on additively.
|
||||
- **D-04:** A **new local login UI (username + password) must be built in the PWA** — none exists today. The PWA currently boots straight into the authed app (OIDC redirect) or via dev-bypass; there is no login form.
|
||||
|
||||
### Session Issuance
|
||||
- **D-05:** Local logins are backed by a **stateless signed httpOnly JWT cookie** carrying `userId`, validated by a **new local-auth middleware that sets `c.get('user')`** the same way `auth/devBypass.ts` does — so every downstream route resolves the user unchanged. **No DB sessions table** (consistent with the app's existing storage-less-JWT approach; right for household scale). Tradeoff accepted: a password change cannot retroactively invalidate other live sessions; logout = clear cookie.
|
||||
- **D-06 (BYO-Auth principle):** Local auth is first-class; OIDC is treated as a **generic RFC-compliant provider, not Authelia-hardcoded**. `@hono/oidc-auth` is already provider-agnostic — work is to de-Authelia-ize config keys and user-facing copy and treat issuer/client as generic OIDC config. Mirrors the planned BYO-CalDAV provider abstraction (999.1).
|
||||
- **D-07 (BYO-Auth scope):** Ship **local + one generic OIDC** with a **clean internal seam** for future methods. **No plugin/registry framework** in this phase.
|
||||
|
||||
### Password Hashing & Storage
|
||||
- **D-08:** Hash local passwords with **`node:crypto` scrypt** — zero new dependency, no native node-gyp build in the Docker image (honors the stack's deliberate no-native-dep stance, the same reason Drizzle was chosen over Prisma). Encode **algorithm + params + salt alongside the hash** so parameters can evolve. (argon2id/bcrypt native addons explicitly rejected.)
|
||||
- **D-09:** Store local credentials in a **new `local_credentials` table** — `user_id` (FK to `users`, UNIQUE), `username` (UNIQUE), `password_hash` (encoded), `createdAt`/`updatedAt` — mirroring the `member_credentials` pattern. Keeps the `users` row identity-method-agnostic. **Auth methods are a per-user property**: a user has local login iff a `local_credentials` row exists, and OIDC login iff an `oidc_iss+oidc_sub` binding exists. Drizzle **generate+migrate, never push** (additive migration on populated MariaDB — same rule as Phases 10/12).
|
||||
|
||||
### Accounts & OIDC-Link
|
||||
- **D-10:** **Admin creates members** + sets an initial password; the member changes it later. **No open self-signup** (wrong trust model for a private household app exposed via Pangolin).
|
||||
- **D-11:** Password lifecycle = **self-change (current + new) + admin-reset** from the admin UI. **No email reset** (email out of project scope). Reuses the admin surface that already rotates Fastmail app passwords.
|
||||
- **D-12:** **OIDC link replaces local at the per-user level**: when a local user links an OIDC identity (explicit action while authenticated as that user — never an email match, per Phase-12 D-10), **delete that user's `local_credentials` row** → they become OIDC-only. OIDC-only users never receive a local credential. The returned `iss+sub` must not already belong to another user.
|
||||
- **D-13 (break-glass):** Lockout recovery does **not** need to be a permanent local user account (avoids a member-vs-operator capability split — explicitly rejected). Instead, recovery is a **CLI/console command and/or env override** (e.g. create/reset a local admin, or disable/force-off OIDC), run on the host/container. **No new role/capability model**; reuse today's single `users.is_admin`. Exact form → researcher (see Open Questions).
|
||||
|
||||
### Testing & Dev-Bypass
|
||||
- **D-14:** The new login UI requires touching existing API/unit tests and the **Phase 7/8 Playwright harness** (which today reaches the authed PWA purely via `DEV_AUTH_BYPASS`, skipping any login). Both the already-authed fast path and the **real login form** must remain testable.
|
||||
- **D-15 (hard constraint):** Any seeded test login / reworked dev-bypass mechanism **stays dev-only and never ships in the Docker/prod image**. It is bound by the existing Phase-16 image-hygiene gates: the IMG-01 boot guard (`assertNotDevBypassInProduction`), `.dockerignore` (IMG-02), and the publish-time hygiene assertion (IMG-03). New dev-seed-login artifacts must be covered by those same gates.
|
||||
|
||||
### Claude's Discretion (decided in-discussion)
|
||||
- Session backing mechanism (chose stateless signed JWT cookie — D-05).
|
||||
- Credential storage location (chose separate `local_credentials` table — D-09).
|
||||
These were "you decide" responses; rationale captured above. Researcher/planner may refine implementation detail but should not reverse the locked choice without cause.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Auth foundation this phase extends
|
||||
- `apps/api/src/auth/user.ts` — `upsertUser` (identity = `oidc_iss+oidc_sub`, never email; first-login-claims of the single unclaimed row; first-login-wins `is_admin` bootstrap; `claimed` semantics). The local-account + OIDC-link model generalizes this.
|
||||
- `apps/api/src/auth/middleware.ts` — OIDC middleware wiring + `oidcConfigFallbackMiddleware` (env-OR-`app_config` fallback for `OIDC_ISSUER`/`OIDC_CLIENT_ID`/`app_external_url`). The generic-OIDC config path lives here.
|
||||
- `apps/api/src/auth/devBypass.ts` — `devAuthBypass()` + `DEV_USER`; the `c.set('user', …)` pattern the new local-auth middleware mirrors. Subject of the dev-bypass rework (D-14/D-15).
|
||||
- `apps/api/src/auth/persistSessionCookie.ts` — session-cookie persistence helper (referenced by the session model).
|
||||
- `apps/api/src/index.ts` — middleware mount order (`/api/setup` pre-auth → `devAuthBypass` → `oidcConfigFallback` → `oidcAuthMiddleware` → `persistSessionCookie`); `devBypassActive` computed once at boot; `assertNotDevBypassInProduction()` boot guard. Local-login routes + middleware slot in here.
|
||||
- `apps/api/src/routes/me.ts` — `resolveUserId` (dev-bypass `c.get('user')` first, else `getAuth`) + `needsProviderSetup`/`isAdmin` exposure. The user-resolution seam for all routes.
|
||||
- `apps/api/src/db/schema.ts` — `users` (nullable `oidc_iss`/`oidc_sub`, `claimed`, `is_admin`, `uniq_oidc_identity`), `member_credentials` (pattern to mirror for `local_credentials`), `app_config` (k/v config; PROHIBITION list for secrets-in-DB).
|
||||
- `apps/api/src/routes/admin.ts` + `apps/api/src/lib/requireAdmin.ts` — admin route surface + role guard the account-management UI and OIDC config UI extend.
|
||||
- `apps/api/src/routes/setup.ts` + `apps/api/src/lib/setupGuard.ts` — Phase-12 pre-auth wizard + 423 lock; the first-local-admin bootstrap replaces the current unclaimed-user provisioning.
|
||||
|
||||
### Image hygiene / dev-prod boundary (constrains D-15)
|
||||
- `apps/api/src/lib/bootGuards.ts` — `assertNotDevBypassInProduction` (IMG-01).
|
||||
- `.dockerignore` (repo root) — IMG-02 dev-artifact exclusion.
|
||||
- `.gitea/workflows/publish.yml` — IMG-03 publish-time image-hygiene + boot-smoke assertions.
|
||||
|
||||
### Test harness this phase must update
|
||||
- `apps/pwa/playwright.config.ts` + `apps/pwa/e2e/` (global-setup deterministic mysql2 seed, `layout.spec.ts`, `calendar.spec.ts`, `lists.spec.ts`) — Phase 7 harness; auth reached via `DEV_AUTH_BYPASS`.
|
||||
- `.gitea/workflows/ci.yml` — CI `harness` job (brings up dev stack with `DEV_AUTH_BYPASS=true`).
|
||||
|
||||
### Provenance / prior decisions
|
||||
- `.planning/phases/12-initial-setup-wizard/12-CONTEXT.md` §Deferred Ideas — origin of this phase (full local-auth/no-OIDC mode deferred from Phase 12; D-07 local-user groundwork is the deliberate foundation).
|
||||
- `.planning/ROADMAP.md` §"Phase 19" — goal, dependency on Phase 12, and the four seed open questions.
|
||||
- `.planning/PROJECT.md` §Constraints / §Auth — Authelia-OIDC constraint context; MariaDB-only; no-native-dep stance.
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `member_credentials` table shape + `validateEncryptAndStoreCredential` flow (`broker/credentialSync.ts`) — direct template for the `local_credentials` table and an admin-managed create/reset write path.
|
||||
- `devAuthBypass()`'s `c.set('user', …)` pattern — the new local-auth middleware reuses it so downstream routes (`resolveUserId` in every router) need no change.
|
||||
- `oidcConfigFallbackMiddleware` (env-OR-`app_config`) — the established pattern for admin-UI-written OIDC config taking effect.
|
||||
- Phase-12 `upsertUser` claim/link machinery — the OIDC-link flow (D-12) is a generalization (bind `iss+sub` to an already-authenticated local user, then drop their local credential).
|
||||
- `assertNotDevBypassInProduction` + `.dockerignore` + `publish.yml` hygiene assertions — the enforcement surface for D-15.
|
||||
|
||||
### Established Patterns
|
||||
- **Identity = `oidc_iss+oidc_sub`, never email** (Phase-12 D-10) — local accounts are a separate per-user credential, and OIDC-link must be explicit (no email matching).
|
||||
- **Drizzle generate+migrate, never push** — additive migration on populated MariaDB (Phases 10/12 precedent); applies to the new `local_credentials` table.
|
||||
- **`is_admin` is the server boundary; client `isAdmin` is UX-only** — local-auth admin gating reuses `requireAdmin`.
|
||||
- **Secrets stay in env, never in `app_config`/DB** (Phase-12 PROHIBITION) — the local-session signing secret and scrypt config live in env, not the DB.
|
||||
- **Storage-less JWT session cookie** (CLAUDE.md, `@hono/oidc-auth`) — the local-session cookie follows the same stateless philosophy (D-05).
|
||||
|
||||
### Integration Points
|
||||
- New local-login routes + local-auth middleware mount in `index.ts` alongside (and ordered against) `devAuthBypass`/`oidcAuthMiddleware`; the OIDC guard must not 302-redirect local-mode requests.
|
||||
- The PWA gate (App.tsx setup/login routing) gains a login screen and a login-vs-OIDC chooser; `/api/me` / a new auth-mode endpoint tells the PWA which methods to offer.
|
||||
- Setup wizard bootstrap shifts from "provision one unclaimed user" to "create the first local admin (username+password)".
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "Bring Your Own Auth" framing (user's words) — explicitly do not pigeon-hole into Authelia; OIDC is one generic provider, parallel to the intended "Bring Your Own CalDAV provider" direction (999.1).
|
||||
- User leans toward **"replace dev-bypass with seeded auto-login"** for the harness, but defers the final call to the researcher.
|
||||
- User prefers the **break-glass to be a CLI/env override rather than a user account**, to avoid added user/capability complexity.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- **Full pluggable auth-provider framework** (registry/plugin for LDAP, magic-link, multiple simultaneous OIDC providers) — auth-layer counterpart of backlog 999.1; its own future phase/milestone. Phase 19 builds only a clean internal seam.
|
||||
- **Member-vs-operator capability/role split** — considered for the break-glass account, explicitly rejected in favor of a CLI/env recovery mechanism + the existing single `is_admin` flag.
|
||||
- **Email-based password reset** — out of project scope (no email features).
|
||||
|
||||
## Open Questions for Research
|
||||
|
||||
- **Dev-bypass rework (decide among 3):** (a) keep bypass + seed a real test login for login-specific specs; (b) replace bypass with seeded auto-login through the real local flow (user's lean); (c) bypass auto-issues a real local-session cookie. Must satisfy D-14 + the D-15 dev-only/no-prod-image constraint.
|
||||
- **Break-glass recovery form:** CLI/console command vs env override (or both) for create/reset-local-admin and/or disable-OIDC; how it interacts with the boot-time mode/middleware selection.
|
||||
- **OIDC-only user provisioning:** how an OIDC-only user is first created given D-10 forbids email-matching unclaimed rows — just-in-time on first OIDC login vs admin pre-creation + claim (the Phase-12 single-unclaimed-row claim can't disambiguate multiple pre-created placeholders).
|
||||
- **Login-vs-OIDC mode signalling to the PWA:** reuse/extend `/api/me` or `/api/setup/status`, or a new pre-auth `/api/auth/mode` endpoint, so the login page knows which methods to render.
|
||||
- **Local-login hardening:** rate-limiting / lockout / timing-safe compare on the local login endpoint (household scale, but Pangolin-exposed).
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 19-local-auth-no-oidc-mode*
|
||||
*Context gathered: 2026-06-16*
|
||||
@@ -0,0 +1,161 @@
|
||||
# Phase 19: Local Auth (No-OIDC Mode) - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-06-16
|
||||
**Phase:** 19-local-auth-no-oidc-mode
|
||||
**Areas discussed:** Mode & coexistence, Session issuance, Password hashing & storage, Accounts & OIDC-link, Testing & dev-bypass
|
||||
|
||||
---
|
||||
|
||||
## Mode & Coexistence
|
||||
|
||||
### Q1 — How should the app decide between local-auth and OIDC?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| app_config flag (runtime) | `auth_mode` row in app_config, set by wizard; no restart | |
|
||||
| Deploy-time env switch | `AUTH_MODE` env read at boot | |
|
||||
| Both always live | Local form + OIDC button always shown | |
|
||||
|
||||
**User's choice:** Free-text — "Default to local and add the ability to wire OIDC in later if wanted."
|
||||
**Notes:** Local is the always-available default; OIDC is additive/opt-in.
|
||||
|
||||
### Q2 — How does the app know OIDC is wired in, and what happens to local login?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Auto-detect, local stays live | OIDC on when config present; local always available | (partial) |
|
||||
| Auto-detect, OIDC takes over | Local disabled once OIDC present | |
|
||||
| Explicit app_config toggle | Separate `auth_mode` controlled from admin UI | (partial) |
|
||||
|
||||
**User's choice:** Free-text — OIDC config is set/stored in a later step, so wire it into the **admin UI**; give users the choice of which to use at login; **no local login UI exists today** so it must be built.
|
||||
**Notes:** Blend — admin-UI-configured OIDC, both methods offered at login, user chooses.
|
||||
|
||||
---
|
||||
|
||||
## Session Issuance
|
||||
|
||||
### Q1 — What backs a local login session?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Stateless signed JWT cookie | userId in signed httpOnly cookie; no DB table | ✓ (Claude) |
|
||||
| Server-side session table | sessions table for true revocation | |
|
||||
| You decide | — | ✓ |
|
||||
|
||||
**User's choice:** "You decide" + "do not pigeon-hole the user into Authelia — Bring Your Own Auth and Bring Your Own CalDAV provider."
|
||||
**Notes:** Claude chose stateless signed JWT cookie. User added the BYO-Auth architectural principle (generic OIDC, not Authelia-locked).
|
||||
|
||||
### Q2 — How far should the BYO-Auth abstraction go in Phase 19?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Local + generic OIDC | Two concrete methods, clean seam, no framework | ✓ |
|
||||
| Full pluggable framework | Provider registry/plugin (LDAP, magic-link, multi-OIDC) | |
|
||||
| Local only for now | Leave Authelia OIDC as-is, defer generic OIDC | |
|
||||
|
||||
**User's choice:** Local + generic OIDC (recommended).
|
||||
**Notes:** Clean internal seam now; full framework deferred.
|
||||
|
||||
---
|
||||
|
||||
## Password Hashing & Storage
|
||||
|
||||
### Q1 — Which password hashing approach?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| scrypt via node:crypto | Stdlib, zero-dep, no native build | ✓ |
|
||||
| argon2id (native dep) | OWASP top pick, needs native addon | |
|
||||
| bcrypt (bcryptjs) | Pure JS, older KDF | |
|
||||
|
||||
**User's choice:** scrypt via node:crypto (recommended).
|
||||
**Notes:** Honors the stack's no-native-dep stance.
|
||||
|
||||
### Q2 — Where to store username + hash?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Separate local_credentials table | Mirrors member_credentials; user-agnostic users row | ✓ (Claude) |
|
||||
| Columns on users | Add username + password_hash to users | |
|
||||
| You decide | — | ✓ |
|
||||
|
||||
**User's choice:** "You decide."
|
||||
**Notes:** Claude chose a separate `local_credentials` table — best fits the BYO-Auth per-user-method seam (one row can hold both a local credential and an OIDC binding).
|
||||
|
||||
---
|
||||
|
||||
## Accounts & OIDC-Link
|
||||
|
||||
### Q1 — How are local accounts created?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Admin creates members | Wizard creates first admin; admin creates rest | ✓ |
|
||||
| Admin creates + invite link | One-time set-password link | |
|
||||
| Open self-signup | Anyone can register | |
|
||||
|
||||
**User's choice:** Admin creates members.
|
||||
|
||||
### Q2 — Password change/reset?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Self-change + admin reset | Member self-change; admin resets lockouts | ✓ |
|
||||
| Self-change only | No admin reset | |
|
||||
| Admin reset only | No self-change | |
|
||||
|
||||
**User's choice:** Self-change + admin reset.
|
||||
**Notes:** No email reset (email out of scope).
|
||||
|
||||
### Q3 — After OIDC-link, what methods stay valid? (reformulated after clarification)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Both stay valid | Row holds local + OIDC; either logs in | |
|
||||
| OIDC primary, local fallback | Same data model, UI emphasis on OIDC | |
|
||||
| OIDC replaces local | Linking removes local credential | ✓ (per user) |
|
||||
|
||||
**User's choice:** Initially requested clarification; then chose **OIDC replaces local per user** — there can/should be OIDC-only users with no local creds. Raised the need for a break-glass path.
|
||||
**Notes:** Auth methods are per-user (presence of local_credentials row and/or OIDC binding). Break-glass need surfaced here.
|
||||
|
||||
### Q4 — Break-glass capability model?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Protected local admin (no new role model) | Initial admin, un-removable local cred | |
|
||||
| Operator-only account (member/operator split) | Strip member capability from break-glass | |
|
||||
| Let researcher scope it | Lock the requirement, defer the how | ✓ (twist) |
|
||||
|
||||
**User's choice:** Let researcher scope it — **with a twist: break-glass can be a CLI/console command or env override instead of a user**, removing the added-user/capability complexity.
|
||||
**Notes:** No new role/capability model; reuse `is_admin`. Recovery mechanism (not account) to be scoped by researcher.
|
||||
|
||||
---
|
||||
|
||||
## Testing & Dev-Bypass (added mid-discussion at user's request)
|
||||
|
||||
### Q1 — How should DEV_AUTH_BYPASS evolve?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Bypass stays + seed a real test login | Fast bypass for most specs; real form for login specs | |
|
||||
| Replace bypass with seeded auto-login | Harness logs in via real local flow | (user's lean) |
|
||||
| Bypass auto-issues a real local session | Bypass logs in seeded user, skips form | |
|
||||
|
||||
**User's choice:** Defer final determination to the **research agent**; user **leans toward "replace bypass with seeded auto-login."**
|
||||
**Notes:** Hard constraint — the seeded test login / dev-bypass **stays dev-only and never ships in the Docker/prod image** (Phase 16 IMG-01/02/03 gates apply).
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Local session backing → stateless signed JWT cookie (D-05).
|
||||
- Credential storage location → separate `local_credentials` table (D-09).
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Full pluggable auth-provider framework (registry/plugin; LDAP, magic-link, multi-OIDC) — future phase, counterpart of 999.1.
|
||||
- Member-vs-operator capability/role split — rejected in favor of CLI/env break-glass recovery.
|
||||
- Email-based password reset — out of project scope.
|
||||
Reference in New Issue
Block a user