@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.
- package/CLAUDE.md +55 -109
- package/README.md +100 -25
- package/package.json +5 -4
- package/src/adapter-subpath.ts +34 -0
- package/src/api-keys.ts +14 -1
- package/src/auth.ts +3 -3
- package/src/authorization-server-clients.ts +19 -1
- package/src/authorization-server-config.ts +13 -4
- package/src/authorization-server-installed.ts +34 -0
- package/src/authorization-server-postgres.ts +4 -2
- package/src/authorization-server-store.ts +14 -4
- package/src/authorization-server.ts +30 -10
- package/src/client-scope.ts +1 -1
- package/src/errors.ts +9 -1
- package/src/grant-authorization-code.ts +21 -16
- package/src/grant-device.ts +2 -2
- package/src/identity-provider-cookie.ts +89 -0
- package/src/identity-provider-oidc.ts +142 -0
- package/src/identity-provider-route-core.ts +253 -0
- package/src/identity-provider-routes.ts +78 -0
- package/src/identity-provider-status.ts +45 -0
- package/src/identity-provider.ts +112 -0
- package/src/index.ts +34 -54
- package/src/jwks.ts +2 -2
- package/src/kdf-gate.ts +24 -14
- package/src/limiter-install.ts +26 -16
- package/src/memory-adapter.ts +5 -5
- package/src/mfa.ts +1 -1
- package/src/oauth-discovery.ts +1 -1
- package/src/oauth-errors.ts +26 -2
- package/src/oauth-exchange.ts +2 -2
- package/src/oauth-paths.ts +5 -2
- package/src/oauth-route.ts +60 -297
- package/src/rate-limit.ts +7 -2
- package/src/saml-assertion.ts +156 -0
- package/src/saml-c14n.ts +118 -0
- package/src/saml-certificate.ts +47 -0
- package/src/saml-dom.ts +66 -0
- package/src/saml-dsig.ts +165 -0
- package/src/saml-email-trust.ts +49 -0
- package/src/saml-errors.ts +96 -0
- package/src/saml-metadata.ts +80 -0
- package/src/saml-provider.ts +179 -0
- package/src/saml-request.ts +51 -0
- package/src/saml-response.ts +131 -0
- 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
|
|
20
|
-
|
|
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`
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
-
|
|
53
|
-
|
|
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
|
-
-
|
|
99
|
-
|
|
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
|
-
- **
|
|
116
|
-
|
|
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
|
|
134
|
-
`
|
|
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
|
|
147
|
-
|
|
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
|
|
156
|
-
matched
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
- **
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
- **`apiKeyResolver(() => store)` is the ONE token → caller mapping
|
|
165
|
-
`
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
`
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
-
|
|
195
|
-
|
|
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`.
|
|
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)
|
|
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`; `
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
318
|
-
and
|
|
319
|
-
|
|
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
|
-
| `
|
|
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.
|
|
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
|
-
|
|
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/
|
|
853
|
-
| success | `302` to the provider, `Set-Cookie: __Host-
|
|
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`
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
(
|
|
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
|
|
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`
|
|
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 `
|
|
1084
|
-
| `PasskeyPolicy` · `PasskeyPolicyInput` · `PasskeyRelyingParty`
|
|
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` · `
|
|
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": "
|
|
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": "
|
|
51
|
-
"@ultimat3/db": "
|
|
52
|
-
"@ultimat3/schema": "
|
|
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';
|