Files
familysync/.planning/phases/19-local-auth-no-oidc-mode/19-CONTEXT.md
T

14 KiB

Phase 19: Local Auth (No-OIDC Mode) - Context

Gathered: 2026-06-16 Status: Ready for planning

## 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).

## 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 tableuser_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).
  • 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.

<canonical_refs>

Canonical References

Downstream agents MUST read these before planning or implementing.

Auth foundation this phase extends

  • apps/api/src/auth/user.tsupsertUser (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.tsdevAuthBypass() + 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 → devAuthBypassoidcConfigFallbackoidcAuthMiddlewarepersistSessionCookie); devBypassActive computed once at boot; assertNotDevBypassInProduction() boot guard. Local-login routes + middleware slot in here.
  • apps/api/src/routes/me.tsresolveUserId (dev-bypass c.get('user') first, else getAuth) + needsProviderSetup/isAdmin exposure. The user-resolution seam for all routes.
  • apps/api/src/db/schema.tsusers (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.tsassertNotDevBypassInProduction (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>

## 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.
## 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).

Phase: 19-local-auth-no-oidc-mode Context gathered: 2026-06-16