Skip to content

Centralized IAM & Authorization Platform

Status: v1.0 — build-ready · Audience: an autonomous LLM coding agent + human reviewers
Codename: iam · Repo style: single pnpm + Turborepo monorepo
Methodology: Test-Driven. Tests are the spec. Every unit below ships its acceptance test FIRST.


How an LLM Must Use This Document

This PRD is written outcome-first. For every buildable unit you will find this exact shape:

Contract — what the unit guarantees (the interface). Test (write FIRST) — executable acceptance criteria. Author and run these before implementation. They MUST fail (red) before code exists. Implementation — how to satisfy the test. Done when — the green gate.

Rules you MUST follow:

  1. Never write implementation before its test exists and fails. A unit is not “started” until its red test is committed.
  2. RFC-2119 keywords (MUST/MUST NOT/SHOULD/MAY) are normative.
  3. §14 Invariants override convenience everywhere. If a task conflicts with an invariant, stop and surface it.
  4. The SQL schema (§5) is the contract, not any ORM. Kysely types are generated from the DB, never the reverse.
  5. Stack (§2) is fixed. Do not substitute libraries silently.
  6. Build in milestone order (§13). Each milestone is a red→green cycle.

Vision & Scope

One microservice that is three things at once:

  1. Identity broker — users sign in with GitHub, Google, and enterprise IdPs (SAML/OIDC); all resolve to one canonical identity.
  2. OAuth 2.1 / OIDC provider — ~30 first-party apps are OAuth clients; they redirect to one hosted login and receive verifiable JWTs.
  3. Authorization engine — roles & permissions defined ONCE, enforced identically in React (hide UI) and in every API (block calls), across a multi-tenant hierarchy.

Outcome metric: a new app integrates auth + authz in under 1 day via one client package + one declarative client-config PR. Zero bespoke permission code.

Non-goals (v1)

  • Not a per-app feature backend. Owns identity, tokens, and authorization decisions only.
  • Not a general policy language. Authorization = RBAC + scoped grants. ReBAC is a deferred option (§9.4).
  • Not cross-domain cookie sharing. Apps get tokens via the OAuth flow.
  • ORM-agnostic, not DB-agnostic. Postgres is committed; use its native types freely.

Fixed Stack

ConcernChoiceNotes
Auth frameworkBetter Authcore + plugins below
Federation INsocial providers (GitHub, Google) + @better-auth/ssoSAML 2.0 + OIDC
Tokens OUTOAuth 2.1 Provider pluginreplaces deprecating OIDC Provider; JWKS, introspection (RFC 7662), revocation (RFC 7009)
JWT/JWKSjwt pluginexposes /api/auth/jwks; kid in header
RBAC primitivesorganization + admin plugins, createAccessControlresource:action statements
AuthorizationCASL (@casl/ability, @casl/react)isomorphic; one Ability gates UI + API
Query layerKysely (+ kysely-codegen)typesafe SQL; DB is source of truth. No Prisma.
DBPostgreSQLuuid, jsonb, partial indexes, recursive CTEs, RLS optional
MigrationsAtlas (or dbmate)ORM-independent, readable by non-TS consumers
Downstream (TS)@iam/client SDKAuthProvider, PermissionsProvider, hooks
Test (TS)Vitest + Testing Libraryunit/integration
Test (components)Storybook + @storybook/test play fns + a11ycomponent states ARE the tests
Test (E2E)Playwrightfull auth flows
Schema validationAjv (JSON Schema)DB & API payload validators

Monorepo Layout

iam/
├── apps/
│ ├── iam-service/ # Better Auth + Kysely + PDP (the backend API)
│ └── iam-login/ # hosted login + OAuth consent UI (the IdP front door)
├── packages/
│ ├── iam-core/ # ISOMORPHIC: types, permission catalog, CASL ability factory, JSON Schemas
│ ├── iam-db/ # Kysely types (generated), repositories, query fns
│ ├── iam-client/ # React: AuthProvider, PermissionsProvider, Can, hooks, callback
│ ├── iam-ui/ # React components: forms, settings, members (design-system driven)
├── db/
│ ├── migrations/ # Atlas SQL — the CONTRACT
│ └── schema/ # *.schema.json + domain.yaml (source of truth definitions)
├── test/
│ ├── fixtures/ # shared seed data (users, scopes, roles)
│ └── e2e/ # Playwright
└── turbo.json

Dependency direction (MUST hold): iam-core depends on nothing app-specific. iam-db, iam-client, iam-ui depend on iam-core. Apps depend on packages. Nothing depends back upward. The CASL ability factory lives in iam-core precisely because it must run identically in React and in the Node API.


Architecture & Workflows (LLM-Friendly Diagrams)

The Three Planes

Upstream IdPs IAM SERVICE
GitHub / Google / Okta / Entra ──┐
│ federation in (social + @better-auth/sso)
┌──────────────────────────────┐
30 apps ──auth-code │ A. AuthN / Broker │ Better Auth core
+PKCE────────▶ │ B. OAuth 2.1 Provider (IdP) │ → issues JWT, JWKS, introspect
◀──JWT/JWKS── │ C. Authorization (PDP) │ CASL + catalog + scoped roles
└──────────────────────────────┘
│ Kysely
PostgreSQL

Flow: Federated Login + First-Party App Token

sequenceDiagram
participant App as App (OAuth client)
participant IAM as IAM (Better Auth)
participant IdP as GitHub/Okta
participant API as Downstream API
App->>IAM: redirect /oauth2/authorize (PKCE challenge, audience)
IAM-->>App: no session -> show hosted login
App->>IAM: user picks "GitHub"
IAM->>IdP: OAuth/OIDC federation
IdP-->>IAM: verified identity (email verified?)
IAM->>IAM: link/create canonical user (verified-email rule)
IAM-->>App: redirect back with authorization code
App->>IAM: /oauth2/token (code + PKCE verifier)
IAM-->>App: access JWT (aud=app-api) + refresh
App->>API: GET /resource Authorization: Bearer <JWT>
API->>IAM: (first time) GET /api/auth/jwks [cached after]
API->>API: verify sig + iss + aud + exp; build CASL ability
API-->>App: 200 filtered data / 403

Flow: Authorization Decision With Scope Inheritance

sequenceDiagram
participant C as Client/<Can>
participant API as API guard (Require)
participant PDP as PDP /v1/authz/check
participant DB as Postgres
C->>API: action="invoice:approve" resource=project:P3
API->>PDP: { subject, action, resource, scopeNode: project:P3 }
PDP->>DB: recursive CTE: ancestors(P3) = [P3, W1, Ent]
DB-->>PDP: union of grants from assignments at P3/W1/Ent
PDP->>PDP: build CASL ability; ability.can(action, subject)
PDP-->>API: { allow: true/false, reason }
API-->>C: proceed / 403 (and <Can> hides the button)

Domain Model & Database (The Contract)

Hierarchy & Concepts

  • Scope tree: enterprise → workspace → project. Modeled as ONE self-referential scope_node table for clean ancestor resolution.
  • Permission: a resource:action string (e.g. invoice:approve). Drupal-style granular. Lives in a catalog keyed by audience (the app/API).
  • Role: a named bundle of grants, scoped to a tier or global. 5 standardized roles (§5.5) + custom roles.
  • Assignment: (subject, role, scope_node). A user MAY hold different roles at different nodes.
  • Effective permissions on node N = union of grants from every assignment whose scope is N or an ancestor of N (inheritance flows DOWN the tree). Optional inherit=false assignments isolate a node.

Domain Definition — db/schema/domain.yaml (Source of Truth)

scope_node:
description: A node in the enterprise→workspace→project tree.
fields:
id: { type: uuid, pk: true }
type: { type: enum, values: [enterprise, workspace, project] }
parent_id: { type: uuid, nullable: true, fk: scope_node.id }
name: { type: text }
created_at:{ type: timestamptz, default: now }
invariants:
- "enterprise nodes MUST have parent_id = null"
- "workspace.parent MUST be an enterprise"
- "project.parent MUST be a workspace"
permission_catalog:
description: Every resource:action an app declares it owns.
fields:
id: { type: uuid, pk: true }
audience: { type: text } # e.g. billing-api
resource: { type: text } # e.g. invoice
action: { type: text } # e.g. approve
unique: [audience, resource, action]
role:
fields:
id: { type: uuid, pk: true }
audience: { type: text, nullable: true } # null => global role
name: { type: text } # owner|admin|editor|viewer|billing|custom
is_system: { type: boolean, default: false }
unique: [audience, name]
role_grant:
description: The permissions a role confers.
fields:
id: { type: uuid, pk: true }
role_id: { type: uuid, fk: role.id, on_delete: cascade }
resource: { type: text }
action: { type: text }
unique: [role_id, resource, action]
role_assignment:
description: A subject holding a role at a scope node.
fields:
id: { type: uuid, pk: true }
subject_type:{ type: enum, values: [user, group] }
subject_id: { type: uuid }
role_id: { type: uuid, fk: role.id, on_delete: cascade }
scope_id: { type: uuid, fk: scope_node.id, on_delete: cascade }
inherit: { type: boolean, default: true }
unique: [subject_type, subject_id, role_id, scope_id]
authz_audit:
fields:
id: { type: uuid, pk: true }
ts: { type: timestamptz, default: now }
subject_id:{ type: uuid }
action: { type: text }
resource: { type: text }
scope_id: { type: uuid }
allow: { type: boolean }
reason: { type: text }

Better Auth migrations own user, session, account, verification, jwks, oauthApplication/oauthAccessToken, ssoProvider, organization, member. The tables above are the authz layer you build. Map Better Auth organization ↔ a scope_node of type enterprise (or workspace) so org-plugin membership and the scope tree stay consistent.

JSON Schema Validators — db/schema/scope_node.schema.json

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "scope_node.schema.json",
"type": "object",
"required": ["id", "type", "name"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "format": "uuid" },
"type": { "enum": ["enterprise", "workspace", "project"] },
"parent_id": { "type": ["string", "null"], "format": "uuid" },
"name": { "type": "string", "minLength": 1 },
"created_at": { "type": "string", "format": "date-time" }
},
"allOf": [
{ "if": { "properties": { "type": { "const": "enterprise" } } },
"then": { "properties": { "parent_id": { "type": "null" } } } }
]
}

Test (write FIRST) — iam-core/schema.test.ts

import Ajv from "ajv"; import addFormats from "ajv-formats";
import schema from "../../db/schema/scope_node.schema.json";
const ajv = addFormats(new Ajv({ allErrors: true }));
const validate = ajv.compile(schema);
test("enterprise must have null parent", () => {
expect(validate({ id: crypto.randomUUID(), type: "enterprise", name: "Acme" })).toBe(true);
expect(validate({ id: crypto.randomUUID(), type: "enterprise", parent_id: crypto.randomUUID(), name: "Acme" })).toBe(false);
});
test("project may have a parent", () => {
expect(validate({ id: crypto.randomUUID(), type: "project", parent_id: crypto.randomUUID(), name: "P3" })).toBe(true);
});

Done when the validator rejects malformed nodes and accepts valid ones. Author a .schema.json + test per table.

SQL DDL — db/migrations/0001_init.sql (Atlas)

CREATE TYPE scope_type AS ENUM ('enterprise','workspace','project');
CREATE TYPE subject_type AS ENUM ('user','group');
CREATE TABLE scope_node (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
type scope_type NOT NULL,
parent_id uuid REFERENCES scope_node(id) ON DELETE CASCADE,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT enterprise_has_no_parent
CHECK ((type = 'enterprise') = (parent_id IS NULL))
);
CREATE INDEX idx_scope_parent ON scope_node(parent_id);
CREATE TABLE permission_catalog (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
audience text NOT NULL, resource text NOT NULL, action text NOT NULL,
UNIQUE (audience, resource, action)
);
CREATE TABLE role (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
audience text, name text NOT NULL, is_system boolean NOT NULL DEFAULT false,
UNIQUE (audience, name)
);
CREATE TABLE role_grant (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
role_id uuid NOT NULL REFERENCES role(id) ON DELETE CASCADE,
resource text NOT NULL, action text NOT NULL,
UNIQUE (role_id, resource, action)
);
CREATE TABLE role_assignment (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
subject_type subject_type NOT NULL,
subject_id uuid NOT NULL,
role_id uuid NOT NULL REFERENCES role(id) ON DELETE CASCADE,
scope_id uuid NOT NULL REFERENCES scope_node(id) ON DELETE CASCADE,
inherit boolean NOT NULL DEFAULT true,
UNIQUE (subject_type, subject_id, role_id, scope_id)
);
CREATE INDEX idx_assign_subject ON role_assignment(subject_id);
CREATE INDEX idx_assign_scope ON role_assignment(scope_id);
CREATE TABLE authz_audit (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
ts timestamptz NOT NULL DEFAULT now(),
subject_id uuid NOT NULL, action text NOT NULL, resource text NOT NULL,
scope_id uuid, allow boolean NOT NULL, reason text
);

Test (write FIRST) — iam-db/migration.test.ts (against an ephemeral Postgres, e.g. Testcontainers)

test("enterprise_has_no_parent constraint is enforced", async () => {
await db.insertInto("scope_node").values({ type: "enterprise", name: "Acme" }).execute(); // ok
await expect(
db.insertInto("scope_node").values({ type: "enterprise", name: "Bad", parent_id: someUuid }).execute()
).rejects.toThrow();
});
test("catalog uniqueness holds", async () => {
const row = { audience: "billing-api", resource: "invoice", action: "approve" };
await db.insertInto("permission_catalog").values(row).execute();
await expect(db.insertInto("permission_catalog").values(row).execute()).rejects.toThrow();
});

Done when constraints reject violations and the schema matches domain.yaml field-for-field.

The 5 Standardized Roles (System Seed)

Tier-agnostic: same name = same meaning at any node. manage is a CASL keyword = all actions on a resource.

Rolememberssettingsbillingcontent/resourcesentity delete/transfer
ownermanagemanagemanagemanageyes
adminmanagemanagereadmanageno
editorreadreadcreate / read / updateno
viewerreadreadreadno
billingreadreadmanageno

Seed as is_system = true, audience = null (global definitions). Apps add their own resource:action to the catalog and may bundle them into these roles or define custom roles.

Test (write FIRST) — iam-db/seed.test.ts

test("system roles seeded with correct grant cardinality", async () => {
const owner = await grantsFor("owner");
expect(owner).toContainEqual({ resource: "*", action: "manage" }); // or explicit manage per resource
const viewer = await grantsFor("viewer");
expect(viewer.every(g => g.action === "read")).toBe(true);
const billing = await grantsFor("billing");
expect(billing).toContainEqual({ resource: "billing", action: "manage" });
expect(billing.find(g => g.resource === "content")).toBeUndefined();
});

Effective-Permissions Query (The Heart) — Recursive CTE

-- effective grants for a subject acting on a target node
WITH RECURSIVE ancestors AS (
SELECT id, parent_id FROM scope_node WHERE id = :nodeId
UNION ALL
SELECT s.id, s.parent_id
FROM scope_node s JOIN ancestors a ON s.id = a.parent_id
)
SELECT DISTINCT rg.resource, rg.action
FROM role_assignment ra
JOIN ancestors a ON a.id = ra.scope_id
JOIN role_grant rg ON rg.role_id = ra.role_id
WHERE ra.subject_id = :subjectId
AND (ra.scope_id = :nodeId OR ra.inherit = true);

Test (write FIRST) — iam-db/effective-perms.test.ts

// fixtures: Ent > W1 > P3 ; Alice admin@Ent ; Bob editor@W1 ; Carol viewer@Ent + admin@P3
test("enterprise admin inherits down to project", async () => {
const g = await effectivePerms(alice, "P3");
expect(g).toContainEqual({ resource: "member", action: "manage" });
});
test("workspace editor does not leak to sibling workspace", async () => {
expect(await effectivePerms(bob, "W2")).toHaveLength(0);
});
test("carol reads everywhere but admins only P3", async () => {
expect(await effectivePerms(carol, "W1")).toEqual(
expect.arrayContaining([{ resource: "content", action: "read" }]));
expect(await effectivePerms(carol, "P3")).toContainEqual(
{ resource: "member", action: "manage" });
});
test("inherit=false isolates a node", async () => {
// assignment with inherit=false at W1 should not apply when querying P3 via ancestry
});

Done when all inheritance, isolation, and non-leak cases pass.


iam-db — Kysely Repository Layer

Contract

The rest of the system NEVER imports Kysely directly. It depends on repository interfaces returning plain domain objects. This is the swap-the-backend seam and the test seam.

// iam-core/ports.ts (interfaces only — no Kysely here)
export interface RoleRepository {
effectivePermissions(subjectId: string, scopeNodeId: string): Promise<Permission[]>;
assignmentsForSubject(subjectId: string): Promise<Assignment[]>;
ancestorsOf(scopeNodeId: string): Promise<ScopeNode[]>;
}
export interface CatalogRepository {
upsertManifest(m: PermissionManifest): Promise<void>; // idempotent
list(audience: string): Promise<Permission[]>;
}
export type Permission = { resource: string; action: string };
// iam-db/role.repository.ts (Kysely confined here)
export class KyselyRoleRepository implements RoleRepository {
constructor(private db: Kysely<DB>) {} // DB type generated by kysely-codegen
async effectivePermissions(subjectId, scopeNodeId) {
return this.db.withRecursive("ancestors", (q) => /* CTE from §5.6 */).select(...).execute();
}
}

Test (Write FIRST) — Repository Contract Test Against Real Kysely + In-Memory Fake

iam-db/role.repository.contract.test.ts
function runContract(makeRepo: () => RoleRepository) {
test("effectivePermissions unions ancestor grants", async () => { /* §5.6 expectations */ });
test("returns [] for unknown subject", async () => {
expect(await makeRepo().effectivePermissions("nobody", root)).toEqual([]);
});
}
runContract(() => new KyselyRoleRepository(testDb));
runContract(() => new InMemoryRoleRepository(fixtures)); // proves the interface, enables UI/Storybook mocks

Done when the same contract suite is green for both implementations. (This is what makes Prisma/Drizzle/Kysely interchangeable — and what powers the mock adapter in §11.)

kysely-codegen

kysely-codegen --out-file packages/iam-db/src/db.d.ts runs in CI after migrations apply to a scratch DB. A drift test asserts generated types match committed types — DB stays the source of truth.


iam-service — Better Auth Backend Configuration

Contract — apps/iam-service/auth.ts

import { betterAuth } from "better-auth";
import { oauthProvider } from "better-auth/plugins"; // OAuth 2.1 Provider
import { jwt } from "better-auth/plugins";
import { organization, admin } from "better-auth/plugins";
import { sso } from "@better-auth/sso";
export const auth = betterAuth({
database: /* Better Auth's own adapter for its tables; app authz tables use Kysely directly */,
emailAndPassword: { enabled: true },
socialProviders: {
github: { clientId: env.GH_ID, clientSecret: env.GH_SECRET },
google: { clientId: env.GOOGLE_ID, clientSecret: env.GOOGLE_SECRET },
},
account: { accountLinking: { enabled: true, trustedProviders: ["github","google"] } }, // verified-email only
plugins: [
sso(), // enterprise OIDC + SAML (Okta/Entra)
organization(), // tenant + member roles; map org -> scope_node
admin(), // super-admin, impersonation
jwt(), // /api/auth/jwks for downstream verification
oauthProvider({ // makes IAM the IdP to the 30 apps
requirePKCE: true,
// trusted first-party clients (consent bypass) loaded from declarative config (§8)
}),
],
});

Tests (Write FIRST) — apps/iam-service/auth.test.ts

test("JWKS endpoint serves a key set", async () => {
const r = await fetch(`${base}/api/auth/jwks`); const jwks = await r.json();
expect(jwks.keys.length).toBeGreaterThan(0);
expect(jwks.keys[0]).toHaveProperty("kid");
});
test("auth-code + PKCE issues a JWT with correct aud", async () => {
const { token } = await runAuthCodePkce({ clientId: "billing", audience: "billing-api" });
const claims = decode(token);
expect(claims.aud).toBe("billing-api");
expect(claims.iss).toBe(env.ISSUER);
});
test("PKCE is mandatory", async () => {
await expect(runAuthCodeWithoutPkce({ clientId: "billing" })).rejects.toMatchObject({ status: 400 });
});
test("unverified federated email does NOT auto-link", async () => {
const u = await federate({ provider: "okta", email: "x@y.com", emailVerified: false });
expect(u.linkedToExisting).toBe(false);
});
test("introspection reports active for a live token, inactive after revoke", async () => {
const t = await mintToken(); expect((await introspect(t)).active).toBe(true);
await revoke(t); expect((await introspect(t)).active).toBe(false);
});

Done when all green. These encode Invariants I1, I4, I9.


Declarative Client & Permission Registration (GitOps)

OAuth Client Config — clients/billing.yaml

clientId: billing
displayName: Billing
trusted: true
pkce: required
audience: billing-api
redirectUris: # EXACT match — wildcards forbidden (Invariant I3)
- https://billing.example.gov/auth/callback
- https://billing.staging.example.gov/auth/callback
postLogoutRedirectUris: [ https://billing.example.gov/ ]
scopes: [openid, profile, email]
accessTokenTtlSeconds: 600

Permission Manifest — permissions/billing.yaml

audience: billing-api
resources:
invoice: [read, write, approve, void]
report: [read, export]
roles:
billing-viewer: { invoice: [read], report: [read] }
billing-approver: { invoice: [read, write, approve] }

Reconciler Contract + Tests (Write FIRST)

test("reconcile is idempotent", async () => {
await reconcile("permissions/billing.yaml");
const before = await snapshot();
await reconcile("permissions/billing.yaml");
expect(await snapshot()).toEqual(before); // no diff => no-op
});
test("wildcard redirect URI fails validation", async () => {
await expect(reconcileClient({ redirectUris: ["https://*.example.gov/cb"] }))
.rejects.toThrow(/wildcard/);
});
test("removing a catalog action with active grants fails loudly", async () => {
await expect(removeCatalog("billing-api","invoice","approve"))
.rejects.toThrow(/active grants/);
});

Done when reconcile is idempotent, rejects wildcards, and never silently orphans grants.


iam-core — CASL Authorization (Isomorphic)

Contract — The Ability Factory (Runs in React and Node)

import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability";
export type AppAbility = MongoAbility<[string, string]>; // [action, subject]
// Build from already-resolved effective permissions (from RoleRepository).
export function defineAbilityFor(perms: Permission[]): AppAbility {
const { can, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
for (const p of perms) can(p.action, p.resource); // 'manage' => all actions (CASL keyword)
return build();
}

PDP Service — /v1/authz/check

export async function check(input: CheckInput, repo: RoleRepository): Promise<CheckResult> {
const perms = await repo.effectivePermissions(input.subjectId, input.scopeNodeId);
const ability = defineAbilityFor(perms);
const allow = ability.can(input.action, input.resource);
await audit({ ...input, allow }); // Invariant I7
return { allow, reason: allow ? "granted" : "no-grant" };
}

Server decision is authoritative (Invariant I5). Better Auth’s client-side role check does not support dynamic AC, and union-of-roles checks have known bugs — clients MUST treat the PDP as truth. The CASL ability is built from the DB-resolved union, sidestepping that bug.

Tests (Write FIRST)

test("manage grants all CRUD on its resource", () => {
const a = defineAbilityFor([{ resource: "invoice", action: "manage" }]);
for (const act of ["read","write","approve","void"]) expect(a.can(act, "invoice")).toBe(true);
});
test("union across two roles allows the union (guards known bug)", async () => {
// subject has role A(invoice:read) + role B(invoice:approve)
expect((await check({ subjectId: u, scopeNodeId: P3, action: "approve", resource: "invoice" }, repo)).allow).toBe(true);
});
test("every decision writes an audit row", async () => {
const n = await auditCount(); await check(input, repo);
expect(await auditCount()).toBe(n + 1);
});

Deferred: ReBAC Option

If resource-graph relationships emerge (“shared with these users”, folder inheritance), add a Postgres relationship-tuples table behind the SAME /v1/authz/check API. Do NOT add an external engine (OpenFGA) unless this is explicitly triggered.


API Surface (Every Endpoint Test-First)

MethodPathPurposeAuth
GET/.well-known/openid-configurationOIDC discoverypublic
GET/api/auth/jwkspublic keyspublic
*/api/auth/*Better Auth (login, social, sso, sessions)per-flow
GET/POST/oauth2/authorize /oauth2/tokenOAuth 2.1 flowclient
POST/oauth2/introspectRFC 7662client
POST/oauth2/revokeRFC 7009client
POST/v1/authz/checksingle decisionbearer
POST/v1/authz/check/batchbulk decisions (screen load)bearer
GET/v1/me/permissions?scope=effective perms for UI bootstrapbearer
POST/v1/scopes /v1/scopes/:id/childrenmanage treebearer + admin
POST/v1/assignmentsassign role at scopebearer + manage:member
GET/v1/catalog?audience=published permissionsbearer

Every payload has a JSON Schema in db/schema/api/ + an Ajv validator test. Example:

Test (write FIRST) — check.contract.test.ts

test("/v1/authz/check request+response match schema", async () => {
const body = { subjectId: u, action: "invoice:approve", resource: "invoice", scopeNodeId: P3 };
expect(validateReq(body)).toBe(true);
const res = await api.post("/v1/authz/check", body, bearer(token));
expect(validateRes(res.data)).toBe(true);
expect(res.data).toHaveProperty("allow");
});
test("check requires a valid bearer", async () => {
expect((await api.post("/v1/authz/check", {})).status).toBe(401);
});

Done when schema validation passes both directions and authz/authn guards return correct codes.


React Component Library (iam-client + iam-ui)

Design constraints (MUST): components consume the app’s design system tokens (do not ship hard-coded styles); use compound-component / slot composition so apps re-skin and re-layout without forking; tokens/branding via props. Storybook stories ARE the acceptance tests — every component ships stories for all states with play() interaction assertions + a11y checks. Every story runs against MockAuthAdapter/InMemoryRoleRepository (no live IAM).

The Adapter Boundary (Enables Storybook + Tests)

export interface AuthClientAdapter {
signIn(i: SignInInput): Promise<SignInResult>;
signOut(): Promise<void>;
getSession(): Promise<Session | null>;
getAccessToken(o?: { audience?: string }): Promise<string>;
getPermissions(scopeNodeId: string): Promise<Permission[]>;
}
// BetterAuthAdapter (real) | MockAuthAdapter (Storybook/tests)

Components to Build

iam-client (logic/context):

  • AuthProvider — owns session + token lifecycle (in-memory token, silent refresh via httpOnly cookie/BFF). Config: authBaseUrl, clientId, audience, redirectUri, postLoginRoute. No tokens in localStorage (Invariant I2).
  • PermissionsProvider — wraps CASL AbilityProvider; fetches /v1/me/permissions?scope= for the active scope, builds AppAbility, re-fetches on scope switch.
  • useSession(), useAccessToken(), useCan().
  • <Can action="invoice:approve" subject="invoice" else={<Hidden/>}>…</Can> (re-exports @casl/react).
  • <ProtectedRoute> — gates on session; redirects to login.
  • <AuthCallback> — exchanges code + PKCE, routes to postLoginRoute.
  • <ScopeProvider> + useScope() — current enterprise/workspace/project context.

iam-ui (visual, design-system driven, compound):

  • LoginPage / LoginForm — email/password + social buttons (slots: <LoginForm.Social>, <LoginForm.Footer>).
  • SignupForm, ForgotPasswordForm, ResetPasswordForm.
  • MfaChallenge (TOTP/passkey).
  • OAuthConsentScreen — app name, requested scopes, allow/deny; bypassed for trusted first-party clients but built for any 3rd-party.
  • AccountSelector — pick among linked identities.
  • UserSettingsPage — compound:
    • Settings.Profile (name, avatar, email)
    • Settings.Security (change password, MFA enroll, active sessions list + revoke)
    • Settings.ConnectedAccounts (linked GitHub/Google/SSO; link/unlink)
    • Settings.Danger (delete account)
  • WorkspaceSwitcher — switches active scope (drives PermissionsProvider refetch).
  • MembersTable — list members at a scope, assign/change role (gated by member:manage), RoleBadge.
  • SsoSetupForm — self-service enterprise SSO (issuer, metadata, domain verify).

Storybook Tests (Write FIRST) — Examples

LoginForm.stories.tsx
export const LoginFails: Story = {
args: { adapter: mockFailing },
play: async ({ canvas }) => {
await userEvent.type(canvas.getByLabelText("Email"), "a@b.com");
await userEvent.type(canvas.getByLabelText("Password"), "wrong");
await userEvent.click(canvas.getByRole("button", { name: /sign in/i }));
await expect(canvas.findByRole("alert")).resolves.toBeVisible();
},
};
// Can.stories.tsx
export const HidesWhenDenied: Story = {
decorators: [withAbility([{ resource: "invoice", action: "read" }])], // no approve
render: () => <Can action="approve" subject="invoice"><button>Approve</button></Can>,
play: async ({ canvas }) => { expect(canvas.queryByText("Approve")).toBeNull(); },
};
// UserSettingsPage.stories.tsx — states: loading, loaded, mfa-enabled, session-revoke
export const RevokeSession: Story = {
play: async ({ canvas }) => {
await userEvent.click(canvas.getAllByText("Revoke")[0]);
await expect(canvas.findByText(/session ended/i)).resolves.toBeVisible();
},
};

Required story states per component (the test matrix)

LoginForm: idle · submitting · invalid creds · social redirect · MFA required · rate-limited. OAuthConsentScreen: first-party (auto-approve) · third-party (consent) · scope denied. UserSettingsPage: loading · loaded · MFA on/off · 1 vs many sessions · unlink last provider (blocked). MembersTable: viewer (no controls) · admin (controls visible) · self-demotion guard. Can/ProtectedRoute: allowed · denied · loading ability.

Done when every listed state has a story and its play()/a11y assertions are green in CI (test-storybook).


Optional Future: Downstream Non-TS Verification SDKs

MVP does not require non-TS downstream SDKs. Current scope assumes first-party Astro/TypeScript apps and APIs. Add language-specific verification SDKs only when non-TS downstreams are in scope.

Example Contract (Go)

// iamauth.Middleware verifies the bearer JWT; iamauth.Require guards authorization.
mw := iamauth.New(iamauth.Config{
JWKSURL: "https://auth.example.gov/api/auth/jwks",
Issuer: "https://auth.example.gov",
Audience: "billing-api",
PDPURL: "https://auth.example.gov/v1/authz/check", // for fine-grained checks
})
r.With(mw.Middleware).Get("/invoices/{id}", mw.Require("invoice:read")(handler))

Verification steps (MUST, in order): signature via cached JWKS (lestrrat-go/jwx) → issaud == this APIexp/nbf → extract claims. JWKS cached with TTL; on unknown kid, refetch once before failing. A single refetch MUST NOT fail all in-flight requests (Invariant compliance: I9 interop).

Example Tests (Write FIRST) — iamauth_test.go

func TestRejectsWrongAudience(t *testing.T) {
tok := signTestJWT(t, claims{Aud: "other-api"})
rec := callWithToken(mw.Middleware, tok)
if rec.Code != http.StatusForbidden { t.Fatalf("want 403 got %d", rec.Code) }
}
func TestRefetchesJWKSOnUnknownKid(t *testing.T) {
rotateSigningKey() // new kid not in cache
tok := signTestJWT(t, validClaims())
rec := callWithToken(mw.Middleware, tok)
if rec.Code != http.StatusOK { t.Fatalf("expected refetch+accept, got %d", rec.Code) }
}
func TestExpiredTokenRejected(t *testing.T) { /* exp in past => 401 */ }
func TestRequireCallsPDPForResourceScopedAction(t *testing.T) { /* mock PDP allow/deny */ }

Done when wrong-aud, expired, tampered, and unknown-kid cases behave correctly and Require honors PDP decisions.


Milestones (Each = Red→Green TDD Cycle)

#MilestoneWrite tests first forGreen gate
M0Repo + CI + test harnessturbo pipelines, Testcontainers PG, Ajv, Storybook test-runnerempty suites run; pnpm test wired
M1Schema & repos§5 schema validators, §5.6 effective-perms, §6 repo contract (both impls)constraints + inheritance + non-leak pass
M2Better Auth core§7 auth tests (JWKS, PKCE, no-auto-link, introspect/revoke)federated login + JWT issuance
M3OAuth provider + GitOps reconcile§8 idempotency, wildcard-reject, orphan-guardadd an app via PR end-to-end
M4CASL PDP + API§9 ability/union/audit, §10 endpoint schema + guard testsidentical decision across 2 apps; revoke is immediate
M5React library§11 Storybook state matrix + play/a11yall component states green; mock adapter only
M6Optional non-TS SDKs (post-MVP)deferred until a non-TS downstream requires first-class supportreference SDK + verification tests available
M7Hardening + pilotkey-rotation overlap, rate-limit, MFA, E2E Playwright across 5 pilot appsonboarding runbook < 1 day/app

Invariants (Override Everything)

  • I1 PKCE required on every OAuth client.
  • I2 Tokens never in localStorage/sessionStorage; access token in memory, refresh via httpOnly cookie/BFF.
  • I3 Redirect URIs exact-match allowlisted. No wildcards, ever. Reconcile rejects them.
  • I4 Auto account-linking ONLY on provider-asserted verified email.
  • I5 Server PDP is authoritative; client <Can> is a UX hint, never the security boundary.
  • I6 JWT carries only the requesting audience’s coarse roles — never the union of all apps’ permissions.
  • I7 Every identity, token, consent, and authorization decision is audit-logged.
  • I8 Auth + authz endpoints are rate-limited (per-endpoint).
  • I9 Signing-key rotation overlaps: publish-before-sign, retire-after-grace; verifiers refetch JWKS on unknown kid.
  • I10 iam-client and any downstream SDK changes keep N-1 backward compat — no lockstep deploy of 30 apps.
  • I11 Nothing imports Kysely outside iam-db; everything else depends on iam-core ports (the swap seam + test seam).
  • I12 No implementation merges without its red-first test green in CI.

Open Questions (Resolve Within M4)

  • Q1 Back-channel single logout in v1, or rely on short token lifetime + revocation?
  • Q2 Group/team assignments in v1, or user-direct only first?
  • Q3 Any app’s resource graph complex enough to trigger §9.4 ReBAC early?
  • Q4 Is Better Auth organization the tenant boundary, or do some apps need a different scoping axis than enterprise/workspace/project?
  • Q5 Token lifetime per client — confirm default 600s and which apps need shorter.

Definition of Done (Whole Platform)

A new app: installs @iam/client, adds AuthProvider + PermissionsProvider, and drops one clients/<app>.yaml + one permissions/<app>.yaml PR. It then has SSO login, centralized roles, UI gating, and verified API authorization — with zero bespoke auth or permission code, and every layer covered by the tests defined above. Non-TS downstream SDKs are optional and deferred until needed.