Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@

## Unreleased

- Added the authoritative Identities-owned end-user lifecycle: relational
users, tenants, memberships, normalized login identifiers, Argon2id
credentials, invite/verification/recovery state, hashed rotating refresh
tokens, session-family and JTI revocation, registration policy and atomic
first-admin bootstrap, timing-safe throttled login, mountable HTTP schemas
and generated SDK operations, a guarded CLI bootstrap, and reversible
checksum-guarded Postgres migrations.
- Hardened lifecycle authority with transactional invite role/scope/tenant
checks, current membership scope intersection, tenant-only suspension versus
platform-global state, atomic family/JTI revocation, bounded concurrent
throttle admission, prewarmed enumeration-safe asynchronous recovery, and
UTS-46 email-domain canonicalization with migration collision audits.
- Added reusable scoped JWT verification and issuance, public JWKS rotation
status, hashed token/session-family revocation checks, and composable auth
API and CLI surfaces for local and self-hosted consumers.
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -223,6 +226,51 @@ SHA-256 hashes and session-family statuses. Private signing keys remain
caller-owned and are accepted only as runtime inputs to
`issueIdentityAccessToken`.

## End-user lifecycle

`IdentityLifecycleService` is the reusable authority for human application
users. It supports `disabled`, `invite`, and `open` registration; an atomic
first-admin bootstrap; normalized unique email or username login identifiers;
Argon2id password credentials; tenant-bound membership scopes; hashed rotating
refresh tokens; replay-driven family revocation; logout, logout-all, disable,
soft-delete, verification, and recovery.

Invite authority, tenant scope allowlists, role hierarchy, membership
suspension, session scope reduction, and platform-global user state are
validated transactionally. Login and recovery use bounded pre-admission
throttles with a client-wide concurrency cap across identifiers; recovery
responses also use a bounded minimum-duration floor so durable token work does
not expose whether an identifier exists. Invite
registration and its initial session commit atomically against the locked
tenant allowlist, email domains use UTS-46/IDNA canonicalization, and
access-token verification observes current user, membership, family, and JTI
state.

The service issues access tokens through an `IdentityAccessTokenIssuer` bound
to the same `IdentityJwksRegistry` used by
`IdentityAccessTokenVerifier`. Consumers cannot silently publish one key set
while signing from another. `createIdentityLifecycleApi` exposes mountable
`/v1/auth/*` handlers, and `PgIdentityLifecycleStore` supplies the relational
Postgres implementation. Infinity and other applications should mount or call
these surfaces instead of creating their own credential or session tables.

The injected CLI bootstrap reads the initial password only from an owner-only
file and never prints access or refresh tokens. An optional session file is
created with owner-only permissions:

```bash
identities auth bootstrap \
--identifier-kind email \
--identifier owner@example.test \
--password-file /owner-only/bootstrap-password \
--display-name "Owner" \
--session-file /owner-only/identity-session.json
```

The standalone CLI intentionally fails closed unless its embedding runtime
injects an `IdentityLifecycleService`; signing keys and deployment-specific
policy never come from command-line flags.

## Instruction Sources

OpenIdentities owns the canonical instruction-source graph for humans, agents,
Expand Down
166 changes: 166 additions & 0 deletions docs/user-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Identities End-User Lifecycle

`@hasna/identities` owns the reusable end-user authentication lifecycle. An
application such as Infinity consumes this contract; it does not create a
parallel user, password, tenant, refresh-token, or revocation store.

The contract version is `hasna.identity-user-lifecycle/v1`.

## Authority boundaries

- `IdentityLifecycleService` owns registration, login, recovery, verification,
session rotation, logout, disable, and soft-delete state transitions.
- `PgIdentityLifecycleStore` is the production relational repository.
`InMemoryIdentityLifecycleStore` exists for deterministic tests and embedded
development only.
- `IdentityAccessTokenIssuer` signs through one active key in the same
`IdentityJwksRegistry` used by `IdentityAccessTokenVerifier`. Private key
loading remains the embedding runtime's responsibility.
- Tenant IDs, tenant scope allowlists, membership status, and membership scopes
are persisted authority. Client-requested tenant IDs or scopes never create
membership or expand grants.
- Raw passwords, invite tokens, verification tokens, recovery tokens, refresh
tokens, JTIs, and session IDs are not persisted in token-state tables.
Passwords use Argon2id; opaque tokens, JTIs, and session references are
stored as SHA-256 hashes.

## Registration policy

The embedding runtime selects exactly one policy:

- `disabled`: public signup is unavailable.
- `invite`: signup requires an unexpired, unused, hashed invite whose optional
normalized identifier matches.
- `open`: public signup is allowed. The first user atomically receives the
configured bootstrap tenant's owner membership; later users receive isolated
personal tenants unless they use an invite policy in another deployment.

`bootstrapFirstAdmin` uses a serialized transaction and succeeds only while no
user exists. Concurrent attempts produce one administrator and one
`bootstrap_complete` result. Login-identifier uniqueness is enforced by both
normalization and a database unique constraint.

Email and username identifiers are trimmed, NFKC-normalized, and lowercased.
Email domains are additionally canonicalized to their UTS-46/IDNA ASCII form,
so Unicode and equivalent punycode spellings collide. Usernames accept 3–64 lowercase
alphanumeric, dot, underscore, or hyphen characters.

Invite creation is a transactional authority check. The current actor user and
membership must be active with an owner or admin role, the access token and
persisted membership must both hold the configured invite-management scope,
the invited role cannot outrank the actor, and invited scopes must be a subset
of the token, membership, and tenant allowlist. Invite consumption rechecks
persisted role and scope
authority while holding the current tenant allowlist row lock. The store
persists only its authorization input's configured management scope; a caller
cannot substitute a weaker scope on the invite record. Registration,
membership creation, invite consumption, and the initial session family commit
in one transaction, so a concurrent allowlist change cannot leave a partial
account after initial-session validation fails.

## Credentials and login

`Argon2idIdentityPasswordHasher` defaults to 64 MiB and three iterations and
enforces a 12–1024 character password boundary. Deployments may raise those
parameters, but cannot configure less than 32 MiB or two iterations.

Unknown users, incorrect passwords, disabled users, deleted users, and tenant
mismatches return the same authentication failure. Unknown users still verify
against a cached Argon2id dummy hash. Throttle keys hash the normalized
identifier and the embedding runtime's client key; failures and lock expiry are
updated atomically. A separate client-wide admission key applies the in-flight
cap across distinct identifiers and across login and recovery. PostgreSQL locks
all participating buckets in deterministic key order and every admitted path
releases both reservations in `finally`, so identifier fan-out cannot multiply
the expensive password work.

Login selects one persisted membership. Requested scopes must be a non-empty
subset of that membership's scopes. Tokens bind `sub`, `tenant`, `session`,
`scopes`, `iat`, `nbf`, `exp`, and `jti`.
Session creation and every refresh re-read active user, membership, and tenant
authority transactionally, intersect family scopes with current grants, and
revoke a family whose scope intersection is empty. Refresh locks authority rows
before session-family rows so concurrent grant narrowing cannot issue stale
scopes or create a lock-order cycle with membership suspension.

## Refresh rotation and revocation

Refresh tokens are 256-bit random values stored only by hash. Every successful
refresh consumes the current row and creates a new generation in the same
session family. Reuse of a consumed or revoked token is treated as compromise:
the family and all its refresh generations are revoked in one transaction.

Access-token verification checks the hashed JTI and hashed session-family
reference on every request. Logout records the current JTI and revokes the
family. Issued JTI hashes are tracked so logout-all, password recovery, global
account disable, and soft-delete atomically revoke both active families and
their unexpired access-token JTIs.

Tenant membership suspension and platform-global user state are separate.
Tenant role hierarchy permits only a strictly higher tenant role to suspend a
membership, without disabling the same user in other tenants. Global
disable/delete/restore requires the configured platform-authority scope in both
the actor token and current owner membership, requires the current tenant slug
to be in the configured platform-authority tenant allowlist, and also enforces
role hierarchy.
Token verification rechecks current user, membership, tenant allowlist, and
family scope state on every request.

## Verification and recovery

Verification and recovery tokens are one-time, hashed, expiring records.
Delivery occurs only through caller-supplied hooks and is dispatched
asynchronously after durable token creation and after the accepted response is
ready, so synchronous hook setup cannot extend the account-dependent response
path. Known and unknown recovery requests run the same prewarmed dummy-hash work, share normalized
identifier-plus-client throttling, and return the same accepted response only
after a 250 ms minimum response floor. The floor is asynchronous, includes
durable token and throttle completion work, and is bounded by the same
client-wide admission cap; deployments may tune `recoveryMinimumResponseMs`
between 100 and 5,000 ms after measuring their database tail latency.
Successful recovery replaces the password hash and revokes all sessions.

Restore requires the same explicit platform authority as disable/delete and is
not exposed by the public lifecycle HTTP handler. Existing sessions remain
revoked after a restore.

## HTTP and SDK

`createIdentityLifecycleApi` provides:

- `POST /v1/auth/signup`
- `POST /v1/auth/login`
- `POST /v1/auth/refresh`
- `POST /v1/auth/logout`
- `POST /v1/auth/logout-all`
- `POST /v1/auth/verification/complete`
- `POST /v1/auth/recovery/start`
- `POST /v1/auth/recovery/complete`

Responses set `Cache-Control: no-store` and never echo passwords. Login and
refresh errors use bounded public reason codes. The serve runtime accepts an
optional lifecycle handler and otherwise returns a fail-closed 503 for these
routes. The OpenAPI document and generated `@hasna/identities/sdk` client
include the same schemas and operations.

## Postgres migrations

Lifecycle migrations are `identities_0004` through `identities_0009`:

1. users, tenants, memberships;
2. normalized login identifiers, Argon2id credentials, invites;
3. session families, hashed refresh tokens, hashed JTI revocations;
4. verification and recovery tokens;
5. login throttle state.
6. tenant allowlists, membership suspension, token-bucket reservations, issued
JTI tracking, and identifier-canonicalization audit state.

Store readiness performs a collision audit of existing email identifiers before
serving lifecycle requests. Non-colliding legacy forms are rewritten to the
canonical UTS-46 ASCII domain form; collisions are recorded for operator
resolution and readiness fails closed.

They use the existing checksum ledger, are safe to reapply, and reject checksum
drift. `rollbackIdentityLifecycleMigrations` is an explicit destructive
operator surface requiring `allowDestructive: true`; it removes only lifecycle
migrations in reverse dependency order and clears only their ledger rows.
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
Loading