@ultimat3/auth 19.2.0 → 19.3.2

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 CHANGED
@@ -214,6 +214,14 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
214
214
  the SESSION write is safe; caching the USER row is not — `authenticate` re-reads it on every
215
215
  request, and that is what makes a revoked role take effect on the next one with no token-expiry
216
216
  lag. Do not cache it.
217
+
218
+ **In `verifySession`'s `observed`, `null` is an ANSWER and `undefined` is silence**
219
+ (`As of 2026-09-06`). `observed?.ip ?? session.ip` collapsed the two, so an explicit
220
+ `{ ip: null }` — this request had no client address — could not clear a stale stored one: the
221
+ address from an earlier request was written forward on every verify, and the device list a user
222
+ checks for a session they do not recognise showed it as current. An observation that names
223
+ neither field still costs no write, because a change nobody reported would turn every
224
+ authenticated request back into a write on the hottest table there is.
217
225
  - A user's `scopes` column reaches `Actor.scopes`. Hardcoding `[]` there made a scope something no
218
226
  human could hold, so `hasScope(actor, 'tenancy:cross')` was satisfiable only by minting a
219
227
  `serviceActor` inside the handler — which discards the operator's identity and makes the sweep
@@ -253,6 +261,33 @@ Tier 2. Produces the `Actor`; produces nothing else. Authorization is `@ultimat3
253
261
  These three renders stay and are not a second path: `renderCauseValue` also QUOTES, which is what
254
262
  makes a forged `iss` legible as a value rather than as prose — the constructor is a floor under
255
263
  every field, not a replacement for saying which value is foreign.
264
+
265
+ **A `fix:` a SHELL reads takes `renderFixShellArg` instead, `As of 2026-09`.**
266
+ `oauth-discovery.ts` composed `curl -sS -m 5 ${url}` from `discoveryUrl(input.issuer)` at four
267
+ sites, and `new URL()` keeps `$`, `(`, `)` and backticks in a path — so an issuer one boot
268
+ mistake away from hostile wrote the command substitution into the line a reader pastes.
269
+ `renderFixLiteral` cannot stand in for it: it answers DOUBLE quotes, in which `$(…)` is still
270
+ live. The two are used side by side in that file — the shell renderer for the four `curl`/prose
271
+ lines, `renderFixLiteral` for the one that is a `registerOAuthProvider({ id: … })` call, where
272
+ quoting IS the escape and a bare `'${id}'` broke on an apostrophe.
273
+
274
+ **Every OAuth endpoint in a command position is screened, not just the discovery URL** (`As of
275
+ 2026-09-06`). `jwks.ts` (three lines, one `probe` const), `oauth-profile.ts`'s userinfo probe and
276
+ `oauth-exchange.ts`'s token probe each composed `curl … ${url}` from `providerFor(id)`, and that
277
+ registry is filled by `discoverOAuthProvider` — the ISSUER's own document — as well as by the
278
+ app. `oauth-exchange.ts`'s own comment claimed `url` was "a framework constant"; it has not been
279
+ one since `registerOAuthProvider` opened, and the comment says so now. The value still rides in
280
+ the `cause` on the two legs whose detail names the host; `X_OAUTH_EXCHANGE_FAILED` carries no
281
+ `meta` passthrough.
282
+
283
+ **A screened value is half the contract; the LINE has to run** (`As of 2026-09-06`). A
284
+ placeholder is honest text and is not a command — `curl -sS -m 5 <the provider jwks_uri>` is read
285
+ by a shell as a redirection from a file called `the`, so the one instruction the reader was
286
+ handed fails, which is axiom 4 inverted. `jwks.ts`'s three lines share one `readTheKeySet(tail)`:
287
+ `isFixShellSafe` (core, the predicate `renderFixShellArg` is itself built on) decides, and the
288
+ whole fix degrades to PROSE naming the discovery document when the URI does not travel.
289
+ `oauth-discovery.ts` still owes the same repair and is what keeps `auth` on
290
+ `FIX_SHELL_ARG_PINS`.
256
291
  - `readCookie` never throws on a malformed value. The `Cookie:` header is attacker-controlled and
257
292
  `decodeURIComponent('%')` is a bare `URIError`, which would escape every coded path in this
258
293
  package — the raw value goes to the signature or hash check, which is the readable refusal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/auth",
3
- "version": "19.2.0",
3
+ "version": "19.3.2",
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": "19.2.0",
35
- "@ultimat3/db": "19.2.0",
36
- "@ultimat3/schema": "19.2.0"
34
+ "@ultimat3/core": "19.3.2",
35
+ "@ultimat3/db": "19.3.2",
36
+ "@ultimat3/schema": "19.3.2"
37
37
  }
38
38
  }
package/src/jwks.ts CHANGED
@@ -7,7 +7,14 @@
7
7
  // no credential, so a signature check has to exist before those doors are opened.
8
8
 
9
9
  import type { Clock, Scheduler } from '@ultimat3/core';
10
- import { createFence, createSingleFlight, renderThrowable, systemClock } from '@ultimat3/core';
10
+ import {
11
+ createFence,
12
+ createSingleFlight,
13
+ isFixShellSafe,
14
+ renderFixShellArg,
15
+ renderThrowable,
16
+ systemClock,
17
+ } from '@ultimat3/core';
11
18
  import { decodeJwtSegment, isRecord } from './json';
12
19
  import type { OAuthProvider } from './oauth';
13
20
  import { oauthExchangeFailed, oauthTokenInvalid } from './oauth-errors';
@@ -138,6 +145,23 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
138
145
  // against the new `kid` would start missing again. Read, never `guard`: a superseded refresh is
139
146
  // still the honest answer for the callers holding it, and only the shared cache is fenced.
140
147
  const fence = createFence('the published jwks key set');
148
+ // All three refusals below put this URI in a COMMAND position, and it came out of the ISSUER's
149
+ // own discovery document — `jwks_uri` is remote text, and `new URL()` keeps `$`, `(`, `)` and a
150
+ // backtick in a path. `renderFixLiteral` is the wrong tool here, because its double quotes leave
151
+ // `$(…)` live. Same screen, same reason, as `oauth-discovery.ts`'s `target`.
152
+ //
153
+ // The whole line degrades rather than the value alone: a PLACEHOLDER is honest text and is not a
154
+ // runnable command — `curl -sS -m 5 <the provider jwks_uri>` is read by a shell as a redirection
155
+ // from a file called `the`, so the one instruction the reader was handed fails, which is axiom 4
156
+ // inverted. `isFixShellSafe` is the predicate `renderFixShellArg` is itself built on, so the two
157
+ // cannot disagree about which URIs travel.
158
+ const spellable = isFixShellSafe(options.jwksUri);
159
+ const probe = renderFixShellArg(options.jwksUri, '<the provider jwks_uri>');
160
+ /** `curl` when the URI survives being read verbatim, prose naming what to fetch when it does not. */
161
+ const readTheKeySet = (tail: string): string =>
162
+ spellable
163
+ ? `curl -sS -m 5 ${probe}${tail}`
164
+ : `fetch the jwks_uri from ${options.provider}'s discovery document by hand — this one carries shell syntax, so no pasteable command can name it${tail}`;
141
165
 
142
166
  const fetchKeys = async (): Promise<Map<string, CryptoKey>> => {
143
167
  const issued = fence.bump();
@@ -156,7 +180,7 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
156
180
  // `renderThrowable`: this catch is the last frame that can still answer with a code, and
157
181
  // the `kid` that got here came out of an attacker-supplied JWT header.
158
182
  detail: renderThrowable(error),
159
- fix: `curl -sS -m 5 ${options.jwksUri}`,
183
+ fix: readTheKeySet(''),
160
184
  });
161
185
  }
162
186
  if (!response.ok) {
@@ -165,7 +189,7 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
165
189
  stage: 'jwks',
166
190
  detail: 'the key set could not be read, so no signature can be checked',
167
191
  status: response.status,
168
- fix: `curl -sS -m 5 ${options.jwksUri}`,
192
+ fix: readTheKeySet(''),
169
193
  });
170
194
  }
171
195
  const body: unknown = await response.json().catch(() => undefined);
@@ -238,7 +262,7 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
238
262
  throw oauthTokenInvalid(
239
263
  options.provider,
240
264
  `no ${alg} key in the published set matches this token's kid`,
241
- `curl -sS -m 5 ${options.jwksUri} # then confirm the token was signed by this issuer`,
265
+ readTheKeySet(' # then confirm the token was signed by this issuer'),
242
266
  );
243
267
  }
244
268
  return key;
@@ -3,7 +3,7 @@
3
3
  // boot, no dependency — an enterprise IdP is then three lines instead of a hand-copied table of
4
4
  // four endpoints that nobody re-checks when the vendor moves one.
5
5
 
6
- import { renderThrowable } from '@ultimat3/core';
6
+ import { renderFixLiteral, renderFixShellArg, renderThrowable } from '@ultimat3/core';
7
7
  import { isRecord } from './json';
8
8
  import type { OAuthProvider } from './oauth';
9
9
  import { oauthExchangeFailed } from './oauth-errors';
@@ -54,6 +54,13 @@ export async function discoverOAuthProvider(
54
54
  input: DiscoverOAuthProviderInput,
55
55
  ): Promise<OAuthProvider> {
56
56
  const url = discoveryUrl(input.issuer);
57
+ // Every `fix:` below puts this URL in a COMMAND position, and `new URL()` keeps `$`, `(`, `)`
58
+ // and backticks in a path — so an issuer one boot mistake away from hostile composes the shell
59
+ // line the framework tells its reader to paste. `renderFixShellArg` passes an ordinary issuer
60
+ // through untouched and substitutes the shape to fill in for anything a shell would read;
61
+ // `renderFixLiteral` is the wrong tool for a command, because its double quotes leave `$(…)`
62
+ // live.
63
+ const target = renderFixShellArg(url, '<issuer>/.well-known/openid-configuration');
57
64
  const doFetch: OAuthFetch = input.fetch ?? ((target, init) => globalThis.fetch(target, init));
58
65
  // Screened OUTSIDE the try below, deliberately: `AbortSignal.timeout(NaN)` throws, and the
59
66
  // catch around this fetch renders every throw as a provider failure — so a typo in the app's
@@ -79,7 +86,7 @@ export async function discoverOAuthProvider(
79
86
  // `renderThrowable`: an injected `fetch` may reject with anything, `instanceof` throws on a
80
87
  // value that traps `getPrototypeOf`, and a bare `TypeError` here is an uncoded crash.
81
88
  detail: renderThrowable(error),
82
- fix: `curl -sS -m 5 ${url}`,
89
+ fix: `curl -sS -m 5 ${target}`,
83
90
  });
84
91
  }
85
92
 
@@ -89,7 +96,7 @@ export async function discoverOAuthProvider(
89
96
  stage: 'discovery',
90
97
  detail: 'the issuer published no discovery document at the well-known path',
91
98
  status: response.status,
92
- fix: `curl -sS -m 5 ${url} # then pass the real issuer URL to discoverOAuthProvider({ issuer })`,
99
+ fix: `curl -sS -m 5 ${target} # then pass the real issuer URL to discoverOAuthProvider({ issuer })`,
93
100
  });
94
101
  }
95
102
 
@@ -99,7 +106,7 @@ export async function discoverOAuthProvider(
99
106
  provider: input.id,
100
107
  stage: 'discovery',
101
108
  detail: 'the discovery document is not a JSON object',
102
- fix: `confirm ${url} is the OP's own discovery document and not a login page`,
109
+ fix: `confirm ${target} is the OP's own discovery document and not a login page`,
103
110
  });
104
111
  }
105
112
 
@@ -113,7 +120,7 @@ export async function discoverOAuthProvider(
113
120
  detail:
114
121
  'the discovery document is missing issuer, authorization_endpoint or token_endpoint, ' +
115
122
  'so no handshake can be built from it',
116
- fix: `curl -sS -m 5 ${url} | jq '{issuer, authorization_endpoint, token_endpoint}'`,
123
+ fix: `curl -sS -m 5 ${target} | jq '{issuer, authorization_endpoint, token_endpoint}'`,
117
124
  });
118
125
  }
119
126
 
@@ -126,7 +133,10 @@ export async function discoverOAuthProvider(
126
133
  stage: 'discovery',
127
134
  detail:
128
135
  'the discovery document publishes no jwks_uri, so no id token from it can be verified',
129
- fix: `register this provider by hand with an explicit jwksUri: registerOAuthProvider({ id: '${input.id}', ... })`,
136
+ // `renderFixLiteral` and not the shell renderer: this fix is a JS call, where quoting IS
137
+ // the escape. A bare interpolation inside the single quotes it used to carry meant an id
138
+ // holding an apostrophe closed the literal and printed a fix that does not parse.
139
+ fix: `register this provider by hand with an explicit jwksUri: registerOAuthProvider({ id: ${renderFixLiteral(input.id, "'<id>'")}, ... })`,
130
140
  });
131
141
  }
132
142
 
@@ -8,6 +8,7 @@ import {
8
8
  EnvMissingError,
9
9
  logger,
10
10
  renderCauseValue,
11
+ renderFixShellArg,
11
12
  renderThrowable,
12
13
  systemClock,
13
14
  } from '@ultimat3/core';
@@ -199,8 +200,11 @@ async function postForm(
199
200
  //
200
201
  // `renderThrowable`, never `error.message` behind an `instanceof`: `instanceof` itself throws
201
202
  // on a value whose `getPrototypeOf` trap does, losing the one refusal that tells a caller the
202
- // code is already spent. What stays in the sentence is `url` and the remedy, both framework
203
- // constants, so the failure is still fixable without quoting anything this package does not own.
203
+ // code is already spent. What stays in the sentence is `url` and the remedy, so the failure is
204
+ // still fixable without quoting anything this package does not own — but `url` is NOT a
205
+ // framework constant, which this comment claimed until 2026-09: `registerOAuthProvider` is open
206
+ // to any app and `discoverOAuthProvider` fills `tokenUrl` from the OP's own document, so the
207
+ // `fix:` below screens it (a `cause` is read, a `fix:` is pasted).
204
208
  logger.error('auth.oauth.token_fetch_failed', {
205
209
  provider,
206
210
  url,
@@ -213,7 +217,7 @@ async function postForm(
213
217
  `nothing left this host for ${url} (egress, DNS or TLS); the reason is in this process ` +
214
218
  'log under auth.oauth.token_fetch_failed. Restart the flow once it does, since the code ' +
215
219
  'is already spent',
216
- fix: `curl -sS -m 5 -o /dev/null ${url}`,
220
+ fix: `curl -sS -m 5 -o /dev/null ${renderFixShellArg(url, '<the token endpoint the cause names>')}`,
217
221
  });
218
222
  }
219
223
 
@@ -3,7 +3,7 @@
3
3
  // when there is not. `emailVerified` is carried honestly rather than assumed — it is what
4
4
  // decides whether this login may attach itself to an existing account by address.
5
5
 
6
- import { logger, renderThrowable } from '@ultimat3/core';
6
+ import { logger, renderFixShellArg, renderThrowable } from '@ultimat3/core';
7
7
  import { idTokenEmailVerified, isVerifiedFlag } from './id-token';
8
8
  import { isRecord } from './json';
9
9
  import type { OAuthProviderId } from './oauth';
@@ -86,7 +86,12 @@ async function getJson(
86
86
  detail:
87
87
  `${reason} — nothing left this host for ${url} (egress, DNS or TLS); restart the ` +
88
88
  'flow once it does',
89
- fix: `curl -sS -m 5 -o /dev/null ${url}`,
89
+ // The endpoint reaches the registry from `discoverOAuthProvider` — the OP's own document —
90
+ // so it is REMOTE text, and this line is a command a reader pastes: `$(…)` and a backtick
91
+ // substitute before `curl` is reached. `renderFixShellArg` passes an ordinary endpoint
92
+ // through and substitutes the shape for anything a shell would read; the URL itself is in
93
+ // the `detail` above, which is prose nobody pastes.
94
+ fix: `curl -sS -m 5 -o /dev/null ${renderFixShellArg(url, '<the userinfo endpoint the cause names>')}`,
90
95
  });
91
96
  }
92
97
  if (!response.ok) {
package/src/session.ts CHANGED
@@ -156,8 +156,16 @@ export async function verifySession(
156
156
  throw sessionExpired(expiry.absoluteExpired ? 'absolute' : 'idle', session.id);
157
157
  }
158
158
 
159
- const ip = observed?.ip ?? session.ip;
160
- const userAgent = observed?.userAgent ?? session.userAgent;
159
+ // `??` collapsed two different statements. `null` is an OBSERVATION — this request had no
160
+ // client address, or no user agent — and `undefined` is silence, so an explicit null could
161
+ // never clear a stale stored value: the address from an earlier request was written forward on
162
+ // every verify, and the device list a user checks for a session they do not recognise showed it
163
+ // as current. Absent still keeps what the row holds; only silence does.
164
+ const ip = observed === undefined || observed.ip === undefined ? session.ip : observed.ip;
165
+ const userAgent =
166
+ observed === undefined || observed.userAgent === undefined
167
+ ? session.userAgent
168
+ : observed.userAgent;
161
169
  // The window slides only when it has actually moved. A second request inside the slide issues
162
170
  // no write at all — the read path stays a read — while a changed address or user agent is
163
171
  // written immediately, because that is the row a device list and an incident review read.