diff --git a/.env.example b/.env.example index 2e85ebf..ff5ad97 100644 --- a/.env.example +++ b/.env.example @@ -36,8 +36,21 @@ FLANK_ALERT_FROM= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= -# Web session signing — HMAC key for the signed session cookie. Must be >= 32 bytes; REQUIRED in -# production (the app refuses to start without it). AUTH_SESSION_SECRET wins; NEXTAUTH_SECRET is a -# fallback. Generate one with: openssl rand -base64 48 -AUTH_SESSION_SECRET= -NEXTAUTH_SECRET= +# Auth.js (NextAuth v5) session/JWT encryption key. REQUIRED in production (the app refuses to start +# without it); auto-generated in dev. Generate one with: openssl rand -base64 48 +AUTH_SECRET= + +# FerrisKey OIDC (identity provider). Provision the local stack with: +# just ferriskey-up && just ferriskey-bootstrap (prints these values to paste here) +# Issuer is the API base; the realm is appended to form the OIDC issuer used for discovery. +FERRISKEY_ISSUER=http://localhost:3333 +FERRISKEY_REALM=flank +FERRISKEY_CLIENT_ID=flank-web +FERRISKEY_CLIENT_SECRET= + +# Admin credentials for the local FerrisKey console + bootstrap script. +# DEV ONLY — admin/admin is fine for localhost. A FerrisKey reachable beyond localhost MUST use a +# strong password: admin access can mint OIDC tokens for any realm user and bypass all app auth. +FERRISKEY_ADMIN_USERNAME=admin +FERRISKEY_ADMIN_PASSWORD=admin +FERRISKEY_ADMIN_EMAIL=admin@flank.local diff --git a/DESIGN.md b/DESIGN.md index 3efdc54..aadb342 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -81,10 +81,23 @@ All history tables are **append-only** (Invariant 5). Workspace-scoped throughou | **battlecard_section** | id, competitor_id, kind (why_we_win/landmines/pricing_counter/objections), version, content_md, claim_ids[], supersedes_id — append-only versions | | **alert** | id, workspace_id, delta_id, channel (slack/email/crm), payload, status (queued/delivered/failed), delivered_at | | **coverage_run** | id, workspace_id, period, sources_checked, fetch_failures, deltas_found, material_deltas, llm_cost_cents — feeds digest receipts and COGS metering | +| **app_user** | id, email, name, external_subject (FerrisKey OIDC `sub`, nullable — linked on first login), created_at — global identity, not workspace-scoped | +| **membership** | id, user_id, workspace_id, role (owner/member), created_at — the ONLY thing that confers tenancy (Invariant 8); mutable | pgvector lives on `snapshot.normalized_text` embeddings (changed-span similarity, "have we seen this repositioning before") — an enhancement, not on the M1 critical path. +### Authentication & tenancy origin + +Identity is delegated to **FerrisKey** (self-hosted, Keycloak-alternative IAM) over OIDC; the web +app is a relying party via **Auth.js v5** (`apps/web/auth.ts`). FerrisKey answers _who_ the user is; +it never carries _tenancy_. On each sign-in the verified identity is mapped to exactly one local +`app_user` (`linkOrCreateUserBySubject`, keyed by the immutable `sub`, email-backfilled for seed +rows), and that local id is pinned on the session. Every authed request then re-derives its +`workspaceId` + role from **live** `membership` rows (`resolveActiveWorkspace` → pure +`resolveWorkspace`), so a revoked grant takes effect immediately and a user with zero memberships is +fail-closed to the "no workspace" screen — Invariant 8 holds without trusting anything in the token. + ## Key Flows ### 1. Ingest & diff (per source, on cadence) diff --git a/TOOLS.md b/TOOLS.md index 6158c4c..c3363e2 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -2,21 +2,24 @@ ## just Recipes -| Recipe | What it does | When to run | -| ---------------- | -------------------------------------------------------- | --------------------------------------------------- | -| `just` | Lists all recipes | Orientation | -| `just setup` | `corepack enable` + `pnpm install` | First clone; after lockfile changes | -| `just dev` | `pnpm dev` — Next.js web + Inngest dev server/workers | Daily development | -| `just db-up` | `docker compose up -d postgres` (pgvector/pgvector:pg16) | Before `just migrate`, `just dev`, `just test` | -| `just db-down` | Stops the local Postgres container | Cleanup | -| `just migrate` | Applies Drizzle migrations to `DATABASE_URL` | After schema changes; after `db-up` on fresh volume | -| `just test` | `pnpm test` — Vitest across packages | TDD loop; before commit | -| `just e2e` | `pnpm e2e` — Playwright suite | Before merging UI/flow changes | -| `just lint` | `pnpm lint` — ESLint workspace-wide | Before commit | -| `just format` | `pnpm format` — Prettier write | When the hook didn't catch a file | -| `just typecheck` | `pnpm typecheck` — `tsc --noEmit` | Before commit | -| `just build` | `pnpm build` — production build all packages | Verifying release readiness | -| `just ci` | lint + typecheck + test + build | Mirror of GitHub Actions; run before pushing | +| Recipe | What it does | When to run | +| -------------------------- | -------------------------------------------------------- | --------------------------------------------------- | +| `just` | Lists all recipes | Orientation | +| `just setup` | `corepack enable` + `pnpm install` | First clone; after lockfile changes | +| `just dev` | `pnpm dev` — Next.js web + Inngest dev server/workers | Daily development | +| `just db-up` | `docker compose up -d postgres` (pgvector/pgvector:pg16) | Before `just migrate`, `just dev`, `just test` | +| `just db-down` | Stops the local Postgres container | Cleanup | +| `just ferriskey-up` | FerrisKey IAM stack (API :3333, console :5555, db :5434) | Before signing in locally | +| `just ferriskey-down` | Stops the FerrisKey IAM stack | Cleanup | +| `just ferriskey-bootstrap` | Realm + client + demo user; prints `FERRISKEY_*` env | Once after `ferriskey-up` | +| `just migrate` | Applies Drizzle migrations to `DATABASE_URL` | After schema changes; after `db-up` on fresh volume | +| `just test` | `pnpm test` — Vitest across packages | TDD loop; before commit | +| `just e2e` | `pnpm e2e` — Playwright suite | Before merging UI/flow changes | +| `just lint` | `pnpm lint` — ESLint workspace-wide | Before commit | +| `just format` | `pnpm format` — Prettier write | When the hook didn't catch a file | +| `just typecheck` | `pnpm typecheck` — `tsc --noEmit` | Before commit | +| `just build` | `pnpm build` — production build all packages | Verifying release readiness | +| `just ci` | lint + typecheck + test + build | Mirror of GitHub Actions; run before pushing | All recipes exit with a helpful message until the pnpm workspace exists (M0 in DESIGN.md). @@ -50,15 +53,39 @@ All recipes exit with a helpful message until the pnpm workspace exists (M0 in D | `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET` | Slack app delivery | | `RESEND_API_KEY` | Email digests | | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` | Billing (M3) | -| `NEXTAUTH_SECRET` (or equivalent) | Web session signing | +| `AUTH_SECRET` | Auth.js (NextAuth v5) session/JWT key | +| `FERRISKEY_ISSUER`, `FERRISKEY_REALM` | FerrisKey OIDC issuer base + realm (discovery) | +| `FERRISKEY_CLIENT_ID`, `FERRISKEY_CLIENT_SECRET` | Confidential OIDC client for the web app | +| `FERRISKEY_ADMIN_USERNAME/PASSWORD/EMAIL` | Local FerrisKey console + bootstrap (dev: admin) | No values in the repo, ever. `packages/core` exposes a startup validator that fails fast when a required var for the running surface is missing. Keep a `.env.example` with names only. +## Authentication (FerrisKey OIDC) + +Identity is owned by **FerrisKey** (self-hosted Keycloak-alternative IAM); the web app is an OIDC +relying party via **Auth.js v5**. Tenancy is NOT in the token — every request re-derives workspace + +- role from the local `memberships` table (Invariant 8). First-time logins are JIT-provisioned into + `app_user` and linked by the IdP `sub` (`linkOrCreateUserBySubject`); a user with no membership is + fail-closed to the "no workspace" screen. Local setup: + +``` +just ferriskey-up # API :3333, console :5555, its own Postgres :5434 +just ferriskey-bootstrap # realm `flank` + client `flank-web` + demo user; prints FERRISKEY_* env +# paste FERRISKEY_CLIENT_SECRET into .env, then: +just db-up && just migrate && just seed && just dev +``` + +Console: http://localhost:5555 (default `admin`/`admin`) is the source of truth if the bootstrap +script drifts from a future FerrisKey API. + ## Local Services - **Postgres 16 + pgvector** via `docker compose` (`pgvector/pgvector:pg16`), port 5432, started with `just db-up`. Used by dev, Vitest integration tests, and Drizzle migrations. +- **FerrisKey IAM** via `docker compose --profile auth` (`just ferriskey-up`): API `:3333`, + console `:5555`, dedicated Postgres `:5434`. The OIDC identity provider for sign-in. - **Inngest dev server** runs as part of `just dev` for local function execution/cron simulation. - S3 is mocked or pointed at a local MinIO container if snapshot tests need it (add to compose when M1 lands). diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..4df80d2 --- /dev/null +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,5 @@ +// FerrisKey OIDC handshake endpoints (authorize, callback, token, signout) handled by Auth.js. +// Runs on the Node runtime (default) — the jwt callback touches the Postgres-backed store. +import { handlers } from '../../../../auth'; + +export const { GET, POST } = handlers; diff --git a/apps/web/app/auth/sign-in/actions.ts b/apps/web/app/auth/sign-in/actions.ts index 682b395..e2535a8 100644 --- a/apps/web/app/auth/sign-in/actions.ts +++ b/apps/web/app/auth/sign-in/actions.ts @@ -1,22 +1,12 @@ 'use server'; -import { EmailSchema } from '@flank/core'; -import { redirect } from 'next/navigation'; -import { startSession } from '../../../lib/auth/session'; -import { getStore } from '../../../lib/store'; +import { signIn } from '../../../auth'; /** - * Dev sign-in: look up a user by email and mint a session. No password / OAuth / magic-link yet - * (deferred) — but the failure path is already constant: an unparseable or unknown email yields the - * SAME generic error, so this endpoint can never be used to enumerate which emails have accounts. + * Start the FerrisKey OIDC login. Auth.js redirects to the realm's authorization endpoint (PKCE), + * and the callback returns the user to `/authed`. No email/password is handled here — identity is + * owned entirely by FerrisKey. */ -export const signIn = async (formData: FormData): Promise => { - const parsed = EmailSchema.safeParse(formData.get('email')); - if (!parsed.success) redirect('/auth/sign-in?e=invalid'); - - const user = await getStore().findUserByEmail(parsed.data); - if (user === null) redirect('/auth/sign-in?e=invalid'); - - await startSession(user.id); - redirect('/authed'); +export const signInWithFerrisKey = async (): Promise => { + await signIn('ferriskey', { redirectTo: '/authed' }); }; diff --git a/apps/web/app/auth/sign-in/page.tsx b/apps/web/app/auth/sign-in/page.tsx index f32543e..ad3b86e 100644 --- a/apps/web/app/auth/sign-in/page.tsx +++ b/apps/web/app/auth/sign-in/page.tsx @@ -1,11 +1,11 @@ import type { Metadata } from 'next'; -import { signIn } from './actions'; +import { signInWithFerrisKey } from './actions'; export const metadata: Metadata = { title: 'Sign in — Flank' }; const MESSAGES: Readonly> = { - invalid: 'We could not sign you in with that email. Check it and try again.', no_workspace: 'That account has no workspace yet. Ask an owner to add you.', + oidc: 'Sign-in failed. Please try again.', }; export default async function SignInPage({ @@ -22,8 +22,8 @@ export default async function SignInPage({

Flank · competitor radar

Sign in

- Enter the email tied to your workspace. We resolve every request to a live membership — no - stale tenancy is ever trusted from the cookie. + Continue with FerrisKey to verify your identity. We resolve every request to a live + membership — no tenancy is ever trusted from the token.

{message !== undefined ? ( @@ -32,27 +32,13 @@ export default async function SignInPage({

) : null} -
- - +
-

- dev sign-in · password & SSO deferred to a later milestone -

+

single sign-on · identity managed by FerrisKey (OIDC)

); diff --git a/apps/web/app/authed/actions.ts b/apps/web/app/authed/actions.ts index d0f4e05..94e26bc 100644 --- a/apps/web/app/authed/actions.ts +++ b/apps/web/app/authed/actions.ts @@ -1,11 +1,21 @@ 'use server'; import { redirect } from 'next/navigation'; -import { endSession, setWorkspaceHint } from '../../lib/auth/session'; +import { signOut as authSignOut } from '../../auth'; +import { + clearWorkspaceHint, + resolveActiveWorkspace, + setWorkspaceHint, +} from '../../lib/auth/session'; +/** + * Sign out: drop the workspace-hint cookie, then clear the Auth.js session and return to sign-in. + * (Local session only — full RP-initiated FerrisKey logout via the realm end-session endpoint is a + * later enhancement; the next protected request already redirects to a fresh FerrisKey login.) + */ export const signOut = async (): Promise => { - await endSession(); - redirect('/auth/sign-in'); + await clearWorkspaceHint(); + await authSignOut({ redirectTo: '/auth/sign-in' }); }; /** @@ -14,6 +24,9 @@ export const signOut = async (): Promise => { * back to the first membership), so a forged id can never widen access. */ export const switchWorkspace = async (formData: FormData): Promise => { + // Fail closed before touching any state: an unauthenticated POST to this action redirects to + // sign-in instead of writing a hint cookie. + await resolveActiveWorkspace(); const workspaceId = formData.get('workspaceId'); if (typeof workspaceId === 'string' && workspaceId !== '') { await setWorkspaceHint(workspaceId); diff --git a/apps/web/auth.ts b/apps/web/auth.ts new file mode 100644 index 0000000..c99df74 --- /dev/null +++ b/apps/web/auth.ts @@ -0,0 +1,90 @@ +import { EmailSchema, type ExternalIdentity } from '@flank/core'; +import NextAuth, { type DefaultSession, type Profile } from 'next-auth'; +import { getStore } from './lib/store'; + +/** + * Auth.js (NextAuth v5) wired to FerrisKey as the sole identity provider. + * + * Scope is authentication ONLY: FerrisKey proves *who* the user is over OIDC; *tenancy* (which + * workspace, which role) is never carried in the token — it is re-derived per request from the + * local `memberships` table in {@link resolveActiveWorkspace} (Product Invariant 8). The bridge + * between the two is the local `AppUser`: on every sign-in we map the verified OIDC identity to + * exactly one local user via {@link FlankStore.linkOrCreateUserBySubject} and stash that local id + * on the token, so all downstream code keys off a stable internal id, not the raw IdP `sub`. + * + * Lazy config form (`NextAuth(() => ...)`) defers every env read to request time, mirroring the + * repo's deferred-validation pattern (see `lib/store.ts`) so `next build` never needs the secrets. + */ + +const requiredEnv = (key: string): string => { + const value = process.env[key]; + if (value === undefined || value === '') { + throw new Error(`${key} is required for FerrisKey OIDC (see TOOLS.md / .env.example)`); + } + return value; +}; + +/** Realm-scoped OIDC issuer; Auth.js discovers endpoints from its `.well-known/openid-configuration`. */ +const ferriskeyIssuer = (): string => + `${requiredEnv('FERRISKEY_ISSUER').replace(/\/$/, '')}/realms/${requiredEnv('FERRISKEY_REALM')}`; + +/** Validate the OIDC profile at the boundary (AGENTS.md: never trust external data). */ +const toIdentity = (profile: Profile | undefined): ExternalIdentity | null => { + if (profile === undefined) return null; + const subject = profile.sub; + const email = EmailSchema.safeParse(profile.email); + if (typeof subject !== 'string' || subject === '' || !email.success) return null; + const name = typeof profile.name === 'string' && profile.name !== '' ? profile.name : null; + // Only a verified email may adopt a pre-provisioned local account (anti-hijack); absent/false fails closed. + const emailVerified = profile.email_verified === true; + return { subject, email: email.data, emailVerified, name }; +}; + +export const { handlers, auth, signIn, signOut } = NextAuth(() => ({ + // Route Auth.js's built-in sign-in + error pages to our own surface, so an OIDC error (cancelled + // login, state mismatch) lands on the branded sign-in card instead of the default Auth.js page. + pages: { signIn: '/auth/sign-in', error: '/auth/sign-in' }, + providers: [ + { + id: 'ferriskey', + name: 'FerrisKey', + type: 'oidc', + issuer: ferriskeyIssuer(), + clientId: requiredEnv('FERRISKEY_CLIENT_ID'), + clientSecret: requiredEnv('FERRISKEY_CLIENT_SECRET'), + authorization: { params: { scope: 'openid email profile' } }, + checks: ['pkce', 'state'], + }, + ], + callbacks: { + // First leg of a sign-in carries `profile`; provision/link the local user and pin its id. + // Throwing here fails the sign-in closed — we never establish a session we can't attribute. + async jwt({ token, profile }) { + if (profile !== undefined) { + const identity = toIdentity(profile); + if (identity === null) { + throw new Error('FerrisKey did not return a usable subject + email'); + } + const user = await getStore().linkOrCreateUserBySubject(identity); + token.uid = user.id; + } + return token; + }, + async session({ session, token }) { + if (typeof token.uid === 'string') { + session.user.id = token.uid; + } + return session; + }, + }, +})); + +declare module 'next-auth' { + /** The local AppUser id (not the IdP `sub`) every server surface reads. */ + interface Session { + user: { id: string } & DefaultSession['user']; + } +} + +// JWT carries the local user id as `token.uid`. Auth.js's JWT extends Record, so +// no module augmentation is needed — reads are narrowed with `typeof token.uid === 'string'`. diff --git a/apps/web/lib/auth/resolver.test.ts b/apps/web/lib/auth/resolver.test.ts index 17b441f..fea188f 100644 --- a/apps/web/lib/auth/resolver.test.ts +++ b/apps/web/lib/auth/resolver.test.ts @@ -1,11 +1,8 @@ import { MemoryFlankStore } from '@flank/pipeline'; import { beforeEach, describe, expect, it } from 'vitest'; import { resolveWorkspace } from './resolver'; -import { signSession } from './session-crypto'; -const SECRET = 'resolver-test-secret-resolver-test-secret'; const NOW = Date.UTC(2026, 5, 20, 12); -const FUTURE = NOW + 60_000; const seeded = async (): Promise => { const store = new MemoryFlankStore(); @@ -15,6 +12,7 @@ const seeded = async (): Promise => { id: 'u-1', email: 'lead@acme.test', name: 'Lead', + externalSubject: 'fk-sub-1', createdAt: new Date(NOW - 1_000), }); return store; @@ -29,97 +27,45 @@ const grant = (store: MemoryFlankStore, id: string, workspaceId: string, created createdAt: new Date(createdAt), }); -const token = (uid = 'u-1', exp = FUTURE) => signSession({ uid, exp }, SECRET); - describe('resolveWorkspace', () => { let store: MemoryFlankStore; beforeEach(async () => { store = await seeded(); }); - it('reports no_session when the cookie is absent', async () => { - const result = await resolveWorkspace({ - token: null, - workspaceHint: null, - secret: SECRET, - store, - nowMs: NOW, - }); - expect(result).toEqual({ ok: false, reason: 'no_session' }); - }); - - it('reports no_session for a token signed with the wrong secret', async () => { - const forged = signSession( - { uid: 'u-1', exp: FUTURE }, - 'a-totally-different-secret-aaaaaaaaaa', - ); - const result = await resolveWorkspace({ - token: forged, - workspaceHint: null, - secret: SECRET, - store, - nowMs: NOW, - }); + it('reports no_session when the request is unauthenticated (no user id)', async () => { + const result = await resolveWorkspace({ userId: null, workspaceHint: null, store }); expect(result).toEqual({ ok: false, reason: 'no_session' }); }); - it('reports no_session for an expired token', async () => { - const result = await resolveWorkspace({ - token: token('u-1', NOW - 1), - workspaceHint: null, - secret: SECRET, - store, - nowMs: NOW, - }); - expect(result).toEqual({ ok: false, reason: 'no_session' }); + it('reports no_workspace when the authenticated user has no memberships', async () => { + const result = await resolveWorkspace({ userId: 'u-1', workspaceHint: null, store }); + expect(result).toEqual({ ok: false, reason: 'no_workspace' }); }); - it('reports no_workspace when the verified user has no memberships', async () => { - const result = await resolveWorkspace({ - token: token(), - workspaceHint: null, - secret: SECRET, - store, - nowMs: NOW, - }); + it('reports no_workspace for an authenticated user that does not exist locally', async () => { + // A freshly provisioned IdP identity with no grant yet — fail closed, never widen access. + const result = await resolveWorkspace({ userId: 'u-unknown', workspaceHint: null, store }); expect(result).toEqual({ ok: false, reason: 'no_workspace' }); }); it('resolves the only membership with no hint', async () => { await grant(store, 'm-a', 'ws-a', NOW - 500); - const result = await resolveWorkspace({ - token: token(), - workspaceHint: null, - secret: SECRET, - store, - nowMs: NOW, - }); + const result = await resolveWorkspace({ userId: 'u-1', workspaceHint: null, store }); expect(result).toMatchObject({ ok: true, userId: 'u-1', workspaceId: 'ws-a', role: 'member' }); }); it('honors a hint that matches a live membership', async () => { await grant(store, 'm-a', 'ws-a', NOW - 500); // earliest -> default await grant(store, 'm-b', 'ws-b', NOW - 100); - const result = await resolveWorkspace({ - token: token(), - workspaceHint: 'ws-b', - secret: SECRET, - store, - nowMs: NOW, - }); + const result = await resolveWorkspace({ userId: 'u-1', workspaceHint: 'ws-b', store }); expect(result).toMatchObject({ ok: true, workspaceId: 'ws-b' }); }); it('falls back to the first membership when the hint is not a member workspace', async () => { await grant(store, 'm-a', 'ws-a', NOW - 500); await grant(store, 'm-b', 'ws-b', NOW - 100); - const result = await resolveWorkspace({ - token: token(), - workspaceHint: 'ws-not-mine', - secret: SECRET, - store, - nowMs: NOW, - }); + const result = await resolveWorkspace({ userId: 'u-1', workspaceHint: 'ws-not-mine', store }); // hint cannot widen access: unmatched -> earliest membership (ws-a) expect(result).toMatchObject({ ok: true, workspaceId: 'ws-a' }); }); diff --git a/apps/web/lib/auth/resolver.ts b/apps/web/lib/auth/resolver.ts index 3010d64..a2742fc 100644 --- a/apps/web/lib/auth/resolver.ts +++ b/apps/web/lib/auth/resolver.ts @@ -1,10 +1,10 @@ import type { FlankStore, MembershipRole, MembershipWithWorkspace } from '@flank/core'; -import { verifySession } from './session-crypto'; /** - * The fully-resolved request principal: a verified user, the one workspace this request acts in, and - * the role the user holds there. Tenancy is re-derived from LIVE memberships every request — never - * trusted from the cookie — so a revoked membership takes effect immediately (fail-closed). + * The fully-resolved request principal: an authenticated user, the one workspace this request acts + * in, and the role the user holds there. Identity comes from the Auth.js / FerrisKey session, but + * tenancy is re-derived from LIVE memberships every request — never trusted from the token — so a + * revoked membership takes effect immediately (fail-closed, Product Invariant 8). */ export type WorkspaceResolution = | { @@ -17,30 +17,26 @@ export type WorkspaceResolution = | { readonly ok: false; readonly reason: 'no_session' | 'no_workspace' }; export interface ResolveWorkspaceInput { - /** Raw signed cookie value, or null when the cookie is absent. */ - readonly token: string | null; + /** Local AppUser id from the authenticated Auth.js session, or null when unauthenticated. */ + readonly userId: string | null; /** Preferred workspace id from a non-authoritative hint cookie, or null. */ readonly workspaceHint: string | null; - readonly secret: string; readonly store: FlankStore; - readonly nowMs: number; } /** - * Resolve a request to an authorized workspace. Pure over an injected store, so it is unit-testable - * without Next wiring. A bad/absent/expired cookie is `no_session`; a valid user with zero live - * memberships is `no_workspace`. The hint only ever SELECTS among memberships the user actually has — - * it can never widen access — and an unmatched hint falls back to the first membership. + * Resolve an authenticated user id to an authorized workspace. Pure over an injected store, so it is + * unit-testable without Next/Auth.js wiring. A null user id is `no_session`; an authenticated user + * with zero live memberships is `no_workspace`. The hint only ever SELECTS among memberships the + * user actually has — it can never widen access — and an unmatched hint falls back to the first + * membership (memberships arrive ordered by createdAt, id). */ export const resolveWorkspace = async ( input: ResolveWorkspaceInput, ): Promise => { - if (input.token === null) return { ok: false, reason: 'no_session' }; + if (input.userId === null) return { ok: false, reason: 'no_session' }; - const verified = verifySession(input.token, input.secret, input.nowMs); - if (!verified.ok) return { ok: false, reason: 'no_session' }; - - const memberships = await input.store.listMembershipsForUser(verified.principal.uid); + const memberships = await input.store.listMembershipsForUser(input.userId); if (memberships.length === 0) return { ok: false, reason: 'no_workspace' }; const hinted = @@ -51,7 +47,7 @@ export const resolveWorkspace = async ( return { ok: true, - userId: verified.principal.uid, + userId: input.userId, workspaceId: active.workspace.id, role: active.membership.role, memberships, diff --git a/apps/web/lib/auth/secret.test.ts b/apps/web/lib/auth/secret.test.ts deleted file mode 100644 index b99f8ad..0000000 --- a/apps/web/lib/auth/secret.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getSessionSecret } from './secret'; - -const STRONG = 'a'.repeat(32); - -describe('getSessionSecret', () => { - it('returns a provided AUTH_SESSION_SECRET when long enough', () => { - expect(getSessionSecret({ AUTH_SESSION_SECRET: STRONG })).toEqual({ - value: STRONG, - isDevDefault: false, - }); - }); - - it('falls back to NEXTAUTH_SECRET when AUTH_SESSION_SECRET is absent', () => { - const result = getSessionSecret({ NEXTAUTH_SECRET: STRONG }); - expect(result.value).toBe(STRONG); - expect(result.isDevDefault).toBe(false); - }); - - it('prefers AUTH_SESSION_SECRET over NEXTAUTH_SECRET', () => { - const result = getSessionSecret({ - AUTH_SESSION_SECRET: STRONG, - NEXTAUTH_SECRET: 'b'.repeat(40), - }); - expect(result.value).toBe(STRONG); - }); - - it('rejects a too-short provided secret in every environment', () => { - expect(() => getSessionSecret({ AUTH_SESSION_SECRET: 'short' })).toThrow(/at least 32 bytes/); - expect(() => - getSessionSecret({ AUTH_SESSION_SECRET: 'short', NODE_ENV: 'production' }), - ).toThrow(/at least 32 bytes/); - }); - - it('treats an empty-string secret as absent', () => { - const result = getSessionSecret({ AUTH_SESSION_SECRET: '', NODE_ENV: 'development' }); - expect(result.isDevDefault).toBe(true); - }); - - it('throws when no secret is set in production', () => { - expect(() => getSessionSecret({ NODE_ENV: 'production' })).toThrow(/required in production/); - }); - - it('falls back to an obvious insecure default outside production', () => { - const result = getSessionSecret({ NODE_ENV: 'development' }); - expect(result.isDevDefault).toBe(true); - expect(result.value.length).toBeGreaterThanOrEqual(32); - expect(result.value).toMatch(/insecure/); - }); -}); diff --git a/apps/web/lib/auth/secret.ts b/apps/web/lib/auth/secret.ts deleted file mode 100644 index 8063d16..0000000 --- a/apps/web/lib/auth/secret.ts +++ /dev/null @@ -1,30 +0,0 @@ -const MIN_SECRET_BYTES = 32; -/** An obvious, never-confusable-with-real insecure default for non-production only. */ -const DEV_DEFAULT_SECRET = 'flank-dev-insecure-session-secret-do-not-ship-0000'; - -export interface SessionSecret { - readonly value: string; - /** True when the insecure dev fallback is in use — callers must NOT set Secure cookies. */ - readonly isDevDefault: boolean; -} - -/** - * Resolve and VALIDATE the session HMAC secret (pure; env injected). A provided secret is rejected in - * ALL environments if shorter than 32 bytes (a weak secret forges every session). An absent secret - * throws in production and falls back to an obvious insecure default elsewhere. - */ -export const getSessionSecret = ( - env: Readonly> = process.env, -): SessionSecret => { - const provided = env.AUTH_SESSION_SECRET ?? env.NEXTAUTH_SECRET; - if (provided !== undefined && provided !== '') { - if (Buffer.byteLength(provided, 'utf8') < MIN_SECRET_BYTES) { - throw new Error(`session secret must be at least ${MIN_SECRET_BYTES} bytes`); - } - return Object.freeze({ value: provided, isDevDefault: false }); - } - if (env.NODE_ENV === 'production') { - throw new Error('AUTH_SESSION_SECRET (or NEXTAUTH_SECRET) is required in production'); - } - return Object.freeze({ value: DEV_DEFAULT_SECRET, isDevDefault: true }); -}; diff --git a/apps/web/lib/auth/session-crypto.test.ts b/apps/web/lib/auth/session-crypto.test.ts deleted file mode 100644 index 25db0f2..0000000 --- a/apps/web/lib/auth/session-crypto.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { createHmac } from 'node:crypto'; -import { describe, expect, it } from 'vitest'; -import { signSession, verifySession, SESSION_VERSION } from './session-crypto'; - -const SECRET = 'a'.repeat(32); -const NOW = 1_900_000_000_000; -const future = NOW + 60_000; - -describe('session crypto', () => { - it('round-trips a signed session', () => { - const token = signSession({ uid: 'u-1', exp: future }, SECRET); - const result = verifySession(token, SECRET, NOW); - expect(result).toEqual({ - ok: true, - principal: { v: SESSION_VERSION, uid: 'u-1', exp: future }, - }); - }); - - it('rejects a token signed with a different secret', () => { - const token = signSession({ uid: 'u-1', exp: future }, SECRET); - expect(verifySession(token, 'b'.repeat(32), NOW)).toEqual({ - ok: false, - reason: 'bad_signature', - }); - }); - - it('rejects a tampered payload (signature no longer matches)', () => { - const token = signSession({ uid: 'u-1', exp: future }, SECRET); - const [, sig] = token.split('.'); - const forged = `${Buffer.from(JSON.stringify({ v: 1, uid: 'admin', exp: future })).toString('base64url')}.${sig}`; - expect(verifySession(forged, SECRET, NOW).ok).toBe(false); - }); - - it('rejects an expired token', () => { - const token = signSession({ uid: 'u-1', exp: NOW - 1 }, SECRET); - expect(verifySession(token, SECRET, NOW)).toEqual({ ok: false, reason: 'expired' }); - }); - - it('rejects a wrong version', () => { - const payload = Buffer.from(JSON.stringify({ v: 99, uid: 'u-1', exp: future })).toString( - 'base64url', - ); - // sign manually with a valid mac so the signature passes but the version is wrong - const sig = createHmac('sha256', SECRET).update(payload).digest('base64url'); - expect(verifySession(`${payload}.${sig}`, SECRET, NOW)).toEqual({ - ok: false, - reason: 'bad_version', - }); - }); - - it('rejects malformed tokens without throwing', () => { - for (const bad of ['', 'nodot', 'a.b.c', '.sig', 'payload.', 'not%base64.x']) { - expect(verifySession(bad, SECRET, NOW).ok).toBe(false); - } - }); -}); diff --git a/apps/web/lib/auth/session-crypto.ts b/apps/web/lib/auth/session-crypto.ts deleted file mode 100644 index a2f7d0e..0000000 --- a/apps/web/lib/auth/session-crypto.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; - -/** Bump to invalidate every existing session (e.g. payload-shape change). */ -export const SESSION_VERSION = 1; - -/** The signed session payload — carries ONLY identity + expiry, NEVER tenancy (re-derived per request). */ -export interface SessionPrincipal { - readonly v: number; - readonly uid: string; - readonly exp: number; // epoch milliseconds -} - -export type SessionVerifyResult = - | { readonly ok: true; readonly principal: SessionPrincipal } - | { - readonly ok: false; - readonly reason: 'malformed' | 'bad_signature' | 'expired' | 'bad_version'; - }; - -const encode = (text: string): string => Buffer.from(text, 'utf8').toString('base64url'); -const decode = (b64: string): string => Buffer.from(b64, 'base64url').toString('utf8'); - -/** HMAC-SHA256 over the EXACT payload string (the base64url text before the dot), not re-serialized - * JSON — so key order / whitespace / unicode escaping can never desync sign and verify. */ -const mac = (payloadB64: string, secret: string): string => - createHmac('sha256', secret).update(payloadB64).digest('base64url'); - -/** Mint `payloadB64.sigB64`. Total — always returns a token. */ -export const signSession = ( - principal: { readonly uid: string; readonly exp: number }, - secret: string, -): string => { - const payload: SessionPrincipal = { v: SESSION_VERSION, uid: principal.uid, exp: principal.exp }; - const payloadB64 = encode(JSON.stringify(payload)); - return `${payloadB64}.${mac(payloadB64, secret)}`; -}; - -const isPrincipal = (value: unknown): value is SessionPrincipal => { - if (typeof value !== 'object' || value === null) return false; - const candidate = value as Record; - return ( - typeof candidate.v === 'number' && - typeof candidate.uid === 'string' && - candidate.uid !== '' && - typeof candidate.exp === 'number' - ); -}; - -/** - * Verify a session token. NEVER throws — returns a discriminated result. Signature is checked first - * in constant time (length mismatch fails before {@link timingSafeEqual}); only then is the payload - * trusted enough to parse, version-check, and expiry-check. - */ -export const verifySession = ( - token: string, - secret: string, - nowMs: number = Date.now(), -): SessionVerifyResult => { - const parts = token.split('.'); - if (parts.length !== 2 || parts[0] === '' || parts[1] === '') { - return { ok: false, reason: 'malformed' }; - } - const [payloadB64, sigB64] = parts; - const actual = Buffer.from(sigB64, 'utf8'); - const expected = Buffer.from(mac(payloadB64, secret), 'utf8'); - if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { - return { ok: false, reason: 'bad_signature' }; - } - - let parsed: unknown; - try { - parsed = JSON.parse(decode(payloadB64)); - } catch { - return { ok: false, reason: 'malformed' }; - } - if (!isPrincipal(parsed)) return { ok: false, reason: 'malformed' }; - if (parsed.v !== SESSION_VERSION) return { ok: false, reason: 'bad_version' }; - if (parsed.exp <= nowMs) return { ok: false, reason: 'expired' }; - return { ok: true, principal: parsed }; -}; diff --git a/apps/web/lib/auth/session.ts b/apps/web/lib/auth/session.ts index f01ce40..c084cff 100644 --- a/apps/web/lib/auth/session.ts +++ b/apps/web/lib/auth/session.ts @@ -3,15 +3,13 @@ import type { MembershipRole, MembershipWithWorkspace } from '@flank/core'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { cache } from 'react'; +import { auth } from '../../auth'; import { getStore } from '../store'; import { resolveWorkspace } from './resolver'; -import { getSessionSecret } from './secret'; -import { signSession } from './session-crypto'; -export const SESSION_COOKIE = 'flank_session'; /** Non-authoritative: only ever SELECTS among the user's live memberships (see resolveWorkspace). */ export const WORKSPACE_HINT_COOKIE = 'flank_ws'; -const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const HINT_TTL_MS = 180 * 24 * 60 * 60 * 1000; // 180 days — a UI preference, not a credential const SIGN_IN_PATH = '/auth/sign-in'; export interface ActiveWorkspace { @@ -21,24 +19,30 @@ export interface ActiveWorkspace { readonly memberships: readonly MembershipWithWorkspace[]; } -// HttpOnly + SameSite=Lax + signed cookie. Secure tracks "is this a real secret": a real secret -// implies a deployed (HTTPS) origin, while the insecure dev default must work over plain-http localhost. -const cookieOptions = (secure: boolean) => - ({ httpOnly: true, sameSite: 'lax', secure, path: '/', maxAge: SESSION_TTL_MS / 1000 }) as const; +// The hint is a plain (unsigned) preference cookie — it carries no authority, so it never needs a +// signature. Secure tracks deployment: HTTPS in production, plain-http localhost in dev. +const hintCookieOptions = () => + ({ + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: HINT_TTL_MS / 1000, + }) as const; /** - * Resolve the request's active workspace, or redirect to sign-in. `cache` dedupes the cookie read + - * membership query across every Server Component in a single render. Tenancy is re-derived from live - * memberships each request, so this is the single trusted gate every authed surface calls. + * Resolve the request's active workspace, or redirect to sign-in. Identity comes from the Auth.js / + * FerrisKey session; tenancy is re-derived from live memberships. `cache` dedupes the session read + + * membership query across every Server Component in a single render. This is the single trusted gate + * every authed surface calls. */ export const resolveActiveWorkspace = cache(async (): Promise => { + const session = await auth(); const jar = await cookies(); const result = await resolveWorkspace({ - token: jar.get(SESSION_COOKIE)?.value ?? null, + userId: session?.user?.id ?? null, workspaceHint: jar.get(WORKSPACE_HINT_COOKIE)?.value ?? null, - secret: getSessionSecret().value, store: getStore(), - nowMs: Date.now(), }); if (!result.ok) { redirect(result.reason === 'no_workspace' ? `${SIGN_IN_PATH}?e=no_workspace` : SIGN_IN_PATH); @@ -46,24 +50,14 @@ export const resolveActiveWorkspace = cache(async (): Promise = return result; }); -/** Mint + set the signed session cookie for a user (called from the sign-in Server Action). */ -export const startSession = async (userId: string): Promise => { - const secret = getSessionSecret(); - const token = signSession({ uid: userId, exp: Date.now() + SESSION_TTL_MS }, secret.value); +/** Persist the user's preferred workspace; only ever selects among live memberships at resolve time. */ +export const setWorkspaceHint = async (workspaceId: string): Promise => { const jar = await cookies(); - jar.set(SESSION_COOKIE, token, cookieOptions(!secret.isDevDefault)); + jar.set(WORKSPACE_HINT_COOKIE, workspaceId, hintCookieOptions()); }; -/** Clear session + workspace-hint cookies (sign-out). */ -export const endSession = async (): Promise => { +/** Drop the workspace-hint cookie (called on sign-out, alongside Auth.js signOut). */ +export const clearWorkspaceHint = async (): Promise => { const jar = await cookies(); - jar.delete(SESSION_COOKIE); jar.delete(WORKSPACE_HINT_COOKIE); }; - -/** Persist the user's preferred workspace; only ever selects among live memberships at resolve time. */ -export const setWorkspaceHint = async (workspaceId: string): Promise => { - const secret = getSessionSecret(); - const jar = await cookies(); - jar.set(WORKSPACE_HINT_COOKIE, workspaceId, cookieOptions(!secret.isDevDefault)); -}; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 8d367b8..6403bff 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -3,8 +3,30 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { // Workspace packages export TypeScript sources directly. transpilePackages: ['@flank/core', '@flank/db', '@flank/pipeline'], + // Allow loading the dev server over the LAN (e.g. testing on a phone) without + // tripping Next's cross-origin dev-resource guard. + allowedDevOrigins: ['192.168.0.86'], // next 16 removed the built-in `eslint` build integration; linting runs at the workspace root // (`just lint`) instead, so there is nothing to configure here. + + // Baseline security headers on every response. Deliberately conservative: clickjacking, MIME + // sniffing, referrer leakage, and (in production) transport downgrade. A full nonce-based CSP is + // a follow-up — it needs per-surface testing against the OIDC redirect + Inngest routes. + async headers() { + const securityHeaders = [ + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + ]; + if (process.env.NODE_ENV === 'production') { + securityHeaders.push({ + key: 'Strict-Transport-Security', + value: 'max-age=31536000; includeSubDomains; preload', + }); + } + return [{ source: '/:path*', headers: securityHeaders }]; + }, }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index d4e39d1..b39fce7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@flank/pipeline": "workspace:*", "inngest": "^4.7.0", "next": "^16.2.9", + "next-auth": "5.0.0-beta.31", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/docker-compose.yml b/docker-compose.yml index c396ce0..ec5e2c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,5 +18,76 @@ services: timeout: 5s retries: 10 + # --- FerrisKey IAM (OIDC identity provider) — started via `just ferriskey-up` (profile: auth) --- + # Kept off the default profile so `just db-up` (app Postgres only) stays lean. Its own Postgres + # uses host port 5434 to avoid the app DB (5432) and the integration-test DB (5433). + ferriskey-db: + image: postgres:18.2 + container_name: flank-ferriskey-db + profiles: ['auth'] + environment: + POSTGRES_USER: ferriskey + POSTGRES_PASSWORD: ferriskey + POSTGRES_DB: ferriskey + PGUSER: ferriskey + ports: + - '5434:5432' + volumes: + - ferriskey-pgdata:/var/lib/postgresql + healthcheck: + test: ['CMD-SHELL', 'pg_isready -d ferriskey'] + interval: 10s + timeout: 10s + retries: 5 + start_period: 30s + + # One-shot: apply FerrisKey's own SQLx migrations, then exit. The API waits on its success. + ferriskey-migrations: + image: ghcr.io/ferriskey/ferriskey-api + container_name: flank-ferriskey-migrations + profiles: ['auth'] + entrypoint: ['sqlx'] + command: ['migrate', 'run', '--source', '/usr/local/src/ferriskey/migrations'] + environment: + DATABASE_URL: postgresql://ferriskey:ferriskey@ferriskey-db/ferriskey + restart: 'no' + depends_on: + ferriskey-db: + condition: service_healthy + + ferriskey: + image: ghcr.io/ferriskey/ferriskey-api + container_name: flank-ferriskey + profiles: ['auth'] + environment: + DATABASE_HOST: ferriskey-db + DATABASE_NAME: ferriskey + DATABASE_USER: ferriskey + DATABASE_PASSWORD: ferriskey + # Console origin + the Flank web app origin (OIDC browser redirects originate here). + ALLOWED_ORIGINS: http://localhost:5555,http://localhost:3000 + WEBAPP_URL: http://localhost:5555 + ADMIN_USERNAME: ${FERRISKEY_ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${FERRISKEY_ADMIN_PASSWORD:-admin} + ADMIN_EMAIL: ${FERRISKEY_ADMIN_EMAIL:-admin@flank.local} + ports: + - '3333:3333' + depends_on: + ferriskey-migrations: + condition: service_completed_successfully + + # Admin console (http://localhost:5555, default admin/admin) — manage realms/clients/users. + ferriskey-console: + image: ghcr.io/ferriskey/ferriskey-webapp + container_name: flank-ferriskey-console + profiles: ['auth'] + environment: + API_URL: http://localhost:3333 + ports: + - '5555:80' + depends_on: + - ferriskey + volumes: flank-pgdata: + ferriskey-pgdata: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f69030d..f2a8509 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -103,10 +103,10 @@ Split into two PRs (keystone stays small and invariant-critical): ### M1-hardening — Auth & tenancy origin -| # | Task | Effort | Depends on | -| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------- | -| 1 | `users` + `memberships` tables + a Next 15 App Router auth/session layer resolving each request to an authorized `workspaceId` | L | store (#4 above) | -| 2 | Zod-parsed runtime env module validated at process start (`ANTHROPIC_API_KEY`, signing/API secrets) for app + worker entrypoints | S | — (parallel) | +| # | Task | Effort | Depends on | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------- | +| 1 | `users` + `memberships` tables + a Next App Router auth/session layer resolving each request to an authorized `workspaceId`. **Done — identity delegated to FerrisKey (OIDC) via Auth.js v5; tenancy still re-derived from live `memberships` locally (Invariant 8). `app_user.external_subject` links the IdP `sub`.** | L | store (#4 above) | +| 2 | Zod-parsed runtime env module validated at process start (`ANTHROPIC_API_KEY`, signing/API secrets) for app + worker entrypoints | S | — (parallel) | ### M1-hardening — Real fetch + schedule runtime diff --git a/justfile b/justfile index 5d2290f..e41589d 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,11 @@ # Flank — living competitor radar. Run `just` to list recipes. # All recipes guard against the not-yet-bootstrapped state (no package.json, see DESIGN.md M0). +# Load the local .env (gitignored) into every recipe's environment. Nothing else auto-loads it: +# next dev runs in apps/web/ and the tsx scripts don't import dotenv, so without this DATABASE_URL +# (and the other vars in .env.example) never reach the running surface. Falls back silently if absent. +set dotenv-load := true + # List available recipes default: @just --list @@ -43,6 +48,19 @@ db-down: fi docker compose down +# Start the FerrisKey IAM stack (API :3333, console :5555, its own Postgres :5434) — the OIDC IdP +ferriskey-up: + docker compose --profile auth up -d ferriskey ferriskey-console + +# Stop the FerrisKey IAM stack (leaves the app Postgres running) +ferriskey-down: + docker compose --profile auth down + +# Provision FerrisKey for Flank: realm + confidential client + redirect URIs + demo user. +# Prints the FERRISKEY_* env values to paste into .env. Re-runnable. +ferriskey-bootstrap: + pnpm ferriskey:bootstrap + # Apply Drizzle migrations to DATABASE_URL migrate: _bootstrapped pnpm migrate diff --git a/package.json b/package.json index a3396d8..53f929c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "build": "pnpm --filter @flank/web build", "migrate": "pnpm --filter @flank/db migrate", "seed": "tsx packages/db/scripts/seed.ts", + "ferriskey:bootstrap": "tsx scripts/ferriskey-bootstrap.ts", "eval:triage": "tsx scripts/eval-triage.ts" }, "dependencies": { diff --git a/packages/core/src/entities.ts b/packages/core/src/entities.ts index 8742821..9f682f0 100644 --- a/packages/core/src/entities.ts +++ b/packages/core/src/entities.ts @@ -179,9 +179,29 @@ export interface AppUser { readonly id: string; readonly email: string; readonly name: string | null; + /** + * Stable subject (`sub`) of the external identity provider (FerrisKey OIDC), or null for + * pre-OIDC/seed rows not yet linked. Linking by this immutable id — not email — keeps a user's + * local identity stable across an IdP email change. + */ + readonly externalSubject: string | null; readonly createdAt: Date; } +/** + * An identity arriving from the OIDC provider (FerrisKey), validated at the auth callback boundary. + * Email is normalized; subject is the IdP's immutable `sub` claim. `emailVerified` reflects the + * `email_verified` claim — it gates the email-backfill linking path (see linkOrCreateUserBySubject): + * an unverified email must never adopt a pre-existing local account, or anyone who can register that + * address at the IdP could hijack the workspace it belongs to. + */ +export interface ExternalIdentity { + readonly subject: string; + readonly email: string; + readonly emailVerified: boolean; + readonly name: string | null; +} + /** Grants a user access to a workspace with a role — the only thing that confers tenancy. */ export interface Membership { readonly id: string; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index e32b5fa..cfab730 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -12,6 +12,7 @@ import type { DeltaState, DossierSection, DossierSectionKind, + ExternalIdentity, Membership, Snapshot, Source, @@ -52,6 +53,17 @@ export class CrossTenantError extends Error { } } +/** + * An unverified OIDC email collided with an existing local account during identity linking. Thrown + * (never linked) to prevent account takeover: only a verified email may adopt a pre-provisioned user. + */ +export class IdentityConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'IdentityConflictError'; + } +} + /** * Structural delta state machine: which states may follow which. The pricing-confirmation * refinement (Invariant 3) is `triageClass`-dependent and therefore cannot live in this static @@ -280,6 +292,16 @@ export interface FlankStore { // --- Identity & membership (M2 auth) --- seedUser(user: AppUser): Promise; seedMembership(membership: Membership): Promise; + /** + * Resolve a verified OIDC identity (FerrisKey) to the one local {@link AppUser}, creating or + * linking exactly one row — the JIT-provisioning seam called from the auth callback. Match order: + * (1) by immutable `externalSubject`; (2) by normalized email — but ONLY when `emailVerified`, + * backfilling the subject so a pre-OIDC/seed row adopts its IdP identity on first login (an + * unverified email colliding with an existing account is rejected, never linked — anti-hijack); + * (3) else create a fresh user. Never grants a workspace — a brand-new user has zero memberships + * and fails closed at `resolveWorkspace`. + */ + linkOrCreateUserBySubject(identity: ExternalIdentity, createdAt?: Date): Promise; /** Look up a user by (normalized) email; null if none. Identity is global, not workspace-scoped. */ findUserByEmail(email: string): Promise; getUserById(userId: string): Promise; diff --git a/packages/db/drizzle/0006_link_external_subject.sql b/packages/db/drizzle/0006_link_external_subject.sql new file mode 100644 index 0000000..ff81831 --- /dev/null +++ b/packages/db/drizzle/0006_link_external_subject.sql @@ -0,0 +1,4 @@ +-- Link a local app_user to its external IdP identity (FerrisKey OIDC `sub`). Nullable so existing +-- rows backfill on first login; unique so one IdP identity maps to exactly one local user. +ALTER TABLE "app_user" ADD COLUMN "external_subject" text;--> statement-breakpoint +ALTER TABLE "app_user" ADD CONSTRAINT "app_user_external_subject_unique" UNIQUE("external_subject"); diff --git a/packages/db/drizzle/meta/0006_snapshot.json b/packages/db/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..94874ca --- /dev/null +++ b/packages/db/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1429 @@ +{ + "id": "55c1e24b-02de-4eaa-94e4-dedd4bebf6dd", + "prevId": "4fa87f27-8259-4879-aa1d-ea7655a66725", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.alert_channel_config": { + "name": "alert_channel_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "alert_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "alert_channel_config_workspace_idx": { + "name": "alert_channel_config_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "alert_channel_config_workspace_id_workspace_id_fk": { + "name": "alert_channel_config_workspace_id_workspace_id_fk", + "tableFrom": "alert_channel_config", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "alert_channel_config_dest_uq": { + "name": "alert_channel_config_dest_uq", + "nullsNotDistinct": false, + "columns": [ + "workspace_id", + "channel", + "destination" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert": { + "name": "alert", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delta_id": { + "name": "delta_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "alert_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "channel_config_id": { + "name": "channel_config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "alert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "alert_workspace_status_idx": { + "name": "alert_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "alert_workspace_id_workspace_id_fk": { + "name": "alert_workspace_id_workspace_id_fk", + "tableFrom": "alert", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "alert_delta_id_delta_id_fk": { + "name": "alert_delta_id_delta_id_fk", + "tableFrom": "alert", + "tableTo": "delta", + "columnsFrom": [ + "delta_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "alert_channel_config_id_alert_channel_config_id_fk": { + "name": "alert_channel_config_id_alert_channel_config_id_fk", + "tableFrom": "alert", + "tableTo": "alert_channel_config", + "columnsFrom": [ + "channel_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "alert_delta_config_uq": { + "name": "alert_delta_config_uq", + "nullsNotDistinct": false, + "columns": [ + "delta_id", + "channel_config_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_user": { + "name": "app_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_subject": { + "name": "external_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_user_email_unique": { + "name": "app_user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "app_user_external_subject_unique": { + "name": "app_user_external_subject_unique", + "nullsNotDistinct": false, + "columns": [ + "external_subject" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.battlecard_section": { + "name": "battlecard_section", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "competitor_id": { + "name": "competitor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "battlecard_section_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_md": { + "name": "content_md", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claim_ids": { + "name": "claim_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "supersedes_id": { + "name": "supersedes_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "battlecard_section_competitor_id_competitor_id_fk": { + "name": "battlecard_section_competitor_id_competitor_id_fk", + "tableFrom": "battlecard_section", + "tableTo": "competitor", + "columnsFrom": [ + "competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "battlecard_section_competitor_kind_version_uq": { + "name": "battlecard_section_competitor_kind_version_uq", + "nullsNotDistinct": false, + "columns": [ + "competitor_id", + "kind", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.claim": { + "name": "claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "delta_id": { + "name": "delta_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quote_text": { + "name": "quote_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "char_start": { + "name": "char_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "char_end": { + "name": "char_end", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "claim_delta_id_delta_id_fk": { + "name": "claim_delta_id_delta_id_fk", + "tableFrom": "claim", + "tableTo": "delta", + "columnsFrom": [ + "delta_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "claim_workspace_id_workspace_id_fk": { + "name": "claim_workspace_id_workspace_id_fk", + "tableFrom": "claim", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "claim_snapshot_id_snapshot_id_fk": { + "name": "claim_snapshot_id_snapshot_id_fk", + "tableFrom": "claim", + "tableTo": "snapshot", + "columnsFrom": [ + "snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitor": { + "name": "competitor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_domain": { + "name": "primary_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "competitor_workspace_id_workspace_id_fk": { + "name": "competitor_workspace_id_workspace_id_fk", + "tableFrom": "competitor", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.coverage_run": { + "name": "coverage_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sources_checked": { + "name": "sources_checked", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fetch_failures": { + "name": "fetch_failures", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deltas_found": { + "name": "deltas_found", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material_deltas": { + "name": "material_deltas", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "llm_calls": { + "name": "llm_calls", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "llm_cost_micros": { + "name": "llm_cost_micros", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "coverage_run_workspace_id_workspace_id_fk": { + "name": "coverage_run_workspace_id_workspace_id_fk", + "tableFrom": "coverage_run", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.delta": { + "name": "delta", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_snapshot_id": { + "name": "from_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_snapshot_id": { + "name": "to_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changed_spans": { + "name": "changed_spans", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "triage_class": { + "name": "triage_class", + "type": "triage_class", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "materiality": { + "name": "materiality", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rationale": { + "name": "rationale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "delta_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "confirmed_by_snapshot_id": { + "name": "confirmed_by_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "delta_source_id_source_id_fk": { + "name": "delta_source_id_source_id_fk", + "tableFrom": "delta", + "tableTo": "source", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "delta_workspace_id_workspace_id_fk": { + "name": "delta_workspace_id_workspace_id_fk", + "tableFrom": "delta", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "delta_from_snapshot_id_snapshot_id_fk": { + "name": "delta_from_snapshot_id_snapshot_id_fk", + "tableFrom": "delta", + "tableTo": "snapshot", + "columnsFrom": [ + "from_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "delta_to_snapshot_id_snapshot_id_fk": { + "name": "delta_to_snapshot_id_snapshot_id_fk", + "tableFrom": "delta", + "tableTo": "snapshot", + "columnsFrom": [ + "to_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "delta_confirmed_by_snapshot_id_snapshot_id_fk": { + "name": "delta_confirmed_by_snapshot_id_snapshot_id_fk", + "tableFrom": "delta", + "tableTo": "snapshot", + "columnsFrom": [ + "confirmed_by_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dossier_section": { + "name": "dossier_section", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "competitor_id": { + "name": "competitor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "dossier_section_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content_md": { + "name": "content_md", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claim_ids": { + "name": "claim_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "supersedes_id": { + "name": "supersedes_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dossier_section_competitor_id_competitor_id_fk": { + "name": "dossier_section_competitor_id_competitor_id_fk", + "tableFrom": "dossier_section", + "tableTo": "competitor", + "columnsFrom": [ + "competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dossier_section_competitor_kind_version_uq": { + "name": "dossier_section_competitor_kind_version_uq", + "nullsNotDistinct": false, + "columns": [ + "competitor_id", + "kind", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership": { + "name": "membership", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "membership_user_idx": { + "name": "membership_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "membership_user_id_app_user_id_fk": { + "name": "membership_user_id_app_user_id_fk", + "tableFrom": "membership", + "tableTo": "app_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "membership_workspace_id_workspace_id_fk": { + "name": "membership_workspace_id_workspace_id_fk", + "tableFrom": "membership", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "membership_user_workspace_uq": { + "name": "membership_user_workspace_uq", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snapshot": { + "name": "snapshot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "s3_key": { + "name": "s3_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "normalized_text": { + "name": "normalized_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "vantage": { + "name": "vantage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "snapshot_source_fetched_idx": { + "name": "snapshot_source_fetched_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fetched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "snapshot_source_id_source_id_fk": { + "name": "snapshot_source_id_source_id_fk", + "tableFrom": "snapshot", + "tableTo": "source", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "snapshot_workspace_id_workspace_id_fk": { + "name": "snapshot_workspace_id_workspace_id_fk", + "tableFrom": "snapshot", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source": { + "name": "source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "competitor_id": { + "name": "competitor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "source_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "url_or_endpoint": { + "name": "url_or_endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "adapter": { + "name": "adapter", + "type": "source_adapter", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "legal_status": { + "name": "legal_status", + "type": "legal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "source_competitor_id_competitor_id_fk": { + "name": "source_competitor_id_competitor_id_fk", + "tableFrom": "source", + "tableTo": "competitor", + "columnsFrom": [ + "competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_tier": { + "name": "plan_tier", + "type": "plan_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'starter'" + }, + "competitor_limit": { + "name": "competitor_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.alert_channel": { + "name": "alert_channel", + "schema": "public", + "values": [ + "slack", + "email", + "crm" + ] + }, + "public.alert_status": { + "name": "alert_status", + "schema": "public", + "values": [ + "queued", + "delivered", + "failed" + ] + }, + "public.battlecard_section_kind": { + "name": "battlecard_section_kind", + "schema": "public", + "values": [ + "why_we_win", + "landmines", + "pricing_counter", + "objections" + ] + }, + "public.delta_state": { + "name": "delta_state", + "schema": "public", + "values": [ + "pending", + "confirmed", + "dismissed", + "published" + ] + }, + "public.legal_status": { + "name": "legal_status", + "schema": "public", + "values": [ + "open", + "licensed", + "blocked" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "owner", + "member" + ] + }, + "public.plan_tier": { + "name": "plan_tier", + "schema": "public", + "values": [ + "starter", + "growth", + "team" + ] + }, + "public.dossier_section_kind": { + "name": "dossier_section_kind", + "schema": "public", + "values": [ + "overview", + "pricing", + "product", + "gtm", + "team" + ] + }, + "public.source_adapter": { + "name": "source_adapter", + "schema": "public", + "values": [ + "rss", + "json", + "html", + "firecrawl", + "zyte" + ] + }, + "public.source_type": { + "name": "source_type", + "schema": "public", + "values": [ + "pricing", + "changelog", + "docs", + "jobs", + "reviews", + "status", + "blog", + "appstore" + ] + }, + "public.triage_class": { + "name": "triage_class", + "schema": "public", + "values": [ + "pricing_change", + "feature_launch", + "repositioning", + "leadership_hire", + "hiring_signal", + "noise" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index ec5b333..cfcf52a 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1782000000000, "tag": "0005_alert_delivery", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1782037213247, + "tag": "0006_link_external_subject", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/scripts/seed.ts b/packages/db/scripts/seed.ts index 0c05c13..0c4068a 100644 --- a/packages/db/scripts/seed.ts +++ b/packages/db/scripts/seed.ts @@ -76,6 +76,8 @@ const main = async (): Promise => { id: 'u-demo', email: SEED_EMAIL, name: 'Dana Founder', + // Null until first FerrisKey login: linkOrCreateUserBySubject backfills the IdP `sub` by email. + externalSubject: null, createdAt: at('2026-05-01T00:00:00Z'), }); await store.seedMembership({ diff --git a/packages/db/src/drizzle-mappers.ts b/packages/db/src/drizzle-mappers.ts index 8921cdf..d4c8155 100644 --- a/packages/db/src/drizzle-mappers.ts +++ b/packages/db/src/drizzle-mappers.ts @@ -145,7 +145,13 @@ export const toBattlecardSection = ( }); export const toAppUser = (row: typeof appUsers.$inferSelect): AppUser => - Object.freeze({ id: row.id, email: row.email, name: row.name, createdAt: row.createdAt }); + Object.freeze({ + id: row.id, + email: row.email, + name: row.name, + externalSubject: row.externalSubject, + createdAt: row.createdAt, + }); export const toMembership = (row: typeof memberships.$inferSelect): Membership => Object.freeze({ diff --git a/packages/db/src/drizzle-store.ts b/packages/db/src/drizzle-store.ts index f4c744c..a3afcf5 100644 --- a/packages/db/src/drizzle-store.ts +++ b/packages/db/src/drizzle-store.ts @@ -2,6 +2,7 @@ import { AppendOnlyViolationError, assertDeltaTransition, CrossTenantError, + IdentityConflictError, UnknownEntityError, type Alert, type AlertChannelConfig, @@ -18,6 +19,7 @@ import { type DossierSection, type DossierSectionKind, type EnabledChannel, + type ExternalIdentity, type FlankStore, type Membership, type MembershipWithWorkspace, @@ -28,6 +30,7 @@ import { type SynthesisCompetitor, type Workspace, } from '@flank/core'; +import { randomUUID } from 'node:crypto'; import { and, desc, eq, inArray, like, ne, sql } from 'drizzle-orm'; import type { FlankDatabase } from './client'; import { @@ -620,6 +623,7 @@ export class DrizzleFlankStore implements FlankStore { id: user.id, email: user.email.toLowerCase(), name: user.name, + externalSubject: user.externalSubject, createdAt: user.createdAt, }) .returning(), @@ -646,6 +650,51 @@ export class DrizzleFlankStore implements FlankStore { ); } + async linkOrCreateUserBySubject( + identity: ExternalIdentity, + createdAt: Date = new Date(), + ): Promise { + const email = identity.email.toLowerCase(); + // (1) Stable subject match — the authoritative link once established. + const bySubject = await this.db + .select() + .from(appUsers) + .where(eq(appUsers.externalSubject, identity.subject)) + .limit(1); + if (bySubject[0]) return toAppUser(bySubject[0]); + + // (2) Email match — a pre-OIDC/seed row adopts its IdP subject on first login (backfill), but + // ONLY for a verified email. An unverified email colliding with an existing account is rejected, + // never linked: otherwise anyone who registers that address at the IdP hijacks the workspace. + const byEmail = await this.db.select().from(appUsers).where(eq(appUsers.email, email)).limit(1); + if (byEmail[0]) { + if (!identity.emailVerified) { + throw new IdentityConflictError( + `unverified email ${email} collides with an existing account — refusing to link`, + ); + } + const updated = await this.db + .update(appUsers) + .set({ externalSubject: identity.subject, name: byEmail[0].name ?? identity.name }) + .where(eq(appUsers.id, byEmail[0].id)) + .returning(); + return toAppUser(updated[0]); + } + + // (3) First time we have ever seen this identity — JIT-create. Confers NO membership. + const inserted = await this.db + .insert(appUsers) + .values({ + id: `user_${randomUUID()}`, + email, + name: identity.name, + externalSubject: identity.subject, + createdAt, + }) + .returning(); + return toAppUser(inserted[0]); + } + async findUserByEmail(email: string): Promise { const rows = await this.db .select() diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 729485b..8de66b8 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -269,6 +269,9 @@ export const appUsers = pgTable('app_user', { id: text('id').primaryKey(), email: text('email').notNull().unique(), name: text('name'), + // Stable `sub` of the external IdP (FerrisKey OIDC). Nullable: pre-OIDC/seed rows link on first + // login. Unique so one IdP identity maps to exactly one local user (Invariant 8 starts here). + externalSubject: text('external_subject').unique(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); diff --git a/packages/pipeline/src/memory-store.ts b/packages/pipeline/src/memory-store.ts index f3b3282..8f451eb 100644 --- a/packages/pipeline/src/memory-store.ts +++ b/packages/pipeline/src/memory-store.ts @@ -1,8 +1,10 @@ +import { randomUUID } from 'node:crypto'; import { AppendOnlyViolationError, assertAlertTransition, assertDeltaTransition, CrossTenantError, + IdentityConflictError, UnknownEntityError, type Alert, type AlertChannelConfig, @@ -19,6 +21,7 @@ import { type DossierSection, type DossierSectionKind, type EnabledChannel, + type ExternalIdentity, type FlankStore, type Membership, type MembershipWithWorkspace, @@ -482,6 +485,45 @@ export class MemoryFlankStore implements FlankStore { return this.insertUnique(this.state.memberships, membership, 'membership'); } + async linkOrCreateUserBySubject( + identity: ExternalIdentity, + createdAt: Date = new Date(), + ): Promise { + const email = identity.email.toLowerCase(); + // (1) Stable subject match — the authoritative link once established. + for (const user of this.state.users.values()) { + if (user.externalSubject !== null && user.externalSubject === identity.subject) return user; + } + // (2) Email match — a pre-OIDC/seed row adopts its IdP subject on first login (backfill), but + // ONLY for a verified email. An unverified email colliding with an existing account is rejected, + // never linked: otherwise anyone who registers that address at the IdP hijacks the workspace. + for (const user of this.state.users.values()) { + if (user.email.toLowerCase() === email) { + if (!identity.emailVerified) { + throw new IdentityConflictError( + `unverified email ${email} collides with an existing account — refusing to link`, + ); + } + const linked = freezeDeep({ + ...user, + externalSubject: identity.subject, + name: user.name ?? identity.name, + }); + this.state.users.set(user.id, linked); + return linked; + } + } + // (3) First time we have ever seen this identity — JIT-create. Confers NO membership. + const created: AppUser = { + id: `user_${randomUUID()}`, + email, + name: identity.name, + externalSubject: identity.subject, + createdAt, + }; + return this.insertUnique(this.state.users, created, 'app_user'); + } + async findUserByEmail(email: string): Promise { const normalized = email.toLowerCase(); for (const user of this.state.users.values()) { diff --git a/packages/pipeline/src/store-contract.ts b/packages/pipeline/src/store-contract.ts index 91be330..ae0f6b1 100644 --- a/packages/pipeline/src/store-contract.ts +++ b/packages/pipeline/src/store-contract.ts @@ -1,6 +1,7 @@ import { AppendOnlyViolationError, CrossTenantError, + IdentityConflictError, IllegalTransitionError, UnknownEntityError, type Alert, @@ -571,6 +572,7 @@ export const runFlankStoreContract = (label: string, makeStore: () => FlankStore id, email, name: null, + externalSubject: null, createdAt: AT, }); const grantOn = ( @@ -588,6 +590,56 @@ export const runFlankStoreContract = (label: string, makeStore: () => FlankStore expect(await store.getUserById('missing')).toBeNull(); }); + describe('linkOrCreateUserBySubject (OIDC JIT provisioning)', () => { + const identity = ( + over: Partial[0]> = {}, + ) => ({ + subject: 'fk-sub-123', + email: 'ada@example.com', + emailVerified: true, + name: 'Ada Lovelace', + ...over, + }); + + it('backfills the subject onto a pre-existing user matched by email', async () => { + await store.seedUser(userOn('u-1', 'Ada@Example.com')); // seed row, externalSubject null + const linked = await store.linkOrCreateUserBySubject(identity()); + expect(linked.id).toBe('u-1'); // same row, not a new user + expect(linked.externalSubject).toBe('fk-sub-123'); + expect(await store.findUserByEmail('ada@example.com')).toMatchObject({ id: 'u-1' }); + }); + + it('returns the same user on a repeat login, matching by stable subject', async () => { + await store.seedUser(userOn('u-1', 'ada@example.com')); + await store.linkOrCreateUserBySubject(identity()); + // IdP email changed but the subject is stable → still resolves to u-1, no new row. + const again = await store.linkOrCreateUserBySubject( + identity({ email: 'ada@new.example' }), + ); + expect(again.id).toBe('u-1'); + expect(again.email).toBe('ada@example.com'); // email is not rewritten by a relogin + }); + + it('creates a fresh user the first time an identity is seen', async () => { + const created = await store.linkOrCreateUserBySubject(identity({ subject: 'fk-new' })); + expect(created.externalSubject).toBe('fk-new'); + expect(created.email).toBe('ada@example.com'); + // Fail-closed: a brand-new user has zero memberships until an owner grants one. + expect(await store.listMembershipsForUser(created.id)).toHaveLength(0); + }); + + it('refuses to link an UNVERIFIED email onto a pre-existing account (anti-hijack)', async () => { + await store.seedUser(userOn('u-1', 'ada@example.com')); // pre-provisioned owner, no subject + await expect( + store.linkOrCreateUserBySubject( + identity({ subject: 'fk-attacker', emailVerified: false }), + ), + ).rejects.toBeInstanceOf(IdentityConflictError); + // The pre-existing account is untouched — no subject was written. + expect((await store.findUserByEmail('ada@example.com'))?.externalSubject).toBeNull(); + }); + }); + it('grants memberships joined to the workspace, rejecting a duplicate grant', async () => { await store.seedUser(userOn('u-1', 'ada@example.com')); await store.seedMembership(grantOn('m-a', 'u-1', WS_A.id, 'owner')); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b9efc6..21bdfba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: next: specifier: ^16.2.9 version: 16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next-auth: + specifier: 5.0.0-beta.31 + version: 5.0.0-beta.31(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) react: specifier: ^19.2.7 version: 19.2.7 @@ -181,6 +184,20 @@ packages: zod: optional: true + '@auth/core@0.41.2': + resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^7.0.7 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -1135,6 +1152,9 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1976,6 +1996,9 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -2057,6 +2080,22 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-auth@5.0.0-beta.31: + resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 + nodemailer: ^7.0.7 + react: ^18.2.0 || ^19.0.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + next@16.2.9: resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==} engines: {node: '>=20.9.0'} @@ -2096,6 +2135,9 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + oauth4webapi@3.8.6: + resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -2175,6 +2217,14 @@ packages: resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} engines: {node: '>=12'} + preact-render-to-string@6.5.11: + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + + preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2549,6 +2599,14 @@ snapshots: optionalDependencies: zod: 4.4.3 + '@auth/core@0.41.2': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.2.3 + oauth4webapi: 3.8.6 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -3579,6 +3637,8 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@panva/hkdf@1.2.1': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -4346,6 +4406,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jose@6.2.3: {} + js-tokens@10.0.0: {} json-bigint@1.0.0: @@ -4416,6 +4478,12 @@ snapshots: natural-compare@1.4.0: {} + next-auth@5.0.0-beta.31(next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + dependencies: + '@auth/core': 0.41.2 + next: 16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + next@16.2.9(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.9 @@ -4453,6 +4521,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + oauth4webapi@3.8.6: {} + obug@2.1.3: {} optionator@0.9.4: @@ -4521,6 +4591,12 @@ snapshots: postgres@3.4.9: {} + preact-render-to-string@6.5.11(preact@10.24.3): + dependencies: + preact: 10.24.3 + + preact@10.24.3: {} + prelude-ls@1.2.1: {} prettier@3.8.4: {} diff --git a/scripts/ferriskey-bootstrap.ts b/scripts/ferriskey-bootstrap.ts new file mode 100644 index 0000000..d68ce59 --- /dev/null +++ b/scripts/ferriskey-bootstrap.ts @@ -0,0 +1,212 @@ +/** + * Provision the local FerrisKey instance for Flank's OIDC login. Idempotent-ish: every step + * tolerates an already-exists (409/422) response so re-runs are safe. Targets the FerrisKey v0.6.x + * admin REST API (paths verified against ferriskey/ferriskey @ v0.6.1). + * + * Steps, all against FERRISKEY_API_URL (default http://localhost:3333): + * 1. Mint an admin token — password grant on the `master` realm via `security-admin-console`. + * 2. Create realm `flank`. + * 3. Create a confidential client `flank-web` and read its generated secret. + * 4. Register the redirect + post-logout URIs for the Next.js app. + * 5. Create the demo user (matching the seed email) and set its password. + * + * It then prints the exact env values to paste into `.env`. The FerrisKey console + * (http://localhost:5555, admin/admin) remains the source of truth if anything drifts. + * + * just ferriskey-bootstrap + */ + +const API = (process.env.FERRISKEY_API_URL ?? 'http://localhost:3333').replace(/\/$/, ''); +const ADMIN_USER = process.env.FERRISKEY_ADMIN_USERNAME ?? 'admin'; +const ADMIN_PASS = process.env.FERRISKEY_ADMIN_PASSWORD ?? 'admin'; +const REALM = process.env.FERRISKEY_REALM ?? 'flank'; +const CLIENT_ID = process.env.FERRISKEY_CLIENT_ID ?? 'flank-web'; +const APP_ORIGIN = (process.env.FLANK_WEB_ORIGIN ?? 'http://localhost:3000').replace(/\/$/, ''); +const REDIRECT_URI = `${APP_ORIGIN}/api/auth/callback/ferriskey`; +const DEMO_EMAIL = process.env.SEED_EMAIL ?? 'founder@northwind.test'; +const DEMO_PASSWORD = process.env.FERRISKEY_DEMO_PASSWORD ?? 'flank-demo-password'; + +/** A response that means "already created" — treated as success so the script is re-runnable. */ +const ALREADY_EXISTS = new Set([409, 422]); + +const fail = (message: string): never => { + console.error(`\n✗ ${message}`); + process.exit(1); +}; + +interface ApiCall { + readonly method: string; + readonly path: string; + readonly token?: string; + readonly json?: unknown; + readonly form?: Record; + /** Status codes (besides 2xx) to accept as success (e.g. already-exists). */ + readonly tolerate?: ReadonlySet; +} + +const call = async ({ method, path, token, json, form, tolerate }: ApiCall): Promise => { + const headers: Record = {}; + let body: string | undefined; + if (token !== undefined) headers.Authorization = `Bearer ${token}`; + if (form !== undefined) { + headers['Content-Type'] = 'application/x-www-form-urlencoded'; + body = new URLSearchParams(form).toString(); + } else if (json !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(json); + } + + let res: Response; + try { + res = await fetch(`${API}${path}`, { method, headers, body }); + } catch (error: unknown) { + return fail( + `cannot reach FerrisKey at ${API} — is it running? (\`just ferriskey-up\`). ${String(error)}`, + ); + } + + const text = await res.text(); + const parsed = text === '' ? undefined : safeJson(text); + if (!res.ok && !(tolerate?.has(res.status) ?? false)) { + return fail(`${method} ${path} → ${res.status} ${res.statusText}\n${text}`); + } + return parsed; +}; + +const safeJson = (text: string): unknown => { + try { + return JSON.parse(text); + } catch { + return text; + } +}; + +const asRecord = (value: unknown): Record => + typeof value === 'object' && value !== null ? (value as Record) : {}; + +const main = async (): Promise => { + console.log(`→ FerrisKey bootstrap against ${API} (realm: ${REALM}, client: ${CLIENT_ID})`); + + // 1. Admin token (master realm, default console client, direct password grant). + const tokenRes = asRecord( + await call({ + method: 'POST', + path: '/realms/master/protocol/openid-connect/token', + form: { + grant_type: 'password', + client_id: 'security-admin-console', + username: ADMIN_USER, + password: ADMIN_PASS, + }, + }), + ); + const token = tokenRes.access_token; + if (typeof token !== 'string') return fail('no access_token in admin token response'); + console.log('✓ admin token acquired'); + + // 2. Realm. + await call({ + method: 'POST', + path: '/realms', + token, + json: { name: REALM }, + tolerate: ALREADY_EXISTS, + }); + console.log(`✓ realm "${REALM}" ready`); + + // 3. Confidential client — read back its generated secret (or fetch it if the client pre-existed). + const created = asRecord( + await call({ + method: 'POST', + path: `/realms/${REALM}/clients`, + token, + json: { + name: 'Flank Web', + client_id: CLIENT_ID, + client_type: 'confidential', + public_client: false, + protocol: 'openid-connect', + enabled: true, + service_account_enabled: false, + direct_access_grants_enabled: true, + oauth_device_code_grant_enabled: false, + }, + tolerate: ALREADY_EXISTS, + }), + ); + + let client = asRecord(created.data ?? created); + if (typeof client.id !== 'string') { + // Pre-existing client: look it up to recover the UUID + secret. + const list = asRecord(await call({ method: 'GET', path: `/realms/${REALM}/clients`, token })); + const items = ( + Array.isArray(list.data) ? list.data : Array.isArray(list) ? list : [] + ) as unknown[]; + const match = items.map(asRecord).find((c) => c.client_id === CLIENT_ID); + if (match === undefined) return fail(`client "${CLIENT_ID}" not found after create`); + client = match; + } + const clientUuid = client.id; + const clientSecret = client.secret; + if (typeof clientUuid !== 'string') return fail('client has no id'); + console.log(`✓ client "${CLIENT_ID}" ready (${clientUuid})`); + + // 4. Redirect + post-logout URIs. + await call({ + method: 'POST', + path: `/realms/${REALM}/clients/${clientUuid}/redirects`, + token, + json: { value: REDIRECT_URI, enabled: true }, + tolerate: ALREADY_EXISTS, + }); + await call({ + method: 'POST', + path: `/realms/${REALM}/clients/${clientUuid}/post-logout-redirects`, + token, + json: { value: APP_ORIGIN, enabled: true }, + tolerate: ALREADY_EXISTS, + }); + console.log(`✓ redirect URIs registered (${REDIRECT_URI})`); + + // 5. Demo user + password (matches `just seed`'s owner email so memberships line up). + const userRes = asRecord( + await call({ + method: 'POST', + path: `/realms/${REALM}/users`, + token, + json: { + username: DEMO_EMAIL, + email: DEMO_EMAIL, + firstname: 'Dana', + lastname: 'Founder', + email_verified: true, + }, + tolerate: ALREADY_EXISTS, + }), + ); + const user = asRecord(userRes.data ?? userRes); + const userUuid = user.id; + if (typeof userUuid === 'string') { + await call({ + method: 'PUT', + path: `/realms/${REALM}/users/${userUuid}/reset-password`, + token, + json: { temporary: false, credential_type: 'password', value: DEMO_PASSWORD }, + }); + console.log(`✓ demo user ${DEMO_EMAIL} ready (password: ${DEMO_PASSWORD})`); + } else { + console.log(`• demo user ${DEMO_EMAIL} already existed — left untouched`); + } + + console.log('\nDone. Put these in your .env (then run `just dev`):\n'); + console.log(`FERRISKEY_ISSUER=${API}`); + console.log(`FERRISKEY_REALM=${REALM}`); + console.log(`FERRISKEY_CLIENT_ID=${CLIENT_ID}`); + console.log( + typeof clientSecret === 'string' + ? `FERRISKEY_CLIENT_SECRET=${clientSecret}` + : 'FERRISKEY_CLIENT_SECRET=', + ); +}; + +main().catch((error: unknown) => fail(String(error))); diff --git a/vitest.config.ts b/vitest.config.ts index 7c41ed8..83263f4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,9 +12,9 @@ export default defineConfig({ // The drizzle-*.ts files (store, alert store, row mappers) are exercised by the DB-backed // integration suite, not the unit run, so they would otherwise report 0% and drag the gate // down — they are covered, just elsewhere (the shared FlankStore contract runs against Postgres). - // session.ts / store.ts are thin server-only Next wiring (cookies/redirect/react cache, DB pool) - // that imports `server-only` (throws under plain Node/vitest); their pure logic lives in the - // covered resolver.ts / secret.ts / session-crypto.ts seams instead. + // session.ts / store.ts are thin server-only Next wiring (cookies/redirect/react cache, DB pool, + // Auth.js session) that imports `server-only` (throws under plain Node/vitest); their pure logic + // lives in the covered resolver.ts seam instead. (auth.ts lives outside apps/web/lib, uncovered.) exclude: [ '**/*.test.ts', '**/*.integration.test.ts',