@ultimat3/auth 1.2.0 → 3.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 +275 -0
- package/README.md +438 -35
- package/package.json +5 -4
- package/src/adapter.ts +69 -4
- package/src/auth.ts +122 -18
- package/src/builtin-adapter.ts +83 -6
- package/src/directory.ts +77 -0
- package/src/email.ts +17 -0
- package/src/errors.ts +239 -14
- package/src/guards.ts +6 -26
- package/src/id-token.ts +48 -26
- package/src/index.ts +109 -13
- package/src/json.ts +33 -0
- package/src/jwks.ts +246 -0
- package/src/kdf-gate.ts +86 -0
- package/src/memory-adapter.ts +66 -4
- package/src/mfa.ts +104 -9
- package/src/oauth-builtins.ts +77 -0
- package/src/oauth-cookie.ts +4 -3
- package/src/oauth-discovery.ts +132 -0
- package/src/oauth-exchange.ts +40 -18
- package/src/oauth-login-fixture.ts +53 -0
- package/src/oauth-login.ts +111 -14
- package/src/oauth-paths.ts +20 -0
- package/src/oauth-profile.ts +9 -10
- package/src/oauth-registry.ts +65 -0
- package/src/oauth-route.ts +293 -0
- package/src/oauth.ts +31 -58
- package/src/password.ts +65 -13
- package/src/policy-bridge.ts +11 -5
- package/src/privileges.ts +74 -0
- package/src/rate-limit.ts +178 -15
- package/src/revocation.ts +100 -0
- package/src/session.ts +33 -4
- package/src/tables.ts +18 -2
- package/src/tokens.ts +26 -17
- package/src/verify.ts +12 -5
- package/src/workload.ts +131 -0
package/README.md
CHANGED
|
@@ -5,17 +5,21 @@ authorizes on a session row, a user row or an api key — http, actions, jobs an
|
|
|
5
5
|
`ctx.actor` and hand it to `@ultimat3/policy`. One authz system, never two.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
|
-
import { BuiltinAdapter, defineAuth, login } from '@ultimat3/auth';
|
|
8
|
+
import { BuiltinAdapter, defineAuth, login, oauthLogin } from '@ultimat3/auth';
|
|
9
9
|
|
|
10
10
|
export const auth = defineAuth({
|
|
11
11
|
adapter: new BuiltinAdapter(), // or MemoryAdapter, or your Better Auth binding
|
|
12
12
|
session: { absoluteTtlMs: 30 * 864e5, idleTtlMs: 7 * 864e5 },
|
|
13
13
|
password: { minLength: 12 },
|
|
14
|
-
mfa: { issuer: 'Acme' },
|
|
14
|
+
mfa: { issuer: 'Acme' }, // the authenticator app's name; `required` only as `false`
|
|
15
15
|
providers: ['github', 'google'],
|
|
16
|
+
link: 'verified-email', // the default; `'never'` is the only other value
|
|
16
17
|
});
|
|
17
18
|
|
|
18
19
|
const { actor, token, cookie } = await login(auth, { email, password, ip });
|
|
20
|
+
|
|
21
|
+
// "Log in with GitHub" is a link to /auth/oauth/github. These two routes are what serves it.
|
|
22
|
+
const { start, callback } = oauthLogin(auth);
|
|
19
23
|
```
|
|
20
24
|
|
|
21
25
|
## Rules
|
|
@@ -23,9 +27,327 @@ const { actor, token, cookie } = await login(auth, { email, password, ip });
|
|
|
23
27
|
- Every login failure throws `loginFailed()` from `rate-limit.ts`. Never a specific message.
|
|
24
28
|
- Session ids are opaque random tokens; only `sha256(secret)` reaches the database.
|
|
25
29
|
- Absolute and idle expiry are evaluated **independently**. Activity never moves the ceiling.
|
|
26
|
-
- PKCE is mandatory on every provider.
|
|
30
|
+
- PKCE is mandatory on every provider — `OAuthProvider.usesPkce` is the literal `true`, so
|
|
31
|
+
`usesPkce: false` does not typecheck and there is no branch that skips the verifier.
|
|
27
32
|
- Recovery codes, verification tokens and api keys are hashed at rest and single-use.
|
|
33
|
+
- A verification token is consumed **only when its hash matches**, in the same statement — the
|
|
34
|
+
store takes `(purpose, identifier, tokenHash)`. Consuming first and comparing afterwards made an
|
|
35
|
+
unauthenticated wrong guess destroy the victim's live reset link.
|
|
28
36
|
- Guards assert on the actor. They never evaluate a policy.
|
|
37
|
+
- A user's `scopes` column reaches `Actor.scopes`, so a human can hold a scope. `permissions` is a
|
|
38
|
+
different field and `hasScope()` does not read it.
|
|
39
|
+
- `verifySession` writes at most once per `idleSlideMs`, not once per request.
|
|
40
|
+
- The lockout counts attempts against one identity, so it has to be **one** count. `AuthLimiter`
|
|
41
|
+
is async on every member and declares the policy it enforces; `defineAuth` refuses a limiter
|
|
42
|
+
that disagrees with the app's declaration.
|
|
43
|
+
|
|
44
|
+
## The lockout across replicas
|
|
45
|
+
|
|
46
|
+
`createAuthLimiter` keeps its table in the process, so `maxAttempts: 5` at `replicas: 3` lets an
|
|
47
|
+
account survive 15 guesses and hides each replica's lockout from the other two. An app that runs
|
|
48
|
+
more than one process says so and brings a limiter that says the same:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
defineAuth({
|
|
52
|
+
adapter,
|
|
53
|
+
rateLimit: { maxAttempts: 5, scope: 'shared' }, // the whole fleet's allowance
|
|
54
|
+
limiter: myLimiter, // whose policy says exactly the same
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`Auth.rateLimit` is what an operator reads as "what this deployment enforces", so `defineAuth`
|
|
59
|
+
refuses any pairing that would make it a lie:
|
|
60
|
+
|
|
61
|
+
| Declared | Limiter's own `policy` | Result |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `scope: 'process'` (default) | anything, same numbers | boots; the lockout is per replica |
|
|
64
|
+
| `scope: 'shared'` | `scope: 'shared'`, same numbers | boots; one count for the fleet |
|
|
65
|
+
| `scope: 'shared'` | `scope: 'process'` | `X_AUTH_LIMITER_NOT_SHARED` |
|
|
66
|
+
| `maxAttempts`/`windowMs`/`lockoutMs` | any of the three different | `X_AUTH_LIMITER_POLICY_MISMATCH` |
|
|
67
|
+
|
|
68
|
+
### The third bucket: one tenant
|
|
69
|
+
|
|
70
|
+
`account:<email>` and `ip:<addr>` were the only key shapes, so one tenant's misconfigured
|
|
71
|
+
integration hammering login from 400 addresses was capped by neither — each IP bucket allowed its
|
|
72
|
+
own quota, the account buckets protected individuals, and the shared limiter saturated behind
|
|
73
|
+
them. `orgKey(orgId)` is the third shape, checked in `login()` once the address resolves to an org
|
|
74
|
+
and still before the KDF runs.
|
|
75
|
+
|
|
76
|
+
`rateLimit.orgMaxAttempts` is its own number, defaulting to `maxAttempts * 20`: a whole tenant
|
|
77
|
+
sharing five attempts is a denial of service against that tenant. A success on one member's
|
|
78
|
+
account clears the **account** window and deliberately not the tenant one — otherwise the traffic
|
|
79
|
+
that proves the tenant is alive is also the traffic that resets its cap. Pass `orgLimiter` to
|
|
80
|
+
`defineAuth` to share those counters across replicas; unlike `limiter`, it is not required under
|
|
81
|
+
`scope: 'shared'`, because a tenant cap is a throughput ceiling and not a guessing allowance.
|
|
82
|
+
|
|
83
|
+
## Service-to-service
|
|
84
|
+
|
|
85
|
+
`verifyWorkloadToken` is the one function, and it reads all three shapes because they are all the
|
|
86
|
+
same JWT: a Kubernetes projected service-account token, a SPIFFE JWT-SVID, and a cloud IMDS token.
|
|
87
|
+
It is also the shape RFC 8693's `subject_token` takes.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const { identity } = await verifyWorkloadToken({
|
|
91
|
+
token,
|
|
92
|
+
issuers: ['https://kubernetes.default.svc'],
|
|
93
|
+
audience: 'https://ledger.internal',
|
|
94
|
+
keys: createJwksClient({ provider: 'k8s', jwksUri: 'https://kubernetes.default.svc/openid/v1/jwks' }),
|
|
95
|
+
clock,
|
|
96
|
+
});
|
|
97
|
+
const actor = actorFromService(identity); // kind: 'service', id: the caller's own sub
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Signature first, then issuer, audience, `exp` and `nbf`. `scope` (space-delimited) or `scp` (an
|
|
101
|
+
array) become the actor's scopes; a token with neither carries none. There is no trusted-channel
|
|
102
|
+
exemption here — the token arrived in a header.
|
|
103
|
+
|
|
104
|
+
**mTLS is out of scope.** TLS termination is the mesh's job ([axiom
|
|
105
|
+
7](../../docs/idea/README.md)); the framework's part is reading a trusted
|
|
106
|
+
`x-forwarded-client-cert` through `@ultimat3/http`'s trusted-proxy seam, which that package owns.
|
|
107
|
+
|
|
108
|
+
`maxKeys` is not compared — it bounds one process' table, not a limit. A custom limiter therefore
|
|
109
|
+
does **not** own its own configuration: the policy stays the app's single statement of the limits,
|
|
110
|
+
and the boot check is what keeps it true. **No shared limiter ships yet, `As of 2026-08`** —
|
|
111
|
+
`createAuthLimiter` is the only implementation in the framework.
|
|
112
|
+
|
|
113
|
+
## Providers are a registry, not a union
|
|
114
|
+
|
|
115
|
+
`OAuthProviderId` is `string`, and `registerOAuthProvider()` is the one way a provider gets in —
|
|
116
|
+
the three built-ins go through the same call. Before 1.3.0 the id was `keyof typeof
|
|
117
|
+
OAUTH_PROVIDERS` over `github | google | apple`, so an enterprise OP was **unrepresentable**: the
|
|
118
|
+
constraint was a type, there was no runtime escape, and the only ways out were forking the package
|
|
119
|
+
or bypassing OAuth entirely and losing PKCE, the sealed handshake, issuer pinning and account
|
|
120
|
+
linking with it.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { discoverOAuthProvider, registerOAuthProvider } from '@ultimat3/auth';
|
|
124
|
+
|
|
125
|
+
// By hand, when you know the four endpoints:
|
|
126
|
+
registerOAuthProvider({
|
|
127
|
+
id: 'bigco-sso',
|
|
128
|
+
authorizeUrl: 'https://sso.bigco.test/oauth2/v1/authorize',
|
|
129
|
+
tokenUrl: 'https://sso.bigco.test/oauth2/v1/token',
|
|
130
|
+
userInfoUrl: 'https://sso.bigco.test/oauth2/v1/userinfo',
|
|
131
|
+
userEmailsUrl: null,
|
|
132
|
+
issuers: ['https://sso.bigco.test'],
|
|
133
|
+
jwksUri: 'https://sso.bigco.test/oauth2/v1/keys',
|
|
134
|
+
scopes: ['openid', 'email', 'profile'],
|
|
135
|
+
usesPkce: true, // the literal `true`; `false` does not typecheck, for anyone
|
|
136
|
+
usesNonce: true,
|
|
137
|
+
clientIdEnv: 'BIGCO_SSO_CLIENT_ID',
|
|
138
|
+
clientSecretEnv: 'BIGCO_SSO_CLIENT_SECRET',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Or read them once at boot, from the issuer's own discovery document:
|
|
142
|
+
registerOAuthProvider(await discoverOAuthProvider({ id: 'bigco-sso', issuer: 'https://sso.bigco.test' }));
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Call | Answers |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `registerOAuthProvider(provider)` | the frozen provider; `X_OAUTH_PROVIDER_DUPLICATE` on a second claim of one id |
|
|
148
|
+
| `providerFor(id)` | the provider, or throws `X_OAUTH_PROVIDER_UNKNOWN` — never `undefined` |
|
|
149
|
+
| `hasOAuthProvider(id)` | whether the id is registered |
|
|
150
|
+
| `BUILTIN_OAUTH_PROVIDER_IDS` | the three shipped ids — the only list an **anonymous** refusal names |
|
|
151
|
+
| `oauthProviderIds()` | every registered id, live — `defineAuth({ providers })` defaults to it |
|
|
152
|
+
|
|
153
|
+
`discoverOAuthProvider` refuses a document with no `jwks_uri`: without a key set there is nothing
|
|
154
|
+
to check an id token's signature against, and the token-endpoint TLS exemption below is a thing a
|
|
155
|
+
caller declares, not one a provider inherits by omission.
|
|
156
|
+
|
|
157
|
+
**SAML is out of scope and will stay out of scope.** XML-DSig canonicalisation has no Bun native
|
|
158
|
+
and implementing it would mean a real dependency in the primitive vocabulary, which
|
|
159
|
+
[`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md) does not permit. The honest
|
|
160
|
+
answer is to put an OIDC-speaking SAML bridge (Okta, Entra, Keycloak, Dex, a SAML-to-OIDC proxy) in
|
|
161
|
+
front and register **that** as a provider.
|
|
162
|
+
|
|
163
|
+
## Id token signatures
|
|
164
|
+
|
|
165
|
+
`verifyIdToken({ keys })` is **required** to say where its trust comes from. There is no default,
|
|
166
|
+
because a default is what silently makes a second door as trusting as the first.
|
|
167
|
+
|
|
168
|
+
| `keys` | Means |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `'token-endpoint-tls'` | this token came off a TLS response from the provider's own token endpoint — the one case OIDC Core 3.1.3.7 exempts. `exchangeOAuthCode` passes it, and that is the only shipped call site that may |
|
|
171
|
+
| a `JwksKeySource` | the signature is checked. `providerJwks(providerFor(id))`, or `createJwksClient({ provider, jwksUri })` |
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const keys = providerJwks(providerFor('bigco-sso'));
|
|
175
|
+
const claims = await verifyIdToken({ provider: 'bigco-sso', idToken, clientId, nonce, clock, keys });
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`crypto.subtle` covers RS256 and ES256, so this costs no dependency. `HS256` and `alg: none` are
|
|
179
|
+
refused before a key is even looked up — a symmetric algorithm verified against a *public* key set
|
|
180
|
+
is the classic algorithm-confusion forgery. Keys are cached by `kid` with a TTL and one unknown
|
|
181
|
+
`kid` triggers exactly one refetch, so a rotation heals itself.
|
|
182
|
+
|
|
183
|
+
Wire an unsolicited token — IdP-initiated login, `response_mode=form_post`, back-channel logout,
|
|
184
|
+
token exchange — to anything that does **not** check the signature and an attacker posts a
|
|
185
|
+
self-minted JWT with the right `iss`, your `aud`, a VP's `sub` and tomorrow's `exp`, and gets a
|
|
186
|
+
session. That is account takeover with no credential, and it is what this exists to stop.
|
|
187
|
+
|
|
188
|
+
## What an SSO user is allowed to do
|
|
189
|
+
|
|
190
|
+
`oauthLogin(auth, { resolveGrants })`. **Omit it and a first-time SSO user is created with
|
|
191
|
+
`roles: []` and `orgId: null`** — an actor every `can()` denies, and a tenant-scoped read that
|
|
192
|
+
throws before the query is built. SSO "works" and the person can do nothing until somebody runs
|
|
193
|
+
SQL, so the omission logs `auth.oauth.user_created_without_grants` rather than passing silently.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
oauthLogin(auth, {
|
|
197
|
+
resolveGrants: async (profile) => {
|
|
198
|
+
const member = await directory.lookup(profile.email);
|
|
199
|
+
return { orgId: member.orgId, roles: member.groups.map(toRole), scopes: [] };
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
It is a **seam and not a group-to-role table**: which IdP group means which role is business
|
|
205
|
+
convention and business convention never ships ([axiom
|
|
206
|
+
8](../../docs/idea/19-mechanism-not-convention.md)). What the framework owns is calling it on
|
|
207
|
+
**every** login — so removing somebody from a group in the IdP takes effect at their next sign-in,
|
|
208
|
+
rather than never. A seam that returns the stored answer writes nothing.
|
|
209
|
+
|
|
210
|
+
## MFA — TOTP and recovery codes
|
|
211
|
+
|
|
212
|
+
Pure functions over a secret and a clock. This package mints, checks and de-duplicates a code;
|
|
213
|
+
**it persists nothing** — the secret, the recovery-code hashes and the spent steps are the app's
|
|
214
|
+
rows, because `AuthAdapter` has no MFA member and adding one would break every third-party
|
|
215
|
+
adapter.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
import type { Auth } from '@ultimat3/auth';
|
|
219
|
+
import { createTotpReplayGuard, enrolTotp, generateRecoveryCodes, verifyTotp } from '@ultimat3/auth';
|
|
220
|
+
import { systemClock } from '@ultimat3/core';
|
|
221
|
+
|
|
222
|
+
declare const auth: Auth; // the `defineAuth` at the top — `enrolTotp` reads `auth.mfa.issuer`
|
|
223
|
+
|
|
224
|
+
const enrolment = enrolTotp(auth, { account: 'ada@example.com' }); // issuer: auth.mfa.issuer
|
|
225
|
+
// enrolment.uri -> otpauth://… the QR code
|
|
226
|
+
// enrolment.secret -> base32, store it against the user
|
|
227
|
+
|
|
228
|
+
const recovery = generateRecoveryCodes(); // { codes, hashes } — show `codes` ONCE, store `hashes`
|
|
229
|
+
const guard = createTotpReplayGuard();
|
|
230
|
+
|
|
231
|
+
export function secondFactorHolds(userId: string, secret: string, code: string): boolean {
|
|
232
|
+
const at = systemClock.now();
|
|
233
|
+
const { ok, step } = verifyTotp({ secret, code, at });
|
|
234
|
+
if (!ok || step === null || guard.isUsed(userId, step)) return false;
|
|
235
|
+
guard.remember(userId, step, at);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
| Call | Answers |
|
|
241
|
+
|---|---|
|
|
242
|
+
| `enrolTotp(auth, { account, issuer?, secret? })` | `{ secret, uri, digits, periodSeconds }` — `issuer` omitted is `auth.mfa.issuer`, `secret` omitted mints one |
|
|
243
|
+
| `verifyTotp({ secret, code, at, drift?, usedSteps? })` | `{ ok, step }`. `step` is the window the code belonged to, `null` on no match |
|
|
244
|
+
| `createTotpReplayGuard(drift?, maxSubjects?)` | the in-process `{ isUsed, remember, size }`; a fleet passes a Redis-backed pair of the same two methods |
|
|
245
|
+
| `generateRecoveryCodes(count = 10)` | `{ codes, hashes }`. `codes` is shown once and is never re-derivable |
|
|
246
|
+
| `redeemRecoveryCode(code, hashes)` | the **remaining** hashes, or `null`. Persisting that array is what makes a code single-use |
|
|
247
|
+
| `totpStep(at, stepSeconds?)` / `totpCode(secret, step, digits?)` | the RFC 6238 halves, for a test that has to mint a valid code |
|
|
248
|
+
|
|
249
|
+
`TOTP_DIGITS` (6), `TOTP_STEP_SECONDS` (30) and `TOTP_DRIFT_STEPS` (±1 window) are exported so an
|
|
250
|
+
app's own copy of the parameters cannot disagree with the verifier's.
|
|
251
|
+
|
|
252
|
+
A step is remembered per **subject**, not globally: a code is valid for `drift` windows either
|
|
253
|
+
side of now, so without the guard the same six digits log in twice inside a minute. `verifyTotp`
|
|
254
|
+
answers `{ ok: false, step }` — the step still named — when `usedSteps` already holds it, which is
|
|
255
|
+
how a replay is told apart from a wrong code.
|
|
256
|
+
|
|
257
|
+
The guard's table is **bounded** (`DEFAULT_MAX_TOTP_SUBJECTS`, 10,000), because a per-subject map
|
|
258
|
+
that only ever grows is one process' lifetime away from an OOM. A subject whose every remembered
|
|
259
|
+
step has fallen below the drift floor is *forgotten* — `verifyTotp` can never offer that step
|
|
260
|
+
again, so the entry answers exactly as a missing one — and only if that is not enough does the cap
|
|
261
|
+
evict live state, furthest from the live window first. The order is the guarantee: evicting a
|
|
262
|
+
subject makes a step they have already spent replayable, so the subject who just authenticated is
|
|
263
|
+
always the last one out.
|
|
264
|
+
|
|
265
|
+
**`mfa.required` is accepted only as `false`, and that is deliberate.** The field exists and is
|
|
266
|
+
typed as the literal `false`, so `required: true` is a compile error; a `true` that reaches
|
|
267
|
+
`defineAuth` from JavaScript or from JSON — where the type cannot — is refused at boot with
|
|
268
|
+
`X_CONFIG_INVALID` naming the key, never an unknown-key error and never a silent accept. Nothing
|
|
269
|
+
read it: both credential paths branch on `user.mfaSecret` alone, so an un-enrolled user was handed
|
|
270
|
+
a full session under a config that read as "this deployment requires a second factor". Enforcing it
|
|
271
|
+
at `login()` instead is a lockout — `actorFromUser` degrades only a user who HAS a secret, and this
|
|
272
|
+
package ships no enrolment route to send the rest to. Gate it in your own sign-in handler —
|
|
273
|
+
`if (user.mfaSecret === null)` send them to `enrolTotp` before you call `createSession`.
|
|
274
|
+
|
|
275
|
+
**The second leg of login is the app's, `As of 2026-08`.** `login()` and `completeOAuthLogin()`
|
|
276
|
+
throw `X_MFA_REQUIRED` before any session exists; finishing the flow is `verifyTotp` followed by
|
|
277
|
+
`createSession({ mfaSatisfied: true })` in the app's own route. The framework ships no
|
|
278
|
+
`POST /auth/mfa/verify`, deliberately — see [`CLAUDE.md`](CLAUDE.md) for the three things that
|
|
279
|
+
would have to land together, and why fewer is worse than none.
|
|
280
|
+
|
|
281
|
+
## Email verification and password reset
|
|
282
|
+
|
|
283
|
+
One issue/consume pair, two purposes. The token is mailed and only its `sha256` is stored, and
|
|
284
|
+
consuming it is a single conditional statement — the hash is an **argument to** the consume, never
|
|
285
|
+
a comparison after one.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import {
|
|
289
|
+
consumeVerification,
|
|
290
|
+
issueVerification,
|
|
291
|
+
type MailSender,
|
|
292
|
+
MemoryAdapter,
|
|
293
|
+
type VerificationRuntime,
|
|
294
|
+
} from '@ultimat3/auth';
|
|
295
|
+
import { systemClock } from '@ultimat3/core';
|
|
296
|
+
|
|
297
|
+
declare const mail: MailSender; // the app wires @ultimat3/mail's `send` here
|
|
298
|
+
|
|
299
|
+
const runtime: VerificationRuntime = { store: new MemoryAdapter(), clock: systemClock, mail };
|
|
300
|
+
|
|
301
|
+
const issued = await issueVerification(runtime, {
|
|
302
|
+
purpose: 'password-reset',
|
|
303
|
+
identifier: 'ada@example.com', // the address; also the store key
|
|
304
|
+
locale: 'en',
|
|
305
|
+
link: (token) => `https://acme.test/reset?token=${token}`,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const verification = await consumeVerification(runtime, {
|
|
309
|
+
purpose: 'password-reset',
|
|
310
|
+
identifier: 'ada@example.com',
|
|
311
|
+
token: issued.token, // in production this arrives off the link
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
| Name | Is |
|
|
316
|
+
|---|---|
|
|
317
|
+
| `VERIFICATION_PURPOSES` | `['email-verify', 'password-reset']` — the whole set, and `VerificationPurpose` is derived from it |
|
|
318
|
+
| `VERIFICATION_TEMPLATES` | purpose → **catalog key** (`auth.email-verify`, `auth.password-reset`), never copy: the body lives in the app's i18n catalog |
|
|
319
|
+
| `DEFAULT_VERIFICATION_TTL_MS` | 24 h for `email-verify`, 1 h for `password-reset` — a reset link is a password |
|
|
320
|
+
| `MailSender` | the injected port: `send(template, to, data, locale)`. `@ultimat3/mail` is tier 4 and this package is tier 2, so the app wires it |
|
|
321
|
+
| `issueVerification(runtime, input)` | `{ token, expiresAt }`. The token comes back for a test or a CLI; production only ever mails it |
|
|
322
|
+
| `consumeVerification(runtime, input)` | the `AuthVerification` row, or `X_UNAUTHENTICATED`. Unknown, spent, expired and mismatched are one answer |
|
|
323
|
+
|
|
324
|
+
One live token per `(purpose, identifier)`. A wrong guess destroys nothing: the store's
|
|
325
|
+
`takeVerification(purpose, identifier, tokenHash)` matches before it consumes, so an
|
|
326
|
+
unauthenticated POST with any token cannot kill the victim's live link.
|
|
327
|
+
|
|
328
|
+
## Revocation, offboarding and access review
|
|
329
|
+
|
|
330
|
+
| Call | Blast radius |
|
|
331
|
+
|---|---|
|
|
332
|
+
| `revokeSession(runtime, id)` | one session |
|
|
333
|
+
| `revokeOtherSessions(runtime, userId, keep)` | every session but the caller's |
|
|
334
|
+
| `revokeUserSessions(auth, userId, reason)` | one person, their current session included |
|
|
335
|
+
| `revokeOrgSessions(auth, orgId, reason)` | one tenant, at 03:00, without touching another |
|
|
336
|
+
| `revokeSessionsCreatedBefore(auth, at, reason)` | everything minted under a rotated secret |
|
|
337
|
+
| `disableUser(auth, userId, reason)` | stamps `disabledAt` **and** kills the sessions |
|
|
338
|
+
| `listOrgUsers(auth, orgId, { role })` | the quarterly access review, as safe summaries |
|
|
339
|
+
| `updatePrivileges(auth, userId, patch, session?)` | the grant, plus the session rotation it requires |
|
|
340
|
+
|
|
341
|
+
`reason` is a required argument on every revocation, for the reason `crossTenant()` requires one:
|
|
342
|
+
an incident review asks who killed these sessions and why, and a `delete` with no line answers
|
|
343
|
+
neither. Each one logs `auth.revocation` before it runs.
|
|
344
|
+
|
|
345
|
+
`deleteSessionsForOrg` joins through `x_users` and `x_sessions` deliberately does **not** gain an
|
|
346
|
+
`org_id`: a denormalised copy of the membership goes stale the moment somebody moves org, and a
|
|
347
|
+
stale row means the sweep leaves live exactly the sessions it was run to kill.
|
|
348
|
+
|
|
349
|
+
`describeUser` is an allow-list, never a delete-list — a column added to `AuthUser` later must not
|
|
350
|
+
appear in an admin response by default. No password hash, no TOTP secret, no recovery-code hash.
|
|
29
351
|
|
|
30
352
|
## Adapter seam
|
|
31
353
|
|
|
@@ -38,9 +360,46 @@ an adapter implementation, not a dependency of this package.
|
|
|
38
360
|
| `MemoryAdapter` | `x new` before a database exists, and every test in this package |
|
|
39
361
|
| your own | implement `AuthAdapter`; DDL in `tables.ts` shows what the columns mean |
|
|
40
362
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
363
|
+
**An adapter stores and matches the address it is handed — it never folds case.** `x_users.email`
|
|
364
|
+
is a plain case-sensitive `text ... unique`, so an adapter that lowercased found accounts Postgres
|
|
365
|
+
would not. Normalisation happens once, above the seam, in `normaliseEmail` (`email.ts`): trim and
|
|
366
|
+
lowercase, nothing else. Call it before `findUserByEmail`/`createUser` in any login route of your
|
|
367
|
+
own, and key any bucket of your own with it — `accountKey` does.
|
|
368
|
+
|
|
369
|
+
The seam's newer members — `findUserByExternalId`, `listUsersByOrg`, `deleteSessionsForUser`,
|
|
370
|
+
`deleteSessionsForOrg`, `deleteSessionsCreatedBefore` — are **optional**, so a 1.2-era adapter
|
|
371
|
+
still satisfies the interface. Calling one an adapter has not implemented is `X_NOT_IMPLEMENTED`
|
|
372
|
+
with the method named, not a silent no-op.
|
|
373
|
+
|
|
374
|
+
`x_users` gained two columns in 1.3.0 — `scopes text[]` and `external_id text unique` — plus an
|
|
375
|
+
`org_id` index. `X_USERS_MIGRATION_1_3` is those statements for an app already on 1.2; both
|
|
376
|
+
columns are additive with a default, so the migration takes no table rewrite.
|
|
377
|
+
|
|
378
|
+
**Nothing wires those statements into a migration for you, `As of 2026-08`.** `AUTH_TABLES` is the
|
|
379
|
+
DDL as plain strings (`AUTH_TABLE_NAMES` is what they create, and `X_USERS_TABLE`,
|
|
380
|
+
`X_SESSIONS_TABLE`, `X_ACCOUNTS_TABLE`, `X_API_KEYS_TABLE`, `X_VERIFICATIONS_TABLE` are the
|
|
381
|
+
individual ones). `x db gen <name>` diffs `describeEntities()` against the newest migration's
|
|
382
|
+
snapshot — these tables are not `entity()` declarations, so nothing outside `tables.ts` reads the
|
|
383
|
+
constant and the command cannot see them. Paste each statement into its own file under
|
|
384
|
+
`packages/db/migrations/`, one statement per migration, then `x db migrate`.
|
|
385
|
+
|
|
386
|
+
## Sessions are not a write path
|
|
387
|
+
|
|
388
|
+
`verifySession` used to `UPDATE x_sessions … RETURNING *` on **every** authenticated request: one
|
|
389
|
+
request was a SELECT, a write and a second SELECT before the app's own first query. At 20k rps
|
|
390
|
+
that is 20k writes a second on one hot table, autovacuum falls behind, and the incident reads as
|
|
391
|
+
"the database is slow".
|
|
392
|
+
|
|
393
|
+
`SessionPolicy.idleSlideMs` — defaulting to `idleTtlMs / 20` — is how far `lastSeenAt` may drift
|
|
394
|
+
before a request writes it forward. A second request inside that window issues no write at all. A
|
|
395
|
+
changed IP or user agent is still written immediately, because that is the row a device list and
|
|
396
|
+
an incident review read. The trade is bounded and explicit: idle expiry is now precise to within
|
|
397
|
+
one `idleSlideMs`.
|
|
398
|
+
|
|
399
|
+
**Revocation is unaffected, and deliberately so.** `authenticate` re-reads the *user* row on every
|
|
400
|
+
request, so a revoked role takes effect on the very next one with no token-expiry lag — a better
|
|
401
|
+
property than any claims-in-a-JWT design. Throttling the session write is safe; caching the user
|
|
402
|
+
row would not be, and nothing does.
|
|
44
403
|
|
|
45
404
|
## Cookie
|
|
46
405
|
|
|
@@ -54,38 +413,78 @@ x db gen "auth tables" # emits AUTH_TABLES into a migration
|
|
|
54
413
|
| `__Host-` + `Path=/` + no `Domain` | a sibling subdomain overwriting it (session fixation) |
|
|
55
414
|
| `Max-Age` | a client keeping it past the server's absolute ceiling |
|
|
56
415
|
|
|
57
|
-
## OAuth
|
|
416
|
+
## OAuth — log in with GitHub
|
|
58
417
|
|
|
59
|
-
|
|
60
|
-
`
|
|
418
|
+
`oauthLogin(auth)` **is** the flow. Two route descriptors, mounted at two fixed paths, composing
|
|
419
|
+
`beginOAuth`, the handshake cookie and `completeOAuthLogin`. Provider configs stay pure data —
|
|
420
|
+
importing `oauth.ts` performs no network I/O and reads no env.
|
|
61
421
|
|
|
62
422
|
```ts
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
423
|
+
const { start, callback } = oauthLogin(auth);
|
|
424
|
+
|
|
425
|
+
Bun.serve({
|
|
426
|
+
fetch(request) {
|
|
427
|
+
const { pathname } = new URL(request.url);
|
|
428
|
+
if (pathname.endsWith('/callback')) return callback.handle(request);
|
|
429
|
+
if (pathname.startsWith('/auth/oauth/')) return start.handle(request);
|
|
430
|
+
return new Response(null, { status: 404 });
|
|
431
|
+
},
|
|
432
|
+
});
|
|
71
433
|
```
|
|
72
434
|
|
|
435
|
+
| | `start` | `callback` |
|
|
436
|
+
|---|---|---|
|
|
437
|
+
| path | `/auth/oauth/:provider` | `/auth/oauth/:provider/callback` |
|
|
438
|
+
| success | `302` to the provider, `Set-Cookie: __Host-x_oauth_<provider>` | `303` to `successPath`, `Set-Cookie: __Host-x_session` **and** the handshake cleared |
|
|
439
|
+
| failure | the coded JSON body, status per code | the same, handshake cleared either way |
|
|
440
|
+
|
|
441
|
+
A **descriptor**, never a mounted handler — the same category as `mcpHttpRoute()`. `@ultimat3/http`
|
|
442
|
+
is tier 2 like this package, so auth may not import it, and `defineRoute` is tier 4 and describes
|
|
443
|
+
a rendered page. A bare `Request` in, a `Response` out: drivable from a test, mountable by any
|
|
444
|
+
router that can match a `:param`.
|
|
445
|
+
|
|
446
|
+
**The `Bun.serve` above is library usage, not app usage.** An Ultimate app's server is `runRole`
|
|
447
|
+
(`apps/web/server.ts` is three lines that call it), and `As of 2026-08` `ServeOptions` has no
|
|
448
|
+
routes seam — the route list is built inside `serveApp` and closed. So a second `Bun.serve` in an
|
|
449
|
+
app does not extend that server, it stands beside it: on its own socket, outside the pipeline, and
|
|
450
|
+
therefore outside `configureAuthenticator`, the rate limiter, the security headers and the
|
|
451
|
+
SIGTERM drain. A login flow is the last surface that should be the one running unthrottled and
|
|
452
|
+
unheadered.
|
|
453
|
+
|
|
454
|
+
Until the seam exists, an app serving these descriptors serves them itself and pays for all of
|
|
455
|
+
that itself — a second port to publish and health-check, its own throttle in front of `callback`,
|
|
456
|
+
its own security headers, and a drain that does not strand a handshake mid-flight. There is no
|
|
457
|
+
mounting API to call today; do not write one, and do not read this section as promising one.
|
|
458
|
+
|
|
459
|
+
**The paths are not configurable.** `X_OAUTH_STATE_INVALID` has always told the caller to restart
|
|
460
|
+
at `GET /auth/oauth/<provider>`; it now quotes `oauthStartPath()`, the same declaration the mount
|
|
461
|
+
reads. A movable base path is that sentence going stale again.
|
|
462
|
+
|
|
463
|
+
**Failure is JSON, not a redirect carrying `?error=`.** The callback is the one request whose
|
|
464
|
+
failure a developer must read, and there is no `?next=` on the success hop either: an
|
|
465
|
+
attacker-supplied return target on the endpoint that hands out a session is the classic open
|
|
466
|
+
redirect, and `nextAfterSignIn` in `@ultimat3/http` is the one implementation of that check.
|
|
467
|
+
|
|
468
|
+
`beginOAuth` / `handshakeCookie` / `completeOAuthLogin` stay exported for a flow that needs the
|
|
469
|
+
seams — but they are the seams, not the path.
|
|
470
|
+
|
|
471
|
+
### Account linking
|
|
472
|
+
|
|
73
473
|
```ts
|
|
74
|
-
|
|
75
|
-
export async function GET(request: Request): Promise<Response> {
|
|
76
|
-
const url = new URL(request.url);
|
|
77
|
-
const { cookie } = await completeOAuthLogin(auth, {
|
|
78
|
-
handshake: readHandshakeCookie(request, 'github'),
|
|
79
|
-
callback: { state: url.searchParams.get('state') ?? '', code: url.searchParams.get('code') ?? '' },
|
|
80
|
-
});
|
|
81
|
-
const headers = new Headers({ location: '/' });
|
|
82
|
-
// Both, always: a code is single-use, so the handshake that authorised it must not outlive it.
|
|
83
|
-
headers.append('set-cookie', cookie);
|
|
84
|
-
headers.append('set-cookie', clearHandshakeCookie('github'));
|
|
85
|
-
return new Response(null, { status: 302, headers });
|
|
86
|
-
}
|
|
474
|
+
defineAuth({ adapter, providers: ['github'], link: 'verified-email' }) // the default
|
|
87
475
|
```
|
|
88
476
|
|
|
477
|
+
| `link` | a provider identity becomes an **existing** user when |
|
|
478
|
+
|---|---|
|
|
479
|
+
| `'verified-email'` (default) | the provider asserted the address verified **and** that account had verified it too |
|
|
480
|
+
| `'never'` | never — a collision is `X_UNAUTHENTICATED` and the caller uses their own credentials |
|
|
481
|
+
|
|
482
|
+
There is deliberately **no third value**. "Link on whatever address the provider sent" is not
|
|
483
|
+
spelled here at all: a provider that does not verify addresses turns it into account takeover —
|
|
484
|
+
register the victim's address there, press the button, inherit the account. Unrepresentable beats
|
|
485
|
+
explicit, the same way `PkcePair.method` is the literal `'S256'` and never `'plain'`. An app that
|
|
486
|
+
truly wants something looser wraps `signInWithOAuth` and resolves the user itself.
|
|
487
|
+
|
|
89
488
|
The handshake carries `state`, `nonce` and the PKCE verifier across two requests, so it needs a
|
|
90
489
|
home. `handshakeCookie` is that home — sealed with `SESSION_SECRET`, `HttpOnly; Secure;
|
|
91
490
|
SameSite=Lax` under a `__Host-` name, and expired against the server's clock rather than the
|
|
@@ -119,6 +518,7 @@ JWT signed with the `.p8` key, which Apple expires every six months.
|
|
|
119
518
|
|
|
120
519
|
| Step | Does | Fails with |
|
|
121
520
|
|---|---|---|
|
|
521
|
+
| `oauthLogin(auth)` | the two routes: redirect out, session back | `X_OAUTH_PROVIDER_UNKNOWN`, `X_OAUTH_DENIED` |
|
|
122
522
|
| `handshakeCookie` / `readHandshakeCookie` | seals the handshake onto the redirect, opens it on the callback | `X_OAUTH_STATE_INVALID`, `X_ENV_MISSING` |
|
|
123
523
|
| `exchangeOAuthCode` | POSTs the code + PKCE verifier, verifies the id token | `X_OAUTH_EXCHANGE_FAILED`, `X_OAUTH_TOKEN_INVALID` |
|
|
124
524
|
| `oauthProfile` | id-token claims, else userinfo → one normalised identity | `X_OAUTH_EXCHANGE_FAILED` |
|
|
@@ -130,8 +530,8 @@ JWT signed with the `.p8` key, which Apple expires every six months.
|
|
|
130
530
|
token, because that is where the code flow actually carries it.
|
|
131
531
|
- GitHub reports a bad, reused or expired code as **HTTP 200 with an `error` field**. Trusting
|
|
132
532
|
the status alone there mints a session from a failed exchange.
|
|
133
|
-
- An address is only linked to an existing account when **both** sides verified it
|
|
134
|
-
whoever registered the address first inherits the login.
|
|
533
|
+
- An address is only linked to an existing account when **both** sides verified it (`link:
|
|
534
|
+
'verified-email'`). Otherwise whoever registered the address first inherits the login.
|
|
135
535
|
|
|
136
536
|
## API keys — how an agent authenticates
|
|
137
537
|
|
|
@@ -155,12 +555,15 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
|
|
|
155
555
|
| `X_MFA_REQUIRED` | password proven, second factor outstanding |
|
|
156
556
|
| `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
|
|
157
557
|
| `X_OAUTH_EXCHANGE_FAILED` | the provider refused the exchange, or returned no usable identity |
|
|
158
|
-
| `X_OAUTH_TOKEN_INVALID` | the id token failed its issuer, audience or expiry check |
|
|
558
|
+
| `X_OAUTH_TOKEN_INVALID` | the id token failed its signature, issuer, audience or expiry check, or no key in the published set matched its `kid` |
|
|
559
|
+
| `X_OAUTH_PROVIDER_UNKNOWN` | the URL named a provider nothing registered, or one `defineAuth({ providers })` did not enable — the route's refusal lists only the three built-ins, never your registry |
|
|
560
|
+
| `X_OAUTH_PROVIDER_DUPLICATE` | two `registerOAuthProvider` calls claimed one id — at boot, never at a login |
|
|
561
|
+
| `X_OAUTH_DENIED` | the user pressed Cancel, or the provider declined — `403`, never a `502` |
|
|
159
562
|
| `X_PASSWORD_WEAK` | strength check rejected the password |
|
|
160
|
-
| `X_ACCOUNT_LOCKED` | per-ip or per-
|
|
563
|
+
| `X_ACCOUNT_LOCKED` | the per-ip, per-account or per-org bucket is inside its lockout |
|
|
161
564
|
| `X_API_KEY_INVALID` | key unknown, revoked, expired or wrong |
|
|
162
565
|
| `X_ENV_MISSING` | `oauthCredentials()` found no client id or secret for an enabled provider |
|
|
163
|
-
| `X_NOT_IMPLEMENTED` | an `AuthAdapter`
|
|
566
|
+
| `X_NOT_IMPLEMENTED` | an `AuthAdapter` has not implemented an optional seam member (`revokeOrgSessions`, `listOrgUsers`, …), or lost a write it accepted — `emailVerifiedNotStored(provider, userId)` when `updateUser` drops the OAuth verified stamp |
|
|
164
567
|
|
|
165
568
|
```bash
|
|
166
569
|
bun test packages/auth
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"CLAUDE.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
@@ -30,8 +31,8 @@
|
|
|
30
31
|
"test": "bun test"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@ultimat3/core": "
|
|
34
|
-
"@ultimat3/db": "
|
|
35
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "3.0.0",
|
|
35
|
+
"@ultimat3/db": "3.0.0",
|
|
36
|
+
"@ultimat3/schema": "3.0.0"
|
|
36
37
|
}
|
|
37
38
|
}
|