@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 +35 -0
- package/package.json +4 -4
- package/src/jwks.ts +28 -4
- package/src/oauth-discovery.ts +16 -6
- package/src/oauth-exchange.ts +7 -3
- package/src/oauth-profile.ts +7 -2
- package/src/session.ts +10 -2
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
|
|
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
|
|
35
|
-
"@ultimat3/db": "19.2
|
|
36
|
-
"@ultimat3/schema": "19.2
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
265
|
+
readTheKeySet(' # then confirm the token was signed by this issuer'),
|
|
242
266
|
);
|
|
243
267
|
}
|
|
244
268
|
return key;
|
package/src/oauth-discovery.ts
CHANGED
|
@@ -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 ${
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
package/src/oauth-exchange.ts
CHANGED
|
@@ -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,
|
|
203
|
-
//
|
|
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
|
|
package/src/oauth-profile.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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.
|