Skip to main content

WorkOS Auth Implementation Plan

Status: DRAFT — blocked on WorkOS sales call. Phase 0 cannot start until questions 1, 2, and 4 from ADR-017's open questions are answered (exact SSO+AuthKit billing, startup program terms, M2M/API Keys pricing). Target: schedule 15-minute call with WorkOS sales.

Implements: ADR-016, ADR-017

Target state reference: 13 — Authentication and User Management

Supersedes: 2026-03-01 User Authentication Plan — moved to archive.


Context

Replace sv0-platform's placeholder auth (header-based tenant, API key, REQUIRE_AUTH=false bypass) with a WorkOS-backed B2B multi-tenant authentication system. The new system uses WorkOS AuthKit for the login experience, WorkOS Organizations as the tenant primitive, and a thin local mirror of users/memberships for fast middleware and cross-tenant super-admin queries.

Two motivating problems this plan solves:

  1. We cannot onboard an enterprise customer today. No SAML, no SCIM, no per-tenant isolation story, no Admin Portal. This is a sales blocker.
  2. Our team cannot efficiently work across tenants today. Tenant switching requires editing localStorage. Shareable links do not work because tenant context is not in the URL. Demo provisioning is a manual multi-step process.

The plan is phased so each phase is independently shippable and leaves the system in a working state.


Prerequisites (must be done before Phase 1)

  1. WorkOS account created with two environments:
    • staging — used by dev.securityv0.com and local ngrok testing
    • production — used by app.securityv0.com
  2. securityv0-internal Organization created in both environments, with domain restriction @securityv0.com.
  3. Google Workspace OAuth enabled on the securityv0-internal Organization so staff can log in with Google (free; no SSO connection fee).
  4. Webhook signing secret obtained from WorkOS and stored as WORKOS_WEBHOOK_SECRET in 1Password.
  5. API keys (WORKOS_API_KEY, WORKOS_CLIENT_ID) obtained for both environments and stored in 1Password.
  6. WorkOS API Keys product enabled in both environments. This is required so staff can generate personal API keys in §1.8.a without a Phase 6 dependency. Configure prefix (sv0_live_ for prod, sv0_test_ for staging) and default expiry policy. Scope registry can be minimal at this point — it will be expanded in Phase 6 for customer use.
  7. Per-service M2M Applications created in the securityv0-internal Organization, one per service, with one credential pair per env. Initial set: github-actions-ci-{staging,production}, workos-reconcile-{staging,production}, deploy-runner-{staging,production}. Do not create a shared securityv0-claude-code M2M Application — staff bots use personal API keys per §1.8.a. Each credential pair goes in 1Password under securityv0 → m2m → <service-name> → <env>.
  8. Pricing and startup program confirmed with WorkOS sales per the ADR-017 open questions. If the startup program applies, document the terms in 1Password.
  9. GitHub Issue created for this plan with label implementation and assigned to @ivanfofanov.

Phase 0 — Prep and scaffolding

Goal: Set up the WorkOS account, env vars, and feature flag. No user-visible changes.

Tasks

  • Create WorkOS account and securityv0-internal Organization per prerequisites above.
  • Add env var shape to .env.example:
    # WorkOS
    WORKOS_API_KEY=sk_...
    WORKOS_CLIENT_ID=client_...
    WORKOS_WEBHOOK_SECRET=wh_...
    WORKOS_REDIRECT_URI=https://dev.securityv0.com/auth/callback
    WORKOS_COOKIE_PASSWORD=<at-least-32-char-random> # iron-session encryption key

    # Auth provider selection — deployment-time choice
    AUTH_PROVIDER=workos # "workos" | "oidc" | "dev"

    # OIDC provider config (only when AUTH_PROVIDER=oidc, for partner-deployed installations)
    # OIDC_ISSUER_URL=https://login.microsoftonline.com/{tenant}/v2.0
    # OIDC_CLIENT_ID=...
    # OIDC_CLIENT_SECRET=...
  • Add AUTH_PROVIDER to src/shared/config/env.ts with validation.
  • Install dependencies:
    npm install @workos-inc/node iron-session
    cd ui && npm install @workos-inc/authkit-react
  • Document the feature flag in sv0-documentation/docs/runbooks/.

Verification

  • npm run typecheck and npm run lint pass.
  • npm run dev still runs with AUTH_PROVIDER=dev — no behavior change.
  • Env validation rejects WorkOS path if keys are missing when AUTH_PROVIDER=workos.

Files touched

  • sv0-platform/.env.example
  • sv0-platform/src/shared/config/env.ts
  • sv0-platform/package.json, sv0-platform/ui/package.json
  • sv0-documentation/docs/runbooks/ (new entry)

Phase 1 — Backend foundation (data model + middleware)

Goal: Introduce the AuthProvider interface, the WorkOS implementation, the new collections, the middleware, and the webhook receiver. The AUTH_PROVIDER env var selects the active provider at startup; dev is the default for local development.

1.1 New domain types

Create sv0-platform/src/domain/tenants/types.ts:

export interface TenantDoc {
_id: ObjectId;
slug: string;
display_name: string;
provider_org_id: string;
status: "evaluation" | "active" | "churned" | "internal";
sso_enforced: boolean;
verified_domains: string[]; // e.g. ["acme.com"] — for domain-match auto-join on social login
created_at: Date;
archived_at: Date | null;
}

export interface TenantConfigDoc {
_id: ObjectId;
tenant_id: ObjectId;
jira_base_url?: string;
jira_project_key?: string;
branding?: { logo_url?: string; primary_color?: string; display_name_override?: string };
feature_flags?: Record<string, boolean>;
connector_credential_refs?: Record<string, string>;
updated_at: Date;
updated_by_user_id: ObjectId;
}

Create sv0-platform/src/domain/users/types.ts:

export interface UserDoc {
_id: ObjectId;
provider_user_id: string;
email: string;
display_name: string;
is_super_admin: boolean;
created_at: Date;
updated_at: Date;
last_seen_at: Date | null;
}

export interface MembershipDoc {
_id: ObjectId;
user_id: ObjectId;
tenant_id: ObjectId;
role: "owner" | "admin" | "member";
created_at: Date;
updated_at: Date;
}

1.2 Storage adapter extensions

Add to sv0-platform/src/storage/mongo/collections.ts:

{
name: "tenants",
indexes: [
{ key: { slug: 1 }, unique: true, name: "slug_unique" },
{ key: { provider_org_id: 1 }, unique: true, name: "provider_org_id_unique" },
{ key: { status: 1 }, name: "status_lookup" },
],
},
{
name: "users",
indexes: [
{ key: { provider_user_id: 1 }, unique: true, name: "provider_user_id_unique" },
{ key: { email: 1 }, unique: true, name: "email_unique" },
{ key: { is_super_admin: 1 }, name: "super_admin_lookup" },
],
},
{
name: "memberships",
indexes: [
{ key: { user_id: 1, tenant_id: 1 }, unique: true, name: "user_tenant_unique" },
{ key: { tenant_id: 1, role: 1 }, name: "tenant_role_lookup" },
{ key: { user_id: 1 }, name: "user_memberships" },
],
},
{
name: "tenant_configs",
indexes: [
{ key: { tenant_id: 1 }, unique: true, name: "tenant_config_unique" },
],
},
{
name: "webhook_events", // idempotency tracking
indexes: [
{ key: { event_id: 1 }, unique: true, name: "event_id_unique" },
{ key: { received_at: 1 }, expireAfterSeconds: 2592000, name: "ttl_30_days" },
],
},

Add repository methods to sv0-platform/src/storage/storage-adapter.ts:

  • findTenantBySlug(slug), findTenantByProviderOrgId(id), createTenant(doc), updateTenant(id, patch), listAllTenants().
  • findUserByProviderUserId(id), findUserByEmail(email), upsertUser(doc).
  • findMembership(userId, tenantId), listMembershipsByUser(userId), listMembershipsByTenant(tenantId), upsertMembership(doc), deleteMembership(userId, tenantId).
  • getTenantConfig(tenantId), upsertTenantConfig(tenantId, patch, updatedByUserId).
  • recordWebhookEvent(eventId) — returns true if newly recorded, false if duplicate (for idempotency).

1.3 Auth provider interface and implementations

Create sv0-platform/src/api/auth/auth-provider.ts:

  • Defines the AuthProvider interface per §2.1 of the architecture doc.
  • Defines the AuthCallbackResult, VerifiedSession, VerifiedApiKey, VerifiedM2MToken, AuthWebhookEvent types.
  • This is the only file the middleware imports. Provider-specific SDKs are never imported outside their implementation.

Create sv0-platform/src/api/auth/providers/workos-provider.ts (implements AuthProvider):

  • Wraps @workos-inc/node with env-validated config.
  • Implements all interface methods: getLoginUrl (redirect to AuthKit), handleCallback (exchange code), verifySession (decrypt iron-session cookie + load from mirror), verifyApiKey (WorkOS API Keys endpoint with cache), verifyM2MToken (JWKS verification), generateAdminPortalLink, listActiveConnections, verifyWebhook.
  • Also exposes WorkOS-specific admin methods not on the interface: createInvitation(email, orgId), createOrganization(name, domains), getUser(id), getOrganization(id), listMemberships(orgId). These are accessed via a type-narrowing check (if (provider instanceof WorkOSAuthProvider)) in provisioning scripts and admin routes only.

Create sv0-platform/src/api/auth/providers/oidc-provider.ts (implements AuthProvider):

  • Wraps openid-client for direct OIDC against any compliant IdP (Okta, Entra ID, PingFederate, Keycloak).
  • Configured via OIDC_ISSUER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET.
  • Implements: getLoginUrl (OIDC authorization URL), handleCallback (code exchange via token endpoint), verifySession (JWT verification against IdP JWKS), logout (RP-initiated logout if supported).
  • verifyApiKey, verifyM2MToken, generateAdminPortalLink, verifyWebhook return null / throw UnsupportedError — these features are WorkOS-specific.
  • listActiveConnections returns [] (SSO enforcement is handled by the IdP itself in this model — the tenant has exactly one IdP, always enforced).
  • Phase 1 scope: stub implementation with the interface defined. Full implementation deferred until the first partner-deployed installation is real. The interface is the load-bearing deliverable; the OIDC implementation is a fast-follow.

Create sv0-platform/src/api/auth/providers/dev-provider.ts (implements AuthProvider):

  • Replaces the current dev-bootstrap.ts approach.
  • init() seeds the internal tenant, dev user, and demo tenant (same as current dev-bootstrap).
  • verifySession() always returns a valid session for the seeded dev user.
  • getLoginUrl() / handleCallback() are no-ops that auto-authenticate the dev user.
  • No external dependencies.

Create sv0-platform/src/api/auth/provider-factory.ts:

  • Reads AUTH_PROVIDER env var.
  • Returns the appropriate AuthProvider implementation.
  • Called once at server startup; the returned instance is injected into the middleware pipeline via Express app.locals or a module-level singleton.
// src/api/auth/provider-factory.ts
export function createAuthProvider(): AuthProvider {
switch (process.env.AUTH_PROVIDER) {
case "workos": return new WorkOSAuthProvider();
case "oidc": return new OIDCAuthProvider();
case "dev": return new DevAuthProvider();
default: throw new Error(`Unknown AUTH_PROVIDER: ${process.env.AUTH_PROVIDER}`);
}
}

1.4 WorkOS client (admin operations)

The WorkOSAuthProvider also serves as the entry point for WorkOS-specific admin operations that don't fit the generic AuthProvider interface:

Create sv0-platform/src/api/auth/session.ts:

  • iron-session wrapper for encrypted session cookies.
  • Session payload (provider-agnostic — populated from AuthCallbackResult):
    {
    provider_user_id: string; // From AuthCallbackResult.providerUserId
    auth_method: "sso" | "password" | "magic_link" | "oauth_google" | "oauth_microsoft"
    | "oauth_github" | "passkey" | "impersonation";
    auth_connection_id: string | null; // SSO connection ID for enforcement; null for non-SSO
    provider_session_id: string; // For logout/revocation via authProvider.logout()
    expires_at: number;
    }
  • auth_method and auth_connection_id are read from the AuthCallbackResult at callback time and persisted in the cookie so downstream SSO enforcement can check them without another provider round-trip.
  • Helpers: createSession(res, result: AuthCallbackResult), destroySession(res), readSession(req).

Create sv0-platform/src/api/auth/permissions.ts:

  • Permission enum and ROLE_PERMISSIONS map per §7.1 of the architecture doc (tenant-scoped roles: owner, admin, member — note: no super_admin entry; super-admin is not a tenant role).
  • INTERNAL_PERMISSIONS map per §7.2 of the architecture doc (internal-org roles: owner, admin, member). member grants read-only across all tenants; write capabilities require internal admin or owner.
  • resolvePermissions(membership, user) — returns the union of ROLE_PERMISSIONS[membership.role] and (if user.is_super_admin) INTERNAL_PERMISSIONS[user.internal_role]. This is the only way permissions are computed; callers never look up either map directly.
  • hasPermission(authContext, permission): boolean helper that delegates to the resolved permission set on authContext.membership.permissions.

Create sv0-platform/src/api/middleware/auth-middleware.ts (provider-agnostic):

  • Takes an AuthProvider instance as a constructor/factory parameter.
  • sessionMiddleware — calls authProvider.verifySession() (or verifyApiKey() / verifyM2MToken() for bearer tokens), loads user from local mirror, attaches to req.sessionUser.
  • tenantMiddleware — extracts :tenantSlug from URL, loads tenant, rejects mismatched slug.
  • membershipMiddleware — checks super-admin or membership row, computes role, permissions. Includes domain-match auto-join for social login users (see below).
  • permissionMiddleware(permission) — route-level gate.
  • Composed into req.authContext as described in §5 of the architecture doc.
  • This file never imports @workos-inc/node or any provider SDK — it only calls AuthProvider interface methods.

Domain-match auto-join (inside membershipMiddleware): When a user authenticates via social login (Google/Microsoft/GitHub OAuth) or magic link and has no existing membership in the current tenant, check if the user's email domain matches one of the tenant's verified_domains. If yes, auto-create a member-role membership row. This gives social-login clients the "just sign in and you're in" experience without a paid SSO connection. ~20 lines of code:

// Inside membershipMiddleware, after checking for super-admin and direct membership:
if (!membership && tenant.verified_domains.length > 0) {
const emailDomain = user.email.split("@")[1];
if (tenant.verified_domains.includes(emailDomain)) {
membership = await storage.upsertMembership({
user_id: user._id,
tenant_id: tenant._id,
role: "member",
created_at: new Date(),
updated_at: new Date(),
});
}
}

This replaces the need for WorkOS SAML SSO for clients whose IT team doesn't mandate IdP-level control. See auth method tiering analysis for the business rationale.

Domain verification admin UI: Phase 3 adds a "Verify domain" button to AdminTenantDetailPage that either (a) generates a DNS TXT record for the client to add, or (b) lets a super-admin manually mark a domain as verified. For Phase 1, domains are set directly in the tenants collection by the super-admin.

Modify sv0-platform/src/api/middleware/auth.ts:

  • Replace the old REQUIRE_AUTH branching with a single call to createAuthProvider() (from provider-factory.ts).
  • Pass the provider instance to the auth-middleware.ts factory. All downstream middleware calls go through the AuthProvider interface.

1.4 Webhook receiver

Create sv0-platform/src/api/routes/webhooks/workos.ts:

  • POST /api/v1/webhooks/workos.
  • Verifies WorkOS-Signature header with HMAC against WORKOS_WEBHOOK_SECRET.
  • Checks idempotency via recordWebhookEvent(event_id).
  • Routes to handler per event type (table in §11.1 of the architecture doc).
  • Handlers are pure upserts/deletes on the local mirror; no external side effects.
  • Recomputes users.is_super_admin whenever an internal-org membership changes.

Unit tests: test/api/webhooks/workos.test.ts with fixtures for each event type.

1.6 Dev provider (replaces dev-bootstrap)

The DevAuthProvider (created in §1.3) replaces the old dev-bootstrap.ts approach:

  • init() seeds the internal tenant, dev user, membership, and demo tenant — same data as before.
  • verifySession() auto-mints a session for dev@securityv0.com if no cookie is present.
  • Selected automatically when AUTH_PROVIDER=dev.

1.7 Initial auth routes

Create sv0-platform/src/api/routes/auth.ts:

  • GET /login — calls authProvider.getLoginUrl({ returnTo }) and redirects. For WorkOS this goes to AuthKit; for OIDC this goes to the IdP's authorization endpoint; for dev this auto-authenticates.
  • GET /auth/callback — calls authProvider.handleCallback(code), upserts user/membership from the AuthCallbackResult, mints session, redirects to return_to.
  • POST /auth/logout — calls authProvider.logout(providerSessionId), destroys session cookie, redirects to /login.
  • GET /api/v1/auth/me — returns current authContext (used by frontend).

Register these routes in sv0-platform/src/api/app.ts before the auth middleware pipeline (they are public).

1.8 Programmatic access for the SecurityV0 team — personal API keys + per-service M2M

Day-1 requirement: Claude Code sessions, CI/CD pipelines, and internal workers must be able to call dev/prod APIs once the legacy X-API-Key path is removed. Per ADR-017 §"SecurityV0 team bot access" (as amended by the Codex review), we deliberately do not ship a single shared securityv0-claude-code M2M credential. The two problems with that pattern are:

  1. Non-attributable. Every call logs the same client_id, so we cannot tell whose Claude session ran POST /api/v1/scans.
  2. Single-secret blast radius. Leaking it compromises every staff bot at once; rotation requires coordinated updates across every engineer's laptop.

Instead, Phase 1 ships two distinct credential types, both resolved by the same middleware pipeline:

1.8.a Personal API keys (human-attributable staff automation)

  • Who uses them: Individual engineers' Claude Code sessions, local dev scripts, ad-hoc curl calls.
  • How they are issued: Each staff member signs into AuthKit, opens Settings → API Keys, and generates a key via the WorkOS API Keys widget (the same widget Phase 6 ships to customers — staff are just the first users of it). The key is bound to that staff user's WorkOS user record.
  • Authorization: When a request arrives with Authorization: Bearer sv0_..., apiKeyMiddleware verifies the key with WorkOS, loads the owning user from the local mirror, and builds the exact same authContext that an interactive session for that user would produce — including the union of their tenant membership permissions and (if they are in securityv0-internal) their INTERNAL_PERMISSIONS[internal_role] derived per §6.3 of the architecture doc.
  • Revocation: Deleting the staff user (or the key itself) invalidates every session and bot that user owns. No shared secret to rotate.
  • Phase 1 scope: Because Phase 6 ships the widget to customers later, Phase 1 delivers the backend apiKeyMiddleware and a minimal dashboard-only path for staff to create keys via WorkOS's hosted widget; the customer-facing Settings tab is Phase 6.

1.8.b Per-service M2M Applications (shared-service automation)

  • Who uses them: Long-running services that are not owned by a single human — CI/CD runners (github-actions-ci), reconciliation workers (workos-reconcile), deploy runners (deploy-runner), scheduled seed refreshers. Each shared service gets its own M2M Application.
  • WorkOS setup (done during Phase 0 per service, not a single shared one):
    • In the securityv0-internal Organization, create one M2M Application per service: github-actions-ci-staging, github-actions-ci-production, workos-reconcile-staging, workos-reconcile-production, etc.
    • Store each credential pair separately in 1Password under securityv0 → m2m → <service-name> → <env>.
    • Every M2M Application has a declared internal role recorded in config (see below). That role is typically member (read-only) and is never owner. A service that needs write access is reviewed individually and may be granted admin with a documented justification.
  • Declared role config. Create sv0-platform/src/api/auth/m2m-applications.ts:
    // Static registry. client_id → declared internal role.
    // Reviewed in PR; mismatches between this file and the WorkOS dashboard are flagged
    // by the reconciliation worker in Phase 5.4.
    export const M2M_APPLICATIONS: Record<string, { name: string; internal_role: "member" | "admin" }> = {
    [process.env.M2M_GITHUB_ACTIONS_CI_CLIENT_ID!]: { name: "github-actions-ci", internal_role: "member" },
    [process.env.M2M_WORKOS_RECONCILE_CLIENT_ID!]: { name: "workos-reconcile", internal_role: "member" },
    [process.env.M2M_DEPLOY_RUNNER_CLIENT_ID!]: { name: "deploy-runner", internal_role: "admin" },
    // NEVER "owner". Adding an entry requires PR review.
    };
  • Backend M2M middleware — extend workos-auth.ts:
    • Add m2mMiddleware to the auth source detection step.
    • Detects Authorization: Bearer <jwt> where the token has 3 JWT segments.
    • Verifies the JWT against the WorkOS JWKS endpoint (cached, 1-hour refresh).
    • Extracts client_id and org_id claims.
    • Looks up the client_id in M2M_APPLICATIONS. If not registered, reject with 401 — no unregistered M2M Application may call the platform.
    • Confirms the org_id claim resolves to the local securityv0-internal tenant (cross-tenant M2M is not supported in Phase 1; customer-owned M2M would be Phase 6+).
    • Builds an authContext with a synthetic service user:
      {
      user: {
      id: <synthetic, stable per client_id>,
      provider_user_id: `m2m:${client_id}`,
      email: `m2m:${app.name}@securityv0.internal`,
      display_name: `M2M: ${app.name}`,
      is_super_admin: true, // via internal-org membership
      internal_role: app.internal_role,
      },
      tenant: <resolved from URL path, not from the token>,
      membership: {
      source: "super_admin_derived",
      role: null, // no direct tenant membership
      permissions: resolvePermissions(
      { role: null, source: "super_admin_derived" }, // no tenant role
      { is_super_admin: true, internal_role: app.internal_role },
      ),
      },
      session: {
      method: "m2m",
      auth_connection_id: null,
      expires_at: token.exp * 1000,
      },
      }
    • Crucially: the permission set is derived from INTERNAL_PERMISSIONS[app.internal_role] only — there is no ALL_SUPERADMIN_PERMISSIONS constant and no short-circuit that grants blanket access. A member-role M2M Application that tries a destructive write returns 403, same as a member-role human.

Client helper — create sv0-platform/scripts/lib/api-client.ts:

  • Reads SV0_M2M_CLIENT_ID, SV0_M2M_CLIENT_SECRET, SV0_API_BASE_URL from env.
  • Exchanges credentials for a short-lived JWT at the WorkOS token endpoint.
  • Caches the JWT until 60 seconds before expiry, then refreshes.
  • Exposes a fetchSv0(path, opts) function that wraps fetch with the bearer token.
  • Each consuming service sets its own pair of env vars (e.g., GitHub Actions workflow sets SV0_M2M_CLIENT_ID=${{ secrets.M2M_GITHUB_ACTIONS_CI_CLIENT_ID }}), so no credential is shared across services.

Staff runbook — document in docs/runbooks/staff-programmatic-access.md:

  • When to use a personal API key (interactive staff work, Claude Code, one-off scripts) vs a per-service M2M Application (long-running services, CI, scheduled jobs).
  • How a staff member generates a personal API key via Settings → API Keys.
  • How to register a new per-service M2M Application (requires a PR against m2m-applications.ts + WorkOS dashboard provisioning + 1Password entry).
  • Credential rotation procedures for each type.
  • Explicit policy: never distribute a personal API key or an M2M credential across multiple services or engineers.

1.9 Extended auth middleware: three sources

The auth middleware pipeline now supports three authentication sources, all resolving to the same AuthContext:

  1. Cookie: sv0_session=... → interactive user (Phase 1 §1.3 path)
  2. Authorization: Bearer ey... (JWT, 3 segments) → per-service M2M Application (§1.8.b above)
  3. Authorization: Bearer sv0_... (prefixed key) → WorkOS API Key — personal (staff, §1.8.a) or customer (Phase 6)

Phase 1 ships all three. For Phase 1, path (3) resolves to personal staff API keys only; Phase 6 adds the customer-facing widget embed and scope-gated permission intersection on top of the same middleware.

The pipeline:

auth-source-detection middleware
├─ cookie → sessionMiddleware
├─ JWT bearer → m2mMiddleware
├─ prefixed-key bearer → apiKeyMiddleware (Phase 6)
└─ none → 401 / redirect to /login


tenantMiddleware → membershipMiddleware → permissionMiddleware → handler

All three paths land on the same route handlers. Route-level permission checks are identical regardless of auth source.

1.10 Integration tests

  • test/integration/auth/workos-login-flow.test.ts: mock WorkOS, exercise the full login → session → tenant route → logout flow.
  • test/integration/auth/webhook-sync.test.ts: replay webhook fixtures, verify mirror state.
  • test/integration/auth/tenant-isolation.test.ts: verify a non-member cannot access another tenant; verify super-admin can.
  • test/integration/auth/dev-bootstrap.test.ts: verify dev bootstrap mints user and session correctly.
  • test/integration/auth/m2m-flow.test.ts: mock WorkOS JWKS, issue a synthetic M2M JWT for the internal org, verify super-admin authContext is produced; same for a regular tenant org.
  • test/integration/auth/auth-source-detection.test.ts: verify the middleware correctly routes between cookie, JWT bearer, and unknown bearer.

Verification

  • npm run ci passes.
  • npm run test:integration passes (requires MongoDB).
  • With AUTH_PROVIDER=workos in staging: log in via AuthKit, land on root, see the seeded securityv0-internal tenant.
  • Webhooks from WorkOS staging reach the endpoint and produce mirror rows.
  • With AUTH_PROVIDER=dev: existing behavior unchanged, all existing tests still pass.
  • Provider abstraction smoke test: swap AUTH_PROVIDER=dev to AUTH_PROVIDER=workos and back; verify the middleware pipeline works identically for both. No provider-specific imports in middleware or route files.
  • Per-service M2M end-to-end: using the staging github-actions-ci credentials, a curl call with Authorization: Bearer <jwt> (obtained from WorkOS token endpoint) returns read-only data from any tenant, but a destructive write (e.g. DELETE /api/v1/findings/:id) returns 403 — confirming that the declared internal_role: "member" is enforced and that no ALL_SUPERADMIN_PERMISSIONS shortcut exists.
  • Per-service M2M attribution: audit logs for the curl calls above show client_id = github-actions-ci-staging, not a generic "securityv0-claude-code".
  • Unregistered M2M rejection: a valid JWT whose client_id is not in m2m-applications.ts returns 401.
  • Personal API key dry run: a staff member logs in, generates a personal API key via the WorkOS hosted widget, uses it in curl against /api/v1/admin/tenants, gets the tenant list (because they are in securityv0-internal), and then revoking the key invalidates subsequent calls within 60 seconds.
  • Claude Code dry run: a staff member puts their own personal API key in ~/.claude/config.json, Claude Code commands successfully call the staging API, and audit logs attribute the calls to that individual user — not a shared bot identity.

Files touched

New:

  • sv0-platform/src/domain/tenants/types.ts
  • sv0-platform/src/domain/users/types.ts
  • sv0-platform/src/api/auth/auth-provider.ts (interface + shared types)
  • sv0-platform/src/api/auth/provider-factory.ts (reads AUTH_PROVIDER, returns implementation)
  • sv0-platform/src/api/auth/providers/workos-provider.ts (WorkOS implementation)
  • sv0-platform/src/api/auth/providers/oidc-provider.ts (stub OIDC implementation)
  • sv0-platform/src/api/auth/providers/dev-provider.ts (local dev implementation)
  • sv0-platform/src/api/auth/session.ts
  • sv0-platform/src/api/auth/permissions.ts
  • sv0-platform/src/api/middleware/auth-middleware.ts (provider-agnostic middleware pipeline)
  • sv0-platform/src/api/routes/auth.ts
  • sv0-platform/src/api/routes/webhooks/workos.ts
  • sv0-platform/src/api/auth/m2m-applications.ts (static registry of per-service M2M Applications → internal role)
  • sv0-platform/scripts/lib/api-client.ts (generic bearer-token client helper used by per-service M2M callers)
  • sv0-documentation/docs/runbooks/staff-programmatic-access.md
  • sv0-platform/test/api/webhooks/workos.test.ts
  • sv0-platform/test/integration/auth/*.test.ts

Modified:

  • sv0-platform/src/storage/mongo/collections.ts
  • sv0-platform/src/storage/storage-adapter.ts
  • sv0-platform/src/api/middleware/auth.ts (delegates to auth-middleware.ts + provider-factory.ts)
  • sv0-platform/src/api/app.ts

Phase 2 — Frontend foundation (routing + login + tenant switcher)

Goal: UI consumes the new auth system. /t/:tenantSlug/... routing, WorkOS login flow, tenant switcher dropdown in the header.

2.1 Router refactor

Update sv0-platform/ui/src/App.tsx:

  • Wrap all existing tenant-scoped routes under a /t/:tenantSlug parent route.
  • Add new public routes: /login, /auth/callback (handled by a small component that hits the backend), /logout.
  • Add root route / that resolves to the user's default tenant (first membership) and redirects.
  • Add super-admin routes: /admin/tenants, /admin/tenants/:slug, /admin/tenants/:slug/config.

2.2 New TenantContext (replaces existing auth-context)

Create sv0-platform/ui/src/context/tenant-context.tsx:

  • Reads :tenantSlug from useParams().
  • Fetches /api/v1/auth/me once on mount, caches current user and memberships via TanStack Query.
  • Exposes: { currentUser, currentTenant, availableTenants, isSuperAdmin, switchTenant(slug) }.

Delete sv0-platform/ui/src/context/auth-context.tsx and remove all its consumers. Replace with tenant-context. This is the biggest frontend diff in the plan.

2.3 API client refactor

Update sv0-platform/ui/src/api/client.ts:

  • Remove x-tenant-id and x-api-key header injection.
  • Add credentials: "include" to all fetches (cookies carry the session).
  • On 401, redirect to /login?return_to=<current URL>.

2.4 Tenant switcher component

Create sv0-platform/ui/src/components/TenantSwitcher.tsx:

  • Header dropdown.
  • Lists availableTenants from context.
  • For super-admins, additionally calls /api/v1/admin/tenants to get the full tenant list and renders them in a sectioned dropdown: "My tenants" / "All tenants (super-admin)".
  • Selecting a tenant navigates to /t/:slug/ (the currently-active page on a different tenant, if it makes sense — otherwise the tenant's dashboard).

Place in the global header (AppHeader.tsx or equivalent).

2.5 Login page

Create sv0-platform/ui/src/pages/LoginPage.tsx:

  • Minimal page with a "Sign in" button that hits GET /login?return_to=....
  • Displays any auth error passed back as a query param.

2.6 Remove the old Settings auth form

Update sv0-platform/ui/src/pages/SettingsPage.tsx:

  • Remove the tenant ID and API key fields.
  • Settings page becomes: display current user info, logout button, (future: per-tenant config — deferred to Phase 3).

2.7 Super-admin UI (minimal)

Create sv0-platform/ui/src/pages/admin/AdminTenantsPage.tsx:

  • Table of all tenants (for super-admins only) with columns: slug, display name, status, created at, SSO enforced.
  • Each row links to /t/:slug/ (to "enter" the tenant) and /admin/tenants/:slug (for management).

Create sv0-platform/ui/src/pages/admin/AdminTenantDetailPage.tsx:

  • Detail view of one tenant.
  • Buttons: "Open as super-admin", "Generate Admin Portal link" (deferred to Phase 4), "Edit config" (deferred to Phase 3), "Convert to paid" (deferred to Phase 4).

2.8 Backend routes for admin UI

Create sv0-platform/src/api/routes/admin.ts:

  • GET /api/v1/admin/tenants — requires INTERNAL_LIST_ALL_TENANTS; returns full tenant list.
  • GET /api/v1/admin/tenants/:slug — requires INTERNAL_LIST_ALL_TENANTS; returns tenant + config.

Verification

  • Log in via staging AuthKit with a @securityv0.com email; land on the root, see the tenant switcher populated with securityv0-internal + demo-acme.
  • Navigate to /t/demo-acme/clusters; see the tenant's data.
  • Paste /t/demo-acme/findings/<id> in a new tab; see the same data after (re)login.
  • Log in as a non-super-admin test user that is a member of only demo-acme; see only that tenant in the dropdown; attempt to navigate to /t/securityv0-internal/ and receive 404.
  • All existing UI tests pass (with routes updated).
  • cd ui && npm run ci passes.

Files touched

New:

  • sv0-platform/ui/src/context/tenant-context.tsx
  • sv0-platform/ui/src/components/TenantSwitcher.tsx
  • sv0-platform/ui/src/pages/LoginPage.tsx
  • sv0-platform/ui/src/pages/admin/AdminTenantsPage.tsx
  • sv0-platform/ui/src/pages/admin/AdminTenantDetailPage.tsx
  • sv0-platform/src/api/routes/admin.ts

Modified:

  • sv0-platform/ui/src/App.tsx
  • sv0-platform/ui/src/api/client.ts
  • sv0-platform/ui/src/pages/SettingsPage.tsx
  • sv0-platform/ui/src/components/AppHeader.tsx (or equivalent)

Deleted:

  • sv0-platform/ui/src/context/auth-context.tsx

Phase 3 — Evaluation flow tooling and per-tenant config

Goal: Make it trivial for a super-admin to provision a new evaluation tenant and edit per-tenant config. This is the phase that unblocks sales demos.

3.1 Provisioning script

Create sv0-platform/scripts/provision-eval-tenant.ts:

  • CLI with flags: --name "Acme Corp", --domain acme.com, --invite ciso@acme.com, --seed, --env dev|prod.
  • Performs:
    1. Create WorkOS Organization via createOrganization.
    2. Insert tenants row (status: "evaluation", generated slug from name).
    3. Insert empty tenant_configs row.
    4. If --seed, run the existing seed-demo-w1.ts pattern against this new tenant.
    5. Create WorkOS invitation for the email.
    6. Print the tenant slug, /t/:slug/ URL, and invitation status for copy-paste into Slack.
  • Writes: docs/runbooks/provision-eval-tenant.md with usage examples.

3.2 Tenant config editor (super-admin only)

Extend AdminTenantDetailPage.tsx:

  • Form for tenant_configs fields: Jira base URL, Jira project key, feature flags (JSON editor), connector credential refs (JSON editor).
  • Save button hits PATCH /api/v1/tenants/:slug/config.

Create sv0-platform/src/api/routes/tenants.ts:

  • PATCH /api/v1/tenants/:slug/config — gated by TENANT_WRITE_CONFIG.

3.3 Jira integration hookup

Update the remediation service (sv0-platform/src/services/remediation.ts or wherever Jira deep links are built) to read tenant_configs.jira_base_url and jira_project_key instead of hardcoded or env-var config. This is the first consumer of tenant_configs and validates the pattern.

Verification

  • Run npx tsx scripts/provision-eval-tenant.ts --name "Test Co" --invite test@example.com --seed on staging.
  • Check WorkOS dashboard: new Organization visible.
  • Check sv0: new tenant visible in /admin/tenants, with seeded data at /t/test-co/.
  • Edit Jira config on the admin page, save, verify a Jira deep link in the remediation UI uses the configured base URL.
  • Integration test test/integration/scripts/provision-eval-tenant.test.ts (against a WorkOS staging sandbox).

Files touched

New:

  • sv0-platform/scripts/provision-eval-tenant.ts
  • sv0-platform/src/api/routes/tenants.ts
  • sv0-documentation/docs/runbooks/provision-eval-tenant.md

Modified:

  • sv0-platform/ui/src/pages/admin/AdminTenantDetailPage.tsx
  • sv0-platform/src/services/remediation.ts (or equivalent)

Phase 4 — Enterprise SSO on-ramp

Goal: Convert an evaluation tenant to a paid enterprise customer with self-serve SAML onboarding.

Extend AdminTenantDetailPage.tsx:

  • "Generate Admin Portal link" button.
  • Calls POST /api/v1/admin/tenants/:slug/admin-portal-link.
  • Response is a one-time WorkOS URL with expiry.
  • UI shows the link and a copy-paste email template: "Forward this to your IT admin. They will configure SAML for your Okta (or equivalent) in about 5 minutes. The link expires in 24 hours."

Add POST /api/v1/admin/tenants/:slug/admin-portal-link to admin.ts:

  • Requires TENANT_GENERATE_ADMIN_PORTAL_LINK.
  • Calls workosClient.generateAdminPortalLink(provider_org_id, "sso").
  • Returns { link, expires_at }.

4.2 Convert-to-paid action

Extend AdminTenantDetailPage.tsx:

  • "Convert to paid" button (visible only when status: "evaluation").
  • Updates tenants.status = "active" via PATCH /api/v1/admin/tenants/:slug.

Add the route and permission.

4.3 SSO enforcement — per-tenant, per-connection

The naive "if sso_enforced and session.method !== 'sso' then reject" check is not sufficient. A staff member who authenticated via SSO into a different customer tenant yesterday would still be holding a session with auth_method = "sso", and the naive check would let them reach the enforced tenant without going through the enforced tenant's IdP. Enforcement must be tied to the specific WorkOS connection.id for the current tenant.

Per §4.4 of the architecture doc, the enforcement rule is:

if authContext.tenant.sso_enforced AND NOT authContext.user.is_super_admin:
allowed_connection_ids := workos.listActiveConnections(authContext.tenant.provider_org_id)
if authContext.session.auth_method != "sso"
OR authContext.session.auth_connection_id NOT IN allowed_connection_ids:
redirect to /login?return_to=<url>&reason=sso_required&org=<tenant.slug>

Super-admins are exempt because they do not hold a direct membership in enforced customer tenants — their access is derived from securityv0-internal membership — and they already authenticated via the internal org's Google Workspace connection.

Webhook handlers

Extend connection.activated:

  • Look up the tenant by provider_org_id.
  • Set tenants.sso_enforced = true.
  • No need to store the connection IDs locally — middleware resolves them live from WorkOS (with a short TTL cache) so we never serve stale allow-lists.

Extend connection.deactivated:

  • Look up the tenant by provider_org_id.
  • Query WorkOS for remaining active connections on that Organization. If zero remain, set tenants.sso_enforced = false (customer is back to non-enforced state). If any remain, leave sso_enforced = true.

Middleware implementation

Extend workos-auth.ts:

  • Add ssoEnforcementCheck to the middleware pipeline, after tenantMiddleware and membershipMiddleware.
  • Skip the check if authContext.user.is_super_admin is true.
  • Otherwise, call workosClient.listActiveConnections(authContext.tenant.provider_org_id) (cached 60s) and compare against authContext.session.auth_connection_id.
  • On mismatch, redirect to /login?return_to=<url>&reason=sso_required&org=<tenant.slug>.
  • The /login handler, seeing reason=sso_required&org=..., passes the org hint through to AuthKit so the user is routed into the correct IdP on the next attempt rather than being shown a generic picker.

Test coverage for §4.3

  • Session with auth_method: "password" → blocked on enforced tenant.
  • Session with auth_method: "sso" but auth_connection_id belonging to a different tenant → blocked.
  • Session with auth_method: "sso" and auth_connection_id in the enforced tenant's active connection list → allowed.
  • Super-admin with auth_method: "oauth_google" (internal org) → allowed on any enforced tenant.
  • Per-service M2M (auth_method: "m2m") → not rejected by SSO enforcement, because M2M Applications are not human users and cannot perform SSO. They can only access a customer tenant if a future phase grants them cross-tenant access (Phase 1 restricts M2M to securityv0-internal).

4.4 Staging dry run

  • Configure a WorkOS staging SAML connection against a test IdP (Okta Developer Edition or WorkOS's test IdP).
  • Convert a staging tenant, send yourself the Admin Portal link, complete SAML setup.
  • Verify connection.activated webhook fires and sso_enforced flips.
  • Verify magic link is blocked for that tenant; SAML login works.
  • Document the dry run in docs/runbooks/customer-enterprise-onboarding.md.

Verification

  • End-to-end dry run on staging: evaluation → Admin Portal link → SAML config → sso_enforced=true → magic link blocked → SAML login succeeds.
  • Integration test: webhook fixture for connection.activated updates sso_enforced.

Files touched

New:

  • sv0-documentation/docs/runbooks/customer-enterprise-onboarding.md

Modified:

  • sv0-platform/ui/src/pages/admin/AdminTenantDetailPage.tsx
  • sv0-platform/src/api/routes/admin.ts
  • sv0-platform/src/api/routes/webhooks/workos.ts
  • sv0-platform/src/api/middleware/workos-auth.ts (session method enforcement)

Phase 5 — Housekeeping and legacy removal

Goal: Remove the legacy auth code (the old REQUIRE_AUTH path). SaaS deployments run AUTH_PROVIDER=workos; local dev uses AUTH_PROVIDER=dev; partner deployments use AUTH_PROVIDER=oidc. Migrate existing tenants to the new model.

5.1 Existing tenant backfill

Today, "tenants" exist only as string values denormalized on entity/finding/etc. documents. To make them first-class, we need a backfill:

Create sv0-platform/scripts/backfill-tenants.ts:

  • Scans all collections for distinct tenant_id values.
  • For each distinct value, creates a tenants row with slug = <value>, status = "active", display_name = <prompted or derived>, and a corresponding WorkOS Organization in production.
  • Prints a report.
  • Idempotent — safe to re-run.

Run it once against production after WorkOS prod is wired up, under manual supervision.

5.2 Migrate internal scripts from legacy keys to the Phase 1.7 credential split

Phase 1 already shipped apiKeyMiddleware (personal staff keys) and m2mMiddleware (per-service M2M Applications). In Phase 5 we migrate the remaining non-interactive internal clients onto whichever of the two is appropriate:

  • Interactive developer scripts (ad-hoc curl, one-off tsx scripts run from a laptop, Claude Code): use the running engineer's personal API key (§1.8.a). Document in docs/runbooks/staff-programmatic-access.md how to put the key in a local env var that scripts/lib/api-client.ts reads. Personal keys are audit-attributable to the individual engineer.
  • Seed scripts (scripts/seed-demo-w1.ts and friends) when run manually by an engineer: use that engineer's personal API key, same as any other interactive script.
  • Seed scripts when run from CI: use the github-actions-ci per-service M2M Application credentials injected as GitHub Actions secrets. The same script supports both modes because api-client.ts reads whichever of SV0_M2M_CLIENT_ID or SV0_API_KEY is set.
  • Connector workers: mostly upstream writers, so typically a no-op. If any reach into the platform API, register a new per-service M2M Application (e.g. connector-sync-worker) via a PR against m2m-applications.ts.
  • Reconciliation worker (workos-reconcile): uses its own per-service M2M Application workos-reconcile-{staging,production} already provisioned in Phase 0.
  • CI/CD (GitHub Actions): every workflow that hits the API uses github-actions-ci-{staging,production}, which is scoped to internal role member (read-only) by default. Any workflow that needs writes requires a PR to promote a separate M2M Application to admin with justification — we do not loosen the blanket role.
  • Deploy runner: uses deploy-runner-{staging,production}, which is granted admin per its registry entry.

After this migration, the only legitimate X-API-Key usage is removed from the codebase. There is no shared bot credential under any name.

5.3 Remove legacy auth path

  • Delete the AUTH_BACKEND=legacy branch from auth.ts.
  • Remove env vars: REQUIRE_AUTH, ALLOWED_API_KEYS, JWT_JWKS_URI, JWT_ISSUER, JWT_AUDIENCE, JWT_ALGORITHMS, JWT_CLOCK_TOLERANCE_SEC.
  • Remove X-API-Key and X-Tenant-Id header handling (except where they are explicitly the service-token mechanism).
  • Update .env.example and all runbooks.
  • Delete the old SettingsPage tenant/api-key form code if not already removed.

5.4 Reconciliation job

Create sv0-platform/src/workers/workos-reconcile.ts:

  • Daily job.
  • Fetches all Organizations from WorkOS, diffs against local tenants, logs mismatches, auto-repairs obvious drift.
  • For each Organization, fetches memberships, diffs against local memberships, logs/repairs.
  • Reports go to structured logs and a simple internal status endpoint.

5.5 Audit logging

Emit structured logs from the auth middleware and webhook handlers:

  • Every request: { user_id, tenant_id, method, path, status, session_method, ts }.
  • Every webhook event: { event_type, event_id, mutation, ts }.
  • Every super-admin action (generate Admin Portal link, provision eval, edit tenant config): { actor_user_id, action, target_tenant_id, ts }.

Forward to existing log pipeline. Deferred: dedicated UI.

Verification

  • grep -r "X-API-Key\|REQUIRE_AUTH" sv0-platform/src sv0-platform/ui sv0-platform/scripts returns only service-token and documentation references.
  • All tests pass.
  • Production deployment with AUTH_BACKEND=workos, existing data intact, all users can log in.
  • Reconciliation job reports clean state on the first run.

Files touched

New:

  • sv0-platform/scripts/backfill-tenants.ts
  • sv0-platform/src/workers/workos-reconcile.ts

Modified / deleted: many across sv0-platform/src/api/, sv0-platform/src/shared/config/, sv0-platform/scripts/seed-*.ts. Delete the old X-API-Key handling code entirely.


Phase 6 — Customer-facing programmatic access (API Keys widget + MCP)

Goal: Give customer users the ability to generate their own API keys and to authorize MCP clients / AI agents to access their sv0 data with scoped, revocable delegation.

When to ship: After Phase 5. Not a pilot blocker, but important for the first enterprise customer who asks "how do my engineers script against your API?" or "can Claude read my findings?".

6.1 API Keys widget integration

WorkOS dashboard configuration:

  • Enable the API Keys product in both staging and production WorkOS environments.
  • Configure key naming, prefix (e.g., sv0_live_ for prod, sv0_test_ for staging), expiration policy defaults, and scope registry.
  • Define the scope registry to match sv0's Permission enum (e.g., findings:read, findings:write, entities:read, evidence_packs:read).

Backend verification:

  • Extend sv0-platform/src/api/middleware/workos-auth.ts with apiKeyMiddleware:
    • Detects Authorization: Bearer sv0_... (prefix match).
    • Calls WorkOS API Keys verification endpoint with the bearer token.
    • Caches the result (user ID, org ID, scopes) for 60 seconds to avoid per-request latency.
    • On cache miss or expiry, re-verifies.
    • On revocation, cache expiry ensures effective revocation within 60 seconds.
  • Builds an authContext with session.method = "api_key", the user from the local mirror, and permissions intersected from both the user's membership role AND the API key's declared scopes.
  • The intersection rule is important: an API key can never grant more access than the owning user has. If a member-role user generates a key with findings:delete scope, the scope is silently dropped because the user's role doesn't allow delete.

Frontend widget embed:

  • Add a new Settings tab: "API Keys".
  • Embed the WorkOS API Keys widget (per WorkOS React SDK docs).
  • Document the feature in docs/runbooks/api-keys-for-customers.md.
  • Add a "Create API Key" button; clicking it opens the widget.

Permission gating:

  • Only tenant members with the admin or owner role (or super-admins) can create keys. Regular member users see a read-only view of their own existing keys.

6.2 MCP / OAuth Applications support

WorkOS dashboard configuration:

  • Enable AuthKit OAuth Applications in both environments.
  • Enable Client ID Metadata Document (CIMD) support per WorkOS docs.
  • Configure the consent screen: logo, app name, scope descriptions.
  • Define the scope registry (same as API Keys §6.1).

Backend changes:

  • Extend sessionMiddleware to also accept WorkOS OAuth Application access tokens. Since these are just AuthKit tokens scoped to a user, the existing session verification path already handles them — we only need to:
    • Detect the scope claim in the token and enforce scope restrictions on each route.
    • Log the client_id of the requesting OAuth Application for audit attribution.

Frontend "Authorized Applications" page:

  • New Settings tab: "Authorized Apps".
  • Calls a WorkOS API to list applications the user has authorized to access their account.
  • Shows name, requested scopes, last used, and a Revoke button.
  • Revoking triggers WorkOS to invalidate all tokens issued to that application for that user.

MCP metadata endpoint:

  • Implement /.well-known/oauth-authorization-server per OAuth 2.0 Authorization Server Metadata spec (RFC 8414). WorkOS AuthKit is the actual AS; this endpoint just points MCP clients to the WorkOS discovery document.
  • Implement /.well-known/openid-configuration similarly (for MCP clients that use OIDC discovery instead).

Dry run with a real MCP client:

  • Set up a local Claude Desktop MCP server that connects to staging sv0.
  • Walk through the OAuth consent flow.
  • Verify the access token is issued and the client can make API calls on behalf of the user.
  • Document the full flow in docs/runbooks/mcp-integration.md for customers.

6.3 Documentation and customer enablement

  • docs/runbooks/api-keys-for-customers.md — how to generate, use, rotate, and revoke API keys.
  • docs/runbooks/mcp-integration.md — how to connect Claude Desktop, Cursor, or any MCP-compatible client to sv0.
  • docs/runbooks/programmatic-access-overview.md — decision tree for customers ("should I use an API key or an MCP integration?").
  • Example snippets in Python, TypeScript, and curl for both flows.

Verification

  • Customer user logs in, opens Settings → API Keys, generates a key, copies it, uses it in a curl command, gets data back.
  • Customer user revokes the key; within 60 seconds, the same curl returns 401.
  • Customer user's member role cannot create keys with write scopes — UI hides the option and backend rejects it.
  • A Claude Desktop MCP client completes the OAuth consent flow against staging sv0, obtains a token, makes API calls on behalf of the user, and the user can revoke the authorization from the "Authorized Apps" page.
  • End-to-end CIMD test: an MCP client with only a metadata URL (no prior registration) successfully completes OAuth discovery and authorization.

Files touched

New:

  • sv0-platform/src/api/middleware/workos-auth.ts (extended with apiKeyMiddleware + OAuth scope enforcement)
  • sv0-platform/src/api/routes/well-known.ts (OAuth metadata endpoints)
  • sv0-platform/ui/src/pages/settings/ApiKeysTab.tsx
  • sv0-platform/ui/src/pages/settings/AuthorizedAppsTab.tsx
  • sv0-documentation/docs/runbooks/api-keys-for-customers.md
  • sv0-documentation/docs/runbooks/mcp-integration.md
  • sv0-documentation/docs/runbooks/programmatic-access-overview.md

Modified:

  • sv0-platform/ui/src/pages/SettingsPage.tsx (add the new tabs)
  • sv0-platform/src/api/middleware/workos-auth.ts
  • sv0-platform/src/api/auth/permissions.ts (map scopes ↔ permissions)

Open questions specific to Phase 6

  1. API Keys widget styling — can the widget be themed to match sv0 branding? If not, document it as a known cosmetic deviation.
  2. CIMD production-readiness — WorkOS CIMD is listed in the docs but we should verify it works end-to-end with at least two real MCP clients (Claude Desktop and one other) before publicizing MCP support.
  3. Scope granularity — how fine-grained should scopes be? Suggested starting set: findings:read, findings:write, entities:read, evidence_packs:read, remediation:read, connectors:read. Refinement based on real customer asks.
  4. Rate limits per programmatic caller — defaults (600 req/min M2M, 300 req/min API key) need load testing before going public.
  5. Scope-vs-role intersection UX — when a member tries to create a key with write scopes, do we silently drop the scope or show an error? Recommendation: show an error explaining which scopes are unavailable and why.

Critical files reference

For quick navigation during implementation, the critical files by responsibility:

ResponsibilityFile
AuthProvider interfacesv0-platform/src/api/auth/auth-provider.ts (new)
Provider factorysv0-platform/src/api/auth/provider-factory.ts (new)
WorkOS implementationsv0-platform/src/api/auth/providers/workos-provider.ts (new)
OIDC implementationsv0-platform/src/api/auth/providers/oidc-provider.ts (new, stub)
Dev implementationsv0-platform/src/api/auth/providers/dev-provider.ts (new)
Session encryptionsv0-platform/src/api/auth/session.ts (new)
Permission definitionssv0-platform/src/api/auth/permissions.ts (new)
Auth middleware (provider-agnostic)sv0-platform/src/api/middleware/auth-middleware.ts (new)
Main auth entry pointsv0-platform/src/api/middleware/auth.ts (modified)
Auth routessv0-platform/src/api/routes/auth.ts (new)
Admin routessv0-platform/src/api/routes/admin.ts (new)
Tenant config routessv0-platform/src/api/routes/tenants.ts (new)
Webhook receiversv0-platform/src/api/routes/webhooks/workos.ts (new)
Storage schemasv0-platform/src/storage/mongo/collections.ts (modified)
Storage adaptersv0-platform/src/storage/storage-adapter.ts (modified)
Tenant typessv0-platform/src/domain/tenants/types.ts (new)
User typessv0-platform/src/domain/users/types.ts (new)
Frontend tenant contextsv0-platform/ui/src/context/tenant-context.tsx (new)
Frontend routersv0-platform/ui/src/App.tsx (modified)
Frontend API clientsv0-platform/ui/src/api/client.ts (modified)
Tenant switchersv0-platform/ui/src/components/TenantSwitcher.tsx (new)
Login pagesv0-platform/ui/src/pages/LoginPage.tsx (new)
Admin tenants pagesv0-platform/ui/src/pages/admin/AdminTenantsPage.tsx (new)
Admin tenant detailsv0-platform/ui/src/pages/admin/AdminTenantDetailPage.tsx (new)
Settings page (shrunken)sv0-platform/ui/src/pages/SettingsPage.tsx (modified)
Provisioning scriptsv0-platform/scripts/provision-eval-tenant.ts (new)
Backfill scriptsv0-platform/scripts/backfill-tenants.ts (new)
Reconciliation workersv0-platform/src/workers/workos-reconcile.ts (new)

Open questions (must be answered before Phase 1 starts)

  1. WorkOS pricing confirmation. Does the 1M MAU AuthKit free tier include SSO connections, or are SSO connections billed separately from $125/month each regardless of MAU? Confirm with WorkOS sales with the exact scenario from ADR-017.
  2. WorkOS startup program terms. Does sv0 qualify? If yes, what is waived and for how long?
  3. Connection deactivation billing. Does disabling an SSO connection mid-cycle actually stop the charge for that connection?
  4. M2M Applications and API Keys widget pricing. Confirm these are bundled with AuthKit at no additional cost, or priced per-application / per-key. Not documented on the public pricing page.
  5. Session cookie domain. Single-domain (app.securityv0.com) or include staging? The decision affects cookie domain attribute. Recommendation: per-env cookies, no sharing.
  6. Super-admin session duration. 7 days (same as regular users) or 24 hours (tighter)? Recommendation: 24 hours with refresh on activity.
  7. Jira deep link location. Confirm where the current remediation code reads Jira config. If it's hardcoded, Phase 3 includes the migration; if it's already per-tenant but in a different collection, adjust the plan.
  8. Existing tenant backfill. How many distinct tenant strings exist in production today? If few (likely <5), manual backfill is fine. If more, we need a review step before Phase 5 runs.
  9. API Keys widget theming. Can the WorkOS API Keys widget be themed to match sv0 branding? If not, acceptable as a cosmetic deviation for Phase 6.
  10. MCP CIMD end-to-end test. Before public MCP support in Phase 6, dry-run CIMD discovery with at least two real MCP clients (Claude Desktop + one other) on staging.

Verification checklist (overall)

By the time Phase 5 is complete, all of these must be true (Phase 6 adds customer-facing programmatic access on top):

Interactive user flows:

  • A SecurityV0 team member can log in via @securityv0.com Google Workspace through AuthKit.
  • A SecurityV0 team member sees every tenant in the header dropdown.
  • A SecurityV0 team member can paste /t/acme/findings/abc123 in Slack and another member can click it and land on the same view after logging in.
  • A non-super-admin user sees only their own tenants in the dropdown and receives 404 (not 403) when navigating to unauthorized tenants.
  • A prospect can click a magic-link invitation, log in, and see their evaluation tenant.

Enterprise SSO flow:

  • A super-admin can generate an Admin Portal link for a paying customer.
  • A customer IT admin walks through the Admin Portal and configures SAML against a test IdP successfully.
  • After SAML is activated, tenants.sso_enforced = true and magic link is blocked for that tenant.

Provisioning and tooling:

  • scripts/provision-eval-tenant.ts creates a new WorkOS Organization, tenant row, invitation, and seeded data in one command.
  • Daily reconciliation job runs cleanly.

SecurityV0 team programmatic access:

  • A staff member can generate a personal API key via Settings → API Keys (WorkOS hosted widget) and use it from their own Claude Code / scripts. Audit logs attribute calls to that individual user.
  • Deleting the staff user (or revoking their personal key) invalidates subsequent calls within 60 seconds.
  • Each long-running service (github-actions-ci, workos-reconcile, deploy-runner) has its own M2M Application and its own 1Password entry. There is no single shared securityv0-claude-code credential.
  • An M2M Application with declared internal_role: "member" can read across tenants but is rejected with 403 on destructive writes. No ALL_SUPERADMIN_PERMISSIONS constant exists in the code.
  • An M2M JWT whose client_id is not registered in m2m-applications.ts is rejected with 401.
  • Rotating a per-service M2M credential in WorkOS dashboard + the corresponding 1Password entry propagates on next service restart; no other service is affected.

Customer programmatic access (Phase 6):

  • Customer users can generate, list, rotate, and revoke API keys via the embedded WorkOS widget in Settings.
  • API keys respect scope × role intersection (cannot grant more than the user has).
  • Revoking an API key invalidates it within 60 seconds.
  • A Claude Desktop MCP client can authorize against staging sv0 via OAuth consent and make API calls.
  • Users can view and revoke authorized OAuth applications from Settings → Authorized Apps.
  • An MCP client with only a CIMD metadata URL can successfully complete OAuth discovery.

Cleanup:

  • REQUIRE_AUTH=false, X-API-Key header auth (user path), X-Tenant-Id header auth are all removed from the code.
  • All internal scripts go through scripts/lib/api-client.ts using either a personal API key (interactive) or a per-service M2M credential (services/CI); no legacy key paths remain.
  • npm run ci and cd ui && npm run ci both pass.

Risks and mitigations

RiskLikelihoodImpactMitigation
WorkOS pricing turns out materially different from ADR-017 readingMediumHighConfirm with sales before Phase 1 starts. If worse, revisit Clerk fallback in ADR-017 §Alternatives.
WorkOS outage blocks new logins during rolloutLowMediumStaged rollout (staging → dev → prod). Existing sessions continue working during outages. Monitor status.workos.com.
Webhook event loss causes mirror driftLowMediumReconciliation job catches drift within 24h. Idempotency prevents duplicate harm.
URL refactor breaks external deep links shared in Slack historyMediumLowNo external customer deep links exist yet. Internal Slack links get a migration-aware redirect for 30 days.
Backfilling existing tenants to WorkOS Organizations mis-maps slugsLowHighManual review before running backfill-tenants.ts in production. Dry-run mode first.
Service-token migration breaks seed scriptsMediumLowapi-client.ts transparently accepts either a personal API key or a per-service M2M credential pair; migrate scripts in the same PR as Phase 5.
Staff use personal API keys as shared bot credentials anywayMediumHighRunbook explicitly forbids sharing keys. Audit logs attribute every call to an individual; reconciliation worker flags suspicious usage patterns. Rotation is per-person, not coordinated.
A per-service M2M Application is added directly in WorkOS without a PRMediumMediumm2mMiddleware rejects any client_id not registered in m2m-applications.ts. Reconciliation worker in §5.4 diffs the WorkOS dashboard against the static registry and alerts.
Local dev bootstrap drifts from production auth pathMediumMediumSame middleware pipeline runs in both modes; only session minting differs. Integration tests cover both paths.