From b70a31eb1fb887a6937ec0fa07f7ba72688ce42a Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 23 Jul 2026 18:50:29 +0300 Subject: [PATCH 1/7] feat: add end-user identity lifecycle --- CHANGELOG.md | 7 + README.md | 37 + docs/user-lifecycle.md | 118 +++ package.json | 10 +- src/cli.ts | 99 +- src/identity-auth.ts | 98 +- src/index.ts | 7 + src/migrations.ts | 34 +- src/pg-store.ts | 3 + src/pg-user-lifecycle.test.ts | 174 ++++ src/pg-user-lifecycle.ts | 881 +++++++++++++++++ src/sdk/client.ts | 92 +- src/server/openapi.ts | 183 ++++ src/server/serve.ts | 21 +- src/user-lifecycle.test.ts | 568 +++++++++++ src/user-lifecycle.ts | 1694 +++++++++++++++++++++++++++++++++ 16 files changed, 4012 insertions(+), 14 deletions(-) create mode 100644 docs/user-lifecycle.md create mode 100644 src/pg-user-lifecycle.test.ts create mode 100644 src/pg-user-lifecycle.ts create mode 100644 src/user-lifecycle.test.ts create mode 100644 src/user-lifecycle.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea41dd3..df2143a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ ## 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. - 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..9083602 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,40 @@ 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. + +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..96eaeef --- /dev/null +++ b/docs/user-lifecycle.md @@ -0,0 +1,118 @@ +# 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 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 has a bounded structural check. Usernames accept 3–64 lowercase +alphanumeric, dot, underscore, or hyphen characters. + +## 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. + +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`. + +## 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. Logout-all, password recovery, account disable, and soft-delete revoke +all of that user's active families. Administrative disable/delete requires an +owner or administrator membership in the same tenant as the target. + +## Verification and recovery + +Verification and recovery tokens are one-time, hashed, expiring records. +Delivery occurs only through caller-supplied hooks. Unknown recovery requests +return the same accepted response without calling the delivery hook. +Successful recovery replaces the password hash and revokes all sessions. + +Restore is a trusted recovery/administrative library hook 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_0008`: + +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. + +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..0943d56 --- /dev/null +++ b/src/pg-user-lifecycle.test.ts @@ -0,0 +1,174 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +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, + IdentityLifecycleError, + IdentityLifecycleService, + identityLifecycleMigrations, +} 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; + +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() { + 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: "open", + bootstrapTenant: { slug: "infinity-pg", name: "Infinity PG" }, + passwordHasher: new Argon2idIdentityPasswordHasher({ + memoryCost: 32_768, + timeCost: 2, + }), + tokenIssuer, + tokenVerifier: verifier, + defaultScopes: ["runs:read", "runs:write"], + }), + }; + } + + 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_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); + }); +}); diff --git a/src/pg-user-lifecycle.ts b/src/pg-user-lifecycle.ts new file mode 100644 index 0000000..b7c82b7 --- /dev/null +++ b/src/pg-user-lifecycle.ts @@ -0,0 +1,881 @@ +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_JTI_REVOCATIONS_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, + type CreateSessionMutation, + type IdentityInviteRecord, + type IdentityJtiRevocationRecord, + type IdentityLifecycleStore, + type IdentityLoginThrottleRecord, + type IdentityOneTimeTokenRecord, + 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; + created_at: Date | string; +} + +interface MembershipTenantRow extends QueryResultRow { + id: string; + tenant_id: string; + user_id: string; + role: "owner" | "admin" | "member"; + scopes: unknown; + created_at: Date | string; + tenant_slug: string; + tenant_name: string; + 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; + 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_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; +} + +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 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, + createdAt: input.user.createdAt, + }; + membership = { + ...input.ownerMembership, + tenantId: tenant.id, + userId: input.user.id, + role: "owner", + }; + } 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`, + [invite.tenant_id], + ); + if (invitedTenant === null) { + 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: parseScopes(invite.scopes), + }; + } else { + membership = { + ...input.ownerMembership, + tenantId: tenant.id, + userId: input.user.id, + role: "owner", + }; + } + + 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, created_at) + VALUES ($1, $2, $3, $4)`, + [tenant.id, tenant.slug, tenant.name, 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, created_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6)`, + [ + membership.id, + membership.tenantId, + membership.userId, + membership.role, + JSON.stringify(membership.scopes), + membership.createdAt, + ], + ); + await insertOneTimeToken(tx, input.verification); + 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 }; + }); + } 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.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, + 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 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: IdentityInviteRecord): Promise { + await this.client.transaction(async (tx) => { + const actor = await tx.get<{ ok: boolean }>( + `SELECT true AS ok FROM ${IDENTITY_MEMBERSHIPS_TABLE} + WHERE tenant_id = $1 AND user_id = $2 AND role IN ('owner', 'admin')`, + [invite.tenantId, invite.createdByUserId], + ); + if (actor === null) throw lifecycleFailure("forbidden", "access denied", 403); + await tx.execute( + `INSERT INTO ${IDENTITY_INVITES_TABLE} + (id, tenant_id, token_hash, identifier_kind, normalized_identifier, + role, scopes, expires_at, created_by_user_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10)`, + [ + invite.id, + invite.tenantId, + invite.tokenHash, + invite.identifierKind ?? null, + invite.normalizedIdentifier ?? null, + 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<{ ok: boolean }>( + `SELECT true AS ok + FROM ${IDENTITY_USERS_TABLE} u + JOIN ${IDENTITY_MEMBERSHIPS_TABLE} m + ON m.user_id = u.id AND m.tenant_id = $2 + WHERE u.id = $1 AND u.status = 'active'`, + [input.family.userId, input.family.tenantId], + ); + if (context === null) throw lifecycleFailure("invalid_credentials", "authentication failed", 401); + await tx.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(tx, input.refresh); + }); + } + + async rotateRefreshToken(input: { + currentTokenHash: string; + replacement: IdentityRefreshTokenRecord; + now: Date; + }): Promise { + return this.client.transaction(async (tx) => { + 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 context = await tx.get( + `SELECT + u.*, + t.id AS tenant_id, + t.slug AS tenant_slug, + t.name AS tenant_name, + t.created_at AS tenant_created_at, + m.id AS membership_id, + m.role AS membership_role, + m.scopes AS membership_scopes, + 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`, + [row.user_id, row.tenant_id], + ); + if (context === null || context.status !== "active") 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 updated_at = $2 WHERE id = $1`, + [row.family_id, input.now.toISOString()], + ); + return { + kind: "rotated", + family: { + id: row.family_id, + userId: row.user_id, + tenantId: row.tenant_id, + scopes: parseScopes(row.family_scopes), + 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, + 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), + createdAt: iso(context.membership_created_at), + }, + }; + }); + } + + async revokeSessionFamily(familyId: string, reason: string, now: Date): Promise { + await this.client.transaction((tx) => revokeFamily(tx, familyId, reason, 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); + }); + } + + 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 canAdminister(actorUserId: string, tenantId: string, targetUserId: string): Promise { + const row = await this.client.get<{ ok: boolean }>( + `SELECT true AS ok + FROM ${IDENTITY_MEMBERSHIPS_TABLE} actor + 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.role IN ('owner', 'admin')`, + [actorUserId, tenantId, targetUserId], + ); + return row !== null; + } + + async setUserStatus( + userId: string, + status: "active" | "disabled" | "deleted", + now: Date, + ): Promise { + const rows = await this.client.many( + `UPDATE ${IDENTITY_USERS_TABLE} + SET status = $2, + updated_at = $3, + disabled_at = CASE WHEN $2 = 'disabled' THEN $3 ELSE NULL END, + deleted_at = CASE WHEN $2 = 'deleted' THEN $3 ELSE NULL END + WHERE id = $1 + RETURNING *`, + [userId, status, now.toISOString()], + ); + return rows[0] === undefined ? null : mapUser(rows[0]); + } + + 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); + 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<{ status: SessionFamilyStatusRow }>( + `SELECT status FROM ${IDENTITY_SESSION_FAMILIES_TABLE} WHERE session_hash = $1`, + [requireSha256(sessionSha256)], + ); + if (row === null) return "unknown"; + if (row.status === "active") return "active"; + if (row.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 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()], + ); +} + +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, + 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), + 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) }), + }; +} + +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..515b697 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", writeOnly: true }, + accessTokenExpiresAt: { type: "string", format: "date-time" }, + refreshToken: { type: "string", writeOnly: 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.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..86d48d8 --- /dev/null +++ b/src/user-lifecycle.test.ts @@ -0,0 +1,568 @@ +import { beforeAll, describe, expect, spyOn, test } from "bun:test"; +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, + IdentityLifecycleError, + IdentityLifecycleService, + InMemoryIdentityLifecycleStore, + createIdentityLifecycleApi, + identityLifecycleMigrations, + 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; + + 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 { + return "test$dummy-password-that-never-matches"; + } +} + +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; + } = {}, +) { + 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, + hooks: { + deliverRecovery: options.recovery, + deliverVerification: options.verification, + }, + defaultScopes: ["runs:read", "runs:write", "identity:read"], + 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"); + }); + + 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); + }); + + 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("open"); + const admin = await firstAdmin(service); + await service.disableUser({ + actorAccessToken: admin.accessToken, + userId: admin.user.id, + }); + expect(await errorReason(service.login({ + identifier: { kind: "email", value: "owner@example.test" }, + password: "correct horse battery staple", + }))).toBe("invalid_credentials"); + await service.restoreUser({ userId: admin.user.id }); + const restored = await service.login({ + identifier: { kind: "email", value: "owner@example.test" }, + password: "correct horse battery staple", + }); + await service.softDeleteUser({ + actorAccessToken: restored.accessToken, + userId: admin.user.id, + }); + expect(await errorReason(service.login({ + identifier: { kind: "email", value: "owner@example.test" }, + password: "correct horse battery staple", + }))).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: "session_inactive", + }); + 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[] = []; + const { service, verifier } = fixture("open", { + verification: ({ token }) => { + verificationTokens.push(token); + }, + recovery: ({ token }) => { + recoveryTokens.push(token); + }, + }); + 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" }, + }); + 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: "session_inactive", + }); + 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", + ]); + 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_0008_user_login_throttle", + "identities_0007_user_verification_recovery", + "identities_0006_user_sessions", + "identities_0005_user_credentials", + "identities_0004_user_tenancy", + ]); + }); +}); diff --git a/src/user-lifecycle.ts b/src/user-lifecycle.ts new file mode 100644 index 0000000..0a41bbd --- /dev/null +++ b/src/user-lifecycle.ts @@ -0,0 +1,1694 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +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 type RegistrationPolicy = "disabled" | "invite" | "open"; +export type LoginIdentifierKind = "email" | "username"; +export type IdentityUserStatus = "active" | "disabled" | "deleted"; +export type IdentityMembershipRole = "owner" | "admin" | "member"; +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; + createdAt: string; +} + +export interface IdentityMembershipRecord { + id: string; + tenantId: string; + userId: string; + role: IdentityMembershipRole; + scopes: string[]; + 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; + 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; +} + +export interface IdentityJtiRevocationRecord { + jtiHash: string; + userId: string; + expiresAt: string; + revokedAt: string; +} + +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[]; +} + +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; +} + +export interface RegistrationResult { + user: IdentityUserRecord; + tenant: IdentityTenantRecord; + membership: IdentityMembershipRecord; + identifier: IdentityLoginIdentifierRecord; +} + +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 { + 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; + createInvite(invite: IdentityInviteRecord): 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; + 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: [], + }; +} + +export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { + private readonly state = emptySnapshot(); + private tail: Promise = Promise.resolve(); + + 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 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), + createdAt: input.user.createdAt, + }; + membership = { + ...input.ownerMembership, + tenantId: tenant.id, + userId: input.user.id, + role: "owner", + }; + } 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"); + tenant = invitedTenant; + membership = { + ...input.ownerMembership, + tenantId: tenant.id, + userId: input.user.id, + role: invite.role, + scopes: [...invite.scopes], + }; + } else { + tenant = input.personalTenant; + membership = { + ...input.ownerMembership, + tenantId: tenant.id, + userId: input.user.id, + role: "owner", + }; + } + + this.state.users.push(input.user); + this.state.loginIdentifiers.push(input.identifier); + this.state.credentials.push(input.credential); + if (!this.state.tenants.some((candidate) => candidate.id === tenant.id)) { + if (this.state.tenants.some((candidate) => candidate.slug === tenant.slug)) { + throw lifecycleError("duplicate_identifier"); + } + this.state.tenants.push(tenant); + } + this.state.memberships.push(membership); + this.state.oneTimeTokens.push(input.verification); + if (invite !== undefined) { + invite.consumedAt = input.user.createdAt; + invite.consumedByUserId = input.user.id; + } + return structuredClone({ user: input.user, tenant, membership, identifier: input.identifier }); + }); + } + + 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 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: IdentityInviteRecord): Promise { + await this.exclusive(() => { + const tenant = this.state.tenants.find((candidate) => candidate.id === invite.tenantId); + const actor = this.state.memberships.find( + (membership) => + membership.tenantId === invite.tenantId && + membership.userId === invite.createdByUserId && + (membership.role === "owner" || membership.role === "admin"), + ); + if (tenant === undefined || actor === undefined) throw lifecycleError("forbidden"); + this.state.invites.push(structuredClone(invite)); + }); + } + + 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, + ); + if (user?.status !== "active" || membership === undefined) throw lifecycleError("invalid_credentials"); + 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, + ); + if ( + family.status !== "active" || + user?.status !== "active" || + tenant === undefined || + membership === undefined || + new Date(current.expiresAt) <= input.now || + new Date(family.expiresAt) <= 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.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); + }); + } + + 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); + } + }); + } + + 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 canAdminister(actorUserId: string, tenantId: string, targetUserId: string): Promise { + return this.exclusive(() => { + const actor = this.state.memberships.find( + (membership) => + membership.userId === actorUserId && + membership.tenantId === tenantId && + (membership.role === "owner" || membership.role === "admin"), + ); + const target = this.state.memberships.find( + (membership) => membership.userId === targetUserId && membership.tenantId === tenantId, + ); + return actor !== undefined && target !== undefined; + }); + } + + 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; + } + 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); + } + 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"; + 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; + loginThrottle?: { + maxFailures: number; + windowSeconds: number; + lockSeconds: 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 loginThrottle: { + maxFailures: number; + windowSeconds: number; + lockSeconds: number; + }; + + 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.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), + }; + } + + 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 { + 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`, + createdAt: now.toISOString(), + }; + const ownerMembership: IdentityMembershipRecord = { + id: `mem_${randomUUID()}`, + tenantId: personalTenant.id, + userId, + role: "owner", + scopes: [...this.defaultScopes], + 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 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, + }); + if (this.hooks.deliverVerification !== undefined) { + await this.hooks.deliverVerification({ + userId, + identifier: { kind: normalized.kind, value: normalized.value }, + token: verificationToken, + expiresAt: verification.expiresAt, + }); + } + return this.createSession(result); + } + + async login(input: { + identifier: LoginIdentifierInput; + password: string; + tenantId?: string; + scopes?: readonly string[]; + throttleKey?: string; + }): Promise { + const now = this.now(); + const normalized = normalizeLoginIdentifier(input.identifier); + const throttleKey = hashSecret( + `${normalized.kind}:${normalized.value}:${requiredText(input.throttleKey ?? "default", "throttleKey", 512)}`, + ); + const throttle = await this.store.getLoginThrottle(throttleKey, now); + if (throttle?.lockedUntil !== undefined && new Date(throttle.lockedUntil) > now) { + throw lifecycleError("rate_limited"); + } + + 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") { + await this.store.recordLoginFailure(throttleKey, now, this.loginThrottle); + throw lifecycleError("invalid_credentials"); + } + + const membership = selectMembership(candidate, input.tenantId); + if (membership === null) { + await this.store.recordLoginFailure(throttleKey, now, this.loginThrottle); + throw lifecycleError("invalid_credentials"); + } + const tenant = candidate.tenants.find((item) => item.id === membership.tenantId); + if (tenant === undefined) { + await this.store.recordLoginFailure(throttleKey, now, this.loginThrottle); + 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"); + } + await this.store.clearLoginFailures(throttleKey); + return this.createSession({ + user: candidate.user, + tenant, + membership, + identifier: candidate.identifier, + }, scopes); + } + + 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: IdentityInviteRecord = { + 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); + 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, + }); + 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 (!(await this.store.canAdminister(actor.sub, actor.tenant, input.userId))) { + throw lifecycleError("forbidden"); + } + const now = this.now(); + const user = await this.store.setUserStatus(requiredText(input.userId, "userId"), "disabled", now); + if (user === null) throw lifecycleError("not_found"); + await this.store.revokeAllUserSessions(user.id, "user_disabled", now); + 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 (!(await this.store.canAdminister(actor.sub, actor.tenant, input.userId))) { + throw lifecycleError("forbidden"); + } + const now = this.now(); + const user = await this.store.setUserStatus(requiredText(input.userId, "userId"), "deleted", now); + if (user === null) throw lifecycleError("not_found"); + await this.store.revokeAllUserSessions(user.id, "user_deleted", now); + await this.hooks.userDeleted?.({ userId: user.id, at: now.toISOString() }); + return user; + } + + async restoreUser(input: { userId: string }): Promise { + const now = this.now(); + const user = await this.store.setUserStatus(requiredText(input.userId, "userId"), "active", now); + if (user === null) throw lifecycleError("not_found"); + await this.hooks.userRestored?.({ userId: user.id, at: now.toISOString() }); + return user; + } + + 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 }): Promise<{ accepted: true }> { + const normalized = normalizeLoginIdentifier(input.identifier); + const candidate = await this.store.findLoginCandidate(normalized.kind, normalized.value); + if (candidate === null || candidate.user.status !== "active") return { accepted: true }; + const now = this.now(); + 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); + await this.hooks.deliverRecovery?.({ + userId: candidate.user.id, + identifier: { kind: normalized.kind, value: normalized.value }, + token, + expiresAt: record.expiresAt, + }); + return { accepted: true }; + } + + 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: RegistrationResult, + scopes: readonly string[] = registration.membership.scopes, + ): Promise { + const now = this.now(); + const family: IdentitySessionFamilyRecord = { + id: `ses_${randomUUID()}`, + userId: registration.user.id, + tenantId: registration.tenant.id, + 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(), + }; + await this.store.createSession({ family, refresh }); + const issue = await this.tokenIssuer.issue({ + subject: registration.user.id, + tenant: registration.tenant.id, + session: family.id, + scopes, + now, + }); + return { + schemaVersion: IDENTITY_USER_LIFECYCLE_SCHEMA_VERSION, + user: registration.user, + tenant: registration.tenant, + membership: { ...registration.membership, scopes: [...scopes] }, + scopes: [...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"); + } + } +} + +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") { + return lifecycleJson( + await options.service.beginRecovery(identityRecoveryStartSchema.parse(await readJson(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`, + }, + ]; + 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 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") { + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalized)) throw lifecycleError("invalid_request"); + } else if (!/^[a-z0-9][a-z0-9._-]{2,63}$/.test(normalized)) { + throw lifecycleError("invalid_request"); + } + return { kind, value: normalized }; +} + +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; +} + +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", + }, + }); +} From 6bac1e8422b0b8ff3010aa58b88246044649145b Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 23 Jul 2026 19:38:41 +0300 Subject: [PATCH 2/7] fix: harden identity lifecycle authority --- CHANGELOG.md | 5 + README.md | 6 + docs/user-lifecycle.md | 59 ++- src/pg-user-lifecycle.test.ts | 314 +++++++++++- src/pg-user-lifecycle.ts | 715 +++++++++++++++++++++++++-- src/user-lifecycle.test.ts | 461 +++++++++++++++++- src/user-lifecycle.ts | 881 ++++++++++++++++++++++++++++++---- 7 files changed, 2281 insertions(+), 160 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df2143a..61e5dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ 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 9083602..e338e4f 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,12 @@ 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, 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 diff --git a/docs/user-lifecycle.md b/docs/user-lifecycle.md index 96eaeef..dfa99f8 100644 --- a/docs/user-lifecycle.md +++ b/docs/user-lifecycle.md @@ -16,8 +16,9 @@ The contract version is `hasna.identity-user-lifecycle/v1`. - `IdentityAccessTokenIssuer` signs through one active key in the same `IdentityJwksRegistry` used by `IdentityAccessTokenVerifier`. Private key loading remains the embedding runtime's responsibility. -- Tenant IDs and membership scopes are persisted authority. Client-requested - tenant IDs or scopes never create membership or expand grants. +- 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 @@ -40,9 +41,17 @@ user exists. Concurrent attempts produce one administrator and one normalization and a database unique constraint. Email and username identifiers are trimmed, NFKC-normalized, and lowercased. -Email has a bounded structural check. Usernames accept 3–64 lowercase +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, 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 before creating the membership. + ## Credentials and login `Argon2idIdentityPasswordHasher` defaults to 64 MiB and three iterations and @@ -53,11 +62,16 @@ 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. +updated atomically. A persisted token bucket and in-flight reservation cap +admit work before password verification, so concurrent requests cannot all +enter the expensive password path after the same check. 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 rotation and revocation @@ -68,20 +82,32 @@ 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. Logout-all, password recovery, account disable, and soft-delete revoke -all of that user's active families. Administrative disable/delete requires an -owner or administrator membership in the same tenant as the target. +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. Unknown recovery requests -return the same accepted response without calling the delivery hook. +Delivery occurs only through caller-supplied hooks and is dispatched +asynchronously after durable token creation. Known and unknown recovery +requests run the same prewarmed dummy-hash work, share normalized +identifier-plus-client throttling, and return the same accepted response. Successful recovery replaces the password hash and revokes all sessions. -Restore is a trusted recovery/administrative library hook and is not exposed by -the public lifecycle HTTP handler. Existing sessions remain revoked after a -restore. +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 @@ -104,13 +130,20 @@ include the same schemas and operations. ## Postgres migrations -Lifecycle migrations are `identities_0004` through `identities_0008`: +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 diff --git a/src/pg-user-lifecycle.test.ts b/src/pg-user-lifecycle.test.ts index 0943d56..4f9e34b 100644 --- a/src/pg-user-lifecycle.test.ts +++ b/src/pg-user-lifecycle.test.ts @@ -41,7 +41,7 @@ describeLive("PgIdentityLifecycleStore live Postgres", () => { await client.close(); }); - function service() { + function service(registrationPolicy: "open" | "invite" = "open") { const registry = new IdentityJwksRegistry({ issuer: ISSUER, revision: 1, @@ -71,7 +71,7 @@ describeLive("PgIdentityLifecycleStore live Postgres", () => { verifier, service: new IdentityLifecycleService({ store, - registrationPolicy: "open", + registrationPolicy, bootstrapTenant: { slug: "infinity-pg", name: "Infinity PG" }, passwordHasher: new Argon2idIdentityPasswordHasher({ memoryCost: 32_768, @@ -79,7 +79,18 @@ describeLive("PgIdentityLifecycleStore live Postgres", () => { }), tokenIssuer, tokenVerifier: verifier, - defaultScopes: ["runs:read", "runs:write"], + defaultScopes: [ + "runs:read", + "runs:write", + "identities:invites:manage", + "identities:platform:admin", + ], + loginThrottle: { + maxFailures: 3, + windowSeconds: 300, + lockSeconds: 300, + maxConcurrent: 2, + }, }), }; } @@ -92,6 +103,7 @@ describeLive("PgIdentityLifecycleStore live Postgres", () => { 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", @@ -171,4 +183,300 @@ describeLive("PgIdentityLifecycleStore live Postgres", () => { 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 throttleKey = "a".repeat(64); + const admitted = await Promise.all( + Array.from({ length: 6 }, () => + live.store.reserveAuthAttempt(throttleKey, new Date(), { + maxFailures: 3, + windowSeconds: 300, + lockSeconds: 300, + maxConcurrent: 2, + }), + ), + ); + expect(admitted.filter(Boolean)).toHaveLength(2); + await Promise.all( + admitted.filter(Boolean).map(() => + live.store.completeAuthAttempt(throttleKey, new Date(), "failure", { + maxFailures: 3, + windowSeconds: 300, + lockSeconds: 300, + maxConcurrent: 2, + }), + ), + ); + await client.execute( + `UPDATE identity_login_throttle + SET tokens = 2, in_flight = 2, updated_at = now() - interval '301 seconds' + WHERE key_hash = $1`, + [throttleKey], + ); + expect( + await live.store.reserveAuthAttempt(throttleKey, new Date(), { + maxFailures: 3, + windowSeconds: 300, + lockSeconds: 300, + maxConcurrent: 2, + }), + ).toBe(true); + await live.store.completeAuthAttempt(throttleKey, 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" }); + }); }); diff --git a/src/pg-user-lifecycle.ts b/src/pg-user-lifecycle.ts index b7c82b7..dd652dd 100644 --- a/src/pg-user-lifecycle.ts +++ b/src/pg-user-lifecycle.ts @@ -3,7 +3,9 @@ import type { PoolQueryClient, TypedQueryClient } from "./generated/storage-kit/ 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, @@ -14,12 +16,17 @@ import { IDENTITY_TENANTS_TABLE, IDENTITY_USERS_TABLE, IdentityLifecycleError, + auditLoginIdentifierCanonicalization, + normalizeLoginIdentifier, type CreateSessionMutation, type IdentityInviteRecord, type IdentityJtiRevocationRecord, type IdentityLifecycleStore, type IdentityLoginThrottleRecord, + type IdentityIssuedAccessTokenRecord, + type IdentityLoginIdentifierCanonicalizationAudit, type IdentityOneTimeTokenRecord, + type IdentityThrottlePolicy, type IdentityUserRecord, type LoginCandidate, type LoginIdentifierKind, @@ -43,6 +50,7 @@ interface TenantRow extends QueryResultRow { id: string; slug: string; name: string; + allowed_scopes: unknown; created_at: Date | string; } @@ -52,9 +60,11 @@ interface MembershipTenantRow extends QueryResultRow { 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; } @@ -77,6 +87,7 @@ interface InviteRow extends QueryResultRow { token_hash: string; identifier_kind: LoginIdentifierKind | null; normalized_identifier: string | null; + management_scope: string; role: "owner" | "admin" | "member"; scopes: unknown; expires_at: Date | string; @@ -114,6 +125,7 @@ interface SessionContextRow extends UserRow { membership_id: string; membership_role: "owner" | "admin" | "member"; membership_scopes: unknown; + membership_status: "active" | "suspended"; membership_created_at: Date | string; } @@ -133,6 +145,10 @@ interface ThrottleRow extends QueryResultRow { 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( @@ -146,6 +162,107 @@ function lifecycleFailure( 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) => { @@ -182,6 +299,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { id: input.personalTenant.id, slug: input.bootstrapTenant.slug, name: input.bootstrapTenant.name, + allowedScopes: [...input.personalTenant.allowedScopes ?? input.ownerMembership.scopes], createdAt: input.user.createdAt, }; membership = { @@ -189,6 +307,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { tenantId: tenant.id, userId: input.user.id, role: "owner", + status: "active", }; } else if (input.policy === "invite" && !input.bootstrapOnly) { invite = await tx.get( @@ -214,20 +333,55 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { 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" || + !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: parseScopes(invite.scopes), + 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", }; } @@ -239,9 +393,15 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { ); if (invite === null) { await tx.execute( - `INSERT INTO ${IDENTITY_TENANTS_TABLE} (id, slug, name, created_at) - VALUES ($1, $2, $3, $4)`, - [tenant.id, tenant.slug, tenant.name, tenant.createdAt], + `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( @@ -271,15 +431,16 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { ); await tx.execute( `INSERT INTO ${IDENTITY_MEMBERSHIPS_TABLE} - (id, tenant_id, user_id, role, scopes, created_at) - VALUES ($1, $2, $3, $4, $5::jsonb, $6)`, + (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.createdAt, + membership.role, + JSON.stringify(membership.scopes), + membership.status ?? "active", + membership.createdAt, ], ); await insertOneTimeToken(tx, input.verification); @@ -331,6 +492,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { 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 @@ -361,6 +523,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { id: membership.tenant_id, slug: membership.tenant_slug, name: membership.tenant_name, + allowedScopes: parseScopes(membership.tenant_allowed_scopes), createdAt: iso(membership.tenant_created_at), })), }; @@ -382,6 +545,91 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { return mapThrottle(row); } + async reserveAuthAttempt( + keyHash: string, + now: Date, + policy: IdentityThrottlePolicy, + ): Promise { + return this.client.transaction(async (tx) => { + 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 row = await tx.one( + `SELECT * FROM ${IDENTITY_LOGIN_THROTTLE_TABLE} + WHERE key_hash = $1 + FOR UPDATE`, + [keyHash], + ); + if (row.locked_until !== null && new Date(row.locked_until) > now) return false; + const lastRefilledAt = new Date(row.last_refilled_at ?? row.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, + (row.tokens === null ? policy.maxFailures : Number(row.tokens)) + elapsedSeconds * refillRate, + ); + const reservationIsStale = + row.updated_at !== null && + new Date(row.updated_at).getTime() <= now.getTime() - policy.windowSeconds * 1_000; + const inFlight = reservationIsStale ? 0 : Number(row.in_flight); + if (inFlight >= policy.maxConcurrent || tokens < 1) return false; + 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(), inFlight + 1], + ); + return true; + }); + } + + async completeAuthAttempt( + keyHash: string, + now: Date, + outcome: "success" | "failure", + policy: IdentityThrottlePolicy, + ): Promise { + await this.client.transaction(async (tx) => { + const row = await tx.get( + `SELECT * FROM ${IDENTITY_LOGIN_THROTTLE_TABLE} + WHERE key_hash = $1 + FOR UPDATE`, + [keyHash], + ); + if (row === null) return; + let failures = outcome === "success" ? 0 : Number(row.failures) + 1; + let windowStartedAt = iso(row.window_started_at); + if ( + outcome === "failure" && + new Date(row.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; + 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()], + ); + }); + } + async recordLoginFailure( keyHash: string, now: Date, @@ -422,25 +670,64 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { ); } - async createInvite(invite: IdentityInviteRecord): Promise { + async createInvite( + invite: IdentityInviteRecord, + authorization?: { + actorTokenScopes: readonly string[]; + inviteManagementScope: string; + }, + ): Promise { await this.client.transaction(async (tx) => { - const actor = await tx.get<{ ok: boolean }>( - `SELECT true AS ok FROM ${IDENTITY_MEMBERSHIPS_TABLE} - WHERE tenant_id = $1 AND user_id = $2 AND role IN ('owner', 'admin')`, + const actor = await tx.get<{ + role: "owner" | "admin" | "member"; + scopes: unknown; + membership_status: "active" | "suspended"; + user_status: "active" | "disabled" | "deleted"; + allowed_scopes: unknown; + }>( + `SELECT + membership.role, + membership.scopes, + membership.status AS membership_status, + actor_user.status AS user_status, + tenant.allowed_scopes + 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.tenant_id = $1 AND membership.user_id = $2 + FOR UPDATE OF membership, actor_user, tenant`, [invite.tenantId, invite.createdByUserId], ); - if (actor === null) throw lifecycleFailure("forbidden", "access denied", 403); + if ( + actor === null || + actor.user_status !== "active" || + actor.membership_status !== "active" || + 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(actor.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, - role, scopes, expires_at, created_by_user_id, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10)`, + 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, + invite.managementScope ?? authorization.inviteManagementScope, invite.role, JSON.stringify(invite.scopes), invite.expiresAt, @@ -453,15 +740,34 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { async createSession(input: CreateSessionMutation): Promise { await this.client.transaction(async (tx) => { - const context = await tx.get<{ ok: boolean }>( - `SELECT true AS ok + 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 - WHERE u.id = $1 AND u.status = 'active'`, + 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) throw lifecycleFailure("invalid_credentials", "authentication failed", 401); + 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 tx.execute( `INSERT INTO ${IDENTITY_SESSION_FAMILIES_TABLE} (id, session_hash, user_id, tenant_id, scopes, status, expires_at, created_at, updated_at) @@ -533,10 +839,12 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { 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 @@ -545,7 +853,23 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { WHERE u.id = $1`, [row.user_id, row.tenant_id], ); - if (context === null || context.status !== "active") 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()], @@ -557,8 +881,10 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { } await insertRefreshToken(tx, input.replacement); await tx.execute( - `UPDATE ${IDENTITY_SESSION_FAMILIES_TABLE} SET updated_at = $2 WHERE id = $1`, - [row.family_id, input.now.toISOString()], + `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", @@ -566,7 +892,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { id: row.family_id, userId: row.user_id, tenantId: row.tenant_id, - scopes: parseScopes(row.family_scopes), + scopes: currentScopes, status: row.family_status, expiresAt: iso(row.family_expires_at), createdAt: iso(row.family_created_at), @@ -579,6 +905,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { 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: { @@ -587,6 +914,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { userId: context.id, role: context.membership_role, scopes: parseScopes(context.membership_scopes), + status: context.membership_status, createdAt: iso(context.membership_created_at), }, }; @@ -594,7 +922,10 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { } async revokeSessionFamily(familyId: string, reason: string, now: Date): Promise { - await this.client.transaction((tx) => revokeFamily(tx, familyId, reason, now)); + 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 { @@ -606,6 +937,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { [userId], ); for (const row of rows) await revokeFamily(tx, row.id, reason, now); + await revokeIssuedJtis(tx, `issued.user_id = $1`, [userId], now); }); } @@ -619,19 +951,206 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { ); } + 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<{ ok: boolean }>( - `SELECT true AS ok + 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.role IN ('owner', 'admin')`, + AND actor.status = 'active' + AND target.status = 'active' + AND actor_user.status = 'active'`, [actorUserId, tenantId, targetUserId], ); - return row !== null; + return row !== null && roleCanManage(row.actor_role, row.target_role); } async setUserStatus( @@ -639,17 +1158,31 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { status: "active" | "disabled" | "deleted", now: Date, ): Promise { - const rows = await this.client.many( - `UPDATE ${IDENTITY_USERS_TABLE} - SET status = $2, - updated_at = $3, - disabled_at = CASE WHEN $2 = 'disabled' THEN $3 ELSE NULL END, - deleted_at = CASE WHEN $2 = 'deleted' THEN $3 ELSE NULL END - WHERE id = $1 - RETURNING *`, - [userId, status, now.toISOString()], - ); - return rows[0] === undefined ? null : mapUser(rows[0]); + 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 { @@ -733,6 +1266,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { [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; }); } @@ -747,13 +1281,41 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { } async getSessionFamilyStatus(sessionSha256: string): Promise { - const row = await this.client.get<{ status: SessionFamilyStatusRow }>( - `SELECT status FROM ${IDENTITY_SESSION_FAMILIES_TABLE} WHERE session_hash = $1`, + 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.status === "active") return "active"; - if (row.status === "disabled") return "disabled"; + 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"; } } @@ -812,6 +1374,25 @@ async function revokeFamily( ); } +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, @@ -829,6 +1410,7 @@ function mapTenant(row: TenantRow) { id: row.id, slug: row.slug, name: row.name, + allowedScopes: parseScopes(row.allowed_scopes), createdAt: iso(row.created_at), }; } @@ -840,6 +1422,7 @@ function mapMembership(row: MembershipTenantRow) { userId: row.user_id, role: row.role, scopes: parseScopes(row.scopes), + status: row.status, createdAt: iso(row.created_at), }; } @@ -850,9 +1433,53 @@ function mapThrottle(row: ThrottleRow): IdentityLoginThrottleRecord { 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")) { diff --git a/src/user-lifecycle.test.ts b/src/user-lifecycle.test.ts index 86d48d8..23e790b 100644 --- a/src/user-lifecycle.test.ts +++ b/src/user-lifecycle.test.ts @@ -15,6 +15,7 @@ import { InMemoryIdentityLifecycleStore, createIdentityLifecycleApi, identityLifecycleMigrations, + normalizeLoginIdentifier, type IdentityPasswordHasher, type RegistrationPolicy, } from "./user-lifecycle.js"; @@ -41,6 +42,7 @@ beforeAll(async () => { class FastPasswordHasher implements IdentityPasswordHasher { readonly algorithm = "test-only" as const; verifyCalls = 0; + dummyHashCalls = 0; async hash(password: string): Promise { return `test$${password}`; @@ -52,10 +54,33 @@ class FastPasswordHasher implements IdentityPasswordHasher { } 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, @@ -107,7 +132,13 @@ function fixture( deliverRecovery: options.recovery, deliverVerification: options.verification, }, - defaultScopes: ["runs:read", "runs:write", "identity:read"], + defaultScopes: [ + "runs:read", + "runs:write", + "identity:read", + "identities:invites:manage", + "identities:platform:admin", + ], loginThrottle: { maxFailures: 3, windowSeconds: 300, @@ -279,28 +310,44 @@ describe("login, tenancy, and account state", () => { }); test("fails closed for disabled and soft-deleted users", async () => { - const { service } = fixture("open"); + 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: admin.user.id, + userId: member.user.id, }); expect(await errorReason(service.login({ - identifier: { kind: "email", value: "owner@example.test" }, - password: "correct horse battery staple", + identifier: { kind: "email", value: "member-state@example.test" }, + password: "a sufficiently strong password", }))).toBe("invalid_credentials"); - await service.restoreUser({ userId: admin.user.id }); + await service.restoreUser({ + actorAccessToken: admin.accessToken, + userId: member.user.id, + }); const restored = await service.login({ - identifier: { kind: "email", value: "owner@example.test" }, - password: "correct horse battery staple", + identifier: { kind: "email", value: "member-state@example.test" }, + password: "a sufficiently strong password", }); await service.softDeleteUser({ - actorAccessToken: restored.accessToken, - userId: admin.user.id, + actorAccessToken: admin.accessToken, + userId: restored.user.id, }); expect(await errorReason(service.login({ - identifier: { kind: "email", value: "owner@example.test" }, - password: "correct horse battery staple", + identifier: { kind: "email", value: "member-state@example.test" }, + password: "a sufficiently strong password", }))).toBe("invalid_credentials"); }); @@ -391,7 +438,7 @@ describe("session rotation and revocation", () => { }); await service.logoutAll({ accessToken: firstAgain.accessToken }); await expect(verifier.verify(firstAgain.accessToken)).rejects.toMatchObject({ - reason: "session_inactive", + reason: "token_revoked", }); expect((await verifier.verify(secondSignup.accessToken)).sub).toBe(secondSignup.user.id); }); @@ -427,7 +474,7 @@ describe("verification and recovery", () => { newPassword: "another secure password", }))).toBe("recovery_invalid"); await expect(verifier.verify(session.accessToken)).rejects.toMatchObject({ - reason: "session_inactive", + reason: "token_revoked", }); const recovered = await service.login({ identifier: { kind: "email", value: "owner@example.test" }, @@ -547,6 +594,7 @@ describe("lifecycle migrations", () => { "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"); @@ -558,6 +606,7 @@ describe("lifecycle migrations", () => { .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", @@ -566,3 +615,387 @@ describe("lifecycle migrations", () => { ]); }); }); + +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("a".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", + }); + await started; + let resolved = false; + void known.then(() => { + resolved = true; + }); + for (let turn = 0; turn < 20 && !resolved; turn += 1) await Promise.resolve(); + const resolvedBeforeDelivery = resolved; + 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", + ); + }); +}); diff --git a/src/user-lifecycle.ts b/src/user-lifecycle.ts index 0a41bbd..459d498 100644 --- a/src/user-lifecycle.ts +++ b/src/user-lifecycle.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { domainToASCII } from "node:url"; import { z } from "zod"; import { IdentityAccessTokenVerifier, @@ -23,11 +24,16 @@ 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"; @@ -50,6 +56,7 @@ export interface IdentityTenantRecord { id: string; slug: string; name: string; + allowedScopes?: string[]; createdAt: string; } @@ -59,6 +66,7 @@ export interface IdentityMembershipRecord { userId: string; role: IdentityMembershipRole; scopes: string[]; + status?: IdentityMembershipStatus; createdAt: string; } @@ -86,6 +94,7 @@ export interface IdentityInviteRecord { tokenHash: string; identifierKind?: LoginIdentifierKind; normalizedIdentifier?: string; + managementScope?: string; role: IdentityMembershipRole; scopes: string[]; expiresAt: string; @@ -135,6 +144,10 @@ export interface IdentityLoginThrottleRecord { failures: number; windowStartedAt: string; lockedUntil?: string; + tokens?: number; + lastRefilledAt?: string; + inFlight?: number; + updatedAt?: string; } export interface IdentityJtiRevocationRecord { @@ -144,6 +157,34 @@ export interface IdentityJtiRevocationRecord { 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 IdentityLifecycleSnapshot { users: IdentityUserRecord[]; tenants: IdentityTenantRecord[]; @@ -156,6 +197,7 @@ export interface IdentityLifecycleSnapshot { oneTimeTokens: IdentityOneTimeTokenRecord[]; loginThrottles: IdentityLoginThrottleRecord[]; jtiRevocations: IdentityJtiRevocationRecord[]; + issuedAccessTokens: IdentityIssuedAccessTokenRecord[]; } export interface IdentityAuthSession { @@ -301,6 +343,7 @@ export type RefreshRotationResult = | { 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; @@ -310,7 +353,20 @@ export interface IdentityLifecycleStore extends IdentityTokenStateStore { policy: { maxFailures: number; windowSeconds: number; lockSeconds: number }, ): Promise; clearLoginFailures(keyHash: string): Promise; - createInvite(invite: IdentityInviteRecord): Promise; + reserveAuthAttempt?(keyHash: string, now: Date, policy: IdentityThrottlePolicy): Promise; + completeAuthAttempt?( + keyHash: string, + now: Date, + outcome: "success" | "failure", + policy: IdentityThrottlePolicy, + ): Promise; + createInvite( + invite: IdentityInviteRecord, + authorization?: { + actorTokenScopes: readonly string[]; + inviteManagementScope: string; + }, + ): Promise; createSession(input: CreateSessionMutation): Promise; rotateRefreshToken(input: { currentTokenHash: string; @@ -320,6 +376,23 @@ export interface IdentityLifecycleStore extends IdentityTokenStateStore { 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; @@ -345,13 +418,28 @@ function emptySnapshot(): IdentityLifecycleSnapshot { oneTimeTokens: [], loginThrottles: [], jtiRevocations: [], + issuedAccessTokens: [], }; } export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { - private readonly state = emptySnapshot(); + 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); } @@ -370,6 +458,26 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { } } + 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 ( @@ -397,6 +505,7 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { 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 = { @@ -404,6 +513,7 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { 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); @@ -419,6 +529,25 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { } 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 || + !roleCanAssign(creatorMembership.role, invite.role) || + !creatorMembership.scopes.includes(invite.managementScope ?? DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE) || + !isScopeSubset(invite.scopes, creatorMembership.scopes) || + !isScopeSubset(invite.scopes, tenantAllowedScopes(invitedTenant)) + ) { + throw lifecycleError("invite_invalid"); + } tenant = invitedTenant; membership = { ...input.ownerMembership, @@ -426,14 +555,19 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { userId: input.user.id, role: invite.role, scopes: [...invite.scopes], + status: "active", }; } else { - tenant = input.personalTenant; + tenant = { + ...input.personalTenant, + allowedScopes: [...input.personalTenant.allowedScopes ?? input.ownerMembership.scopes], + }; membership = { ...input.ownerMembership, tenantId: tenant.id, userId: input.user.id, role: "owner", + status: "active", }; } @@ -489,6 +623,73 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { }); } + async reserveAuthAttempt( + keyHash: string, + now: Date, + policy: IdentityThrottlePolicy, + ): Promise { + return this.exclusive(() => { + let throttle = this.state.loginThrottles.find((candidate) => candidate.keyHash === keyHash); + if (throttle === undefined) { + throttle = { + keyHash, + failures: 0, + windowStartedAt: now.toISOString(), + tokens: policy.maxFailures - 1, + lastRefilledAt: now.toISOString(), + inFlight: 1, + updatedAt: now.toISOString(), + }; + this.state.loginThrottles.push(throttle); + return true; + } + if (throttle.lockedUntil !== undefined && new Date(throttle.lockedUntil) > now) return false; + const lastRefilledAt = new Date(throttle.lastRefilledAt ?? throttle.windowStartedAt); + const elapsedSeconds = Math.max(0, (now.getTime() - lastRefilledAt.getTime()) / 1_000); + const refillRate = policy.maxFailures / policy.windowSeconds; + const tokens = Math.min(policy.maxFailures, (throttle.tokens ?? policy.maxFailures) + elapsedSeconds * refillRate); + const reservationIsStale = + throttle.updatedAt !== undefined && + new Date(throttle.updatedAt).getTime() <= now.getTime() - policy.windowSeconds * 1_000; + const inFlight = reservationIsStale ? 0 : throttle.inFlight ?? 0; + if (inFlight >= policy.maxConcurrent || tokens < 1) return false; + throttle.tokens = tokens - 1; + throttle.lastRefilledAt = now.toISOString(); + throttle.inFlight = inFlight + 1; + throttle.updatedAt = now.toISOString(); + return true; + }); + } + + async completeAuthAttempt( + keyHash: string, + now: Date, + outcome: "success" | "failure", + policy: IdentityThrottlePolicy, + ): Promise { + await this.exclusive(() => { + const throttle = this.state.loginThrottles.find((candidate) => candidate.keyHash === keyHash); + if (throttle === undefined) return; + throttle.inFlight = Math.max(0, (throttle.inFlight ?? 0) - 1); + throttle.updatedAt = now.toISOString(); + if (outcome === "success") { + throttle.failures = 0; + delete throttle.lockedUntil; + return; + } + const windowCutoff = now.getTime() - policy.windowSeconds * 1_000; + if (new Date(throttle.windowStartedAt).getTime() < windowCutoff) { + throttle.windowStartedAt = now.toISOString(); + throttle.failures = 1; + } else { + throttle.failures += 1; + } + if (throttle.failures >= policy.maxFailures) { + throttle.lockedUntil = addSeconds(now, policy.lockSeconds).toISOString(); + } + }); + } + async recordLoginFailure( keyHash: string, now: Date, @@ -523,16 +724,43 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { }); } - async createInvite(invite: IdentityInviteRecord): Promise { + async createInvite( + invite: IdentityInviteRecord, + 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 || actor === undefined) throw lifecycleError("forbidden"); + 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)); }); } @@ -542,9 +770,20 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { 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, + 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 (user?.status !== "active" || membership === undefined) throw lifecycleError("invalid_credentials"); + if (input.family.scopes.length === 0) throw lifecycleError("invalid_scope"); this.state.sessionFamilies.push(structuredClone(input.family)); this.state.refreshTokens.push(structuredClone(input.refresh)); }); @@ -571,16 +810,25 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { 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, + (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(); @@ -590,6 +838,7 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { input.replacement.expiresAt = family.expiresAt; } this.state.refreshTokens.push(structuredClone(input.replacement)); + family.scopes = currentScopes; family.updatedAt = input.now.toISOString(); return { kind: "rotated", @@ -604,7 +853,10 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { 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); + if (family !== undefined) { + revokeFamilyState(this.state, family, reason, now); + revokeIssuedJtisState(this.state, (token) => token.familyId === family.id, now); + } }); } @@ -613,6 +865,7 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { 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); }); } @@ -624,18 +877,134 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { }); } + 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", + ); + if (family === 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 targetRoles = this.state.memberships + .filter((membership) => membership.userId === input.targetUserId) + .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 actor = this.state.memberships.find( + (candidate) => + candidate.userId === input.actorUserId && + candidate.tenantId === input.tenantId && + membershipStatus(candidate) === "active", + ); + const target = this.state.memberships.find( + (candidate) => candidate.userId === input.targetUserId && candidate.tenantId === input.tenantId, + ); + if (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 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, ); - return actor !== undefined && target !== undefined; + return actor !== undefined && target !== undefined && roleCanManage(actor.role, target.role); }); } @@ -654,6 +1023,11 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { 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); }); @@ -722,6 +1096,7 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { 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; }); } @@ -738,6 +1113,22 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { (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"; @@ -759,10 +1150,14 @@ export interface IdentityLifecycleServiceOptions { inviteTtlSeconds?: number; verificationTtlSeconds?: number; recoveryTtlSeconds?: number; + inviteManagementScope?: string; + platformAuthorityScope?: string; + platformAuthorityTenantSlugs?: readonly string[]; loginThrottle?: { maxFailures: number; windowSeconds: number; lockSeconds: number; + maxConcurrent?: number; }; } @@ -780,11 +1175,11 @@ export class IdentityLifecycleService { private readonly inviteTtlSeconds: number; private readonly verificationTtlSeconds: number; private readonly recoveryTtlSeconds: number; - private readonly loginThrottle: { - maxFailures: number; - windowSeconds: number; - lockSeconds: 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; @@ -828,11 +1223,41 @@ export class IdentityLifecycleService { 60, 86_400, ); + 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: { @@ -861,6 +1286,7 @@ export class IdentityLifecycleService { }, bootstrapOnly: boolean, ): Promise { + await this.ready(); if (!bootstrapOnly && this.registrationPolicy === "disabled") { throw lifecycleError("registration_disabled"); } @@ -896,6 +1322,7 @@ export class IdentityLifecycleService { id: `ten_${randomUUID()}`, slug: `personal-${userId.slice(4, 16).toLowerCase()}`, name: `${user.displayName}'s workspace`, + allowedScopes: [...this.defaultScopes], createdAt: now.toISOString(), }; const ownerMembership: IdentityMembershipRecord = { @@ -904,6 +1331,7 @@ export class IdentityLifecycleService { userId, role: "owner", scopes: [...this.defaultScopes], + status: "active", createdAt: now.toISOString(), }; const verification: IdentityOneTimeTokenRecord = { @@ -945,46 +1373,50 @@ export class IdentityLifecycleService { scopes?: readonly string[]; throttleKey?: string; }): Promise { + await this.ready(); const now = this.now(); const normalized = normalizeLoginIdentifier(input.identifier); const throttleKey = hashSecret( `${normalized.kind}:${normalized.value}:${requiredText(input.throttleKey ?? "default", "throttleKey", 512)}`, ); - const throttle = await this.store.getLoginThrottle(throttleKey, now); - if (throttle?.lockedUntil !== undefined && new Date(throttle.lockedUntil) > now) { - throw lifecycleError("rate_limited"); + if ( + this.store.reserveAuthAttempt === undefined || + this.store.completeAuthAttempt === undefined + ) { + throw lifecycleError("invalid_configuration"); } - - 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") { - await this.store.recordLoginFailure(throttleKey, now, this.loginThrottle); - throw lifecycleError("invalid_credentials"); + if (!(await this.store.reserveAuthAttempt(throttleKey, 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) { - await this.store.recordLoginFailure(throttleKey, now, this.loginThrottle); - throw lifecycleError("invalid_credentials"); - } - const tenant = candidate.tenants.find((item) => item.id === membership.tenantId); - if (tenant === undefined) { - await this.store.recordLoginFailure(throttleKey, now, this.loginThrottle); - 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 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(throttleKey, this.now(), outcome, this.loginThrottle); } - await this.store.clearLoginFailures(throttleKey); - return this.createSession({ - user: candidate.user, - tenant, - membership, - identifier: candidate.identifier, - }, scopes); } async createInvite(input: { @@ -1013,6 +1445,7 @@ export class IdentityLifecycleService { tokenHash: hashSecret(token), identifierKind: normalized?.kind, normalizedIdentifier: normalized?.value, + managementScope: this.inviteManagementScope, role: normalizeRole(input.role), scopes: normalizeScopes(input.scopes), expiresAt: addSeconds(now, expiresInSeconds).toISOString(), @@ -1020,7 +1453,10 @@ export class IdentityLifecycleService { createdAt: now.toISOString(), }; if (invite.scopes.length === 0) throw lifecycleError("invalid_scope"); - await this.store.createInvite(invite); + await this.store.createInvite(invite, { + actorTokenScopes: actor.scopes, + inviteManagementScope: this.inviteManagementScope, + }); return { id: invite.id, token, expiresAt: invite.expiresAt }; } @@ -1043,13 +1479,21 @@ export class IdentityLifecycleService { }); if (rotated.kind === "replay") throw lifecycleError("refresh_replay"); if (rotated.kind === "invalid") throw lifecycleError("refresh_invalid"); - const issue = await this.tokenIssuer.issue({ + 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, @@ -1087,13 +1531,19 @@ export class IdentityLifecycleService { userId: string; }): Promise { const actor = await this.verifyAccessToken(input.actorAccessToken); - if (!(await this.store.canAdminister(actor.sub, actor.tenant, input.userId))) { - throw lifecycleError("forbidden"); - } + if (this.store.mutateUserSecurityState === undefined) throw lifecycleError("invalid_configuration"); const now = this.now(); - const user = await this.store.setUserStatus(requiredText(input.userId, "userId"), "disabled", 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.store.revokeAllUserSessions(user.id, "user_disabled", now); await this.hooks.userDisabled?.({ userId: user.id, at: now.toISOString() }); return user; } @@ -1103,25 +1553,60 @@ export class IdentityLifecycleService { userId: string; }): Promise { const actor = await this.verifyAccessToken(input.actorAccessToken); - if (!(await this.store.canAdminister(actor.sub, actor.tenant, input.userId))) { - throw lifecycleError("forbidden"); - } + if (this.store.mutateUserSecurityState === undefined) throw lifecycleError("invalid_configuration"); const now = this.now(); - const user = await this.store.setUserStatus(requiredText(input.userId, "userId"), "deleted", 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.store.revokeAllUserSessions(user.id, "user_deleted", now); await this.hooks.userDeleted?.({ userId: user.id, at: now.toISOString() }); return user; } - async restoreUser(input: { userId: string }): Promise { + 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.setUserStatus(requiredText(input.userId, "userId"), "active", 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")), @@ -1131,29 +1616,61 @@ export class IdentityLifecycleService { return { verified: true }; } - async beginRecovery(input: { identifier: LoginIdentifierInput }): Promise<{ accepted: true }> { + async beginRecovery(input: { + identifier: LoginIdentifierInput; + throttleKey?: string; + }): Promise<{ accepted: true }> { + await this.ready(); const normalized = normalizeLoginIdentifier(input.identifier); - const candidate = await this.store.findLoginCandidate(normalized.kind, normalized.value); - if (candidate === null || candidate.user.status !== "active") return { accepted: true }; const now = this.now(); - 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); - await this.hooks.deliverRecovery?.({ - userId: candidate.user.id, - identifier: { kind: normalized.kind, value: normalized.value }, - token, - expiresAt: record.expiresAt, - }); - return { accepted: true }; + const throttleKey = hashSecret( + `recovery:${normalized.kind}:${normalized.value}:${requiredText( + input.throttleKey ?? "default", + "throttleKey", + 512, + )}`, + ); + if ( + this.store.reserveAuthAttempt === undefined || + this.store.completeAuthAttempt === undefined + ) { + throw lifecycleError("invalid_configuration"); + } + if (!(await this.store.reserveAuthAttempt(throttleKey, now, this.loginThrottle))) { + return { accepted: true }; + } + try { + 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) { + void Promise.resolve().then(() => + this.hooks.deliverRecovery?.({ + userId: candidate.user.id, + identifier: { kind: normalized.kind, value: normalized.value }, + token, + expiresAt: record.expiresAt, + }), + ).catch(() => undefined); + } + return { accepted: true }; + } finally { + await this.store.completeAuthAttempt(throttleKey, this.now(), "failure", this.loginThrottle); + } } async completeRecovery(input: { @@ -1201,15 +1718,23 @@ export class IdentityLifecycleService { subject: registration.user.id, tenant: registration.tenant.id, session: family.id, - scopes, + 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: [...scopes] }, - scopes: [...scopes], + membership: { ...registration.membership, scopes: [...family.scopes] }, + scopes: [...family.scopes], accessToken: issue.token, accessTokenExpiresAt: new Date(issue.expiresAt * 1_000).toISOString(), refreshToken, @@ -1230,6 +1755,11 @@ export class IdentityLifecycleService { 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({ @@ -1309,8 +1839,12 @@ export function createIdentityLifecycleApi(options: { ); } if (request.method === "POST" && url.pathname === "/v1/auth/recovery/start") { + const input = identityRecoveryStartSchema.parse(await readJson(request)); return lifecycleJson( - await options.service.beginRecovery(identityRecoveryStartSchema.parse(await readJson(request))), + await options.service.beginRecovery({ + ...input, + throttleKey: options.throttleKey?.(request), + }), 202, ); } @@ -1487,6 +2021,66 @@ export function identityLifecycleMigrations(): IdentityLifecycleMigration[] { 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, @@ -1514,6 +2108,24 @@ function revokeFamilyState( } } +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, @@ -1544,13 +2156,110 @@ export function normalizeLoginIdentifier(input: LoginIdentifierInput): LoginIden if (kind !== "email" && kind !== "username") throw lifecycleError("invalid_request"); const normalized = requiredText(input.value, "login identifier", 320).normalize("NFKC").toLowerCase(); if (kind === "email") { - if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalized)) throw lifecycleError("invalid_request"); + 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)) { From 7992a28bf9cbaf1f5f7c1593f3d132501406f273 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 23 Jul 2026 20:12:16 +0300 Subject: [PATCH 3/7] fix: close lifecycle review gaps --- README.md | 7 +- docs/user-lifecycle.md | 15 +- src/pg-user-lifecycle.test.ts | 262 +++++++++++++++++++++++++++++++-- src/pg-user-lifecycle.ts | 263 +++++++++++++++++++++++---------- src/user-lifecycle.test.ts | 94 +++++++++++- src/user-lifecycle.ts | 269 ++++++++++++++++++++++++++-------- 6 files changed, 746 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index e338e4f..9ce4754 100644 --- a/README.md +++ b/README.md @@ -238,8 +238,11 @@ 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, email domains use UTS-46/IDNA canonicalization, and access-token -verification observes current user, membership, family, and JTI state. +throttles with a client-wide concurrency cap across identifiers. 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 diff --git a/docs/user-lifecycle.md b/docs/user-lifecycle.md index dfa99f8..85bc183 100644 --- a/docs/user-lifecycle.md +++ b/docs/user-lifecycle.md @@ -50,7 +50,12 @@ membership must be active, 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 before creating the membership. +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 @@ -62,9 +67,11 @@ 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 persisted token bucket and in-flight reservation cap -admit work before password verification, so concurrent requests cannot all -enter the expensive password path after the same check. +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`, diff --git a/src/pg-user-lifecycle.test.ts b/src/pg-user-lifecycle.test.ts index 4f9e34b..089ef2a 100644 --- a/src/pg-user-lifecycle.test.ts +++ b/src/pg-user-lifecycle.test.ts @@ -1,4 +1,5 @@ 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"; @@ -12,9 +13,11 @@ import { PgIdentityLifecycleStore } from "./pg-user-lifecycle.js"; import { runIdentitiesMigrations } from "./pg-store.js"; import { Argon2idIdentityPasswordHasher, + DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE, IdentityLifecycleError, IdentityLifecycleService, identityLifecycleMigrations, + type IdentityInviteRecord, } from "./user-lifecycle.js"; const databaseUrl = process.env["TEST_DATABASE_URL"]; @@ -328,10 +331,14 @@ describeLive("PgIdentityLifecycleStore live Postgres", () => { reason: "token_revoked", }); - const throttleKey = "a".repeat(64); + 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( - Array.from({ length: 6 }, () => - live.store.reserveAuthAttempt(throttleKey, new Date(), { + attemptKeys.map((keys) => + live.store.reserveAuthAttempt(keys, new Date(), { maxFailures: 3, windowSeconds: 300, lockSeconds: 300, @@ -341,30 +348,44 @@ describeLive("PgIdentityLifecycleStore live Postgres", () => { ); expect(admitted.filter(Boolean)).toHaveLength(2); await Promise.all( - admitted.filter(Boolean).map(() => - live.store.completeAuthAttempt(throttleKey, new Date(), "failure", { - maxFailures: 3, - windowSeconds: 300, - lockSeconds: 300, - maxConcurrent: 2, - }), + 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 = $1`, - [throttleKey], + WHERE key_hash = ANY($1::text[])`, + [[staleKeys.failureKeyHash, staleKeys.admissionKeyHash]], ); expect( - await live.store.reserveAuthAttempt(throttleKey, new Date(), { + await live.store.reserveAuthAttempt(staleKeys, new Date(), { maxFailures: 3, windowSeconds: 300, lockSeconds: 300, maxConcurrent: 2, }), ).toBe(true); - await live.store.completeAuthAttempt(throttleKey, new Date(), "failure", { + await live.store.completeAuthAttempt(staleKeys, new Date(), "failure", { maxFailures: 3, windowSeconds: 300, lockSeconds: 300, @@ -479,4 +500,217 @@ describeLive("PgIdentityLifecycleStore live Postgres", () => { 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]), + ], + ); + }); }); diff --git a/src/pg-user-lifecycle.ts b/src/pg-user-lifecycle.ts index dd652dd..caaa6eb 100644 --- a/src/pg-user-lifecycle.ts +++ b/src/pg-user-lifecycle.ts @@ -19,7 +19,8 @@ import { auditLoginIdentifierCanonicalization, normalizeLoginIdentifier, type CreateSessionMutation, - type IdentityInviteRecord, + type IdentityAuthAttemptKeys, + type IdentityInviteCreation, type IdentityJtiRevocationRecord, type IdentityLifecycleStore, type IdentityLoginThrottleRecord, @@ -327,7 +328,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { throw lifecycleFailure("invite_invalid", "invite is invalid or expired", 400); } const invitedTenant = await tx.get( - `SELECT * FROM ${IDENTITY_TENANTS_TABLE} WHERE id = $1`, + `SELECT * FROM ${IDENTITY_TENANTS_TABLE} WHERE id = $1 FOR UPDATE`, [invite.tenant_id], ); if (invitedTenant === null) { @@ -385,6 +386,19 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { }; } + 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) @@ -444,6 +458,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { ], ); await insertOneTimeToken(tx, input.verification); + await insertSessionMutation(tx, initialSession); if (invite !== null) { await tx.execute( `UPDATE ${IDENTITY_INVITES_TABLE} @@ -452,7 +467,13 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { [invite.id, input.user.createdAt, input.user.id], ); } - return { user: input.user, tenant, membership, identifier: input.identifier }; + return { + user: input.user, + tenant, + membership, + identifier: input.identifier, + initialSession, + }; }); } catch (error) { if (error instanceof IdentityLifecycleError) throw error; @@ -546,69 +567,129 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { } async reserveAuthAttempt( - keyHash: string, + 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) => { - 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 row = await tx.one( - `SELECT * FROM ${IDENTITY_LOGIN_THROTTLE_TABLE} - WHERE key_hash = $1 - FOR UPDATE`, - [keyHash], + 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, ); - if (row.locked_until !== null && new Date(row.locked_until) > now) return false; - const lastRefilledAt = new Date(row.last_refilled_at ?? row.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, - (row.tokens === null ? policy.maxFailures : Number(row.tokens)) + elapsedSeconds * refillRate, - ); - const reservationIsStale = - row.updated_at !== null && - new Date(row.updated_at).getTime() <= now.getTime() - policy.windowSeconds * 1_000; - const inFlight = reservationIsStale ? 0 : Number(row.in_flight); - if (inFlight >= policy.maxConcurrent || tokens < 1) return false; - 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(), inFlight + 1], + (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( - keyHash: string, + 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 row = await tx.get( - `SELECT * FROM ${IDENTITY_LOGIN_THROTTLE_TABLE} - WHERE key_hash = $1 - FOR UPDATE`, - [keyHash], - ); - if (row === null) return; - let failures = outcome === "success" ? 0 : Number(row.failures) + 1; - let windowStartedAt = iso(row.window_started_at); + 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(row.window_started_at).getTime() < now.getTime() - policy.windowSeconds * 1_000 + new Date(failureBucket.window_started_at).getTime() < + now.getTime() - policy.windowSeconds * 1_000 ) { failures = 1; windowStartedAt = now.toISOString(); @@ -617,16 +698,28 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { outcome === "failure" && failures >= policy.maxFailures ? new Date(now.getTime() + policy.lockSeconds * 1_000).toISOString() : null; - 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()], - ); + 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()], + ); + } + } }); } @@ -671,34 +764,39 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { } async createInvite( - invite: IdentityInviteRecord, + 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"; - allowed_scopes: unknown; }>( `SELECT membership.role, membership.scopes, membership.status AS membership_status, - actor_user.status AS user_status, - tenant.allowed_scopes + actor_user.status AS user_status 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.tenant_id = $1 AND membership.user_id = $2 - FOR UPDATE OF membership, actor_user, tenant`, + FOR UPDATE OF membership, actor_user`, [invite.tenantId, invite.createdByUserId], ); if ( + tenant === null || actor === null || actor.user_status !== "active" || actor.membership_status !== "active" || @@ -712,7 +810,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { if ( !scopeSubset(invite.scopes, authorization.actorTokenScopes) || !scopeSubset(invite.scopes, parseScopes(actor.scopes)) || - !scopeSubset(invite.scopes, parseScopes(actor.allowed_scopes)) + !scopeSubset(invite.scopes, parseScopes(tenant.allowed_scopes)) ) { throw lifecycleFailure("invalid_scope", "requested scope is not allowed", 403); } @@ -727,7 +825,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { invite.tokenHash, invite.identifierKind ?? null, invite.normalizedIdentifier ?? null, - invite.managementScope ?? authorization.inviteManagementScope, + authorization.inviteManagementScope, invite.role, JSON.stringify(invite.scopes), invite.expiresAt, @@ -768,23 +866,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { if (input.family.scopes.length === 0) { throw lifecycleFailure("invalid_scope", "requested scope is not allowed", 403); } - await tx.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(tx, input.refresh); + await insertSessionMutation(tx, input); }); } @@ -1342,6 +1424,29 @@ async function insertOneTimeToken( ); } +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, diff --git a/src/user-lifecycle.test.ts b/src/user-lifecycle.test.ts index 23e790b..f7e6386 100644 --- a/src/user-lifecycle.test.ts +++ b/src/user-lifecycle.test.ts @@ -1,4 +1,5 @@ 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"; @@ -10,12 +11,14 @@ import { } 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"; @@ -201,6 +204,8 @@ describe("registration and first-admin bootstrap", () => { 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 () => { @@ -219,6 +224,8 @@ describe("registration and first-admin bootstrap", () => { 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 () => { @@ -908,7 +915,10 @@ describe("review-A lifecycle security regressions", () => { }], }); expect( - await staleStore.reserveAuthAttempt("a".repeat(64), new Date("2026-01-01T00:06:00.000Z"), { + 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, @@ -999,3 +1009,85 @@ describe("review-A lifecycle security regressions", () => { ); }); }); + +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, + ); + }); +}); diff --git a/src/user-lifecycle.ts b/src/user-lifecycle.ts index 459d498..22002c6 100644 --- a/src/user-lifecycle.ts +++ b/src/user-lifecycle.ts @@ -94,7 +94,7 @@ export interface IdentityInviteRecord { tokenHash: string; identifierKind?: LoginIdentifierKind; normalizedIdentifier?: string; - managementScope?: string; + managementScope: string; role: IdentityMembershipRole; scopes: string[]; expiresAt: string; @@ -185,6 +185,13 @@ export interface IdentityThrottlePolicy { maxConcurrent: number; } +export interface IdentityAuthAttemptKeys { + failureKeyHash: string; + admissionKeyHash: string; +} + +export type IdentityInviteCreation = Omit; + export interface IdentityLifecycleSnapshot { users: IdentityUserRecord[]; tenants: IdentityTenantRecord[]; @@ -309,6 +316,7 @@ export interface RegistrationMutation { personalTenant: IdentityTenantRecord; ownerMembership: IdentityMembershipRecord; verification: IdentityOneTimeTokenRecord; + initialSession: CreateSessionMutation; } export interface RegistrationResult { @@ -316,8 +324,11 @@ export interface RegistrationResult { tenant: IdentityTenantRecord; membership: IdentityMembershipRecord; identifier: IdentityLoginIdentifierRecord; + initialSession: CreateSessionMutation; } +type SessionRegistrationContext = Omit; + export interface LoginCandidate { user: IdentityUserRecord; identifier: IdentityLoginIdentifierRecord; @@ -353,15 +364,19 @@ export interface IdentityLifecycleStore extends IdentityTokenStateStore { policy: { maxFailures: number; windowSeconds: number; lockSeconds: number }, ): Promise; clearLoginFailures(keyHash: string): Promise; - reserveAuthAttempt?(keyHash: string, now: Date, policy: IdentityThrottlePolicy): Promise; + reserveAuthAttempt?( + keys: IdentityAuthAttemptKeys, + now: Date, + policy: IdentityThrottlePolicy, + ): Promise; completeAuthAttempt?( - keyHash: string, + keys: IdentityAuthAttemptKeys, now: Date, outcome: "success" | "failure", policy: IdentityThrottlePolicy, ): Promise; createInvite( - invite: IdentityInviteRecord, + invite: IdentityInviteCreation, authorization?: { actorTokenScopes: readonly string[]; inviteManagementScope: string; @@ -542,7 +557,7 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { creator === undefined || creatorMembership === undefined || !roleCanAssign(creatorMembership.role, invite.role) || - !creatorMembership.scopes.includes(invite.managementScope ?? DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE) || + !creatorMembership.scopes.includes(invite.managementScope) || !isScopeSubset(invite.scopes, creatorMembership.scopes) || !isScopeSubset(invite.scopes, tenantAllowedScopes(invitedTenant)) ) { @@ -571,22 +586,45 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { }; } + 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 (!this.state.tenants.some((candidate) => candidate.id === tenant.id)) { - if (this.state.tenants.some((candidate) => candidate.slug === tenant.slug)) { - throw lifecycleError("duplicate_identifier"); - } + 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 }); + return structuredClone({ + user: input.user, + tenant, + membership, + identifier: input.identifier, + initialSession, + }); }); } @@ -624,68 +662,108 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { } async reserveAuthAttempt( - keyHash: string, + keys: IdentityAuthAttemptKeys, now: Date, policy: IdentityThrottlePolicy, ): Promise { return this.exclusive(() => { - let throttle = this.state.loginThrottles.find((candidate) => candidate.keyHash === keyHash); - if (throttle === undefined) { + 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 - 1, + tokens: policy.maxFailures, lastRefilledAt: now.toISOString(), - inFlight: 1, + inFlight: 0, updatedAt: now.toISOString(), }; this.state.loginThrottles.push(throttle); - return true; + return throttle; + }; + const failureBucket = getOrCreate(keys.failureKeyHash); + const admissionBucket = getOrCreate(keys.admissionKeyHash); + if ( + failureBucket.lockedUntil !== undefined && + new Date(failureBucket.lockedUntil) > now + ) { + return false; } - if (throttle.lockedUntil !== undefined && new Date(throttle.lockedUntil) > now) return false; - const lastRefilledAt = new Date(throttle.lastRefilledAt ?? throttle.windowStartedAt); + 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, (throttle.tokens ?? policy.maxFailures) + elapsedSeconds * refillRate); - const reservationIsStale = - throttle.updatedAt !== undefined && - new Date(throttle.updatedAt).getTime() <= now.getTime() - policy.windowSeconds * 1_000; - const inFlight = reservationIsStale ? 0 : throttle.inFlight ?? 0; - if (inFlight >= policy.maxConcurrent || tokens < 1) return false; - throttle.tokens = tokens - 1; - throttle.lastRefilledAt = now.toISOString(); - throttle.inFlight = inFlight + 1; - throttle.updatedAt = now.toISOString(); + 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( - keyHash: string, + keys: IdentityAuthAttemptKeys, now: Date, outcome: "success" | "failure", policy: IdentityThrottlePolicy, ): Promise { await this.exclusive(() => { - const throttle = this.state.loginThrottles.find((candidate) => candidate.keyHash === keyHash); - if (throttle === undefined) return; - throttle.inFlight = Math.max(0, (throttle.inFlight ?? 0) - 1); - throttle.updatedAt = now.toISOString(); + 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") { - throttle.failures = 0; - delete throttle.lockedUntil; + failureBucket.failures = 0; + delete failureBucket.lockedUntil; return; } const windowCutoff = now.getTime() - policy.windowSeconds * 1_000; - if (new Date(throttle.windowStartedAt).getTime() < windowCutoff) { - throttle.windowStartedAt = now.toISOString(); - throttle.failures = 1; + if (new Date(failureBucket.windowStartedAt).getTime() < windowCutoff) { + failureBucket.windowStartedAt = now.toISOString(); + failureBucket.failures = 1; } else { - throttle.failures += 1; + failureBucket.failures += 1; } - if (throttle.failures >= policy.maxFailures) { - throttle.lockedUntil = addSeconds(now, policy.lockSeconds).toISOString(); + if (failureBucket.failures >= policy.maxFailures) { + failureBucket.lockedUntil = addSeconds(now, policy.lockSeconds).toISOString(); } }); } @@ -725,7 +803,7 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { } async createInvite( - invite: IdentityInviteRecord, + invite: IdentityInviteCreation, authorization?: { actorTokenScopes: readonly string[]; inviteManagementScope: string; @@ -761,7 +839,10 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { ) { throw lifecycleError("invalid_scope"); } - this.state.invites.push(structuredClone(invite)); + this.state.invites.push(structuredClone({ + ...invite, + managementScope: authorization.inviteManagementScope, + })); }); } @@ -1343,6 +1424,12 @@ export class IdentityLifecycleService { 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, @@ -1354,6 +1441,7 @@ export class IdentityLifecycleService { personalTenant, ownerMembership, verification, + initialSession: initialSession.mutation, }); if (this.hooks.deliverVerification !== undefined) { await this.hooks.deliverVerification({ @@ -1363,7 +1451,12 @@ export class IdentityLifecycleService { expiresAt: verification.expiresAt, }); } - return this.createSession(result); + return this.issueSession( + result, + result.initialSession, + initialSession.refreshToken, + now, + ); } async login(input: { @@ -1376,16 +1469,26 @@ export class IdentityLifecycleService { await this.ready(); const now = this.now(); const normalized = normalizeLoginIdentifier(input.identifier); - const throttleKey = hashSecret( - `${normalized.kind}:${normalized.value}:${requiredText(input.throttleKey ?? "default", "throttleKey", 512)}`, - ); + 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(throttleKey, now, this.loginThrottle))) { + if (!(await this.store.reserveAuthAttempt(attemptKeys, now, this.loginThrottle))) { throw lifecycleError("rate_limited"); } let outcome: "success" | "failure" = "failure"; @@ -1415,7 +1518,7 @@ export class IdentityLifecycleService { outcome = "success"; return session; } finally { - await this.store.completeAuthAttempt(throttleKey, this.now(), outcome, this.loginThrottle); + await this.store.completeAuthAttempt(attemptKeys, this.now(), outcome, this.loginThrottle); } } @@ -1439,13 +1542,12 @@ export class IdentityLifecycleService { 60, 2_592_000, ); - const invite: IdentityInviteRecord = { + const invite: IdentityInviteCreation = { id: `inv_${randomUUID()}`, tenantId: input.tenantId, tokenHash: hashSecret(token), identifierKind: normalized?.kind, normalizedIdentifier: normalized?.value, - managementScope: this.inviteManagementScope, role: normalizeRole(input.role), scopes: normalizeScopes(input.scopes), expiresAt: addSeconds(now, expiresInSeconds).toISOString(), @@ -1623,20 +1725,26 @@ export class IdentityLifecycleService { await this.ready(); const normalized = normalizeLoginIdentifier(input.identifier); const now = this.now(); - const throttleKey = hashSecret( - `recovery:${normalized.kind}:${normalized.value}:${requiredText( - input.throttleKey ?? "default", - "throttleKey", - 512, - )}`, - ); + const throttleClient = requiredText(input.throttleKey ?? "default", "throttleKey", 512); + 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"); } - if (!(await this.store.reserveAuthAttempt(throttleKey, now, this.loginThrottle))) { + if (!(await this.store.reserveAuthAttempt(attemptKeys, now, this.loginThrottle))) { return { accepted: true }; } try { @@ -1669,7 +1777,7 @@ export class IdentityLifecycleService { } return { accepted: true }; } finally { - await this.store.completeAuthAttempt(throttleKey, this.now(), "failure", this.loginThrottle); + await this.store.completeAuthAttempt(attemptKeys, this.now(), "failure", this.loginThrottle); } } @@ -1690,14 +1798,35 @@ export class IdentityLifecycleService { } private async createSession( - registration: RegistrationResult, + 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: registration.user.id, - tenantId: registration.tenant.id, + userId, + tenantId, scopes: [...scopes], status: "active", expiresAt: addSeconds(now, this.refreshTokenTtlSeconds).toISOString(), @@ -1713,7 +1842,19 @@ export class IdentityLifecycleService { expiresAt: family.expiresAt, createdAt: now.toISOString(), }; - await this.store.createSession({ family, refresh }); + 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, From 806c8ac1ec81dfb6d6b40e5bcd5ad188d33c5436 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 23 Jul 2026 20:45:20 +0300 Subject: [PATCH 4/7] fix: reconcile lifecycle authority review --- README.md | 4 +- docs/user-lifecycle.md | 24 ++++-- src/pg-user-lifecycle.test.ts | 153 ++++++++++++++++++++++++++++++++++ src/pg-user-lifecycle.ts | 50 ++++++----- src/server/openapi.ts | 4 +- src/server/serve.test.ts | 17 ++++ src/user-lifecycle.test.ts | 89 ++++++++++++++++++-- src/user-lifecycle.ts | 39 +++++++-- 8 files changed, 336 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 9ce4754..4fd87c6 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,9 @@ 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. Invite +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 diff --git a/docs/user-lifecycle.md b/docs/user-lifecycle.md index 85bc183..bd31051 100644 --- a/docs/user-lifecycle.md +++ b/docs/user-lifecycle.md @@ -46,10 +46,11 @@ so Unicode and equivalent punycode spellings collide. Usernames accept 3–64 lo alphanumeric, dot, underscore, or hyphen characters. Invite creation is a transactional authority check. The current actor user and -membership must be active, 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 +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, @@ -78,7 +79,9 @@ 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. +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 @@ -107,9 +110,14 @@ family scope state on every request. 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. Known and unknown recovery -requests run the same prewarmed dummy-hash work, share normalized -identifier-plus-client throttling, and return the same accepted response. +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 diff --git a/src/pg-user-lifecycle.test.ts b/src/pg-user-lifecycle.test.ts index 089ef2a..55018f2 100644 --- a/src/pg-user-lifecycle.test.ts +++ b/src/pg-user-lifecycle.test.ts @@ -713,4 +713,157 @@ describeLive("PgIdentityLifecycleStore live Postgres", () => { ], ); }); + + 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("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); }); diff --git a/src/pg-user-lifecycle.ts b/src/pg-user-lifecycle.ts index caaa6eb..3c0fdcb 100644 --- a/src/pg-user-lifecycle.ts +++ b/src/pg-user-lifecycle.ts @@ -800,6 +800,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { 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) || @@ -876,6 +877,35 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { 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, @@ -915,26 +945,6 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { ) { 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`, - [row.user_id, row.tenant_id], - ); const currentScopes = context === null ? [] diff --git a/src/server/openapi.ts b/src/server/openapi.ts index 515b697..f275606 100644 --- a/src/server/openapi.ts +++ b/src/server/openapi.ts @@ -181,9 +181,9 @@ export function buildOpenApiDocument(version: string) { tenant: { type: "object", additionalProperties: true }, membership: { type: "object", additionalProperties: true }, scopes: { type: "array", items: { type: "string" } }, - accessToken: { type: "string", writeOnly: true }, + accessToken: { type: "string", readOnly: true }, accessTokenExpiresAt: { type: "string", format: "date-time" }, - refreshToken: { type: "string", writeOnly: true }, + refreshToken: { type: "string", readOnly: true }, refreshTokenExpiresAt: { type: "string", format: "date-time" }, }, required: [ 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/user-lifecycle.test.ts b/src/user-lifecycle.test.ts index f7e6386..a219337 100644 --- a/src/user-lifecycle.test.ts +++ b/src/user-lifecycle.test.ts @@ -119,6 +119,7 @@ function fixture( hasher?: IdentityPasswordHasher; recovery?: (input: { userId: string; token: string }) => void | Promise; verification?: (input: { userId: string; token: string }) => void | Promise; + recoveryMinimumResponseMs?: number; } = {}, ) { const auth = authFixture(); @@ -131,6 +132,7 @@ function fixture( tokenIssuer: auth.issuer, tokenVerifier: auth.verifier, now: options.now, + recoveryMinimumResponseMs: options.recoveryMinimumResponseMs, hooks: { deliverRecovery: options.recovery, deliverVerification: options.verification, @@ -455,12 +457,17 @@ 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); @@ -471,6 +478,7 @@ describe("verification and recovery", () => { await service.beginRecovery({ identifier: { kind: "email", value: "owner@example.test" }, }); + await recoveryDelivered; expect(recoveryTokens).toHaveLength(1); await service.completeRecovery({ token: recoveryTokens[0]!, @@ -960,13 +968,12 @@ describe("review-A lifecycle security regressions", () => { identifier: { kind: "email", value: "OWNER@EXAMPLE.TEST" }, throttleKey: "recovery-client", }); - await started; - let resolved = false; + let responseResolved = false; void known.then(() => { - resolved = true; + responseResolved = true; }); - for (let turn = 0; turn < 20 && !resolved; turn += 1) await Promise.resolve(); - const resolvedBeforeDelivery = resolved; + await started; + const resolvedBeforeDelivery = responseResolved; releaseDelivery(); await known; expect(resolvedBeforeDelivery).toBe(true); @@ -1091,3 +1098,75 @@ describe("review-B lifecycle security regressions", () => { ); }); }); + +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); +}); diff --git a/src/user-lifecycle.ts b/src/user-lifecycle.ts index 22002c6..2894421 100644 --- a/src/user-lifecycle.ts +++ b/src/user-lifecycle.ts @@ -1231,6 +1231,7 @@ export interface IdentityLifecycleServiceOptions { inviteTtlSeconds?: number; verificationTtlSeconds?: number; recoveryTtlSeconds?: number; + recoveryMinimumResponseMs?: number; inviteManagementScope?: string; platformAuthorityScope?: string; platformAuthorityTenantSlugs?: readonly string[]; @@ -1256,6 +1257,7 @@ export class IdentityLifecycleService { 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[]; @@ -1304,6 +1306,12 @@ export class IdentityLifecycleService { 60, 86_400, ); + this.recoveryMinimumResponseMs = boundedInteger( + options.recoveryMinimumResponseMs ?? 250, + "recoveryMinimumResponseMs", + 100, + 5_000, + ); this.inviteManagementScope = requiredText( options.inviteManagementScope ?? DEFAULT_IDENTITY_INVITE_MANAGEMENT_SCOPE, "inviteManagementScope", @@ -1726,6 +1734,7 @@ export class IdentityLifecycleService { 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", @@ -1744,10 +1753,11 @@ export class IdentityLifecycleService { ) { throw lifecycleError("invalid_configuration"); } - if (!(await this.store.reserveAuthAttempt(attemptKeys, now, this.loginThrottle))) { - return { accepted: true }; - } + 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), @@ -1766,18 +1776,25 @@ export class IdentityLifecycleService { }; await this.store.createOneTimeToken(record); if (this.hooks.deliverRecovery !== undefined) { - void Promise.resolve().then(() => - this.hooks.deliverRecovery?.({ + recoveryDelivery = () => { + void Promise.resolve(this.hooks.deliverRecovery?.({ userId: candidate.user.id, identifier: { kind: normalized.kind, value: normalized.value }, token, expiresAt: record.expiresAt, - }), - ).catch(() => undefined); + })).catch(() => undefined); + }; } return { accepted: true }; } finally { - await this.store.completeAuthAttempt(attemptKeys, this.now(), "failure", this.loginThrottle); + 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); + } } } @@ -2469,6 +2486,12 @@ function boundedInteger( 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(); From b41300c91f70dbf71e97d559160d2ab79c5edf4e Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 23 Jul 2026 21:09:46 +0300 Subject: [PATCH 5/7] fix: enforce active administration states --- src/pg-user-lifecycle.test.ts | 276 ++++++++++++++++++++++++++++++++++ src/user-lifecycle.ts | 19 ++- 2 files changed, 293 insertions(+), 2 deletions(-) diff --git a/src/pg-user-lifecycle.test.ts b/src/pg-user-lifecycle.test.ts index 55018f2..123fe53 100644 --- a/src/pg-user-lifecycle.test.ts +++ b/src/pg-user-lifecycle.test.ts @@ -14,10 +14,15 @@ 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"]; @@ -26,6 +31,175 @@ 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 }; +} + +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); + }); + } +}); + describeLive("PgIdentityLifecycleStore live Postgres", () => { let client: PoolQueryClient; let privateKey: SigningKey; @@ -866,4 +1040,106 @@ describeLive("PgIdentityLifecycleStore live Postgres", () => { expect(unknownMedian).toBeGreaterThanOrEqual(225); expect(Math.abs(knownMedian - unknownMedian)).toBeLessThan(100); }, 15_000); + + 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/user-lifecycle.ts b/src/user-lifecycle.ts index 2894421..010fced 100644 --- a/src/user-lifecycle.ts +++ b/src/user-lifecycle.ts @@ -1075,6 +1075,10 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { 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 && @@ -1083,9 +1087,20 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { (membership.role === "owner" || membership.role === "admin"), ); const target = this.state.memberships.find( - (membership) => membership.userId === targetUserId && membership.tenantId === tenantId, + (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) ); - return actor !== undefined && target !== undefined && roleCanManage(actor.role, target.role); }); } From 6a59ce4dd51d8359c6209de862c6c01bf063cbb4 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 23 Jul 2026 21:34:22 +0300 Subject: [PATCH 6/7] fix: align in-memory lifecycle mutations --- src/pg-user-lifecycle.test.ts | 749 ++++++++++++++++++++++++++++++++++ src/user-lifecycle.ts | 38 +- 2 files changed, 784 insertions(+), 3 deletions(-) diff --git a/src/pg-user-lifecycle.test.ts b/src/pg-user-lifecycle.test.ts index 123fe53..648fb32 100644 --- a/src/pg-user-lifecycle.test.ts +++ b/src/pg-user-lifecycle.test.ts @@ -184,6 +184,390 @@ function administrationParityState( 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 () => { @@ -200,6 +584,58 @@ describe("InMemoryIdentityLifecycleStore canAdminister active-state contract", ( } }); +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; @@ -1041,6 +1477,319 @@ describeLive("PgIdentityLifecycleStore live Postgres", () => { 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( diff --git a/src/user-lifecycle.ts b/src/user-lifecycle.ts index 010fced..b616d0f 100644 --- a/src/user-lifecycle.ts +++ b/src/user-lifecycle.ts @@ -967,7 +967,28 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { candidate.tenantId === input.tenantId && candidate.status === "active", ); - if (family === undefined) throw lifecycleError("invalid_credentials"); + 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)); } @@ -997,8 +1018,13 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { 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) + .filter( + (membership) => + membership.userId === input.targetUserId && + tenantIds.has(membership.tenantId), + ) .map((membership) => membership.role); const highestTargetRole = highestRole(targetRoles); if ( @@ -1040,16 +1066,22 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { 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 (target === undefined) return null; + if (tenant === undefined || targetUser === undefined || target === undefined) return null; if ( actorUser === undefined || actor === undefined || From b3054234d8f86981fb90739bb2736a433dc50953 Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 23 Jul 2026 22:03:08 +0300 Subject: [PATCH 7/7] fix: revalidate invite creator authority --- src/pg-user-lifecycle.test.ts | 460 ++++++++++++++++++++++++++++++++++ src/pg-user-lifecycle.ts | 1 + src/user-lifecycle.test.ts | 254 +++++++++++++++++++ src/user-lifecycle.ts | 1 + 4 files changed, 716 insertions(+) diff --git a/src/pg-user-lifecycle.test.ts b/src/pg-user-lifecycle.test.ts index 648fb32..4cd718f 100644 --- a/src/pg-user-lifecycle.test.ts +++ b/src/pg-user-lifecycle.test.ts @@ -1363,6 +1363,466 @@ describeLive("PgIdentityLifecycleStore live Postgres", () => { })).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 }>( diff --git a/src/pg-user-lifecycle.ts b/src/pg-user-lifecycle.ts index 3c0fdcb..532865c 100644 --- a/src/pg-user-lifecycle.ts +++ b/src/pg-user-lifecycle.ts @@ -356,6 +356,7 @@ export class PgIdentityLifecycleStore implements IdentityLifecycleStore { 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)) || diff --git a/src/user-lifecycle.test.ts b/src/user-lifecycle.test.ts index a219337..3a39b85 100644 --- a/src/user-lifecycle.test.ts +++ b/src/user-lifecycle.test.ts @@ -1170,3 +1170,257 @@ describe("review-C2 lifecycle security regressions", () => { 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 index b616d0f..b03186a 100644 --- a/src/user-lifecycle.ts +++ b/src/user-lifecycle.ts @@ -556,6 +556,7 @@ export class InMemoryIdentityLifecycleStore implements IdentityLifecycleStore { 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) ||