@ultimat3/auth 1.2.0 → 2.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 +231 -0
- package/README.md +416 -34
- package/package.json +5 -4
- package/src/adapter.ts +69 -4
- package/src/auth.ts +96 -14
- package/src/builtin-adapter.ts +83 -6
- package/src/directory.ts +77 -0
- package/src/email.ts +17 -0
- package/src/errors.ts +219 -14
- package/src/guards.ts +6 -26
- package/src/id-token.ts +48 -26
- package/src/index.ts +105 -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/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 +20 -8
- 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/src/errors.ts
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
// factories here are deliberately coarse — `rate-limit.ts` owns the single login failure
|
|
4
4
|
// every credential path must throw, and nothing else describes *why* a credential failed.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
registerErrorCodes,
|
|
8
|
+
renderCauseValue,
|
|
9
|
+
renderFixLiteral,
|
|
10
|
+
UltimateError,
|
|
11
|
+
} from '@ultimat3/core';
|
|
12
|
+
import { oauthStartPath } from './oauth-paths';
|
|
7
13
|
|
|
8
14
|
/** Codes this package declares and owns. `X_UNAUTHENTICATED` is auth's; http only borrows it. */
|
|
9
15
|
export const AUTH_OWNED_ERROR_CODES = [
|
|
@@ -13,10 +19,15 @@ export const AUTH_OWNED_ERROR_CODES = [
|
|
|
13
19
|
'X_OAUTH_STATE_INVALID',
|
|
14
20
|
'X_OAUTH_EXCHANGE_FAILED',
|
|
15
21
|
'X_OAUTH_TOKEN_INVALID',
|
|
22
|
+
'X_OAUTH_PROVIDER_UNKNOWN',
|
|
23
|
+
'X_OAUTH_PROVIDER_DUPLICATE',
|
|
24
|
+
'X_OAUTH_DENIED',
|
|
16
25
|
'X_PASSWORD_WEAK',
|
|
17
26
|
'X_ACCOUNT_LOCKED',
|
|
18
27
|
'X_API_KEY_INVALID',
|
|
19
28
|
'X_AUTH_WRITE_FAILED',
|
|
29
|
+
'X_AUTH_LIMITER_NOT_SHARED',
|
|
30
|
+
'X_AUTH_LIMITER_POLICY_MISMATCH',
|
|
20
31
|
] as const;
|
|
21
32
|
|
|
22
33
|
/**
|
|
@@ -24,7 +35,20 @@ export const AUTH_OWNED_ERROR_CODES = [
|
|
|
24
35
|
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. No titles here on purpose — a second copy of a title
|
|
25
36
|
* is a title that drifts, and registering one of these would be `X_ERROR_CODE_DUPLICATE`.
|
|
26
37
|
*/
|
|
27
|
-
|
|
38
|
+
// `X_ENV_MISSING` and `X_CONFIG_INVALID` were thrown here long before they were declared here —
|
|
39
|
+
// oauth-cookie.ts and oauth-exchange.ts refuse on a missing secret, oauth-login.ts on a bad
|
|
40
|
+
// config. An undeclared borrow is a code the manifest cannot attribute to the package that throws
|
|
41
|
+
// it, so `x errors` could not point a reader at this file.
|
|
42
|
+
// `X_OVERLOADED` is `@ultimat3/http`'s, borrowed rather than re-declared: this package is the
|
|
43
|
+
// same tier and can never import http, but a refusal to start 19 MiB of argon2 work IS load
|
|
44
|
+
// shedding and must read as the same thing to a client (503 + Retry-After) whichever layer shed it.
|
|
45
|
+
export const AUTH_BORROWED_ERROR_CODES = [
|
|
46
|
+
'X_FORBIDDEN',
|
|
47
|
+
'X_NOT_IMPLEMENTED',
|
|
48
|
+
'X_ENV_MISSING',
|
|
49
|
+
'X_CONFIG_INVALID',
|
|
50
|
+
'X_OVERLOADED',
|
|
51
|
+
] as const;
|
|
28
52
|
|
|
29
53
|
/** Every code auth can throw: the ones it owns plus the ones it borrows. */
|
|
30
54
|
export const AUTH_ERROR_CODES = [...AUTH_OWNED_ERROR_CODES, ...AUTH_BORROWED_ERROR_CODES] as const;
|
|
@@ -38,11 +62,16 @@ export const AUTH_ERROR_TITLES: Readonly<Record<AuthOwnedErrorCode, string>> = {
|
|
|
38
62
|
X_MFA_REQUIRED: 'a second factor is required before this session is usable',
|
|
39
63
|
X_OAUTH_STATE_INVALID: 'oauth state, nonce or pkce verifier did not match',
|
|
40
64
|
X_OAUTH_EXCHANGE_FAILED: 'the oauth provider refused the exchange or returned no usable identity',
|
|
41
|
-
X_OAUTH_TOKEN_INVALID: 'id token failed its issuer, audience or expiry check',
|
|
65
|
+
X_OAUTH_TOKEN_INVALID: 'id token failed its signature, issuer, audience or expiry check',
|
|
66
|
+
X_OAUTH_PROVIDER_UNKNOWN: 'the URL named a provider this app has not enabled',
|
|
67
|
+
X_OAUTH_PROVIDER_DUPLICATE: 'two oauth providers were registered under one id',
|
|
68
|
+
X_OAUTH_DENIED: 'the user or the provider declined the authorization',
|
|
42
69
|
X_PASSWORD_WEAK: 'password does not meet the configured policy',
|
|
43
70
|
X_ACCOUNT_LOCKED: 'too many failed attempts; this key is locked out',
|
|
44
71
|
X_API_KEY_INVALID: 'api key is unknown, revoked, expired or wrong',
|
|
45
72
|
X_AUTH_WRITE_FAILED: 'an adapter write returned no row, so it cannot be confirmed',
|
|
73
|
+
X_AUTH_LIMITER_NOT_SHARED: 'the lockout is declared fleet-wide and the limiter is per-process',
|
|
74
|
+
X_AUTH_LIMITER_POLICY_MISMATCH: 'the limiter in use enforces other numbers than the app declared',
|
|
46
75
|
};
|
|
47
76
|
|
|
48
77
|
// Registered unconditionally, in one call: a second package claiming a code auth owns has to fail
|
|
@@ -74,11 +103,17 @@ export class AuthError extends UltimateError {
|
|
|
74
103
|
}
|
|
75
104
|
}
|
|
76
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The two things that produce an anonymous actor on a surface that needs one, so the fix names
|
|
108
|
+
* both: the request carried no `__Host-x_session` cookie, or it did and nothing resolved it.
|
|
109
|
+
* `authenticate()` returns the anonymous actor for a missing cookie rather than throwing, which
|
|
110
|
+
* is correct — and is exactly why the failure surfaces here, one layer later, instead of there.
|
|
111
|
+
*/
|
|
77
112
|
export const unauthenticated = (surface: string): AuthError =>
|
|
78
113
|
new AuthError({
|
|
79
114
|
code: 'X_UNAUTHENTICATED',
|
|
80
115
|
cause: `${surface} needs an actor but ctx.actor is anonymous`,
|
|
81
|
-
fix: '
|
|
116
|
+
fix: 'send the __Host-x_session cookie with this request, and resolve it once at the boundary: authenticate(auth, readSessionCookie(request, auth.sessions.policy))',
|
|
82
117
|
});
|
|
83
118
|
|
|
84
119
|
export const forbidden = (surface: string, reason: string): AuthError =>
|
|
@@ -100,27 +135,138 @@ export const sessionUnknown = (): AuthError =>
|
|
|
100
135
|
new AuthError({
|
|
101
136
|
code: 'X_UNAUTHENTICATED',
|
|
102
137
|
cause: 'the session cookie does not match any live session',
|
|
103
|
-
|
|
138
|
+
// No lookup by cookie is offered, and that is deliberate: a forged id and a deleted session
|
|
139
|
+
// are indistinguishable here by design. The reader signs in again; a developer holding a
|
|
140
|
+
// user id can see what is still live without the cookie telling them anything.
|
|
141
|
+
fix: 'sign in again to mint a fresh session — listDevices(auth.sessions, userId) lists the sessions still live for a user',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Shed BEFORE the KDF runs, the same decision `@ultimat3/http`'s `admit` stage makes for a whole
|
|
146
|
+
* request. `retryAfterSeconds` rides in `meta` because this package cannot reach an HTTP header;
|
|
147
|
+
* the host reads it onto `Retry-After`.
|
|
148
|
+
*/
|
|
149
|
+
export const kdfOverloaded = (active: number, queued: number): AuthError =>
|
|
150
|
+
new AuthError({
|
|
151
|
+
code: 'X_OVERLOADED',
|
|
152
|
+
cause: `${active} password hashes are already running and ${queued} more are queued`,
|
|
153
|
+
fix: 'retry after the Retry-After header; widen the ceiling with configureKdfGate({ maxConcurrent, maxQueued }) only if the box has the memory — every argon2id hash holds ~19 MiB while it runs',
|
|
154
|
+
meta: { active, queued, retryAfterSeconds: 1 },
|
|
104
155
|
});
|
|
105
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The second leg is the APP's, and this line says so — it named `POST /auth/mfa/verify` for a
|
|
159
|
+
* release while no such route, no `completeMfa()` and no pending-MFA credential existed anywhere,
|
|
160
|
+
* the same dead-`fix:` defect `oauth-paths.ts` exists to stop. Shipping that route from here would
|
|
161
|
+
* be worse than saying nothing: the only correlation value this error carries is a user id, so the
|
|
162
|
+
* route would be unauthenticated by construction and MFA would become the ONLY factor. The design
|
|
163
|
+
* constraint for the real second leg is in `packages/auth/CLAUDE.md`.
|
|
164
|
+
*
|
|
165
|
+
* `userId` is `meta`, never `cause`: both surfaces that render this code to an anonymous caller
|
|
166
|
+
* (`oauth-route.ts`'s `publicBody`, `@ultimat3/http`'s problem document) publish `cause` and drop
|
|
167
|
+
* `meta`, and a user id handed to whoever typed the URL is what feeds the attack above.
|
|
168
|
+
*/
|
|
106
169
|
export const mfaRequired = (userId: string): AuthError =>
|
|
107
170
|
new AuthError({
|
|
108
171
|
code: 'X_MFA_REQUIRED',
|
|
109
|
-
cause:
|
|
110
|
-
fix: '
|
|
172
|
+
cause: 'this account has TOTP enrolled and the second factor has not been satisfied',
|
|
173
|
+
fix: 'no second-factor route ships yet — catch X_MFA_REQUIRED in your sign-in handler, check the code with verifyTotp({ secret: user.mfaSecret, code, at }), then mint the session with createSession(auth.sessions, { userId, mfaSatisfied: true })',
|
|
174
|
+
meta: { userId },
|
|
111
175
|
});
|
|
112
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The `fix:` quotes `oauthStartPath` rather than a hand-written path. That is not tidiness: this
|
|
179
|
+
* line shipped naming `GET /auth/oauth/<provider>` while `@ultimat3/auth` mounted no route at all,
|
|
180
|
+
* so every caller who followed it hit a 404. One declaration, read by the mount and by the fix,
|
|
181
|
+
* is what stops that recurring — `oauthLogin()` cannot move without moving this sentence.
|
|
182
|
+
*/
|
|
113
183
|
export const oauthStateInvalid = (provider: string, part: string): AuthError =>
|
|
114
184
|
new AuthError({
|
|
115
185
|
code: 'X_OAUTH_STATE_INVALID',
|
|
116
186
|
cause: `${provider} callback rejected: ${part}`,
|
|
117
|
-
fix:
|
|
187
|
+
fix: `${restartAt(provider)} — a callback URL is single-use`,
|
|
188
|
+
meta: { provider },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/** The one phrase every "start over" fix is built from, so none of them can name a dead route. */
|
|
192
|
+
export const restartAt = (provider: string): string =>
|
|
193
|
+
`restart the flow at GET ${oauthStartPath(provider)}`;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The provider came back with `error=` and no code — almost always the user pressing Cancel.
|
|
197
|
+
* A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
|
|
198
|
+
* misconfigured, and folding the single commonest non-success outcome of a login into the code
|
|
199
|
+
* that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
|
|
200
|
+
*/
|
|
201
|
+
export const oauthDenied = (
|
|
202
|
+
provider: string,
|
|
203
|
+
reason: string,
|
|
204
|
+
description: string | null,
|
|
205
|
+
): AuthError =>
|
|
206
|
+
new AuthError({
|
|
207
|
+
code: 'X_OAUTH_DENIED',
|
|
208
|
+
// `reason` and `description` are query parameters off the callback URL — whatever the browser
|
|
209
|
+
// was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
|
|
210
|
+
// string literals, so a forged `error_description` cannot forge a second log line or break the
|
|
211
|
+
// sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
|
|
212
|
+
cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
|
|
213
|
+
description === null ? '' : ` (${renderCauseValue(description)})`
|
|
214
|
+
}`,
|
|
215
|
+
fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
|
|
216
|
+
meta: { provider, reason },
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A URL segment naming a provider no `registerOAuthProvider` call has claimed, or one that is but
|
|
221
|
+
* was left out of `defineAuth({ providers })`. One refusal for both: which of the two it is
|
|
222
|
+
* describes the app's configuration to an unauthenticated caller, and the fix is the same sentence
|
|
223
|
+
* either way.
|
|
224
|
+
*
|
|
225
|
+
* **`supported` is the CALLER's to scope, because the two callers have two audiences.**
|
|
226
|
+
* `oauth-route.ts` passes `BUILTIN_OAUTH_PROVIDER_IDS` — its reader is an anonymous stranger who
|
|
227
|
+
* typed a URL, and the three built-ins are a framework constant already in the public docs, while
|
|
228
|
+
* the live registry holds whatever internal OP this deployment registered. `providerFor()` passes
|
|
229
|
+
* `oauthProviderIds()` — its reader is a developer holding a stack trace, and there the full list
|
|
230
|
+
* is exactly what makes the fix runnable. Neither ever passes `defineAuth({ providers })`: naming
|
|
231
|
+
* what this deployment turned on is the disclosure the shared refusal exists to prevent.
|
|
232
|
+
*
|
|
233
|
+
* The fix names `registerOAuthProvider` first so it stays executable for the branch the narrowed
|
|
234
|
+
* list cannot cover — a segment nothing registered cannot be added to `providers` at all, so
|
|
235
|
+
* "add it" alone was an instruction that could not be followed.
|
|
236
|
+
*
|
|
237
|
+
* The segment itself is a URL path the caller typed, so it goes through `renderCauseValue` in the
|
|
238
|
+
* sentence and `renderFixLiteral` in the command — a fix has to parse after a hostile value lands
|
|
239
|
+
* in it.
|
|
240
|
+
*/
|
|
241
|
+
export const oauthProviderUnknown = (provider: string, supported: readonly string[]): AuthError =>
|
|
242
|
+
new AuthError({
|
|
243
|
+
code: 'X_OAUTH_PROVIDER_UNKNOWN',
|
|
244
|
+
cause: `no oauth provider is mounted at ${renderCauseValue(oauthStartPath(provider))}`,
|
|
245
|
+
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(', ')}`,
|
|
246
|
+
meta: { provider },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Two `registerOAuthProvider` calls claiming one id. A silent replacement would let whichever
|
|
251
|
+
* module imported second decide where every login for that id goes — including which `issuers`
|
|
252
|
+
* an id token may claim — so the second registration refuses at boot instead.
|
|
253
|
+
*/
|
|
254
|
+
export const oauthProviderDuplicate = (provider: string): AuthError =>
|
|
255
|
+
new AuthError({
|
|
256
|
+
code: 'X_OAUTH_PROVIDER_DUPLICATE',
|
|
257
|
+
cause: `an oauth provider is already registered as ${renderCauseValue(provider)}, so the second registration would silently replace the first`,
|
|
258
|
+
fix: `give one of them a different id, or delete the duplicate registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) call`,
|
|
259
|
+
meta: { provider },
|
|
118
260
|
});
|
|
119
261
|
|
|
120
262
|
export interface OAuthExchangeFailure {
|
|
121
263
|
readonly provider: string;
|
|
122
|
-
/**
|
|
123
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Which leg of the server-to-server conversation failed. `discovery` and `jwks` are the two
|
|
266
|
+
* boot/verification legs an enterprise OP adds: reading `/.well-known/openid-configuration`,
|
|
267
|
+
* and reading the key set an id token's signature is checked against.
|
|
268
|
+
*/
|
|
269
|
+
readonly stage: 'token' | 'userinfo' | 'discovery' | 'jwks';
|
|
124
270
|
readonly detail: string;
|
|
125
271
|
readonly status?: number | undefined;
|
|
126
272
|
readonly fix: string;
|
|
@@ -162,6 +308,19 @@ export const oauthAccountNotLinked = (provider: string, email: string): AuthErro
|
|
|
162
308
|
meta: { provider, email },
|
|
163
309
|
});
|
|
164
310
|
|
|
311
|
+
/**
|
|
312
|
+
* `link: 'never'` and a local account already holds the address. Same code and same disclosure
|
|
313
|
+
* rule as `oauthAccountNotLinked` — the caller proved to the provider that the address is theirs,
|
|
314
|
+
* so naming the collision is not enumeration — and the address rides in `meta`, never in `cause`.
|
|
315
|
+
*/
|
|
316
|
+
export const oauthLinkingDisabled = (provider: string, email: string): AuthError =>
|
|
317
|
+
new AuthError({
|
|
318
|
+
code: 'X_UNAUTHENTICATED',
|
|
319
|
+
cause: `an account already holds this address and defineAuth({ link: 'never' }) forbids ${provider} from claiming it`,
|
|
320
|
+
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",
|
|
321
|
+
meta: { provider, email },
|
|
322
|
+
});
|
|
323
|
+
|
|
165
324
|
/**
|
|
166
325
|
* `CreateUserInput` carries no `emailVerifiedAt`, so a provider-verified address takes a second
|
|
167
326
|
* write. Falling back to the unstamped row would mint a session for a user every later login
|
|
@@ -192,11 +351,24 @@ export const passwordWeak = (reasons: readonly string[]): AuthError =>
|
|
|
192
351
|
fix: 'choose a longer, uncommon password — or relax defineAuth({ password: { minLength } })',
|
|
193
352
|
});
|
|
194
353
|
|
|
354
|
+
/**
|
|
355
|
+
* The escape is `recordSuccess(key)`, which is what a successful login already calls: it deletes
|
|
356
|
+
* the bucket, so it clears exactly this one key and nothing else. `reset()` exists too and is the
|
|
357
|
+
* wrong reach — it drops every bucket in the table, including the spray this lockout is holding.
|
|
358
|
+
*
|
|
359
|
+
* `key` carries an address or an email the caller chose, so it goes through `renderFixLiteral`:
|
|
360
|
+
* a fix line has to still parse after a hostile value lands in it.
|
|
361
|
+
*/
|
|
195
362
|
export const accountLocked = (key: string, retryAfterSeconds: number): AuthError =>
|
|
196
363
|
new AuthError({
|
|
197
364
|
code: 'X_ACCOUNT_LOCKED',
|
|
198
|
-
|
|
199
|
-
|
|
365
|
+
// `renderCauseValue`, matching `oauthDenied`: `ipKey(ip)` builds this key from whatever address
|
|
366
|
+
// string its caller passed, so a newline in it writes a second log line an operator reads as
|
|
367
|
+
// genuine. The value is `string` by type — the static scan only sees `unknown`/`any`, so this
|
|
368
|
+
// one was never going to be caught for us — and rendering it as a JSON string literal is
|
|
369
|
+
// escaping, not throw-safety.
|
|
370
|
+
cause: `${renderCauseValue(key)} is locked out for another ${retryAfterSeconds}s after repeated failures`,
|
|
371
|
+
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 })`,
|
|
200
372
|
});
|
|
201
373
|
|
|
202
374
|
/** One shape for every api-key rejection: unknown, revoked, expired and wrong all look alike. */
|
|
@@ -204,7 +376,9 @@ export const apiKeyInvalid = (): AuthError =>
|
|
|
204
376
|
new AuthError({
|
|
205
377
|
code: 'X_API_KEY_INVALID',
|
|
206
378
|
cause: 'the presented api key is unknown, revoked, expired or does not match its hash',
|
|
207
|
-
|
|
379
|
+
// Which of the four it was is not named here and never will be — see the factory's contract
|
|
380
|
+
// above — so the fix is the one action that resolves all four: issue a replacement.
|
|
381
|
+
fix: 'issue a replacement: const { plaintext, record } = issueApiKey({ env, scopes }); await auth.adapter.putApiKey(record) — auth.adapter.listApiKeys(ownerId) through describeApiKey() shows which keys are still live',
|
|
208
382
|
});
|
|
209
383
|
|
|
210
384
|
/**
|
|
@@ -216,10 +390,41 @@ export const authWriteFailed = (operation: string, table: string): AuthError =>
|
|
|
216
390
|
new AuthError({
|
|
217
391
|
code: 'X_AUTH_WRITE_FAILED',
|
|
218
392
|
cause: `${operation} returned no row from ${table}, so the write cannot be confirmed`,
|
|
219
|
-
fix: `x db migrate # then: x
|
|
393
|
+
fix: `x db migrate # then, if ${table} is already there: x dev, and read it in the db panel at /_x`,
|
|
220
394
|
meta: { operation, table },
|
|
221
395
|
});
|
|
222
396
|
|
|
397
|
+
/**
|
|
398
|
+
* At `defineAuth`, never at a login. `replicas: 3` behind one policy means each process counts
|
|
399
|
+
* failures on its own, so the account survives `maxAttempts × 3` guesses and a lockout established
|
|
400
|
+
* on one replica is invisible to the other two — a throttle that reads as configured and is not.
|
|
401
|
+
*/
|
|
402
|
+
export const authLimiterNotShared = (found: string): AuthError =>
|
|
403
|
+
new AuthError({
|
|
404
|
+
code: 'X_AUTH_LIMITER_NOT_SHARED',
|
|
405
|
+
cause: `rateLimit.scope is 'shared' but the limiter in use is ${found}, so every replica would grant the full maxAttempts on its own`,
|
|
406
|
+
fix: "pass a limiter whose scope is 'shared' — defineAuth({ adapter, limiter }) — or set rateLimit.scope: 'process' in defineAuth to accept per-replica lockouts",
|
|
407
|
+
meta: { scope: found },
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* At `defineAuth`, never at a login. `Auth.rateLimit` is what an operator reads as "what this
|
|
412
|
+
* deployment enforces", and an injected limiter counting to its own numbers makes that field a
|
|
413
|
+
* claim nothing backs — five attempts declared, fifty granted, and every surface reporting five.
|
|
414
|
+
* The policy is the app's single statement of the limits; this is what keeps it true.
|
|
415
|
+
*/
|
|
416
|
+
export const authLimiterPolicyMismatch = (
|
|
417
|
+
field: string,
|
|
418
|
+
declared: number,
|
|
419
|
+
enforced: number,
|
|
420
|
+
): AuthError =>
|
|
421
|
+
new AuthError({
|
|
422
|
+
code: 'X_AUTH_LIMITER_POLICY_MISMATCH',
|
|
423
|
+
cause: `defineAuth declares rateLimit.${field} = ${declared} but the limiter passed to it enforces ${enforced}; if ${enforced} is the number this deployment means to enforce, then the declaration is the half that is wrong`,
|
|
424
|
+
fix: `construct the limiter with ${field}: ${declared} — defineAuth({ rateLimit, limiter }) compares the two, and the declaration is what Auth.rateLimit reports`,
|
|
425
|
+
meta: { field, declared, enforced },
|
|
426
|
+
});
|
|
427
|
+
|
|
223
428
|
/** For a custom `AuthAdapter` that implements part of the seam. Nothing shipped throws it. */
|
|
224
429
|
export const authNotImplemented = (feature: string, fix: string): AuthError =>
|
|
225
430
|
new AuthError({
|
package/src/guards.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// Single responsibility:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Single responsibility: whether somebody is signed in. Both read the ambient actor from core's
|
|
2
|
+
// context and assert on it — they never evaluate a policy, never load a row and never look at a
|
|
3
|
+
// session. `@ultimat3/policy` is the only authz evaluator, so a role or scope decision does not
|
|
4
|
+
// live here: it is a `Policy`, which `x routes`, the manifest and `x policy list` can all see.
|
|
5
5
|
|
|
6
6
|
import type { Actor } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { isAnonymous, useContext } from '@ultimat3/core';
|
|
8
|
+
import { unauthenticated } from './errors';
|
|
9
9
|
|
|
10
10
|
const DEFAULT_SURFACE = 'this request';
|
|
11
11
|
|
|
@@ -16,26 +16,6 @@ export function requireActor(surface: string = DEFAULT_SURFACE): Actor {
|
|
|
16
16
|
return actor;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* A coarse role gate for routes that are role-shaped rather than permission-shaped (an admin
|
|
21
|
-
* area). Anything finer belongs in a policy — `can('post:publish')`, evaluated by policy.
|
|
22
|
-
*/
|
|
23
|
-
export function requireRole(role: string, surface: string = DEFAULT_SURFACE): Actor {
|
|
24
|
-
const actor = requireActor(surface);
|
|
25
|
-
if (!hasRole(actor, role)) throw forbidden(surface, `actor lacks role "${role}"`);
|
|
26
|
-
return actor;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* The api-key path: an agent's scopes are exactly its key's scopes, so a scope check is a
|
|
31
|
-
* credential check, not an authorization decision.
|
|
32
|
-
*/
|
|
33
|
-
export function requireScope(scope: string, surface: string = DEFAULT_SURFACE): Actor {
|
|
34
|
-
const actor = requireActor(surface);
|
|
35
|
-
if (!hasScope(actor, scope)) throw forbidden(surface, `actor lacks scope "${scope}"`);
|
|
36
|
-
return actor;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
19
|
/** Non-throwing form for a route that renders differently when signed out. */
|
|
40
20
|
export function currentActor(): Actor | null {
|
|
41
21
|
const { actor } = useContext();
|
package/src/id-token.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
// Single responsibility: turning a provider's id token into claims this handshake is allowed to
|
|
2
|
-
// believe.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// believe. Every caller must say where its trust comes from: `keys: 'token-endpoint-tls'` is the
|
|
3
|
+
// one channel OIDC Core 3.1.3.7 exempts from a signature check, and anything else — IdP-initiated
|
|
4
|
+
// login, `response_mode=form_post`, back-channel logout, token exchange — passes a `JwksKeySource`
|
|
5
|
+
// and gets the signature verified. The argument is required rather than defaulted because a
|
|
6
|
+
// default is what silently makes the second door as trusting as the first.
|
|
5
7
|
|
|
6
8
|
import type { Clock } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
+
import { renderCauseValue } from '@ultimat3/core';
|
|
10
|
+
import { oauthStateInvalid, oauthTokenInvalid, restartAt } from './errors';
|
|
11
|
+
import { decodeJwtSegment } from './json';
|
|
12
|
+
import { type IdTokenKeys, verifyJwtSignature } from './jwks';
|
|
13
|
+
import type { OAuthProvider, OAuthProviderId } from './oauth';
|
|
14
|
+
import { providerFor } from './oauth-registry';
|
|
9
15
|
import { timingSafeEqual } from './tokens';
|
|
10
16
|
|
|
11
17
|
/** The subset of OIDC claims this package acts on. Provider-specific extras are ignored. */
|
|
@@ -25,26 +31,18 @@ export interface IdTokenClaims {
|
|
|
25
31
|
/** Two servers rarely agree on the second. Anything wider hides a genuinely expired token. */
|
|
26
32
|
export const ID_TOKEN_CLOCK_SKEW_MS = 60_000;
|
|
27
33
|
|
|
28
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
29
|
-
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
-
|
|
31
34
|
const stringOrUndefined = (value: unknown): string | undefined =>
|
|
32
35
|
typeof value === 'string' ? value : undefined;
|
|
33
36
|
|
|
34
37
|
function decodeSegment(provider: string, segment: string, fix: string): Record<string, unknown> {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
43
|
-
parsed = JSON.parse(new TextDecoder().decode(bytes));
|
|
44
|
-
} catch {
|
|
45
|
-
throw oauthTokenInvalid(provider, 'the payload segment is not base64url-encoded JSON', fix);
|
|
38
|
+
const parsed = decodeJwtSegment(segment);
|
|
39
|
+
if (parsed === null) {
|
|
40
|
+
throw oauthTokenInvalid(
|
|
41
|
+
provider,
|
|
42
|
+
'the payload segment is not base64url-encoded JSON describing an object',
|
|
43
|
+
fix,
|
|
44
|
+
);
|
|
46
45
|
}
|
|
47
|
-
if (!isRecord(parsed)) throw oauthTokenInvalid(provider, 'the payload is not an object', fix);
|
|
48
46
|
return parsed;
|
|
49
47
|
}
|
|
50
48
|
|
|
@@ -53,7 +51,7 @@ function decodeSegment(provider: string, segment: string, fix: string): Record<s
|
|
|
53
51
|
* point every flow uses; this one exists because the two halves are worth reading apart.
|
|
54
52
|
*/
|
|
55
53
|
export function decodeIdToken(provider: OAuthProviderId, idToken: string): IdTokenClaims {
|
|
56
|
-
const fix = `check that ${
|
|
54
|
+
const fix = `check that ${providerFor(provider).clientIdEnv} names an app whose id token is a JWT`;
|
|
57
55
|
const segments = idToken.split('.');
|
|
58
56
|
if (segments.length !== 3 || segments[1] === undefined || segments[1] === '') {
|
|
59
57
|
throw oauthTokenInvalid(provider, 'the token is not a three-segment JWT', fix);
|
|
@@ -115,23 +113,47 @@ export interface VerifyIdTokenInput {
|
|
|
115
113
|
/** `OAuthHandshake.nonce`. Checked whenever the provider was asked for one. */
|
|
116
114
|
readonly nonce: string;
|
|
117
115
|
readonly clock: Clock;
|
|
116
|
+
/**
|
|
117
|
+
* Required, and there is deliberately no default. `'token-endpoint-tls'` asserts this token was
|
|
118
|
+
* read off a TLS response from the provider's own token endpoint — the OIDC Core 3.1.3.7 case,
|
|
119
|
+
* and the only one where an unverified JWT is safe. Every other channel passes a key source:
|
|
120
|
+
* `providerJwks(providerFor(id))`, or a `createJwksClient({ jwksUri })` of its own.
|
|
121
|
+
*/
|
|
122
|
+
readonly keys: IdTokenKeys;
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
/**
|
|
121
|
-
*
|
|
126
|
+
* Signature, issuer, audience, expiry and nonce, in that order — signature first, because every
|
|
127
|
+
* claim below it is only worth reading once something proved who wrote them. A nonce mismatch is
|
|
122
128
|
* `X_OAUTH_STATE_INVALID` rather than a token error on purpose: it is the same class of event
|
|
123
129
|
* as a forged `state` — a token minted for another browser being replayed into this one.
|
|
124
130
|
*/
|
|
125
|
-
export function verifyIdToken(input: VerifyIdTokenInput): IdTokenClaims {
|
|
131
|
+
export async function verifyIdToken(input: VerifyIdTokenInput): Promise<IdTokenClaims> {
|
|
126
132
|
// Widened on purpose: `issuers` is a literal `readonly []` for a provider that issues no id
|
|
127
133
|
// token, and `[].includes(string)` does not typecheck against `never`.
|
|
128
|
-
const provider: OAuthProvider =
|
|
134
|
+
const provider: OAuthProvider = providerFor(input.provider);
|
|
129
135
|
const claims = decodeIdToken(input.provider, input.idToken);
|
|
130
136
|
|
|
137
|
+
if (
|
|
138
|
+
input.keys !== 'token-endpoint-tls' &&
|
|
139
|
+
!(await verifyJwtSignature(input.idToken, input.keys))
|
|
140
|
+
) {
|
|
141
|
+
throw oauthTokenInvalid(
|
|
142
|
+
provider.id,
|
|
143
|
+
'the signature does not verify against the published key set for this issuer',
|
|
144
|
+
`confirm the token came from ${provider.tokenUrl} and was not minted by whoever posted it here`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
if (!provider.issuers.includes(claims.iss)) {
|
|
132
149
|
throw oauthTokenInvalid(
|
|
133
150
|
provider.id,
|
|
134
|
-
`
|
|
151
|
+
// `claims.iss` is a field of the JWT the caller just presented — whatever they put in it,
|
|
152
|
+
// newlines and quotes included. Hand-written quotes around it did not escape either, so a
|
|
153
|
+
// forged `iss` could close the sentence and write a second log line an operator reads as
|
|
154
|
+
// genuine. `renderCauseValue` renders it as a JSON string literal, quotes included, which
|
|
155
|
+
// is why the manual pair is gone. `issuers` is registered config and needs no rendering.
|
|
156
|
+
`iss was ${renderCauseValue(claims.iss)}, expected one of ${provider.issuers.join(', ')}`,
|
|
135
157
|
`confirm the token came from ${provider.tokenUrl} and not from a proxy that re-signs it`,
|
|
136
158
|
);
|
|
137
159
|
}
|
|
@@ -149,7 +171,7 @@ export function verifyIdToken(input: VerifyIdTokenInput): IdTokenClaims {
|
|
|
149
171
|
throw oauthTokenInvalid(
|
|
150
172
|
provider.id,
|
|
151
173
|
'the token is already expired',
|
|
152
|
-
|
|
174
|
+
`sync this host's clock (\`timedatectl status\`), then ${restartAt(provider.id)}`,
|
|
153
175
|
);
|
|
154
176
|
}
|
|
155
177
|
|