Skip to content

feat: replace custom session auth with FerrisKey OIDC (Auth.js v5)#11

Merged
asmuelle merged 1 commit into
mainfrom
feat/ferriskey-auth
Jul 1, 2026
Merged

feat: replace custom session auth with FerrisKey OIDC (Auth.js v5)#11
asmuelle merged 1 commit into
mainfrom
feat/ferriskey-auth

Conversation

@asmuelle

@asmuelle asmuelle commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Summary

Replaces the hand-rolled email "dev sign-in" + HMAC session cookie with real OIDC authentication against FerrisKey (a Rust, Keycloak-API-compatible IAM), via Auth.js v5.

Scope is authentication only (a deliberate decision): FerrisKey owns who you are; tenancy (workspace + owner/member role) stays in the local memberships table and is re-derived per request — preserving Product Invariant 8 and the append-only membership moat. The bridge is the local AppUser: each sign-in maps the verified OIDC identity to exactly one local user and pins that internal id on the session.

Route protection stays in the server layer (authed/layout.tsxresolveActiveWorkspace), not middleware — which also sidesteps Next 16's middleware.tsproxy.ts edge-runtime gotcha.

What changed

Identity bridge

  • app_user.external_subject (nullable, unique) + Drizzle migration 0006.
  • linkOrCreateUserBySubject on FlankStore (Memory + Drizzle): match by immutable IdP sub → else verified-email backfill of a pre-provisioned row → else JIT-create. A membership-less user fails closed to the "no workspace" screen.

Auth.js wiring (apps/web)

  • auth.ts — lazy-config custom OIDC provider against the realm discovery URL (PKCE; jwt/session callbacks pin the local user id; profile validated at the boundary).
  • app/api/auth/[...nextauth]/route.ts — handlers.
  • lib/auth/resolver.ts re-keyed to { userId }; session.ts rides on auth(). Deleted hand-rolled session-crypto.ts / secret.ts (+ tests).

UI — "Continue with FerrisKey" sign-in; Auth.js sign-out + workspace-hint clear; switchWorkspace fails closed before writing state.

Local infra — FerrisKey API/console/Postgres added to docker-compose.yml (profile auth; its PG on host 5434 to avoid the app's 5432 and the integration 5433); just ferriskey-{up,down,bootstrap}; scripts/ferriskey-bootstrap.ts (realm + confidential client + redirect URIs + demo user — grounded in FerrisKey v0.6.1's admin API/validators).

Env/docsAUTH_SECRET + FERRISKEY_* in .env.example; TOOLS.md, DESIGN.md, ROADMAP updated.

Security

Ran a security review of the auth surface. The one genuine account-takeover path — email-backfill linking with no email_verified check — is closed: emailVerified is threaded through ExternalIdentity, and an unverified email colliding with an existing account is rejected (IdentityConflictError), with a contract test on both stores. Also added: Auth.js error→sign-in routing, a session guard on switchWorkspace, baseline security headers (X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy, prod HSTS), and an admin-credential warning in .env.example.

Accepted follow-ups (robustness/UX, not escalations): RP-initiated FerrisKey logout (local sign-out only today); a transaction/row-lock around the link-or-create upsert (the unique constraint already fails closed on the rare race); a full nonce-based CSP.

Test plan

Verified locally:

  • just ci green — lint, format-check, typecheck, coverage (93%+, ≥80% gate), build, audit
  • 279 unit tests pass (incl. JIT-provisioning + anti-hijack contract tests)
  • 57 DB-backed integration tests pass against real Postgres (just test-integration)
  • Migration 0006 applies cleanly; just seed works with the new column
  • next build succeeds; /api/auth/[...nextauth] route registered

Not yet exercised (needs the running FerrisKey Docker stack + a browser):

  • Live OIDC handshake end-to-end: just ferriskey-up && just ferriskey-bootstrap → paste FERRISKEY_CLIENT_SECRET into .envjust dev → sign in → land on /authed with seeded workspace/role
  • Sign-out + protected-route redirect
  • New FerrisKey user with no membership → /auth/sign-in?e=no_workspace
  • Bootstrap script against a live FerrisKey admin API (console at :5555, admin/admin, is the source of truth if their API drifts)

🤖 Generated with Claude Code

Identity is now owned by FerrisKey (Keycloak-alternative IAM) over OIDC via
Auth.js v5. Scope is authentication only: workspace tenancy + owner/member role
stay in the local memberships table, re-derived per request (Invariant 8).

- core/db: add app_user.external_subject + linkOrCreateUserBySubject (memory +
  Drizzle), migration 0006. Match by IdP sub, else verified-email backfill, else
  JIT-create. Unverified-email collision is rejected (IdentityConflictError) to
  prevent account takeover.
- web: apps/web/auth.ts custom OIDC provider (PKCE, jwt/session callbacks pin the
  local user id) + [...nextauth] route. resolveWorkspace keyed by {userId};
  resolveActiveWorkspace rides on auth(). Remove hand-rolled session-crypto/secret.
- ui: "Continue with FerrisKey" sign-in; Auth.js sign-out + hint-cookie clear;
  switchWorkspace fails closed before writing the hint.
- infra: FerrisKey API/console/Postgres in docker-compose (profile auth, pg 5434);
  just ferriskey-up/down/bootstrap; scripts/ferriskey-bootstrap.ts.
- hardening: baseline security headers, Auth.js error->sign-in routing.
- env/docs: AUTH_SECRET + FERRISKEY_* ; TOOLS.md, DESIGN.md, ROADMAP updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
token,
json: { temporary: false, credential_type: 'password', value: DEMO_PASSWORD },
});
console.log(`✓ demo user ${DEMO_EMAIL} ready (password: ${DEMO_PASSWORD})`);
@asmuelle
asmuelle merged commit 995313d into main Jul 1, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants