@ultimat3/auth 9.0.0 → 11.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 +75 -4
- package/README.md +4 -4
- package/package.json +4 -4
- package/src/auth.ts +31 -6
- package/src/errors.ts +24 -172
- package/src/id-token.ts +36 -2
- package/src/index.ts +17 -12
- package/src/jwks.ts +17 -5
- package/src/limiter-install.ts +28 -6
- package/src/memory-adapter.ts +44 -1
- package/src/oauth-cookie.ts +1 -1
- package/src/oauth-discovery.ts +1 -1
- package/src/oauth-errors.ts +178 -0
- package/src/oauth-exchange.ts +58 -10
- package/src/oauth-login.ts +17 -6
- package/src/oauth-profile.ts +1 -1
- package/src/oauth-registry.ts +1 -1
- package/src/oauth-route.ts +91 -16
- package/src/oauth.ts +1 -1
- package/src/tables.ts +29 -2
- package/src/verify.ts +17 -3
package/CLAUDE.md
CHANGED
|
@@ -98,6 +98,13 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
98
98
|
a limiter built through the seam holds the clock its host handed it, and that is the clock every
|
|
99
99
|
`at_ms` in those tables was written from. `AuthLimiter.purgeExpired` is OPTIONAL so
|
|
100
100
|
`createAuthLimiter` can keep bounding itself; a limiter with no table declares nothing.
|
|
101
|
+
**What the seam RETAINS is one limiter per window, `As of 2026-08-23`** — a `Map` keyed by
|
|
102
|
+
`windowMs`, because the widest window is the only thing a purge reads. It was a list appended to
|
|
103
|
+
on every `installedAuthLimiter` call, two per `defineAuth`, trimmed by nothing: a process that
|
|
104
|
+
redefines auth (`x dev`'s reload, a test file, a host building one `Auth` per app) held every
|
|
105
|
+
limiter it ever built and the store behind each. `installedLimiterCount()` is the only
|
|
106
|
+
observation of that growth — a purge sweeps exactly one limiter however many are held — and it
|
|
107
|
+
is not exported from `src/index.ts`.
|
|
101
108
|
|
|
102
109
|
- **`normaliseEmail` is the ONE normalisation, it lives ABOVE the `AuthAdapter` seam, and no
|
|
103
110
|
adapter may fold case** (`As of 2026-08`). `MemoryAdapter` lowercased and trimmed on both
|
|
@@ -202,7 +209,7 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
202
209
|
provider, so it is unrepresentable rather than discouraged. An app that wants it wraps
|
|
203
210
|
`signInWithOAuth`.
|
|
204
211
|
- **The OAuth route paths are not configurable.** `oauth-paths.ts` imports nothing and is
|
|
205
|
-
read by both `errors.ts` and `oauth-route.ts`, so a `fix:` line naming
|
|
212
|
+
read by both `oauth-errors.ts` and `oauth-route.ts`, so a `fix:` line naming
|
|
206
213
|
`GET /auth/oauth/<provider>` cannot outlive the route again — which is exactly what it did
|
|
207
214
|
through 1.2.0, when the library functions shipped with no route to mount them in. Every
|
|
208
215
|
"start over" fix is built from `restartAt(provider)`.
|
|
@@ -214,9 +221,71 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
214
221
|
implementation of the open-redirect check and a second copy is one that drifts.
|
|
215
222
|
- The handshake cookie is cleared on **every** callback outcome, success and failure alike:
|
|
216
223
|
the code it authorised is spent either way.
|
|
217
|
-
- Refresh is **not implemented
|
|
218
|
-
|
|
219
|
-
|
|
224
|
+
- Refresh is **not implemented**, and the framework therefore **stores no provider token** (`As of
|
|
225
|
+
2026-08-23`). `accountFor` (`oauth-login.ts`) writes `accessToken: null, refreshToken: null`:
|
|
226
|
+
nothing in this package ever read either column back — `oauth-profile.ts` uses the token from the
|
|
227
|
+
exchange, in flight — while `tables.ts`'s header promised "no column holds a plaintext secret".
|
|
228
|
+
Declared and never wired, the deletion this repo already ran for `jobs.driver`, except this one
|
|
229
|
+
turned a database dump into a set of usable third-party credentials. The COLUMNS and the
|
|
230
|
+
`AuthAccount` fields both stay: the type is the documented adapter seam, and an app that
|
|
231
|
+
deliberately stores tokens implements `linkAccount` itself. `expiresAt` is still written.
|
|
232
|
+
- **`defineAuth({ providers })` defaults to `[]`**, never the live registry (`As of 2026-08-23`,
|
|
233
|
+
BREAKING). It was `oauthProviderIds()`, so nothing was ever "left out" and the uniform-404 the
|
|
234
|
+
option exists for could not fire — while a registry any dependency writes into decided which
|
|
235
|
+
login endpoints the app served. Name what you enabled.
|
|
236
|
+
- **A provider with no credentials answers the SAME 404 an unknown one does.** Past `assertEnabled`,
|
|
237
|
+
`oauthCredentials` threw `X_ENV_MISSING` and the route published a 500 carrying the app's own env
|
|
238
|
+
var names, so `500 = registered here, 404 = not` re-opened the oracle one request later.
|
|
239
|
+
`credentialsFor` (`oauth-route.ts`) catches it on BOTH legs, logs the real cause under
|
|
240
|
+
`auth.oauth.credentials_missing`, and re-throws `oauthProviderUnknown`.
|
|
241
|
+
- **Nothing this package does not own reaches a published `cause:`.** Both legs of the OAuth flow
|
|
242
|
+
are anonymous by definition and `publicBody` serialises `cause`. Two paths carried an internal
|
|
243
|
+
message into it and both now log instead: an uncoded throw from an adapter (`uncoded()` in
|
|
244
|
+
`oauth-route.ts` → `auth.oauth.uncoded_failure`) and a rejecting `OAuthFetch`
|
|
245
|
+
(`postForm` → `auth.oauth.token_fetch_failed`). The `POST /token` leg also reads its response
|
|
246
|
+
body as `providerDetail(response, 'coded-only')` — that request carries `client_secret`, and an
|
|
247
|
+
echoing endpoint put 38 of 42 characters of it into a 502 body. `error`/`error_description` still
|
|
248
|
+
come through; userinfo, discovery and jwks keep the raw fallback, because their requests carry
|
|
249
|
+
no secret.
|
|
250
|
+
- **`x_users.mfa_secret` is a PLAINTEXT secret and `tables.ts` now says so, column by column.** A
|
|
251
|
+
TOTP seed is symmetric, so a digest cannot verify a code; encrypting it needs a key-management
|
|
252
|
+
seam this package does not have. **Deferred deliberately** — `mfa.ts`'s "a database dump is not a
|
|
253
|
+
permanent MFA bypass" is true of the recovery CODES and not of the seed.
|
|
254
|
+
- **`issueVerification`/`consumeVerification` normalise too** — the fourth identity door.
|
|
255
|
+
`putVerification` upserts on `(purpose, identifier)` and `adapter.ts` promises a new token
|
|
256
|
+
invalidates the previous one; that promise was per SPELLING, so N live reset tokens for one
|
|
257
|
+
address could be held at once by varying the case, and each is a password.
|
|
258
|
+
- **`providerJwks` memoises only the DEFAULT client.** The memo was keyed on the provider id alone
|
|
259
|
+
and silently discarded a later caller's options, so an app pinning a corporate egress proxy got
|
|
260
|
+
it only if it called first — the `jobs.driver` shape, with a network path as the substituted
|
|
261
|
+
value. A caller that supplies options gets its own client.
|
|
262
|
+
- **`verifyIdToken` checks `nbf` and `azp`.** `workload.ts` imports `ID_TOKEN_CLOCK_SKEW_MS` from
|
|
263
|
+
`id-token.ts` and then enforced a bound `id-token.ts` did not: an `nbf` ten years out verified.
|
|
264
|
+
`azp` is OIDC Core 3.1.3.7 step 5 — with more than one audience, `aud` naming this client says
|
|
265
|
+
only that the token MENTIONS it. Both only narrow what is accepted.
|
|
266
|
+
- **A success clears the ACCOUNT bucket and nothing else.** `recordSuccess(ipKey(ip))` used to run
|
|
267
|
+
on every login and deleted the whole address bucket, which made it inert against the attack it
|
|
268
|
+
exists for: a stuffing run never spends `maxAttempts` guesses on one account, so
|
|
269
|
+
`4 wrong + 1 login to an account the attacker owns, repeat` never locked. Measured: 5 guesses to
|
|
270
|
+
`X_ACCOUNT_LOCKED` without the reset, 160 and unlocked with it. The cost is a shared NAT
|
|
271
|
+
accumulating failures, which is what `windowMs` bounds and what `X_ACCOUNT_LOCKED`'s `fix:`
|
|
272
|
+
already names the manual escape for.
|
|
273
|
+
- **`MemoryAdapter.createUser` enforces the two UNIQUE constraints `BuiltinAdapter` leans on** —
|
|
274
|
+
`x_users.email` and `x_users.external_id`, `authUniqueViolation` (`X_AUTH_WRITE_FAILED`). It is
|
|
275
|
+
what `x new` scaffolds and what every test runs against, so the duplicate path was only ever
|
|
276
|
+
exercised against the permissive half of the seam: two `register()` calls at one address made two
|
|
277
|
+
rows, and the second was unreachable forever. `adapter-parity.test.ts` pins both halves.
|
|
278
|
+
**A NULL is not a value to either index, `As of 2026-08-23`**: a Postgres unique index is NULLS
|
|
279
|
+
DISTINCT, so `external_id` constrains only the rows that carry one — the check was
|
|
280
|
+
`!== undefined`, and `oauth-login.ts` binds `grants.externalId ?? null` for every first-time
|
|
281
|
+
OAuth user, so the SECOND such signup on a `MemoryAdapter` app failed with `X_AUTH_WRITE_FAILED`
|
|
282
|
+
against a constraint production does not have.
|
|
283
|
+
- **`MemoryAdapter` takes a `Clock`, and every instant it stamps comes from it** —
|
|
284
|
+
`new MemoryAdapter(clock)`, defaulting to `systemClock`, so the no-argument construction every
|
|
285
|
+
test already writes is unchanged. `takeVerification` stamped `consumedAt` with the record's
|
|
286
|
+
own `createdAt` — the moment the link was ISSUED — where `BuiltinAdapter` writes
|
|
287
|
+
`consumed_at = now()`, the moment it was REDEEMED: a redemption an hour later and one a second
|
|
288
|
+
later recorded the identical instant, and a frozen test clock could not move either.
|
|
220
289
|
- The new `AuthAdapter` members are OPTIONAL (`findUserByExternalId`, `listUsersByOrg`,
|
|
221
290
|
`deleteSessionsForUser`, `deleteSessionsForOrg`, `deleteSessionsCreatedBefore`). A required
|
|
222
291
|
member is a breaking change to every third-party adapter; the callers throw
|
|
@@ -329,6 +398,8 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
|
|
|
329
398
|
| `oauth-login.ts` | profile → account link → session. `completeOAuthLogin` is the entry point |
|
|
330
399
|
| `oauth-login-fixture.ts` | the adapter, clock and profile the three `oauth-login*` suites share. Off `index.ts` |
|
|
331
400
|
| `oauth-paths.ts` | the one declaration of where the two routes live. Imports nothing |
|
|
401
|
+
| `errors.ts` | the codes this package owns and borrows, their titles, the one `registerErrorCodes()` call, `AuthError`, and every non-OAuth factory |
|
|
402
|
+
| `oauth-errors.ts` | the OAuth half of those factories, and `restartAt`. Split off at the 500-line ceiling; declares no code and registers nothing |
|
|
332
403
|
| `oauth-route.ts` | `oauthLogin(auth)` — the redirect out and the callback back |
|
|
333
404
|
| `kdf-gate.ts` | the one bound on concurrent argon2 work, and the `X_OVERLOADED` past it |
|
|
334
405
|
| `email.ts` | `normaliseEmail` — the one normalisation an address gets before it is an identity key |
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ export const auth = defineAuth({
|
|
|
12
12
|
session: { absoluteTtlMs: 30 * 864e5, idleTtlMs: 7 * 864e5 },
|
|
13
13
|
password: { minLength: 12 },
|
|
14
14
|
mfa: { issuer: 'Acme' }, // the authenticator app's name; `required` only as `false`
|
|
15
|
-
providers: ['github', 'google'],
|
|
15
|
+
providers: ['github', 'google'], // REQUIRED to serve any OAuth route — the default is []
|
|
16
16
|
link: 'verified-email', // the default; `'never'` is the only other value
|
|
17
17
|
});
|
|
18
18
|
|
|
@@ -223,7 +223,7 @@ registerOAuthProvider(await discoverOAuthProvider({ id: 'bigco-sso', issuer: 'ht
|
|
|
223
223
|
| `providerFor(id)` | the provider, or throws `X_OAUTH_PROVIDER_UNKNOWN` — never `undefined` |
|
|
224
224
|
| `hasOAuthProvider(id)` | whether the id is registered |
|
|
225
225
|
| `BUILTIN_OAUTH_PROVIDER_IDS` | the three shipped ids — the only list an **anonymous** refusal names |
|
|
226
|
-
| `oauthProviderIds()` | every registered id, live
|
|
226
|
+
| `oauthProviderIds()` | every registered id, live. **NOT** what `defineAuth({ providers })` defaults to — that is `[]`, so an app names what it enabled |
|
|
227
227
|
|
|
228
228
|
`discoverOAuthProvider` refuses a document with no `jwks_uri`: without a key set there is nothing
|
|
229
229
|
to check an id token's signature against, and the token-endpoint TLS exemption below is a thing a
|
|
@@ -445,7 +445,7 @@ an adapter implementation, not a dependency of this package.
|
|
|
445
445
|
| Driver | Use |
|
|
446
446
|
|---|---|
|
|
447
447
|
| `BuiltinAdapter` | Postgres via `@ultimat3/db`; takes an injected `DbClient` |
|
|
448
|
-
| `MemoryAdapter` | `x new` before a database exists, and every test in this package |
|
|
448
|
+
| `MemoryAdapter` | `x new` before a database exists, and every test in this package. `new MemoryAdapter(clock)` — `systemClock` by default — stamps every instant it writes |
|
|
449
449
|
| your own | implement `AuthAdapter`; DDL in `tables.ts` shows what the columns mean |
|
|
450
450
|
|
|
451
451
|
**An adapter stores and matches the address it is handed — it never folds case.** `x_users.email`
|
|
@@ -644,7 +644,7 @@ An api key's scopes become **exactly** the agent actor's scopes — never the ow
|
|
|
644
644
|
| `X_OAUTH_STATE_INVALID` | state, nonce or PKCE verifier did not match |
|
|
645
645
|
| `X_OAUTH_EXCHANGE_FAILED` | the provider refused the exchange, or returned no usable identity |
|
|
646
646
|
| `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` |
|
|
647
|
-
| `X_OAUTH_PROVIDER_UNKNOWN` | the URL named a provider nothing registered,
|
|
647
|
+
| `X_OAUTH_PROVIDER_UNKNOWN` | the URL named a provider nothing registered, one `defineAuth({ providers })` did not enable, or one whose `*_CLIENT_ID`/`*_CLIENT_SECRET` are unset — all three answer 404 with the same body, because telling an anonymous caller which is which describes this deployment for free. The real reason is logged. The refusal lists only the three built-ins, never your registry |
|
|
648
648
|
| `X_OAUTH_PROVIDER_DUPLICATE` | two `registerOAuthProvider` calls claimed one id — at boot, never at a login |
|
|
649
649
|
| `X_OAUTH_DENIED` | the user pressed Cancel, or the provider declined — `403`, never a `502` |
|
|
650
650
|
| `X_PASSWORD_WEAK` | strength check rejected the password |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.0",
|
|
4
4
|
"description": "Sessions, passwords, OAuth, MFA and api keys — resolved to one Actor",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/db": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
34
|
+
"@ultimat3/core": "11.0.0",
|
|
35
|
+
"@ultimat3/db": "11.0.0",
|
|
36
|
+
"@ultimat3/schema": "11.0.0"
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/auth.ts
CHANGED
|
@@ -10,7 +10,6 @@ import { normaliseEmail } from './email';
|
|
|
10
10
|
import { mfaRequired, mfaRequiredUnenforceable, sessionUnknown } from './errors';
|
|
11
11
|
import { installedAuthLimiter } from './limiter-install';
|
|
12
12
|
import type { OAuthProviderId } from './oauth';
|
|
13
|
-
import { oauthProviderIds } from './oauth-registry';
|
|
14
13
|
import {
|
|
15
14
|
checkPasswordStrength,
|
|
16
15
|
DEFAULT_PASSWORD_POLICY,
|
|
@@ -147,6 +146,11 @@ export interface AuthConfigInput {
|
|
|
147
146
|
*/
|
|
148
147
|
readonly orgLimiter?: AuthLimiter | undefined;
|
|
149
148
|
readonly mfa?: Partial<AuthMfaPolicy> | undefined;
|
|
149
|
+
/**
|
|
150
|
+
* The OAuth providers this app serves login routes for. Defaults to `[]` — an empty list is
|
|
151
|
+
* "no OAuth", and every `/auth/oauth/<id>` answers `X_OAUTH_PROVIDER_UNKNOWN`. Never the live
|
|
152
|
+
* registry, which a dependency can write into.
|
|
153
|
+
*/
|
|
150
154
|
readonly providers?: readonly OAuthProviderId[] | undefined;
|
|
151
155
|
/** Defaults to `'verified-email'` — both halves proven. See `OAuthLinkPolicy`. */
|
|
152
156
|
readonly link?: OAuthLinkPolicy | undefined;
|
|
@@ -213,7 +217,17 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
213
217
|
orgRateLimit: orgLimiter.policy,
|
|
214
218
|
orgLimiter,
|
|
215
219
|
mfa,
|
|
216
|
-
|
|
220
|
+
// BREAKING (majors only): the default is `[]`, never the live registry.
|
|
221
|
+
//
|
|
222
|
+
// It was `oauthProviderIds()`, so `defineAuth({ providers })`'s own documented purpose — the
|
|
223
|
+
// uniform 404 for a provider this app "left out" — could never fire: nothing was ever left
|
|
224
|
+
// out. Worse, a registry any dependency writes into with `registerOAuthProvider` decided which
|
|
225
|
+
// login endpoints an app serves. A capability is DECLARED, never inherited.
|
|
226
|
+
//
|
|
227
|
+
// Migration is one line: `defineAuth({ providers: ['github', 'google'] })`, naming what the app
|
|
228
|
+
// actually enabled. Nothing else changes — an unnamed provider is the 404 it was always meant
|
|
229
|
+
// to be.
|
|
230
|
+
providers: config.providers ?? [],
|
|
217
231
|
link: config.link ?? 'verified-email',
|
|
218
232
|
});
|
|
219
233
|
}
|
|
@@ -286,10 +300,21 @@ export async function login(auth: Auth, input: LoginInput): Promise<LoginResult>
|
|
|
286
300
|
}
|
|
287
301
|
|
|
288
302
|
await auth.limiter.recordSuccess(account);
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
// the tenant
|
|
303
|
+
// ONLY the account bucket is cleared. The ACCOUNT window belongs to one person, so a success
|
|
304
|
+
// proves the typos before it were theirs and clearing it is what stops a typo costing a lockout.
|
|
305
|
+
//
|
|
306
|
+
// Neither the IP nor the tenant bucket is cleared, and it is the same argument for both: they
|
|
307
|
+
// count traffic from a SHARED source, so a success is not evidence the failures beside it were
|
|
308
|
+
// benign. `recordSuccess(ipKey(ip))` used to run here and deleted the whole address bucket —
|
|
309
|
+
// which made it inert against the attack it exists for. A credential-stuffing run never spends
|
|
310
|
+
// `maxAttempts` guesses on one account, so the per-account bucket never fires; the address
|
|
311
|
+
// bucket is the only one that sees the pattern, and `4 wrong guesses + 1 login to an account
|
|
312
|
+
// the attacker owns, repeat` wiped it every fifth request. Measured: 5 guesses to
|
|
313
|
+
// `X_ACCOUNT_LOCKED` without the reset, 160 and never locked with it.
|
|
314
|
+
//
|
|
315
|
+
// The cost is a shared NAT accumulating failures from unrelated people, which is exactly what
|
|
316
|
+
// `windowMs` bounds — and `X_ACCOUNT_LOCKED`'s `fix:` already names `recordSuccess(<key>)` as
|
|
317
|
+
// the deliberate manual escape for that case.
|
|
293
318
|
|
|
294
319
|
// Parameters were raised since this hash was written: upgrade it now, while we hold the
|
|
295
320
|
// plaintext. This is the only moment it is possible without asking the user for anything.
|
package/src/errors.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
renderFixLiteral,
|
|
10
10
|
UltimateError,
|
|
11
11
|
} from '@ultimat3/core';
|
|
12
|
-
import { oauthStartPath } from './oauth-paths';
|
|
13
12
|
|
|
14
13
|
/** Codes this package declares and owns. `X_UNAUTHENTICATED` is auth's; http only borrows it. */
|
|
15
14
|
export const AUTH_OWNED_ERROR_CODES = [
|
|
@@ -95,11 +94,12 @@ export class AuthError extends UltimateError {
|
|
|
95
94
|
fix: string;
|
|
96
95
|
meta?: Readonly<Record<string, unknown>> | undefined;
|
|
97
96
|
}) {
|
|
97
|
+
// No `docs:`: `UltimateError` fills it from `describeErrorCode(code).docs`. The
|
|
98
|
+
// `https://ultimate.dev/errors/<code>` link this built until 9.x answered 404, host and all.
|
|
98
99
|
super({
|
|
99
100
|
code: init.code,
|
|
100
101
|
cause: init.cause,
|
|
101
102
|
fix: init.fix,
|
|
102
|
-
docs: `https://ultimate.dev/errors/${init.code}`,
|
|
103
103
|
meta: init.meta,
|
|
104
104
|
});
|
|
105
105
|
}
|
|
@@ -216,176 +216,6 @@ export const mfaRequiredUnenforceable = (): AuthError =>
|
|
|
216
216
|
fix: 'drop required from defineAuth({ mfa }) and gate it in your own sign-in handler, which is where the enrolment route lives: if (user.mfaSecret === null) send them to enrolTotp(auth, { account: user.email }) instead of createSession(auth.sessions, ...)',
|
|
217
217
|
});
|
|
218
218
|
|
|
219
|
-
/**
|
|
220
|
-
* The `fix:` quotes `oauthStartPath` rather than a hand-written path. That is not tidiness: this
|
|
221
|
-
* line shipped naming `GET /auth/oauth/<provider>` while `@ultimat3/auth` mounted no route at all,
|
|
222
|
-
* so every caller who followed it hit a 404. One declaration, read by the mount and by the fix,
|
|
223
|
-
* is what stops that recurring — `oauthLogin()` cannot move without moving this sentence.
|
|
224
|
-
*/
|
|
225
|
-
export const oauthStateInvalid = (provider: string, part: string): AuthError =>
|
|
226
|
-
new AuthError({
|
|
227
|
-
code: 'X_OAUTH_STATE_INVALID',
|
|
228
|
-
cause: `${provider} callback rejected: ${part}`,
|
|
229
|
-
fix: `${restartAt(provider)} — a callback URL is single-use`,
|
|
230
|
-
meta: { provider },
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
/** The one phrase every "start over" fix is built from, so none of them can name a dead route. */
|
|
234
|
-
export const restartAt = (provider: string): string =>
|
|
235
|
-
`restart the flow at GET ${oauthStartPath(provider)}`;
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* The provider came back with `error=` and no code — almost always the user pressing Cancel.
|
|
239
|
-
* A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
|
|
240
|
-
* misconfigured, and folding the single commonest non-success outcome of a login into the code
|
|
241
|
-
* that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
|
|
242
|
-
*/
|
|
243
|
-
export const oauthDenied = (
|
|
244
|
-
provider: string,
|
|
245
|
-
reason: string,
|
|
246
|
-
description: string | null,
|
|
247
|
-
): AuthError =>
|
|
248
|
-
new AuthError({
|
|
249
|
-
code: 'X_OAUTH_DENIED',
|
|
250
|
-
// `reason` and `description` are query parameters off the callback URL — whatever the browser
|
|
251
|
-
// was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
|
|
252
|
-
// string literals, so a forged `error_description` cannot forge a second log line or break the
|
|
253
|
-
// sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
|
|
254
|
-
cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
|
|
255
|
-
description === null ? '' : ` (${renderCauseValue(description)})`
|
|
256
|
-
}`,
|
|
257
|
-
fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
|
|
258
|
-
meta: { provider, reason },
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* A URL segment naming a provider no `registerOAuthProvider` call has claimed, or one that is but
|
|
263
|
-
* was left out of `defineAuth({ providers })`. One refusal for both: which of the two it is
|
|
264
|
-
* describes the app's configuration to an unauthenticated caller, and the fix is the same sentence
|
|
265
|
-
* either way.
|
|
266
|
-
*
|
|
267
|
-
* **`supported` is the CALLER's to scope, because the two callers have two audiences.**
|
|
268
|
-
* `oauth-route.ts` passes `BUILTIN_OAUTH_PROVIDER_IDS` — its reader is an anonymous stranger who
|
|
269
|
-
* typed a URL, and the three built-ins are a framework constant already in the public docs, while
|
|
270
|
-
* the live registry holds whatever internal OP this deployment registered. `providerFor()` passes
|
|
271
|
-
* `oauthProviderIds()` — its reader is a developer holding a stack trace, and there the full list
|
|
272
|
-
* is exactly what makes the fix runnable. Neither ever passes `defineAuth({ providers })`: naming
|
|
273
|
-
* what this deployment turned on is the disclosure the shared refusal exists to prevent.
|
|
274
|
-
*
|
|
275
|
-
* The fix names `registerOAuthProvider` first so it stays executable for the branch the narrowed
|
|
276
|
-
* list cannot cover — a segment nothing registered cannot be added to `providers` at all, so
|
|
277
|
-
* "add it" alone was an instruction that could not be followed.
|
|
278
|
-
*
|
|
279
|
-
* The segment itself is a URL path the caller typed, so it goes through `renderCauseValue` in the
|
|
280
|
-
* sentence and `renderFixLiteral` in the command — a fix has to parse after a hostile value lands
|
|
281
|
-
* in it.
|
|
282
|
-
*/
|
|
283
|
-
export const oauthProviderUnknown = (provider: string, supported: readonly string[]): AuthError =>
|
|
284
|
-
new AuthError({
|
|
285
|
-
code: 'X_OAUTH_PROVIDER_UNKNOWN',
|
|
286
|
-
cause: `no oauth provider is mounted at ${renderCauseValue(oauthStartPath(provider))}`,
|
|
287
|
-
fix: `registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) if it is not built in, then add that id to defineAuth({ providers: [...] }) — known here: ${supported.map((id) => `'${id}'`).join(', ')}`,
|
|
288
|
-
meta: { provider },
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Two `registerOAuthProvider` calls claiming one id. A silent replacement would let whichever
|
|
293
|
-
* module imported second decide where every login for that id goes — including which `issuers`
|
|
294
|
-
* an id token may claim — so the second registration refuses at boot instead.
|
|
295
|
-
*/
|
|
296
|
-
export const oauthProviderDuplicate = (provider: string): AuthError =>
|
|
297
|
-
new AuthError({
|
|
298
|
-
code: 'X_OAUTH_PROVIDER_DUPLICATE',
|
|
299
|
-
cause: `an oauth provider is already registered as ${renderCauseValue(provider)}, so the second registration would silently replace the first`,
|
|
300
|
-
fix: `give one of them a different id, or delete the duplicate registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) call`,
|
|
301
|
-
meta: { provider },
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
export interface OAuthExchangeFailure {
|
|
305
|
-
readonly provider: string;
|
|
306
|
-
/**
|
|
307
|
-
* Which leg of the server-to-server conversation failed. `discovery` and `jwks` are the two
|
|
308
|
-
* boot/verification legs an enterprise OP adds: reading `/.well-known/openid-configuration`,
|
|
309
|
-
* and reading the key set an id token's signature is checked against.
|
|
310
|
-
*/
|
|
311
|
-
readonly stage: 'token' | 'userinfo' | 'discovery' | 'jwks';
|
|
312
|
-
readonly detail: string;
|
|
313
|
-
readonly status?: number | undefined;
|
|
314
|
-
readonly fix: string;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Deliberately specific, unlike every credential error above it. This one describes a
|
|
319
|
-
* conversation between two servers — naming the stage, the provider and its own status
|
|
320
|
-
* discloses nothing about any user, and is the difference between a fixable misconfiguration
|
|
321
|
-
* and a shrug.
|
|
322
|
-
*/
|
|
323
|
-
export const oauthExchangeFailed = (failure: OAuthExchangeFailure): AuthError =>
|
|
324
|
-
new AuthError({
|
|
325
|
-
code: 'X_OAUTH_EXCHANGE_FAILED',
|
|
326
|
-
cause:
|
|
327
|
-
`${failure.provider} ${failure.stage} request failed` +
|
|
328
|
-
`${failure.status === undefined ? '' : ` with HTTP ${failure.status}`}: ${failure.detail}`,
|
|
329
|
-
fix: failure.fix,
|
|
330
|
-
meta: {
|
|
331
|
-
provider: failure.provider,
|
|
332
|
-
stage: failure.stage,
|
|
333
|
-
...(failure.status === undefined ? {} : { status: failure.status }),
|
|
334
|
-
},
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* The address is proven to the provider, and an account that never proved it already holds it.
|
|
339
|
-
* Naming that is not account enumeration — this caller just demonstrated they own the address —
|
|
340
|
-
* and staying silent would leave them with a login that fails forever and no way out.
|
|
341
|
-
*
|
|
342
|
-
* The address itself rides in `meta`, never in `cause`: a log pipeline can redact a field by
|
|
343
|
-
* key, and cannot redact an address that was already interpolated into a sentence.
|
|
344
|
-
*/
|
|
345
|
-
export const oauthAccountNotLinked = (provider: string, email: string): AuthError =>
|
|
346
|
-
new AuthError({
|
|
347
|
-
code: 'X_UNAUTHENTICATED',
|
|
348
|
-
cause: `an account holds this ${provider} address but never verified it, so ${provider} may not claim it`,
|
|
349
|
-
fix: `sign in with that account's password and confirm the email-verify link, then retry ${provider}`,
|
|
350
|
-
meta: { provider, email },
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* `link: 'never'` and a local account already holds the address. Same code and same disclosure
|
|
355
|
-
* rule as `oauthAccountNotLinked` — the caller proved to the provider that the address is theirs,
|
|
356
|
-
* so naming the collision is not enumeration — and the address rides in `meta`, never in `cause`.
|
|
357
|
-
*/
|
|
358
|
-
export const oauthLinkingDisabled = (provider: string, email: string): AuthError =>
|
|
359
|
-
new AuthError({
|
|
360
|
-
code: 'X_UNAUTHENTICATED',
|
|
361
|
-
cause: `an account already holds this address and defineAuth({ link: 'never' }) forbids ${provider} from claiming it`,
|
|
362
|
-
fix: "sign in with that account's own credentials, or set link: 'verified-email' in defineAuth to let a provider-verified address claim a locally-verified account",
|
|
363
|
-
meta: { provider, email },
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* `CreateUserInput` carries no `emailVerifiedAt`, so a provider-verified address takes a second
|
|
368
|
-
* write. Falling back to the unstamped row would mint a session for a user every later login
|
|
369
|
-
* reads as unverified — the exact state `resolveUser` refuses to link a provider to — so the
|
|
370
|
-
* flow fails closed on an adapter that loses the stamp instead of half-succeeding.
|
|
371
|
-
*/
|
|
372
|
-
export const emailVerifiedNotStored = (provider: string, userId: string): AuthError =>
|
|
373
|
-
new AuthError({
|
|
374
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
375
|
-
cause: `the adapter returned no row for new user ${userId}, so the ${provider}-verified address was never stamped verified`,
|
|
376
|
-
fix: 'return the updated row from AuthAdapter.updateUser — MemoryAdapter.updateUser is the reference implementation',
|
|
377
|
-
meta: { provider, userId },
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
/** The token arrived, and is not one this handshake can trust: wrong `iss`, `aud`, or expired. */
|
|
381
|
-
export const oauthTokenInvalid = (provider: string, reason: string, fix: string): AuthError =>
|
|
382
|
-
new AuthError({
|
|
383
|
-
code: 'X_OAUTH_TOKEN_INVALID',
|
|
384
|
-
cause: `${provider} id token rejected: ${reason}`,
|
|
385
|
-
fix,
|
|
386
|
-
meta: { provider },
|
|
387
|
-
});
|
|
388
|
-
|
|
389
219
|
export const passwordWeak = (reasons: readonly string[]): AuthError =>
|
|
390
220
|
new AuthError({
|
|
391
221
|
code: 'X_PASSWORD_WEAK',
|
|
@@ -411,6 +241,9 @@ export const accountLocked = (key: string, retryAfterSeconds: number): AuthError
|
|
|
411
241
|
// escaping, not throw-safety.
|
|
412
242
|
cause: `${renderCauseValue(key)} is locked out for another ${retryAfterSeconds}s after repeated failures`,
|
|
413
243
|
fix: `wait ${retryAfterSeconds}s — or clear this one bucket: auth.limiter.recordSuccess(${renderFixLiteral(key, '<key>')}), auth.orgLimiter for an org: key — or raise defineAuth({ rateLimit })`,
|
|
244
|
+
// `kdfOverloaded`'s shape: `@ultimat3/http`'s `retryAfterOf` reads exactly this field. `key`
|
|
245
|
+
// stays out — `cause`/`fix` escape it on purpose and `meta` is read by surfaces that do not.
|
|
246
|
+
meta: { retryAfterSeconds },
|
|
414
247
|
});
|
|
415
248
|
|
|
416
249
|
/** One shape for every api-key rejection: unknown, revoked, expired and wrong all look alike. */
|
|
@@ -436,6 +269,25 @@ export const authWriteFailed = (operation: string, table: string): AuthError =>
|
|
|
436
269
|
meta: { operation, table },
|
|
437
270
|
});
|
|
438
271
|
|
|
272
|
+
/**
|
|
273
|
+
* A write the table's own UNIQUE constraint refuses. Same code as the write above, because it is
|
|
274
|
+
* the same failure to an operator — the row is not there and the caller must not assume it is —
|
|
275
|
+
* and the cause says which of the two happened.
|
|
276
|
+
*
|
|
277
|
+
* `MemoryAdapter` enforced neither `x_users.email` nor `x_users.external_id`, while `BuiltinAdapter`
|
|
278
|
+
* leans on both: two `register()` calls at one address made TWO rows in memory, and the second was
|
|
279
|
+
* unreachable forever because `findUserByEmail` returns the first. That adapter is what `x new`
|
|
280
|
+
* scaffolds and what every test runs against, so the duplicate path was only exercised against the
|
|
281
|
+
* permissive half of the seam.
|
|
282
|
+
*/
|
|
283
|
+
export const authUniqueViolation = (operation: string, table: string, column: string): AuthError =>
|
|
284
|
+
new AuthError({
|
|
285
|
+
code: 'X_AUTH_WRITE_FAILED',
|
|
286
|
+
cause: `${operation} would add a second ${table} row with the same ${column}, which that column's unique constraint refuses`,
|
|
287
|
+
fix: `look the row up first and update it — findUserByEmail(normaliseEmail(email)) — or write a different ${column}`,
|
|
288
|
+
meta: { operation, table, column },
|
|
289
|
+
});
|
|
290
|
+
|
|
439
291
|
/**
|
|
440
292
|
* At `defineAuth`, never at a login. `replicas: 3` behind one policy means each process counts
|
|
441
293
|
* failures on its own, so the account survives `maxAttempts × 3` guesses and a lockout established
|
package/src/id-token.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Clock } from '@ultimat3/core';
|
|
9
9
|
import { renderCauseValue } from '@ultimat3/core';
|
|
10
|
-
import { oauthStateInvalid, oauthTokenInvalid, restartAt } from './errors';
|
|
11
10
|
import { decodeJwtSegment } from './json';
|
|
12
11
|
import { type IdTokenKeys, verifyJwtSignature } from './jwks';
|
|
13
12
|
import type { OAuthProvider, OAuthProviderId } from './oauth';
|
|
13
|
+
import { oauthStateInvalid, oauthTokenInvalid, restartAt } from './oauth-errors';
|
|
14
14
|
import { providerFor } from './oauth-registry';
|
|
15
15
|
import { timingSafeEqual } from './tokens';
|
|
16
16
|
|
|
@@ -21,6 +21,13 @@ export interface IdTokenClaims {
|
|
|
21
21
|
readonly sub: string;
|
|
22
22
|
readonly exp: number;
|
|
23
23
|
readonly iat?: number | undefined;
|
|
24
|
+
/** Not-before. Checked with the same skew `workload.ts` allows, from the same constant. */
|
|
25
|
+
readonly nbf?: number | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Authorised party — the client this token was minted FOR. OIDC Core 3.1.3.7 step 5: with more
|
|
28
|
+
* than one audience, `aud` naming this client is not evidence the token is addressed to it.
|
|
29
|
+
*/
|
|
30
|
+
readonly azp?: string | undefined;
|
|
24
31
|
readonly nonce?: string | undefined;
|
|
25
32
|
readonly email?: string | undefined;
|
|
26
33
|
/** Google sends a boolean, Apple a `"true"` string. Both mean the same thing. */
|
|
@@ -75,6 +82,8 @@ export function decodeIdToken(provider: OAuthProviderId, idToken: string): IdTok
|
|
|
75
82
|
}
|
|
76
83
|
// `exactOptionalPropertyTypes`: an absent claim must be absent, not present-and-undefined.
|
|
77
84
|
const iat = payload['iat'];
|
|
85
|
+
const nbf = payload['nbf'];
|
|
86
|
+
const azp = stringOrUndefined(payload['azp']);
|
|
78
87
|
const verified = payload['email_verified'];
|
|
79
88
|
const nonce = stringOrUndefined(payload['nonce']);
|
|
80
89
|
const email = stringOrUndefined(payload['email']);
|
|
@@ -85,6 +94,8 @@ export function decodeIdToken(provider: OAuthProviderId, idToken: string): IdTok
|
|
|
85
94
|
sub,
|
|
86
95
|
exp,
|
|
87
96
|
...(typeof iat === 'number' ? { iat } : {}),
|
|
97
|
+
...(typeof nbf === 'number' ? { nbf } : {}),
|
|
98
|
+
...(azp === undefined ? {} : { azp }),
|
|
88
99
|
...(nonce === undefined ? {} : { nonce }),
|
|
89
100
|
...(email === undefined ? {} : { email }),
|
|
90
101
|
...(typeof verified === 'boolean' || typeof verified === 'string'
|
|
@@ -167,7 +178,19 @@ export async function verifyIdToken(input: VerifyIdTokenInput): Promise<IdTokenC
|
|
|
167
178
|
);
|
|
168
179
|
}
|
|
169
180
|
|
|
170
|
-
|
|
181
|
+
// OIDC Core 3.1.3.7 steps 4-5. With more than one audience, `aud` naming this client says only
|
|
182
|
+
// that the token MENTIONS it — the authorised party is what says it was minted for it. Without
|
|
183
|
+
// this, a token an OP issued for another client that also lists ours verified here.
|
|
184
|
+
if (audience.length > 1 && claims.azp !== input.clientId) {
|
|
185
|
+
throw oauthTokenInvalid(
|
|
186
|
+
provider.id,
|
|
187
|
+
'the token names several audiences and its azp is not this client',
|
|
188
|
+
`ask ${provider.id} for a token whose azp is ${provider.clientIdEnv}'s client id, or one with a single aud`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const nowMs = input.clock.now().getTime();
|
|
193
|
+
if (claims.exp * 1000 + ID_TOKEN_CLOCK_SKEW_MS <= nowMs) {
|
|
171
194
|
throw oauthTokenInvalid(
|
|
172
195
|
provider.id,
|
|
173
196
|
'the token is already expired',
|
|
@@ -175,6 +198,17 @@ export async function verifyIdToken(input: VerifyIdTokenInput): Promise<IdTokenC
|
|
|
175
198
|
);
|
|
176
199
|
}
|
|
177
200
|
|
|
201
|
+
// The bound `workload.ts` already checks, with the same skew, from THIS FILE's constant — it
|
|
202
|
+
// imports `ID_TOKEN_CLOCK_SKEW_MS` from here and then enforced a limit here did not. An `nbf`
|
|
203
|
+
// ten years out verified.
|
|
204
|
+
if (claims.nbf !== undefined && claims.nbf * 1000 - ID_TOKEN_CLOCK_SKEW_MS > nowMs) {
|
|
205
|
+
throw oauthTokenInvalid(
|
|
206
|
+
provider.id,
|
|
207
|
+
'the token is not valid yet',
|
|
208
|
+
`sync this host's clock (\`timedatectl status\`), then ${restartAt(provider.id)}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
178
212
|
if (provider.usesNonce && !timingSafeEqual(input.nonce, claims.nonce ?? '')) {
|
|
179
213
|
throw oauthStateInvalid(provider.id, 'the id token nonce did not match the stored handshake');
|
|
180
214
|
}
|
package/src/index.ts
CHANGED
|
@@ -57,7 +57,7 @@ export { describeUser, findUserByExternalId, listOrgUsers } from './directory';
|
|
|
57
57
|
// The one normalisation an address gets before it is an identity key. Public because an app
|
|
58
58
|
// writing its own `AuthAdapter`, or its own login route, has to key exactly the way this does.
|
|
59
59
|
export { normaliseEmail } from './email';
|
|
60
|
-
export type { AuthErrorCode, AuthThrowCode
|
|
60
|
+
export type { AuthErrorCode, AuthThrowCode } from './errors';
|
|
61
61
|
export {
|
|
62
62
|
AUTH_BORROWED_ERROR_CODES,
|
|
63
63
|
AUTH_ERROR_CODES,
|
|
@@ -68,28 +68,18 @@ export {
|
|
|
68
68
|
authLimiterNotShared,
|
|
69
69
|
authLimiterPolicyMismatch,
|
|
70
70
|
authNotImplemented,
|
|
71
|
+
authUniqueViolation,
|
|
71
72
|
authWriteFailed,
|
|
72
|
-
emailVerifiedNotStored,
|
|
73
73
|
forbidden,
|
|
74
74
|
kdfOverloaded,
|
|
75
75
|
mfaRequired,
|
|
76
76
|
mfaRequiredUnenforceable,
|
|
77
77
|
mfaSecretInvalid,
|
|
78
|
-
oauthAccountNotLinked,
|
|
79
|
-
oauthDenied,
|
|
80
|
-
oauthExchangeFailed,
|
|
81
|
-
oauthLinkingDisabled,
|
|
82
|
-
oauthProviderDuplicate,
|
|
83
|
-
oauthProviderUnknown,
|
|
84
|
-
oauthStateInvalid,
|
|
85
|
-
oauthTokenInvalid,
|
|
86
78
|
passwordWeak,
|
|
87
|
-
restartAt,
|
|
88
79
|
sessionExpired,
|
|
89
80
|
sessionUnknown,
|
|
90
81
|
unauthenticated,
|
|
91
82
|
} from './errors';
|
|
92
|
-
|
|
93
83
|
export { currentActor, requireActor } from './guards';
|
|
94
84
|
export type { IdTokenClaims, VerifyIdTokenInput } from './id-token';
|
|
95
85
|
export {
|
|
@@ -180,6 +170,21 @@ export {
|
|
|
180
170
|
} from './oauth-cookie';
|
|
181
171
|
export type { DiscoverOAuthProviderInput } from './oauth-discovery';
|
|
182
172
|
export { discoverOAuthProvider, discoveryUrl } from './oauth-discovery';
|
|
173
|
+
// The OAuth half of the same contract, split out of `errors.ts` at the 500-line ceiling. Every
|
|
174
|
+
// name below was exported from `./errors` before the split and is exported here after it.
|
|
175
|
+
export type { OAuthExchangeFailure } from './oauth-errors';
|
|
176
|
+
export {
|
|
177
|
+
emailVerifiedNotStored,
|
|
178
|
+
oauthAccountNotLinked,
|
|
179
|
+
oauthDenied,
|
|
180
|
+
oauthExchangeFailed,
|
|
181
|
+
oauthLinkingDisabled,
|
|
182
|
+
oauthProviderDuplicate,
|
|
183
|
+
oauthProviderUnknown,
|
|
184
|
+
oauthStateInvalid,
|
|
185
|
+
oauthTokenInvalid,
|
|
186
|
+
restartAt,
|
|
187
|
+
} from './oauth-errors';
|
|
183
188
|
export type {
|
|
184
189
|
OAuthClientCredentials,
|
|
185
190
|
OAuthExchangeOptions,
|
package/src/jwks.ts
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Clock } from '@ultimat3/core';
|
|
10
10
|
import { renderThrowable, systemClock } from '@ultimat3/core';
|
|
11
|
-
import { oauthExchangeFailed, oauthTokenInvalid } from './errors';
|
|
12
11
|
import { decodeJwtSegment, isRecord } from './json';
|
|
13
12
|
import type { OAuthProvider } from './oauth';
|
|
13
|
+
import { oauthExchangeFailed, oauthTokenInvalid } from './oauth-errors';
|
|
14
14
|
import type { OAuthFetch } from './oauth-exchange';
|
|
15
15
|
import { base64UrlBytes } from './tokens';
|
|
16
16
|
|
|
@@ -200,8 +200,19 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
|
|
|
200
200
|
const clients = new Map<string, JwksKeySource>();
|
|
201
201
|
|
|
202
202
|
/**
|
|
203
|
-
* The provider's own key set
|
|
204
|
-
* a client rebuilt per request refetches the key set per request.
|
|
203
|
+
* The provider's own key set. The DEFAULT client is built once per provider id — memoised because
|
|
204
|
+
* the cache is the point: a client rebuilt per request refetches the key set per request.
|
|
205
|
+
*
|
|
206
|
+
* A caller that SUPPLIES options gets a client built with them, and is not served the memo. The
|
|
207
|
+
* memo was keyed on the provider id alone, so the second caller's `fetch`, `clock`, `ttlMs` and
|
|
208
|
+
* `timeoutMs` were silently discarded: an app pinning a corporate egress proxy got it only if it
|
|
209
|
+
* happened to call first, and nothing said otherwise. That is the `jobs.driver` shape — a key read
|
|
210
|
+
* once and thereafter ignored — except the value quietly substituted here is a network path.
|
|
211
|
+
*
|
|
212
|
+
* Not cached by option identity, deliberately: `fetch` and `clock` are functions and objects, so
|
|
213
|
+
* any canonical key over them either collides (two different proxies, one entry) or never hits.
|
|
214
|
+
* A bespoke client is a bespoke client; `createJwksClient` is what it is, and its own cache still
|
|
215
|
+
* works for as long as the caller holds it.
|
|
205
216
|
*/
|
|
206
217
|
export function providerJwks(
|
|
207
218
|
provider: OAuthProvider,
|
|
@@ -214,10 +225,11 @@ export function providerJwks(
|
|
|
214
225
|
`register ${provider.id} with an explicit jwksUri, or read its id token only through exchangeOAuthCode()`,
|
|
215
226
|
);
|
|
216
227
|
}
|
|
217
|
-
const
|
|
228
|
+
const bespoke = options !== undefined && Object.keys(options).length > 0;
|
|
229
|
+
const existing = bespoke ? undefined : clients.get(provider.id);
|
|
218
230
|
if (existing !== undefined) return existing;
|
|
219
231
|
const client = createJwksClient({ ...options, provider: provider.id, jwksUri: provider.jwksUri });
|
|
220
|
-
clients.set(provider.id, client);
|
|
232
|
+
if (!bespoke) clients.set(provider.id, client);
|
|
221
233
|
return client;
|
|
222
234
|
}
|
|
223
235
|
|