@ultimat3/auth 30.9.0 → 31.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CLAUDE.md +55 -109
  2. package/README.md +100 -25
  3. package/package.json +5 -4
  4. package/src/adapter-subpath.ts +34 -0
  5. package/src/api-keys.ts +14 -1
  6. package/src/auth.ts +3 -3
  7. package/src/authorization-server-clients.ts +19 -1
  8. package/src/authorization-server-config.ts +13 -4
  9. package/src/authorization-server-installed.ts +34 -0
  10. package/src/authorization-server-postgres.ts +4 -2
  11. package/src/authorization-server-store.ts +14 -4
  12. package/src/authorization-server.ts +30 -10
  13. package/src/client-scope.ts +1 -1
  14. package/src/errors.ts +9 -1
  15. package/src/grant-authorization-code.ts +21 -16
  16. package/src/grant-device.ts +2 -2
  17. package/src/identity-provider-cookie.ts +89 -0
  18. package/src/identity-provider-oidc.ts +142 -0
  19. package/src/identity-provider-route-core.ts +253 -0
  20. package/src/identity-provider-routes.ts +78 -0
  21. package/src/identity-provider-status.ts +45 -0
  22. package/src/identity-provider.ts +112 -0
  23. package/src/index.ts +34 -54
  24. package/src/jwks.ts +2 -2
  25. package/src/kdf-gate.ts +24 -14
  26. package/src/limiter-install.ts +26 -16
  27. package/src/memory-adapter.ts +5 -5
  28. package/src/mfa.ts +1 -1
  29. package/src/oauth-discovery.ts +1 -1
  30. package/src/oauth-errors.ts +26 -2
  31. package/src/oauth-exchange.ts +2 -2
  32. package/src/oauth-paths.ts +5 -2
  33. package/src/oauth-route.ts +60 -297
  34. package/src/rate-limit.ts +7 -2
  35. package/src/saml-assertion.ts +156 -0
  36. package/src/saml-c14n.ts +118 -0
  37. package/src/saml-certificate.ts +47 -0
  38. package/src/saml-dom.ts +66 -0
  39. package/src/saml-dsig.ts +165 -0
  40. package/src/saml-email-trust.ts +49 -0
  41. package/src/saml-errors.ts +96 -0
  42. package/src/saml-metadata.ts +80 -0
  43. package/src/saml-provider.ts +179 -0
  44. package/src/saml-request.ts +51 -0
  45. package/src/saml-response.ts +131 -0
  46. package/src/saml-xml.ts +328 -0
package/CLAUDE.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # @ultimat3/auth — agent notes
2
2
 
3
3
  Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3/policy`'s job.
4
+ A rule marked `†` has its full text in [`docs/history/auth-rules.md`](../../docs/history/auth-rules.md).
4
5
 
5
6
  | Rule | |
6
7
  |---|---|
@@ -16,14 +17,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
16
17
 
17
18
  ## Non-negotiables
18
19
 
19
- - **Every number this package bounds anything with is screened** (`policy-numbers.ts`): policy
20
- numbers at `defineAuth` (`X_CONFIG_INVALID` — `session.absoluteTtlMs`, `idleTtlMs`,
21
- `idleSlideMs`, `password.minLength`, the two argon2 costs, every `rateLimit` number), and every
22
- runtime one through `assertFiniteAuthCount` — `jwks.ttlMs`, the three OAuth legs' `timeoutMs`
23
- (screened OUTSIDE each leg's `try`), the limiter's `maxKeys`, `mfa.drift`,
24
- `oauth.handshake.ttlMs`, `session.cookie.maxAgeSeconds`, `kdf.maxConcurrent`/`maxQueued`.
25
- **`mfa.drift` and the KDF gate are the two that HANG** — mutate `verifyTotp` with the
26
- `NaN` case, never by deleting the screen (that wedges `mfa.test.ts`).
20
+ - **Every number this package bounds anything with is screened** (`policy-numbers.ts`,
21
+ `assertFiniteAuthCount`); `mfa.drift` and the KDF gate HANG if unscreened. †
27
22
  - **A number is screened ABOVE the write it feeds.** `issueVerification` screens its TTL before
28
23
  `putVerification` (which upserts on `(purpose, identifier)`); `verify.test.ts` asserts no row
29
24
  written, no mail sent, the earlier token still redeemable.
@@ -33,44 +28,21 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
33
28
  - Every credential failure throws `loginFailed()` — one code, one cause, one fix. Adding a
34
29
  parameter to it re-opens account enumeration.
35
30
  - **`login()` takes an email or a handle** (`login-identity.ts`, `defineAuth({ handles })`).
36
- - **A stored hash Bun cannot read is the generic failure, and it burns the same KDF.**
37
- `verifyAgainst` (`password.ts`) answers `null` for `Bun.password.verify`'s throw (unsupported
38
- algorithm, malformed PHC) and `''`, joining the no-user branch; nothing is logged. An
39
- `X_OVERLOADED` from the gate is RE-THROWN (a shed is load, not a verdict). Supported-but-old
40
- (bcrypt) stays a verdict and `needsRehash` flags it. `password.test.ts` pins both halves.
31
+ - **A stored hash Bun cannot read is the generic failure, and it burns the same KDF**
32
+ (`verifyAgainst`); an `X_OVERLOADED` is re-thrown. †
41
33
  - **The limiter's table is bounded, and the eviction order is the guarantee.** Every bucket carries
42
34
  `forgetAtMs`; `policy.maxKeys` (`DEFAULT_MAX_AUTH_LIMIT_KEYS`) is the backstop, and a **live
43
35
  lockout outranks its own deadline** in the comparator. Never reduce that sort to recency.
44
- - **`AuthLimiter` is async on every member, declares the policy it enforces, and is a
45
- RESERVATION.** `reserve(key)` counts the attempt in the step that admits it (refused, counting
46
- nothing, while locked); `refund(reservation)` gives ONE back and lifts a lockout the window no
47
- longer justifies; `recordSuccess(key)` clears the key. There is no check-then-record pair: N
48
- concurrent guesses all passed the check. `assertAuthLimiterPolicy` (once, in `defineAuth`)
49
- refuses a per-process limiter under `scope: 'shared'` (`X_AUTH_LIMITER_NOT_SHARED`) and different
50
- `maxAttempts`/`windowMs`/`lockoutMs` (`X_AUTH_LIMITER_POLICY_MISMATCH`). `maxKeys` is not
51
- compared.
52
- - **Every credential check runs through `openLoginAttempt`** (`login-attempt.ts`): account → ip →
53
- org reserved in that fixed order BEFORE the KDF or the TOTP compare. A failure calls nothing (the
54
- reservations are the record); `release()` for no verdict (a shed KDF, a store that is down, a
55
- bucket that refused) or a factor proven with another still owed; `succeed()` clears the account
56
- and refunds the shared two.
57
- - **`postgresAuthLimiter` is the shared limiter: ONE row per key in `x_auth_lockouts`, holding the
58
- instants still inside the window** (`attempts_ms bigint[]` — a sliding window). The take is one
59
- `insert … on conflict do update` reading the window off `x_auth_lockouts.<column>`, never a CTE
60
- and never a second table: only the row the statement locks is re-read after a wait. No row back is never an
61
- admission; a live lockout is never rewritten. Full rule: `docs/history/auth.md`. `PgExecutor` (`@ultimat3/core`'s, re-exported) is structural:
62
- the pool is the host's.
63
- - **`configureAuthLimiters` is the HOST's install point and takes a FACTORY**, called with the
64
- RESOLVED policy once per bucket; the comparison still runs on what comes back. Precedence:
65
- `config.limiter` → the installed factory → `memoryAuthLimiter`. `installedAuthLimiter` and
66
- `installedLimiterCount()` are NOT in `src/index.ts`. `purgeAuthLimits()` sweeps only the
67
- **widest** window among the built limiters (retained one per `windowMs` in a `Map`), on the
68
- limiter's own clock. `AuthLimiter.purgeExpired` is optional.
69
- - **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no adapter
70
- may fold case.** Every door now normalises before the adapter sees the address — `register`,
71
- `login`, `profileEmail` in `oauth-login.ts`, `accountKey`, and `issueVerification`/
72
- `consumeVerification`. Trim and lowercase only; never strip a `+tag` or a gmail dot.
73
- `adapter-parity.test.ts` pins both adapters in one test.
36
+ - **`AuthLimiter` is a RESERVATION** (`reserve`/`refund`/`recordSuccess`), never check-then-record;
37
+ `assertAuthLimiterPolicy` (`X_AUTH_LIMITER_NOT_SHARED`, `X_AUTH_LIMITER_POLICY_MISMATCH`). †
38
+ - **Every credential check runs through `openLoginAttempt`**: account → ip → org reserved, in that
39
+ order, BEFORE the KDF or the TOTP compare. †
40
+ - **`postgresAuthLimiter` is the shared limiter**: ONE `x_auth_lockouts` row per key, a sliding
41
+ window, a one-statement take; a live lockout is never rewritten. †
42
+ - **`installAuthLimiters` (`@ultimat3/auth/adapter`) is the HOST's install point and takes a
43
+ FACTORY**: `config.limiter` → installed factory → `memoryAuthLimiter`. †
44
+ - **`normaliseEmail` is the ONE normalisation, ABOVE the `AuthAdapter` seam**: trim + lowercase,
45
+ never a `+tag` or gmail dot; no adapter folds case. †
74
46
  - **`json.ts` owns `isRecord` and `decodeJwtSegment`; no second copy.** An array is not a record.
75
47
  `decodeJwtSegment` answers `null` for all three failures; each caller raises its own refusal.
76
48
  - Absolute and idle expiry are two separate computations in `sessionExpiry()`. Do not fold them.
@@ -95,9 +67,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
95
67
  `deleteSessionsForOrg` joins through `x_users`; `x_sessions` does **not** gain an `org_id`.
96
68
  - The code flow carries `nonce` inside the id token. `assertOAuthCallback` checks an echoed one when
97
69
  present and never requires it; `verifyIdToken` is the real gate.
98
- - The handshake is sealed (`sealHandshake`); `openHandshake` takes the provider as an argument.
99
- Expiry is the server's clock, not `Max-Age`. One handshake cookie **per provider**
100
- (`handshakeCookieName`), and `clearHandshakeCookie(provider)`.
70
+ - `sealHandshake`/`openHandshake` (a hand-built flow's cookie, not `oauthLogin`'s): the provider is
71
+ an argument, expiry the server's clock, one cookie per provider (`handshakeCookieName`).
101
72
  - `takeVerification(purpose, identifier, tokenHash)` consumes **only on a hash match**, in one
102
73
  conditional statement (`consumed_at is null` on the UPDATE and its subselect,
103
74
  `order by created_at desc limit 1`); `consumeVerification` still compares in constant time.
@@ -112,14 +83,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
112
83
  - A token endpoint's HTTP 200 is not success — read `error`.
113
84
  - Link by address only when the provider **and** the local account both verified it
114
85
  (`link: 'verified-email'`, default; `'never'` is the only other value).
115
- - **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is read by
116
- both `oauth-errors.ts` and `oauth-route.ts`; every "start over" fix is `restartAt(provider)`.
117
- - The routes are **descriptors** (`AuthRouteDescriptor`), never mounted handlers.
118
- - The callback answers failure as **coded JSON** — a fixed body per code (`code`, `title`, `docs`,
119
- one `cause`, `fix: x errors explain <CODE> --json`); the authored cause and fix are the
120
- `auth.oauth.refused` log line — and success redirects to a fixed `successPath` —
121
- never `?next=` (`nextAfterSignIn` in `@ultimat3/http` is the one open-redirect check).
122
- - The handshake cookie is cleared on **every** callback outcome.
86
+ - **One sign-in route implementation** (`identity-provider-route-core.ts`) at
87
+ `/auth/sso/:provider[/callback]`; failure is coded JSON, success a 303 to `successPath`. †
123
88
  - Refresh is **not implemented**, so the framework **stores no provider token**: `accountFor`
124
89
  (`oauth-login.ts`) writes `accessToken: null, refreshToken: null`. The columns and `AuthAccount`
125
90
  fields stay; an app that stores tokens implements `linkAccount` itself.
@@ -130,9 +95,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
130
95
  an uncoded adapter throw logs `auth.oauth.uncoded_failure`, a rejecting `OAuthFetch` logs
131
96
  `auth.oauth.token_fetch_failed`, and the `POST /token` body is read as
132
97
  `providerDetail(response, 'coded-only')` (that request carries `client_secret`).
133
- - **`x_users.mfa_secret` is SEALED** (`mfa-secret.ts`, purpose `auth:x_users.mfa_secret`):
134
- `saveTotpSecret` writes, `openTotpSecret` reads and REFUSES a value core's `isSealed` does not
135
- recognise (`X_MFA_SECRET_UNSEALED`, fix `x auth seal-mfa --json`). No plaintext fallback, ever. Full rule: `docs/history/auth.md`.
98
+ - **`x_users.mfa_secret` is SEALED** (`mfa-secret.ts`); an unsealed value is
99
+ `X_MFA_SECRET_UNSEALED`. No plaintext fallback. †
136
100
  - **`providerJwks` memoises only the DEFAULT client**; a caller supplying options gets its own.
137
101
  - **A JWKS refresh is single-flighted through core's `singleFlight` with
138
102
  `deadlineMs = timeoutMs * 2`**; eviction frees the key, never the work, and a `generationFence`
@@ -143,74 +107,54 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
143
107
  - **A success clears the ACCOUNT bucket and nothing else** — clearing the address bucket made the
144
108
  limiter inert against stuffing. A password proven with a second factor still owed clears
145
109
  NOTHING: `completeMfa` is what clears, or a repeat of the first leg wipes the wrong codes.
146
- - **`MemoryAuthAdapter.createUser` and `updateUser` enforce `x_users.id`, `email` and `external_id`
147
- uniqueness** (`authUniqueViolation`, `X_AUTH_WRITE_FAILED`), NULLS DISTINCT like Postgres;
148
- `PostgresAuthAdapter` answers the same code through `builtin-adapter-violation.ts`.
149
- **`MemoryAuthAdapter` takes a `Clock`** (default `systemClock`) and stamps every instant from it.
150
- `adapter-parity.test.ts` pins both.
110
+ - **`MemoryAuthAdapter` enforces `x_users` uniqueness like Postgres and takes a `Clock`**
111
+ (`authAdapterConformance` pins both adapters). †
151
112
  - **`listUsersByOrg` orders by byte**: `collate "C"` in Postgres, a code-point compare in memory —
152
113
  never `localeCompare`, never UTF-16 `<` (`list-users-order.live.test.ts`, on an `en_US` db).
153
114
  - Every `AuthAdapter` member is REQUIRED — a missing one is a compile error at `defineAuth`, and
154
115
  no caller in this package refuses at runtime with `X_NOT_IMPLEMENTED`.
155
- - **An api key is its owner's credential.** `verifyApiKey` re-reads the owner (after the secret
156
- matched, never before) and refuses a missing or disabled one as the same `X_API_KEY_INVALID`; a
157
- key with `userId === null` has no owner and is unchanged. `disableUser` revokes the user's live
158
- keys beside their sessions, and logs before its first write.
159
- - **A key's scopes only ever shrink** (`apiKeyScopes`, `policy-bridge.ts`): `*` and `<res>:*` are
160
- refused at `issueApiKey` (`X_CONFIG_INVALID`) and dropped from a stored row; an OWNED key keeps
161
- only what its owner's grants cover (core's `grantCovers`, never a local copy). Roles are policy's to expand, so the default grants are the
162
- row's `permissions` + `scopes` (`directGrants`) and a role-based app passes
163
- `apiKeyResolver(store, { grantsOf })`. Never union a key with the owner's roles.
164
- - **`apiKeyResolver(() => store)` is the ONE token → caller mapping** (`api-key-resolver.ts`):
165
- `verifyApiKey` → `apiKeyActor`, `X_API_KEY_INVALID` → `null`, everything else rethrown. Its
166
- `ApiKeyCaller` is structurally http's `BearerCaller` and mcp's `ResolvedToken` — declared here
167
- because tier 2 imports neither. The store is a thunk (a mount is declared before boot installs
168
- the database client). No second in-memory key store: `MemoryAuthAdapter` IS an `ApiKeyStore`.
116
+ - **An api key is its owner's credential**: `verifyApiKey` re-reads the owner after the secret
117
+ matched; `disableUser` revokes their keys. †
118
+ - **`verifyApiKey` refuses an `env` segment that is not the stored prefix's, and touches
119
+ `lastUsedAt` at most once per `API_KEY_TOUCH_MS`** — above the seam, so both adapters agree.
120
+ - **An authorization code is checked on `getCode`, spent by `takeCode` only after client, PKCE,
121
+ redirect and resource held**; replay revocation needs the verifier. `pollDevice` takes the
122
+ `clientId` and stamps nothing for another client's poll.
123
+ - **A key's scopes only ever shrink** (`apiKeyScopes`): wildcards refused, an owned key keeps only
124
+ what its owner's grants cover. Never union with the owner's roles. †
125
+ - **`apiKeyResolver(() => store)` is the ONE token → caller mapping**; `X_API_KEY_INVALID` →
126
+ `null`, all else rethrown. No second in-memory key store. †
169
127
  - **`jwtBearerResolver` is its twin for an external JWT**: `checkWorkloadToken`'s checks, then
170
128
  scope, then `accept(raw)`. Every refusal — a throwing key source too — is `null` plus one
171
129
  `auth.jwt_bearer.refused` line naming the check, never a claim. `keys` is a thunk.
172
- - Rotate the session id on any privilege change (`rotateSession`, called by `updatePrivileges` in
173
- `privileges.ts`), never patch the row. A `passwordHash` change also ends every OTHER session of
174
- that user (`deleteOtherSessions`), or all of them when the caller passed no session of theirs.
175
- **A new id, never a new lifetime**: rotation carries `createdAt` and `absoluteExpiresAt` over
176
- (`createSession` clamps both, so they only shorten), and the rotated cookie's `Max-Age` is
177
- `remainingMaxAgeSeconds` — `session-rotation.test.ts` pins both adapters.
178
- - **Every argon2 call goes through `kdfGate()`** — width 8, queue 64, `X_OVERLOADED` past it
179
- (borrowed from http, in `AUTH_BORROWED_ERROR_CODES`). The pool is core's `flightGate`, the
180
- refusal auth's own through core's `overflow:` seam. `configureKdfGate()` is the ONE install point
181
- and deliberately not a `defineAuth` key.
182
- - **MFA's second leg is `completeMfa(auth, challenge, code)`** (`mfa-challenge.ts`). `X_MFA_REQUIRED`'s
183
- `meta.challenge` is a `seal()` value, never a bare user id; the code is reserved against the three
184
- buckets before it is compared; a recovery code is consumed by one conditional UPDATE. Full rule: `docs/history/auth.md`.
130
+ - Rotate the session id on any privilege change (`rotateSession`); **a new id, never a new
131
+ lifetime**. A `passwordHash` change ends every other session. †
132
+ - **Every argon2 call goes through `kdfGate()`** (width 8, queue 64, `X_OVERLOADED`);
133
+ `installKdfGate()` on `./adapter` is the ONE install point. †
134
+ - **MFA's second leg is `completeMfa(auth, challenge, code)`**; `X_MFA_REQUIRED`'s challenge is a
135
+ `seal()` value. †
185
136
  - **`mfa.required` is the literal `false`, and `defineAuth` refuses a `true`** (`X_CONFIG_INVALID`).
186
137
  `mfa.issuer` is read by `enrolTotp(auth, { account })`.
187
138
  - **A TOTP secret that decodes to zero bytes is NO key**: `verifyTotp` returns the generic failure
188
139
  (`{ ok: false, step: null }`), `totpCode` and `enrolTotp` throw `X_MFA_SECRET_INVALID`. The secret
189
140
  never reaches `cause:`/`fix:`. No length floor beyond one byte.
190
- - **`TotpReplayGuard` is one atomic `claim(subject, step, at): Promise<boolean>`**; a rejection is
191
- no verdict (`attempt.release()`, rethrown). Precedence: `config.totpReplay` →
192
- `configureTotpReplay`'s factory → memory. `postgresTotpReplayGuard`: `x_totp_steps`, one
193
- `on conflict do nothing`; `purgeExpired(nowMs)` keeps slack past the drift.
194
- - **`memoryTotpReplayGuard`'s table is bounded and the eviction ORDER is the guarantee**: subjects
195
- whose steps are all below the drift floor are forgotten first; past `DEFAULT_MAX_TOTP_SUBJECTS`,
196
- evict by newest spent step ascending, least-recently-seen tie-break. `maxSubjects` is normalised
197
- first (`boundedSubjects`): anything not a positive finite integer takes the default.
198
- - **Passkeys are verified by hand** (`passkey-*.ts`, `cbor.ts`, `cose-key.ts`, `webauthn-data.ts`):
199
- UV required, origin exact, the key's alg (never the assertion's), counter non-regression by
200
- compare-and-set, challenges single-use in `x_passkey_challenges` (never a process map). A step-up
201
- challenge is DERIVED (`stepUpChallenge`), never supplied. Browser half: `./client`, two modules
202
- (`client-bundle.test.ts`); never import the barrel there. `docs/history/auth.md`.
203
- - SAML is out of scope permanently (XML-DSig canonicalisation has no Bun native). Put an
204
- OIDC-speaking bridge in front and register that.
141
+ - **`TotpReplayGuard` is one atomic `claim(subject, step, at): Promise<boolean>`**; a rejection is no verdict. Full rule: `docs/history/auth.md`.
142
+ - **`memoryTotpReplayGuard`'s table is bounded and the eviction ORDER is the guarantee.** Full rule: `docs/history/auth.md`.
143
+ - **Passkeys are verified by hand** (`passkey-*.ts`, `cbor.ts`, `cose-key.ts`): UV required, exact
144
+ origin, compare-and-set counter, single-use challenges. †
145
+ - **SAML = `samlIdentityProvider`**, no dependency: assertion signature required, one assertion,
146
+ metadata keys only, replay via `auth.totpReplay`, `trustEmail: true` needs `emailDomains`.
205
147
 
206
148
  ## Files
207
149
 
208
150
  | File | Job |
209
151
  |---|---|
210
- | `auth.ts` | `defineAuth`, entity schemas, `login`/`register`/`authenticate`/`logout`. Three suites, split at the line ceiling along the three subjects: `auth.test.ts` (the credential flow), `auth-config.test.ts` (`defineAuth`'s defaults and refusals) and `auth-lockout.test.ts` (the tenant and address buckets) |
152
+ | `auth.ts` | `defineAuth`, entity schemas, `login`/`register`/`authenticate`/`logout`. Suites: `auth.test.ts` (credential flow), `auth-config.test.ts` (defaults, refusals), `auth-lockout.test.ts` (tenant and address buckets) |
211
153
  | `policy-bridge.ts` | the one funnel: identity → `Actor`, all four `ActorKind`s |
212
154
  | `session.ts` | two expiries, rotation, revocation, device list, the cookie |
213
155
  | `adapter.ts` | the seam; `builtin-adapter.ts` (Postgres) + `memory-adapter.ts` |
156
+ | `adapter-subpath.ts` | `@ultimat3/auth/adapter`: the `authLimiter`/`totpReplay`/`authServerStore` port types, their installers and resets, and `installKdfGate` |
157
+ | `authorization-server-installed.ts` | the `authServerStore` slot: `authorizationServer()` with no `store` reads it per CALL (never captured: `runtime.ts` may build the server first); empty, Postgres over the ambient pool |
214
158
  | `builtin-adapter-violation.ts` | a Postgres unique violation on an auth table → `authUniqueViolation` |
215
159
  | `rate-limit.ts` | per-ip, per-account and per-org buckets as reservations, lockout, scope check, `loginFailed()` |
216
160
  | `login-attempt.ts` | one attempt's reservations across the three buckets, taken before the check and settled after it |
@@ -236,10 +180,9 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
236
180
  | `oauth-profile.ts` | claims or userinfo → one `OAuthProfile`; `userinfoBody`, the one userinfo reader |
237
181
  | `oauth-grant-context.ts` | `resolveGrants`' second argument: tokens, every id-token claim, a lazy subject-checked `userinfo()` |
238
182
  | `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |
239
- | `oauth-paths.ts` | the one declaration of where the two routes live. Imports nothing |
240
183
  | `errors.ts` | the codes this package owns and borrows, their titles, the one `registerErrorCodes()` call, `AuthError`, and every non-OAuth factory |
241
184
  | `oauth-errors.ts` | the OAuth refusals more than one OAuth module raises, and `restartAt`. A refusal with one thrower lives beside it; declares no code and registers nothing |
242
- | `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
185
+ | `oauth-route.ts` | `oauthLogin(auth)`, the OAuth policy over the sign-in route core |
243
186
  | `kdf-gate.ts` | the one bound on concurrent argon2 work: core's `flightGate`, auth's `X_OVERLOADED` |
244
187
  | `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
245
188
  | `json.ts` | reading untrusted JSON: `isRecord`, and a base64url JWT segment as an object or `null` |
@@ -247,9 +190,11 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
247
190
  | `subject-target.ts` | `authSubjectTarget({ executor, user })`: the identity tables as a `defineSubject({ targets })` entry, structural (`@ultimat3/jobs` imports nothing from here). Export = `AUTH_SUBJECT_COLUMNS` (never `password_hash`, `mfa_secret`, a recovery digest); erase = api keys, pending verifications, then the user (sessions, accounts cascade). Three idempotent statements, no transaction of its own |
248
191
  | `passkey-login.ts` / `passkey-step-up.ts` / `passkey-evidence.ts` | the factories; the offline re-check. `passkey-verify.ts` + `passkey-assertion.ts` are every check; `*-postgres`/`*-memory` the store; `soft-authenticator.ts` (`./soft-authenticator`) the WebCrypto authenticator tests sign with |
249
192
  | `client-scope.ts` | `clientScopeOf(actor)`, the OPAQUE id for `<meta name="ultimate-scope">`: keyed SHA-256 (`SESSION_SECRET`, else a per-process key and one warning) over kind, id, impersonator; `''` anonymous |
193
+ | `identity-provider*.ts` | SSO port, `signInWithIdentity`, OIDC adapter, route core, state cookie, status table |
194
+ | `saml-*.ts` | SAML adapter: strict XML reader, exc-c14n, XML-DSig, response checks, metadata, AuthnRequest |
250
195
 
251
196
  ```bash
252
- bun test packages/auth
197
+ bun test ./packages/auth/
253
198
  bun run --filter @ultimat3/auth typecheck
254
199
  ```
255
200
 
@@ -259,7 +204,8 @@ Gotchas:
259
204
  - `errors.ts` registers only the codes this package **owns**, unconditionally; borrowed ones
260
205
  (`X_FORBIDDEN`, `X_NOT_IMPLEMENTED`, …) sit untitled in `AUTH_BORROWED_ERROR_CODES`. Never a
261
206
  `hasErrorCode()` guard.
262
- - Tests run against `MemoryAuthAdapter`; `adapter-parity-identity.test.ts` boots PGlite. The two
207
+ - Tests run against `MemoryAuthAdapter`; `authAdapterConformance` and `authServerStoreConformance`
208
+ (`@ultimat3/testing/conformance`) run memory and Postgres through one suite each. The two
263
209
  Postgres guards pair a scripted twin with a `*.live.test.ts` (skips without `TEST_DATABASE_URL`).
264
210
 
265
211
  Why each rule above is shaped the way it is: [`docs/history/auth.md`](../../docs/history/auth.md).
package/README.md CHANGED
@@ -24,7 +24,7 @@ const { actor, token, cookie } = await login(auth, { email, password, ip });
24
24
  // a normalised handle to the auth user's id, and `register(auth, { id, ... })` links the two rows.
25
25
  const byHandle = await login(auth, { handle: 'ada', password, ip });
26
26
 
27
- // "Log in with GitHub" is a link to /auth/oauth/github. These two routes are what serves it.
27
+ // "Log in with GitHub" is a link to /auth/sso/github. These two routes are what serves it.
28
28
  const { start, callback } = oauthLogin(auth);
29
29
  ```
30
30
 
@@ -217,14 +217,17 @@ A limiter of your own implements the same five members:
217
217
  `defineAuth` in the process picks it up:
218
218
 
219
219
  ```ts
220
- import { configureAuthLimiters, type PgExecutor, postgresAuthLimiter } from '@ultimat3/auth';
220
+ // The HOST's code, not an app's: `@ultimat3/cli`'s boot does exactly this. The installer is on
221
+ // `@ultimat3/auth/adapter` only (31.0.0) — the `.` barrel carries no setter.
222
+ import { type PgExecutor, postgresAuthLimiter } from '@ultimat3/auth';
223
+ import { installAuthLimiters } from '@ultimat3/auth/adapter';
221
224
  import type { Clock } from '@ultimat3/core';
222
225
 
223
226
  declare const bootExecutor: PgExecutor;
224
227
  declare const bootClock: Clock;
225
228
 
226
229
  // In the HOST, before the app's modules import.
227
- configureAuthLimiters((policy) =>
230
+ installAuthLimiters((policy) =>
228
231
  postgresAuthLimiter({ executor: bootExecutor, clock: bootClock, policy }),
229
232
  );
230
233
  ```
@@ -234,7 +237,7 @@ limiter enforces against what the app declared, so a limiter built at boot on th
234
237
  defaults would be `X_AUTH_LIMITER_POLICY_MISMATCH` for every app that tuned its numbers. The
235
238
  factory is called once per bucket, with the resolved policy, so the two halves cannot disagree.
236
239
  Precedence is `defineAuth({ limiter })` → the installed factory → `memoryAuthLimiter`, and
237
- `resetAuthLimiters()` puts the per-process default back. `@ultimat3/cli`'s `startServices` calls it
240
+ `resetAuthLimiters()` (also `@ultimat3/auth/adapter`) puts the per-process default back. `@ultimat3/cli`'s `startServices` calls it
238
241
  on every boot, so a scaffolded app gets a fleet-wide lockout with nothing to remember.
239
242
 
240
243
  The table does not forget on its own, and `purgeAuthLimits()` is the framework's reader for that:
@@ -314,11 +317,9 @@ registerOAuthProvider(
314
317
  PKCE stays mandatory for all three: for a public client it is the only thing binding the code to
315
318
  the browser that started the flow.
316
319
 
317
- **SAML is out of scope and will stay out of scope.** XML-DSig canonicalisation has no Bun native
318
- and implementing it would mean a real dependency in the primitive vocabulary, which
319
- [`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md) does not permit. The honest
320
- answer is to put an OIDC-speaking SAML bridge (Okta, Entra, Keycloak, Dex, a SAML-to-OIDC proxy) in
321
- front and register **that** as a provider.
320
+ **SAML 2.0 is supported natively since 31.0.0** — see [SSO: one IdentityProvider port, OIDC and
321
+ SAML](#sso-one-identityprovider-port-oidc-and-saml). No dependency: the XML reader, exclusive c14n
322
+ and XML-DSig verification are this package's own, held to xmlsec-signed vectors.
322
323
 
323
324
  ## Id token signatures
324
325
 
@@ -463,7 +464,7 @@ every enrolled user's second factor.
463
464
  | `TotpReplayGuard` | one atomic method, `claim(subject, step, at): Promise<boolean>`: `true` the first time, `false` for a replay. A store that cannot answer rejects: a down database is a fault, never a wrong code |
464
465
  | `memoryTotpReplayGuard(drift?, maxSubjects?)` | the in-process guard, `{ claim, isUsed, size }`; `isUsed` is for a test. The default only where no host installed one |
465
466
  | `postgresTotpReplayGuard({ executor })` | the shared guard: `x_totp_steps` (`SQL_TOTP_REPLAY_TABLE`), one `insert … on conflict do nothing` per claim, `purgeExpired(nowMs)` for the sweep. A framework boot installs it |
466
- | `configureTotpReplay(() => guard)` / `resetTotpReplay()` | the host's install point, beside `configureAuthLimiters`. Precedence: `defineAuth({ totpReplay })` → the installed factory → memory |
467
+ | `installTotpReplay(() => guard)` (`@ultimat3/auth/adapter`) / `resetTotpReplay()` (both `@ultimat3/auth/adapter`) | the host's install point, beside `installAuthLimiters`. Precedence: `defineAuth({ totpReplay })` → the installed factory → memory |
467
468
  | `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
468
469
  | `recoveryCodeHash(code)` | what a code is stored and looked up as — dashes, spaces and case removed first. `auth.adapter.consumeRecoveryCode(userId, hash)` removes it in ONE statement and answers whether it was there, so two requests carrying one code cannot both redeem it |
469
470
  | `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code. `totpCode` throws `X_MFA_SECRET_INVALID` on a secret that decodes to zero bytes |
@@ -659,7 +660,10 @@ OAuth 2.1 server: authorization code + PKCE (S256 only), refresh rotation with r
659
660
  `runtime.routes`; the consent and device pages are the app's (`readRequest`/`approve`/`deny`,
660
661
  `readDevice`/`approveDevice`/`denyDevice`). `oauthBearerResolver(server, { actor, resource? })` is
661
662
  the `resolveToken`. Stores: `memoryAuthServerStore()`, `postgresAuthServerStore({ executor })`
662
- over `SQL_AUTH_SERVER_TABLES`, which the boot applies. Full table and the rule each test holds:
663
+ over `SQL_AUTH_SERVER_TABLES`, which the boot applies. A code is spent only by a request that could
664
+ redeem it: client, PKCE verifier, `redirect_uri` and `resource` are checked on `getCode` before
665
+ `takeCode` marks it used, so a request without the verifier neither burns a login nor trips the
666
+ replay revocation. `pollDevice(hash, clientId, at)` stamps only the issuing client's poll. Full table and the rule each test holds:
663
667
  [wiki Auth](https://github.com/developerz-ai/ultimate/wiki/Auth#authorization-server).
664
668
 
665
669
  | File | Job |
@@ -839,7 +843,9 @@ const mounted = (leg: AuthRouteDescriptor): Route => ({
839
843
  method: leg.method,
840
844
  path: leg.path,
841
845
  handler: (request) => leg.handle(request.raw),
842
- meta: { name: leg.name, auth: leg.auth },
846
+ // Verbatim: the SAML ACS (POST) declares `csrf: 'cookieless'` + `authenticate: () => null`,
847
+ // because the IdP answers with a cross-site form. Hand-copy `name`/`auth` and it is a 403.
848
+ meta: leg.meta,
843
849
  });
844
850
 
845
851
  export const runtime: NonNullable<ServeOptions['runtime']> = {
@@ -849,8 +855,8 @@ export const runtime: NonNullable<ServeOptions['runtime']> = {
849
855
 
850
856
  | | `start` | `callback` |
851
857
  |---|---|---|
852
- | path | `/auth/oauth/:provider` | `/auth/oauth/:provider/callback` |
853
- | success | `302` to the provider, `Set-Cookie: __Host-x_oauth_<provider>` | `303` to `successPath`, `Set-Cookie: __Host-x_session` **and** the handshake cleared |
858
+ | path | `/auth/sso/:provider` | `/auth/sso/:provider/callback` |
859
+ | success | `302` to the provider, `Set-Cookie: __Host-x_sso_<provider>` | `303` to `successPath`, `Set-Cookie: __Host-x_session` **and** the handshake cleared |
854
860
  | failure | coded JSON, status per code: `code` + `title` + `docs`, one fixed `cause`, `fix: x errors explain <CODE> --json` | the same, handshake cleared either way |
855
861
 
856
862
  A **descriptor**, never a mounted handler — the same category as `mcpHttpRoute()`. `@ultimat3/http`
@@ -860,9 +866,11 @@ router that can match a `:param` — and `runtime.routes` is the one an app has.
860
866
  framework's own routes and before the pages; every field it shares the file with is in
861
867
  [`wiki/Configuration.md`](../../wiki/Configuration.md#runtime-overrides--appsappruntimets).
862
868
 
863
- **The paths are not configurable.** `X_OAUTH_STATE_INVALID` has always told the caller to restart
864
- at `GET /auth/oauth/<provider>`; it now quotes `oauthStartPath()`, the same declaration the mount
865
- reads. A movable base path is that sentence going stale again.
869
+ **The paths are not configurable.** `X_OAUTH_STATE_INVALID` quotes `oauthStartPath()`, the same
870
+ declaration the mount reads. A movable base path is that sentence going stale again. Since 31.0.0
871
+ the base is `/auth/sso`: `oauthLogin` is a front door over `identityProviderRoutes`' legs, so OAuth
872
+ and SAML share the paths, the sealed state cookie and the refusal body (re-register the callback
873
+ URL at each IdP — `wiki/Upgrading.md`).
866
874
 
867
875
  `oauthLogin` needs an origin to build the provider's `redirect_uri` from: `baseUrl`, or `APP_URL`.
868
876
  With neither, the start leg refuses with `X_ENV_MISSING` rather than trust a request header.
@@ -899,6 +907,10 @@ SameSite=Lax` under a `__Host-` name, and expired against the server's clock rat
899
907
  client's copy of `Max-Age`. `sealHandshake` / `openHandshake` are the same codec without the
900
908
  cookie, for an app that would rather keep it server-side.
901
909
 
910
+ `oauthLogin` itself no longer uses this cookie (31.0.0): its state rides the sign-in routes' own
911
+ `__Host-x_sso_<provider>`, sealed the same way and claimed once in `auth.totpReplay`. The
912
+ `handshakeCookie` family stays for a hand-built flow over `beginOAuth` / `completeOAuthLogin`.
913
+
902
914
  **One cookie per provider:** `handshakeCookieName(provider)` → `__Host-x_oauth_github`. A browser
903
915
  is one cookie jar and a user is allowed two tabs, so a single shared name means the `google`
904
916
  redirect overwrites a `github` handshake still in flight — and the github callback then opens
@@ -943,6 +955,64 @@ both callback routes are GET. An app that needs it registers its own with `regis
943
955
  `loginFailed()` the taken-address case answers. Minting one is a pre-hijack: the owner's
944
956
  `register()` then collides and their verified login is refused, with the attacker still linked.
945
957
 
958
+ ## SSO: one IdentityProvider port, OIDC and SAML
959
+
960
+ Every federated sign-in is an `IdentityProvider`: `begin` sends the browser out, `complete` takes
961
+ the answer back and proves one `OAuthProfile`. Two adapters, one set of routes, one session path.
962
+
963
+ ```ts
964
+ import {
965
+ defineAuth,
966
+ identityProviderRoutes,
967
+ memoryAuthAdapter,
968
+ oidcIdentityProvider,
969
+ parseSamlMetadata,
970
+ samlIdentityProvider,
971
+ } from '@ultimat3/auth';
972
+
973
+ // The app's group → role mapping: business convention, so it is yours (axiom 8).
974
+ declare function groupsToRoles(claims: Readonly<Record<string, unknown>>): string[];
975
+
976
+ const auth = defineAuth({ adapter: memoryAuthAdapter(), providers: ['corp', 'github'] });
977
+ const routes = identityProviderRoutes(auth, {
978
+ providers: [
979
+ samlIdentityProvider({
980
+ id: 'corp',
981
+ idp: parseSamlMetadata(await Bun.file('config/corp-idp-metadata.xml').text()),
982
+ spEntityId: 'https://app.example.com/saml/metadata',
983
+ trustEmail: true, // required: does this IdP vouch for the addresses it asserts?
984
+ emailDomains: ['corp.example'], // required with true: only these domains are believed
985
+ }),
986
+ oidcIdentityProvider({ provider: 'github' }),
987
+ ],
988
+ resolveGrants: (identity) => ({ roles: groupsToRoles(identity.claims) }), // yours, never shipped
989
+ });
990
+ // routes.start GET /auth/sso/:provider → 302 to the IdP
991
+ // routes.acs POST /auth/sso/:provider/callback → SAML ACS (register this URL at the IdP)
992
+ // routes.callback GET /auth/sso/:provider/callback → OIDC redirect_uri
993
+ ```
994
+
995
+ | Rule | Why |
996
+ |---|---|
997
+ | The assertion **itself** must be signed; a signed response around an unsigned assertion is refused | the node that is read is the node that was verified — no wrapping |
998
+ | Exactly one `saml:Assertion` in the whole document, a direct child of the Response; no ID twice; no `ds:Object` | the signature-wrapping (XSW) attacks all need a second candidate |
999
+ | The `Reference` must name the enveloping element's own `ID`; transforms exactly enveloped-signature + exc-c14n | no XPath/XSLT, no detached reference |
1000
+ | RSA-SHA256 with SHA-256 digests, RSA ≥ 2048 | SHA-1 is refused |
1001
+ | Keys come from metadata only; `KeyInfo` is never read | a self-signed cert in the response proves nothing |
1002
+ | NameID text is every text node concatenated | `a@corp.com<!---->.evil.com` reads as what was signed |
1003
+ | Recipient, Destination, InResponseTo, Audience, NotBefore/NotOnOrAfter (±`skewMs`, 60 s) | SP-initiated only: an unsolicited response has no InResponseTo to match |
1004
+ | One answer completes once: claimed in `auth.totpReplay` (the shared replay store) until it expires | replay across replicas |
1005
+ | DOCTYPE, entities, processing instructions refused by the reader | XXE, billion laughs |
1006
+ | An address is verified only inside `emailDomains` (exact; list subdomains); outside, it can neither link nor create an account. `trustEmail: true` with no domains does not compile, and refuses at construction (`X_CONFIG_INVALID`) | an IdP's admin can assert ANY address — unbound, one tenant's IdP signs in as another tenant's verified user |
1007
+ | Transient NameIDs refused | linked by a per-login id, every sign-in is a stranger |
1008
+ | Encrypted assertions refused (`meta.check: 'encrypted'`) | not supported |
1009
+
1010
+ Every SAML refusal is `X_SAML_RESPONSE_INVALID` with the failed check in `meta.check` and a fix per
1011
+ check, logged as `auth.sso.refused`; the anonymous caller gets the code and title only. Bad metadata
1012
+ is `X_SAML_METADATA_INVALID` at boot. An id the routes do not serve is `X_IDENTITY_PROVIDER_UNKNOWN`.
1013
+ An adapter of your own is held to the same contract by
1014
+ `identityProviderConformance` (`@ultimat3/testing/conformance`).
1015
+
946
1016
  ## API keys — how an agent authenticates
947
1017
 
948
1018
  `ult_<env>_<id>_<secret>`. The plaintext is shown once; the row holds `sha256(secret)` and is
@@ -966,6 +1036,12 @@ export const actor = apiKeyActor(await verifyApiKey(auth.adapter, plaintext));
966
1036
  | names no `userId` | nothing more — a key no user owns is cut by its scopes and its org alone |
967
1037
  | names a `userId` | re-reads that user from the store. Missing or disabled is the same `X_API_KEY_INVALID` every other rejection is, on the very next use; `disableUser` also revokes the key outright |
968
1038
 
1039
+ The `env` segment is part of the credential: a key issued `dev` and presented as `ult_prod_…` is
1040
+ the same `X_API_KEY_INVALID`, so the triage label cannot be forged. `lastUsedAt` is written at most
1041
+ once per `API_KEY_TOUCH_MS` (60 s), `As of 2026-11`: a dashboard's "last used", never an audit
1042
+ trail — N requests a second on one key are N reads, not N row-locked `UPDATE`s, the trade a
1043
+ session's `idleSlideMs` makes.
1044
+
969
1045
  A key's scopes only ever shrink on the way to the actor — never the owning user's roles, never a
970
1046
  default set:
971
1047
 
@@ -1026,7 +1102,7 @@ be built before boot installs the client, and joins an open `withTransaction`. I
1026
1102
  `authSubjectTarget({ executor, user })` is the identity tables as a `defineSubject({ targets })`
1027
1103
  entry (`@ultimat3/jobs`). `user(subject)` names the `x_users.id` the subject is
1028
1104
  (`({ row }) => String(row.userId)`), or `undefined` for none. The export carries the `x_users` row
1029
- (`AUTH_SUBJECT_COLUMNS`) — never `password_hash`, `mfa_secret` or a recovery digest; the erase
1105
+ (its named columns) — never `password_hash`, `mfa_secret` or a recovery digest; the erase
1030
1106
  deletes the user (sessions and accounts cascade), their API keys and pending verifications. A
1031
1107
  user in several orgs is erased from all of them: list it `{ target, retain }` when that is wrong.
1032
1108
 
@@ -1045,7 +1121,7 @@ user in several orgs is erased from all of them: list it `{ target, retain }` wh
1045
1121
  | `X_OAUTH_PROVIDER_DUPLICATE` | two `registerOAuthProvider` calls claimed one id — at boot, never at a login |
1046
1122
  | `X_OAUTH_DENIED` | the user pressed Cancel, or the provider declined — `403`, never a `502` |
1047
1123
  | `X_PASSWORD_WEAK` | strength check rejected the password |
1048
- | `X_ACCOUNT_LOCKED` | the per-ip, per-account or per-org bucket is inside its lockout. The cause names the caller's own address or email, never an org |
1124
+ | `X_ACCOUNT_LOCKED` | the per-ip, per-account or per-org bucket is inside its lockout. The per-ip bucket counts a network (`ipKey` over core's `addressNetwork`: IPv4 whole, IPv6 by its /64). The cause names the caller's own network or email, never an org |
1049
1125
  | `X_API_KEY_INVALID` | key unknown, revoked, expired, wrong, or owned by a user who is gone or disabled |
1050
1126
  | `X_ENV_MISSING` | `oauthCredentials()` found no client id or secret for an enabled provider |
1051
1127
  | `X_AUTH_WRITE_FAILED` | an `AuthAdapter` lost a write it accepted — `emailVerifiedNotStored(provider, userId)` when `updateUser` drops the OAuth verified stamp |
@@ -1055,7 +1131,7 @@ user in several orgs is erased from all of them: list it `{ target, retain }` wh
1055
1131
  | `X_AUTH_SIGN_IN_UNCONFIGURED` | `signInUnconfigured(role, missing)`, thrown by the boot guard `x g sign-in` writes into the app: a `web` or `sync` role outside development and test with sign-in keys unset. Off the socket — a boot refusal, never a response |
1056
1132
 
1057
1133
  ```bash
1058
- bun test packages/auth
1134
+ bun test ./packages/auth/
1059
1135
  bun run --filter @ultimat3/auth typecheck
1060
1136
  ```
1061
1137
 
@@ -1066,7 +1142,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
1066
1142
 
1067
1143
  | Class | Code | Declared in |
1068
1144
  |---|---|---|
1069
- | `AuthError` | any `AuthErrorCode` — `AUTH_ERROR_CODES` | `src/errors.ts` |
1145
+ | `AuthError` | any `AuthErrorCode` | `src/errors.ts` |
1070
1146
 
1071
1147
  ## Supporting exports
1072
1148
 
@@ -1076,12 +1152,11 @@ a job boundary the class is gone and the `code` is what survives — match on th
1076
1152
  |---|---|
1077
1153
  | `JwtBearerCaller` | what `jwtBearerResolver` answers: `{ actor, scopes }`, structurally `ApiKeyCaller` |
1078
1154
  | `JwtBearerResolverOptions` | what `jwtBearerResolver` takes |
1079
- | `JwtBearerCheck` | the check a refusal's log line names: a `WorkloadCheck`, or `key-set`, `scope`, `accept` |
1080
1155
  | `WorkloadCheck` · `WorkloadTokenCheck` | `checkWorkloadToken`'s verdict: the token, or the check that refused it |
1081
1156
  | `PostgresTotpReplayGuard` | the `TotpReplayGuard` `postgresTotpReplayGuard` returns |
1082
1157
  | `PostgresTotpReplayGuardOptions` | what `postgresTotpReplayGuard` takes |
1083
- | `TotpReplayFactory` | `() => TotpReplayGuard`: what `configureTotpReplay` takes |
1084
- | `PasskeyPolicy` · `PasskeyPolicyInput` · `PasskeyRelyingParty` · `DEFAULT_PASSKEY_CHALLENGE_TTL_MS` | the `passkeys` block, resolved and as written; the `{ rpId, origins }` a verifier needs |
1158
+ | `TotpReplayFactory` | `() => TotpReplayGuard`: what `installTotpReplay` takes (`@ultimat3/auth/adapter`) |
1159
+ | `PasskeyPolicy` · `PasskeyPolicyInput` · `PasskeyRelyingParty` | the `passkeys` block, resolved and as written; the `{ rpId, origins }` a verifier needs |
1085
1160
  | `PasskeyCredential` · `PasskeyChallengeRecord` · `PasskeyEvidence` · `PasskeyCeremony` · `PasskeyStore` | the passkey rows and the adapter slice that stores them |
1086
- | `PasskeyCreationOptions` · `PasskeyRequestOptions` · `PasskeyRegistrationResponse` · `PasskeyAssertionResponse` · `PasskeyCredentialDescriptor` | the JSON wire of a ceremony (WebAuthn L3's `*JSON` dictionaries) |
1161
+ | `PasskeyCreationOptions` · `PasskeyRequestOptions` · `PasskeyCredentialDescriptor` (the two responses: `@ultimat3/auth/client`) | the JSON wire of a ceremony (WebAuthn L3's `*JSON` dictionaries) |
1087
1162
  | `PasskeyLogin` · `PasskeyStepUp` · `PasskeyStepUpOptions` | what the two factories answer and take |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "30.9.0",
3
+ "version": "31.0.0",
4
4
  "description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "exports": {
30
30
  ".": "./src/index.ts",
31
+ "./adapter": "./src/adapter-subpath.ts",
31
32
  "./client": "./src/client.ts",
32
33
  "./soft-authenticator": "./src/soft-authenticator.ts"
33
34
  },
@@ -47,8 +48,8 @@
47
48
  "test": "bun test"
48
49
  },
49
50
  "dependencies": {
50
- "@ultimat3/core": "30.9.0",
51
- "@ultimat3/db": "30.9.0",
52
- "@ultimat3/schema": "30.9.0"
51
+ "@ultimat3/core": "31.0.0",
52
+ "@ultimat3/db": "31.0.0",
53
+ "@ultimat3/schema": "31.0.0"
53
54
  }
54
55
  }
@@ -0,0 +1,34 @@
1
+ // `@ultimat3/auth/adapter` — auth's three `ADAPTER_PORTS` rows (`authLimiter`, `totpReplay`,
2
+ // `authServerStore`) and the KDF gate's width: interfaces, installers and resets, never on the `.`
3
+ // barrel, so an app swaps each one way — `apps/<app>/runtime.ts`. Not `src/adapter.ts`: that is
4
+ // the `AuthAdapter` seam's.
5
+
6
+ export {
7
+ installAuthServerStore,
8
+ installedAuthServerStore,
9
+ resetAuthServerStore,
10
+ } from './authorization-server-installed';
11
+ export type {
12
+ AuthServerStore,
13
+ DeviceDecision,
14
+ RefreshRotation,
15
+ } from './authorization-server-store';
16
+ export type {
17
+ AuthCodeRecord,
18
+ AuthGrantRecord,
19
+ AuthServerClient,
20
+ AuthTokenKind,
21
+ AuthTokenRecord,
22
+ DeviceCodeRecord,
23
+ DeviceStatus,
24
+ } from './authorization-server-types';
25
+ export { installKdfGate, resetKdfGate } from './kdf-gate';
26
+ export type { AuthLimiterFactory, TotpReplayFactory } from './limiter-install';
27
+ export {
28
+ installAuthLimiters,
29
+ installTotpReplay,
30
+ resetAuthLimiters,
31
+ resetTotpReplay,
32
+ } from './limiter-install';
33
+ export type { TotpReplayGuard } from './mfa';
34
+ export type { AuthLimiter, AuthLimiterScope, AuthRateLimitPolicy } from './rate-limit';