diff --git a/CHANGELOG.md b/CHANGELOG.md index ea41dd3..61e5dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,18 @@ ## Unreleased +- Added the authoritative Identities-owned end-user lifecycle: relational + users, tenants, memberships, normalized login identifiers, Argon2id + credentials, invite/verification/recovery state, hashed rotating refresh + tokens, session-family and JTI revocation, registration policy and atomic + first-admin bootstrap, timing-safe throttled login, mountable HTTP schemas + and generated SDK operations, a guarded CLI bootstrap, and reversible + checksum-guarded Postgres migrations. +- Hardened lifecycle authority with transactional invite role/scope/tenant + checks, current membership scope intersection, tenant-only suspension versus + platform-global state, atomic family/JTI revocation, bounded concurrent + throttle admission, prewarmed enumeration-safe asynchronous recovery, and + UTS-46 email-domain canonicalization with migration collision audits. - Added reusable scoped JWT verification and issuance, public JWKS rotation status, hashed token/session-family revocation checks, and composable auth API and CLI surfaces for local and self-hosted consumers. diff --git a/README.md b/README.md index 6b8710e..4fd87c6 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,9 @@ See [docs/browserplan.md](docs/browserplan.md) for the BrowserPlan machine, iden See [docs/media.md](docs/media.md) for voice and profile image generation. See [docs/instructions.md](docs/instructions.md) for the instruction-source schema, precedence, export contract, and fail-closed safety rules. See [docs/identity-contract.md](docs/identity-contract.md) for the versioned canonical agent identity, scoped handle/alias resolver, runtime-context, five-part source-lineage revision history, and dry-run migration contracts. Cross-repo consumers can vendor [the V1 conformance fixture](docs/fixtures/agent-identity-v1.conformance.json), identified as `hasna.identities.agent-identity/v1/conformance/1`; the SDK exports its repository-relative path and SHA-256 fingerprint for deterministic pinning without machine paths or network tests. +See [docs/user-lifecycle.md](docs/user-lifecycle.md) for the Identities-owned +end-user registration, login, tenant membership, refresh rotation, recovery, +revocation, API, CLI bootstrap, and Postgres migration contract. ## Scoped access-token contract @@ -223,6 +226,51 @@ SHA-256 hashes and session-family statuses. Private signing keys remain caller-owned and are accepted only as runtime inputs to `issueIdentityAccessToken`. +## End-user lifecycle + +`IdentityLifecycleService` is the reusable authority for human application +users. It supports `disabled`, `invite`, and `open` registration; an atomic +first-admin bootstrap; normalized unique email or username login identifiers; +Argon2id password credentials; tenant-bound membership scopes; hashed rotating +refresh tokens; replay-driven family revocation; logout, logout-all, disable, +soft-delete, verification, and recovery. + +Invite authority, tenant scope allowlists, role hierarchy, membership +suspension, session scope reduction, and platform-global user state are +validated transactionally. Login and recovery use bounded pre-admission +throttles with a client-wide concurrency cap across identifiers; recovery +responses also use a bounded minimum-duration floor so durable token work does +not expose whether an identifier exists. Invite +registration and its initial session commit atomically against the locked +tenant allowlist, email domains use UTS-46/IDNA canonicalization, and +access-token verification observes current user, membership, family, and JTI +state. + +The service issues access tokens through an `IdentityAccessTokenIssuer` bound +to the same `IdentityJwksRegistry` used by +`IdentityAccessTokenVerifier`. Consumers cannot silently publish one key set +while signing from another. `createIdentityLifecycleApi` exposes mountable +`/v1/auth/*` handlers, and `PgIdentityLifecycleStore` supplies the relational +Postgres implementation. Infinity and other applications should mount or call +these surfaces instead of creating their own credential or session tables. + +The injected CLI bootstrap reads the initial password only from an owner-only +file and never prints access or refresh tokens. An optional session file is +created with owner-only permissions: + +```bash +identities auth bootstrap \ + --identifier-kind email \ + --identifier owner@example.test \ + --password-file /owner-only/bootstrap-password \ + --display-name "Owner" \ + --session-file /owner-only/identity-session.json +``` + +The standalone CLI intentionally fails closed unless its embedding runtime +injects an `IdentityLifecycleService`; signing keys and deployment-specific +policy never come from command-line flags. + ## Instruction Sources OpenIdentities owns the canonical instruction-source graph for humans, agents, diff --git a/docs/user-lifecycle.md b/docs/user-lifecycle.md new file mode 100644 index 0000000..bd31051 --- /dev/null +++ b/docs/user-lifecycle.md @@ -0,0 +1,166 @@ +# Identities End-User Lifecycle + +`@hasna/identities` owns the reusable end-user authentication lifecycle. An +application such as Infinity consumes this contract; it does not create a +parallel user, password, tenant, refresh-token, or revocation store. + +The contract version is `hasna.identity-user-lifecycle/v1`. + +## Authority boundaries + +- `IdentityLifecycleService` owns registration, login, recovery, verification, + session rotation, logout, disable, and soft-delete state transitions. +- `PgIdentityLifecycleStore` is the production relational repository. + `InMemoryIdentityLifecycleStore` exists for deterministic tests and embedded + development only. +- `IdentityAccessTokenIssuer` signs through one active key in the same + `IdentityJwksRegistry` used by `IdentityAccessTokenVerifier`. Private key + loading remains the embedding runtime's responsibility. +- Tenant IDs, tenant scope allowlists, membership status, and membership scopes + are persisted authority. Client-requested tenant IDs or scopes never create + membership or expand grants. +- Raw passwords, invite tokens, verification tokens, recovery tokens, refresh + tokens, JTIs, and session IDs are not persisted in token-state tables. + Passwords use Argon2id; opaque tokens, JTIs, and session references are + stored as SHA-256 hashes. + +## Registration policy + +The embedding runtime selects exactly one policy: + +- `disabled`: public signup is unavailable. +- `invite`: signup requires an unexpired, unused, hashed invite whose optional + normalized identifier matches. +- `open`: public signup is allowed. The first user atomically receives the + configured bootstrap tenant's owner membership; later users receive isolated + personal tenants unless they use an invite policy in another deployment. + +`bootstrapFirstAdmin` uses a serialized transaction and succeeds only while no +user exists. Concurrent attempts produce one administrator and one +`bootstrap_complete` result. Login-identifier uniqueness is enforced by both +normalization and a database unique constraint. + +Email and username identifiers are trimmed, NFKC-normalized, and lowercased. +Email domains are additionally canonicalized to their UTS-46/IDNA ASCII form, +so Unicode and equivalent punycode spellings collide. Usernames accept 3–64 lowercase +alphanumeric, dot, underscore, or hyphen characters. + +Invite creation is a transactional authority check. The current actor user and +membership must be active with an owner or admin role, the access token and +persisted membership must both hold the configured invite-management scope, +the invited role cannot outrank the actor, and invited scopes must be a subset +of the token, membership, and tenant allowlist. Invite consumption rechecks +persisted role and scope +authority while holding the current tenant allowlist row lock. The store +persists only its authorization input's configured management scope; a caller +cannot substitute a weaker scope on the invite record. Registration, +membership creation, invite consumption, and the initial session family commit +in one transaction, so a concurrent allowlist change cannot leave a partial +account after initial-session validation fails. + +## Credentials and login + +`Argon2idIdentityPasswordHasher` defaults to 64 MiB and three iterations and +enforces a 12–1024 character password boundary. Deployments may raise those +parameters, but cannot configure less than 32 MiB or two iterations. + +Unknown users, incorrect passwords, disabled users, deleted users, and tenant +mismatches return the same authentication failure. Unknown users still verify +against a cached Argon2id dummy hash. Throttle keys hash the normalized +identifier and the embedding runtime's client key; failures and lock expiry are +updated atomically. A separate client-wide admission key applies the in-flight +cap across distinct identifiers and across login and recovery. PostgreSQL locks +all participating buckets in deterministic key order and every admitted path +releases both reservations in `finally`, so identifier fan-out cannot multiply +the expensive password work. + +Login selects one persisted membership. Requested scopes must be a non-empty +subset of that membership's scopes. Tokens bind `sub`, `tenant`, `session`, +`scopes`, `iat`, `nbf`, `exp`, and `jti`. +Session creation and every refresh re-read active user, membership, and tenant +authority transactionally, intersect family scopes with current grants, and +revoke a family whose scope intersection is empty. Refresh locks authority rows +before session-family rows so concurrent grant narrowing cannot issue stale +scopes or create a lock-order cycle with membership suspension. + +## Refresh rotation and revocation + +Refresh tokens are 256-bit random values stored only by hash. Every successful +refresh consumes the current row and creates a new generation in the same +session family. Reuse of a consumed or revoked token is treated as compromise: +the family and all its refresh generations are revoked in one transaction. + +Access-token verification checks the hashed JTI and hashed session-family +reference on every request. Logout records the current JTI and revokes the +family. Issued JTI hashes are tracked so logout-all, password recovery, global +account disable, and soft-delete atomically revoke both active families and +their unexpired access-token JTIs. + +Tenant membership suspension and platform-global user state are separate. +Tenant role hierarchy permits only a strictly higher tenant role to suspend a +membership, without disabling the same user in other tenants. Global +disable/delete/restore requires the configured platform-authority scope in both +the actor token and current owner membership, requires the current tenant slug +to be in the configured platform-authority tenant allowlist, and also enforces +role hierarchy. +Token verification rechecks current user, membership, tenant allowlist, and +family scope state on every request. + +## Verification and recovery + +Verification and recovery tokens are one-time, hashed, expiring records. +Delivery occurs only through caller-supplied hooks and is dispatched +asynchronously after durable token creation and after the accepted response is +ready, so synchronous hook setup cannot extend the account-dependent response +path. Known and unknown recovery requests run the same prewarmed dummy-hash work, share normalized +identifier-plus-client throttling, and return the same accepted response only +after a 250 ms minimum response floor. The floor is asynchronous, includes +durable token and throttle completion work, and is bounded by the same +client-wide admission cap; deployments may tune `recoveryMinimumResponseMs` +between 100 and 5,000 ms after measuring their database tail latency. +Successful recovery replaces the password hash and revokes all sessions. + +Restore requires the same explicit platform authority as disable/delete and is +not exposed by the public lifecycle HTTP handler. Existing sessions remain +revoked after a restore. + +## HTTP and SDK + +`createIdentityLifecycleApi` provides: + +- `POST /v1/auth/signup` +- `POST /v1/auth/login` +- `POST /v1/auth/refresh` +- `POST /v1/auth/logout` +- `POST /v1/auth/logout-all` +- `POST /v1/auth/verification/complete` +- `POST /v1/auth/recovery/start` +- `POST /v1/auth/recovery/complete` + +Responses set `Cache-Control: no-store` and never echo passwords. Login and +refresh errors use bounded public reason codes. The serve runtime accepts an +optional lifecycle handler and otherwise returns a fail-closed 503 for these +routes. The OpenAPI document and generated `@hasna/identities/sdk` client +include the same schemas and operations. + +## Postgres migrations + +Lifecycle migrations are `identities_0004` through `identities_0009`: + +1. users, tenants, memberships; +2. normalized login identifiers, Argon2id credentials, invites; +3. session families, hashed refresh tokens, hashed JTI revocations; +4. verification and recovery tokens; +5. login throttle state. +6. tenant allowlists, membership suspension, token-bucket reservations, issued + JTI tracking, and identifier-canonicalization audit state. + +Store readiness performs a collision audit of existing email identifiers before +serving lifecycle requests. Non-colliding legacy forms are rewritten to the +canonical UTS-46 ASCII domain form; collisions are recorded for operator +resolution and readiness fails closed. + +They use the existing checksum ledger, are safe to reapply, and reject checksum +drift. `rollbackIdentityLifecycleMigrations` is an explicit destructive +operator surface requiring `allowDestructive: true`; it removes only lifecycle +migrations in reverse dependency order and clears only their ledger rows. diff --git a/package.json b/package.json index 654dfc1..fb1172f 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,14 @@ "types": "./dist/migrations.d.ts", "import": "./dist/src/migrations.js" }, + "./user-lifecycle": { + "types": "./dist/user-lifecycle.d.ts", + "import": "./dist/src/user-lifecycle.js" + }, + "./pg-user-lifecycle": { + "types": "./dist/pg-user-lifecycle.d.ts", + "import": "./dist/src/pg-user-lifecycle.js" + }, "./storage": { "types": "./dist/storage.d.ts", "import": "./dist/src/storage.js" @@ -84,7 +92,7 @@ ], "scripts": { "clean": "rm -rf dist", - "build": "bun run clean && bun build ./src/index.ts ./src/storage.ts ./src/http-store.ts ./src/pg-store.ts ./src/migrations.ts ./src/version.ts ./src/browserplan.ts ./src/integrations.ts ./src/instructions.ts ./src/global-agent-rules.ts ./src/ecosystem.ts ./src/eve.ts ./src/status.ts ./src/media.ts ./src/roster.ts ./src/cli.ts ./src/mcp/index.ts ./src/server/index.ts ./src/sdk/index.ts --outdir dist --target bun --external pg --external @hasna/contracts --external @modelcontextprotocol/sdk --external zod && tsc --emitDeclarationOnly --outDir dist", + "build": "bun run clean && bun build ./src/index.ts ./src/storage.ts ./src/http-store.ts ./src/pg-store.ts ./src/migrations.ts ./src/user-lifecycle.ts ./src/pg-user-lifecycle.ts ./src/version.ts ./src/browserplan.ts ./src/integrations.ts ./src/instructions.ts ./src/global-agent-rules.ts ./src/ecosystem.ts ./src/eve.ts ./src/status.ts ./src/media.ts ./src/roster.ts ./src/cli.ts ./src/mcp/index.ts ./src/server/index.ts ./src/sdk/index.ts --outdir dist --target bun --external pg --external @hasna/contracts --external @modelcontextprotocol/sdk --external zod && tsc --emitDeclarationOnly --outDir dist", "generate:sdk": "bun run scripts/generate-sdk.ts", "vendor-kit:check": "bunx @hasna/contracts vendor-kit . --check --no-contract", "typecheck": "tsc --noEmit", diff --git a/src/cli.ts b/src/cli.ts index 4560d94..e4fd869 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -59,6 +59,7 @@ import { type IdentityPublicKeyAlgorithm, type IdentitySessionFamilyStatus, } from "./identity-auth.js"; +import type { IdentityLifecycleService, LoginIdentifierKind } from "./user-lifecycle.js"; interface ParsedArgs { positionals: string[]; @@ -122,6 +123,7 @@ Commands: agent seed-company [--docs-dir dir] [--keep-deprecated] auth jwks --jwks-file auth verify --token-file --jwks-file --token-state-file --issuer --audience --algorithm [--tenant ] [--scope ...] + auth bootstrap --identifier-kind --identifier --password-file --display-name [--session-file ] eve export --out media doctor media status [id|identifier] @@ -145,13 +147,20 @@ Human output is compact by default. Use --verbose for full object details, --jso const defaultHumanLimit = 20; const defaultPreviewLength = 160; -export async function runCli(argv = process.argv.slice(2)): Promise { +export interface IdentityCliRuntime { + lifecycleService?: IdentityLifecycleService; +} + +export async function runCli( + argv = process.argv.slice(2), + runtime: IdentityCliRuntime = {}, +): Promise { const parsed = parseArgs(argv); const json = hasFlag(parsed, "json"); const store = createStoreFromArgs(parsed); try { - await dispatch(parsed, store, json); + await dispatch(parsed, store, json, runtime); } catch (error) { if (json) { console.log(JSON.stringify({ error: errorMessage(error) }, null, 2)); @@ -162,7 +171,12 @@ export async function runCli(argv = process.argv.slice(2)): Promise { } } -async function dispatch(parsed: ParsedArgs, store: IdentityStore, json: boolean): Promise { +async function dispatch( + parsed: ParsedArgs, + store: IdentityStore, + json: boolean, + runtime: IdentityCliRuntime, +): Promise { const [command, ...rest] = parsed.positionals; if (!command || command === "help" || hasFlag(parsed, "help") || hasFlag(parsed, "h")) { @@ -278,7 +292,7 @@ async function dispatch(parsed: ParsedArgs, store: IdentityStore, json: boolean) } if (command === "auth") { - await dispatchAuth(rest, parsed, json); + await dispatchAuth(rest, parsed, json, runtime.lifecycleService); return; } @@ -355,8 +369,64 @@ async function dispatch(parsed: ParsedArgs, store: IdentityStore, json: boolean) throw new Error(`Unknown command: ${command}`); } -async function dispatchAuth(rest: string[], parsed: ParsedArgs, json: boolean): Promise { - const subcommand = required(rest[0], "auth requires jwks or verify"); +async function dispatchAuth( + rest: string[], + parsed: ParsedArgs, + json: boolean, + lifecycleService?: IdentityLifecycleService, +): Promise { + const subcommand = required(rest[0], "auth requires bootstrap, jwks, or verify"); + if (subcommand === "bootstrap") { + if (lifecycleService === undefined) { + throw new Error("auth bootstrap requires an injected IdentityLifecycleService"); + } + const identifierKind = required( + flagValue(parsed, "identifier-kind"), + "auth bootstrap requires --identifier-kind", + ); + if (identifierKind !== "email" && identifierKind !== "username") { + throw new Error("auth bootstrap --identifier-kind must be email or username"); + } + const passwordPath = required( + flagValue(parsed, "password-file"), + "auth bootstrap requires --password-file", + ); + const password = await readOwnerOnlySecretFile(passwordPath, { + label: "password", + minimumBytes: 12, + maximumBytes: 1_024, + }); + const session = await lifecycleService.bootstrapFirstAdmin({ + identifier: { + kind: identifierKind as LoginIdentifierKind, + value: required(flagValue(parsed, "identifier"), "auth bootstrap requires --identifier"), + }, + password, + displayName: required(flagValue(parsed, "display-name"), "auth bootstrap requires --display-name"), + }); + const sessionFile = flagValue(parsed, "session-file"); + if (sessionFile !== undefined) { + await writeFile( + sessionFile, + `${JSON.stringify({ + schemaVersion: session.schemaVersion, + accessToken: session.accessToken, + accessTokenExpiresAt: session.accessTokenExpiresAt, + refreshToken: session.refreshToken, + refreshTokenExpiresAt: session.refreshTokenExpiresAt, + })}\n`, + { encoding: "utf8", mode: 0o600, flag: "wx" }, + ); + } + output({ + bootstrapped: true, + userId: session.user.id, + tenantId: session.tenant.id, + scopes: session.scopes, + sessionFileCreated: sessionFile !== undefined, + }, json); + return; + } const jwksPath = required(flagValue(parsed, "jwks-file"), "auth requires --jwks-file"); const document = JSON.parse(await readFile(jwksPath, "utf8")) as IdentityJwksDocument; const jwks = IdentityJwksRegistry.fromDocument(document); @@ -404,6 +474,17 @@ async function dispatchAuth(rest: string[], parsed: ParsedArgs, json: boolean): } async function readOwnerOnlyTokenFile(path: string): Promise { + return readOwnerOnlySecretFile(path, { + label: "auth token", + minimumBytes: 32, + maximumBytes: 16_384, + }); +} + +async function readOwnerOnlySecretFile( + path: string, + options: { label: string; minimumBytes: number; maximumBytes: number }, +): Promise { const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); try { const fileStat = await handle.stat(); @@ -412,10 +493,10 @@ async function readOwnerOnlyTokenFile(path: string): Promise { !fileStat.isFile() || (fileStat.mode & 0o077) !== 0 || (effectiveUid !== undefined && fileStat.uid !== effectiveUid) || - fileStat.size < 32 || - fileStat.size > 16_384 + fileStat.size < options.minimumBytes || + fileStat.size > options.maximumBytes ) { - throw new Error("auth token file must be an owner-owned, owner-only regular file"); + throw new Error(`${options.label} file must be an owner-owned, owner-only regular file`); } return (await handle.readFile("utf8")).trim(); } finally { diff --git a/src/identity-auth.ts b/src/identity-auth.ts index a6ec941..19fcd95 100644 --- a/src/identity-auth.ts +++ b/src/identity-auth.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { SignJWT, createLocalJWKSet, @@ -106,6 +106,32 @@ export interface IssueIdentityAccessTokenOptions { expiresAt: number; } +export interface IdentityAccessTokenIssuerOptions { + registry: IdentityJwksRegistry; + privateKey: CryptoKey | KeyObject | JWK | Uint8Array; + kid: string; + alg: IdentityPublicKeyAlgorithm; + issuer: string; + audience: string | readonly string[]; + accessTokenTtlSeconds?: number; +} + +export interface IdentityAccessTokenIssueInput { + subject: string; + tenant: string; + session: string; + scopes: readonly string[]; + now?: Date; + jti?: string; +} + +export interface IdentityAccessTokenIssue { + token: string; + jti: string; + issuedAt: number; + expiresAt: number; +} + export class IdentityAuthError extends Error { constructor( readonly reason: @@ -226,6 +252,76 @@ export class IdentityJwksRegistry { } return { keys: [toVerificationJwk(key)] }; } + + assertCanIssue(kid: string, alg: IdentityPublicKeyAlgorithm, now = new Date()): void { + const normalizedKid = requiredText(kid, "kid"); + const key = this.keys.get(normalizedKid); + if (key === undefined || key.status !== "active") { + throw configurationError("access tokens require an active signing key from the bound JWKS registry"); + } + if (key.alg !== alg) { + throw configurationError("access token signing algorithm must match the bound JWKS key"); + } + if (!isWithinPublicationWindow(key, now)) { + throw configurationError("access token signing key is outside its publication window"); + } + } +} + +export class IdentityAccessTokenIssuer { + readonly registry: IdentityJwksRegistry; + private readonly privateKey: CryptoKey | KeyObject | JWK | Uint8Array; + private readonly kid: string; + private readonly alg: IdentityPublicKeyAlgorithm; + private readonly issuer: string; + private readonly audience: string | string[]; + private readonly accessTokenTtlSeconds: number; + + constructor(options: IdentityAccessTokenIssuerOptions) { + this.registry = options.registry; + this.privateKey = options.privateKey; + this.kid = requiredText(options.kid, "kid"); + this.alg = requirePublicKeyAlgorithm(options.alg); + this.issuer = requiredText(options.issuer, "issuer"); + if (this.issuer !== options.registry.issuer) { + throw configurationError("access token issuer must match the bound JWKS registry"); + } + this.audience = normalizeAudience(options.audience); + this.accessTokenTtlSeconds = boundedPositiveInteger( + options.accessTokenTtlSeconds ?? 600, + "accessTokenTtlSeconds", + 3_600, + ); + options.registry.assertCanIssue(this.kid, this.alg); + } + + isBoundToJwksRegistry(registry: IdentityJwksRegistry): boolean { + return this.registry === registry; + } + + async issue(input: IdentityAccessTokenIssueInput): Promise { + const now = input.now ?? new Date(); + this.registry.assertCanIssue(this.kid, this.alg, now); + const issuedAt = Math.floor(now.getTime() / 1_000); + const expiresAt = issuedAt + this.accessTokenTtlSeconds; + const jti = requiredText(input.jti ?? randomUUID(), "jti"); + const token = await issueIdentityAccessToken({ + privateKey: this.privateKey, + kid: this.kid, + alg: this.alg, + issuer: this.issuer, + audience: this.audience, + subject: input.subject, + tenant: input.tenant, + session: input.session, + scopes: input.scopes, + jti, + issuedAt, + notBefore: issuedAt, + expiresAt, + }); + return { token, jti, issuedAt, expiresAt }; + } } export class IdentityAccessTokenVerifier { diff --git a/src/index.ts b/src/index.ts index 66fa2a2..9f9d152 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,6 +70,7 @@ export { IDENTITY_AUDIT_TABLE, IDENTITY_STORE_TABLE, identitiesMigrations, + rollbackIdentityLifecycleMigrations, } from "./migrations.js"; export { getPackageVersion } from "./version.js"; export { @@ -157,6 +158,7 @@ export { IDENTITY_JWKS_SCHEMA_VERSION, IDENTITY_PUBLIC_KEY_ALGORITHMS, IdentityAccessTokenVerifier, + IdentityAccessTokenIssuer, IdentityAuthError, IdentityJwksRegistry, InMemoryHashedTokenStateStore, @@ -164,6 +166,9 @@ export { hashOpaqueClaim, issueIdentityAccessToken, type IdentityAccessTokenClaims, + type IdentityAccessTokenIssue, + type IdentityAccessTokenIssueInput, + type IdentityAccessTokenIssuerOptions, type IdentityAccessTokenVerifierOptions, type IdentityJwksDocument, type IdentityJwksKeyInput, @@ -180,6 +185,8 @@ export { type IdentityAuthApi, type IdentityAuthApiOptions, } from "./identity-auth-api.js"; +export * from "./user-lifecycle.js"; +export { PgIdentityLifecycleStore } from "./pg-user-lifecycle.js"; export { listEveDocumentKeys, writeEveAgent } from "./eve.js"; export { applyContactPointSyncResults, syncIdentityContactPoints, syncIdentityContactPointsAndUpdate } from "./integrations.js"; export { diff --git a/src/migrations.ts b/src/migrations.ts index b423336..f27e87e 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -8,8 +8,14 @@ // against the whole store on every mutation. The api-keys table comes from the // canonical @hasna/contracts auth kit. -import { defineMigration, type Migration } from "./generated/storage-kit/index.js"; +import { + DEFAULT_MIGRATION_LEDGER_TABLE, + defineMigration, + type Migration, + type PoolQueryClient, +} from "./generated/storage-kit/index.js"; import { apiKeyMigrations } from "@hasna/contracts/auth"; +import { identityLifecycleMigrations } from "./user-lifecycle.js"; export const IDENTITY_STORE_TABLE = "identity_store"; export const IDENTITY_AUDIT_TABLE = "identity_audit"; @@ -43,6 +49,32 @@ export function identitiesMigrations(): Migration[] { `CREATE INDEX IF NOT EXISTS ${IDENTITY_AUDIT_TABLE}_store_seq_idx ON ${IDENTITY_AUDIT_TABLE} (store_id, seq DESC)`, ), + ...identityLifecycleMigrations().map((migration) => defineMigration(migration.id, migration.up)), ...apiKeyMigrations(API_KEYS_TABLE).map((m) => defineMigration(m.id, m.sql)), ]; } + +export async function rollbackIdentityLifecycleMigrations( + client: PoolQueryClient, + options: { allowDestructive: true; ledgerTable?: string }, +): Promise<{ rolledBack: string[] }> { + if (options.allowDestructive !== true) { + throw new Error("identity lifecycle rollback requires allowDestructive: true"); + } + const ledgerTable = options.ledgerTable ?? DEFAULT_MIGRATION_LEDGER_TABLE; + const migrations = identityLifecycleMigrations().slice().reverse(); + return client.transaction(async (tx) => { + const rolledBack: string[] = []; + for (const migration of migrations) { + const applied = await tx.get<{ id: string }>( + `SELECT id FROM ${ledgerTable} WHERE id = $1 FOR UPDATE`, + [migration.id], + ); + if (applied === null) continue; + await tx.execute(migration.down); + await tx.execute(`DELETE FROM ${ledgerTable} WHERE id = $1`, [migration.id]); + rolledBack.push(migration.id); + } + return { rolledBack }; + }); +} diff --git a/src/pg-store.ts b/src/pg-store.ts index b8ffcff..ef7435c 100644 --- a/src/pg-store.ts +++ b/src/pg-store.ts @@ -26,6 +26,7 @@ import { type TypedQueryClient, } from "./generated/storage-kit/index.js"; import { DEFAULT_STORE_ID, IDENTITY_AUDIT_TABLE, IDENTITY_STORE_TABLE, identitiesMigrations } from "./migrations.js"; +import { PgIdentityLifecycleStore } from "./pg-user-lifecycle.js"; export const IDENTITIES_APP_NAME = "identities"; @@ -103,6 +104,7 @@ export class PgStorageBackend implements StorageBackend { export interface CloudIdentityStore { store: IdentityStore; + lifecycleStore: PgIdentityLifecycleStore; client: PoolQueryClient; connectionSource: string; close: () => Promise; @@ -119,6 +121,7 @@ export function createCloudIdentityStore(options: { storeId?: string; applicatio const store = new IdentityStore({ backend: new PgStorageBackend(client, options.storeId) }); return { store, + lifecycleStore: new PgIdentityLifecycleStore(client), client, connectionSource, close: async () => { diff --git a/src/pg-user-lifecycle.test.ts b/src/pg-user-lifecycle.test.ts new file mode 100644 index 0000000..4cd718f --- /dev/null +++ b/src/pg-user-lifecycle.test.ts @@ -0,0 +1,2354 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { exportJWK, generateKeyPair, type CryptoKey, type JWK, type KeyObject } from "jose"; +import { Pool } from "pg"; +import { createQueryClient, type PoolQueryClient } from "./generated/storage-kit/query.js"; +import { + IdentityAccessTokenIssuer, + IdentityAccessTokenVerifier, + IdentityJwksRegistry, +} from "./identity-auth.js"; +import { rollbackIdentityLifecycleMigrations } from "./migrations.js"; +import { PgIdentityLifecycleStore } from "./pg-user-lifecycle.js"; +import { runIdentitiesMigrations } from "./pg-store.js"; +import { + Argon2idIdentityPasswordHasher, + DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE, + InMemoryIdentityLifecycleStore, + IdentityLifecycleError, + IdentityLifecycleService, + identityLifecycleMigrations, + type IdentityLifecycleSnapshot, + type IdentityInviteRecord, + type IdentityMembershipRole, + type IdentityMembershipStatus, + type IdentityUserStatus, +} from "./user-lifecycle.js"; + +const databaseUrl = process.env["TEST_DATABASE_URL"]; +const describeLive = databaseUrl === undefined ? describe.skip : describe; +const ISSUER = "https://identity-pg.example.test"; + +type SigningKey = CryptoKey | KeyObject; + +type AdministrationParityCase = { + name: string; + actorUserStatus?: IdentityUserStatus | "missing"; + tenantState?: "present" | "missing"; + actorRole?: IdentityMembershipRole | "missing"; + actorMembershipStatus?: IdentityMembershipStatus; + actorTenant?: "requested" | "other"; + targetUserState?: "present" | "missing"; + targetRole?: IdentityMembershipRole | "missing"; + targetMembershipStatus?: IdentityMembershipStatus; + targetTenant?: "requested" | "other"; + expected: boolean; +}; + +const administrationParityCases: readonly AdministrationParityCase[] = [ + { name: "active owner administers active member", expected: true }, + { name: "active admin administers active member", actorRole: "admin", expected: true }, + { name: "active owner administers active admin", targetRole: "admin", expected: true }, + { + name: "suspended target membership is not administrable", + targetMembershipStatus: "suspended", + expected: false, + }, + { name: "disabled actor user cannot administer", actorUserStatus: "disabled", expected: false }, + { name: "deleted actor user cannot administer", actorUserStatus: "deleted", expected: false }, + { + name: "suspended actor membership cannot administer", + actorMembershipStatus: "suspended", + expected: false, + }, + { name: "member role cannot administer", actorRole: "member", expected: false }, + { + name: "admin cannot administer another admin", + actorRole: "admin", + targetRole: "admin", + expected: false, + }, + { name: "owner cannot administer another owner", targetRole: "owner", expected: false }, + { name: "missing requested tenant is denied", tenantState: "missing", expected: false }, + { name: "missing actor user is denied", actorUserStatus: "missing", expected: false }, + { name: "missing actor membership is denied", actorRole: "missing", expected: false }, + { name: "missing target user is denied", targetUserState: "missing", expected: false }, + { name: "missing target membership is denied", targetRole: "missing", expected: false }, + { name: "cross-tenant actor membership is denied", actorTenant: "other", expected: false }, + { name: "cross-tenant target membership is denied", targetTenant: "other", expected: false }, +]; + +function administrationParityState( + index: number, + scenario: AdministrationParityCase, +): { + actorUserId: string; + targetUserId: string; + tenantId: string; + snapshot: IdentityLifecycleSnapshot; +} { + const suffix = index.toString().padStart(2, "0"); + const actorUserId = `usr_review_d2_actor_${suffix}`; + const targetUserId = `usr_review_d2_target_${suffix}`; + const tenantId = `ten_review_d2_requested_${suffix}`; + const otherTenantId = `ten_review_d2_other_${suffix}`; + const createdAt = "2026-07-23T00:00:00.000Z"; + const actorUserStatus = scenario.actorUserStatus ?? "active"; + const tenantState = scenario.tenantState ?? "present"; + const actorRole = scenario.actorRole ?? "owner"; + const actorMembershipStatus = scenario.actorMembershipStatus ?? "active"; + const actorTenant = scenario.actorTenant ?? "requested"; + const targetUserState = scenario.targetUserState ?? "present"; + const targetRole = scenario.targetRole ?? "member"; + const targetMembershipStatus = scenario.targetMembershipStatus ?? "active"; + const targetTenant = scenario.targetTenant ?? "requested"; + const needsOtherTenant = + actorTenant === "other" || targetTenant === "other"; + const actorMembershipTenantId = actorTenant === "requested" ? tenantId : otherTenantId; + const targetMembershipTenantId = targetTenant === "requested" ? tenantId : otherTenantId; + const snapshot: IdentityLifecycleSnapshot = { + users: [ + ...(actorUserStatus === "missing" + ? [] + : [{ + id: actorUserId, + status: actorUserStatus, + displayName: `Parity actor ${suffix}`, + createdAt, + updatedAt: createdAt, + }]), + ...(targetUserState === "present" + ? [{ + id: targetUserId, + status: "active" as const, + displayName: `Parity target ${suffix}`, + createdAt, + updatedAt: createdAt, + }] + : []), + ], + tenants: [ + ...(tenantState === "present" + ? [{ + id: tenantId, + slug: `review-d2-requested-${suffix}`, + name: `Parity requested tenant ${suffix}`, + allowedScopes: ["runs:read"], + createdAt, + }] + : []), + ...(needsOtherTenant + ? [{ + id: otherTenantId, + slug: `review-d2-other-${suffix}`, + name: `Parity other tenant ${suffix}`, + allowedScopes: ["runs:read"], + createdAt, + }] + : []), + ], + memberships: [ + ...(actorRole === "missing" + ? [] + : [{ + id: `mem_review_d2_actor_${suffix}`, + tenantId: actorMembershipTenantId, + userId: actorUserId, + role: actorRole, + scopes: ["runs:read"], + status: actorMembershipStatus, + createdAt, + }]), + ...(targetRole === "missing" + ? [] + : [{ + id: `mem_review_d2_target_${suffix}`, + tenantId: targetMembershipTenantId, + userId: targetUserId, + role: targetRole, + scopes: ["runs:read"], + status: targetMembershipStatus, + createdAt, + }]), + ], + loginIdentifiers: [], + credentials: [], + invites: [], + sessionFamilies: [], + refreshTokens: [], + oneTimeTokens: [], + loginThrottles: [], + jtiRevocations: [], + issuedAccessTokens: [], + }; + return { actorUserId, targetUserId, tenantId, snapshot }; +} + +type MembershipSuspensionParityCase = { + name: string; + tenantState?: "present" | "missing"; + actorMembershipStatus?: IdentityMembershipStatus; + targetUserStatus?: IdentityUserStatus | "missing"; + targetMembershipStatus?: IdentityMembershipStatus | "missing"; + targetTenant?: "requested" | "other"; + expectedOutcome: "suspended" | "not_found" | "forbidden"; + expectedMemoryTargetStatus: IdentityMembershipStatus | "missing"; +}; + +const membershipSuspensionParityCases: readonly MembershipSuspensionParityCase[] = [ + { + name: "suspends an active target membership", + expectedOutcome: "suspended", + expectedMemoryTargetStatus: "suspended", + }, + { + name: "keeps an already suspended target membership suspended", + targetMembershipStatus: "suspended", + expectedOutcome: "suspended", + expectedMemoryTargetStatus: "suspended", + }, + { + name: "permits suspension when the existing target user is disabled", + targetUserStatus: "disabled", + expectedOutcome: "suspended", + expectedMemoryTargetStatus: "suspended", + }, + { + name: "denies a suspended actor membership without mutating the target", + actorMembershipStatus: "suspended", + expectedOutcome: "forbidden", + expectedMemoryTargetStatus: "active", + }, + { + name: "treats orphan memberships for a missing requested tenant as not found", + tenantState: "missing", + expectedOutcome: "not_found", + expectedMemoryTargetStatus: "active", + }, + { + name: "treats an orphan target membership for a missing target user as not found", + targetUserStatus: "missing", + expectedOutcome: "not_found", + expectedMemoryTargetStatus: "active", + }, + { + name: "treats a missing target membership as not found", + targetMembershipStatus: "missing", + expectedOutcome: "not_found", + expectedMemoryTargetStatus: "missing", + }, + { + name: "does not mutate a cross-tenant target membership", + targetTenant: "other", + expectedOutcome: "not_found", + expectedMemoryTargetStatus: "active", + }, +]; + +function membershipSuspensionParityState( + index: number, + scenario: MembershipSuspensionParityCase, +): { + actorUserId: string; + targetUserId: string; + tenantId: string; + targetMembershipId: string; + snapshot: IdentityLifecycleSnapshot; +} { + const suffix = index.toString().padStart(2, "0"); + const actorUserId = `usr_review_e_actor_${suffix}`; + const targetUserId = `usr_review_e_target_${suffix}`; + const tenantId = `ten_review_e_requested_${suffix}`; + const otherTenantId = `ten_review_e_other_${suffix}`; + const targetMembershipId = `mem_review_e_target_${suffix}`; + const createdAt = "2026-07-23T00:00:00.000Z"; + const targetUserStatus = scenario.targetUserStatus ?? "active"; + const targetMembershipStatus = scenario.targetMembershipStatus ?? "active"; + const targetTenant = scenario.targetTenant ?? "requested"; + const snapshot: IdentityLifecycleSnapshot = { + users: [ + { + id: actorUserId, + status: "active", + displayName: `Suspension actor ${suffix}`, + createdAt, + updatedAt: createdAt, + }, + ...(targetUserStatus === "missing" + ? [] + : [{ + id: targetUserId, + status: targetUserStatus, + displayName: `Suspension target ${suffix}`, + createdAt, + updatedAt: createdAt, + }]), + ], + tenants: [ + ...(scenario.tenantState === "missing" + ? [] + : [{ + id: tenantId, + slug: `review-e-requested-${suffix}`, + name: `Suspension requested tenant ${suffix}`, + allowedScopes: ["runs:read"], + createdAt, + }]), + ...(targetTenant === "other" + ? [{ + id: otherTenantId, + slug: `review-e-other-${suffix}`, + name: `Suspension other tenant ${suffix}`, + allowedScopes: ["runs:read"], + createdAt, + }] + : []), + ], + memberships: [ + { + id: `mem_review_e_actor_${suffix}`, + tenantId, + userId: actorUserId, + role: "owner", + scopes: ["runs:read"], + status: scenario.actorMembershipStatus ?? "active", + createdAt, + }, + ...(targetMembershipStatus === "missing" + ? [] + : [{ + id: targetMembershipId, + tenantId: targetTenant === "requested" ? tenantId : otherTenantId, + userId: targetUserId, + role: "member" as const, + scopes: ["runs:read"], + status: targetMembershipStatus, + createdAt, + }]), + ], + loginIdentifiers: [], + credentials: [], + invites: [], + sessionFamilies: [], + refreshTokens: [], + oneTimeTokens: [], + loginThrottles: [], + jtiRevocations: [], + issuedAccessTokens: [], + }; + return { actorUserId, targetUserId, tenantId, targetMembershipId, snapshot }; +} + +async function observeMembershipSuspension( + store: InMemoryIdentityLifecycleStore | PgIdentityLifecycleStore, + fixture: ReturnType, +): Promise { + try { + const membership = await store.suspendMembership({ + actorUserId: fixture.actorUserId, + tenantId: fixture.tenantId, + targetUserId: fixture.targetUserId, + now: new Date("2026-07-23T01:00:00.000Z"), + }); + return membership?.status ?? "not_found"; + } catch (error) { + if (error instanceof IdentityLifecycleError) return error.reason; + throw error; + } +} + +const REVIEW_E_PLATFORM_AUTHORITY_SCOPE = "identities:users:manage"; + +function orphanTargetRoleMutationState(): IdentityLifecycleSnapshot { + const createdAt = "2026-07-23T00:00:00.000Z"; + return { + users: [ + { + id: "usr_review_e_mutation_actor", + status: "active", + displayName: "Mutation actor", + createdAt, + updatedAt: createdAt, + }, + { + id: "usr_review_e_mutation_target", + status: "active", + displayName: "Mutation target", + createdAt, + updatedAt: createdAt, + }, + ], + tenants: [{ + id: "ten_review_e_mutation_actor", + slug: "review-e-mutation-actor", + name: "Mutation actor tenant", + allowedScopes: [REVIEW_E_PLATFORM_AUTHORITY_SCOPE], + createdAt, + }], + memberships: [ + { + id: "mem_review_e_mutation_actor", + tenantId: "ten_review_e_mutation_actor", + userId: "usr_review_e_mutation_actor", + role: "owner", + scopes: [REVIEW_E_PLATFORM_AUTHORITY_SCOPE], + status: "active", + createdAt, + }, + { + id: "mem_review_e_mutation_orphan_target", + tenantId: "ten_review_e_mutation_missing", + userId: "usr_review_e_mutation_target", + role: "member", + scopes: ["runs:read"], + status: "active", + createdAt, + }, + ], + loginIdentifiers: [], + credentials: [], + invites: [], + sessionFamilies: [], + refreshTokens: [], + oneTimeTokens: [], + loginThrottles: [], + jtiRevocations: [], + issuedAccessTokens: [], + }; +} + +async function observeUserSecurityMutation( + store: InMemoryIdentityLifecycleStore | PgIdentityLifecycleStore, +): Promise { + try { + const user = await store.mutateUserSecurityState({ + actorUserId: "usr_review_e_mutation_actor", + actorTenantId: "ten_review_e_mutation_actor", + actorTokenScopes: [REVIEW_E_PLATFORM_AUTHORITY_SCOPE], + platformAuthorityScope: REVIEW_E_PLATFORM_AUTHORITY_SCOPE, + platformAuthorityTenantSlugs: ["review-e-mutation-actor"], + targetUserId: "usr_review_e_mutation_target", + status: "disabled", + now: new Date("2026-07-23T01:00:00.000Z"), + }); + return user?.status ?? "not_found"; + } catch (error) { + if (error instanceof IdentityLifecycleError) return error.reason; + throw error; + } +} + +type IssuedTokenParityCase = { + name: string; + userStatus?: IdentityUserStatus | "missing"; + tenantState?: "present" | "missing"; + membershipStatus?: IdentityMembershipStatus | "missing"; + expectedOutcome: "recorded" | "invalid_credentials"; +}; + +const issuedTokenParityCases: readonly IssuedTokenParityCase[] = [ + { + name: "records for an active user, tenant, membership, and family", + expectedOutcome: "recorded", + }, + { + name: "rejects an active family whose user is missing", + userStatus: "missing", + expectedOutcome: "invalid_credentials", + }, + { + name: "rejects an active family whose user is disabled", + userStatus: "disabled", + expectedOutcome: "invalid_credentials", + }, + { + name: "rejects an active family whose tenant is missing", + tenantState: "missing", + expectedOutcome: "invalid_credentials", + }, + { + name: "rejects an active family whose membership is missing", + membershipStatus: "missing", + expectedOutcome: "invalid_credentials", + }, + { + name: "rejects an active family whose membership is suspended", + membershipStatus: "suspended", + expectedOutcome: "invalid_credentials", + }, +]; + +function issuedTokenParityState( + index: number, + scenario: IssuedTokenParityCase, +): { + snapshot: IdentityLifecycleSnapshot; + input: Parameters[0]; +} { + const suffix = index.toString().padStart(2, "0"); + const userId = `usr_review_e_issued_${suffix}`; + const tenantId = `ten_review_e_issued_${suffix}`; + const familyId = `fam_review_e_issued_${suffix}`; + const createdAt = "2026-07-23T00:00:00.000Z"; + const expiresAt = "2026-07-24T00:00:00.000Z"; + const userStatus = scenario.userStatus ?? "active"; + const membershipStatus = scenario.membershipStatus ?? "active"; + const snapshot: IdentityLifecycleSnapshot = { + users: userStatus === "missing" + ? [] + : [{ + id: userId, + status: userStatus, + displayName: `Issued-token user ${suffix}`, + createdAt, + updatedAt: createdAt, + }], + tenants: scenario.tenantState === "missing" + ? [] + : [{ + id: tenantId, + slug: `review-e-issued-${suffix}`, + name: `Issued-token tenant ${suffix}`, + allowedScopes: ["runs:read"], + createdAt, + }], + memberships: membershipStatus === "missing" + ? [] + : [{ + id: `mem_review_e_issued_${suffix}`, + tenantId, + userId, + role: "member", + scopes: ["runs:read"], + status: membershipStatus, + createdAt, + }], + loginIdentifiers: [], + credentials: [], + invites: [], + sessionFamilies: [{ + id: familyId, + userId, + tenantId, + scopes: ["runs:read"], + status: "active", + expiresAt, + createdAt, + updatedAt: createdAt, + }], + refreshTokens: [], + oneTimeTokens: [], + loginThrottles: [], + jtiRevocations: [], + issuedAccessTokens: [], + }; + return { + snapshot, + input: { + jtiHash: `jti_review_e_issued_${suffix}`, + familyId, + userId, + tenantId, + expiresAt, + issuedAt: createdAt, + }, + }; +} + +async function observeIssuedAccessTokenRecording( + store: InMemoryIdentityLifecycleStore | PgIdentityLifecycleStore, + input: Parameters[0], +): Promise { + try { + await store.recordIssuedAccessToken(input); + return "recorded"; + } catch (error) { + if (error instanceof IdentityLifecycleError) return error.reason; + throw error; + } +} + +describe("InMemoryIdentityLifecycleStore canAdminister active-state contract", () => { + for (const [index, scenario] of administrationParityCases.entries()) { + test(scenario.name, async () => { + const fixture = administrationParityState(index, scenario); + const store = new InMemoryIdentityLifecycleStore(fixture.snapshot); + expect( + await store.canAdminister( + fixture.actorUserId, + fixture.tenantId, + fixture.targetUserId, + ), + ).toBe(scenario.expected); + }); + } +}); + +describe("InMemoryIdentityLifecycleStore suspendMembership relational parity", () => { + for (const [index, scenario] of membershipSuspensionParityCases.entries()) { + test(scenario.name, async () => { + const fixture = membershipSuspensionParityState(index, scenario); + const store = new InMemoryIdentityLifecycleStore(fixture.snapshot); + const outcome = await observeMembershipSuspension(store, fixture); + const targetStatus = + store.snapshot().memberships.find( + (membership) => membership.id === fixture.targetMembershipId, + )?.status ?? "missing"; + expect({ + outcome, + targetStatus, + }).toEqual({ + outcome: scenario.expectedOutcome, + targetStatus: scenario.expectedMemoryTargetStatus, + }); + }); + } +}); + +test("InMemoryIdentityLifecycleStore ignores orphan target roles for user security mutation", async () => { + const store = new InMemoryIdentityLifecycleStore(orphanTargetRoleMutationState()); + expect({ + outcome: await observeUserSecurityMutation(store), + targetStatus: store.snapshot().users.find( + (user) => user.id === "usr_review_e_mutation_target", + )?.status, + }).toEqual({ + outcome: "forbidden", + targetStatus: "active", + }); +}); + +describe("InMemoryIdentityLifecycleStore recordIssuedAccessToken authority parity", () => { + for (const [index, scenario] of issuedTokenParityCases.entries()) { + test(scenario.name, async () => { + const fixture = issuedTokenParityState(index, scenario); + const store = new InMemoryIdentityLifecycleStore(fixture.snapshot); + expect({ + outcome: await observeIssuedAccessTokenRecording(store, fixture.input), + recorded: store.snapshot().issuedAccessTokens.some( + (token) => token.jtiHash === fixture.input.jtiHash, + ), + }).toEqual({ + outcome: scenario.expectedOutcome, + recorded: scenario.expectedOutcome === "recorded", + }); + }); + } +}); + +describeLive("PgIdentityLifecycleStore live Postgres", () => { + let client: PoolQueryClient; + let privateKey: SigningKey; + let publicJwk: JWK; + + beforeAll(async () => { + const pool = new Pool({ connectionString: databaseUrl, max: 6 }); + client = createQueryClient(pool); + const pair = await generateKeyPair("EdDSA"); + privateKey = pair.privateKey; + publicJwk = await exportJWK(pair.publicKey); + await runIdentitiesMigrations(client); + }); + + afterAll(async () => { + await client.close(); + }); + + function service(registrationPolicy: "open" | "invite" = "open") { + const registry = new IdentityJwksRegistry({ + issuer: ISSUER, + revision: 1, + keys: [{ kid: "live", alg: "EdDSA", status: "active", publicJwk }], + }); + const store = new PgIdentityLifecycleStore(client); + const tokenIssuer = new IdentityAccessTokenIssuer({ + registry, + privateKey, + kid: "live", + alg: "EdDSA", + issuer: ISSUER, + audience: "infinity-pg", + accessTokenTtlSeconds: 300, + }); + const verifier = new IdentityAccessTokenVerifier({ + issuer: ISSUER, + audience: "infinity-pg", + algorithms: ["EdDSA"], + jwks: registry, + tokenState: store, + clockToleranceSeconds: 0, + maxTokenLifetimeSeconds: 300, + }); + return { + store, + verifier, + service: new IdentityLifecycleService({ + store, + registrationPolicy, + bootstrapTenant: { slug: "infinity-pg", name: "Infinity PG" }, + passwordHasher: new Argon2idIdentityPasswordHasher({ + memoryCost: 32_768, + timeCost: 2, + }), + tokenIssuer, + tokenVerifier: verifier, + defaultScopes: [ + "runs:read", + "runs:write", + "identities:invites:manage", + "identities:platform:admin", + ], + loginThrottle: { + maxFailures: 3, + windowSeconds: 300, + lockSeconds: 300, + maxConcurrent: 2, + }, + }), + }; + } + + test("migrations reapply without drift, roll back in reverse, and reapply cleanly", async () => { + const reapplied = await runIdentitiesMigrations(client); + expect(reapplied.plan.every((item) => item.state === "already_applied")).toBe(true); + + const rolledBack = await rollbackIdentityLifecycleMigrations(client, { + allowDestructive: true, + }); + expect(rolledBack.rolledBack).toEqual([ + "identities_0009_user_lifecycle_security", + "identities_0008_user_login_throttle", + "identities_0007_user_verification_recovery", + "identities_0006_user_sessions", + "identities_0005_user_credentials", + "identities_0004_user_tenancy", + ]); + const missing = await client.one<{ table_name: string | null }>( + "SELECT to_regclass('public.identity_users')::text AS table_name", + ); + expect(missing.table_name).toBeNull(); + + const restored = await runIdentitiesMigrations(client); + const lifecycleIds = new Set(identityLifecycleMigrations().map((migration) => migration.id)); + expect( + restored.plan + .filter((item) => lifecycleIds.has(item.migration.id)) + .every((item) => item.state === "pending"), + ).toBe(true); + const present = await client.one<{ table_name: string | null }>( + "SELECT to_regclass('public.identity_users')::text AS table_name", + ); + expect(present.table_name).toBe("identity_users"); + }); + + test("persists atomic signup, tenant-bound sessions, rotation, replay, and revocation", async () => { + const live = service(); + const admin = await live.service.bootstrapFirstAdmin({ + identifier: { kind: "email", value: "owner@pg.example.test" }, + password: "correct horse battery staple", + displayName: "PG Owner", + }); + expect((await live.verifier.verify(admin.accessToken)).tenant).toBe(admin.tenant.id); + const narrow = await live.service.login({ + identifier: { kind: "email", value: "owner@pg.example.test" }, + password: "correct horse battery staple", + tenantId: admin.tenant.id, + scopes: ["runs:read"], + }); + const narrowRotated = await live.service.refresh({ refreshToken: narrow.refreshToken }); + expect(narrowRotated.scopes).toEqual(["runs:read"]); + + const concurrent = await Promise.allSettled([ + live.service.signup({ + identifier: { kind: "email", value: "race@pg.example.test" }, + password: "a secure concurrent password", + displayName: "Race One", + }), + live.service.signup({ + identifier: { kind: "email", value: " RACE@PG.EXAMPLE.TEST " }, + password: "a secure concurrent password", + displayName: "Race Two", + }), + ]); + expect(concurrent.filter((result) => result.status === "fulfilled")).toHaveLength(1); + const rejected = concurrent.find((result) => result.status === "rejected") as PromiseRejectedResult; + expect(rejected.reason).toBeInstanceOf(IdentityLifecycleError); + expect((rejected.reason as IdentityLifecycleError).reason).toBe("duplicate_identifier"); + + const rotated = await live.service.refresh({ refreshToken: admin.refreshToken }); + expect(rotated.refreshToken).not.toBe(admin.refreshToken); + await expect(live.service.refresh({ refreshToken: admin.refreshToken })).rejects.toMatchObject({ + reason: "refresh_replay", + }); + await expect(live.verifier.verify(rotated.accessToken)).rejects.toMatchObject({ + reason: "session_inactive", + }); + + const familyRows = await client.many<{ status: string; session_hash: string }>( + "SELECT status, session_hash FROM identity_session_families WHERE user_id = $1", + [admin.user.id], + ); + expect(familyRows.some((row) => row.status === "revoked")).toBe(true); + expect(familyRows.every((row) => /^[a-f0-9]{64}$/.test(row.session_hash))).toBe(true); + const refreshRows = await client.many<{ token_hash: string }>( + "SELECT token_hash FROM identity_refresh_tokens", + ); + expect(refreshRows.every((row) => /^[a-f0-9]{64}$/.test(row.token_hash))).toBe(true); + expect(JSON.stringify(refreshRows)).not.toContain(admin.refreshToken); + }); + + test("enforces review-A authorization, current scope, atomic state, throttle, and IDN invariants", async () => { + const live = service("invite"); + await live.service.ready(); + const ownerTenant = await client.one<{ id: string }>( + "SELECT id FROM identity_tenants WHERE slug = 'infinity-pg'", + ); + const owner = await live.service.login({ + identifier: { kind: "email", value: "owner@pg.example.test" }, + password: "correct horse battery staple", + tenantId: ownerTenant.id, + }); + + await expect(live.service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: ownerTenant.id, + role: "member", + scopes: ["root:arbitrary"], + })).rejects.toMatchObject({ reason: "invalid_scope" }); + + const adminInvite = await live.service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: ownerTenant.id, + identifier: { kind: "email", value: "pg-admin@example.test" }, + role: "admin", + scopes: ["runs:read", "identities:invites:manage"], + }); + const admin = await live.service.signup({ + identifier: { kind: "email", value: "pg-admin@example.test" }, + password: "a sufficiently strong password", + displayName: "PG Admin", + inviteToken: adminInvite.token, + }); + await expect(live.service.createInvite({ + actorAccessToken: admin.accessToken, + tenantId: ownerTenant.id, + role: "owner", + scopes: ["runs:read"], + })).rejects.toMatchObject({ reason: "forbidden" }); + await expect(live.service.disableUser({ + actorAccessToken: admin.accessToken, + userId: owner.user.id, + })).rejects.toMatchObject({ reason: "forbidden" }); + expect((await live.verifier.verify(owner.accessToken)).sub).toBe(owner.user.id); + + const memberInvite = await live.service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: ownerTenant.id, + identifier: { kind: "email", value: "pg-scope-member@example.test" }, + role: "member", + scopes: ["runs:read", "runs:write"], + }); + const member = await live.service.signup({ + identifier: { kind: "email", value: "pg-scope-member@example.test" }, + password: "a sufficiently strong password", + displayName: "PG Scope Member", + inviteToken: memberInvite.token, + }); + await client.execute( + "UPDATE identity_memberships SET scopes = $3::jsonb WHERE tenant_id = $1 AND user_id = $2", + [ownerTenant.id, member.user.id, JSON.stringify(["runs:read"])], + ); + const directNow = new Date(); + const directFamily = { + id: "ses_pg_scope_intersection", + userId: member.user.id, + tenantId: ownerTenant.id, + scopes: ["runs:read", "runs:write"], + status: "active" as const, + expiresAt: new Date(directNow.getTime() + 300_000).toISOString(), + createdAt: directNow.toISOString(), + updatedAt: directNow.toISOString(), + }; + await live.store.createSession({ + family: directFamily, + refresh: { + id: "rft_pg_scope_intersection", + familyId: directFamily.id, + tokenHash: "b".repeat(64), + generation: 0, + expiresAt: directFamily.expiresAt, + createdAt: directNow.toISOString(), + }, + }); + expect(directFamily.scopes).toEqual(["runs:read"]); + const narrowed = await live.service.refresh({ refreshToken: member.refreshToken }); + expect(narrowed.scopes).toEqual(["runs:read"]); + await client.execute( + "UPDATE identity_memberships SET scopes = '[]'::jsonb WHERE tenant_id = $1 AND user_id = $2", + [ownerTenant.id, member.user.id], + ); + await expect(live.service.refresh({ refreshToken: narrowed.refreshToken })).rejects.toMatchObject({ + reason: "refresh_invalid", + }); + await expect(live.verifier.verify(narrowed.accessToken)).rejects.toMatchObject({ + reason: "session_inactive", + }); + + const disableInvite = await live.service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: ownerTenant.id, + identifier: { kind: "email", value: "pg-disable-member@example.test" }, + role: "member", + scopes: ["runs:read"], + }); + const disabled = await live.service.signup({ + identifier: { kind: "email", value: "pg-disable-member@example.test" }, + password: "a sufficiently strong password", + displayName: "PG Disabled Member", + inviteToken: disableInvite.token, + }); + await live.service.disableUser({ + actorAccessToken: owner.accessToken, + userId: disabled.user.id, + }); + const atomicState = await client.one<{ + status: string; + active_families: string; + revoked_jtis: string; + }>( + `SELECT + identity_user.status, + ( + SELECT count(*)::text + FROM identity_session_families family + WHERE family.user_id = identity_user.id AND family.status = 'active' + ) AS active_families, + ( + SELECT count(*)::text + FROM identity_jti_revocations revocation + WHERE revocation.user_id = identity_user.id + ) AS revoked_jtis + FROM identity_users identity_user + WHERE identity_user.id = $1`, + [disabled.user.id], + ); + expect(atomicState).toMatchObject({ + status: "disabled", + active_families: "0", + }); + expect(Number(atomicState.revoked_jtis)).toBeGreaterThan(0); + await expect(live.verifier.verify(disabled.accessToken)).rejects.toMatchObject({ + reason: "token_revoked", + }); + + const admissionKeyHash = "f".repeat(64); + const attemptKeys = Array.from({ length: 8 }, (_, index) => ({ + failureKeyHash: (index + 1).toString(16).padStart(64, "0"), + admissionKeyHash, + })); + const admitted = await Promise.all( + attemptKeys.map((keys) => + live.store.reserveAuthAttempt(keys, new Date(), { + maxFailures: 3, + windowSeconds: 300, + lockSeconds: 300, + maxConcurrent: 2, + }), + ), + ); + expect(admitted.filter(Boolean)).toHaveLength(2); + await Promise.all( + admitted.map((accepted, index) => + accepted + ? live.store.completeAuthAttempt(attemptKeys[index]!, new Date(), "failure", { + maxFailures: 3, + windowSeconds: 300, + lockSeconds: 300, + maxConcurrent: 2, + }) + : Promise.resolve(), + ), + ); + const staleKeys = { + failureKeyHash: "c".repeat(64), + admissionKeyHash: "d".repeat(64), + }; + expect( + await live.store.reserveAuthAttempt(staleKeys, new Date(), { + maxFailures: 3, + windowSeconds: 300, + lockSeconds: 300, + maxConcurrent: 2, + }), + ).toBe(true); + await client.execute( + `UPDATE identity_login_throttle + SET tokens = 2, in_flight = 2, updated_at = now() - interval '301 seconds' + WHERE key_hash = ANY($1::text[])`, + [[staleKeys.failureKeyHash, staleKeys.admissionKeyHash]], + ); + expect( + await live.store.reserveAuthAttempt(staleKeys, new Date(), { + maxFailures: 3, + windowSeconds: 300, + lockSeconds: 300, + maxConcurrent: 2, + }), + ).toBe(true); + await live.store.completeAuthAttempt(staleKeys, new Date(), "failure", { + maxFailures: 3, + windowSeconds: 300, + lockSeconds: 300, + maxConcurrent: 2, + }); + + const openLive = service("open"); + const multiTenant = await openLive.service.signup({ + identifier: { kind: "email", value: "pg-multi-tenant@example.test" }, + password: "a sufficiently strong password", + displayName: "PG Multi Tenant", + }); + await client.execute( + `INSERT INTO identity_memberships + (id, tenant_id, user_id, role, scopes, status, created_at) + VALUES ('mem_pg_multi_tenant', $1, $2, 'member', '["runs:read"]'::jsonb, 'active', now())`, + [ownerTenant.id, multiTenant.user.id], + ); + const suspended = await live.service.suspendMembership({ + actorAccessToken: admin.accessToken, + tenantId: ownerTenant.id, + userId: multiTenant.user.id, + }); + expect(suspended.status).toBe("suspended"); + const multiTenantState = await client.one<{ status: string }>( + "SELECT status FROM identity_users WHERE id = $1", + [multiTenant.user.id], + ); + expect(multiTenantState.status).toBe("active"); + expect((await openLive.verifier.verify(multiTenant.accessToken)).sub).toBe(multiTenant.user.id); + + const personalOwner = await openLive.service.signup({ + identifier: { kind: "email", value: "pg-personal-owner@example.test" }, + password: "a sufficiently strong password", + displayName: "PG Personal Owner", + }); + const lowerTarget = await openLive.service.signup({ + identifier: { kind: "email", value: "pg-lower-target@example.test" }, + password: "a sufficiently strong password", + displayName: "PG Lower Target", + }); + await client.execute( + "UPDATE identity_memberships SET role = 'member' WHERE tenant_id = $1 AND user_id = $2", + [lowerTarget.tenant.id, lowerTarget.user.id], + ); + await expect(openLive.service.disableUser({ + actorAccessToken: personalOwner.accessToken, + userId: lowerTarget.user.id, + })).rejects.toMatchObject({ reason: "forbidden" }); + + const unicode = await openLive.service.signup({ + identifier: { kind: "email", value: "idn@bücher.pg-example.test" }, + password: "a sufficiently strong password", + displayName: "PG Unicode", + }); + await expect(openLive.service.signup({ + identifier: { kind: "email", value: "idn@xn--bcher-kva.pg-example.test" }, + password: "a sufficiently strong password", + displayName: "PG Punycode", + })).rejects.toMatchObject({ reason: "duplicate_identifier" }); + expect(unicode.user.status).toBe("active"); + + await client.execute( + `INSERT INTO identity_login_identifiers (id, user_id, kind, normalized_value, created_at) + VALUES + ('lid_audit_unicode', $1, 'email', 'audit@bücher.pg-example.test', now()), + ('lid_audit_ascii', $1, 'email', 'audit@xn--bcher-kva.pg-example.test', now())`, + [owner.user.id], + ); + await expect(live.store.prepare()).rejects.toMatchObject({ + reason: "invalid_configuration", + }); + const collisionAudit = await client.many<{ collision: boolean }>( + `SELECT collision + FROM identity_login_identifier_canonicalization_audit + WHERE identifier_id IN ('lid_audit_unicode', 'lid_audit_ascii')`, + ); + expect(collisionAudit).toHaveLength(2); + expect(collisionAudit.every((row) => row.collision)).toBe(true); + await client.execute( + `DELETE FROM identity_login_identifier_canonicalization_audit + WHERE identifier_id IN ('lid_audit_unicode', 'lid_audit_ascii')`, + ); + await client.execute( + `DELETE FROM identity_login_identifiers + WHERE id IN ('lid_audit_unicode', 'lid_audit_ascii')`, + ); + + const staleInvite = await live.service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: ownerTenant.id, + identifier: { kind: "email", value: "pg-stale-invite@example.test" }, + role: "member", + scopes: ["runs:read"], + }); + await client.execute( + `UPDATE identity_memberships + SET scopes = scopes - 'identities:invites:manage' + WHERE tenant_id = $1 AND user_id = $2`, + [ownerTenant.id, owner.user.id], + ); + await expect(live.service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: ownerTenant.id, + role: "member", + scopes: ["runs:read"], + })).rejects.toMatchObject({ reason: "forbidden" }); + await expect(live.service.signup({ + identifier: { kind: "email", value: "pg-stale-invite@example.test" }, + password: "a sufficiently strong password", + displayName: "PG Stale Invite", + inviteToken: staleInvite.token, + })).rejects.toMatchObject({ reason: "invite_invalid" }); + }); + + test("serializes invite scope changes with registration and rolls back the whole initial session", async () => { + const live = service("invite"); + const ownerTenant = await client.one<{ id: string }>( + "SELECT id FROM identity_tenants WHERE slug = 'infinity-pg'", + ); + await client.execute( + `UPDATE identity_memberships + SET scopes = scopes || $2::jsonb + WHERE tenant_id = $1 AND role = 'owner'`, + [ + ownerTenant.id, + JSON.stringify([DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE]), + ], + ); + const owner = await live.service.login({ + identifier: { kind: "email", value: "owner@pg.example.test" }, + password: "correct horse battery staple", + tenantId: ownerTenant.id, + throttleKey: "review-b-interleave-owner", + }); + const invite = await live.service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: ownerTenant.id, + identifier: { kind: "email", value: "pg-interleaved-scope@example.test" }, + role: "member", + scopes: ["runs:read"], + }); + + const blocker = await client.pool.connect(); + let transactionOpen = false; + let signup: + | ReturnType + | undefined; + let observedLockWait = false; + try { + await blocker.query("BEGIN"); + transactionOpen = true; + await blocker.query( + "SELECT id FROM identity_tenants WHERE id = $1 FOR UPDATE", + [ownerTenant.id], + ); + signup = live.service.signup({ + identifier: { kind: "email", value: "pg-interleaved-scope@example.test" }, + password: "a sufficiently strong password", + displayName: "PG Interleaved Scope", + inviteToken: invite.token, + }); + for (let attempt = 0; attempt < 200; attempt += 1) { + const state = await client.one<{ waiting: boolean; partial_user: boolean }>( + `SELECT + EXISTS ( + SELECT 1 + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND wait_event_type = 'Lock' + ) AS waiting, + EXISTS ( + SELECT 1 + FROM identity_login_identifiers + WHERE normalized_value = 'pg-interleaved-scope@example.test' + ) AS partial_user`, + ); + if (state.waiting || state.partial_user) { + observedLockWait = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + await blocker.query( + "UPDATE identity_tenants SET allowed_scopes = allowed_scopes - 'runs:read' WHERE id = $1", + [ownerTenant.id], + ); + await blocker.query("COMMIT"); + transactionOpen = false; + } finally { + if (transactionOpen) await blocker.query("ROLLBACK"); + blocker.release(); + } + + const signupOutcome = await signup!.catch((error) => error); + const persisted = await client.one<{ + users: string; + memberships: string; + sessions: string; + consumed_at: Date | string | null; + }>( + `SELECT + (SELECT count(*)::text + FROM identity_users identity_user + JOIN identity_login_identifiers identifier ON identifier.user_id = identity_user.id + WHERE identifier.normalized_value = 'pg-interleaved-scope@example.test') AS users, + (SELECT count(*)::text + FROM identity_memberships membership + JOIN identity_login_identifiers identifier ON identifier.user_id = membership.user_id + WHERE identifier.normalized_value = 'pg-interleaved-scope@example.test') AS memberships, + (SELECT count(*)::text + FROM identity_session_families family + JOIN identity_login_identifiers identifier ON identifier.user_id = family.user_id + WHERE identifier.normalized_value = 'pg-interleaved-scope@example.test') AS sessions, + consumed_at + FROM identity_invites + WHERE id = $1`, + [invite.id], + ); + await client.transaction(async (tx) => { + const partialUser = await tx.get<{ user_id: string }>( + `SELECT user_id FROM identity_login_identifiers + WHERE normalized_value = 'pg-interleaved-scope@example.test'`, + ); + await tx.execute("DELETE FROM identity_invites WHERE id = $1", [invite.id]); + if (partialUser !== null) { + await tx.execute("DELETE FROM identity_issued_access_tokens WHERE user_id = $1", [partialUser.user_id]); + await tx.execute( + `DELETE FROM identity_refresh_tokens + WHERE family_id IN (SELECT id FROM identity_session_families WHERE user_id = $1)`, + [partialUser.user_id], + ); + await tx.execute("DELETE FROM identity_session_families WHERE user_id = $1", [partialUser.user_id]); + await tx.execute("DELETE FROM identity_one_time_tokens WHERE user_id = $1", [partialUser.user_id]); + await tx.execute("DELETE FROM identity_password_credentials WHERE user_id = $1", [partialUser.user_id]); + await tx.execute("DELETE FROM identity_memberships WHERE user_id = $1", [partialUser.user_id]); + await tx.execute("DELETE FROM identity_login_identifiers WHERE user_id = $1", [partialUser.user_id]); + await tx.execute("DELETE FROM identity_users WHERE id = $1", [partialUser.user_id]); + } + await tx.execute( + "UPDATE identity_tenants SET allowed_scopes = allowed_scopes || '[\"runs:read\"]'::jsonb WHERE id = $1", + [ownerTenant.id], + ); + }); + expect(persisted).toMatchObject({ + users: "0", + memberships: "0", + sessions: "0", + consumed_at: null, + }); + expect(observedLockWait).toBe(true); + expect(signupOutcome).toMatchObject({ reason: "invite_invalid" }); + }, 15_000); + + test("persists the store authorization scope and rejects use after creator scope loss", async () => { + const live = service("invite"); + const ownerTenant = await client.one<{ id: string }>( + "SELECT id FROM identity_tenants WHERE slug = 'infinity-pg'", + ); + await client.execute( + `UPDATE identity_memberships + SET scopes = scopes || $2::jsonb + WHERE tenant_id = $1 AND role = 'owner'`, + [ownerTenant.id, JSON.stringify([DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE])], + ); + const owner = await live.service.login({ + identifier: { kind: "email", value: "owner@pg.example.test" }, + password: "correct horse battery staple", + tenantId: ownerTenant.id, + throttleKey: "review-b-authoritative-owner", + }); + const token = "review-b-pg-authoritative-invite-token"; + const inviteId = "inv_review_b_pg_authoritative_scope"; + const now = new Date(); + const forgedInvite: IdentityInviteRecord = { + id: inviteId, + tenantId: ownerTenant.id, + tokenHash: createHash("sha256").update(token).digest("hex"), + identifierKind: "email", + normalizedIdentifier: "pg-authoritative-scope@example.test", + managementScope: "runs:read", + role: "member", + scopes: ["runs:read"], + expiresAt: new Date(now.getTime() + 300_000).toISOString(), + createdByUserId: owner.user.id, + createdAt: now.toISOString(), + }; + await live.store.createInvite(forgedInvite, { + actorTokenScopes: owner.scopes, + inviteManagementScope: DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE, + }); + const persisted = await client.one<{ management_scope: string }>( + "SELECT management_scope FROM identity_invites WHERE id = $1", + [inviteId], + ); + expect(persisted.management_scope).toBe(DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE); + + await client.execute( + `UPDATE identity_memberships + SET scopes = scopes - $3 + WHERE tenant_id = $1 AND user_id = $2`, + [ownerTenant.id, owner.user.id, DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE], + ); + await expect(live.service.signup({ + identifier: { kind: "email", value: "pg-authoritative-scope@example.test" }, + password: "a sufficiently strong password", + displayName: "PG Authoritative Scope", + inviteToken: token, + })).rejects.toMatchObject({ reason: "invite_invalid" }); + const inviteAfter = await client.one<{ consumed_at: Date | string | null }>( + "SELECT consumed_at FROM identity_invites WHERE id = $1", + [inviteId], + ); + expect(inviteAfter.consumed_at).toBeNull(); + await client.execute("DELETE FROM identity_invites WHERE id = $1", [inviteId]); + await client.execute( + `UPDATE identity_memberships + SET scopes = scopes || $3::jsonb + WHERE tenant_id = $1 AND user_id = $2`, + [ + ownerTenant.id, + owner.user.id, + JSON.stringify([DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE]), + ], + ); + }); + + test("matches in-memory invite role authority when a member holds the management scope", async () => { + const live = service("invite"); + const ownerTenant = await client.one<{ id: string }>( + "SELECT id FROM identity_tenants WHERE slug = 'infinity-pg'", + ); + await client.execute( + `UPDATE identity_memberships + SET scopes = scopes || $2::jsonb + WHERE tenant_id = $1 AND role = 'owner'`, + [ownerTenant.id, JSON.stringify([DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE])], + ); + const owner = await live.service.login({ + identifier: { kind: "email", value: "owner@pg.example.test" }, + password: "correct horse battery staple", + tenantId: ownerTenant.id, + throttleKey: "review-c2-role-owner", + }); + const memberInvite = await live.service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: ownerTenant.id, + identifier: { kind: "email", value: "pg-review-c2-role-member@example.test" }, + role: "member", + scopes: ["runs:read", DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE], + }); + const member = await live.service.signup({ + identifier: { kind: "email", value: "pg-review-c2-role-member@example.test" }, + password: "a sufficiently strong password", + displayName: "PG Review C2 Role Member", + inviteToken: memberInvite.token, + }); + + await expect(live.service.createInvite({ + actorAccessToken: member.accessToken, + tenantId: ownerTenant.id, + role: "member", + scopes: ["runs:read"], + })).rejects.toMatchObject({ reason: "forbidden" }); + }); + + test("revalidates invite creator authority after role changes with locked atomic consumption", async () => { + type InviteAuthorityCase = { + name: string; + creationRole: "owner" | "admin"; + currentState: + | "unchanged" + | "demoted-admin" + | "demoted-member" + | "suspended" + | "missing" + | "cross-tenant" + | "scope-lost" + | "user-disabled"; + targetRole: "owner" | "admin" | "member"; + expected: "consumed" | "invite_invalid"; + concurrent?: boolean; + }; + const cases: readonly InviteAuthorityCase[] = [ + { + name: "active owner consumes an owner invite", + creationRole: "owner", + currentState: "unchanged", + targetRole: "owner", + expected: "consumed", + }, + { + name: "active owner consumes an admin invite", + creationRole: "owner", + currentState: "unchanged", + targetRole: "admin", + expected: "consumed", + }, + { + name: "active owner consumes a member invite", + creationRole: "owner", + currentState: "unchanged", + targetRole: "member", + expected: "consumed", + }, + { + name: "active admin consumes an admin invite", + creationRole: "admin", + currentState: "unchanged", + targetRole: "admin", + expected: "consumed", + }, + { + name: "active admin consumes a member invite", + creationRole: "admin", + currentState: "unchanged", + targetRole: "member", + expected: "consumed", + }, + { + name: "owner demoted to admin cannot consume an owner invite", + creationRole: "owner", + currentState: "demoted-admin", + targetRole: "owner", + expected: "invite_invalid", + }, + { + name: "owner demoted to admin can consume a member invite", + creationRole: "owner", + currentState: "demoted-admin", + targetRole: "member", + expected: "consumed", + }, + { + name: "owner demoted to member cannot consume a member invite", + creationRole: "owner", + currentState: "demoted-member", + targetRole: "member", + expected: "invite_invalid", + concurrent: true, + }, + { + name: "admin demoted to member cannot consume a member invite", + creationRole: "admin", + currentState: "demoted-member", + targetRole: "member", + expected: "invite_invalid", + }, + { + name: "suspended creator membership cannot consume an invite", + creationRole: "owner", + currentState: "suspended", + targetRole: "member", + expected: "invite_invalid", + }, + { + name: "missing creator membership cannot consume an invite", + creationRole: "owner", + currentState: "missing", + targetRole: "member", + expected: "invite_invalid", + }, + { + name: "cross-tenant creator membership cannot consume an invite", + creationRole: "owner", + currentState: "cross-tenant", + targetRole: "member", + expected: "invite_invalid", + }, + { + name: "creator scope loss invalidates a pending invite", + creationRole: "owner", + currentState: "scope-lost", + targetRole: "member", + expected: "invite_invalid", + }, + { + name: "disabled creator user cannot consume an invite", + creationRole: "owner", + currentState: "user-disabled", + targetRole: "member", + expected: "invite_invalid", + }, + ]; + const live = service("invite"); + let ownerUser = await client.get<{ user_id: string }>( + `SELECT identifier.user_id + FROM identity_login_identifiers identifier + WHERE identifier.kind = 'email' + AND identifier.normalized_value = 'owner@pg.example.test'`, + ); + if (ownerUser === null) { + await live.service.bootstrapFirstAdmin({ + identifier: { kind: "email", value: "owner@pg.example.test" }, + password: "correct horse battery staple", + displayName: "PG Owner", + }); + ownerUser = await client.one<{ user_id: string }>( + `SELECT identifier.user_id + FROM identity_login_identifiers identifier + WHERE identifier.kind = 'email' + AND identifier.normalized_value = 'owner@pg.example.test'`, + ); + } + const ownerTenant = await client.one<{ id: string }>( + `SELECT id + FROM identity_tenants + WHERE slug = 'infinity-pg'`, + ); + const ownerScopes = [ + "runs:read", + "runs:write", + DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE, + "identities:platform:admin", + ]; + await client.execute( + "UPDATE identity_users SET status = 'active', disabled_at = NULL WHERE id = $1", + [ownerUser.user_id], + ); + await client.execute( + `UPDATE identity_tenants + SET allowed_scopes = $2::jsonb + WHERE id = $1`, + [ownerTenant.id, JSON.stringify(ownerScopes)], + ); + await client.execute( + `UPDATE identity_memberships + SET role = 'owner', scopes = $3::jsonb, status = 'active', tenant_id = $1 + WHERE user_id = $2`, + [ownerTenant.id, ownerUser.user_id, JSON.stringify(ownerScopes)], + ); + const owner = await live.service.login({ + identifier: { kind: "email", value: "owner@pg.example.test" }, + password: "correct horse battery staple", + tenantId: ownerTenant.id, + throttleKey: "review-f-owner", + }); + const adminIdentifier = "pg-review-f-admin@example.test"; + const adminScopes = ["runs:read", DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE]; + const adminInvite = await live.service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: ownerTenant.id, + identifier: { kind: "email", value: adminIdentifier }, + role: "admin", + scopes: adminScopes, + }); + const admin = await live.service.signup({ + identifier: { kind: "email", value: adminIdentifier }, + password: "a sufficiently strong password", + displayName: "PG Review F Admin", + inviteToken: adminInvite.token, + }); + const otherTenantId = "ten_pg_review_f_other"; + await client.execute( + `INSERT INTO identity_tenants (id, slug, name, allowed_scopes, created_at) + VALUES ($1, 'pg-review-f-other', 'PG Review F Other', $2::jsonb, NOW()) + ON CONFLICT (id) DO UPDATE SET allowed_scopes = EXCLUDED.allowed_scopes`, + [otherTenantId, JSON.stringify(ownerScopes)], + ); + + const restoreCreator = async (input: { + userId: string; + membershipId: string; + role: "owner" | "admin"; + scopes: readonly string[]; + createdAt: string; + }): Promise => { + await client.execute( + "UPDATE identity_users SET status = 'active', disabled_at = NULL WHERE id = $1", + [input.userId], + ); + await client.execute( + `INSERT INTO identity_memberships + (id, tenant_id, user_id, role, scopes, status, created_at) + VALUES ($1, $2, $3, $4, $5::jsonb, 'active', $6) + ON CONFLICT (id) DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + user_id = EXCLUDED.user_id, + role = EXCLUDED.role, + scopes = EXCLUDED.scopes, + status = EXCLUDED.status`, + [ + input.membershipId, + ownerTenant.id, + input.userId, + input.role, + JSON.stringify(input.scopes), + input.createdAt, + ], + ); + }; + const cleanupUser = async (userId: string): Promise => { + await client.transaction(async (tx) => { + await tx.execute( + "DELETE FROM identity_invites WHERE created_by_user_id = $1 OR consumed_by_user_id = $1", + [userId], + ); + await tx.execute("DELETE FROM identity_jti_revocations WHERE user_id = $1", [userId]); + await tx.execute("DELETE FROM identity_issued_access_tokens WHERE user_id = $1", [userId]); + await tx.execute( + `DELETE FROM identity_refresh_tokens + WHERE family_id IN (SELECT id FROM identity_session_families WHERE user_id = $1)`, + [userId], + ); + await tx.execute("DELETE FROM identity_session_families WHERE user_id = $1", [userId]); + await tx.execute("DELETE FROM identity_one_time_tokens WHERE user_id = $1", [userId]); + await tx.execute("DELETE FROM identity_password_credentials WHERE user_id = $1", [userId]); + await tx.execute("DELETE FROM identity_memberships WHERE user_id = $1", [userId]); + await tx.execute( + `DELETE FROM identity_login_identifier_canonicalization_audit + WHERE identifier_id IN ( + SELECT id FROM identity_login_identifiers WHERE user_id = $1 + )`, + [userId], + ); + await tx.execute("DELETE FROM identity_login_identifiers WHERE user_id = $1", [userId]); + await tx.execute("DELETE FROM identity_users WHERE id = $1", [userId]); + }); + }; + const creatorByRole = { + owner: { + session: owner, + userId: owner.user.id, + membershipId: owner.membership.id, + role: "owner" as const, + scopes: ownerScopes, + createdAt: owner.membership.createdAt, + }, + admin: { + session: admin, + userId: admin.user.id, + membershipId: admin.membership.id, + role: "admin" as const, + scopes: adminScopes, + createdAt: admin.membership.createdAt, + }, + }; + + try { + for (const [index, scenario] of cases.entries()) { + const creator = creatorByRole[scenario.creationRole]; + await restoreCreator(creator); + const targetIdentifier = `pg-review-f-target-${index}@example.test`; + const pending = await live.service.createInvite({ + actorAccessToken: creator.session.accessToken, + tenantId: ownerTenant.id, + identifier: { kind: "email", value: targetIdentifier }, + role: scenario.targetRole, + scopes: ["runs:read"], + }); + let blocker: Awaited> | undefined; + let blockerTransactionOpen = false; + let signupResolved = false; + let observedLockWait = false; + try { + if (scenario.concurrent === true) { + blocker = await client.pool.connect(); + await blocker.query("BEGIN"); + blockerTransactionOpen = true; + await blocker.query( + "UPDATE identity_memberships SET role = 'member' WHERE id = $1", + [creator.membershipId], + ); + } else if (scenario.currentState === "demoted-admin") { + await client.execute( + "UPDATE identity_memberships SET role = 'admin' WHERE id = $1", + [creator.membershipId], + ); + } else if (scenario.currentState === "demoted-member") { + await client.execute( + "UPDATE identity_memberships SET role = 'member' WHERE id = $1", + [creator.membershipId], + ); + } else if (scenario.currentState === "suspended") { + await client.execute( + "UPDATE identity_memberships SET status = 'suspended' WHERE id = $1", + [creator.membershipId], + ); + } else if (scenario.currentState === "missing") { + await client.execute( + "DELETE FROM identity_memberships WHERE id = $1", + [creator.membershipId], + ); + } else if (scenario.currentState === "cross-tenant") { + await client.execute( + "UPDATE identity_memberships SET tenant_id = $2 WHERE id = $1", + [creator.membershipId, otherTenantId], + ); + } else if (scenario.currentState === "scope-lost") { + await client.execute( + "UPDATE identity_memberships SET scopes = scopes - $2 WHERE id = $1", + [creator.membershipId, DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE], + ); + } else if (scenario.currentState === "user-disabled") { + await client.execute( + "UPDATE identity_users SET status = 'disabled', disabled_at = NOW() WHERE id = $1", + [creator.userId], + ); + } + + const signup = live.service.signup({ + identifier: { kind: "email", value: targetIdentifier }, + password: "a sufficiently strong password", + displayName: `PG Review F Target ${index}`, + inviteToken: pending.token, + }); + void signup.finally(() => { + signupResolved = true; + }).catch(() => undefined); + if (scenario.concurrent === true) { + for (let attempt = 0; attempt < 200; attempt += 1) { + const state = await client.one<{ waiting: boolean }>( + `SELECT EXISTS ( + SELECT 1 + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND wait_event_type = 'Lock' + AND query LIKE '%FROM identity_memberships%' + AND query LIKE '%FOR UPDATE OF membership, creator%' + ) AS waiting`, + ); + if (state.waiting) { + observedLockWait = true; + break; + } + if (signupResolved) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + await blocker!.query("COMMIT"); + blockerTransactionOpen = false; + } + const outcome = await signup.then( + (session) => ({ reason: "consumed", role: session.membership.role }), + (error: unknown) => { + if (!(error instanceof IdentityLifecycleError)) throw error; + return { reason: error.reason, role: null }; + }, + ); + const persisted = await client.one<{ + consumed_at: Date | string | null; + users: string; + memberships: string; + sessions: string; + role: "owner" | "admin" | "member" | null; + }>( + `SELECT + invite.consumed_at, + ( + SELECT count(*)::text + FROM identity_users target_user + JOIN identity_login_identifiers identifier + ON identifier.user_id = target_user.id + WHERE identifier.kind = 'email' + AND identifier.normalized_value = $2 + ) AS users, + ( + SELECT count(*)::text + FROM identity_memberships target_membership + JOIN identity_login_identifiers identifier + ON identifier.user_id = target_membership.user_id + WHERE identifier.kind = 'email' + AND identifier.normalized_value = $2 + ) AS memberships, + ( + SELECT count(*)::text + FROM identity_session_families family + JOIN identity_login_identifiers identifier + ON identifier.user_id = family.user_id + WHERE identifier.kind = 'email' + AND identifier.normalized_value = $2 + ) AS sessions, + ( + SELECT target_membership.role + FROM identity_memberships target_membership + JOIN identity_login_identifiers identifier + ON identifier.user_id = target_membership.user_id + WHERE identifier.kind = 'email' + AND identifier.normalized_value = $2 + LIMIT 1 + ) AS role + FROM identity_invites invite + WHERE invite.id = $1`, + [pending.id, targetIdentifier], + ); + expect({ + scenario: scenario.name, + reason: outcome.reason, + role: outcome.role, + consumed: persisted.consumed_at !== null, + users: persisted.users, + memberships: persisted.memberships, + sessions: persisted.sessions, + lockWait: scenario.concurrent === true ? observedLockWait : undefined, + }).toEqual({ + scenario: scenario.name, + reason: scenario.expected, + role: scenario.expected === "consumed" ? scenario.targetRole : null, + consumed: scenario.expected === "consumed", + users: scenario.expected === "consumed" ? "1" : "0", + memberships: scenario.expected === "consumed" ? "1" : "0", + sessions: scenario.expected === "consumed" ? "1" : "0", + lockWait: scenario.concurrent === true ? true : undefined, + }); + } finally { + if (blockerTransactionOpen) await blocker!.query("ROLLBACK"); + blocker?.release(); + await client.execute("DELETE FROM identity_invites WHERE id = $1", [pending.id]); + const target = await client.get<{ user_id: string }>( + `SELECT user_id + FROM identity_login_identifiers + WHERE kind = 'email' AND normalized_value = $1`, + [targetIdentifier], + ); + if (target !== null) await cleanupUser(target.user_id); + await restoreCreator(creator); + } + } + } finally { + await client.execute("DELETE FROM identity_invites WHERE id = $1", [adminInvite.id]); + await cleanupUser(admin.user.id); + await restoreCreator(creatorByRole.owner); + await client.execute("DELETE FROM identity_tenants WHERE id = $1", [otherTenantId]); + } + }, 60_000); + + test("locks refresh authority rows until a concurrent membership narrowing commits", async () => { + const live = service("invite"); + const ownerTenant = await client.one<{ id: string }>( + "SELECT id FROM identity_tenants WHERE slug = 'infinity-pg'", + ); + const owner = await live.service.login({ + identifier: { kind: "email", value: "owner@pg.example.test" }, + password: "correct horse battery staple", + tenantId: ownerTenant.id, + throttleKey: "review-c2-refresh-owner", + }); + const memberInvite = await live.service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: ownerTenant.id, + identifier: { kind: "email", value: "pg-review-c2-refresh@example.test" }, + role: "member", + scopes: ["runs:read", "runs:write"], + }); + const member = await live.service.signup({ + identifier: { kind: "email", value: "pg-review-c2-refresh@example.test" }, + password: "a sufficiently strong password", + displayName: "PG Review C2 Refresh", + inviteToken: memberInvite.token, + }); + + const blocker = await client.pool.connect(); + let transactionOpen = false; + let refreshResolved = false; + let observedLockWait = false; + try { + await blocker.query("BEGIN"); + transactionOpen = true; + await blocker.query( + `UPDATE identity_memberships + SET scopes = '["runs:read"]'::jsonb + WHERE tenant_id = $1 AND user_id = $2`, + [ownerTenant.id, member.user.id], + ); + const refreshOutcome = live.service.refresh({ refreshToken: member.refreshToken }).then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + void refreshOutcome.then(() => { + refreshResolved = true; + }); + for (let attempt = 0; attempt < 200; attempt += 1) { + const state = await client.one<{ waiting: boolean }>( + `SELECT EXISTS ( + SELECT 1 + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND wait_event_type = 'Lock' + AND query LIKE '%FROM identity_users%' + ) AS waiting`, + ); + if (state.waiting) { + observedLockWait = true; + break; + } + if (refreshResolved) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + await blocker.query( + `SELECT id + FROM identity_session_families + WHERE user_id = $1 AND tenant_id = $2 + FOR UPDATE`, + [member.user.id, ownerTenant.id], + ); + await blocker.query("COMMIT"); + transactionOpen = false; + + const outcome = await refreshOutcome; + expect("error" in outcome ? outcome.error : undefined).toBeUndefined(); + expect("value" in outcome ? outcome.value.scopes : undefined).toEqual(["runs:read"]); + } finally { + if (transactionOpen) await blocker.query("ROLLBACK"); + blocker.release(); + } + expect(observedLockWait).toBe(true); + }, 15_000); + + test("keeps live known and unknown recovery distributions behind the response floor", async () => { + const live = service("open"); + const knownDurations: number[] = []; + const unknownDurations: number[] = []; + for (let sample = 0; sample < 4; sample += 1) { + let startedAt = performance.now(); + await live.service.beginRecovery({ + identifier: { kind: "email", value: `pg-unknown-c2-${sample}@example.test` }, + throttleKey: `pg-review-c2-unknown-${sample}`, + }); + unknownDurations.push(performance.now() - startedAt); + + startedAt = performance.now(); + await live.service.beginRecovery({ + identifier: { kind: "email", value: "owner@pg.example.test" }, + throttleKey: `pg-review-c2-known-${sample}`, + }); + knownDurations.push(performance.now() - startedAt); + } + const median = (samples: readonly number[]): number => { + const ordered = [...samples].sort((left, right) => left - right); + return ordered[Math.floor(ordered.length / 2)]!; + }; + const knownMedian = median(knownDurations); + const unknownMedian = median(unknownDurations); + + expect(knownMedian).toBeGreaterThanOrEqual(225); + expect(unknownMedian).toBeGreaterThanOrEqual(225); + expect(Math.abs(knownMedian - unknownMedian)).toBeLessThan(100); + }, 15_000); + + test("matches the in-memory suspendMembership relational parity corpus", async () => { + const store = new PgIdentityLifecycleStore(client); + await client.execute( + "DELETE FROM identity_memberships WHERE id LIKE 'mem_review_e_%'", + ); + await client.execute( + "DELETE FROM identity_tenants WHERE id LIKE 'ten_review_e_%'", + ); + await client.execute( + "DELETE FROM identity_users WHERE id LIKE 'usr_review_e_%'", + ); + try { + for (const [index, scenario] of membershipSuspensionParityCases.entries()) { + const fixture = membershipSuspensionParityState(index, scenario); + for (const user of fixture.snapshot.users) { + await client.execute( + `INSERT INTO identity_users + (id, status, display_name, created_at, updated_at, disabled_at, deleted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + user.id, + user.status, + user.displayName, + user.createdAt, + user.updatedAt, + user.disabledAt ?? null, + user.deletedAt ?? null, + ], + ); + } + for (const tenant of fixture.snapshot.tenants) { + await client.execute( + `INSERT INTO identity_tenants + (id, slug, name, allowed_scopes, created_at) + VALUES ($1, $2, $3, $4::jsonb, $5)`, + [ + tenant.id, + tenant.slug, + tenant.name, + JSON.stringify(tenant.allowedScopes ?? []), + tenant.createdAt, + ], + ); + } + const persistedUserIds = new Set(fixture.snapshot.users.map((user) => user.id)); + const persistedTenantIds = new Set(fixture.snapshot.tenants.map((tenant) => tenant.id)); + const persistedMembershipIds = new Set(); + for (const membership of fixture.snapshot.memberships.filter( + (candidate) => + persistedUserIds.has(candidate.userId) && + persistedTenantIds.has(candidate.tenantId), + )) { + await client.execute( + `INSERT INTO identity_memberships + (id, tenant_id, user_id, role, scopes, status, created_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)`, + [ + membership.id, + membership.tenantId, + membership.userId, + membership.role, + JSON.stringify(membership.scopes), + membership.status ?? "active", + membership.createdAt, + ], + ); + persistedMembershipIds.add(membership.id); + } + + const outcome = await observeMembershipSuspension(store, fixture); + const target = await client.get<{ status: IdentityMembershipStatus }>( + "SELECT status FROM identity_memberships WHERE id = $1", + [fixture.targetMembershipId], + ); + expect({ + scenario: scenario.name, + outcome, + targetStatus: target?.status ?? "missing", + }).toEqual({ + scenario: scenario.name, + outcome: scenario.expectedOutcome, + targetStatus: persistedMembershipIds.has(fixture.targetMembershipId) + ? scenario.expectedMemoryTargetStatus + : "missing", + }); + } + } finally { + await client.execute( + "DELETE FROM identity_memberships WHERE id LIKE 'mem_review_e_%'", + ); + await client.execute( + "DELETE FROM identity_tenants WHERE id LIKE 'ten_review_e_%'", + ); + await client.execute( + "DELETE FROM identity_users WHERE id LIKE 'usr_review_e_%'", + ); + } + }); + + test("ignores an unrepresentable orphan target role during user security mutation", async () => { + const store = new PgIdentityLifecycleStore(client); + await client.execute( + "DELETE FROM identity_memberships WHERE id LIKE 'mem_review_e_mutation_%'", + ); + await client.execute( + "DELETE FROM identity_tenants WHERE id LIKE 'ten_review_e_mutation_%'", + ); + await client.execute( + "DELETE FROM identity_users WHERE id LIKE 'usr_review_e_mutation_%'", + ); + const snapshot = orphanTargetRoleMutationState(); + try { + for (const user of snapshot.users) { + await client.execute( + `INSERT INTO identity_users + (id, status, display_name, created_at, updated_at, disabled_at, deleted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + user.id, + user.status, + user.displayName, + user.createdAt, + user.updatedAt, + user.disabledAt ?? null, + user.deletedAt ?? null, + ], + ); + } + for (const tenant of snapshot.tenants) { + await client.execute( + `INSERT INTO identity_tenants + (id, slug, name, allowed_scopes, created_at) + VALUES ($1, $2, $3, $4::jsonb, $5)`, + [ + tenant.id, + tenant.slug, + tenant.name, + JSON.stringify(tenant.allowedScopes ?? []), + tenant.createdAt, + ], + ); + } + await client.execute( + `INSERT INTO identity_memberships + (id, tenant_id, user_id, role, scopes, status, created_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)`, + [ + snapshot.memberships[0]!.id, + snapshot.memberships[0]!.tenantId, + snapshot.memberships[0]!.userId, + snapshot.memberships[0]!.role, + JSON.stringify(snapshot.memberships[0]!.scopes), + snapshot.memberships[0]!.status ?? "active", + snapshot.memberships[0]!.createdAt, + ], + ); + + expect({ + outcome: await observeUserSecurityMutation(store), + targetStatus: ( + await client.one<{ status: IdentityUserStatus }>( + "SELECT status FROM identity_users WHERE id = 'usr_review_e_mutation_target'", + ) + ).status, + }).toEqual({ + outcome: "forbidden", + targetStatus: "active", + }); + } finally { + await client.execute( + "DELETE FROM identity_memberships WHERE id LIKE 'mem_review_e_mutation_%'", + ); + await client.execute( + "DELETE FROM identity_tenants WHERE id LIKE 'ten_review_e_mutation_%'", + ); + await client.execute( + "DELETE FROM identity_users WHERE id LIKE 'usr_review_e_mutation_%'", + ); + } + }); + + test("matches the in-memory recordIssuedAccessToken authority corpus", async () => { + const store = new PgIdentityLifecycleStore(client); + await client.execute( + "DELETE FROM identity_issued_access_tokens WHERE jti_hash LIKE 'jti_review_e_issued_%'", + ); + await client.execute( + "DELETE FROM identity_session_families WHERE id LIKE 'fam_review_e_issued_%'", + ); + await client.execute( + "DELETE FROM identity_memberships WHERE id LIKE 'mem_review_e_issued_%'", + ); + await client.execute( + "DELETE FROM identity_tenants WHERE id LIKE 'ten_review_e_issued_%'", + ); + await client.execute( + "DELETE FROM identity_users WHERE id LIKE 'usr_review_e_issued_%'", + ); + try { + for (const [index, scenario] of issuedTokenParityCases.entries()) { + const fixture = issuedTokenParityState(index, scenario); + for (const user of fixture.snapshot.users) { + await client.execute( + `INSERT INTO identity_users + (id, status, display_name, created_at, updated_at, disabled_at, deleted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + user.id, + user.status, + user.displayName, + user.createdAt, + user.updatedAt, + user.disabledAt ?? null, + user.deletedAt ?? null, + ], + ); + } + for (const tenant of fixture.snapshot.tenants) { + await client.execute( + `INSERT INTO identity_tenants + (id, slug, name, allowed_scopes, created_at) + VALUES ($1, $2, $3, $4::jsonb, $5)`, + [ + tenant.id, + tenant.slug, + tenant.name, + JSON.stringify(tenant.allowedScopes ?? []), + tenant.createdAt, + ], + ); + } + const persistedUserIds = new Set(fixture.snapshot.users.map((user) => user.id)); + const persistedTenantIds = new Set(fixture.snapshot.tenants.map((tenant) => tenant.id)); + for (const membership of fixture.snapshot.memberships.filter( + (candidate) => + persistedUserIds.has(candidate.userId) && + persistedTenantIds.has(candidate.tenantId), + )) { + await client.execute( + `INSERT INTO identity_memberships + (id, tenant_id, user_id, role, scopes, status, created_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)`, + [ + membership.id, + membership.tenantId, + membership.userId, + membership.role, + JSON.stringify(membership.scopes), + membership.status ?? "active", + membership.createdAt, + ], + ); + } + for (const family of fixture.snapshot.sessionFamilies.filter( + (candidate) => + persistedUserIds.has(candidate.userId) && + persistedTenantIds.has(candidate.tenantId), + )) { + await client.execute( + `INSERT INTO identity_session_families + (id, session_hash, user_id, tenant_id, scopes, status, + expires_at, created_at, updated_at, revoked_at, revoke_reason) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, $10, $11)`, + [ + family.id, + `session_${family.id}`, + family.userId, + family.tenantId, + JSON.stringify(family.scopes), + family.status, + family.expiresAt, + family.createdAt, + family.updatedAt, + family.revokedAt ?? null, + family.revokeReason ?? null, + ], + ); + } + + expect({ + scenario: scenario.name, + outcome: await observeIssuedAccessTokenRecording(store, fixture.input), + recorded: ( + await client.get<{ jti_hash: string }>( + "SELECT jti_hash FROM identity_issued_access_tokens WHERE jti_hash = $1", + [fixture.input.jtiHash], + ) + ) !== null, + }).toEqual({ + scenario: scenario.name, + outcome: scenario.expectedOutcome, + recorded: scenario.expectedOutcome === "recorded", + }); + } + } finally { + await client.execute( + "DELETE FROM identity_issued_access_tokens WHERE jti_hash LIKE 'jti_review_e_issued_%'", + ); + await client.execute( + "DELETE FROM identity_session_families WHERE id LIKE 'fam_review_e_issued_%'", + ); + await client.execute( + "DELETE FROM identity_memberships WHERE id LIKE 'mem_review_e_issued_%'", + ); + await client.execute( + "DELETE FROM identity_tenants WHERE id LIKE 'ten_review_e_issued_%'", + ); + await client.execute( + "DELETE FROM identity_users WHERE id LIKE 'usr_review_e_issued_%'", + ); + } + }); + + test("matches the in-memory canAdminister active-state corpus", async () => { + const store = new PgIdentityLifecycleStore(client); + await client.execute( + "DELETE FROM identity_memberships WHERE id LIKE 'mem_review_d2_%'", + ); + await client.execute( + "DELETE FROM identity_tenants WHERE id LIKE 'ten_review_d2_%'", + ); + await client.execute( + "DELETE FROM identity_users WHERE id LIKE 'usr_review_d2_%'", + ); + try { + for (const [index, scenario] of administrationParityCases.entries()) { + const fixture = administrationParityState(index, scenario); + for (const user of fixture.snapshot.users) { + await client.execute( + `INSERT INTO identity_users + (id, status, display_name, created_at, updated_at, disabled_at, deleted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + user.id, + user.status, + user.displayName, + user.createdAt, + user.updatedAt, + user.disabledAt ?? null, + user.deletedAt ?? null, + ], + ); + } + for (const tenant of fixture.snapshot.tenants) { + await client.execute( + `INSERT INTO identity_tenants + (id, slug, name, allowed_scopes, created_at) + VALUES ($1, $2, $3, $4::jsonb, $5)`, + [ + tenant.id, + tenant.slug, + tenant.name, + JSON.stringify(tenant.allowedScopes ?? []), + tenant.createdAt, + ], + ); + } + const persistedUserIds = new Set(fixture.snapshot.users.map((user) => user.id)); + const persistedTenantIds = new Set(fixture.snapshot.tenants.map((tenant) => tenant.id)); + // PostgreSQL's foreign keys make orphaned memberships unrepresentable. + for (const membership of fixture.snapshot.memberships.filter( + (candidate) => + persistedUserIds.has(candidate.userId) && + persistedTenantIds.has(candidate.tenantId), + )) { + await client.execute( + `INSERT INTO identity_memberships + (id, tenant_id, user_id, role, scopes, status, created_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)`, + [ + membership.id, + membership.tenantId, + membership.userId, + membership.role, + JSON.stringify(membership.scopes), + membership.status ?? "active", + membership.createdAt, + ], + ); + } + + const memoryStore = new InMemoryIdentityLifecycleStore(fixture.snapshot); + const memoryResult = await memoryStore.canAdminister( + fixture.actorUserId, + fixture.tenantId, + fixture.targetUserId, + ); + const postgresResult = await store.canAdminister( + fixture.actorUserId, + fixture.tenantId, + fixture.targetUserId, + ); + expect({ + scenario: scenario.name, + memoryResult, + postgresResult, + }).toEqual({ + scenario: scenario.name, + memoryResult: scenario.expected, + postgresResult: scenario.expected, + }); + } + } finally { + await client.execute( + "DELETE FROM identity_memberships WHERE id LIKE 'mem_review_d2_%'", + ); + await client.execute( + "DELETE FROM identity_tenants WHERE id LIKE 'ten_review_d2_%'", + ); + await client.execute( + "DELETE FROM identity_users WHERE id LIKE 'usr_review_d2_%'", + ); + } + }); +}); diff --git a/src/pg-user-lifecycle.ts b/src/pg-user-lifecycle.ts new file mode 100644 index 0000000..532865c --- /dev/null +++ b/src/pg-user-lifecycle.ts @@ -0,0 +1,1624 @@ +import type { QueryResultRow } from "pg"; +import type { PoolQueryClient, TypedQueryClient } from "./generated/storage-kit/query.js"; +import { hashOpaqueClaim, type IdentitySessionFamilyStatus } from "./identity-auth.js"; +import { + IDENTITY_INVITES_TABLE, + IDENTITY_ISSUED_ACCESS_TOKENS_TABLE, + IDENTITY_JTI_REVOCATIONS_TABLE, + IDENTITY_LOGIN_IDENTIFIER_AUDIT_TABLE, + IDENTITY_LOGIN_IDENTIFIERS_TABLE, + IDENTITY_LOGIN_THROTTLE_TABLE, + IDENTITY_MEMBERSHIPS_TABLE, + IDENTITY_ONE_TIME_TOKENS_TABLE, + IDENTITY_PASSWORD_CREDENTIALS_TABLE, + IDENTITY_REFRESH_TOKENS_TABLE, + IDENTITY_SESSION_FAMILIES_TABLE, + IDENTITY_TENANTS_TABLE, + IDENTITY_USERS_TABLE, + IdentityLifecycleError, + auditLoginIdentifierCanonicalization, + normalizeLoginIdentifier, + type CreateSessionMutation, + type IdentityAuthAttemptKeys, + type IdentityInviteCreation, + type IdentityJtiRevocationRecord, + type IdentityLifecycleStore, + type IdentityLoginThrottleRecord, + type IdentityIssuedAccessTokenRecord, + type IdentityLoginIdentifierCanonicalizationAudit, + type IdentityOneTimeTokenRecord, + type IdentityThrottlePolicy, + type IdentityUserRecord, + type LoginCandidate, + type LoginIdentifierKind, + type RefreshRotationResult, + type RegistrationMutation, + type RegistrationResult, + type IdentityRefreshTokenRecord, +} from "./user-lifecycle.js"; + +interface UserRow extends QueryResultRow { + id: string; + status: "active" | "disabled" | "deleted"; + display_name: string; + created_at: Date | string; + updated_at: Date | string; + disabled_at: Date | string | null; + deleted_at: Date | string | null; +} + +interface TenantRow extends QueryResultRow { + id: string; + slug: string; + name: string; + allowed_scopes: unknown; + created_at: Date | string; +} + +interface MembershipTenantRow extends QueryResultRow { + id: string; + tenant_id: string; + user_id: string; + role: "owner" | "admin" | "member"; + scopes: unknown; + status: "active" | "suspended"; + created_at: Date | string; + tenant_slug: string; + tenant_name: string; + tenant_allowed_scopes: unknown; + tenant_created_at: Date | string; +} + +interface IdentifierCredentialUserRow extends UserRow { + identifier_id: string; + identifier_kind: LoginIdentifierKind; + normalized_value: string; + verified_at: Date | string | null; + identifier_created_at: Date | string; + credential_id: string; + password_hash: string; + algorithm: string; + credential_created_at: Date | string; + credential_updated_at: Date | string; +} + +interface InviteRow extends QueryResultRow { + id: string; + tenant_id: string; + token_hash: string; + identifier_kind: LoginIdentifierKind | null; + normalized_identifier: string | null; + management_scope: string; + role: "owner" | "admin" | "member"; + scopes: unknown; + expires_at: Date | string; + consumed_at: Date | string | null; + consumed_by_user_id: string | null; + created_by_user_id: string; + created_at: Date | string; +} + +interface RefreshFamilyRow extends QueryResultRow { + refresh_id: string; + family_id: string; + token_hash: string; + generation: number; + refresh_expires_at: Date | string; + refresh_created_at: Date | string; + used_at: Date | string | null; + refresh_revoked_at: Date | string | null; + user_id: string; + tenant_id: string; + family_scopes: unknown; + family_status: "active" | "revoked" | "disabled" | "deleted"; + family_expires_at: Date | string; + family_created_at: Date | string; + family_updated_at: Date | string; + family_revoked_at: Date | string | null; + revoke_reason: string | null; +} + +interface SessionContextRow extends UserRow { + tenant_id: string; + tenant_slug: string; + tenant_name: string; + tenant_created_at: Date | string; + membership_id: string; + membership_role: "owner" | "admin" | "member"; + membership_scopes: unknown; + membership_status: "active" | "suspended"; + membership_created_at: Date | string; +} + +interface OneTimeRow extends QueryResultRow { + id: string; + user_id: string; + identifier_id: string | null; + kind: "verification" | "recovery"; + token_hash: string; + expires_at: Date | string; + created_at: Date | string; + consumed_at: Date | string | null; +} + +interface ThrottleRow extends QueryResultRow { + key_hash: string; + failures: number; + window_started_at: Date | string; + locked_until: Date | string | null; + tokens: number | string | null; + last_refilled_at: Date | string | null; + in_flight: number; + updated_at: Date | string | null; +} + +function lifecycleFailure( + reason: IdentityLifecycleError["reason"], + message: string, + status: IdentityLifecycleError["status"], +): IdentityLifecycleError { + return new IdentityLifecycleError(reason, message, status); +} + +export class PgIdentityLifecycleStore implements IdentityLifecycleStore { + constructor(private readonly client: PoolQueryClient) {} + + async prepare(): Promise { + let audit: IdentityLoginIdentifierCanonicalizationAudit; + try { + audit = await this.client.transaction(async (tx) => { + await tx.execute("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [ + "hasna-identities-user-registration-v1", + ]); + const identifiers = await tx.many<{ + id: string; + kind: LoginIdentifierKind; + normalized_value: string; + }>( + `SELECT id, kind, normalized_value + FROM ${IDENTITY_LOGIN_IDENTIFIERS_TABLE} + WHERE kind = 'email' + ORDER BY id + FOR UPDATE`, + ); + const result = auditLoginIdentifierCanonicalization( + identifiers.map((identifier) => ({ + id: identifier.id, + kind: identifier.kind, + normalizedValue: identifier.normalized_value, + })), + ); + const auditedAt = new Date().toISOString(); + for (const entry of result.entries) { + await tx.execute( + `INSERT INTO ${IDENTITY_LOGIN_IDENTIFIER_AUDIT_TABLE} + (identifier_id, previous_value, canonical_value, conflicting_identifier_ids, collision, audited_at) + VALUES ($1, $2, $3, $4::jsonb, $5, $6) + ON CONFLICT (identifier_id, canonical_value) DO UPDATE SET + previous_value = EXCLUDED.previous_value, + conflicting_identifier_ids = EXCLUDED.conflicting_identifier_ids, + collision = EXCLUDED.collision, + audited_at = EXCLUDED.audited_at`, + [ + entry.identifierId, + entry.previousValue, + entry.canonicalValue, + JSON.stringify(entry.conflictingIdentifierIds), + entry.conflictingIdentifierIds.length > 0, + auditedAt, + ], + ); + } + if (result.collisions.length === 0) { + for (const entry of result.entries) { + await tx.execute( + `UPDATE ${IDENTITY_LOGIN_IDENTIFIERS_TABLE} + SET normalized_value = $2 + WHERE id = $1`, + [entry.identifierId, entry.canonicalValue], + ); + } + } + const emailInvites = await tx.many<{ + id: string; + normalized_identifier: string; + }>( + `SELECT id, normalized_identifier + FROM ${IDENTITY_INVITES_TABLE} + WHERE identifier_kind = 'email' AND normalized_identifier IS NOT NULL + FOR UPDATE`, + ); + for (const invite of emailInvites) { + const canonical = normalizeLoginIdentifier({ + kind: "email", + value: invite.normalized_identifier, + }).value; + if (canonical !== invite.normalized_identifier) { + await tx.execute( + `UPDATE ${IDENTITY_INVITES_TABLE} + SET normalized_identifier = $2 + WHERE id = $1`, + [invite.id, canonical], + ); + } + } + return result; + }); + } catch (error) { + if (error instanceof IdentityLifecycleError && error.reason === "invalid_request") { + throw lifecycleFailure( + "invalid_configuration", + "persisted login identifier cannot be canonicalized", + 500, + ); + } + throw error; + } + if (audit.collisions.length > 0) { + throw lifecycleFailure( + "invalid_configuration", + "canonical login identifier collision requires operator resolution", + 500, + ); + } + return audit; + } + + async register(input: RegistrationMutation): Promise { + try { + return await this.client.transaction(async (tx) => { + await tx.execute("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [ + "hasna-identities-user-registration-v1", + ]); + const duplicate = await tx.get<{ exists: boolean }>( + `SELECT true AS exists FROM ${IDENTITY_LOGIN_IDENTIFIERS_TABLE} + WHERE kind = $1 AND normalized_value = $2`, + [input.identifier.kind, input.identifier.normalizedValue], + ); + if (duplicate !== null) { + throw lifecycleFailure("duplicate_identifier", "registration could not be completed", 409); + } + const count = await tx.one<{ count: string }>( + `SELECT count(*)::text AS count FROM ${IDENTITY_USERS_TABLE}`, + ); + const isFirstUser = Number(count.count) === 0; + if (input.bootstrapOnly && !isFirstUser) { + throw lifecycleFailure("bootstrap_complete", "initial administrator already exists", 409); + } + if (!input.bootstrapOnly && input.policy === "disabled") { + throw lifecycleFailure("registration_disabled", "registration is not available", 403); + } + if (!input.bootstrapOnly && isFirstUser && input.policy === "invite") { + throw lifecycleFailure("invite_invalid", "invite is invalid or expired", 400); + } + + let tenant = input.personalTenant; + let membership = input.ownerMembership; + let invite: InviteRow | null = null; + if (isFirstUser) { + tenant = { + id: input.personalTenant.id, + slug: input.bootstrapTenant.slug, + name: input.bootstrapTenant.name, + allowedScopes: [...input.personalTenant.allowedScopes ?? input.ownerMembership.scopes], + createdAt: input.user.createdAt, + }; + membership = { + ...input.ownerMembership, + tenantId: tenant.id, + userId: input.user.id, + role: "owner", + status: "active", + }; + } else if (input.policy === "invite" && !input.bootstrapOnly) { + invite = await tx.get( + `SELECT * FROM ${IDENTITY_INVITES_TABLE} + WHERE token_hash = $1 + FOR UPDATE`, + [input.inviteTokenHash ?? ""], + ); + if ( + invite === null || + invite.consumed_at !== null || + new Date(invite.expires_at) <= new Date(input.user.createdAt) || + (invite.identifier_kind !== null && + (invite.identifier_kind !== input.identifier.kind || + invite.normalized_identifier !== input.identifier.normalizedValue)) + ) { + throw lifecycleFailure("invite_invalid", "invite is invalid or expired", 400); + } + const invitedTenant = await tx.get( + `SELECT * FROM ${IDENTITY_TENANTS_TABLE} WHERE id = $1 FOR UPDATE`, + [invite.tenant_id], + ); + if (invitedTenant === null) { + throw lifecycleFailure("invite_invalid", "invite is invalid or expired", 400); + } + const creator = await tx.get<{ + role: "owner" | "admin" | "member"; + scopes: unknown; + membership_status: "active" | "suspended"; + user_status: "active" | "disabled" | "deleted"; + }>( + `SELECT + membership.role, + membership.scopes, + membership.status AS membership_status, + creator.status AS user_status + FROM ${IDENTITY_MEMBERSHIPS_TABLE} membership + JOIN ${IDENTITY_USERS_TABLE} creator ON creator.id = membership.user_id + WHERE membership.tenant_id = $1 AND membership.user_id = $2 + FOR UPDATE OF membership, creator`, + [invite.tenant_id, invite.created_by_user_id], + ); + const inviteScopes = parseScopes(invite.scopes); + if ( + creator === null || + creator.user_status !== "active" || + creator.membership_status !== "active" || + (creator.role !== "owner" && creator.role !== "admin") || + !roleCanAssign(creator.role, invite.role) || + !parseScopes(creator.scopes).includes(invite.management_scope) || + !scopeSubset(inviteScopes, parseScopes(creator.scopes)) || + !scopeSubset(inviteScopes, parseScopes(invitedTenant.allowed_scopes)) + ) { + throw lifecycleFailure("invite_invalid", "invite is invalid or expired", 400); + } + tenant = mapTenant(invitedTenant); + membership = { + ...input.ownerMembership, + tenantId: tenant.id, + userId: input.user.id, + role: invite.role, + scopes: inviteScopes, + status: "active", + }; + } else { + tenant = { + ...tenant, + allowedScopes: [...tenant.allowedScopes ?? input.ownerMembership.scopes], + }; + membership = { + ...input.ownerMembership, + tenantId: tenant.id, + userId: input.user.id, + role: "owner", + status: "active", + }; + } + + const initialSession = structuredClone(input.initialSession); + initialSession.family.userId = input.user.id; + initialSession.family.tenantId = tenant.id; + initialSession.family.scopes = intersectScopeSets( + initialSession.family.scopes, + membership.scopes, + tenant.allowedScopes ?? membership.scopes, + ); + if (initialSession.family.scopes.length === 0) { + throw lifecycleFailure("invalid_scope", "requested scope is not allowed", 403); + } + initialSession.refresh.familyId = initialSession.family.id; + + await tx.execute( + `INSERT INTO ${IDENTITY_USERS_TABLE} + (id, status, display_name, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5)`, + [input.user.id, input.user.status, input.user.displayName, input.user.createdAt, input.user.updatedAt], + ); + if (invite === null) { + await tx.execute( + `INSERT INTO ${IDENTITY_TENANTS_TABLE} (id, slug, name, allowed_scopes, created_at) + VALUES ($1, $2, $3, $4::jsonb, $5)`, + [ + tenant.id, + tenant.slug, + tenant.name, + JSON.stringify(tenant.allowedScopes ?? input.ownerMembership.scopes), + tenant.createdAt, + ], + ); + } + await tx.execute( + `INSERT INTO ${IDENTITY_LOGIN_IDENTIFIERS_TABLE} + (id, user_id, kind, normalized_value, created_at) + VALUES ($1, $2, $3, $4, $5)`, + [ + input.identifier.id, + input.identifier.userId, + input.identifier.kind, + input.identifier.normalizedValue, + input.identifier.createdAt, + ], + ); + await tx.execute( + `INSERT INTO ${IDENTITY_PASSWORD_CREDENTIALS_TABLE} + (id, user_id, password_hash, algorithm, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + input.credential.id, + input.credential.userId, + input.credential.passwordHash, + input.credential.algorithm, + input.credential.createdAt, + input.credential.updatedAt, + ], + ); + await tx.execute( + `INSERT INTO ${IDENTITY_MEMBERSHIPS_TABLE} + (id, tenant_id, user_id, role, scopes, status, created_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)`, + [ + membership.id, + membership.tenantId, + membership.userId, + membership.role, + JSON.stringify(membership.scopes), + membership.status ?? "active", + membership.createdAt, + ], + ); + await insertOneTimeToken(tx, input.verification); + await insertSessionMutation(tx, initialSession); + if (invite !== null) { + await tx.execute( + `UPDATE ${IDENTITY_INVITES_TABLE} + SET consumed_at = $2, consumed_by_user_id = $3 + WHERE id = $1`, + [invite.id, input.user.createdAt, input.user.id], + ); + } + return { + user: input.user, + tenant, + membership, + identifier: input.identifier, + initialSession, + }; + }); + } catch (error) { + if (error instanceof IdentityLifecycleError) throw error; + if (isUniqueViolation(error)) { + throw lifecycleFailure("duplicate_identifier", "registration could not be completed", 409); + } + throw error; + } + } + + async findLoginCandidate( + kind: LoginIdentifierKind, + normalizedValue: string, + ): Promise { + const row = await this.client.get( + `SELECT + u.*, + li.id AS identifier_id, + li.kind AS identifier_kind, + li.normalized_value, + li.verified_at, + li.created_at AS identifier_created_at, + pc.id AS credential_id, + pc.password_hash, + pc.algorithm, + pc.created_at AS credential_created_at, + pc.updated_at AS credential_updated_at + FROM ${IDENTITY_LOGIN_IDENTIFIERS_TABLE} li + JOIN ${IDENTITY_USERS_TABLE} u ON u.id = li.user_id + JOIN ${IDENTITY_PASSWORD_CREDENTIALS_TABLE} pc ON pc.user_id = u.id + WHERE li.kind = $1 AND li.normalized_value = $2`, + [kind, normalizedValue], + ); + if (row === null) return null; + const membershipRows = await this.client.many( + `SELECT + m.*, + t.slug AS tenant_slug, + t.name AS tenant_name, + t.allowed_scopes AS tenant_allowed_scopes, + t.created_at AS tenant_created_at + FROM ${IDENTITY_MEMBERSHIPS_TABLE} m + JOIN ${IDENTITY_TENANTS_TABLE} t ON t.id = m.tenant_id + WHERE m.user_id = $1 + ORDER BY m.created_at ASC, m.id ASC`, + [row.id], + ); + return { + user: mapUser(row), + identifier: { + id: row.identifier_id, + userId: row.id, + kind: row.identifier_kind, + normalizedValue: row.normalized_value, + ...(row.verified_at === null ? {} : { verifiedAt: iso(row.verified_at) }), + createdAt: iso(row.identifier_created_at), + }, + credential: { + id: row.credential_id, + userId: row.id, + passwordHash: row.password_hash, + algorithm: row.algorithm, + createdAt: iso(row.credential_created_at), + updatedAt: iso(row.credential_updated_at), + }, + memberships: membershipRows.map(mapMembership), + tenants: membershipRows.map((membership) => ({ + id: membership.tenant_id, + slug: membership.tenant_slug, + name: membership.tenant_name, + allowedScopes: parseScopes(membership.tenant_allowed_scopes), + createdAt: iso(membership.tenant_created_at), + })), + }; + } + + async getLoginThrottle(keyHash: string, now: Date): Promise { + const row = await this.client.get( + `SELECT * FROM ${IDENTITY_LOGIN_THROTTLE_TABLE} WHERE key_hash = $1`, + [keyHash], + ); + if (row === null) return null; + if (row.locked_until !== null && new Date(row.locked_until) <= now) { + await this.client.execute( + `DELETE FROM ${IDENTITY_LOGIN_THROTTLE_TABLE} WHERE key_hash = $1`, + [keyHash], + ); + return null; + } + return mapThrottle(row); + } + + async reserveAuthAttempt( + keys: IdentityAuthAttemptKeys, + now: Date, + policy: IdentityThrottlePolicy, + ): Promise { + if (keys.failureKeyHash === keys.admissionKeyHash) { + throw lifecycleFailure("invalid_configuration", "auth admission keys must be distinct", 500); + } + const orderedKeys = [keys.failureKeyHash, keys.admissionKeyHash].sort(); + return this.client.transaction(async (tx) => { + for (const keyHash of orderedKeys) { + await tx.execute( + `INSERT INTO ${IDENTITY_LOGIN_THROTTLE_TABLE} + (key_hash, failures, window_started_at, locked_until, tokens, last_refilled_at, in_flight, updated_at) + VALUES ($1, 0, $2, NULL, $3, $2, 0, $2) + ON CONFLICT (key_hash) DO NOTHING`, + [keyHash, now.toISOString(), policy.maxFailures], + ); + } + const buckets = new Map(); + for (const keyHash of orderedKeys) { + const row = await tx.one( + `SELECT * FROM ${IDENTITY_LOGIN_THROTTLE_TABLE} + WHERE key_hash = $1 + FOR UPDATE`, + [keyHash], + ); + buckets.set(keyHash, row); + } + const failureBucket = buckets.get(keys.failureKeyHash)!; + const admissionBucket = buckets.get(keys.admissionKeyHash)!; + if ( + failureBucket.locked_until !== null && + new Date(failureBucket.locked_until) > now + ) { + return false; + } + const lastRefilledAt = new Date( + failureBucket.last_refilled_at ?? failureBucket.window_started_at, + ); + const elapsedSeconds = Math.max(0, (now.getTime() - lastRefilledAt.getTime()) / 1_000); + const refillRate = policy.maxFailures / policy.windowSeconds; + const tokens = Math.min( + policy.maxFailures, + (failureBucket.tokens === null ? policy.maxFailures : Number(failureBucket.tokens)) + + elapsedSeconds * refillRate, + ); + const currentInFlight = (bucket: ThrottleRow): number => { + const reservationIsStale = + bucket.updated_at !== null && + new Date(bucket.updated_at).getTime() <= + now.getTime() - policy.windowSeconds * 1_000; + return reservationIsStale ? 0 : Number(bucket.in_flight); + }; + const failureInFlight = currentInFlight(failureBucket); + const admissionInFlight = currentInFlight(admissionBucket); + if ( + failureInFlight >= policy.maxConcurrent || + admissionInFlight >= policy.maxConcurrent || + tokens < 1 + ) { + return false; + } + for (const keyHash of orderedKeys) { + if (keyHash === keys.failureKeyHash) { + await tx.execute( + `UPDATE ${IDENTITY_LOGIN_THROTTLE_TABLE} + SET tokens = $2, + last_refilled_at = $3, + in_flight = $4, + updated_at = $3 + WHERE key_hash = $1`, + [keyHash, tokens - 1, now.toISOString(), failureInFlight + 1], + ); + } else { + await tx.execute( + `UPDATE ${IDENTITY_LOGIN_THROTTLE_TABLE} + SET in_flight = $2, + updated_at = $3 + WHERE key_hash = $1`, + [keyHash, admissionInFlight + 1, now.toISOString()], + ); + } + } + return true; + }); + } + + async completeAuthAttempt( + keys: IdentityAuthAttemptKeys, + now: Date, + outcome: "success" | "failure", + policy: IdentityThrottlePolicy, + ): Promise { + if (keys.failureKeyHash === keys.admissionKeyHash) { + throw lifecycleFailure("invalid_configuration", "auth admission keys must be distinct", 500); + } + const orderedKeys = [keys.failureKeyHash, keys.admissionKeyHash].sort(); + await this.client.transaction(async (tx) => { + const buckets = new Map(); + for (const keyHash of orderedKeys) { + const row = await tx.get( + `SELECT * FROM ${IDENTITY_LOGIN_THROTTLE_TABLE} + WHERE key_hash = $1 + FOR UPDATE`, + [keyHash], + ); + if (row !== null) buckets.set(keyHash, row); + } + const failureBucket = buckets.get(keys.failureKeyHash); + const admissionBucket = buckets.get(keys.admissionKeyHash); + let failures = + failureBucket === undefined + ? 0 + : outcome === "success" + ? 0 + : Number(failureBucket.failures) + 1; + let windowStartedAt = + failureBucket === undefined ? now.toISOString() : iso(failureBucket.window_started_at); + if ( + failureBucket !== undefined && + outcome === "failure" && + new Date(failureBucket.window_started_at).getTime() < + now.getTime() - policy.windowSeconds * 1_000 + ) { + failures = 1; + windowStartedAt = now.toISOString(); + } + const lockedUntil = + outcome === "failure" && failures >= policy.maxFailures + ? new Date(now.getTime() + policy.lockSeconds * 1_000).toISOString() + : null; + for (const keyHash of orderedKeys) { + if (keyHash === keys.failureKeyHash && failureBucket !== undefined) { + await tx.execute( + `UPDATE ${IDENTITY_LOGIN_THROTTLE_TABLE} + SET failures = $2, + window_started_at = $3, + locked_until = $4, + in_flight = GREATEST(0, in_flight - 1), + updated_at = $5 + WHERE key_hash = $1`, + [keyHash, failures, windowStartedAt, lockedUntil, now.toISOString()], + ); + } else if (keyHash === keys.admissionKeyHash && admissionBucket !== undefined) { + await tx.execute( + `UPDATE ${IDENTITY_LOGIN_THROTTLE_TABLE} + SET in_flight = GREATEST(0, in_flight - 1), + updated_at = $2 + WHERE key_hash = $1`, + [keyHash, now.toISOString()], + ); + } + } + }); + } + + async recordLoginFailure( + keyHash: string, + now: Date, + policy: { maxFailures: number; windowSeconds: number; lockSeconds: number }, + ): Promise { + const windowCutoff = new Date(now.getTime() - policy.windowSeconds * 1_000); + const lockedUntil = new Date(now.getTime() + policy.lockSeconds * 1_000); + await this.client.execute( + `INSERT INTO ${IDENTITY_LOGIN_THROTTLE_TABLE} + (key_hash, failures, window_started_at, locked_until) + VALUES ($1, 1, $2, NULL) + ON CONFLICT (key_hash) DO UPDATE SET + failures = CASE + WHEN ${IDENTITY_LOGIN_THROTTLE_TABLE}.window_started_at < $3 THEN 1 + ELSE ${IDENTITY_LOGIN_THROTTLE_TABLE}.failures + 1 + END, + window_started_at = CASE + WHEN ${IDENTITY_LOGIN_THROTTLE_TABLE}.window_started_at < $3 THEN $2 + ELSE ${IDENTITY_LOGIN_THROTTLE_TABLE}.window_started_at + END, + locked_until = CASE + WHEN ( + CASE + WHEN ${IDENTITY_LOGIN_THROTTLE_TABLE}.window_started_at < $3 THEN 1 + ELSE ${IDENTITY_LOGIN_THROTTLE_TABLE}.failures + 1 + END + ) >= $4 THEN $5 + ELSE ${IDENTITY_LOGIN_THROTTLE_TABLE}.locked_until + END`, + [keyHash, now.toISOString(), windowCutoff.toISOString(), policy.maxFailures, lockedUntil.toISOString()], + ); + } + + async clearLoginFailures(keyHash: string): Promise { + await this.client.execute( + `DELETE FROM ${IDENTITY_LOGIN_THROTTLE_TABLE} WHERE key_hash = $1`, + [keyHash], + ); + } + + async createInvite( + invite: IdentityInviteCreation, + authorization?: { + actorTokenScopes: readonly string[]; + inviteManagementScope: string; + }, + ): Promise { + await this.client.transaction(async (tx) => { + const tenant = await tx.get<{ allowed_scopes: unknown }>( + `SELECT allowed_scopes + FROM ${IDENTITY_TENANTS_TABLE} + WHERE id = $1 + FOR UPDATE`, + [invite.tenantId], + ); + const actor = await tx.get<{ + role: "owner" | "admin" | "member"; + scopes: unknown; + membership_status: "active" | "suspended"; + user_status: "active" | "disabled" | "deleted"; + }>( + `SELECT + membership.role, + membership.scopes, + membership.status AS membership_status, + actor_user.status AS user_status + FROM ${IDENTITY_MEMBERSHIPS_TABLE} membership + JOIN ${IDENTITY_USERS_TABLE} actor_user ON actor_user.id = membership.user_id + WHERE membership.tenant_id = $1 AND membership.user_id = $2 + FOR UPDATE OF membership, actor_user`, + [invite.tenantId, invite.createdByUserId], + ); + if ( + tenant === null || + actor === null || + actor.user_status !== "active" || + actor.membership_status !== "active" || + (actor.role !== "owner" && actor.role !== "admin") || + authorization === undefined || + !authorization.actorTokenScopes.includes(authorization.inviteManagementScope) || + !parseScopes(actor.scopes).includes(authorization.inviteManagementScope) || + !roleCanAssign(actor.role, invite.role) + ) { + throw lifecycleFailure("forbidden", "access denied", 403); + } + if ( + !scopeSubset(invite.scopes, authorization.actorTokenScopes) || + !scopeSubset(invite.scopes, parseScopes(actor.scopes)) || + !scopeSubset(invite.scopes, parseScopes(tenant.allowed_scopes)) + ) { + throw lifecycleFailure("invalid_scope", "requested scope is not allowed", 403); + } + await tx.execute( + `INSERT INTO ${IDENTITY_INVITES_TABLE} + (id, tenant_id, token_hash, identifier_kind, normalized_identifier, + management_scope, role, scopes, expires_at, created_by_user_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)`, + [ + invite.id, + invite.tenantId, + invite.tokenHash, + invite.identifierKind ?? null, + invite.normalizedIdentifier ?? null, + authorization.inviteManagementScope, + invite.role, + JSON.stringify(invite.scopes), + invite.expiresAt, + invite.createdByUserId, + invite.createdAt, + ], + ); + }); + } + + async createSession(input: CreateSessionMutation): Promise { + await this.client.transaction(async (tx) => { + const context = await tx.get<{ + membership_scopes: unknown; + membership_status: "active" | "suspended"; + allowed_scopes: unknown; + }>( + `SELECT + m.scopes AS membership_scopes, + m.status AS membership_status, + t.allowed_scopes + FROM ${IDENTITY_USERS_TABLE} u + JOIN ${IDENTITY_MEMBERSHIPS_TABLE} m + ON m.user_id = u.id AND m.tenant_id = $2 + JOIN ${IDENTITY_TENANTS_TABLE} t ON t.id = m.tenant_id + WHERE u.id = $1 AND u.status = 'active' + FOR UPDATE OF u, m, t`, + [input.family.userId, input.family.tenantId], + ); + if (context === null || context.membership_status !== "active") { + throw lifecycleFailure("invalid_credentials", "authentication failed", 401); + } + input.family.scopes = intersectScopeSets( + input.family.scopes, + parseScopes(context.membership_scopes), + parseScopes(context.allowed_scopes), + ); + if (input.family.scopes.length === 0) { + throw lifecycleFailure("invalid_scope", "requested scope is not allowed", 403); + } + await insertSessionMutation(tx, input); + }); + } + + async rotateRefreshToken(input: { + currentTokenHash: string; + replacement: IdentityRefreshTokenRecord; + now: Date; + }): Promise { + return this.client.transaction(async (tx) => { + const reference = await tx.get<{ user_id: string; tenant_id: string }>( + `SELECT f.user_id, f.tenant_id + FROM ${IDENTITY_REFRESH_TOKENS_TABLE} r + JOIN ${IDENTITY_SESSION_FAMILIES_TABLE} f ON f.id = r.family_id + WHERE r.token_hash = $1`, + [input.currentTokenHash], + ); + if (reference === null) return { kind: "invalid" }; + const context = await tx.get( + `SELECT + u.*, + t.id AS tenant_id, + t.slug AS tenant_slug, + t.name AS tenant_name, + t.allowed_scopes AS tenant_allowed_scopes, + t.created_at AS tenant_created_at, + m.id AS membership_id, + m.role AS membership_role, + m.scopes AS membership_scopes, + m.status AS membership_status, + m.created_at AS membership_created_at + FROM ${IDENTITY_USERS_TABLE} u + JOIN ${IDENTITY_MEMBERSHIPS_TABLE} m + ON m.user_id = u.id AND m.tenant_id = $2 + JOIN ${IDENTITY_TENANTS_TABLE} t ON t.id = m.tenant_id + WHERE u.id = $1 + FOR UPDATE OF u, m, t`, + [reference.user_id, reference.tenant_id], + ); + const row = await tx.get( + `SELECT + r.id AS refresh_id, + r.family_id, + r.token_hash, + r.generation, + r.expires_at AS refresh_expires_at, + r.created_at AS refresh_created_at, + r.used_at, + r.revoked_at AS refresh_revoked_at, + f.user_id, + f.tenant_id, + f.scopes AS family_scopes, + f.status AS family_status, + f.expires_at AS family_expires_at, + f.created_at AS family_created_at, + f.updated_at AS family_updated_at, + f.revoked_at AS family_revoked_at, + f.revoke_reason + FROM ${IDENTITY_REFRESH_TOKENS_TABLE} r + JOIN ${IDENTITY_SESSION_FAMILIES_TABLE} f ON f.id = r.family_id + WHERE r.token_hash = $1 + FOR UPDATE OF r, f`, + [input.currentTokenHash], + ); + if (row === null) return { kind: "invalid" }; + if (row.used_at !== null || row.refresh_revoked_at !== null) { + if (row.family_status === "active") { + await revokeFamily(tx, row.family_id, "refresh_replay", input.now); + } + return { kind: "replay" }; + } + if ( + row.family_status !== "active" || + new Date(row.refresh_expires_at) <= input.now || + new Date(row.family_expires_at) <= input.now + ) { + return { kind: "invalid" }; + } + const currentScopes = + context === null + ? [] + : intersectScopeSets( + parseScopes(row.family_scopes), + parseScopes(context.membership_scopes), + parseScopes(context.tenant_allowed_scopes), + ); + if ( + context === null || + context.status !== "active" || + context.membership_status !== "active" || + currentScopes.length === 0 + ) { + await revokeFamily(tx, row.family_id, "membership_incompatible", input.now); + return { kind: "invalid" }; + } + await tx.execute( + `UPDATE ${IDENTITY_REFRESH_TOKENS_TABLE} SET used_at = $2 WHERE id = $1`, + [row.refresh_id, input.now.toISOString()], + ); + input.replacement.familyId = row.family_id; + input.replacement.generation = row.generation + 1; + if (new Date(input.replacement.expiresAt) > new Date(row.family_expires_at)) { + input.replacement.expiresAt = iso(row.family_expires_at); + } + await insertRefreshToken(tx, input.replacement); + await tx.execute( + `UPDATE ${IDENTITY_SESSION_FAMILIES_TABLE} + SET scopes = $2::jsonb, updated_at = $3 + WHERE id = $1`, + [row.family_id, JSON.stringify(currentScopes), input.now.toISOString()], + ); + return { + kind: "rotated", + family: { + id: row.family_id, + userId: row.user_id, + tenantId: row.tenant_id, + scopes: currentScopes, + status: row.family_status, + expiresAt: iso(row.family_expires_at), + createdAt: iso(row.family_created_at), + updatedAt: input.now.toISOString(), + ...(row.family_revoked_at === null ? {} : { revokedAt: iso(row.family_revoked_at) }), + ...(row.revoke_reason === null ? {} : { revokeReason: row.revoke_reason }), + }, + user: mapUser(context), + tenant: { + id: context.tenant_id, + slug: context.tenant_slug, + name: context.tenant_name, + allowedScopes: parseScopes(context.tenant_allowed_scopes), + createdAt: iso(context.tenant_created_at), + }, + membership: { + id: context.membership_id, + tenantId: context.tenant_id, + userId: context.id, + role: context.membership_role, + scopes: parseScopes(context.membership_scopes), + status: context.membership_status, + createdAt: iso(context.membership_created_at), + }, + }; + }); + } + + async revokeSessionFamily(familyId: string, reason: string, now: Date): Promise { + await this.client.transaction(async (tx) => { + await revokeFamily(tx, familyId, reason, now); + await revokeIssuedJtis(tx, `issued.family_id = $1`, [familyId], now); + }); + } + + async revokeAllUserSessions(userId: string, reason: string, now: Date): Promise { + await this.client.transaction(async (tx) => { + const rows = await tx.many<{ id: string }>( + `SELECT id FROM ${IDENTITY_SESSION_FAMILIES_TABLE} + WHERE user_id = $1 AND status = 'active' + FOR UPDATE`, + [userId], + ); + for (const row of rows) await revokeFamily(tx, row.id, reason, now); + await revokeIssuedJtis(tx, `issued.user_id = $1`, [userId], now); + }); + } + + async revokeJti(input: IdentityJtiRevocationRecord): Promise { + await this.client.execute( + `INSERT INTO ${IDENTITY_JTI_REVOCATIONS_TABLE} + (jti_hash, user_id, expires_at, revoked_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (jti_hash) DO NOTHING`, + [input.jtiHash, input.userId, input.expiresAt, input.revokedAt], + ); + } + + async recordIssuedAccessToken(input: IdentityIssuedAccessTokenRecord): Promise { + await this.client.transaction(async (tx) => { + const family = await tx.get<{ ok: boolean }>( + `SELECT true AS ok + FROM ${IDENTITY_SESSION_FAMILIES_TABLE} family + JOIN ${IDENTITY_USERS_TABLE} identity_user ON identity_user.id = family.user_id + JOIN ${IDENTITY_MEMBERSHIPS_TABLE} membership + ON membership.user_id = family.user_id AND membership.tenant_id = family.tenant_id + WHERE family.id = $1 + AND family.user_id = $2 + AND family.tenant_id = $3 + AND family.status = 'active' + AND identity_user.status = 'active' + AND membership.status = 'active' + FOR UPDATE OF family, identity_user, membership`, + [input.familyId, input.userId, input.tenantId], + ); + if (family === null) { + throw lifecycleFailure("invalid_credentials", "authentication failed", 401); + } + await tx.execute( + `INSERT INTO ${IDENTITY_ISSUED_ACCESS_TOKENS_TABLE} + (jti_hash, family_id, user_id, tenant_id, expires_at, issued_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (jti_hash) DO NOTHING`, + [ + input.jtiHash, + input.familyId, + input.userId, + input.tenantId, + input.expiresAt, + input.issuedAt, + ], + ); + }); + } + + async mutateUserSecurityState(input: { + actorUserId: string; + actorTenantId: string; + actorTokenScopes: readonly string[]; + platformAuthorityScope: string; + platformAuthorityTenantSlugs: readonly string[]; + targetUserId: string; + status: "active" | "disabled" | "deleted"; + now: Date; + }): Promise { + return this.client.transaction(async (tx) => { + const actor = await tx.get<{ + role: "owner" | "admin" | "member"; + scopes: unknown; + membership_status: "active" | "suspended"; + user_status: "active" | "disabled" | "deleted"; + tenant_slug: string; + }>( + `SELECT + membership.role, + membership.scopes, + membership.status AS membership_status, + actor_user.status AS user_status, + tenant.slug AS tenant_slug + FROM ${IDENTITY_MEMBERSHIPS_TABLE} membership + JOIN ${IDENTITY_USERS_TABLE} actor_user ON actor_user.id = membership.user_id + JOIN ${IDENTITY_TENANTS_TABLE} tenant ON tenant.id = membership.tenant_id + WHERE membership.user_id = $1 AND membership.tenant_id = $2 + FOR UPDATE OF membership, actor_user, tenant`, + [input.actorUserId, input.actorTenantId], + ); + const target = await tx.get( + `SELECT * FROM ${IDENTITY_USERS_TABLE} WHERE id = $1 FOR UPDATE`, + [input.targetUserId], + ); + if (target === null) return null; + const targetRoles = await tx.many<{ role: "owner" | "admin" | "member" }>( + `SELECT role FROM ${IDENTITY_MEMBERSHIPS_TABLE} + WHERE user_id = $1 + FOR UPDATE`, + [input.targetUserId], + ); + const highestTargetRole = highestMembershipRole(targetRoles.map((item) => item.role)); + if ( + actor === null || + actor.user_status !== "active" || + actor.membership_status !== "active" || + actor.role !== "owner" || + !input.platformAuthorityTenantSlugs.includes(actor.tenant_slug) || + !input.actorTokenScopes.includes(input.platformAuthorityScope) || + !parseScopes(actor.scopes).includes(input.platformAuthorityScope) || + highestTargetRole === null || + !roleCanManage(actor.role, highestTargetRole) + ) { + throw lifecycleFailure("forbidden", "access denied", 403); + } + const updated = await tx.one( + `UPDATE ${IDENTITY_USERS_TABLE} + SET status = $2::text, + updated_at = $3::timestamptz, + disabled_at = CASE WHEN $2::text = 'disabled' THEN $3::timestamptz ELSE NULL END, + deleted_at = CASE WHEN $2::text = 'deleted' THEN $3::timestamptz ELSE NULL END + WHERE id = $1 + RETURNING *`, + [input.targetUserId, input.status, input.now.toISOString()], + ); + const families = await tx.many<{ id: string }>( + `SELECT id FROM ${IDENTITY_SESSION_FAMILIES_TABLE} + WHERE user_id = $1 AND status = 'active' + FOR UPDATE`, + [input.targetUserId], + ); + for (const family of families) { + await revokeFamily(tx, family.id, `user_${input.status}`, input.now); + } + await revokeIssuedJtis(tx, `issued.user_id = $1`, [input.targetUserId], input.now); + return mapUser(updated); + }); + } + + async suspendMembership(input: { + actorUserId: string; + tenantId: string; + targetUserId: string; + now: Date; + }) { + return this.client.transaction(async (tx) => { + const memberships = await tx.many<{ + id: string; + user_id: string; + role: "owner" | "admin" | "member"; + scopes: unknown; + status: "active" | "suspended"; + created_at: Date | string; + user_status: "active" | "disabled" | "deleted"; + }>( + `SELECT membership.*, identity_user.status AS user_status + FROM ${IDENTITY_MEMBERSHIPS_TABLE} membership + JOIN ${IDENTITY_USERS_TABLE} identity_user ON identity_user.id = membership.user_id + WHERE membership.tenant_id = $1 + AND membership.user_id IN ($2, $3) + FOR UPDATE OF membership, identity_user`, + [input.tenantId, input.actorUserId, input.targetUserId], + ); + const actor = memberships.find((membership) => membership.user_id === input.actorUserId); + const target = memberships.find((membership) => membership.user_id === input.targetUserId); + if (target === undefined) return null; + if ( + actor === undefined || + actor.user_status !== "active" || + actor.status !== "active" || + !roleCanManage(actor.role, target.role) + ) { + throw lifecycleFailure("forbidden", "access denied", 403); + } + await tx.execute( + `UPDATE ${IDENTITY_MEMBERSHIPS_TABLE} SET status = 'suspended' WHERE id = $1`, + [target.id], + ); + const families = await tx.many<{ id: string }>( + `SELECT id FROM ${IDENTITY_SESSION_FAMILIES_TABLE} + WHERE user_id = $1 AND tenant_id = $2 AND status = 'active' + FOR UPDATE`, + [input.targetUserId, input.tenantId], + ); + for (const family of families) await revokeFamily(tx, family.id, "membership_suspended", input.now); + await revokeIssuedJtis( + tx, + `issued.user_id = $1 AND issued.tenant_id = $2`, + [input.targetUserId, input.tenantId], + input.now, + ); + return { + id: target.id, + tenantId: input.tenantId, + userId: target.user_id, + role: target.role, + scopes: parseScopes(target.scopes), + status: "suspended" as const, + createdAt: iso(target.created_at), + }; + }); + } + + async canAdminister(actorUserId: string, tenantId: string, targetUserId: string): Promise { + const row = await this.client.get<{ + actor_role: "owner" | "admin" | "member"; + target_role: "owner" | "admin" | "member"; + }>( + `SELECT actor.role AS actor_role, target.role AS target_role + FROM ${IDENTITY_MEMBERSHIPS_TABLE} actor + JOIN ${IDENTITY_USERS_TABLE} actor_user ON actor_user.id = actor.user_id + JOIN ${IDENTITY_MEMBERSHIPS_TABLE} target + ON target.tenant_id = actor.tenant_id + AND target.user_id = $3 + WHERE actor.user_id = $1 + AND actor.tenant_id = $2 + AND actor.status = 'active' + AND target.status = 'active' + AND actor_user.status = 'active'`, + [actorUserId, tenantId, targetUserId], + ); + return row !== null && roleCanManage(row.actor_role, row.target_role); + } + + async setUserStatus( + userId: string, + status: "active" | "disabled" | "deleted", + now: Date, + ): Promise { + return this.client.transaction(async (tx) => { + const rows = await tx.many( + `UPDATE ${IDENTITY_USERS_TABLE} + SET status = $2::text, + updated_at = $3::timestamptz, + disabled_at = CASE WHEN $2::text = 'disabled' THEN $3::timestamptz ELSE NULL END, + deleted_at = CASE WHEN $2::text = 'deleted' THEN $3::timestamptz ELSE NULL END + WHERE id = $1 + RETURNING *`, + [userId, status, now.toISOString()], + ); + const user = rows[0]; + if (user === undefined) return null; + if (status !== "active") { + const families = await tx.many<{ id: string }>( + `SELECT id FROM ${IDENTITY_SESSION_FAMILIES_TABLE} + WHERE user_id = $1 AND status = 'active' + FOR UPDATE`, + [userId], + ); + for (const family of families) await revokeFamily(tx, family.id, `user_${status}`, now); + await revokeIssuedJtis(tx, `issued.user_id = $1`, [userId], now); + } + return mapUser(user); + }); + } + + async createOneTimeToken(token: IdentityOneTimeTokenRecord): Promise { + await this.client.transaction(async (tx) => { + await tx.execute( + `UPDATE ${IDENTITY_ONE_TIME_TOKENS_TABLE} + SET consumed_at = $3 + WHERE user_id = $1 AND kind = $2 AND consumed_at IS NULL`, + [token.userId, token.kind, token.createdAt], + ); + await insertOneTimeToken(tx, token); + }); + } + + async consumeVerification(tokenHash: string, now: Date): Promise { + return this.client.transaction(async (tx) => { + const token = await tx.get( + `SELECT * FROM ${IDENTITY_ONE_TIME_TOKENS_TABLE} + WHERE token_hash = $1 AND kind = 'verification' + FOR UPDATE`, + [tokenHash], + ); + if ( + token === null || + token.consumed_at !== null || + token.identifier_id === null || + new Date(token.expires_at) <= now + ) { + return false; + } + const updated = await tx.many<{ id: string }>( + `UPDATE ${IDENTITY_LOGIN_IDENTIFIERS_TABLE} + SET verified_at = $2 + WHERE id = $1 AND user_id = $3 + RETURNING id`, + [token.identifier_id, now.toISOString(), token.user_id], + ); + if (updated.length !== 1) return false; + await tx.execute( + `UPDATE ${IDENTITY_ONE_TIME_TOKENS_TABLE} SET consumed_at = $2 WHERE id = $1`, + [token.id, now.toISOString()], + ); + return true; + }); + } + + async completeRecovery(input: { + tokenHash: string; + passwordHash: string; + algorithm: string; + now: Date; + }): Promise { + return this.client.transaction(async (tx) => { + const token = await tx.get( + `SELECT ott.* + FROM ${IDENTITY_ONE_TIME_TOKENS_TABLE} ott + JOIN ${IDENTITY_USERS_TABLE} u ON u.id = ott.user_id + WHERE ott.token_hash = $1 + AND ott.kind = 'recovery' + AND u.status = 'active' + FOR UPDATE OF ott`, + [input.tokenHash], + ); + if (token === null || token.consumed_at !== null || new Date(token.expires_at) <= input.now) { + return null; + } + await tx.execute( + `UPDATE ${IDENTITY_PASSWORD_CREDENTIALS_TABLE} + SET password_hash = $2, algorithm = $3, updated_at = $4 + WHERE user_id = $1`, + [token.user_id, input.passwordHash, input.algorithm, input.now.toISOString()], + ); + await tx.execute( + `UPDATE ${IDENTITY_ONE_TIME_TOKENS_TABLE} SET consumed_at = $2 WHERE id = $1`, + [token.id, input.now.toISOString()], + ); + const families = await tx.many<{ id: string }>( + `SELECT id FROM ${IDENTITY_SESSION_FAMILIES_TABLE} + WHERE user_id = $1 AND status = 'active' + FOR UPDATE`, + [token.user_id], + ); + for (const family of families) await revokeFamily(tx, family.id, "password_recovery", input.now); + await revokeIssuedJtis(tx, `issued.user_id = $1`, [token.user_id], input.now); + return token.user_id; + }); + } + + async isJtiRevoked(jtiSha256: string): Promise { + const row = await this.client.get<{ exists: boolean }>( + `SELECT true AS exists FROM ${IDENTITY_JTI_REVOCATIONS_TABLE} + WHERE jti_hash = $1 AND expires_at > now()`, + [requireSha256(jtiSha256)], + ); + return row !== null; + } + + async getSessionFamilyStatus(sessionSha256: string): Promise { + const row = await this.client.get<{ + family_status: SessionFamilyStatusRow; + family_scopes: unknown; + user_status: "active" | "disabled" | "deleted"; + membership_status: "active" | "suspended"; + membership_scopes: unknown; + allowed_scopes: unknown; + }>( + `SELECT + family.status AS family_status, + family.scopes AS family_scopes, + identity_user.status AS user_status, + membership.status AS membership_status, + membership.scopes AS membership_scopes, + tenant.allowed_scopes + FROM ${IDENTITY_SESSION_FAMILIES_TABLE} family + JOIN ${IDENTITY_USERS_TABLE} identity_user ON identity_user.id = family.user_id + JOIN ${IDENTITY_MEMBERSHIPS_TABLE} membership + ON membership.user_id = family.user_id AND membership.tenant_id = family.tenant_id + JOIN ${IDENTITY_TENANTS_TABLE} tenant ON tenant.id = family.tenant_id + WHERE family.session_hash = $1`, + [requireSha256(sessionSha256)], + ); + if (row === null) return "unknown"; + if (row.user_status === "deleted") return "deleted"; + if ( + row.user_status !== "active" || + row.membership_status !== "active" || + !scopeSubset(parseScopes(row.family_scopes), parseScopes(row.membership_scopes)) || + !scopeSubset(parseScopes(row.family_scopes), parseScopes(row.allowed_scopes)) + ) { + return "disabled"; + } + if (row.family_status === "active") return "active"; + if (row.family_status === "disabled") return "disabled"; + return "deleted"; + } +} + +type SessionFamilyStatusRow = "active" | "revoked" | "disabled" | "deleted"; + +async function insertOneTimeToken( + client: TypedQueryClient, + token: IdentityOneTimeTokenRecord, +): Promise { + await client.execute( + `INSERT INTO ${IDENTITY_ONE_TIME_TOKENS_TABLE} + (id, user_id, identifier_id, kind, token_hash, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + token.id, + token.userId, + token.identifierId ?? null, + token.kind, + token.tokenHash, + token.expiresAt, + token.createdAt, + ], + ); +} + +async function insertSessionMutation( + client: TypedQueryClient, + input: CreateSessionMutation, +): Promise { + await client.execute( + `INSERT INTO ${IDENTITY_SESSION_FAMILIES_TABLE} + (id, session_hash, user_id, tenant_id, scopes, status, expires_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9)`, + [ + input.family.id, + hashOpaqueClaim(input.family.id), + input.family.userId, + input.family.tenantId, + JSON.stringify(input.family.scopes), + input.family.status, + input.family.expiresAt, + input.family.createdAt, + input.family.updatedAt, + ], + ); + await insertRefreshToken(client, input.refresh); +} + +async function insertRefreshToken( + client: TypedQueryClient, + token: IdentityRefreshTokenRecord, +): Promise { + await client.execute( + `INSERT INTO ${IDENTITY_REFRESH_TOKENS_TABLE} + (id, family_id, token_hash, generation, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6)`, + [token.id, token.familyId, token.tokenHash, token.generation, token.expiresAt, token.createdAt], + ); +} + +async function revokeFamily( + client: TypedQueryClient, + familyId: string, + reason: string, + now: Date, +): Promise { + await client.execute( + `UPDATE ${IDENTITY_SESSION_FAMILIES_TABLE} + SET status = 'revoked', revoked_at = $2, updated_at = $2, revoke_reason = $3 + WHERE id = $1 AND status = 'active'`, + [familyId, now.toISOString(), reason], + ); + await client.execute( + `UPDATE ${IDENTITY_REFRESH_TOKENS_TABLE} + SET revoked_at = COALESCE(revoked_at, $2) + WHERE family_id = $1`, + [familyId, now.toISOString()], + ); +} + +async function revokeIssuedJtis( + client: TypedQueryClient, + predicateSql: string, + predicateParameters: readonly unknown[], + now: Date, +): Promise { + const nowParameter = `$${predicateParameters.length + 1}`; + await client.execute( + `INSERT INTO ${IDENTITY_JTI_REVOCATIONS_TABLE} + (jti_hash, user_id, expires_at, revoked_at) + SELECT issued.jti_hash, issued.user_id, issued.expires_at, ${nowParameter} + FROM ${IDENTITY_ISSUED_ACCESS_TOKENS_TABLE} issued + WHERE ${predicateSql} + AND issued.expires_at > ${nowParameter} + ON CONFLICT (jti_hash) DO NOTHING`, + [...predicateParameters, now.toISOString()], + ); +} + +function mapUser(row: UserRow): IdentityUserRecord { + return { + id: row.id, + status: row.status, + displayName: row.display_name, + createdAt: iso(row.created_at), + updatedAt: iso(row.updated_at), + ...(row.disabled_at === null ? {} : { disabledAt: iso(row.disabled_at) }), + ...(row.deleted_at === null ? {} : { deletedAt: iso(row.deleted_at) }), + }; +} + +function mapTenant(row: TenantRow) { + return { + id: row.id, + slug: row.slug, + name: row.name, + allowedScopes: parseScopes(row.allowed_scopes), + createdAt: iso(row.created_at), + }; +} + +function mapMembership(row: MembershipTenantRow) { + return { + id: row.id, + tenantId: row.tenant_id, + userId: row.user_id, + role: row.role, + scopes: parseScopes(row.scopes), + status: row.status, + createdAt: iso(row.created_at), + }; +} + +function mapThrottle(row: ThrottleRow): IdentityLoginThrottleRecord { + return { + keyHash: row.key_hash, + failures: Number(row.failures), + windowStartedAt: iso(row.window_started_at), + ...(row.locked_until === null ? {} : { lockedUntil: iso(row.locked_until) }), + ...(row.tokens === null ? {} : { tokens: Number(row.tokens) }), + ...(row.last_refilled_at === null ? {} : { lastRefilledAt: iso(row.last_refilled_at) }), + inFlight: Number(row.in_flight), + ...(row.updated_at === null ? {} : { updatedAt: iso(row.updated_at) }), + }; +} + +function scopeSubset(values: readonly string[], allowedValues: readonly string[]): boolean { + const allowed = new Set(allowedValues); + return values.every((value) => allowed.has(value)); +} + +function intersectScopeSets(...collections: readonly (readonly string[])[]): string[] { + if (collections.length === 0) return []; + const [first, ...rest] = collections; + return [...new Set((first ?? []).filter((scope) => rest.every((collection) => collection.includes(scope))))].sort(); +} + +const ROLE_RANK: Readonly> = { + member: 0, + admin: 1, + owner: 2, +}; + +function roleCanAssign( + actor: "owner" | "admin" | "member", + target: "owner" | "admin" | "member", +): boolean { + return ROLE_RANK[actor] >= ROLE_RANK[target]; +} + +function roleCanManage( + actor: "owner" | "admin" | "member", + target: "owner" | "admin" | "member", +): boolean { + return ROLE_RANK[actor] > ROLE_RANK[target]; +} + +function highestMembershipRole( + roles: readonly ("owner" | "admin" | "member")[], +): "owner" | "admin" | "member" | null { + return roles.reduce<"owner" | "admin" | "member" | null>( + (highest, role) => highest === null || ROLE_RANK[role] > ROLE_RANK[highest] ? role : highest, + null, + ); +} + +function parseScopes(value: unknown): string[] { + const parsed = typeof value === "string" ? JSON.parse(value) : value; + if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) { + throw lifecycleFailure("invalid_configuration", "persisted membership scopes are invalid", 500); + } + return [...new Set(parsed)].sort(); +} + +function iso(value: Date | string): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + throw lifecycleFailure("invalid_configuration", "persisted timestamp is invalid", 500); + } + return date.toISOString(); +} + +function requireSha256(value: string): string { + if (!/^[a-f0-9]{64}$/.test(value)) { + throw lifecycleFailure("invalid_request", "token state hash is invalid", 400); + } + return value; +} + +function isUniqueViolation(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "23505"; +} diff --git a/src/sdk/client.ts b/src/sdk/client.ts index 5dd0e92..4b59c1c 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -2,7 +2,7 @@ // Regenerate: bun run generate:sdk // @generated from OpenAPI by @hasna/contracts SDK generator — DO NOT EDIT. -// Source: Identities API 0.2.0 +// Source: Identities API 0.3.5 export interface Identity { "id": string; "kind": "human" | "agent" | "organization" | "service"; "fullName": string; "displayName"?: string; "createdAt"?: string; "updatedAt"?: string } @@ -24,6 +24,24 @@ export interface DeleteResponse { "deleted": boolean; "target": string } export interface ErrorResponse { "error": string; "reason"?: string } +export interface LoginIdentifierInput { "kind": "email" | "username"; "value": string } + +export interface SignupInput { "identifier": LoginIdentifierInput; "password": string; "displayName": string; "inviteToken"?: string } + +export interface LoginInput { "identifier": LoginIdentifierInput; "password": string; "tenantId"?: string; "scopes"?: Array } + +export interface RefreshInput { "refreshToken": string } + +export interface AuthSession { "schemaVersion": string; "user": Record; "tenant": Record; "membership": Record; "scopes": Array; "accessToken": string; "accessTokenExpiresAt": string; "refreshToken": string; "refreshTokenExpiresAt": string } + +export interface VerificationInput { "token": string } + +export interface RecoveryStartInput { "identifier": LoginIdentifierInput } + +export interface RecoveryCompleteInput { "token": string; "newPassword": string } + +export interface ActionAccepted { "accepted"?: boolean; "verified"?: boolean; "recovered"?: boolean; "loggedOut"?: boolean; "loggedOutAll"?: boolean } + export interface IdentitiesClientOptions { /** Base URL, e.g. process.env.APP_API_URL. */ baseUrl: string; @@ -79,6 +97,78 @@ export class IdentitiesClient { return data as T; } + /** Authenticate an end user with timing-safe errors and tenant-bound scopes */ + async loginIdentityUser(body: LoginInput, init?: RequestInit): Promise { + return this.request("POST", `/v1/auth/login`, { + body, + query: undefined, + init, + }); + } + + /** Revoke the current JTI and session family */ + async logoutIdentitySession(init?: RequestInit): Promise { + return this.request("POST", `/v1/auth/logout`, { + body: undefined, + query: undefined, + init, + }); + } + + /** Revoke every session family for the current user */ + async logoutAllIdentitySessions(init?: RequestInit): Promise { + return this.request("POST", `/v1/auth/logout-all`, { + body: undefined, + query: undefined, + init, + }); + } + + /** Consume a one-time recovery token, replace the credential, and revoke sessions */ + async completeIdentityRecovery(body: RecoveryCompleteInput, init?: RequestInit): Promise { + return this.request("POST", `/v1/auth/recovery/complete`, { + body, + query: undefined, + init, + }); + } + + /** Start recovery with an enumeration-safe accepted response */ + async startIdentityRecovery(body: RecoveryStartInput, init?: RequestInit): Promise { + return this.request("POST", `/v1/auth/recovery/start`, { + body, + query: undefined, + init, + }); + } + + /** Rotate a hashed refresh token; replay revokes the entire session family */ + async refreshIdentitySession(body: RefreshInput, init?: RequestInit): Promise { + return this.request("POST", `/v1/auth/refresh`, { + body, + query: undefined, + init, + }); + } + + /** Register an end user under the configured disabled, invite, or open policy */ + async signupIdentityUser(body: SignupInput, init?: RequestInit): Promise { + return this.request("POST", `/v1/auth/signup`, { + body, + query: undefined, + init, + }); + } + + /** Consume a one-time login-identifier verification token */ + async verifyIdentityLoginIdentifier(body: VerificationInput, init?: RequestInit): Promise { + return this.request("POST", `/v1/auth/verification/complete`, { + body, + query: undefined, + init, + }); + } + /** List identity contact cards */ async listCards(init?: RequestInit): Promise { return this.request("GET", `/v1/cards`, { diff --git a/src/server/openapi.ts b/src/server/openapi.ts index 9716b78..f275606 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -34,6 +34,7 @@ export function buildOpenApiDocument(version: string) { components: { securitySchemes: { ApiKeyAuth: { type: "apiKey", in: "header", name: "x-api-key" }, + BearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" }, }, schemas: { Identity: { @@ -132,6 +133,107 @@ export function buildOpenApiDocument(version: string) { properties: { error: { type: "string" }, reason: { type: "string" } }, required: ["error"], }, + LoginIdentifierInput: { + type: "object", + additionalProperties: false, + properties: { + kind: { type: "string", enum: ["email", "username"] }, + value: { type: "string", minLength: 1, maxLength: 320 }, + }, + required: ["kind", "value"], + }, + SignupInput: { + type: "object", + additionalProperties: false, + properties: { + identifier: { $ref: "#/components/schemas/LoginIdentifierInput" }, + password: { type: "string", minLength: 12, maxLength: 1024, format: "password", writeOnly: true }, + displayName: { type: "string", minLength: 1, maxLength: 160 }, + inviteToken: { type: "string", minLength: 32, maxLength: 512, writeOnly: true }, + }, + required: ["identifier", "password", "displayName"], + }, + LoginInput: { + type: "object", + additionalProperties: false, + properties: { + identifier: { $ref: "#/components/schemas/LoginIdentifierInput" }, + password: { type: "string", minLength: 1, maxLength: 1024, format: "password", writeOnly: true }, + tenantId: { type: "string" }, + scopes: { type: "array", items: { type: "string" }, maxItems: 100 }, + }, + required: ["identifier", "password"], + }, + RefreshInput: { + type: "object", + additionalProperties: false, + properties: { + refreshToken: { type: "string", minLength: 32, maxLength: 512, writeOnly: true }, + }, + required: ["refreshToken"], + }, + AuthSession: { + type: "object", + additionalProperties: false, + properties: { + schemaVersion: { type: "string", const: "hasna.identity-user-lifecycle/v1" }, + user: { type: "object", additionalProperties: true }, + tenant: { type: "object", additionalProperties: true }, + membership: { type: "object", additionalProperties: true }, + scopes: { type: "array", items: { type: "string" } }, + accessToken: { type: "string", readOnly: true }, + accessTokenExpiresAt: { type: "string", format: "date-time" }, + refreshToken: { type: "string", readOnly: true }, + refreshTokenExpiresAt: { type: "string", format: "date-time" }, + }, + required: [ + "schemaVersion", + "user", + "tenant", + "membership", + "scopes", + "accessToken", + "accessTokenExpiresAt", + "refreshToken", + "refreshTokenExpiresAt", + ], + }, + VerificationInput: { + type: "object", + additionalProperties: false, + properties: { + token: { type: "string", minLength: 32, maxLength: 512, writeOnly: true }, + }, + required: ["token"], + }, + RecoveryStartInput: { + type: "object", + additionalProperties: false, + properties: { + identifier: { $ref: "#/components/schemas/LoginIdentifierInput" }, + }, + required: ["identifier"], + }, + RecoveryCompleteInput: { + type: "object", + additionalProperties: false, + properties: { + token: { type: "string", minLength: 32, maxLength: 512, writeOnly: true }, + newPassword: { type: "string", minLength: 12, maxLength: 1024, format: "password", writeOnly: true }, + }, + required: ["token", "newPassword"], + }, + ActionAccepted: { + type: "object", + additionalProperties: false, + properties: { + accepted: { type: "boolean" }, + verified: { type: "boolean" }, + recovered: { type: "boolean" }, + loggedOut: { type: "boolean" }, + loggedOutAll: { type: "boolean" }, + }, + }, }, }, security: [{ ApiKeyAuth: [] }], @@ -195,10 +297,91 @@ export function buildOpenApiDocument(version: string) { responses: jsonResponse("Identity"), }, }, + "/v1/auth/signup": { + post: publicAuthOperation( + "signupIdentityUser", + "Register an end user under the configured disabled, invite, or open policy", + "SignupInput", + "AuthSession", + "201", + ), + }, + "/v1/auth/login": { + post: publicAuthOperation( + "loginIdentityUser", + "Authenticate an end user with timing-safe errors and tenant-bound scopes", + "LoginInput", + "AuthSession", + ), + }, + "/v1/auth/refresh": { + post: publicAuthOperation( + "refreshIdentitySession", + "Rotate a hashed refresh token; replay revokes the entire session family", + "RefreshInput", + "AuthSession", + ), + }, + "/v1/auth/logout": { + post: bearerAuthOperation("logoutIdentitySession", "Revoke the current JTI and session family"), + }, + "/v1/auth/logout-all": { + post: bearerAuthOperation("logoutAllIdentitySessions", "Revoke every session family for the current user"), + }, + "/v1/auth/verification/complete": { + post: publicAuthOperation( + "verifyIdentityLoginIdentifier", + "Consume a one-time login-identifier verification token", + "VerificationInput", + "ActionAccepted", + ), + }, + "/v1/auth/recovery/start": { + post: publicAuthOperation( + "startIdentityRecovery", + "Start recovery with an enumeration-safe accepted response", + "RecoveryStartInput", + "ActionAccepted", + "202", + ), + }, + "/v1/auth/recovery/complete": { + post: publicAuthOperation( + "completeIdentityRecovery", + "Consume a one-time recovery token, replace the credential, and revoke sessions", + "RecoveryCompleteInput", + "ActionAccepted", + ), + }, }, }; } +function publicAuthOperation( + operationId: string, + summary: string, + requestSchema: string, + responseSchema: string, + status = "200", +) { + return { + operationId, + summary, + security: [], + requestBody: jsonBody(requestSchema), + responses: jsonResponse(responseSchema, status), + }; +} + +function bearerAuthOperation(operationId: string, summary: string) { + return { + operationId, + summary, + security: [{ BearerAuth: [] }], + responses: jsonResponse("ActionAccepted"), + }; +} + function targetParam() { return { name: "target", in: "path", required: true, schema: { type: "string" } } as const; } diff --git a/src/server/serve.test.ts b/src/server/serve.test.ts index 3304453..49de6e9 100644 --- a/src/server/serve.test.ts +++ b/src/server/serve.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { mintApiKey } from "@hasna/contracts/auth"; import { IdentityStore, type IdentityStoreFile, type StorageBackend, type StorageSnapshot } from "../storage.js"; +import type { AuthSession } from "../sdk/client.js"; import { createFetchHandler } from "./serve.js"; import type { CloudIdentityStore } from "../pg-store.js"; @@ -81,6 +82,22 @@ describe("identities serve", () => { expect(res.status).toBe(200); const spec = await res.json(); expect(spec.paths["/v1/identities"]).toBeDefined(); + expect(spec.components.schemas.AuthSession.properties.accessToken).toMatchObject({ + type: "string", + readOnly: true, + }); + expect(spec.components.schemas.AuthSession.properties.accessToken.writeOnly).toBeUndefined(); + expect(spec.components.schemas.AuthSession.properties.refreshToken).toMatchObject({ + type: "string", + readOnly: true, + }); + expect(spec.components.schemas.AuthSession.properties.refreshToken.writeOnly).toBeUndefined(); + expect(spec.components.schemas.RefreshInput.properties.refreshToken.writeOnly).toBe(true); + const sdkResponseTokens: Pick = { + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + }; + expect(Object.keys(sdkResponseTokens).sort()).toEqual(["accessToken", "refreshToken"]); }); test("/v1 requires an API key", async () => { diff --git a/src/server/serve.ts b/src/server/serve.ts index 08881ec..4661076 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -15,6 +15,7 @@ import type { IdentityStore } from "../storage.js"; import { createCloudIdentityStore, cloudHealth, cloudReady, type CloudIdentityStore } from "../pg-store.js"; import { getPackageVersion } from "../version.js"; import { buildOpenApiDocument } from "./openapi.js"; +import type { IdentityLifecycleApi } from "../user-lifecycle.js"; export const IDENTITIES_SERVE_APP = "identities"; const DEFAULT_PORT = 15455; @@ -28,6 +29,8 @@ export interface ServeOptions { signingSecret?: string; /** Called on each auth decision for the AUDIT trail. */ audit?: (event: unknown) => void; + /** Optional Identities-owned public end-user lifecycle handler. */ + lifecycleApi?: IdentityLifecycleApi; } export interface RunningServer { @@ -62,6 +65,7 @@ interface Handler { verifier: ApiKeyVerifier; keys: ApiKeyStore; version: string; + lifecycleApi?: IdentityLifecycleApi; } async function readJsonBody(req: Request): Promise { @@ -177,7 +181,14 @@ export async function buildHandler(options: ServeOptions = {}): Promise isRevoked: keys.isRevoked, ...(options.audit ? { audit: options.audit as any } : {}), }); - return { store: cloud.store, cloud, verifier, keys, version: getPackageVersion() }; + return { + store: cloud.store, + cloud, + verifier, + keys, + version: getPackageVersion(), + ...(options.lifecycleApi === undefined ? {} : { lifecycleApi: options.lifecycleApi }), + }; } export async function createFetchHandler(options: ServeOptions = {}): Promise<{ @@ -217,6 +228,14 @@ export async function createFetchHandler(options: ServeOptions = {}): Promise<{ if (path === "/" ) { return json({ name: "@hasna/identities", status: "ok", version: handler.version, mode: "cloud" }); } + if (path.startsWith("/v1/auth/")) { + if (handler.lifecycleApi === undefined) { + return json({ error: "Identity user lifecycle is not configured", reason: "lifecycle_not_configured" }, 503, { + "cache-control": "no-store", + }); + } + return handler.lifecycleApi.handle(req); + } if (path.startsWith("/v1/") || path === "/v1") { return handleV1(handler, req, url); } diff --git a/src/user-lifecycle.test.ts b/src/user-lifecycle.test.ts new file mode 100644 index 0000000..3a39b85 --- /dev/null +++ b/src/user-lifecycle.test.ts @@ -0,0 +1,1426 @@ +import { beforeAll, describe, expect, spyOn, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { exportJWK, generateKeyPair, type CryptoKey, type JWK, type KeyObject } from "jose"; +import { + IdentityAccessTokenIssuer, + IdentityAccessTokenVerifier, + IdentityJwksRegistry, +} from "./identity-auth.js"; +import { + Argon2idIdentityPasswordHasher, + DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE, + IdentityLifecycleError, + IdentityLifecycleService, + InMemoryIdentityLifecycleStore, + createIdentityLifecycleApi, + identityLifecycleMigrations, + normalizeLoginIdentifier, + type IdentityInviteRecord, + type IdentityPasswordHasher, + type RegistrationPolicy, +} from "./user-lifecycle.js"; +import { runCli } from "./cli.js"; + +const ISSUER = "https://identity.example.test"; +const AUDIENCE = "infinity-local"; +const BOOTSTRAP_TENANT = { + slug: "infinity", + name: "Infinity", +}; + +type SigningKey = CryptoKey | KeyObject; + +let privateKey: SigningKey; +let publicJwk: JWK; + +beforeAll(async () => { + const pair = await generateKeyPair("EdDSA"); + privateKey = pair.privateKey; + publicJwk = await exportJWK(pair.publicKey); +}); + +class FastPasswordHasher implements IdentityPasswordHasher { + readonly algorithm = "test-only" as const; + verifyCalls = 0; + dummyHashCalls = 0; + + async hash(password: string): Promise { + return `test$${password}`; + } + + async verify(password: string, encoded: string): Promise { + this.verifyCalls += 1; + return encoded === `test$${password}`; + } + + async dummyHash(): Promise { + this.dummyHashCalls += 1; + return "test$dummy-password-that-never-matches"; + } +} + +class GatedPasswordHasher extends FastPasswordHasher { + active = 0; + maxActive = 0; + private releaseGate!: () => void; + private readonly gate = new Promise((resolve) => { + this.releaseGate = resolve; + }); + + override async verify(password: string, encoded: string): Promise { + this.verifyCalls += 1; + this.active += 1; + this.maxActive = Math.max(this.maxActive, this.active); + await this.gate; + this.active -= 1; + return encoded === `test$${password}`; + } + + release(): void { + this.releaseGate(); + } +} + +function authFixture() { + const registry = new IdentityJwksRegistry({ + issuer: ISSUER, + revision: 1, + keys: [{ kid: "current", alg: "EdDSA", status: "active", publicJwk }], + }); + const store = new InMemoryIdentityLifecycleStore(); + const issuer = new IdentityAccessTokenIssuer({ + registry, + privateKey, + kid: "current", + alg: "EdDSA", + issuer: ISSUER, + audience: AUDIENCE, + accessTokenTtlSeconds: 300, + }); + const verifier = new IdentityAccessTokenVerifier({ + issuer: ISSUER, + audience: AUDIENCE, + algorithms: ["EdDSA"], + jwks: registry, + tokenState: store, + clockToleranceSeconds: 0, + maxTokenLifetimeSeconds: 300, + }); + return { registry, store, issuer, verifier }; +} + +function fixture( + policy: RegistrationPolicy = "open", + options: { + now?: () => Date; + hasher?: IdentityPasswordHasher; + recovery?: (input: { userId: string; token: string }) => void | Promise; + verification?: (input: { userId: string; token: string }) => void | Promise; + recoveryMinimumResponseMs?: number; + } = {}, +) { + const auth = authFixture(); + const hasher = options.hasher ?? new FastPasswordHasher(); + const service = new IdentityLifecycleService({ + store: auth.store, + registrationPolicy: policy, + bootstrapTenant: BOOTSTRAP_TENANT, + passwordHasher: hasher, + tokenIssuer: auth.issuer, + tokenVerifier: auth.verifier, + now: options.now, + recoveryMinimumResponseMs: options.recoveryMinimumResponseMs, + hooks: { + deliverRecovery: options.recovery, + deliverVerification: options.verification, + }, + defaultScopes: [ + "runs:read", + "runs:write", + "identity:read", + "identities:invites:manage", + "identities:platform:admin", + ], + loginThrottle: { + maxFailures: 3, + windowSeconds: 300, + lockSeconds: 300, + }, + }); + return { ...auth, hasher, service }; +} + +async function errorReason(promise: Promise): Promise { + try { + await promise; + return "none"; + } catch (error) { + expect(error).toBeInstanceOf(IdentityLifecycleError); + return (error as IdentityLifecycleError).reason; + } +} + +async function firstAdmin(service: IdentityLifecycleService, email = "owner@example.test") { + return service.bootstrapFirstAdmin({ + identifier: { kind: "email", value: email }, + password: "correct horse battery staple", + displayName: "Owner", + }); +} + +describe("Argon2id password credentials", () => { + test("hashes with Argon2id and verifies without exposing the password", async () => { + const hasher = new Argon2idIdentityPasswordHasher({ + memoryCost: 65_536, + timeCost: 2, + }); + const encoded = await hasher.hash("correct horse battery staple"); + expect(encoded).toStartWith("$argon2id$"); + expect(encoded).not.toContain("correct horse battery staple"); + expect(await hasher.verify("correct horse battery staple", encoded)).toBe(true); + expect(await hasher.verify("wrong password", encoded)).toBe(false); + }); +}); + +describe("registration and first-admin bootstrap", () => { + test("atomically bootstraps exactly one first administrator", async () => { + const { service, store } = fixture("open"); + const [one, two] = await Promise.allSettled([ + service.bootstrapFirstAdmin({ + identifier: { kind: "email", value: "first@example.test" }, + password: "a secure first password", + displayName: "First", + }), + service.bootstrapFirstAdmin({ + identifier: { kind: "email", value: "second@example.test" }, + password: "a secure second password", + displayName: "Second", + }), + ]); + expect([one.status, two.status].sort()).toEqual(["fulfilled", "rejected"]); + const snapshot = store.snapshot(); + expect(snapshot.users).toHaveLength(1); + expect(snapshot.tenants).toHaveLength(1); + expect(snapshot.memberships).toHaveLength(1); + expect(snapshot.memberships[0]?.role).toBe("owner"); + expect(snapshot.sessionFamilies).toHaveLength(1); + expect(snapshot.refreshTokens).toHaveLength(1); + }); + + test("rejects duplicate and concurrent signup without partial rows", async () => { + const { service, store } = fixture("open"); + const request = { + identifier: { kind: "email" as const, value: "Duplicate@Example.Test" }, + password: "a sufficiently strong password", + displayName: "Duplicate", + }; + const results = await Promise.allSettled([ + service.signup(request), + service.signup({ ...request, identifier: { ...request.identifier, value: " duplicate@example.test " } }), + ]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + expect(store.snapshot().users).toHaveLength(1); + expect(store.snapshot().loginIdentifiers).toHaveLength(1); + expect(store.snapshot().credentials).toHaveLength(1); + expect(store.snapshot().sessionFamilies).toHaveLength(1); + expect(store.snapshot().refreshTokens).toHaveLength(1); + }); + + test("enforces disabled, invite, expired-invite, and used-invite policies", async () => { + const disabled = fixture("disabled"); + expect(await errorReason(disabled.service.signup({ + identifier: { kind: "email", value: "disabled@example.test" }, + password: "a sufficiently strong password", + displayName: "Disabled", + }))).toBe("registration_disabled"); + + let current = new Date(); + const invited = fixture("invite", { now: () => current }); + const admin = await firstAdmin(invited.service); + const invite = await invited.service.createInvite({ + actorAccessToken: admin.accessToken, + tenantId: admin.tenant.id, + identifier: { kind: "email", value: "member@example.test" }, + role: "member", + scopes: ["runs:read"], + expiresInSeconds: 60, + }); + current = new Date(current.getTime() + 120_000); + expect(await errorReason(invited.service.signup({ + identifier: { kind: "email", value: "member@example.test" }, + password: "a sufficiently strong password", + displayName: "Member", + inviteToken: invite.token, + }))).toBe("invite_invalid"); + + current = new Date(current.getTime() + 60_000); + const fresh = await invited.service.createInvite({ + actorAccessToken: admin.accessToken, + tenantId: admin.tenant.id, + identifier: { kind: "email", value: "member@example.test" }, + role: "member", + scopes: ["runs:read"], + expiresInSeconds: 300, + }); + await invited.service.signup({ + identifier: { kind: "email", value: "member@example.test" }, + password: "a sufficiently strong password", + displayName: "Member", + inviteToken: fresh.token, + }); + expect(await errorReason(invited.service.signup({ + identifier: { kind: "email", value: "other@example.test" }, + password: "a sufficiently strong password", + displayName: "Other", + inviteToken: fresh.token, + }))).toBe("invite_invalid"); + }); +}); + +describe("login, tenancy, and account state", () => { + test("uses the same password verification path and error for unknown and wrong users", async () => { + const hasher = new FastPasswordHasher(); + const { service } = fixture("open", { hasher }); + await firstAdmin(service); + hasher.verifyCalls = 0; + const unknown = service.login({ + identifier: { kind: "email", value: "unknown@example.test" }, + password: "wrong", + }); + expect(await errorReason(unknown)).toBe("invalid_credentials"); + expect(hasher.verifyCalls).toBe(1); + const wrong = service.login({ + identifier: { kind: "email", value: "owner@example.test" }, + password: "wrong", + }); + expect(await errorReason(wrong)).toBe("invalid_credentials"); + expect(hasher.verifyCalls).toBe(2); + }); + + test("throttles repeated failures without changing the generic login error", async () => { + const { service } = fixture("open"); + await firstAdmin(service); + for (let attempt = 0; attempt < 3; attempt += 1) { + expect(await errorReason(service.login({ + identifier: { kind: "email", value: "owner@example.test" }, + password: "wrong", + throttleKey: "test-client", + }))).toBe("invalid_credentials"); + } + expect(await errorReason(service.login({ + identifier: { kind: "email", value: "owner@example.test" }, + password: "correct horse battery staple", + throttleKey: "test-client", + }))).toBe("rate_limited"); + }); + + test("fails closed for disabled and soft-deleted users", async () => { + const { service } = fixture("invite"); + const admin = await firstAdmin(service); + const invite = await service.createInvite({ + actorAccessToken: admin.accessToken, + tenantId: admin.tenant.id, + identifier: { kind: "email", value: "member-state@example.test" }, + role: "member", + scopes: ["runs:read"], + }); + const member = await service.signup({ + identifier: { kind: "email", value: "member-state@example.test" }, + password: "a sufficiently strong password", + displayName: "Member State", + inviteToken: invite.token, + }); + await service.disableUser({ + actorAccessToken: admin.accessToken, + userId: member.user.id, + }); + expect(await errorReason(service.login({ + identifier: { kind: "email", value: "member-state@example.test" }, + password: "a sufficiently strong password", + }))).toBe("invalid_credentials"); + await service.restoreUser({ + actorAccessToken: admin.accessToken, + userId: member.user.id, + }); + const restored = await service.login({ + identifier: { kind: "email", value: "member-state@example.test" }, + password: "a sufficiently strong password", + }); + await service.softDeleteUser({ + actorAccessToken: admin.accessToken, + userId: restored.user.id, + }); + expect(await errorReason(service.login({ + identifier: { kind: "email", value: "member-state@example.test" }, + password: "a sufficiently strong password", + }))).toBe("invalid_credentials"); + }); + + test("binds login and granted scopes to one tenant membership", async () => { + const { service } = fixture("invite"); + const admin = await firstAdmin(service); + const invite = await service.createInvite({ + actorAccessToken: admin.accessToken, + tenantId: admin.tenant.id, + identifier: { kind: "email", value: "member@example.test" }, + role: "member", + scopes: ["runs:read"], + }); + const member = await service.signup({ + identifier: { kind: "email", value: "member@example.test" }, + password: "a sufficiently strong password", + displayName: "Member", + inviteToken: invite.token, + }); + expect(await errorReason(service.login({ + identifier: { kind: "email", value: "member@example.test" }, + password: "a sufficiently strong password", + tenantId: "tenant-other", + }))).toBe("invalid_credentials"); + expect(await errorReason(service.login({ + identifier: { kind: "email", value: "member@example.test" }, + password: "a sufficiently strong password", + tenantId: member.tenant.id, + scopes: ["runs:write"], + }))).toBe("invalid_scope"); + const session = await service.login({ + identifier: { kind: "email", value: "member@example.test" }, + password: "a sufficiently strong password", + tenantId: member.tenant.id, + scopes: ["runs:read"], + }); + expect(session.scopes).toEqual(["runs:read"]); + const refreshed = await service.refresh({ refreshToken: session.refreshToken }); + expect(refreshed.scopes).toEqual(["runs:read"]); + }); + + test("prevents a user in another tenant from administering the target user", async () => { + const { service, verifier } = fixture("open"); + const first = await firstAdmin(service); + const second = await service.signup({ + identifier: { kind: "email", value: "second-admin@example.test" }, + password: "a sufficiently strong password", + displayName: "Second", + }); + expect(await errorReason(service.disableUser({ + actorAccessToken: second.accessToken, + userId: first.user.id, + }))).toBe("forbidden"); + expect((await verifier.verify(first.accessToken)).sub).toBe(first.user.id); + }); +}); + +describe("session rotation and revocation", () => { + test("rotates refresh tokens and treats replay as a family compromise", async () => { + const { service, verifier } = fixture("open"); + const session = await firstAdmin(service); + const rotated = await service.refresh({ refreshToken: session.refreshToken }); + expect(rotated.refreshToken).not.toBe(session.refreshToken); + expect(await errorReason(service.refresh({ refreshToken: session.refreshToken }))).toBe("refresh_replay"); + await expect(verifier.verify(rotated.accessToken)).rejects.toMatchObject({ + reason: "session_inactive", + }); + }); + + test("logout revokes the family and current JTI; logout-all is cross-user isolated", async () => { + const { service, verifier } = fixture("open"); + const first = await firstAdmin(service); + const secondSignup = await service.signup({ + identifier: { kind: "email", value: "second@example.test" }, + password: "a sufficiently strong password", + displayName: "Second", + }); + const firstAgain = await service.login({ + identifier: { kind: "email", value: "owner@example.test" }, + password: "correct horse battery staple", + }); + await service.logout({ + accessToken: first.accessToken, + refreshToken: first.refreshToken, + }); + await expect(verifier.verify(first.accessToken)).rejects.toMatchObject({ + reason: "token_revoked", + }); + await service.logoutAll({ accessToken: firstAgain.accessToken }); + await expect(verifier.verify(firstAgain.accessToken)).rejects.toMatchObject({ + reason: "token_revoked", + }); + expect((await verifier.verify(secondSignup.accessToken)).sub).toBe(secondSignup.user.id); + }); +}); + +describe("verification and recovery", () => { + test("consumes verification and recovery tokens once and revokes sessions after recovery", async () => { + const verificationTokens: string[] = []; + const recoveryTokens: string[] = []; + let markRecoveryDelivered!: () => void; + const recoveryDelivered = new Promise((resolve) => { + markRecoveryDelivered = resolve; + }); + const { service, verifier } = fixture("open", { + verification: ({ token }) => { + verificationTokens.push(token); + }, + recovery: ({ token }) => { + recoveryTokens.push(token); + markRecoveryDelivered(); + }, + }); + const session = await firstAdmin(service); + expect(verificationTokens).toHaveLength(1); + await service.verifyIdentifier({ token: verificationTokens[0]! }); + expect(await errorReason(service.verifyIdentifier({ token: verificationTokens[0]! }))).toBe("verification_invalid"); + + await service.beginRecovery({ + identifier: { kind: "email", value: "owner@example.test" }, + }); + await recoveryDelivered; + expect(recoveryTokens).toHaveLength(1); + await service.completeRecovery({ + token: recoveryTokens[0]!, + newPassword: "a different secure password", + }); + expect(await errorReason(service.completeRecovery({ + token: recoveryTokens[0]!, + newPassword: "another secure password", + }))).toBe("recovery_invalid"); + await expect(verifier.verify(session.accessToken)).rejects.toMatchObject({ + reason: "token_revoked", + }); + const recovered = await service.login({ + identifier: { kind: "email", value: "owner@example.test" }, + password: "a different secure password", + }); + expect(recovered.user.id).toBe(session.user.id); + }); + + test("unknown recovery requests are indistinguishable and do not call delivery hooks", async () => { + const delivered: string[] = []; + const { service } = fixture("open", { + recovery: ({ token }) => { + delivered.push(token); + }, + }); + await firstAdmin(service); + expect(await service.beginRecovery({ + identifier: { kind: "email", value: "unknown@example.test" }, + })).toEqual({ accepted: true }); + expect(delivered).toHaveLength(0); + }); +}); + +describe("mountable lifecycle API and schemas", () => { + test("supports signup/login/refresh/logout without echoing credentials", async () => { + const { service } = fixture("open"); + const api = createIdentityLifecycleApi({ service }); + const signup = await api.handle(new Request("http://local/v1/auth/signup", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + identifier: { kind: "email", value: "api@example.test" }, + password: "a sufficiently strong password", + displayName: "API User", + }), + })); + expect(signup.status).toBe(201); + const signupBody = await signup.json(); + expect(signupBody.refreshToken).toBeString(); + expect(JSON.stringify(signupBody)).not.toContain("a sufficiently strong password"); + + const login = await api.handle(new Request("http://local/v1/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + identifier: { kind: "email", value: "api@example.test" }, + password: "wrong", + }), + })); + expect(login.status).toBe(401); + expect(await login.json()).toEqual({ + error: "authentication_failed", + reason: "invalid_credentials", + }); + + const refresh = await api.handle(new Request("http://local/v1/auth/refresh", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ refreshToken: signupBody.refreshToken }), + })); + expect(refresh.status).toBe(200); + const refreshBody = await refresh.json(); + expect(refreshBody.refreshToken).not.toBe(signupBody.refreshToken); + }); + + test("CLI bootstrap reads and writes owner-only secret files without printing tokens", async () => { + const directory = await mkdtemp(join(tmpdir(), "identities-lifecycle-cli-")); + const passwordPath = join(directory, "password"); + const sessionPath = join(directory, "session.json"); + const storePath = join(directory, "identities.json"); + await writeFile(passwordPath, "a secure bootstrap password\n", { mode: 0o600 }); + await chmod(passwordPath, 0o600); + const { service } = fixture("open"); + const log = spyOn(console, "log").mockImplementation(() => {}); + try { + await runCli([ + "--json", + "--store", + storePath, + "auth", + "bootstrap", + "--identifier-kind", + "email", + "--identifier", + "cli@example.test", + "--password-file", + passwordPath, + "--display-name", + "CLI Owner", + "--session-file", + sessionPath, + ], { lifecycleService: service }); + expect(process.exitCode).not.toBe(1); + const output = log.mock.calls.map((call) => String(call[0])).join("\n"); + const session = await readFile(sessionPath, "utf8"); + expect(output).not.toContain("a secure bootstrap password"); + expect(output).not.toContain(JSON.parse(session).refreshToken); + expect(JSON.parse(output)).toMatchObject({ + bootstrapped: true, + sessionFileCreated: true, + }); + expect((await stat(sessionPath)).mode & 0o077).toBe(0); + } finally { + log.mockRestore(); + process.exitCode = 0; + await rm(directory, { recursive: true, force: true }); + } + }); +}); + +describe("lifecycle migrations", () => { + test("are versioned, idempotent on reapply, and define reverse-order rollback", () => { + const migrations = identityLifecycleMigrations(); + expect(migrations.map((migration) => migration.id)).toEqual([ + "identities_0004_user_tenancy", + "identities_0005_user_credentials", + "identities_0006_user_sessions", + "identities_0007_user_verification_recovery", + "identities_0008_user_login_throttle", + "identities_0009_user_lifecycle_security", + ]); + for (const migration of migrations) { + expect(migration.up).toContain("IF NOT EXISTS"); + expect(migration.down).toContain("IF EXISTS"); + expect(migration.checksum).toMatch(/^sha256:[a-f0-9]{64}$/); + } + const rollbackOrder = migrations + .slice() + .reverse() + .map((migration) => migration.id); + expect(rollbackOrder).toEqual([ + "identities_0009_user_lifecycle_security", + "identities_0008_user_login_throttle", + "identities_0007_user_verification_recovery", + "identities_0006_user_sessions", + "identities_0005_user_credentials", + "identities_0004_user_tenancy", + ]); + }); +}); + +describe("review-A lifecycle security regressions", () => { + test("enforces invite role order, management scope, actor scope subset, and tenant allowlist", async () => { + const { service, store } = fixture("invite"); + const owner = await firstAdmin(service); + + expect(await errorReason(service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: owner.tenant.id, + role: "member", + scopes: ["root:arbitrary"], + }))).toBe("invalid_scope"); + + const adminInvite = await service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: owner.tenant.id, + identifier: { kind: "email", value: "limited-admin@example.test" }, + role: "admin", + scopes: ["runs:read"], + }); + const admin = await service.signup({ + identifier: { kind: "email", value: "limited-admin@example.test" }, + password: "a sufficiently strong password", + displayName: "Limited Admin", + inviteToken: adminInvite.token, + }); + + expect(await errorReason(service.createInvite({ + actorAccessToken: admin.accessToken, + tenantId: owner.tenant.id, + role: "owner", + scopes: ["runs:read"], + }))).toBe("forbidden"); + expect(await errorReason(service.createInvite({ + actorAccessToken: admin.accessToken, + tenantId: owner.tenant.id, + role: "member", + scopes: ["runs:read"], + }))).toBe("forbidden"); + + const pendingInvite = await service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: owner.tenant.id, + identifier: { kind: "email", value: "stale-invite@example.test" }, + role: "member", + scopes: ["runs:read"], + }); + const mutable = store as unknown as { + state: { memberships: Array<{ userId: string; tenantId: string; scopes: string[] }> }; + }; + const currentOwner = mutable.state.memberships.find( + (membership) => membership.userId === owner.user.id && membership.tenantId === owner.tenant.id, + )!; + currentOwner.scopes = currentOwner.scopes.filter((scope) => scope !== "identities:invites:manage"); + expect(await errorReason(service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: owner.tenant.id, + role: "member", + scopes: ["runs:read"], + }))).toBe("forbidden"); + expect(await errorReason(service.signup({ + identifier: { kind: "email", value: "stale-invite@example.test" }, + password: "a sufficiently strong password", + displayName: "Stale Invite", + inviteToken: pendingInvite.token, + }))).toBe("invite_invalid"); + }); + + test("intersects current membership scopes at session creation and refresh and revokes an incompatible family", async () => { + const gatedHasher = new GatedPasswordHasher(); + const creationFixture = fixture("open", { hasher: gatedHasher }); + const creationAdmin = await firstAdmin(creationFixture.service, "creation-owner@example.test"); + const creationLogin = creationFixture.service.login({ + identifier: { kind: "email", value: "creation-owner@example.test" }, + password: "correct horse battery staple", + }); + for (let turn = 0; turn < 20 && gatedHasher.active === 0; turn += 1) await Promise.resolve(); + const creationMutable = creationFixture.store as unknown as { + state: { memberships: Array<{ userId: string; tenantId: string; scopes: string[] }> }; + }; + creationMutable.state.memberships.find( + (membership) => + membership.userId === creationAdmin.user.id && membership.tenantId === creationAdmin.tenant.id, + )!.scopes = ["runs:read"]; + gatedHasher.release(); + expect((await creationLogin).scopes).toEqual(["runs:read"]); + + const { service, store, verifier } = fixture("open"); + const initial = await firstAdmin(service); + const mutable = store as unknown as { + state: { + memberships: Array<{ userId: string; tenantId: string; scopes: string[] }>; + }; + }; + const membership = mutable.state.memberships.find( + (candidate) => candidate.userId === initial.user.id && candidate.tenantId === initial.tenant.id, + )!; + membership.scopes = ["runs:read"]; + + const narrowed = await service.refresh({ refreshToken: initial.refreshToken }); + expect(narrowed.scopes).toEqual(["runs:read"]); + + membership.scopes = []; + expect(await errorReason(service.refresh({ refreshToken: narrowed.refreshToken }))).toBe("refresh_invalid"); + await expect(verifier.verify(narrowed.accessToken)).rejects.toMatchObject({ + reason: "session_inactive", + }); + }); + + test("does not let a tenant admin globally disable a multi-tenant owner", async () => { + const { service, store, verifier } = fixture("open"); + const target = await firstAdmin(service); + const actorPersonal = await service.signup({ + identifier: { kind: "email", value: "tenant-admin@example.test" }, + password: "a sufficiently strong password", + displayName: "Tenant Admin", + }); + const mutable = store as unknown as { + state: { + tenants: Array<{ id: string; slug: string; name: string; createdAt: string; allowedScopes?: string[] }>; + memberships: Array<{ + id: string; + userId: string; + tenantId: string; + role: "owner" | "admin" | "member"; + scopes: string[]; + createdAt: string; + status?: "active" | "suspended"; + }>; + }; + }; + mutable.state.memberships.push({ + id: "mem_tenant_admin", + userId: actorPersonal.user.id, + tenantId: target.tenant.id, + role: "admin", + scopes: ["runs:read", "identities:invites:manage"], + status: "active", + createdAt: new Date().toISOString(), + }); + mutable.state.tenants.push({ + id: "ten_second_owner", + slug: "second-owner", + name: "Second Owner Tenant", + allowedScopes: ["runs:read"], + createdAt: new Date().toISOString(), + }); + mutable.state.memberships.push({ + id: "mem_second_owner", + userId: target.user.id, + tenantId: "ten_second_owner", + role: "owner", + scopes: ["runs:read"], + status: "active", + createdAt: new Date().toISOString(), + }); + const actor = await service.login({ + identifier: { kind: "email", value: "tenant-admin@example.test" }, + password: "a sufficiently strong password", + tenantId: target.tenant.id, + }); + + expect(await errorReason(service.disableUser({ + actorAccessToken: actor.accessToken, + userId: target.user.id, + }))).toBe("forbidden"); + expect((await verifier.verify(target.accessToken)).sub).toBe(target.user.id); + + const multiTenantMember = await service.signup({ + identifier: { kind: "email", value: "multi-tenant-member@example.test" }, + password: "a sufficiently strong password", + displayName: "Multi Tenant Member", + }); + mutable.state.memberships.push({ + id: "mem_multi_tenant_member", + userId: multiTenantMember.user.id, + tenantId: target.tenant.id, + role: "member", + scopes: ["runs:read"], + status: "active", + createdAt: new Date().toISOString(), + }); + const suspended = await service.suspendMembership({ + actorAccessToken: actor.accessToken, + tenantId: target.tenant.id, + userId: multiTenantMember.user.id, + }); + expect(suspended.status).toBe("suspended"); + expect( + store.snapshot().users.find((user) => user.id === multiTenantMember.user.id)?.status, + ).toBe("active"); + expect((await verifier.verify(multiTenantMember.accessToken)).sub).toBe(multiTenantMember.user.id); + + const tenantBoundary = fixture("open"); + await firstAdmin(tenantBoundary.service, "platform-owner@example.test"); + const personalOwner = await tenantBoundary.service.signup({ + identifier: { kind: "email", value: "personal-owner@example.test" }, + password: "a sufficiently strong password", + displayName: "Personal Owner", + }); + const lowerTarget = await tenantBoundary.service.signup({ + identifier: { kind: "email", value: "lower-target@example.test" }, + password: "a sufficiently strong password", + displayName: "Lower Target", + }); + const tenantBoundaryMutable = tenantBoundary.store as unknown as { + state: { memberships: Array<{ userId: string; role: "owner" | "admin" | "member" }> }; + }; + tenantBoundaryMutable.state.memberships.find( + (membership) => membership.userId === lowerTarget.user.id, + )!.role = "member"; + expect(await errorReason(tenantBoundary.service.disableUser({ + actorAccessToken: personalOwner.accessToken, + userId: lowerTarget.user.id, + }))).toBe("forbidden"); + }); + + test("atomically revokes issued JTIs and sessions while verification observes current user state", async () => { + const { service, store, verifier } = fixture("invite"); + const owner = await firstAdmin(service); + const invite = await service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: owner.tenant.id, + identifier: { kind: "email", value: "atomic-member@example.test" }, + role: "member", + scopes: ["runs:read"], + }); + const member = await service.signup({ + identifier: { kind: "email", value: "atomic-member@example.test" }, + password: "a sufficiently strong password", + displayName: "Atomic Member", + inviteToken: invite.token, + }); + + await service.disableUser({ + actorAccessToken: owner.accessToken, + userId: member.user.id, + }); + const snapshot = store.snapshot(); + expect(snapshot.users.find((user) => user.id === member.user.id)?.status).toBe("disabled"); + expect( + snapshot.sessionFamilies + .filter((family) => family.userId === member.user.id) + .every((family) => family.status !== "active"), + ).toBe(true); + expect(snapshot.jtiRevocations.some((revocation) => revocation.userId === member.user.id)).toBe(true); + await expect(verifier.verify(member.accessToken)).rejects.toMatchObject({ + reason: "token_revoked", + }); + + const stateFixture = fixture("open"); + const stateSession = await firstAdmin(stateFixture.service, "state-check@example.test"); + const mutable = stateFixture.store as unknown as { + state: { users: Array<{ id: string; status: "active" | "disabled" | "deleted" }> }; + }; + mutable.state.users.find((user) => user.id === stateSession.user.id)!.status = "disabled"; + await expect(stateFixture.verifier.verify(stateSession.accessToken)).rejects.toMatchObject({ + reason: "session_inactive", + }); + }); + + test("atomically reserves bounded login work before password verification", async () => { + const hasher = new GatedPasswordHasher(); + const { service } = fixture("open", { hasher }); + await firstAdmin(service); + hasher.verifyCalls = 0; + const attempts = Array.from({ length: 6 }, () => + service.login({ + identifier: { kind: "email", value: "owner@example.test" }, + password: "wrong", + throttleKey: "one-client", + }).catch((error) => error), + ); + for (let turn = 0; turn < 20; turn += 1) await Promise.resolve(); + hasher.release(); + const results = await Promise.all(attempts); + expect(hasher.maxActive).toBeLessThanOrEqual(2); + expect(results.some((result) => result instanceof IdentityLifecycleError && result.reason === "rate_limited")).toBe( + true, + ); + + const staleStore = new InMemoryIdentityLifecycleStore({ + loginThrottles: [{ + keyHash: "a".repeat(64), + failures: 0, + windowStartedAt: "2026-01-01T00:00:00.000Z", + tokens: 2, + lastRefilledAt: "2026-01-01T00:00:00.000Z", + inFlight: 2, + updatedAt: "2026-01-01T00:00:00.000Z", + }], + }); + expect( + await staleStore.reserveAuthAttempt({ + failureKeyHash: "a".repeat(64), + admissionKeyHash: "b".repeat(64), + }, new Date("2026-01-01T00:06:00.000Z"), { + maxFailures: 3, + windowSeconds: 300, + lockSeconds: 300, + maxConcurrent: 2, + }), + ).toBe(true); + }); + + test("prewarms the dummy hash, equalizes recovery work, throttles by identifier and client, and delivers async", async () => { + const hasher = new FastPasswordHasher(); + let releaseDelivery!: () => void; + let deliveryStarted!: () => void; + const deliveryGate = new Promise((resolve) => { + releaseDelivery = resolve; + }); + const started = new Promise((resolve) => { + deliveryStarted = resolve; + }); + const delivered: string[] = []; + const { service } = fixture("open", { + hasher, + recovery: async ({ token }) => { + delivered.push(token); + deliveryStarted(); + await deliveryGate; + }, + }); + expect(hasher.dummyHashCalls).toBe(1); + await firstAdmin(service); + hasher.verifyCalls = 0; + + await service.beginRecovery({ + identifier: { kind: "email", value: "unknown@example.test" }, + throttleKey: "recovery-client", + }); + expect(hasher.verifyCalls).toBe(1); + + const known = service.beginRecovery({ + identifier: { kind: "email", value: "OWNER@EXAMPLE.TEST" }, + throttleKey: "recovery-client", + }); + let responseResolved = false; + void known.then(() => { + responseResolved = true; + }); + await started; + const resolvedBeforeDelivery = responseResolved; + releaseDelivery(); + await known; + expect(resolvedBeforeDelivery).toBe(true); + expect(hasher.verifyCalls).toBe(2); + + for (let attempt = 0; attempt < 6; attempt += 1) { + await service.beginRecovery({ + identifier: { + kind: "email", + value: attempt % 2 === 0 ? "owner@example.test" : " OWNER@EXAMPLE.TEST ", + }, + throttleKey: "bounded-recovery-client", + }); + } + await new Promise((resolve) => setImmediate(resolve)); + expect(delivered.length).toBeLessThanOrEqual(4); + }); + + test("canonicalizes UTS-46 IDN domains and exposes migration collision audit", async () => { + const unicode = normalizeLoginIdentifier({ kind: "email", value: "User@bücher.example" }); + const ascii = normalizeLoginIdentifier({ kind: "email", value: "user@xn--bcher-kva.example" }); + expect(unicode).toEqual(ascii); + + const { service, store } = fixture("open"); + await service.signup({ + identifier: { kind: "email", value: "User@bücher.example" }, + password: "a sufficiently strong password", + displayName: "Unicode", + }); + expect(await errorReason(service.signup({ + identifier: { kind: "email", value: "user@xn--bcher-kva.example" }, + password: "a sufficiently strong password", + displayName: "Punycode", + }))).toBe("duplicate_identifier"); + expect(store.snapshot().users).toHaveLength(1); + + const lifecycleModule = await import("./user-lifecycle.js"); + expect(typeof (lifecycleModule as Record)["auditLoginIdentifierCanonicalization"]).toBe( + "function", + ); + }); +}); + +describe("review-B lifecycle security regressions", () => { + test("persists only the authoritative invite management scope and rechecks creator scope", async () => { + const { service, store } = fixture("invite"); + const owner = await firstAdmin(service); + const token = "review-b-authoritative-invite-token"; + const now = new Date(); + + const forgedInvite: IdentityInviteRecord = { + id: "inv_review_b_authoritative_scope", + tenantId: owner.tenant.id, + tokenHash: createHash("sha256").update(token).digest("hex"), + identifierKind: "email", + normalizedIdentifier: "review-b-scope@example.test", + managementScope: "runs:read", + role: "member", + scopes: ["runs:read"], + expiresAt: new Date(now.getTime() + 300_000).toISOString(), + createdByUserId: owner.user.id, + createdAt: now.toISOString(), + }; + await store.createInvite(forgedInvite, { + actorTokenScopes: owner.scopes, + inviteManagementScope: DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE, + }); + + expect( + store.snapshot().invites.find((invite) => invite.id === "inv_review_b_authoritative_scope") + ?.managementScope, + ).toBe(DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE); + + const mutable = store as unknown as { + state: { memberships: Array<{ userId: string; tenantId: string; scopes: string[] }> }; + }; + const creatorMembership = mutable.state.memberships.find( + (membership) => membership.userId === owner.user.id && membership.tenantId === owner.tenant.id, + )!; + creatorMembership.scopes = creatorMembership.scopes.filter( + (scope) => scope !== DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE, + ); + + expect(await errorReason(service.signup({ + identifier: { kind: "email", value: "review-b-scope@example.test" }, + password: "a sufficiently strong password", + displayName: "Review B Scope", + inviteToken: token, + }))).toBe("invite_invalid"); + expect(store.snapshot().users).toHaveLength(1); + expect( + store.snapshot().invites.find((invite) => invite.id === "inv_review_b_authoritative_scope") + ?.consumedAt, + ).toBeUndefined(); + }); + + test("bounds one client's Argon2 work across distinct unknown identifiers", async () => { + const hasher = new GatedPasswordHasher(); + const { service, store } = fixture("open", { hasher }); + await firstAdmin(service); + hasher.verifyCalls = 0; + + const attempts = Array.from({ length: 8 }, (_, index) => + service.login({ + identifier: { kind: "email", value: `unknown-${index}@example.test` }, + password: "wrong", + throttleKey: "one-fanout-client", + }).catch((error) => error), + ); + for (let turn = 0; turn < 50 && hasher.active < 2; turn += 1) await Promise.resolve(); + hasher.release(); + const results = await Promise.all(attempts); + + expect(hasher.maxActive).toBeLessThanOrEqual(2); + expect( + results.filter( + (result) => result instanceof IdentityLifecycleError && result.reason === "rate_limited", + ), + ).toHaveLength(6); + expect(store.snapshot().loginThrottles.every((throttle) => (throttle.inFlight ?? 0) === 0)).toBe( + true, + ); + }); +}); + +describe("review-C2 lifecycle security regressions", () => { + test("requires an owner or admin role even when a member holds the invite-management scope", async () => { + const { service } = fixture("invite"); + const owner = await firstAdmin(service); + const memberInvite = await service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: owner.tenant.id, + identifier: { kind: "email", value: "review-c2-member@example.test" }, + role: "member", + scopes: ["runs:read", DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE], + }); + const member = await service.signup({ + identifier: { kind: "email", value: "review-c2-member@example.test" }, + password: "a sufficiently strong password", + displayName: "Review C2 Member", + inviteToken: memberInvite.token, + }); + + expect(await errorReason(service.createInvite({ + actorAccessToken: member.accessToken, + tenantId: owner.tenant.id, + role: "member", + scopes: ["runs:read"], + }))).toBe("forbidden"); + }); + + test("keeps known and unknown recovery response distributions behind the same bounded floor", async () => { + expect(() => fixture("open", { recoveryMinimumResponseMs: 99 })).toThrow( + "recoveryMinimumResponseMs must be an integer between 100 and 5000", + ); + expect(() => fixture("open", { recoveryMinimumResponseMs: 5_001 })).toThrow( + "recoveryMinimumResponseMs must be an integer between 100 and 5000", + ); + const { service, store } = fixture("open"); + await firstAdmin(service); + const createOneTimeToken = store.createOneTimeToken.bind(store); + store.createOneTimeToken = async (token) => { + await new Promise((resolve) => setTimeout(resolve, 40)); + await createOneTimeToken(token); + }; + + const knownDurations: number[] = []; + const unknownDurations: number[] = []; + for (let sample = 0; sample < 4; sample += 1) { + let startedAt = performance.now(); + await service.beginRecovery({ + identifier: { kind: "email", value: `unknown-c2-${sample}@example.test` }, + throttleKey: `review-c2-unknown-${sample}`, + }); + unknownDurations.push(performance.now() - startedAt); + + startedAt = performance.now(); + await service.beginRecovery({ + identifier: { kind: "email", value: "owner@example.test" }, + throttleKey: `review-c2-known-${sample}`, + }); + knownDurations.push(performance.now() - startedAt); + } + + const median = (samples: readonly number[]): number => { + const ordered = [...samples].sort((left, right) => left - right); + return ordered[Math.floor(ordered.length / 2)]!; + }; + const knownMedian = median(knownDurations); + const unknownMedian = median(unknownDurations); + + expect(knownMedian).toBeGreaterThanOrEqual(225); + expect(unknownMedian).toBeGreaterThanOrEqual(225); + expect(Math.abs(knownMedian - unknownMedian)).toBeLessThan(40); + }, 10_000); +}); + +describe("review-F invite consumption authority regressions", () => { + type InviteAuthorityCase = { + name: string; + creationRole: "owner" | "admin"; + currentState: + | "unchanged" + | "demoted-admin" + | "demoted-member" + | "suspended" + | "missing" + | "cross-tenant" + | "scope-lost" + | "user-disabled"; + targetRole: "owner" | "admin" | "member"; + expected: "consumed" | "invite_invalid"; + }; + + const cases: readonly InviteAuthorityCase[] = [ + { + name: "active owner consumes an owner invite", + creationRole: "owner", + currentState: "unchanged", + targetRole: "owner", + expected: "consumed", + }, + { + name: "active owner consumes an admin invite", + creationRole: "owner", + currentState: "unchanged", + targetRole: "admin", + expected: "consumed", + }, + { + name: "active owner consumes a member invite", + creationRole: "owner", + currentState: "unchanged", + targetRole: "member", + expected: "consumed", + }, + { + name: "active admin consumes an admin invite", + creationRole: "admin", + currentState: "unchanged", + targetRole: "admin", + expected: "consumed", + }, + { + name: "active admin consumes a member invite", + creationRole: "admin", + currentState: "unchanged", + targetRole: "member", + expected: "consumed", + }, + { + name: "owner demoted to admin cannot consume an owner invite", + creationRole: "owner", + currentState: "demoted-admin", + targetRole: "owner", + expected: "invite_invalid", + }, + { + name: "owner demoted to admin can consume a member invite", + creationRole: "owner", + currentState: "demoted-admin", + targetRole: "member", + expected: "consumed", + }, + { + name: "owner demoted to member cannot consume a member invite", + creationRole: "owner", + currentState: "demoted-member", + targetRole: "member", + expected: "invite_invalid", + }, + { + name: "admin demoted to member cannot consume a member invite", + creationRole: "admin", + currentState: "demoted-member", + targetRole: "member", + expected: "invite_invalid", + }, + { + name: "suspended creator membership cannot consume an invite", + creationRole: "owner", + currentState: "suspended", + targetRole: "member", + expected: "invite_invalid", + }, + { + name: "missing creator membership cannot consume an invite", + creationRole: "owner", + currentState: "missing", + targetRole: "member", + expected: "invite_invalid", + }, + { + name: "cross-tenant creator membership cannot consume an invite", + creationRole: "owner", + currentState: "cross-tenant", + targetRole: "member", + expected: "invite_invalid", + }, + { + name: "creator scope loss invalidates a pending invite", + creationRole: "owner", + currentState: "scope-lost", + targetRole: "member", + expected: "invite_invalid", + }, + { + name: "disabled creator user cannot consume an invite", + creationRole: "owner", + currentState: "user-disabled", + targetRole: "member", + expected: "invite_invalid", + }, + ]; + + test("requires the creator to remain an active owner or admin with current authority", async () => { + for (const [index, scenario] of cases.entries()) { + const { service, store } = fixture("invite"); + const owner = await firstAdmin(service, `review-f-owner-${index}@example.test`); + let creator = owner; + if (scenario.creationRole === "admin") { + const creatorIdentifier = `review-f-admin-${index}@example.test`; + const creatorInvite = await service.createInvite({ + actorAccessToken: owner.accessToken, + tenantId: owner.tenant.id, + identifier: { kind: "email", value: creatorIdentifier }, + role: "admin", + scopes: ["runs:read", DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE], + }); + creator = await service.signup({ + identifier: { kind: "email", value: creatorIdentifier }, + password: "a sufficiently strong password", + displayName: `Review F Admin ${index}`, + inviteToken: creatorInvite.token, + }); + } + + const targetIdentifier = `review-f-target-${index}@example.test`; + const pending = await service.createInvite({ + actorAccessToken: creator.accessToken, + tenantId: owner.tenant.id, + identifier: { kind: "email", value: targetIdentifier }, + role: scenario.targetRole, + scopes: ["runs:read"], + }); + const mutable = store as unknown as { + state: { + users: Array<{ + id: string; + status: "active" | "disabled" | "deleted"; + }>; + tenants: Array<{ + id: string; + slug: string; + name: string; + allowedScopes?: string[]; + createdAt: string; + }>; + memberships: Array<{ + id: string; + userId: string; + tenantId: string; + role: "owner" | "admin" | "member"; + scopes: string[]; + status?: "active" | "suspended"; + createdAt: string; + }>; + }; + }; + const creatorMembershipIndex = mutable.state.memberships.findIndex( + (membership) => + membership.userId === creator.user.id && membership.tenantId === owner.tenant.id, + ); + const creatorMembership = mutable.state.memberships[creatorMembershipIndex]!; + if (scenario.currentState === "demoted-admin") { + creatorMembership.role = "admin"; + } else if (scenario.currentState === "demoted-member") { + creatorMembership.role = "member"; + } else if (scenario.currentState === "suspended") { + creatorMembership.status = "suspended"; + } else if (scenario.currentState === "missing") { + mutable.state.memberships.splice(creatorMembershipIndex, 1); + } else if (scenario.currentState === "cross-tenant") { + const otherTenantId = `ten_review_f_other_${index}`; + mutable.state.tenants.push({ + id: otherTenantId, + slug: `review-f-other-${index}`, + name: `Review F Other ${index}`, + allowedScopes: ["runs:read", DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE], + createdAt: new Date().toISOString(), + }); + creatorMembership.tenantId = otherTenantId; + } else if (scenario.currentState === "scope-lost") { + creatorMembership.scopes = creatorMembership.scopes.filter( + (scope) => scope !== DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE, + ); + } else if (scenario.currentState === "user-disabled") { + mutable.state.users.find((user) => user.id === creator.user.id)!.status = "disabled"; + } + + const before = store.snapshot(); + const signup = service.signup({ + identifier: { kind: "email", value: targetIdentifier }, + password: "a sufficiently strong password", + displayName: `Review F Target ${index}`, + inviteToken: pending.token, + }); + if (scenario.expected === "consumed") { + const session = await signup; + expect({ + scenario: scenario.name, + role: session.membership.role, + }).toEqual({ + scenario: scenario.name, + role: scenario.targetRole, + }); + } else { + expect({ + scenario: scenario.name, + reason: await errorReason(signup), + }).toEqual({ + scenario: scenario.name, + reason: "invite_invalid", + }); + } + + const after = store.snapshot(); + const persistedInvite = after.invites.find((invite) => invite.id === pending.id); + expect({ + scenario: scenario.name, + consumed: persistedInvite?.consumedAt !== undefined, + userDelta: after.users.length - before.users.length, + identifierDelta: after.loginIdentifiers.length - before.loginIdentifiers.length, + credentialDelta: after.credentials.length - before.credentials.length, + membershipDelta: after.memberships.length - before.memberships.length, + familyDelta: after.sessionFamilies.length - before.sessionFamilies.length, + refreshDelta: after.refreshTokens.length - before.refreshTokens.length, + }).toEqual({ + scenario: scenario.name, + consumed: scenario.expected === "consumed", + userDelta: scenario.expected === "consumed" ? 1 : 0, + identifierDelta: scenario.expected === "consumed" ? 1 : 0, + credentialDelta: scenario.expected === "consumed" ? 1 : 0, + membershipDelta: scenario.expected === "consumed" ? 1 : 0, + familyDelta: scenario.expected === "consumed" ? 1 : 0, + refreshDelta: scenario.expected === "consumed" ? 1 : 0, + }); + } + }); +}); diff --git a/src/user-lifecycle.ts b/src/user-lifecycle.ts new file mode 100644 index 0000000..b03186a --- /dev/null +++ b/src/user-lifecycle.ts @@ -0,0 +1,2615 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { domainToASCII } from "node:url"; +import { z } from "zod"; +import { + IdentityAccessTokenVerifier, + IdentityAuthError, + IdentityAccessTokenIssuer, + hashOpaqueClaim, + type IdentityAccessTokenClaims, + type IdentitySessionFamilyStatus, + type IdentityTokenStateStore, +} from "./identity-auth.js"; +import { checksumSql } from "./generated/storage-kit/migrations.js"; + +export const IDENTITY_USER_LIFECYCLE_SCHEMA_VERSION = "hasna.identity-user-lifecycle/v1" as const; +export const IDENTITY_USERS_TABLE = "identity_users"; +export const IDENTITY_TENANTS_TABLE = "identity_tenants"; +export const IDENTITY_MEMBERSHIPS_TABLE = "identity_memberships"; +export const IDENTITY_LOGIN_IDENTIFIERS_TABLE = "identity_login_identifiers"; +export const IDENTITY_PASSWORD_CREDENTIALS_TABLE = "identity_password_credentials"; +export const IDENTITY_INVITES_TABLE = "identity_invites"; +export const IDENTITY_SESSION_FAMILIES_TABLE = "identity_session_families"; +export const IDENTITY_REFRESH_TOKENS_TABLE = "identity_refresh_tokens"; +export const IDENTITY_JTI_REVOCATIONS_TABLE = "identity_jti_revocations"; +export const IDENTITY_ONE_TIME_TOKENS_TABLE = "identity_one_time_tokens"; +export const IDENTITY_LOGIN_THROTTLE_TABLE = "identity_login_throttle"; +export const IDENTITY_ISSUED_ACCESS_TOKENS_TABLE = "identity_issued_access_tokens"; +export const IDENTITY_LOGIN_IDENTIFIER_AUDIT_TABLE = "identity_login_identifier_canonicalization_audit"; +export const DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE = "identities:invites:manage"; +export const DEFAULT_IDENTITY_PLATFORM_ADMIN_SCOPE = "identities:platform:admin"; + +export type RegistrationPolicy = "disabled" | "invite" | "open"; +export type LoginIdentifierKind = "email" | "username"; +export type IdentityUserStatus = "active" | "disabled" | "deleted"; +export type IdentityMembershipRole = "owner" | "admin" | "member"; +export type IdentityMembershipStatus = "active" | "suspended"; +export type SessionFamilyStatus = "active" | "revoked" | "disabled" | "deleted"; +export type OneTimeTokenKind = "verification" | "recovery"; + +export interface LoginIdentifierInput { + kind: LoginIdentifierKind; + value: string; +} + +export interface IdentityUserRecord { + id: string; + status: IdentityUserStatus; + displayName: string; + createdAt: string; + updatedAt: string; + disabledAt?: string; + deletedAt?: string; +} + +export interface IdentityTenantRecord { + id: string; + slug: string; + name: string; + allowedScopes?: string[]; + createdAt: string; +} + +export interface IdentityMembershipRecord { + id: string; + tenantId: string; + userId: string; + role: IdentityMembershipRole; + scopes: string[]; + status?: IdentityMembershipStatus; + createdAt: string; +} + +export interface IdentityLoginIdentifierRecord { + id: string; + userId: string; + kind: LoginIdentifierKind; + normalizedValue: string; + verifiedAt?: string; + createdAt: string; +} + +export interface IdentityPasswordCredentialRecord { + id: string; + userId: string; + passwordHash: string; + algorithm: string; + createdAt: string; + updatedAt: string; +} + +export interface IdentityInviteRecord { + id: string; + tenantId: string; + tokenHash: string; + identifierKind?: LoginIdentifierKind; + normalizedIdentifier?: string; + managementScope: string; + role: IdentityMembershipRole; + scopes: string[]; + expiresAt: string; + consumedAt?: string; + consumedByUserId?: string; + createdByUserId: string; + createdAt: string; +} + +export interface IdentitySessionFamilyRecord { + id: string; + userId: string; + tenantId: string; + scopes: string[]; + status: SessionFamilyStatus; + expiresAt: string; + createdAt: string; + updatedAt: string; + revokedAt?: string; + revokeReason?: string; +} + +export interface IdentityRefreshTokenRecord { + id: string; + familyId: string; + tokenHash: string; + generation: number; + expiresAt: string; + createdAt: string; + usedAt?: string; + revokedAt?: string; +} + +export interface IdentityOneTimeTokenRecord { + id: string; + userId: string; + identifierId?: string; + kind: OneTimeTokenKind; + tokenHash: string; + expiresAt: string; + createdAt: string; + consumedAt?: string; +} + +export interface IdentityLoginThrottleRecord { + keyHash: string; + failures: number; + windowStartedAt: string; + lockedUntil?: string; + tokens?: number; + lastRefilledAt?: string; + inFlight?: number; + updatedAt?: string; +} + +export interface IdentityJtiRevocationRecord { + jtiHash: string; + userId: string; + expiresAt: string; + revokedAt: string; +} + +export interface IdentityIssuedAccessTokenRecord { + jtiHash: string; + familyId: string; + userId: string; + tenantId: string; + expiresAt: string; + issuedAt: string; +} + +export interface IdentityLoginIdentifierCanonicalizationAuditEntry { + identifierId: string; + previousValue: string; + canonicalValue: string; + conflictingIdentifierIds: string[]; +} + +export interface IdentityLoginIdentifierCanonicalizationAudit { + entries: IdentityLoginIdentifierCanonicalizationAuditEntry[]; + collisions: IdentityLoginIdentifierCanonicalizationAuditEntry[]; +} + +export interface IdentityThrottlePolicy { + maxFailures: number; + windowSeconds: number; + lockSeconds: number; + maxConcurrent: number; +} + +export interface IdentityAuthAttemptKeys { + failureKeyHash: string; + admissionKeyHash: string; +} + +export type IdentityInviteCreation = Omit; + +export interface IdentityLifecycleSnapshot { + users: IdentityUserRecord[]; + tenants: IdentityTenantRecord[]; + memberships: IdentityMembershipRecord[]; + loginIdentifiers: IdentityLoginIdentifierRecord[]; + credentials: IdentityPasswordCredentialRecord[]; + invites: IdentityInviteRecord[]; + sessionFamilies: IdentitySessionFamilyRecord[]; + refreshTokens: IdentityRefreshTokenRecord[]; + oneTimeTokens: IdentityOneTimeTokenRecord[]; + loginThrottles: IdentityLoginThrottleRecord[]; + jtiRevocations: IdentityJtiRevocationRecord[]; + issuedAccessTokens: IdentityIssuedAccessTokenRecord[]; +} + +export interface IdentityAuthSession { + schemaVersion: typeof IDENTITY_USER_LIFECYCLE_SCHEMA_VERSION; + user: IdentityUserRecord; + tenant: IdentityTenantRecord; + membership: IdentityMembershipRecord; + scopes: string[]; + accessToken: string; + accessTokenExpiresAt: string; + refreshToken: string; + refreshTokenExpiresAt: string; +} + +export interface IdentityPasswordHasher { + readonly algorithm: string; + hash(password: string): Promise; + verify(password: string, encoded: string): Promise; + dummyHash(): Promise; +} + +export interface IdentityLifecycleHooks { + deliverVerification?: (input: { + userId: string; + identifier: LoginIdentifierInput; + token: string; + expiresAt: string; + }) => void | Promise; + deliverRecovery?: (input: { + userId: string; + identifier: LoginIdentifierInput; + token: string; + expiresAt: string; + }) => void | Promise; + userDisabled?: (input: { userId: string; at: string }) => void | Promise; + userDeleted?: (input: { userId: string; at: string }) => void | Promise; + userRestored?: (input: { userId: string; at: string }) => void | Promise; +} + +export class IdentityLifecycleError extends Error { + constructor( + readonly reason: + | "invalid_request" + | "registration_disabled" + | "duplicate_identifier" + | "bootstrap_complete" + | "invite_invalid" + | "invalid_credentials" + | "rate_limited" + | "invalid_scope" + | "refresh_invalid" + | "refresh_replay" + | "verification_invalid" + | "recovery_invalid" + | "forbidden" + | "not_found" + | "invalid_configuration", + message: string, + readonly status: 400 | 401 | 403 | 404 | 409 | 429 | 500, + ) { + super(message); + this.name = "IdentityLifecycleError"; + } +} + +export class Argon2idIdentityPasswordHasher implements IdentityPasswordHasher { + readonly algorithm = "argon2id"; + private readonly memoryCost: number; + private readonly timeCost: number; + private dummyHashPromise?: Promise; + + constructor(options: { memoryCost?: number; timeCost?: number } = {}) { + this.memoryCost = boundedInteger(options.memoryCost ?? 65_536, "memoryCost", 32_768, 1_048_576); + this.timeCost = boundedInteger(options.timeCost ?? 3, "timeCost", 2, 10); + } + + hash(password: string): Promise { + validatePassword(password); + return Bun.password.hash(password, { + algorithm: "argon2id", + memoryCost: this.memoryCost, + timeCost: this.timeCost, + }); + } + + async verify(password: string, encoded: string): Promise { + if (typeof password !== "string" || typeof encoded !== "string") return false; + try { + return await Bun.password.verify(password, encoded, "argon2id"); + } catch { + return false; + } + } + + dummyHash(): Promise { + this.dummyHashPromise ??= this.hash(randomToken()); + return this.dummyHashPromise; + } +} + +export interface RegistrationMutation { + bootstrapOnly: boolean; + policy: RegistrationPolicy; + bootstrapTenant: { slug: string; name: string }; + user: IdentityUserRecord; + identifier: IdentityLoginIdentifierRecord; + credential: IdentityPasswordCredentialRecord; + inviteTokenHash?: string; + personalTenant: IdentityTenantRecord; + ownerMembership: IdentityMembershipRecord; + verification: IdentityOneTimeTokenRecord; + initialSession: CreateSessionMutation; +} + +export interface RegistrationResult { + user: IdentityUserRecord; + tenant: IdentityTenantRecord; + membership: IdentityMembershipRecord; + identifier: IdentityLoginIdentifierRecord; + initialSession: CreateSessionMutation; +} + +type SessionRegistrationContext = Omit; + +export interface LoginCandidate { + user: IdentityUserRecord; + identifier: IdentityLoginIdentifierRecord; + credential: IdentityPasswordCredentialRecord; + memberships: IdentityMembershipRecord[]; + tenants: IdentityTenantRecord[]; +} + +export interface CreateSessionMutation { + family: IdentitySessionFamilyRecord; + refresh: IdentityRefreshTokenRecord; +} + +export type RefreshRotationResult = + | { + kind: "rotated"; + family: IdentitySessionFamilyRecord; + user: IdentityUserRecord; + tenant: IdentityTenantRecord; + membership: IdentityMembershipRecord; + } + | { kind: "replay" } + | { kind: "invalid" }; + +export interface IdentityLifecycleStore extends IdentityTokenStateStore { + prepare?(): Promise; + register(input: RegistrationMutation): Promise; + findLoginCandidate(kind: LoginIdentifierKind, normalizedValue: string): Promise; + getLoginThrottle(keyHash: string, now: Date): Promise; + recordLoginFailure( + keyHash: string, + now: Date, + policy: { maxFailures: number; windowSeconds: number; lockSeconds: number }, + ): Promise; + clearLoginFailures(keyHash: string): Promise; + reserveAuthAttempt?( + keys: IdentityAuthAttemptKeys, + now: Date, + policy: IdentityThrottlePolicy, + ): Promise; + completeAuthAttempt?( + keys: IdentityAuthAttemptKeys, + now: Date, + outcome: "success" | "failure", + policy: IdentityThrottlePolicy, + ): Promise; + createInvite( + invite: IdentityInviteCreation, + authorization?: { + actorTokenScopes: readonly string[]; + inviteManagementScope: string; + }, + ): Promise; + createSession(input: CreateSessionMutation): Promise; + rotateRefreshToken(input: { + currentTokenHash: string; + replacement: IdentityRefreshTokenRecord; + now: Date; + }): Promise; + revokeSessionFamily(familyId: string, reason: string, now: Date): Promise; + revokeAllUserSessions(userId: string, reason: string, now: Date): Promise; + revokeJti(input: IdentityJtiRevocationRecord): Promise; + recordIssuedAccessToken?(input: IdentityIssuedAccessTokenRecord): Promise; + mutateUserSecurityState?(input: { + actorUserId: string; + actorTenantId: string; + actorTokenScopes: readonly string[]; + platformAuthorityScope: string; + platformAuthorityTenantSlugs: readonly string[]; + targetUserId: string; + status: IdentityUserStatus; + now: Date; + }): Promise; + suspendMembership?(input: { + actorUserId: string; + tenantId: string; + targetUserId: string; + now: Date; + }): Promise; + canAdminister(actorUserId: string, tenantId: string, targetUserId: string): Promise; + setUserStatus(userId: string, status: IdentityUserStatus, now: Date): Promise; + createOneTimeToken(token: IdentityOneTimeTokenRecord): Promise; + consumeVerification(tokenHash: string, now: Date): Promise; + completeRecovery(input: { + tokenHash: string; + passwordHash: string; + algorithm: string; + now: Date; + }): Promise; +} + +function emptySnapshot(): IdentityLifecycleSnapshot { + return { + users: [], + tenants: [], + memberships: [], + loginIdentifiers: [], + credentials: [], + invites: [], + sessionFamilies: [], + refreshTokens: [], + oneTimeTokens: [], + loginThrottles: [], + jtiRevocations: [], + issuedAccessTokens: [], + }; +} + +export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { + private readonly state: IdentityLifecycleSnapshot; + private tail: Promise = Promise.resolve(); + + constructor(initial: Partial = {}) { + this.state = { + ...emptySnapshot(), + ...structuredClone(initial), + }; + for (const tenant of this.state.tenants) { + tenant.allowedScopes ??= normalizeScopes( + this.state.memberships + .filter((membership) => membership.tenantId === tenant.id) + .flatMap((membership) => membership.scopes), + ); + } + } + + snapshot(): IdentityLifecycleSnapshot { + return structuredClone(this.state); + } + + private async exclusive(operation: () => T | Promise): Promise { + let release = () => {}; + const predecessor = this.tail; + this.tail = new Promise((resolve) => { + release = resolve; + }); + await predecessor; + try { + return await operation(); + } finally { + release(); + } + } + + async prepare(): Promise { + return this.exclusive(() => { + const audit = auditLoginIdentifierCanonicalization(this.state.loginIdentifiers); + if (audit.collisions.length > 0) throw lifecycleError("invalid_configuration"); + const canonicalById = new Map(audit.entries.map((entry) => [entry.identifierId, entry.canonicalValue])); + for (const identifier of this.state.loginIdentifiers) { + identifier.normalizedValue = canonicalById.get(identifier.id) ?? identifier.normalizedValue; + } + for (const invite of this.state.invites) { + if (invite.identifierKind === "email" && invite.normalizedIdentifier !== undefined) { + invite.normalizedIdentifier = normalizeLoginIdentifier({ + kind: "email", + value: invite.normalizedIdentifier, + }).value; + } + } + return structuredClone(audit); + }); + } + + async register(input: RegistrationMutation): Promise { + return this.exclusive(() => { + if ( + this.state.loginIdentifiers.some( + (identifier) => + identifier.kind === input.identifier.kind && + identifier.normalizedValue === input.identifier.normalizedValue, + ) + ) { + throw lifecycleError("duplicate_identifier"); + } + + const isFirstUser = this.state.users.length === 0; + if (input.bootstrapOnly && !isFirstUser) throw lifecycleError("bootstrap_complete"); + if (!input.bootstrapOnly && input.policy === "disabled") throw lifecycleError("registration_disabled"); + if (!input.bootstrapOnly && isFirstUser && input.policy === "invite") { + throw lifecycleError("invite_invalid"); + } + + let tenant: IdentityTenantRecord; + let membership: IdentityMembershipRecord; + let invite: IdentityInviteRecord | undefined; + if (isFirstUser) { + tenant = { + id: `ten_${randomUUID()}`, + slug: normalizeTenantSlug(input.bootstrapTenant.slug), + name: requiredText(input.bootstrapTenant.name, "bootstrap tenant name", 160), + allowedScopes: [...input.personalTenant.allowedScopes ?? input.ownerMembership.scopes], + createdAt: input.user.createdAt, + }; + membership = { + ...input.ownerMembership, + tenantId: tenant.id, + userId: input.user.id, + role: "owner", + status: "active", + }; + } else if (input.policy === "invite" && !input.bootstrapOnly) { + invite = this.state.invites.find((candidate) => candidate.tokenHash === input.inviteTokenHash); + if ( + invite === undefined || + invite.consumedAt !== undefined || + new Date(invite.expiresAt).getTime() <= new Date(input.user.createdAt).getTime() || + (invite.identifierKind !== undefined && + (invite.identifierKind !== input.identifier.kind || + invite.normalizedIdentifier !== input.identifier.normalizedValue)) + ) { + throw lifecycleError("invite_invalid"); + } + const invitedTenant = this.state.tenants.find((candidate) => candidate.id === invite!.tenantId); + if (invitedTenant === undefined) throw lifecycleError("invite_invalid"); + const creator = this.state.users.find( + (candidate) => candidate.id === invite!.createdByUserId && candidate.status === "active", + ); + const creatorMembership = this.state.memberships.find( + (candidate) => + candidate.userId === invite!.createdByUserId && + candidate.tenantId === invite!.tenantId && + membershipStatus(candidate) === "active", + ); + if ( + creator === undefined || + creatorMembership === undefined || + (creatorMembership.role !== "owner" && creatorMembership.role !== "admin") || + !roleCanAssign(creatorMembership.role, invite.role) || + !creatorMembership.scopes.includes(invite.managementScope) || + !isScopeSubset(invite.scopes, creatorMembership.scopes) || + !isScopeSubset(invite.scopes, tenantAllowedScopes(invitedTenant)) + ) { + throw lifecycleError("invite_invalid"); + } + tenant = invitedTenant; + membership = { + ...input.ownerMembership, + tenantId: tenant.id, + userId: input.user.id, + role: invite.role, + scopes: [...invite.scopes], + status: "active", + }; + } else { + tenant = { + ...input.personalTenant, + allowedScopes: [...input.personalTenant.allowedScopes ?? input.ownerMembership.scopes], + }; + membership = { + ...input.ownerMembership, + tenantId: tenant.id, + userId: input.user.id, + role: "owner", + status: "active", + }; + } + + const insertsTenant = !this.state.tenants.some((candidate) => candidate.id === tenant.id); + if ( + insertsTenant && + this.state.tenants.some((candidate) => candidate.slug === tenant.slug) + ) { + throw lifecycleError("duplicate_identifier"); + } + const initialSession = structuredClone(input.initialSession); + initialSession.family.userId = input.user.id; + initialSession.family.tenantId = tenant.id; + initialSession.family.scopes = intersectScopes( + initialSession.family.scopes, + membership.scopes, + tenantAllowedScopes(tenant), + ); + if (initialSession.family.scopes.length === 0) throw lifecycleError("invalid_scope"); + initialSession.refresh.familyId = initialSession.family.id; + + this.state.users.push(input.user); + this.state.loginIdentifiers.push(input.identifier); + this.state.credentials.push(input.credential); + if (insertsTenant) { + this.state.tenants.push(tenant); + } + this.state.memberships.push(membership); + this.state.oneTimeTokens.push(input.verification); + this.state.sessionFamilies.push(structuredClone(initialSession.family)); + this.state.refreshTokens.push(structuredClone(initialSession.refresh)); + if (invite !== undefined) { + invite.consumedAt = input.user.createdAt; + invite.consumedByUserId = input.user.id; + } + return structuredClone({ + user: input.user, + tenant, + membership, + identifier: input.identifier, + initialSession, + }); + }); + } + + async findLoginCandidate( + kind: LoginIdentifierKind, + normalizedValue: string, + ): Promise { + return this.exclusive(() => { + const identifier = this.state.loginIdentifiers.find( + (candidate) => candidate.kind === kind && candidate.normalizedValue === normalizedValue, + ); + if (identifier === undefined) return null; + const user = this.state.users.find((candidate) => candidate.id === identifier.userId); + const credential = this.state.credentials.find((candidate) => candidate.userId === identifier.userId); + if (user === undefined || credential === undefined) return null; + const memberships = this.state.memberships.filter((candidate) => candidate.userId === user.id); + const tenantIds = new Set(memberships.map((membership) => membership.tenantId)); + const tenants = this.state.tenants.filter((tenant) => tenantIds.has(tenant.id)); + return structuredClone({ user, identifier, credential, memberships, tenants }); + }); + } + + async getLoginThrottle(keyHash: string, now: Date): Promise { + return this.exclusive(() => { + const throttle = this.state.loginThrottles.find((candidate) => candidate.keyHash === keyHash); + if (throttle === undefined) return null; + if (throttle.lockedUntil !== undefined && new Date(throttle.lockedUntil) <= now) { + this.state.loginThrottles = this.state.loginThrottles.filter( + (candidate) => candidate.keyHash !== keyHash, + ); + return null; + } + return structuredClone(throttle); + }); + } + + async reserveAuthAttempt( + keys: IdentityAuthAttemptKeys, + now: Date, + policy: IdentityThrottlePolicy, + ): Promise { + return this.exclusive(() => { + if (keys.failureKeyHash === keys.admissionKeyHash) { + throw lifecycleError("invalid_configuration"); + } + const getOrCreate = (keyHash: string): IdentityLoginThrottleRecord => { + let throttle = this.state.loginThrottles.find((candidate) => candidate.keyHash === keyHash); + if (throttle !== undefined) return throttle; + throttle = { + keyHash, + failures: 0, + windowStartedAt: now.toISOString(), + tokens: policy.maxFailures, + lastRefilledAt: now.toISOString(), + inFlight: 0, + updatedAt: now.toISOString(), + }; + this.state.loginThrottles.push(throttle); + return throttle; + }; + const failureBucket = getOrCreate(keys.failureKeyHash); + const admissionBucket = getOrCreate(keys.admissionKeyHash); + if ( + failureBucket.lockedUntil !== undefined && + new Date(failureBucket.lockedUntil) > now + ) { + return false; + } + const lastRefilledAt = new Date( + failureBucket.lastRefilledAt ?? failureBucket.windowStartedAt, + ); + const elapsedSeconds = Math.max(0, (now.getTime() - lastRefilledAt.getTime()) / 1_000); + const refillRate = policy.maxFailures / policy.windowSeconds; + const tokens = Math.min( + policy.maxFailures, + (failureBucket.tokens ?? policy.maxFailures) + elapsedSeconds * refillRate, + ); + const currentInFlight = (bucket: IdentityLoginThrottleRecord): number => { + const reservationIsStale = + bucket.updatedAt !== undefined && + new Date(bucket.updatedAt).getTime() <= + now.getTime() - policy.windowSeconds * 1_000; + return reservationIsStale ? 0 : bucket.inFlight ?? 0; + }; + const failureInFlight = currentInFlight(failureBucket); + const admissionInFlight = currentInFlight(admissionBucket); + if ( + failureInFlight >= policy.maxConcurrent || + admissionInFlight >= policy.maxConcurrent || + tokens < 1 + ) { + return false; + } + failureBucket.tokens = tokens - 1; + failureBucket.lastRefilledAt = now.toISOString(); + failureBucket.inFlight = failureInFlight + 1; + failureBucket.updatedAt = now.toISOString(); + admissionBucket.inFlight = admissionInFlight + 1; + admissionBucket.updatedAt = now.toISOString(); + return true; + }); + } + + async completeAuthAttempt( + keys: IdentityAuthAttemptKeys, + now: Date, + outcome: "success" | "failure", + policy: IdentityThrottlePolicy, + ): Promise { + await this.exclusive(() => { + const failureBucket = this.state.loginThrottles.find( + (candidate) => candidate.keyHash === keys.failureKeyHash, + ); + const admissionBucket = this.state.loginThrottles.find( + (candidate) => candidate.keyHash === keys.admissionKeyHash, + ); + if (failureBucket !== undefined) { + failureBucket.inFlight = Math.max(0, (failureBucket.inFlight ?? 0) - 1); + failureBucket.updatedAt = now.toISOString(); + } + if (admissionBucket !== undefined) { + admissionBucket.inFlight = Math.max(0, (admissionBucket.inFlight ?? 0) - 1); + admissionBucket.updatedAt = now.toISOString(); + } + if (failureBucket === undefined) return; + if (outcome === "success") { + failureBucket.failures = 0; + delete failureBucket.lockedUntil; + return; + } + const windowCutoff = now.getTime() - policy.windowSeconds * 1_000; + if (new Date(failureBucket.windowStartedAt).getTime() < windowCutoff) { + failureBucket.windowStartedAt = now.toISOString(); + failureBucket.failures = 1; + } else { + failureBucket.failures += 1; + } + if (failureBucket.failures >= policy.maxFailures) { + failureBucket.lockedUntil = addSeconds(now, policy.lockSeconds).toISOString(); + } + }); + } + + async recordLoginFailure( + keyHash: string, + now: Date, + policy: { maxFailures: number; windowSeconds: number; lockSeconds: number }, + ): Promise { + await this.exclusive(() => { + const current = this.state.loginThrottles.find((candidate) => candidate.keyHash === keyHash); + const windowCutoff = now.getTime() - policy.windowSeconds * 1_000; + if (current === undefined || new Date(current.windowStartedAt).getTime() < windowCutoff) { + this.state.loginThrottles = this.state.loginThrottles.filter( + (candidate) => candidate.keyHash !== keyHash, + ); + this.state.loginThrottles.push({ + keyHash, + failures: 1, + windowStartedAt: now.toISOString(), + }); + return; + } + current.failures += 1; + if (current.failures >= policy.maxFailures) { + current.lockedUntil = new Date(now.getTime() + policy.lockSeconds * 1_000).toISOString(); + } + }); + } + + async clearLoginFailures(keyHash: string): Promise { + await this.exclusive(() => { + this.state.loginThrottles = this.state.loginThrottles.filter( + (candidate) => candidate.keyHash !== keyHash, + ); + }); + } + + async createInvite( + invite: IdentityInviteCreation, + authorization?: { + actorTokenScopes: readonly string[]; + inviteManagementScope: string; + }, + ): Promise { + await this.exclusive(() => { + const tenant = this.state.tenants.find((candidate) => candidate.id === invite.tenantId); + const actorUser = this.state.users.find( + (candidate) => candidate.id === invite.createdByUserId && candidate.status === "active", + ); + const actor = this.state.memberships.find( + (membership) => + membership.tenantId === invite.tenantId && + membership.userId === invite.createdByUserId && + membershipStatus(membership) === "active" && + (membership.role === "owner" || membership.role === "admin"), + ); + if ( + tenant === undefined || + actorUser === undefined || + actor === undefined || + authorization === undefined || + !authorization.actorTokenScopes.includes(authorization.inviteManagementScope) || + !actor.scopes.includes(authorization.inviteManagementScope) || + !roleCanAssign(actor.role, invite.role) + ) { + throw lifecycleError("forbidden"); + } + if ( + !isScopeSubset(invite.scopes, authorization.actorTokenScopes) || + !isScopeSubset(invite.scopes, actor.scopes) || + !isScopeSubset(invite.scopes, tenantAllowedScopes(tenant)) + ) { + throw lifecycleError("invalid_scope"); + } + this.state.invites.push(structuredClone({ + ...invite, + managementScope: authorization.inviteManagementScope, + })); + }); + } + + async createSession(input: CreateSessionMutation): Promise { + await this.exclusive(() => { + const user = this.state.users.find((candidate) => candidate.id === input.family.userId); + const membership = this.state.memberships.find( + (candidate) => + candidate.userId === input.family.userId && + candidate.tenantId === input.family.tenantId && + membershipStatus(candidate) === "active", + ); + const tenant = this.state.tenants.find((candidate) => candidate.id === input.family.tenantId); + if (user?.status !== "active" || membership === undefined || tenant === undefined) { + throw lifecycleError("invalid_credentials"); + } + input.family.scopes = intersectScopes( + input.family.scopes, + membership.scopes, + tenantAllowedScopes(tenant), + ); + if (input.family.scopes.length === 0) throw lifecycleError("invalid_scope"); + this.state.sessionFamilies.push(structuredClone(input.family)); + this.state.refreshTokens.push(structuredClone(input.refresh)); + }); + } + + async rotateRefreshToken(input: { + currentTokenHash: string; + replacement: IdentityRefreshTokenRecord; + now: Date; + }): Promise { + return this.exclusive(() => { + const current = this.state.refreshTokens.find( + (candidate) => candidate.tokenHash === input.currentTokenHash, + ); + if (current === undefined) return { kind: "invalid" }; + const family = this.state.sessionFamilies.find((candidate) => candidate.id === current.familyId); + if (family === undefined) return { kind: "invalid" }; + if (current.usedAt !== undefined || current.revokedAt !== undefined) { + if (family.status === "active") { + revokeFamilyState(this.state, family, "refresh_replay", input.now); + } + return { kind: "replay" }; + } + const user = this.state.users.find((candidate) => candidate.id === family.userId); + const tenant = this.state.tenants.find((candidate) => candidate.id === family.tenantId); + const membership = this.state.memberships.find( + (candidate) => + candidate.userId === family.userId && + candidate.tenantId === family.tenantId && + membershipStatus(candidate) === "active", + ); + const currentScopes = + membership === undefined || tenant === undefined + ? [] + : intersectScopes(family.scopes, membership.scopes, tenantAllowedScopes(tenant)); + if ( + family.status !== "active" || + user?.status !== "active" || + tenant === undefined || + membership === undefined || + currentScopes.length === 0 || + new Date(current.expiresAt) <= input.now || + new Date(family.expiresAt) <= input.now + ) { + if (family.status === "active") revokeFamilyState(this.state, family, "membership_incompatible", input.now); + return { kind: "invalid" }; + } + current.usedAt = input.now.toISOString(); + input.replacement.familyId = family.id; + input.replacement.generation = current.generation + 1; + if (new Date(input.replacement.expiresAt) > new Date(family.expiresAt)) { + input.replacement.expiresAt = family.expiresAt; + } + this.state.refreshTokens.push(structuredClone(input.replacement)); + family.scopes = currentScopes; + family.updatedAt = input.now.toISOString(); + return { + kind: "rotated", + family: structuredClone(family), + user: structuredClone(user), + tenant: structuredClone(tenant), + membership: structuredClone(membership), + }; + }); + } + + async revokeSessionFamily(familyId: string, reason: string, now: Date): Promise { + await this.exclusive(() => { + const family = this.state.sessionFamilies.find((candidate) => candidate.id === familyId); + if (family !== undefined) { + revokeFamilyState(this.state, family, reason, now); + revokeIssuedJtisState(this.state, (token) => token.familyId === family.id, now); + } + }); + } + + async revokeAllUserSessions(userId: string, reason: string, now: Date): Promise { + await this.exclusive(() => { + for (const family of this.state.sessionFamilies) { + if (family.userId === userId) revokeFamilyState(this.state, family, reason, now); + } + revokeIssuedJtisState(this.state, (token) => token.userId === userId, now); + }); + } + + async revokeJti(input: IdentityJtiRevocationRecord): Promise { + await this.exclusive(() => { + if (!this.state.jtiRevocations.some((candidate) => candidate.jtiHash === input.jtiHash)) { + this.state.jtiRevocations.push(structuredClone(input)); + } + }); + } + + async recordIssuedAccessToken(input: IdentityIssuedAccessTokenRecord): Promise { + await this.exclusive(() => { + const family = this.state.sessionFamilies.find( + (candidate) => + candidate.id === input.familyId && + candidate.userId === input.userId && + candidate.tenantId === input.tenantId && + candidate.status === "active", + ); + const user = this.state.users.find( + (candidate) => + candidate.id === input.userId && + candidate.status === "active", + ); + const tenant = this.state.tenants.find( + (candidate) => candidate.id === input.tenantId, + ); + const membership = this.state.memberships.find( + (candidate) => + candidate.userId === input.userId && + candidate.tenantId === input.tenantId && + membershipStatus(candidate) === "active", + ); + if ( + family === undefined || + user === undefined || + tenant === undefined || + membership === undefined + ) { + throw lifecycleError("invalid_credentials"); + } + if (!this.state.issuedAccessTokens.some((candidate) => candidate.jtiHash === input.jtiHash)) { + this.state.issuedAccessTokens.push(structuredClone(input)); + } + }); + } + + async mutateUserSecurityState(input: { + actorUserId: string; + actorTenantId: string; + actorTokenScopes: readonly string[]; + platformAuthorityScope: string; + platformAuthorityTenantSlugs: readonly string[]; + targetUserId: string; + status: IdentityUserStatus; + now: Date; + }): Promise { + return this.exclusive(() => { + const actorUser = this.state.users.find( + (candidate) => candidate.id === input.actorUserId && candidate.status === "active", + ); + const actorMembership = this.state.memberships.find( + (candidate) => + candidate.userId === input.actorUserId && + candidate.tenantId === input.actorTenantId && + membershipStatus(candidate) === "active", + ); + const actorTenant = this.state.tenants.find((tenant) => tenant.id === input.actorTenantId); + const target = this.state.users.find((candidate) => candidate.id === input.targetUserId); + if (target === undefined) return null; + const tenantIds = new Set(this.state.tenants.map((tenant) => tenant.id)); + const targetRoles = this.state.memberships + .filter( + (membership) => + membership.userId === input.targetUserId && + tenantIds.has(membership.tenantId), + ) + .map((membership) => membership.role); + const highestTargetRole = highestRole(targetRoles); + if ( + actorUser === undefined || + actorMembership === undefined || + actorTenant === undefined || + actorMembership.role !== "owner" || + !input.platformAuthorityTenantSlugs.includes(actorTenant.slug) || + !input.actorTokenScopes.includes(input.platformAuthorityScope) || + !actorMembership.scopes.includes(input.platformAuthorityScope) || + highestTargetRole === null || + !roleCanManage(actorMembership.role, highestTargetRole) + ) { + throw lifecycleError("forbidden"); + } + target.status = input.status; + target.updatedAt = input.now.toISOString(); + if (input.status === "disabled") target.disabledAt = input.now.toISOString(); + if (input.status === "deleted") target.deletedAt = input.now.toISOString(); + if (input.status === "active") { + delete target.disabledAt; + delete target.deletedAt; + } + for (const family of this.state.sessionFamilies) { + if (family.userId === target.id) revokeFamilyState(this.state, family, `user_${input.status}`, input.now); + } + revokeIssuedJtisState(this.state, (token) => token.userId === target.id, input.now); + return structuredClone(target); + }); + } + + async suspendMembership(input: { + actorUserId: string; + tenantId: string; + targetUserId: string; + now: Date; + }): Promise { + return this.exclusive(() => { + const actorUser = this.state.users.find( + (candidate) => candidate.id === input.actorUserId && candidate.status === "active", + ); + const tenant = this.state.tenants.find( + (candidate) => candidate.id === input.tenantId, + ); + const actor = this.state.memberships.find( + (candidate) => + candidate.userId === input.actorUserId && + candidate.tenantId === input.tenantId && + membershipStatus(candidate) === "active", + ); + const targetUser = this.state.users.find( + (candidate) => candidate.id === input.targetUserId, + ); + const target = this.state.memberships.find( + (candidate) => candidate.userId === input.targetUserId && candidate.tenantId === input.tenantId, + ); + if (tenant === undefined || targetUser === undefined || target === undefined) return null; + if ( + actorUser === undefined || + actor === undefined || + !roleCanManage(actor.role, target.role) + ) { + throw lifecycleError("forbidden"); + } + target.status = "suspended"; + for (const family of this.state.sessionFamilies) { + if (family.userId === input.targetUserId && family.tenantId === input.tenantId) { + revokeFamilyState(this.state, family, "membership_suspended", input.now); + } + } + const targetFamilyIds = new Set( + this.state.sessionFamilies + .filter((family) => family.userId === input.targetUserId && family.tenantId === input.tenantId) + .map((family) => family.id), + ); + revokeIssuedJtisState(this.state, (token) => targetFamilyIds.has(token.familyId), input.now); + return structuredClone(target); + }); + } + + async canAdminister(actorUserId: string, tenantId: string, targetUserId: string): Promise { + return this.exclusive(() => { + const actorUser = this.state.users.find( + (user) => user.id === actorUserId && user.status === "active", + ); + const tenant = this.state.tenants.find((candidate) => candidate.id === tenantId); + const actor = this.state.memberships.find( + (membership) => + membership.userId === actorUserId && + membership.tenantId === tenantId && + membershipStatus(membership) === "active" && + (membership.role === "owner" || membership.role === "admin"), + ); + const target = this.state.memberships.find( + (membership) => + membership.userId === targetUserId && + membership.tenantId === tenantId && + membershipStatus(membership) === "active", + ); + const targetUser = this.state.users.find((user) => user.id === targetUserId); + return ( + actorUser !== undefined && + tenant !== undefined && + actor !== undefined && + target !== undefined && + targetUser !== undefined && + roleCanManage(actor.role, target.role) + ); + }); + } + + async setUserStatus( + userId: string, + status: IdentityUserStatus, + now: Date, + ): Promise { + return this.exclusive(() => { + const user = this.state.users.find((candidate) => candidate.id === userId); + if (user === undefined) return null; + user.status = status; + user.updatedAt = now.toISOString(); + if (status === "disabled") user.disabledAt = now.toISOString(); + if (status === "deleted") user.deletedAt = now.toISOString(); + if (status === "active") { + delete user.disabledAt; + delete user.deletedAt; + } else { + for (const family of this.state.sessionFamilies) { + if (family.userId === user.id) revokeFamilyState(this.state, family, `user_${status}`, now); + } + revokeIssuedJtisState(this.state, (token) => token.userId === user.id, now); + } + return structuredClone(user); + }); + } + + async createOneTimeToken(token: IdentityOneTimeTokenRecord): Promise { + await this.exclusive(() => { + for (const existing of this.state.oneTimeTokens) { + if ( + existing.userId === token.userId && + existing.kind === token.kind && + existing.consumedAt === undefined + ) { + existing.consumedAt = token.createdAt; + } + } + this.state.oneTimeTokens.push(structuredClone(token)); + }); + } + + async consumeVerification(tokenHash: string, now: Date): Promise { + return this.exclusive(() => { + const token = this.state.oneTimeTokens.find( + (candidate) => candidate.kind === "verification" && candidate.tokenHash === tokenHash, + ); + if ( + token === undefined || + token.consumedAt !== undefined || + new Date(token.expiresAt) <= now || + token.identifierId === undefined + ) { + return false; + } + const identifier = this.state.loginIdentifiers.find( + (candidate) => candidate.id === token.identifierId && candidate.userId === token.userId, + ); + if (identifier === undefined) return false; + token.consumedAt = now.toISOString(); + identifier.verifiedAt = now.toISOString(); + return true; + }); + } + + async completeRecovery(input: { + tokenHash: string; + passwordHash: string; + algorithm: string; + now: Date; + }): Promise { + return this.exclusive(() => { + const token = this.state.oneTimeTokens.find( + (candidate) => candidate.kind === "recovery" && candidate.tokenHash === input.tokenHash, + ); + if (token === undefined || token.consumedAt !== undefined || new Date(token.expiresAt) <= input.now) { + return null; + } + const user = this.state.users.find( + (candidate) => candidate.id === token.userId && candidate.status === "active", + ); + const credential = this.state.credentials.find((candidate) => candidate.userId === token.userId); + if (user === undefined || credential === undefined) return null; + token.consumedAt = input.now.toISOString(); + credential.passwordHash = input.passwordHash; + credential.algorithm = input.algorithm; + credential.updatedAt = input.now.toISOString(); + for (const family of this.state.sessionFamilies) { + if (family.userId === user.id) revokeFamilyState(this.state, family, "password_recovery", input.now); + } + revokeIssuedJtisState(this.state, (issued) => issued.userId === user.id, input.now); + return user.id; + }); + } + + async isJtiRevoked(jtiSha256: string): Promise { + const hash = requireSha256(jtiSha256); + return this.exclusive(() => this.state.jtiRevocations.some((candidate) => candidate.jtiHash === hash)); + } + + async getSessionFamilyStatus(sessionSha256: string): Promise { + const hash = requireSha256(sessionSha256); + return this.exclusive(() => { + const family = this.state.sessionFamilies.find( + (candidate) => hashOpaqueClaim(candidate.id) === hash, + ); + if (family === undefined) return "unknown"; + const user = this.state.users.find((candidate) => candidate.id === family.userId); + const membership = this.state.memberships.find( + (candidate) => candidate.userId === family.userId && candidate.tenantId === family.tenantId, + ); + const tenant = this.state.tenants.find((candidate) => candidate.id === family.tenantId); + if (user?.status === "deleted") return "deleted"; + if ( + user?.status !== "active" || + membership === undefined || + membershipStatus(membership) !== "active" || + tenant === undefined || + !isScopeSubset(family.scopes, membership.scopes) || + !isScopeSubset(family.scopes, tenantAllowedScopes(tenant)) + ) { + return "disabled"; + } + if (family.status === "active") return "active"; + if (family.status === "disabled") return "disabled"; + return "deleted"; + }); + } +} + +export interface IdentityLifecycleServiceOptions { + store: IdentityLifecycleStore; + registrationPolicy: RegistrationPolicy; + bootstrapTenant: { slug: string; name: string }; + tokenIssuer: IdentityAccessTokenIssuer; + tokenVerifier: IdentityAccessTokenVerifier; + passwordHasher?: IdentityPasswordHasher; + defaultScopes: readonly string[]; + now?: () => Date; + hooks?: IdentityLifecycleHooks; + refreshTokenTtlSeconds?: number; + inviteTtlSeconds?: number; + verificationTtlSeconds?: number; + recoveryTtlSeconds?: number; + recoveryMinimumResponseMs?: number; + inviteManagementScope?: string; + platformAuthorityScope?: string; + platformAuthorityTenantSlugs?: readonly string[]; + loginThrottle?: { + maxFailures: number; + windowSeconds: number; + lockSeconds: number; + maxConcurrent?: number; + }; +} + +export class IdentityLifecycleService { + private readonly store: IdentityLifecycleStore; + private readonly registrationPolicy: RegistrationPolicy; + private readonly bootstrapTenant: { slug: string; name: string }; + private readonly tokenIssuer: IdentityAccessTokenIssuer; + private readonly tokenVerifier: IdentityAccessTokenVerifier; + private readonly passwordHasher: IdentityPasswordHasher; + private readonly defaultScopes: string[]; + private readonly now: () => Date; + private readonly hooks: IdentityLifecycleHooks; + private readonly refreshTokenTtlSeconds: number; + private readonly inviteTtlSeconds: number; + private readonly verificationTtlSeconds: number; + private readonly recoveryTtlSeconds: number; + private readonly recoveryMinimumResponseMs: number; + private readonly inviteManagementScope: string; + private readonly platformAuthorityScope: string; + private readonly platformAuthorityTenantSlugs: string[]; + private readonly loginThrottle: IdentityThrottlePolicy; + private readonly readyPromise: Promise; + + constructor(options: IdentityLifecycleServiceOptions) { + this.store = options.store; + this.registrationPolicy = normalizeRegistrationPolicy(options.registrationPolicy); + this.bootstrapTenant = { + slug: normalizeTenantSlug(options.bootstrapTenant.slug), + name: requiredText(options.bootstrapTenant.name, "bootstrap tenant name", 160), + }; + this.tokenIssuer = options.tokenIssuer; + this.tokenVerifier = options.tokenVerifier; + if (!options.tokenVerifier.isBoundToJwksRegistry(options.tokenIssuer.registry)) { + throw lifecycleError("invalid_configuration"); + } + this.passwordHasher = options.passwordHasher ?? new Argon2idIdentityPasswordHasher(); + this.defaultScopes = normalizeScopes(options.defaultScopes); + if (this.defaultScopes.length === 0) { + throw new IdentityLifecycleError( + "invalid_configuration", + "default scopes cannot be empty", + 500, + ); + } + this.now = options.now ?? (() => new Date()); + this.hooks = options.hooks ?? {}; + this.refreshTokenTtlSeconds = boundedInteger( + options.refreshTokenTtlSeconds ?? 2_592_000, + "refreshTokenTtlSeconds", + 300, + 31_536_000, + ); + this.inviteTtlSeconds = boundedInteger(options.inviteTtlSeconds ?? 86_400, "inviteTtlSeconds", 60, 2_592_000); + this.verificationTtlSeconds = boundedInteger( + options.verificationTtlSeconds ?? 86_400, + "verificationTtlSeconds", + 60, + 604_800, + ); + this.recoveryTtlSeconds = boundedInteger( + options.recoveryTtlSeconds ?? 1_800, + "recoveryTtlSeconds", + 60, + 86_400, + ); + this.recoveryMinimumResponseMs = boundedInteger( + options.recoveryMinimumResponseMs ?? 250, + "recoveryMinimumResponseMs", + 100, + 5_000, + ); + this.inviteManagementScope = requiredText( + options.inviteManagementScope ?? DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE, + "inviteManagementScope", + 160, + ); + this.platformAuthorityScope = requiredText( + options.platformAuthorityScope ?? DEFAULT_IDENTITY_PLATFORM_ADMIN_SCOPE, + "platformAuthorityScope", + 160, + ); + this.platformAuthorityTenantSlugs = [ + ...new Set( + (options.platformAuthorityTenantSlugs ?? [this.bootstrapTenant.slug]).map((slug) => + normalizeTenantSlug(slug), + ), + ), + ].sort(); + if (this.platformAuthorityTenantSlugs.length === 0) { + throw lifecycleError("invalid_configuration"); + } + this.loginThrottle = { + maxFailures: boundedInteger(options.loginThrottle?.maxFailures ?? 8, "maxFailures", 2, 100), + windowSeconds: boundedInteger(options.loginThrottle?.windowSeconds ?? 900, "windowSeconds", 60, 86_400), + lockSeconds: boundedInteger(options.loginThrottle?.lockSeconds ?? 900, "lockSeconds", 60, 86_400), + maxConcurrent: boundedInteger(options.loginThrottle?.maxConcurrent ?? 2, "maxConcurrent", 1, 32), + }; + this.readyPromise = Promise.all([ + this.passwordHasher.dummyHash(), + this.store.prepare?.() ?? Promise.resolve({ entries: [], collisions: [] }), + ]).then(() => undefined); + void this.readyPromise.catch(() => undefined); + } + + async ready(): Promise { + await this.readyPromise; + } + + bootstrapFirstAdmin(input: { + identifier: LoginIdentifierInput; + password: string; + displayName: string; + }): Promise { + return this.register(input, true); + } + + signup(input: { + identifier: LoginIdentifierInput; + password: string; + displayName: string; + inviteToken?: string; + }): Promise { + return this.register(input, false); + } + + private async register( + input: { + identifier: LoginIdentifierInput; + password: string; + displayName: string; + inviteToken?: string; + }, + bootstrapOnly: boolean, + ): Promise { + await this.ready(); + if (!bootstrapOnly && this.registrationPolicy === "disabled") { + throw lifecycleError("registration_disabled"); + } + const now = this.now(); + const normalized = normalizeLoginIdentifier(input.identifier); + validatePassword(input.password); + const passwordHash = await this.passwordHasher.hash(input.password); + const userId = `usr_${randomUUID()}`; + const verificationToken = randomToken(); + const user: IdentityUserRecord = { + id: userId, + status: "active", + displayName: requiredText(input.displayName, "displayName", 160), + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + const identifier: IdentityLoginIdentifierRecord = { + id: `lid_${randomUUID()}`, + userId, + kind: normalized.kind, + normalizedValue: normalized.value, + createdAt: now.toISOString(), + }; + const credential: IdentityPasswordCredentialRecord = { + id: `pwd_${randomUUID()}`, + userId, + passwordHash, + algorithm: this.passwordHasher.algorithm, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + const personalTenant: IdentityTenantRecord = { + id: `ten_${randomUUID()}`, + slug: `personal-${userId.slice(4, 16).toLowerCase()}`, + name: `${user.displayName}'s workspace`, + allowedScopes: [...this.defaultScopes], + createdAt: now.toISOString(), + }; + const ownerMembership: IdentityMembershipRecord = { + id: `mem_${randomUUID()}`, + tenantId: personalTenant.id, + userId, + role: "owner", + scopes: [...this.defaultScopes], + status: "active", + createdAt: now.toISOString(), + }; + const verification: IdentityOneTimeTokenRecord = { + id: `ott_${randomUUID()}`, + userId, + identifierId: identifier.id, + kind: "verification", + tokenHash: hashSecret(verificationToken), + expiresAt: addSeconds(now, this.verificationTtlSeconds).toISOString(), + createdAt: now.toISOString(), + }; + const initialSession = this.prepareSessionMutation( + userId, + personalTenant.id, + this.defaultScopes, + now, + ); + const result = await this.store.register({ + bootstrapOnly, + policy: this.registrationPolicy, + bootstrapTenant: this.bootstrapTenant, + user, + identifier, + credential, + inviteTokenHash: input.inviteToken === undefined ? undefined : hashSecret(input.inviteToken), + personalTenant, + ownerMembership, + verification, + initialSession: initialSession.mutation, + }); + if (this.hooks.deliverVerification !== undefined) { + await this.hooks.deliverVerification({ + userId, + identifier: { kind: normalized.kind, value: normalized.value }, + token: verificationToken, + expiresAt: verification.expiresAt, + }); + } + return this.issueSession( + result, + result.initialSession, + initialSession.refreshToken, + now, + ); + } + + async login(input: { + identifier: LoginIdentifierInput; + password: string; + tenantId?: string; + scopes?: readonly string[]; + throttleKey?: string; + }): Promise { + await this.ready(); + const now = this.now(); + const normalized = normalizeLoginIdentifier(input.identifier); + const throttleClient = requiredText(input.throttleKey ?? "default", "throttleKey", 512); + const attemptKeys: IdentityAuthAttemptKeys = { + failureKeyHash: hashSecret(JSON.stringify([ + "identity-login-failure-v1", + normalized.kind, + normalized.value, + throttleClient, + ])), + admissionKeyHash: hashSecret(JSON.stringify([ + "identity-auth-client-admission-v1", + throttleClient, + ])), + }; + if ( + this.store.reserveAuthAttempt === undefined || + this.store.completeAuthAttempt === undefined + ) { + throw lifecycleError("invalid_configuration"); + } + if (!(await this.store.reserveAuthAttempt(attemptKeys, now, this.loginThrottle))) { + throw lifecycleError("rate_limited"); + } + let outcome: "success" | "failure" = "failure"; + try { + const candidate = await this.store.findLoginCandidate(normalized.kind, normalized.value); + const encoded = candidate?.credential.passwordHash ?? await this.passwordHasher.dummyHash(); + const passwordValid = await this.passwordHasher.verify(input.password, encoded); + if (candidate === null || !passwordValid || candidate.user.status !== "active") { + throw lifecycleError("invalid_credentials"); + } + + const membership = selectMembership(candidate, input.tenantId); + if (membership === null) throw lifecycleError("invalid_credentials"); + const tenant = candidate.tenants.find((item) => item.id === membership.tenantId); + if (tenant === undefined) throw lifecycleError("invalid_credentials"); + const scopes = input.scopes === undefined ? [...membership.scopes] : normalizeScopes(input.scopes); + const allowed = new Set(membership.scopes); + if (scopes.length === 0 || scopes.some((scope) => !allowed.has(scope))) { + throw lifecycleError("invalid_scope"); + } + const session = await this.createSession({ + user: candidate.user, + tenant, + membership, + identifier: candidate.identifier, + }, scopes); + outcome = "success"; + return session; + } finally { + await this.store.completeAuthAttempt(attemptKeys, this.now(), outcome, this.loginThrottle); + } + } + + async createInvite(input: { + actorAccessToken: string; + tenantId: string; + identifier?: LoginIdentifierInput; + role: IdentityMembershipRole; + scopes: readonly string[]; + expiresInSeconds?: number; + }): Promise<{ id: string; token: string; expiresAt: string }> { + const actor = await this.verifyAccessToken(input.actorAccessToken, { + tenant: input.tenantId, + }); + const token = randomToken(); + const now = this.now(); + const normalized = input.identifier === undefined ? undefined : normalizeLoginIdentifier(input.identifier); + const expiresInSeconds = boundedInteger( + input.expiresInSeconds ?? this.inviteTtlSeconds, + "expiresInSeconds", + 60, + 2_592_000, + ); + const invite: IdentityInviteCreation = { + id: `inv_${randomUUID()}`, + tenantId: input.tenantId, + tokenHash: hashSecret(token), + identifierKind: normalized?.kind, + normalizedIdentifier: normalized?.value, + role: normalizeRole(input.role), + scopes: normalizeScopes(input.scopes), + expiresAt: addSeconds(now, expiresInSeconds).toISOString(), + createdByUserId: actor.sub, + createdAt: now.toISOString(), + }; + if (invite.scopes.length === 0) throw lifecycleError("invalid_scope"); + await this.store.createInvite(invite, { + actorTokenScopes: actor.scopes, + inviteManagementScope: this.inviteManagementScope, + }); + return { id: invite.id, token, expiresAt: invite.expiresAt }; + } + + async refresh(input: { refreshToken: string }): Promise { + const currentTokenHash = hashSecret(requireOpaqueToken(input.refreshToken, "refresh token")); + const now = this.now(); + const replacementToken = randomToken(); + const replacement: IdentityRefreshTokenRecord = { + id: `rft_${randomUUID()}`, + familyId: "pending", + tokenHash: hashSecret(replacementToken), + generation: 0, + expiresAt: addSeconds(now, this.refreshTokenTtlSeconds).toISOString(), + createdAt: now.toISOString(), + }; + const rotated = await this.store.rotateRefreshToken({ + currentTokenHash, + replacement, + now, + }); + if (rotated.kind === "replay") throw lifecycleError("refresh_replay"); + if (rotated.kind === "invalid") throw lifecycleError("refresh_invalid"); + const issue = await this.tokenIssuer.issue({ + subject: rotated.user.id, + tenant: rotated.tenant.id, + session: rotated.family.id, + scopes: rotated.family.scopes, + now, + }); + await this.recordIssuedAccessToken({ + jtiHash: hashOpaqueClaim(issue.jti), + familyId: rotated.family.id, + userId: rotated.user.id, + tenantId: rotated.tenant.id, + expiresAt: new Date(issue.expiresAt * 1_000).toISOString(), + issuedAt: new Date(issue.issuedAt * 1_000).toISOString(), + }); + return { + schemaVersion: IDENTITY_USER_LIFECYCLE_SCHEMA_VERSION, + user: rotated.user, + tenant: rotated.tenant, + membership: { ...rotated.membership, scopes: [...rotated.family.scopes] }, + scopes: [...rotated.family.scopes], + accessToken: issue.token, + accessTokenExpiresAt: new Date(issue.expiresAt * 1_000).toISOString(), + refreshToken: replacementToken, + refreshTokenExpiresAt: replacement.expiresAt, + }; + } + + async logout(input: { accessToken: string; refreshToken?: string }): Promise<{ loggedOut: true }> { + const claims = await this.verifyAccessToken(input.accessToken); + const now = this.now(); + await this.store.revokeJti({ + jtiHash: hashOpaqueClaim(claims.jti), + userId: claims.sub, + expiresAt: new Date(claims.exp * 1_000).toISOString(), + revokedAt: now.toISOString(), + }); + await this.store.revokeSessionFamily(claims.session, "logout", now); + return { loggedOut: true }; + } + + async logoutAll(input: { accessToken: string }): Promise<{ loggedOutAll: true }> { + const claims = await this.verifyAccessToken(input.accessToken); + await this.store.revokeAllUserSessions(claims.sub, "logout_all", this.now()); + return { loggedOutAll: true }; + } + + async disableUser(input: { + actorAccessToken: string; + userId: string; + }): Promise { + const actor = await this.verifyAccessToken(input.actorAccessToken); + if (this.store.mutateUserSecurityState === undefined) throw lifecycleError("invalid_configuration"); + const now = this.now(); + const user = await this.store.mutateUserSecurityState({ + actorUserId: actor.sub, + actorTenantId: actor.tenant, + actorTokenScopes: actor.scopes, + platformAuthorityScope: this.platformAuthorityScope, + platformAuthorityTenantSlugs: this.platformAuthorityTenantSlugs, + targetUserId: requiredText(input.userId, "userId"), + status: "disabled", + now, + }); + if (user === null) throw lifecycleError("not_found"); + await this.hooks.userDisabled?.({ userId: user.id, at: now.toISOString() }); + return user; + } + + async softDeleteUser(input: { + actorAccessToken: string; + userId: string; + }): Promise { + const actor = await this.verifyAccessToken(input.actorAccessToken); + if (this.store.mutateUserSecurityState === undefined) throw lifecycleError("invalid_configuration"); + const now = this.now(); + const user = await this.store.mutateUserSecurityState({ + actorUserId: actor.sub, + actorTenantId: actor.tenant, + actorTokenScopes: actor.scopes, + platformAuthorityScope: this.platformAuthorityScope, + platformAuthorityTenantSlugs: this.platformAuthorityTenantSlugs, + targetUserId: requiredText(input.userId, "userId"), + status: "deleted", + now, + }); + if (user === null) throw lifecycleError("not_found"); + await this.hooks.userDeleted?.({ userId: user.id, at: now.toISOString() }); + return user; + } + + async restoreUser(input: { userId: string; actorAccessToken?: string }): Promise { + if (input.actorAccessToken === undefined) throw lifecycleError("forbidden"); + const actor = await this.verifyAccessToken(input.actorAccessToken); + if (this.store.mutateUserSecurityState === undefined) throw lifecycleError("invalid_configuration"); + const now = this.now(); + const user = await this.store.mutateUserSecurityState({ + actorUserId: actor.sub, + actorTenantId: actor.tenant, + actorTokenScopes: actor.scopes, + platformAuthorityScope: this.platformAuthorityScope, + platformAuthorityTenantSlugs: this.platformAuthorityTenantSlugs, + targetUserId: requiredText(input.userId, "userId"), + status: "active", + now, + }); + if (user === null) throw lifecycleError("not_found"); + await this.hooks.userRestored?.({ userId: user.id, at: now.toISOString() }); + return user; + } + + async suspendMembership(input: { + actorAccessToken: string; + tenantId: string; + userId: string; + }): Promise { + const actor = await this.verifyAccessToken(input.actorAccessToken, { tenant: input.tenantId }); + if (this.store.suspendMembership === undefined) throw lifecycleError("invalid_configuration"); + const membership = await this.store.suspendMembership({ + actorUserId: actor.sub, + tenantId: requiredText(input.tenantId, "tenantId"), + targetUserId: requiredText(input.userId, "userId"), + now: this.now(), + }); + if (membership === null) throw lifecycleError("not_found"); + return membership; + } + + async verifyIdentifier(input: { token: string }): Promise<{ verified: true }> { + const verified = await this.store.consumeVerification( + hashSecret(requireOpaqueToken(input.token, "verification token")), + this.now(), + ); + if (!verified) throw lifecycleError("verification_invalid"); + return { verified: true }; + } + + async beginRecovery(input: { + identifier: LoginIdentifierInput; + throttleKey?: string; + }): Promise<{ accepted: true }> { + await this.ready(); + const normalized = normalizeLoginIdentifier(input.identifier); + const now = this.now(); + const throttleClient = requiredText(input.throttleKey ?? "default", "throttleKey", 512); + const responseStartedAt = performance.now(); + const attemptKeys: IdentityAuthAttemptKeys = { + failureKeyHash: hashSecret(JSON.stringify([ + "identity-recovery-failure-v1", + normalized.kind, + normalized.value, + throttleClient, + ])), + admissionKeyHash: hashSecret(JSON.stringify([ + "identity-auth-client-admission-v1", + throttleClient, + ])), + }; + if ( + this.store.reserveAuthAttempt === undefined || + this.store.completeAuthAttempt === undefined + ) { + throw lifecycleError("invalid_configuration"); + } + let attemptReserved = false; + let recoveryDelivery: (() => void) | undefined; + try { + attemptReserved = await this.store.reserveAuthAttempt(attemptKeys, now, this.loginThrottle); + if (!attemptReserved) return { accepted: true }; + const dummyHash = await this.passwordHasher.dummyHash(); + const [candidate] = await Promise.all([ + this.store.findLoginCandidate(normalized.kind, normalized.value), + this.passwordHasher.verify(randomToken(), dummyHash), + ]); + if (candidate === null || candidate.user.status !== "active") return { accepted: true }; + const token = randomToken(); + const record: IdentityOneTimeTokenRecord = { + id: `ott_${randomUUID()}`, + userId: candidate.user.id, + identifierId: candidate.identifier.id, + kind: "recovery", + tokenHash: hashSecret(token), + expiresAt: addSeconds(now, this.recoveryTtlSeconds).toISOString(), + createdAt: now.toISOString(), + }; + await this.store.createOneTimeToken(record); + if (this.hooks.deliverRecovery !== undefined) { + recoveryDelivery = () => { + void Promise.resolve(this.hooks.deliverRecovery?.({ + userId: candidate.user.id, + identifier: { kind: normalized.kind, value: normalized.value }, + token, + expiresAt: record.expiresAt, + })).catch(() => undefined); + }; + } + return { accepted: true }; + } finally { + try { + if (attemptReserved) { + await this.store.completeAuthAttempt(attemptKeys, this.now(), "failure", this.loginThrottle); + } + } finally { + await waitForMinimumDuration(responseStartedAt, this.recoveryMinimumResponseMs); + if (recoveryDelivery !== undefined) setTimeout(recoveryDelivery, 0); + } + } + } + + async completeRecovery(input: { + token: string; + newPassword: string; + }): Promise<{ recovered: true }> { + validatePassword(input.newPassword); + const passwordHash = await this.passwordHasher.hash(input.newPassword); + const userId = await this.store.completeRecovery({ + tokenHash: hashSecret(requireOpaqueToken(input.token, "recovery token")), + passwordHash, + algorithm: this.passwordHasher.algorithm, + now: this.now(), + }); + if (userId === null) throw lifecycleError("recovery_invalid"); + return { recovered: true }; + } + + private async createSession( + registration: SessionRegistrationContext, + scopes: readonly string[] = registration.membership.scopes, + ): Promise { + const now = this.now(); + const prepared = this.prepareSessionMutation( + registration.user.id, + registration.tenant.id, + scopes, + now, + ); + await this.store.createSession(prepared.mutation); + return this.issueSession( + registration, + prepared.mutation, + prepared.refreshToken, + now, + ); + } + + private prepareSessionMutation( + userId: string, + tenantId: string, + scopes: readonly string[], + now: Date, + ): { mutation: CreateSessionMutation; refreshToken: string } { + const family: IdentitySessionFamilyRecord = { + id: `ses_${randomUUID()}`, + userId, + tenantId, + scopes: [...scopes], + status: "active", + expiresAt: addSeconds(now, this.refreshTokenTtlSeconds).toISOString(), + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + const refreshToken = randomToken(); + const refresh: IdentityRefreshTokenRecord = { + id: `rft_${randomUUID()}`, + familyId: family.id, + tokenHash: hashSecret(refreshToken), + generation: 0, + expiresAt: family.expiresAt, + createdAt: now.toISOString(), + }; + return { + mutation: { family, refresh }, + refreshToken, + }; + } + + private async issueSession( + registration: SessionRegistrationContext, + session: CreateSessionMutation, + refreshToken: string, + now: Date, + ): Promise { + const { family, refresh } = session; + const issue = await this.tokenIssuer.issue({ + subject: registration.user.id, + tenant: registration.tenant.id, + session: family.id, + scopes: family.scopes, + now, + }); + await this.recordIssuedAccessToken({ + jtiHash: hashOpaqueClaim(issue.jti), + familyId: family.id, + userId: registration.user.id, + tenantId: registration.tenant.id, + expiresAt: new Date(issue.expiresAt * 1_000).toISOString(), + issuedAt: new Date(issue.issuedAt * 1_000).toISOString(), + }); + return { + schemaVersion: IDENTITY_USER_LIFECYCLE_SCHEMA_VERSION, + user: registration.user, + tenant: registration.tenant, + membership: { ...registration.membership, scopes: [...family.scopes] }, + scopes: [...family.scopes], + accessToken: issue.token, + accessTokenExpiresAt: new Date(issue.expiresAt * 1_000).toISOString(), + refreshToken, + refreshTokenExpiresAt: refresh.expiresAt, + }; + } + + private async verifyAccessToken( + token: string, + requirements: { tenant?: string; scopes?: readonly string[] } = {}, + ): Promise { + try { + return await this.tokenVerifier.verify(requireOpaqueToken(token, "access token"), requirements); + } catch (error) { + if (error instanceof IdentityAuthError) { + throw new IdentityLifecycleError("forbidden", "access denied", error.status === 500 ? 500 : error.status); + } + throw lifecycleError("forbidden"); + } + } + + private async recordIssuedAccessToken(input: IdentityIssuedAccessTokenRecord): Promise { + if (this.store.recordIssuedAccessToken === undefined) throw lifecycleError("invalid_configuration"); + await this.store.recordIssuedAccessToken(input); + } +} + +export const loginIdentifierSchema = z.object({ + kind: z.enum(["email", "username"]), + value: z.string().min(1).max(320), +}).strict(); + +export const identitySignupSchema = z.object({ + identifier: loginIdentifierSchema, + password: z.string().min(12).max(1_024), + displayName: z.string().min(1).max(160), + inviteToken: z.string().min(32).max(512).optional(), +}).strict(); + +export const identityLoginSchema = z.object({ + identifier: loginIdentifierSchema, + password: z.string().min(1).max(1_024), + tenantId: z.string().min(1).max(160).optional(), + scopes: z.array(z.string().min(1).max(160)).max(100).optional(), +}).strict(); + +export const identityRefreshSchema = z.object({ + refreshToken: z.string().min(32).max(512), +}).strict(); + +export const identityRecoveryStartSchema = z.object({ + identifier: loginIdentifierSchema, +}).strict(); + +export const identityRecoveryCompleteSchema = z.object({ + token: z.string().min(32).max(512), + newPassword: z.string().min(12).max(1_024), +}).strict(); + +export const identityVerificationSchema = z.object({ + token: z.string().min(32).max(512), +}).strict(); + +export interface IdentityLifecycleApi { + handle(request: Request): Promise; +} + +export function createIdentityLifecycleApi(options: { + service: IdentityLifecycleService; + throttleKey?: (request: Request) => string | undefined; +}): IdentityLifecycleApi { + return { + async handle(request: Request): Promise { + const url = new URL(request.url); + try { + if (request.method === "POST" && url.pathname === "/v1/auth/signup") { + return lifecycleJson(await options.service.signup(identitySignupSchema.parse(await readJson(request))), 201); + } + if (request.method === "POST" && url.pathname === "/v1/auth/login") { + const input = identityLoginSchema.parse(await readJson(request)); + return lifecycleJson(await options.service.login({ + ...input, + throttleKey: options.throttleKey?.(request), + })); + } + if (request.method === "POST" && url.pathname === "/v1/auth/refresh") { + return lifecycleJson(await options.service.refresh(identityRefreshSchema.parse(await readJson(request)))); + } + if (request.method === "POST" && url.pathname === "/v1/auth/logout") { + return lifecycleJson(await options.service.logout({ + accessToken: bearerAccessToken(request), + })); + } + if (request.method === "POST" && url.pathname === "/v1/auth/logout-all") { + return lifecycleJson(await options.service.logoutAll({ + accessToken: bearerAccessToken(request), + })); + } + if (request.method === "POST" && url.pathname === "/v1/auth/verification/complete") { + return lifecycleJson( + await options.service.verifyIdentifier(identityVerificationSchema.parse(await readJson(request))), + ); + } + if (request.method === "POST" && url.pathname === "/v1/auth/recovery/start") { + const input = identityRecoveryStartSchema.parse(await readJson(request)); + return lifecycleJson( + await options.service.beginRecovery({ + ...input, + throttleKey: options.throttleKey?.(request), + }), + 202, + ); + } + if (request.method === "POST" && url.pathname === "/v1/auth/recovery/complete") { + return lifecycleJson( + await options.service.completeRecovery(identityRecoveryCompleteSchema.parse(await readJson(request))), + ); + } + return lifecycleJson({ error: "not_found" }, 404); + } catch (error) { + if (error instanceof IdentityLifecycleError) { + return lifecycleJson({ + error: publicLifecycleError(error), + reason: error.reason, + }, error.status); + } + if (error instanceof z.ZodError || error instanceof SyntaxError) { + return lifecycleJson({ error: "invalid_request", reason: "invalid_request" }, 400); + } + return lifecycleJson({ error: "internal_error", reason: "invalid_configuration" }, 500); + } + }, + }; +} + +export interface IdentityLifecycleMigration { + id: string; + up: string; + down: string; + checksum: string; +} + +export function identityLifecycleMigrations(): IdentityLifecycleMigration[] { + const definitions = [ + { + id: "identities_0004_user_tenancy", + up: ` + CREATE TABLE IF NOT EXISTS identity_users ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK (status IN ('active','disabled','deleted')), + display_name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + disabled_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ + ); + CREATE TABLE IF NOT EXISTS identity_tenants ( + id TEXT PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL + ); + CREATE TABLE IF NOT EXISTS identity_memberships ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES identity_tenants(id), + user_id TEXT NOT NULL REFERENCES identity_users(id), + role TEXT NOT NULL CHECK (role IN ('owner','admin','member')), + scopes JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE (tenant_id, user_id) + )`, + down: ` + DROP TABLE IF EXISTS identity_memberships; + DROP TABLE IF EXISTS identity_tenants; + DROP TABLE IF EXISTS identity_users`, + }, + { + id: "identities_0005_user_credentials", + up: ` + CREATE TABLE IF NOT EXISTS identity_login_identifiers ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES identity_users(id), + kind TEXT NOT NULL CHECK (kind IN ('email','username')), + normalized_value TEXT NOT NULL, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + UNIQUE (kind, normalized_value) + ); + CREATE TABLE IF NOT EXISTS identity_password_credentials ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL UNIQUE REFERENCES identity_users(id), + password_hash TEXT NOT NULL, + algorithm TEXT NOT NULL CHECK (algorithm = 'argon2id'), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + ); + CREATE TABLE IF NOT EXISTS identity_invites ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES identity_tenants(id), + token_hash TEXT NOT NULL UNIQUE, + identifier_kind TEXT, + normalized_identifier TEXT, + role TEXT NOT NULL CHECK (role IN ('owner','admin','member')), + scopes JSONB NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + consumed_by_user_id TEXT REFERENCES identity_users(id), + created_by_user_id TEXT NOT NULL REFERENCES identity_users(id), + created_at TIMESTAMPTZ NOT NULL + )`, + down: ` + DROP TABLE IF EXISTS identity_invites; + DROP TABLE IF EXISTS identity_password_credentials; + DROP TABLE IF EXISTS identity_login_identifiers`, + }, + { + id: "identities_0006_user_sessions", + up: ` + CREATE TABLE IF NOT EXISTS identity_session_families ( + id TEXT PRIMARY KEY, + session_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES identity_users(id), + tenant_id TEXT NOT NULL REFERENCES identity_tenants(id), + scopes JSONB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active','revoked','disabled','deleted')), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + revoke_reason TEXT + ); + CREATE TABLE IF NOT EXISTS identity_refresh_tokens ( + id TEXT PRIMARY KEY, + family_id TEXT NOT NULL REFERENCES identity_session_families(id), + token_hash TEXT NOT NULL UNIQUE, + generation INTEGER NOT NULL CHECK (generation >= 0), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ + ); + CREATE TABLE IF NOT EXISTS identity_jti_revocations ( + jti_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES identity_users(id), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ NOT NULL + ); + CREATE INDEX IF NOT EXISTS identity_session_families_user_idx + ON identity_session_families (user_id, status); + CREATE INDEX IF NOT EXISTS identity_refresh_tokens_family_idx + ON identity_refresh_tokens (family_id, generation DESC)`, + down: ` + DROP TABLE IF EXISTS identity_jti_revocations; + DROP TABLE IF EXISTS identity_refresh_tokens; + DROP TABLE IF EXISTS identity_session_families`, + }, + { + id: "identities_0007_user_verification_recovery", + up: ` + CREATE TABLE IF NOT EXISTS identity_one_time_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES identity_users(id), + identifier_id TEXT REFERENCES identity_login_identifiers(id), + kind TEXT NOT NULL CHECK (kind IN ('verification','recovery')), + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS identity_one_time_tokens_user_kind_idx + ON identity_one_time_tokens (user_id, kind, created_at DESC)`, + down: `DROP TABLE IF EXISTS identity_one_time_tokens`, + }, + { + id: "identities_0008_user_login_throttle", + up: ` + CREATE TABLE IF NOT EXISTS identity_login_throttle ( + key_hash TEXT PRIMARY KEY, + failures INTEGER NOT NULL CHECK (failures >= 0), + window_started_at TIMESTAMPTZ NOT NULL, + locked_until TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS identity_login_throttle_locked_idx + ON identity_login_throttle (locked_until)`, + down: `DROP TABLE IF EXISTS identity_login_throttle`, + }, + { + id: "identities_0009_user_lifecycle_security", + up: ` + ALTER TABLE identity_tenants + ADD COLUMN IF NOT EXISTS allowed_scopes JSONB NOT NULL DEFAULT '[]'::jsonb; + ALTER TABLE identity_memberships + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active','suspended')); + ALTER TABLE identity_invites + ADD COLUMN IF NOT EXISTS management_scope TEXT NOT NULL + DEFAULT 'identities:invites:manage'; + ALTER TABLE identity_login_throttle + ADD COLUMN IF NOT EXISTS tokens DOUBLE PRECISION; + ALTER TABLE identity_login_throttle + ADD COLUMN IF NOT EXISTS last_refilled_at TIMESTAMPTZ; + ALTER TABLE identity_login_throttle + ADD COLUMN IF NOT EXISTS in_flight INTEGER NOT NULL DEFAULT 0 + CHECK (in_flight >= 0); + ALTER TABLE identity_login_throttle + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ; + UPDATE identity_tenants AS tenant + SET allowed_scopes = COALESCE(( + SELECT jsonb_agg(scope ORDER BY scope) + FROM ( + SELECT DISTINCT jsonb_array_elements_text(membership.scopes) AS scope + FROM identity_memberships AS membership + WHERE membership.tenant_id = tenant.id + ) AS distinct_scopes + ), '[]'::jsonb) + WHERE tenant.allowed_scopes = '[]'::jsonb; + CREATE TABLE IF NOT EXISTS identity_issued_access_tokens ( + jti_hash TEXT PRIMARY KEY, + family_id TEXT NOT NULL REFERENCES identity_session_families(id), + user_id TEXT NOT NULL REFERENCES identity_users(id), + tenant_id TEXT NOT NULL REFERENCES identity_tenants(id), + expires_at TIMESTAMPTZ NOT NULL, + issued_at TIMESTAMPTZ NOT NULL + ); + CREATE INDEX IF NOT EXISTS identity_issued_access_tokens_user_idx + ON identity_issued_access_tokens (user_id, expires_at); + CREATE TABLE IF NOT EXISTS identity_login_identifier_canonicalization_audit ( + identifier_id TEXT NOT NULL REFERENCES identity_login_identifiers(id), + previous_value TEXT NOT NULL, + canonical_value TEXT NOT NULL, + conflicting_identifier_ids JSONB NOT NULL, + collision BOOLEAN NOT NULL, + audited_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (identifier_id, canonical_value) + )`, + down: ` + DROP TABLE IF EXISTS identity_login_identifier_canonicalization_audit; + DROP TABLE IF EXISTS identity_issued_access_tokens; + ALTER TABLE identity_login_throttle DROP COLUMN IF EXISTS updated_at; + ALTER TABLE identity_login_throttle DROP COLUMN IF EXISTS in_flight; + ALTER TABLE identity_login_throttle DROP COLUMN IF EXISTS last_refilled_at; + ALTER TABLE identity_login_throttle DROP COLUMN IF EXISTS tokens; + ALTER TABLE identity_invites DROP COLUMN IF EXISTS management_scope; + ALTER TABLE identity_memberships DROP COLUMN IF EXISTS status; + ALTER TABLE identity_tenants DROP COLUMN IF EXISTS allowed_scopes`, + }, + ]; + return definitions.map((definition) => ({ + ...definition, + up: definition.up.trim(), + down: definition.down.trim(), + checksum: checksumSql(definition.up), + })); +} + +function revokeFamilyState( + state: IdentityLifecycleSnapshot, + family: IdentitySessionFamilyRecord, + reason: string, + now: Date, +): void { + if (family.status !== "active") return; + family.status = "revoked"; + family.revokedAt = now.toISOString(); + family.updatedAt = now.toISOString(); + family.revokeReason = requiredText(reason, "revoke reason", 160); + for (const refresh of state.refreshTokens) { + if (refresh.familyId === family.id && refresh.revokedAt === undefined) { + refresh.revokedAt = now.toISOString(); + } + } +} + +function revokeIssuedJtisState( + state: IdentityLifecycleSnapshot, + predicate: (token: IdentityIssuedAccessTokenRecord) => boolean, + now: Date, +): void { + for (const token of state.issuedAccessTokens) { + if (!predicate(token) || new Date(token.expiresAt) <= now) continue; + if (!state.jtiRevocations.some((revocation) => revocation.jtiHash === token.jtiHash)) { + state.jtiRevocations.push({ + jtiHash: token.jtiHash, + userId: token.userId, + expiresAt: token.expiresAt, + revokedAt: now.toISOString(), + }); + } + } +} + +function selectMembership( + candidate: LoginCandidate, + tenantId: string | undefined, +): IdentityMembershipRecord | null { + if (tenantId !== undefined) { + return candidate.memberships.find((membership) => membership.tenantId === tenantId) ?? null; + } + if (candidate.memberships.length !== 1) return null; + return candidate.memberships[0] ?? null; +} + +function normalizeRegistrationPolicy(value: string): RegistrationPolicy { + if (value === "disabled" || value === "invite" || value === "open") return value; + throw new IdentityLifecycleError( + "invalid_configuration", + "registration policy must be disabled, invite, or open", + 500, + ); +} + +function normalizeRole(value: IdentityMembershipRole): IdentityMembershipRole { + if (value === "owner" || value === "admin" || value === "member") return value; + throw lifecycleError("invalid_request"); +} + +export function normalizeLoginIdentifier(input: LoginIdentifierInput): LoginIdentifierInput { + const kind = input.kind; + if (kind !== "email" && kind !== "username") throw lifecycleError("invalid_request"); + const normalized = requiredText(input.value, "login identifier", 320).normalize("NFKC").toLowerCase(); + if (kind === "email") { + const at = normalized.lastIndexOf("@"); + if (at <= 0 || at !== normalized.indexOf("@")) throw lifecycleError("invalid_request"); + const local = normalized.slice(0, at); + const unicodeDomain = normalized.slice(at + 1); + const asciiDomain = domainToASCII(unicodeDomain).toLowerCase(); + if ( + local.length === 0 || + /\s/.test(local) || + asciiDomain.length === 0 || + asciiDomain.length > 253 || + !asciiDomain.includes(".") || + asciiDomain.split(".").some( + (label) => + label.length === 0 || + label.length > 63 || + !/^[a-z0-9-]+$/.test(label) || + label.startsWith("-") || + label.endsWith("-"), + ) + ) { + throw lifecycleError("invalid_request"); + } + const canonical = `${local}@${asciiDomain}`; + if (canonical.length > 320) throw lifecycleError("invalid_request"); + return { kind, value: canonical }; + } else if (!/^[a-z0-9][a-z0-9._-]{2,63}$/.test(normalized)) { + throw lifecycleError("invalid_request"); + } + return { kind, value: normalized }; +} + +export function auditLoginIdentifierCanonicalization( + identifiers: readonly Pick[], +): IdentityLoginIdentifierCanonicalizationAudit { + const canonical = identifiers + .filter((identifier) => identifier.kind === "email") + .map((identifier) => ({ + identifierId: identifier.id, + previousValue: identifier.normalizedValue, + canonicalValue: normalizeLoginIdentifier({ + kind: "email", + value: identifier.normalizedValue, + }).value, + })); + const byCanonical = new Map(); + for (const item of canonical) { + const ids = byCanonical.get(item.canonicalValue) ?? []; + ids.push(item.identifierId); + byCanonical.set(item.canonicalValue, ids); + } + const entries = canonical + .filter((item) => item.previousValue !== item.canonicalValue || (byCanonical.get(item.canonicalValue)?.length ?? 0) > 1) + .map((item) => ({ + ...item, + conflictingIdentifierIds: (byCanonical.get(item.canonicalValue) ?? []) + .filter((identifierId) => identifierId !== item.identifierId) + .sort(), + })); + return { + entries, + collisions: entries.filter((entry) => entry.conflictingIdentifierIds.length > 0), + }; +} + +function membershipStatus(membership: IdentityMembershipRecord): IdentityMembershipStatus { + return membership.status ?? "active"; +} + +function tenantAllowedScopes(tenant: IdentityTenantRecord): string[] { + return normalizeScopes(tenant.allowedScopes ?? []); +} + +function isScopeSubset(values: readonly string[], allowedValues: readonly string[]): boolean { + const allowed = new Set(allowedValues); + return values.every((value) => allowed.has(value)); +} + +function intersectScopes(...collections: readonly (readonly string[])[]): string[] { + if (collections.length === 0) return []; + const [first, ...rest] = collections; + return normalizeScopes((first ?? []).filter((scope) => rest.every((collection) => collection.includes(scope)))); +} + +const ROLE_RANK: Readonly> = { + member: 0, + admin: 1, + owner: 2, +}; + +function roleCanAssign(actor: IdentityMembershipRole, target: IdentityMembershipRole): boolean { + return ROLE_RANK[actor] >= ROLE_RANK[target]; +} + +function roleCanManage(actor: IdentityMembershipRole, target: IdentityMembershipRole): boolean { + return ROLE_RANK[actor] > ROLE_RANK[target]; +} + +function highestRole(roles: readonly IdentityMembershipRole[]): IdentityMembershipRole | null { + return roles.reduce( + (highest, role) => highest === null || ROLE_RANK[role] > ROLE_RANK[highest] ? role : highest, + null, + ); +} + +function normalizeTenantSlug(value: string): string { + const normalized = requiredText(value, "tenant slug", 80).normalize("NFKC").toLowerCase(); + if (!/^[a-z0-9][a-z0-9-]{1,78}[a-z0-9]$/.test(normalized)) { + throw lifecycleError("invalid_request"); + } + return normalized; +} + +function normalizeScopes(values: readonly string[]): string[] { + return [...new Set(values.map((value) => requiredText(value, "scope", 160)))].sort(); +} + +function validatePassword(password: string): void { + if ( + typeof password !== "string" || + password.length < 12 || + password.length > 1_024 || + password.trim().length < 12 + ) { + throw lifecycleError("invalid_request"); + } +} + +function requireOpaqueToken(value: string, label: string): string { + if ( + typeof value !== "string" || + value.length < 32 || + value.length > 16_384 || + /\s/.test(value) + ) { + throw lifecycleError("invalid_request"); + } + return value; +} + +function randomToken(): string { + return randomBytes(32).toString("base64url"); +} + +function hashSecret(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function requireSha256(value: string): string { + if (!/^[a-f0-9]{64}$/.test(value)) throw lifecycleError("invalid_request"); + return value; +} + +function addSeconds(date: Date, seconds: number): Date { + return new Date(date.getTime() + seconds * 1_000); +} + +function boundedInteger( + value: number, + label: string, + minimum: number, + maximum: number, +): number { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new IdentityLifecycleError( + "invalid_configuration", + `${label} must be an integer between ${minimum} and ${maximum}`, + 500, + ); + } + return value; +} + +async function waitForMinimumDuration(startedAt: number, minimumMs: number): Promise { + const remainingMs = minimumMs - (performance.now() - startedAt); + if (remainingMs <= 0) return; + await new Promise((resolve) => setTimeout(resolve, Math.ceil(remainingMs))); +} + +function requiredText(value: unknown, label: string, maximum = 1_024): string { + if (typeof value !== "string") throw lifecycleError("invalid_request"); + const normalized = value.trim(); + if (normalized.length === 0 || normalized.length > maximum || /[\u0000-\u001f\u007f]/.test(normalized)) { + throw lifecycleError("invalid_request"); + } + return normalized; +} + +function lifecycleError(reason: IdentityLifecycleError["reason"]): IdentityLifecycleError { + switch (reason) { + case "registration_disabled": + return new IdentityLifecycleError(reason, "registration is not available", 403); + case "duplicate_identifier": + return new IdentityLifecycleError(reason, "registration could not be completed", 409); + case "bootstrap_complete": + return new IdentityLifecycleError(reason, "initial administrator already exists", 409); + case "invite_invalid": + return new IdentityLifecycleError(reason, "invite is invalid or expired", 400); + case "invalid_credentials": + return new IdentityLifecycleError(reason, "authentication failed", 401); + case "rate_limited": + return new IdentityLifecycleError(reason, "authentication temporarily unavailable", 429); + case "invalid_scope": + return new IdentityLifecycleError(reason, "requested scope is not allowed", 403); + case "refresh_invalid": + case "refresh_replay": + return new IdentityLifecycleError(reason, "session refresh failed", 401); + case "verification_invalid": + return new IdentityLifecycleError(reason, "verification token is invalid or expired", 400); + case "recovery_invalid": + return new IdentityLifecycleError(reason, "recovery token is invalid or expired", 400); + case "forbidden": + return new IdentityLifecycleError(reason, "access denied", 403); + case "not_found": + return new IdentityLifecycleError(reason, "record not found", 404); + case "invalid_configuration": + return new IdentityLifecycleError(reason, "identity lifecycle is not configured", 500); + default: + return new IdentityLifecycleError("invalid_request", "request is invalid", 400); + } +} + +function publicLifecycleError(error: IdentityLifecycleError): string { + if (error.reason === "invalid_credentials") return "authentication_failed"; + if (error.reason === "refresh_invalid" || error.reason === "refresh_replay") return "session_refresh_failed"; + if (error.reason === "duplicate_identifier") return "registration_failed"; + return error.reason; +} + +async function readJson(request: Request): Promise { + const text = await request.text(); + if (text.length === 0 || text.length > 65_536) throw lifecycleError("invalid_request"); + return JSON.parse(text); +} + +function bearerAccessToken(request: Request): string { + const value = request.headers.get("authorization") ?? ""; + const match = /^Bearer ([A-Za-z0-9._~-]+)$/.exec(value); + if (match === null) throw lifecycleError("forbidden"); + return match[1]!; +} + +function lifecycleJson(body: unknown, status = 200): Response { + return Response.json(body, { + status, + headers: { + "cache-control": "no-store", + "content-type": "application/json", + "x-content-type-options": "nosniff", + }, + }); +}