@ultimat3/auth 29.4.0 → 30.1.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 +46 -58
- package/README.md +162 -9
- package/package.json +20 -5
- package/src/adapter.ts +88 -2
- package/src/api-key-resolver.ts +3 -3
- package/src/auth.ts +29 -7
- package/src/authorization-server-clients.ts +169 -0
- package/src/authorization-server-config.ts +239 -0
- package/src/authorization-server-errors.ts +23 -0
- package/src/authorization-server-postgres-sql.ts +107 -0
- package/src/authorization-server-postgres.ts +222 -0
- package/src/authorization-server-register.ts +98 -0
- package/src/authorization-server-request.ts +126 -0
- package/src/authorization-server-routes.ts +181 -0
- package/src/authorization-server-schema.ts +82 -0
- package/src/authorization-server-store.ts +189 -0
- package/src/authorization-server-tokens.ts +118 -0
- package/src/authorization-server-types.ts +108 -0
- package/src/authorization-server-wire.ts +135 -0
- package/src/authorization-server.ts +178 -0
- package/src/base64url.ts +40 -0
- package/src/builtin-adapter-violation.ts +12 -9
- package/src/builtin-adapter.ts +31 -5
- package/src/cbor-encode.ts +52 -0
- package/src/cbor.ts +129 -0
- package/src/client-scope.ts +2 -5
- package/src/client.ts +16 -0
- package/src/cose-key.ts +129 -0
- package/src/errors.ts +51 -0
- package/src/grant-authorization-code.ts +113 -0
- package/src/grant-client-credentials.ts +55 -0
- package/src/grant-device.ts +188 -0
- package/src/index.ts +108 -3
- package/src/jwks.ts +81 -12
- package/src/jwt-bearer-resolver.ts +80 -0
- package/src/limiter-install.ts +31 -2
- package/src/memory-adapter.ts +18 -0
- package/src/mfa-challenge.ts +13 -7
- package/src/mfa-replay-postgres.ts +71 -0
- package/src/mfa.ts +24 -13
- package/src/oauth-bearer-resolver.ts +44 -0
- package/src/oauth-cookie.ts +2 -1
- package/src/oauth-discovery.ts +17 -6
- package/src/oauth-exchange.ts +23 -12
- package/src/oauth-profile.ts +20 -9
- package/src/oauth-route.ts +5 -5
- package/src/passkey-assertion.ts +60 -0
- package/src/passkey-challenge.ts +59 -0
- package/src/passkey-client.ts +156 -0
- package/src/passkey-config.ts +92 -0
- package/src/passkey-errors.ts +89 -0
- package/src/passkey-evidence.ts +74 -0
- package/src/passkey-login.ts +127 -0
- package/src/passkey-memory.ts +77 -0
- package/src/passkey-postgres.ts +155 -0
- package/src/passkey-step-up.ts +135 -0
- package/src/passkey-verify.ts +180 -0
- package/src/passkey-wire.ts +61 -0
- package/src/soft-authenticator.ts +208 -0
- package/src/subject-target.ts +73 -0
- package/src/tables.ts +46 -0
- package/src/token-endpoints.ts +61 -0
- package/src/tokens.ts +2 -25
- package/src/verification-purposes.ts +61 -0
- package/src/verify.ts +86 -12
- package/src/webauthn-data.ts +111 -0
- package/src/workload.ts +67 -15
package/src/index.ts
CHANGED
|
@@ -12,6 +12,11 @@ export type {
|
|
|
12
12
|
AuthUser,
|
|
13
13
|
AuthVerification,
|
|
14
14
|
CreateUserInput,
|
|
15
|
+
PasskeyCeremony,
|
|
16
|
+
PasskeyChallengeRecord,
|
|
17
|
+
PasskeyCredential,
|
|
18
|
+
PasskeyEvidence,
|
|
19
|
+
PasskeyStore,
|
|
15
20
|
SessionPatch,
|
|
16
21
|
SessionStore,
|
|
17
22
|
StoredMfaSecret,
|
|
@@ -61,6 +66,47 @@ export {
|
|
|
61
66
|
UserSchema,
|
|
62
67
|
VerificationSchema,
|
|
63
68
|
} from './auth';
|
|
69
|
+
export type { AuthorizationServer } from './authorization-server';
|
|
70
|
+
export { AUTH_CODE_TTL_MS, authorizationServer } from './authorization-server';
|
|
71
|
+
export type {
|
|
72
|
+
RegisterClientInput,
|
|
73
|
+
RegisteredClient,
|
|
74
|
+
} from './authorization-server-clients';
|
|
75
|
+
export type {
|
|
76
|
+
AuthorizationServerOptions,
|
|
77
|
+
AuthServerEvent,
|
|
78
|
+
AuthServerLifetimes,
|
|
79
|
+
} from './authorization-server-config';
|
|
80
|
+
export type { PostgresAuthServerStoreOptions } from './authorization-server-postgres';
|
|
81
|
+
export { postgresAuthServerStore } from './authorization-server-postgres';
|
|
82
|
+
export type { ReadRequestResult } from './authorization-server-request';
|
|
83
|
+
export { AUTH_SERVER_METADATA_WELL_KNOWN } from './authorization-server-routes';
|
|
84
|
+
export { AUTH_SERVER_TABLES, SQL_AUTH_SERVER_TABLES } from './authorization-server-schema';
|
|
85
|
+
export type {
|
|
86
|
+
AuthServerStore,
|
|
87
|
+
DeviceDecision,
|
|
88
|
+
RefreshRotation,
|
|
89
|
+
} from './authorization-server-store';
|
|
90
|
+
export { memoryAuthServerStore } from './authorization-server-store';
|
|
91
|
+
export type {
|
|
92
|
+
AuthCodeRecord,
|
|
93
|
+
AuthGrantRecord,
|
|
94
|
+
AuthorizationRequest,
|
|
95
|
+
AuthServerClient,
|
|
96
|
+
AuthServerGrant,
|
|
97
|
+
AuthTokenKind,
|
|
98
|
+
AuthTokenRecord,
|
|
99
|
+
ClientAuthMethod,
|
|
100
|
+
DeviceCodeRecord,
|
|
101
|
+
DeviceStatus,
|
|
102
|
+
ResolvedGrant,
|
|
103
|
+
} from './authorization-server-types';
|
|
104
|
+
export { DEVICE_GRANT } from './authorization-server-types';
|
|
105
|
+
export type {
|
|
106
|
+
AuthServerHttpContext,
|
|
107
|
+
AuthServerHttpRequest,
|
|
108
|
+
AuthServerRoute,
|
|
109
|
+
} from './authorization-server-wire';
|
|
64
110
|
export { type PostgresAuthAdapter, postgresAuthAdapter } from './builtin-adapter';
|
|
65
111
|
/** The opaque per-principal id a per-request document hands the page's client store. */
|
|
66
112
|
export type { ClientScopeOptions } from './client-scope';
|
|
@@ -91,7 +137,9 @@ export {
|
|
|
91
137
|
passwordWeak,
|
|
92
138
|
sessionExpired,
|
|
93
139
|
sessionUnknown,
|
|
140
|
+
signInUnconfigured,
|
|
94
141
|
} from './errors';
|
|
142
|
+
export type { DeviceReading } from './grant-device';
|
|
95
143
|
export { currentActor, requireActor } from './guards';
|
|
96
144
|
export type { IdTokenClaims, VerifyIdTokenInput } from './id-token';
|
|
97
145
|
export {
|
|
@@ -113,16 +161,28 @@ export {
|
|
|
113
161
|
providerJwks,
|
|
114
162
|
verifyJwtSignature,
|
|
115
163
|
} from './jwks';
|
|
164
|
+
export type {
|
|
165
|
+
JwtBearerCaller,
|
|
166
|
+
JwtBearerCheck,
|
|
167
|
+
JwtBearerResolverOptions,
|
|
168
|
+
} from './jwt-bearer-resolver';
|
|
169
|
+
export { jwtBearerResolver } from './jwt-bearer-resolver';
|
|
116
170
|
export type { KdfGate, KdfLimits } from './kdf-gate';
|
|
117
171
|
export {
|
|
118
172
|
boundedKdfGate,
|
|
119
173
|
configureKdfGate,
|
|
120
174
|
kdfGate,
|
|
121
175
|
} from './kdf-gate';
|
|
122
|
-
export type { AuthLimiterFactory } from './limiter-install';
|
|
176
|
+
export type { AuthLimiterFactory, TotpReplayFactory } from './limiter-install';
|
|
123
177
|
// `installedAuthLimiter` is deliberately absent: `defineAuth` is the one reader, and a second
|
|
124
178
|
// caller building limiters out of band would be a second answer to where failures are counted.
|
|
125
|
-
export {
|
|
179
|
+
export {
|
|
180
|
+
configureAuthLimiters,
|
|
181
|
+
configureTotpReplay,
|
|
182
|
+
purgeAuthLimits,
|
|
183
|
+
resetAuthLimiters,
|
|
184
|
+
resetTotpReplay,
|
|
185
|
+
} from './limiter-install';
|
|
126
186
|
export type { HandleDirectory } from './login-identity';
|
|
127
187
|
export { normaliseHandle } from './login-identity';
|
|
128
188
|
export { type MemoryAuthAdapter, memoryAuthAdapter } from './memory-adapter';
|
|
@@ -160,6 +220,11 @@ export {
|
|
|
160
220
|
MFA_CHALLENGE_TTL_MS,
|
|
161
221
|
mfaChallengeRequired,
|
|
162
222
|
} from './mfa-challenge';
|
|
223
|
+
export type {
|
|
224
|
+
PostgresTotpReplayGuard,
|
|
225
|
+
PostgresTotpReplayGuardOptions,
|
|
226
|
+
} from './mfa-replay-postgres';
|
|
227
|
+
export { postgresTotpReplayGuard, SQL_TOTP_REPLAY_TABLE } from './mfa-replay-postgres';
|
|
163
228
|
export type { MfaSecretStore, SealMfaSecretsReport } from './mfa-secret';
|
|
164
229
|
export {
|
|
165
230
|
countUnsealedMfaSecrets,
|
|
@@ -178,6 +243,12 @@ export type {
|
|
|
178
243
|
TokenEndpointAuth,
|
|
179
244
|
} from './oauth';
|
|
180
245
|
export { assertOAuthCallback, beginOAuth, pkcePair } from './oauth';
|
|
246
|
+
export type {
|
|
247
|
+
OAuthBearerCaller,
|
|
248
|
+
OAuthBearerResolverOptions,
|
|
249
|
+
OAuthTokenSource,
|
|
250
|
+
} from './oauth-bearer-resolver';
|
|
251
|
+
export { oauthBearerResolver } from './oauth-bearer-resolver';
|
|
181
252
|
export {
|
|
182
253
|
BUILTIN_OAUTH_PROVIDER_IDS,
|
|
183
254
|
BUILTIN_OAUTH_PROVIDERS,
|
|
@@ -246,6 +317,25 @@ export {
|
|
|
246
317
|
} from './oauth-registry';
|
|
247
318
|
export type { AuthRouteDescriptor, OAuthLoginOptions, OAuthLoginRoutes } from './oauth-route';
|
|
248
319
|
export { OAUTH_ROUTE_STATUS, oauthDenied, oauthLogin } from './oauth-route';
|
|
320
|
+
// Passkeys (WebAuthn): sign-in and digest-bound step-up. The browser half is `@ultimat3/auth/client`.
|
|
321
|
+
export type {
|
|
322
|
+
PasskeyPolicy,
|
|
323
|
+
PasskeyPolicyInput,
|
|
324
|
+
PasskeyRelyingParty,
|
|
325
|
+
} from './passkey-config';
|
|
326
|
+
export { DEFAULT_PASSKEY_CHALLENGE_TTL_MS } from './passkey-config';
|
|
327
|
+
export { verifyPasskeyEvidence } from './passkey-evidence';
|
|
328
|
+
export type { PasskeyLogin } from './passkey-login';
|
|
329
|
+
export { passkeyLogin } from './passkey-login';
|
|
330
|
+
export type { PasskeyStepUp, PasskeyStepUpOptions } from './passkey-step-up';
|
|
331
|
+
export { passkeyStepUp } from './passkey-step-up';
|
|
332
|
+
export type {
|
|
333
|
+
PasskeyAssertionResponse,
|
|
334
|
+
PasskeyCreationOptions,
|
|
335
|
+
PasskeyCredentialDescriptor,
|
|
336
|
+
PasskeyRegistrationResponse,
|
|
337
|
+
PasskeyRequestOptions,
|
|
338
|
+
} from './passkey-wire';
|
|
249
339
|
export type {
|
|
250
340
|
PasswordParams,
|
|
251
341
|
PasswordPolicy,
|
|
@@ -337,6 +427,12 @@ export {
|
|
|
337
427
|
/** A sign-out response's headers: the session cookie expired, and `Clear-Site-Data`. */
|
|
338
428
|
export type { SignOutHeadersOptions } from './sign-out';
|
|
339
429
|
export { SIGN_OUT_CLEAR_SITE_DATA, signOutHeaders } from './sign-out';
|
|
430
|
+
export type {
|
|
431
|
+
AuthSubjectRef,
|
|
432
|
+
AuthSubjectTarget,
|
|
433
|
+
AuthSubjectTargetOptions,
|
|
434
|
+
} from './subject-target';
|
|
435
|
+
export { AUTH_SUBJECT_COLUMNS, authSubjectTarget } from './subject-target';
|
|
340
436
|
|
|
341
437
|
export {
|
|
342
438
|
AUTH_TABLE_NAMES,
|
|
@@ -346,24 +442,33 @@ export {
|
|
|
346
442
|
randomToken,
|
|
347
443
|
sha256Hex,
|
|
348
444
|
} from './tokens';
|
|
445
|
+
export type { VerificationPurposeDef } from './verification-purposes';
|
|
446
|
+
export { verificationPurposeNames } from './verification-purposes';
|
|
349
447
|
export type {
|
|
350
448
|
ConsumeVerificationInput,
|
|
449
|
+
DefineVerificationPurposeInput,
|
|
351
450
|
IssuedVerification,
|
|
352
451
|
IssueVerificationInput,
|
|
353
452
|
MailSender,
|
|
354
453
|
VerificationPurpose,
|
|
454
|
+
VerificationPurposeHandle,
|
|
355
455
|
VerificationRuntime,
|
|
356
456
|
} from './verify';
|
|
357
457
|
export {
|
|
358
458
|
consumeVerification,
|
|
359
459
|
DEFAULT_VERIFICATION_TTL_MS,
|
|
460
|
+
defineVerificationPurpose,
|
|
461
|
+
emailVerification,
|
|
360
462
|
issueVerification,
|
|
463
|
+
passwordReset,
|
|
361
464
|
VERIFICATION_PURPOSES,
|
|
362
465
|
VERIFICATION_TEMPLATES,
|
|
363
466
|
} from './verify';
|
|
364
467
|
export type {
|
|
365
468
|
VerifyWorkloadTokenInput,
|
|
469
|
+
WorkloadCheck,
|
|
366
470
|
WorkloadClaims,
|
|
367
471
|
WorkloadToken,
|
|
472
|
+
WorkloadTokenCheck,
|
|
368
473
|
} from './workload';
|
|
369
|
-
export { verifyWorkloadToken } from './workload';
|
|
474
|
+
export { checkWorkloadToken, verifyWorkloadToken } from './workload';
|
package/src/jwks.ts
CHANGED
|
@@ -10,11 +10,13 @@ import type { Clock, Scheduler } from '@ultimat3/core';
|
|
|
10
10
|
import {
|
|
11
11
|
generationFence,
|
|
12
12
|
isFixShellSafe,
|
|
13
|
+
logger,
|
|
13
14
|
renderFixShellArg,
|
|
14
15
|
renderThrowable,
|
|
15
16
|
singleFlight,
|
|
16
17
|
systemClock,
|
|
17
18
|
} from '@ultimat3/core';
|
|
19
|
+
import { outboundResponse } from '@ultimat3/core/outbound';
|
|
18
20
|
import { decodeJwtSegment, isRecord } from './json';
|
|
19
21
|
import type { OAuthProvider } from './oauth';
|
|
20
22
|
import { oauthExchangeFailed, oauthTokenInvalid } from './oauth-errors';
|
|
@@ -51,6 +53,16 @@ export type IdTokenKeys = 'token-endpoint-tls' | JwksKeySource;
|
|
|
51
53
|
/** Short enough that a rotated key set is picked up on its own; a new `kid` refreshes early. */
|
|
52
54
|
export const DEFAULT_JWKS_TTL_MS = 10 * 60 * 1000;
|
|
53
55
|
|
|
56
|
+
/**
|
|
57
|
+
* How long past its TTL a key already held still answers while the key set cannot be read. An IdP
|
|
58
|
+
* outage otherwise ends every login with the verifying key in memory. Past it, the refusal: a key
|
|
59
|
+
* may have been withdrawn during the outage, and nothing here can tell.
|
|
60
|
+
*/
|
|
61
|
+
export const JWKS_STALE_IF_ERROR_MS = 60 * 60 * 1000;
|
|
62
|
+
|
|
63
|
+
/** No fetch this soon after a failed one, on either path: an outage costs no request per token. */
|
|
64
|
+
export const JWKS_FAILURE_COOLDOWN_MS = 30 * 1000;
|
|
65
|
+
|
|
54
66
|
/** One client is one key set, so the flight has exactly one key and its name is never read. */
|
|
55
67
|
const REFRESH_KEY = 'jwks';
|
|
56
68
|
|
|
@@ -110,12 +122,16 @@ const algorithmOf = (jwk: Record<string, unknown>): JwtAlgorithm | null => {
|
|
|
110
122
|
return null;
|
|
111
123
|
};
|
|
112
124
|
|
|
125
|
+
/** Where a key published with no `kid` is filed: no name a token could mean starts with NUL. */
|
|
126
|
+
const KID_LESS = '\u0000';
|
|
127
|
+
|
|
113
128
|
/**
|
|
114
129
|
* Caches by `kid` with a TTL, and refetches once when a token names a `kid` the cache has never
|
|
115
130
|
* seen — which is what makes a key rotation heal itself instead of failing every login until the
|
|
116
131
|
* TTL runs out. One refetch, not one per token: an unknown `kid` an attacker invents must not
|
|
117
132
|
* become an unbounded outbound request per attempt, so the refresh is rate-limited by the same
|
|
118
|
-
* TTL as the ordinary one.
|
|
133
|
+
* TTL as the ordinary one. A failed fetch is rated too (`JWKS_FAILURE_COOLDOWN_MS`), and leaves a
|
|
134
|
+
* key already held answering for `JWKS_STALE_IF_ERROR_MS`.
|
|
119
135
|
*/
|
|
120
136
|
export function jwksClient(options: JwksClientOptions): JwksKeySource {
|
|
121
137
|
const clock = options.clock ?? systemClock;
|
|
@@ -139,6 +155,9 @@ export function jwksClient(options: JwksClientOptions): JwksKeySource {
|
|
|
139
155
|
// is reset by every fetch, ordinary ones included — so gating the early refresh on it would let
|
|
140
156
|
// an attacker's own refresh authorise the next one.
|
|
141
157
|
let lastMissRefreshMs = Number.NEGATIVE_INFINITY;
|
|
158
|
+
// The last failed fetch, kept so a caller inside the cooldown gets its refusal without a request.
|
|
159
|
+
let lastFailure: { readonly atMs: number; readonly error: unknown } | null = null;
|
|
160
|
+
let staleAnnounced = false;
|
|
142
161
|
// Two refreshes can overlap once the deadline below can evict one, and BOTH end by installing
|
|
143
162
|
// what they read. Without this, the later-settling of the two wins — so a refresh the client
|
|
144
163
|
// already gave up on could drop a pre-rotation key set on top of the live one, and every login
|
|
@@ -165,14 +184,27 @@ export function jwksClient(options: JwksClientOptions): JwksKeySource {
|
|
|
165
184
|
|
|
166
185
|
const fetchKeys = async (): Promise<Map<string, CryptoKey>> => {
|
|
167
186
|
const issued = fence.bump();
|
|
168
|
-
const doFetch: OAuthFetch =
|
|
187
|
+
const doFetch: OAuthFetch =
|
|
188
|
+
options.fetch ??
|
|
189
|
+
((input, init) => globalThis.fetch(input, { ...init, signal: init.signal ?? null }));
|
|
169
190
|
let response: Response;
|
|
170
191
|
try {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
192
|
+
// Through core's `outbound()`: `timeoutMs` bounds the whole call, the ambient request's signal
|
|
193
|
+
// ends it, a GET's 5xx is retried inside that bound (a POST never is), a 3xx is never followed.
|
|
194
|
+
response = await outboundResponse(
|
|
195
|
+
{
|
|
196
|
+
url: options.jwksUri,
|
|
197
|
+
vendor: options.provider,
|
|
198
|
+
dial: 'named',
|
|
199
|
+
deadlineMs: timeoutMs,
|
|
200
|
+
// One attempt: this file's own fence and cooldown are its retry discipline (one fetch per
|
|
201
|
+
// outage), and a login waiting on a key must not wait out a backoff.
|
|
202
|
+
retry: { attempts: 1, base: 0, max: 0, jitter: 'none' },
|
|
203
|
+
method: 'GET',
|
|
204
|
+
headers: { Accept: 'application/json' },
|
|
205
|
+
},
|
|
206
|
+
{ fetch: doFetch },
|
|
207
|
+
);
|
|
176
208
|
} catch (error) {
|
|
177
209
|
throw oauthExchangeFailed({
|
|
178
210
|
provider: options.provider,
|
|
@@ -199,13 +231,16 @@ export function jwksClient(options: JwksClientOptions): JwksKeySource {
|
|
|
199
231
|
if (!isRecord(entry)) continue;
|
|
200
232
|
const alg = algorithmOf(entry);
|
|
201
233
|
const kid = entry['kid'];
|
|
202
|
-
|
|
234
|
+
const named = typeof kid === 'string' && kid !== '';
|
|
235
|
+
// RFC 7517: `kid` is optional. An absent one is filed by position, so two such keys stay two
|
|
236
|
+
// and `lookup` sees the ambiguity; a present one that is no name is a malformed entry.
|
|
237
|
+
if (alg === null || (kid !== undefined && !named)) continue;
|
|
203
238
|
// A key the runtime cannot import is skipped rather than fatal: one malformed entry in a
|
|
204
239
|
// published set must not take down every login the other keys would still have verified.
|
|
205
240
|
const key = await crypto.subtle
|
|
206
241
|
.importKey('jwk', entry as JsonWebKey, importParams(alg), false, ['verify'])
|
|
207
242
|
.catch(() => null);
|
|
208
|
-
if (key !== null) next.set(`${kid}:${alg}`, key);
|
|
243
|
+
if (key !== null) next.set(`${named ? kid : `${KID_LESS}${next.size}`}:${alg}`, key);
|
|
209
244
|
}
|
|
210
245
|
// A 200 with nothing importable in it is a failed fetch, not a rotation to zero keys. Adopted,
|
|
211
246
|
// it replaced a working cache with an empty one stamped fresh for a whole TTL.
|
|
@@ -244,7 +279,9 @@ export function jwksClient(options: JwksClientOptions): JwksKeySource {
|
|
|
244
279
|
await flight.run(REFRESH_KEY, fetchKeys);
|
|
245
280
|
|
|
246
281
|
const lookup = (current: Map<string, CryptoKey>, kid: string | null, alg: JwtAlgorithm) => {
|
|
247
|
-
|
|
282
|
+
// A token naming a `kid` needs that key: its text is the caller's, so it never spells a filing.
|
|
283
|
+
if (kid !== null)
|
|
284
|
+
return kid.startsWith(KID_LESS) ? null : (current.get(`${kid}:${alg}`) ?? null);
|
|
248
285
|
// No `kid`: unambiguous only when the set holds exactly one key for this algorithm.
|
|
249
286
|
const matches = [...current.entries()].filter(([id]) => id.endsWith(`:${alg}`));
|
|
250
287
|
return matches.length === 1 ? (matches[0]?.[1] ?? null) : null;
|
|
@@ -265,10 +302,42 @@ export function jwksClient(options: JwksClientOptions): JwksKeySource {
|
|
|
265
302
|
// early refresh is rate-limited by the same TTL as the ordinary one — which is what the
|
|
266
303
|
// docstring above always promised. Set BEFORE the await so concurrent callers that pass the
|
|
267
304
|
// gate together still coalesce into the one shared refresh.
|
|
268
|
-
const
|
|
305
|
+
const cooling = lastFailure !== null && nowMs < lastFailure.atMs + JWKS_FAILURE_COOLDOWN_MS;
|
|
306
|
+
const earlyRefresh = !known && !stale && !cooling && nowMs >= lastMissRefreshMs + ttlMs;
|
|
307
|
+
const missGateWas = lastMissRefreshMs;
|
|
269
308
|
if (earlyRefresh) lastMissRefreshMs = nowMs;
|
|
270
|
-
|
|
309
|
+
let failure = cooling ? lastFailure : null;
|
|
310
|
+
if (!cooling && (stale || earlyRefresh)) {
|
|
311
|
+
try {
|
|
312
|
+
current = await load();
|
|
313
|
+
lastFailure = null;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
// Every joiner of one failed fetch lands here holding the same error: one announcement.
|
|
316
|
+
if (lastFailure?.error !== error) staleAnnounced = false;
|
|
317
|
+
failure = { atMs: clock.now().getTime(), error };
|
|
318
|
+
lastFailure = failure;
|
|
319
|
+
// A fetch that read nothing proved nothing about this `kid`. The cooldown rates failures,
|
|
320
|
+
// so the gate is given back, or a rotated key misses for a whole TTL after the issuer heals.
|
|
321
|
+
if (earlyRefresh) lastMissRefreshMs = missGateWas;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
271
324
|
const key = lookup(current, kid, alg);
|
|
325
|
+
if (failure !== null) {
|
|
326
|
+
// Measured from the set's own age, so a flapping issuer cannot extend the window by failing
|
|
327
|
+
// again: a key this old is refused however recently the last fetch was tried.
|
|
328
|
+
const usable =
|
|
329
|
+
key !== null && (!stale || nowMs < fetchedAtMs + ttlMs + JWKS_STALE_IF_ERROR_MS);
|
|
330
|
+
if (!usable) throw failure.error;
|
|
331
|
+
// Once per failed fetch, not per token: an outage at login volume is one line per cooldown.
|
|
332
|
+
if (stale && !staleAnnounced) {
|
|
333
|
+
staleAnnounced = true;
|
|
334
|
+
logger.warn('auth.jwks.stale_served', {
|
|
335
|
+
provider: options.provider,
|
|
336
|
+
staleForMs: nowMs - (fetchedAtMs + ttlMs),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return key;
|
|
340
|
+
}
|
|
272
341
|
if (key === null) {
|
|
273
342
|
throw oauthTokenInvalid(
|
|
274
343
|
options.provider,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Single responsibility: an external issuer's bearer JWT → who is calling, in the `resolveToken`
|
|
2
|
+
// shape a bearer mount and an MCP endpoint take. `apiKeyResolver`'s twin for tokens: the checks are
|
|
3
|
+
// `verifyWorkloadToken`'s, and every refusal — a key set that cannot be read included — is `null`
|
|
4
|
+
// plus one log line naming the check, never a claim's value and never the token.
|
|
5
|
+
|
|
6
|
+
import { type Clock, logger, systemClock } from '@ultimat3/core';
|
|
7
|
+
import type { JwksKeySource } from './jwks';
|
|
8
|
+
import type { PolicyActor } from './policy-bridge';
|
|
9
|
+
import { checkWorkloadToken, type WorkloadCheck, type WorkloadToken } from './workload';
|
|
10
|
+
|
|
11
|
+
/** Structurally `ApiKeyCaller`: http's `BearerCaller` and mcp's `ResolvedToken`. */
|
|
12
|
+
export interface JwtBearerCaller {
|
|
13
|
+
readonly actor: PolicyActor;
|
|
14
|
+
/** The scopes the token carries (`scope` or `scp`). The mount's scope map is cut by these. */
|
|
15
|
+
readonly scopes: ReadonlySet<string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface JwtBearerResolverOptions {
|
|
19
|
+
/**
|
|
20
|
+
* A THUNK, read when a token arrives: discovery opens no network at import, and a mount is
|
|
21
|
+
* declared before boot. Hold the client and return it — a new one per call has an empty cache.
|
|
22
|
+
*/
|
|
23
|
+
readonly keys: () => JwksKeySource | Promise<JwksKeySource>;
|
|
24
|
+
/** Every issuer accepted. A list, never a wildcard. */
|
|
25
|
+
readonly issuers: readonly string[];
|
|
26
|
+
/** This app's own audience. */
|
|
27
|
+
readonly audience: string;
|
|
28
|
+
/** A scope the token must carry, when the mount needs one. The name is the app's. */
|
|
29
|
+
readonly requiredScope?: string | undefined;
|
|
30
|
+
/** The app's own claim check, after the signature held (`tid`, `azp`, `groups`). */
|
|
31
|
+
readonly accept?: ((raw: Readonly<Record<string, unknown>>) => boolean) | undefined;
|
|
32
|
+
/** Builds the actor. Roles and org are the app's decision, never guessed from a claim here. */
|
|
33
|
+
readonly actorOf: (token: WorkloadToken) => PolicyActor;
|
|
34
|
+
readonly clock?: Clock | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every check a refusal can name: the token's own, then the three this resolver adds. */
|
|
38
|
+
export type JwtBearerCheck = WorkloadCheck | 'key-set' | 'scope' | 'accept';
|
|
39
|
+
|
|
40
|
+
const refuse = (check: JwtBearerCheck): null => {
|
|
41
|
+
// The check's NAME only: a claim's value is the caller's text, and the token is a credential.
|
|
42
|
+
logger.warn('auth.jwt_bearer.refused', { check });
|
|
43
|
+
return null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `resolveToken: jwtBearerResolver({ keys, issuers, audience, actorOf })` on a bearer mount or
|
|
48
|
+
* `defineAppMcp()`. A thrown `accept` or `actorOf` is the app's bug and propagates; a key source
|
|
49
|
+
* that throws or rejects — discovery down, an unknown `kid` — fails closed as `null`.
|
|
50
|
+
*/
|
|
51
|
+
export function jwtBearerResolver(
|
|
52
|
+
options: JwtBearerResolverOptions,
|
|
53
|
+
): (token: string) => Promise<JwtBearerCaller | null> {
|
|
54
|
+
return async (token) => {
|
|
55
|
+
let checked: Awaited<ReturnType<typeof checkWorkloadToken>>;
|
|
56
|
+
try {
|
|
57
|
+
checked = await checkWorkloadToken({
|
|
58
|
+
token,
|
|
59
|
+
keys: await options.keys(),
|
|
60
|
+
issuers: options.issuers,
|
|
61
|
+
audience: options.audience,
|
|
62
|
+
clock: options.clock ?? systemClock,
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
// Deliberately unrendered: the throw may quote the issuer's response, and an unreadable key
|
|
66
|
+
// set and an unknown `kid` must read the same to whoever is probing.
|
|
67
|
+
return refuse('key-set');
|
|
68
|
+
}
|
|
69
|
+
if (!checked.ok) return refuse(checked.check);
|
|
70
|
+
const verified = checked.token;
|
|
71
|
+
if (
|
|
72
|
+
options.requiredScope !== undefined &&
|
|
73
|
+
!verified.claims.scopes.includes(options.requiredScope)
|
|
74
|
+
) {
|
|
75
|
+
return refuse('scope');
|
|
76
|
+
}
|
|
77
|
+
if (options.accept !== undefined && !options.accept(verified.raw)) return refuse('accept');
|
|
78
|
+
return { actor: options.actorOf(verified), scopes: new Set(verified.claims.scopes) };
|
|
79
|
+
};
|
|
80
|
+
}
|
package/src/limiter-install.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Single responsibility: the ONE ambient install point for
|
|
2
|
-
// counted,
|
|
1
|
+
// Single responsibility: the ONE ambient install point for the credential state a fleet has to
|
|
2
|
+
// share — where failed attempts are counted, with the purge over whatever it built, and where spent
|
|
3
|
+
// TOTP steps are kept.
|
|
3
4
|
//
|
|
4
5
|
// WHY a seam and not a `defineAuth` argument: `defineAuth` is the APP's call, and the app is not
|
|
5
6
|
// the thing that knows which database this process opened. A host boot resolves the pool long
|
|
@@ -14,6 +15,7 @@
|
|
|
14
15
|
// make every app that tunes its own numbers fail at boot with `X_AUTH_LIMITER_POLICY_MISMATCH`.
|
|
15
16
|
// The factory is called WITH the resolved policy, so the two halves cannot disagree.
|
|
16
17
|
|
|
18
|
+
import type { TotpReplayGuard } from './mfa';
|
|
17
19
|
import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
|
|
18
20
|
|
|
19
21
|
/**
|
|
@@ -110,3 +112,30 @@ export async function purgeAuthLimits(): Promise<number> {
|
|
|
110
112
|
}
|
|
111
113
|
return widest === undefined ? 0 : await widest.purgeExpired();
|
|
112
114
|
}
|
|
115
|
+
|
|
116
|
+
/** Build the guard an `Auth` keeps its spent TOTP steps in. Called once per `defineAuth`. */
|
|
117
|
+
export type TotpReplayFactory = () => TotpReplayGuard;
|
|
118
|
+
|
|
119
|
+
let replayFactory: TotpReplayFactory | undefined;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The host's install point for where spent steps are kept, for the limiter's reason: `defineAuth`
|
|
123
|
+
* is the app's call and the app does not know which database this process opened. Until a boot
|
|
124
|
+
* filled this, one code signed in once per replica. A second install replaces the first.
|
|
125
|
+
*/
|
|
126
|
+
export function configureTotpReplay(next: TotpReplayFactory): void {
|
|
127
|
+
replayFactory = next;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Back to `memoryTotpReplayGuard`, the per-process default. A host that installs one calls this on stop. */
|
|
131
|
+
export function resetTotpReplay(): void {
|
|
132
|
+
replayFactory = undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `defineAuth`'s reader, deliberately NOT exported from `src/index.ts` for `installedAuthLimiter`'s
|
|
137
|
+
* reason. `undefined` means no host installed one, and the caller falls back to memory.
|
|
138
|
+
*/
|
|
139
|
+
export function installedTotpReplay(): TotpReplayGuard | undefined {
|
|
140
|
+
return replayFactory?.();
|
|
141
|
+
}
|
package/src/memory-adapter.ts
CHANGED
|
@@ -11,12 +11,14 @@ import type {
|
|
|
11
11
|
AuthUser,
|
|
12
12
|
AuthVerification,
|
|
13
13
|
CreateUserInput,
|
|
14
|
+
PasskeyStore,
|
|
14
15
|
SessionPatch,
|
|
15
16
|
StoredMfaSecret,
|
|
16
17
|
UserPatch,
|
|
17
18
|
UserQuery,
|
|
18
19
|
} from './adapter';
|
|
19
20
|
import { authUniqueViolation } from './errors';
|
|
21
|
+
import { memoryPasskeyStore } from './passkey-memory';
|
|
20
22
|
|
|
21
23
|
const verificationKey = (purpose: string, identifier: string): string => `${purpose}:${identifier}`;
|
|
22
24
|
|
|
@@ -46,6 +48,22 @@ export class MemoryAuthAdapter implements AuthAdapter {
|
|
|
46
48
|
readonly #accounts = new Map<string, AuthAccount>();
|
|
47
49
|
readonly #verifications = new Map<string, AuthVerification>();
|
|
48
50
|
readonly #apiKeys = new Map<string, AuthApiKeyRecord>();
|
|
51
|
+
readonly #passkeys: PasskeyStore = memoryPasskeyStore();
|
|
52
|
+
// The passkey members are `passkey-memory.ts`'s, bound once: closures over the store, no `this`.
|
|
53
|
+
readonly putPasskey: PasskeyStore['putPasskey'] = this.#passkeys.putPasskey;
|
|
54
|
+
readonly findPasskey: PasskeyStore['findPasskey'] = this.#passkeys.findPasskey;
|
|
55
|
+
readonly listPasskeys: PasskeyStore['listPasskeys'] = this.#passkeys.listPasskeys;
|
|
56
|
+
readonly advancePasskeyCounter: PasskeyStore['advancePasskeyCounter'] =
|
|
57
|
+
this.#passkeys.advancePasskeyCounter;
|
|
58
|
+
readonly deletePasskey: PasskeyStore['deletePasskey'] = this.#passkeys.deletePasskey;
|
|
59
|
+
readonly putPasskeyChallenge: PasskeyStore['putPasskeyChallenge'] =
|
|
60
|
+
this.#passkeys.putPasskeyChallenge;
|
|
61
|
+
readonly takePasskeyChallenge: PasskeyStore['takePasskeyChallenge'] =
|
|
62
|
+
this.#passkeys.takePasskeyChallenge;
|
|
63
|
+
readonly putPasskeyEvidence: PasskeyStore['putPasskeyEvidence'] =
|
|
64
|
+
this.#passkeys.putPasskeyEvidence;
|
|
65
|
+
readonly listPasskeyEvidence: PasskeyStore['listPasskeyEvidence'] =
|
|
66
|
+
this.#passkeys.listPasskeyEvidence;
|
|
49
67
|
|
|
50
68
|
/**
|
|
51
69
|
* The clock every instant this adapter stamps comes from — one argument, because a stamp is a
|
package/src/mfa-challenge.ts
CHANGED
|
@@ -90,8 +90,8 @@ const TOTP_SHAPE = new RegExp(`^[0-9]{${TOTP_DIGITS}}$`);
|
|
|
90
90
|
* The guess is RESERVED before the code is looked at, against the account, the address and the
|
|
91
91
|
* tenant — the buckets a password guess spends — so six digits are worth `maxAttempts` tries per
|
|
92
92
|
* window and no more, and a wrong code is followed by `X_ACCOUNT_LOCKED`. A code is good once:
|
|
93
|
-
* a TOTP step is
|
|
94
|
-
* in one atomic step, so two requests carrying one code cannot both finish.
|
|
93
|
+
* a TOTP step is claimed in `auth.totpReplay` and a recovery code is consumed by the adapter, each
|
|
94
|
+
* in one atomic step, so two requests carrying one code cannot both finish, on any replica.
|
|
95
95
|
*/
|
|
96
96
|
export async function completeMfa(
|
|
97
97
|
auth: Auth,
|
|
@@ -117,12 +117,18 @@ export async function completeMfa(
|
|
|
117
117
|
if (TOTP_SHAPE.test(presented)) {
|
|
118
118
|
const now = auth.clock.now();
|
|
119
119
|
const verdict = verifyTotp({ secret, code: presented, at: now });
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
// Only a code that verified claims its step, so a wrong guess never writes.
|
|
121
|
+
if (!verdict.ok || verdict.step === null) throw mfaCodeRejected();
|
|
122
|
+
let claimed: boolean;
|
|
123
|
+
try {
|
|
124
|
+
claimed = await auth.totpReplay.claim(user.id, verdict.step, now);
|
|
125
|
+
} catch (thrown) {
|
|
126
|
+
// A store that is down reached no verdict, so the guess is not counted as a wrong one.
|
|
127
|
+
await attempt.release();
|
|
128
|
+
throw thrown;
|
|
124
129
|
}
|
|
125
|
-
|
|
130
|
+
// `=== true`: an answer that is anything else is not evidence the step was unspent.
|
|
131
|
+
if (claimed !== true) throw mfaCodeRejected();
|
|
126
132
|
} else {
|
|
127
133
|
let consumed: boolean;
|
|
128
134
|
try {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The shared replay guard: one Postgres table, so a TOTP code spent on one replica is spent on all
|
|
2
|
+
// of them. A claim is one `insert … on conflict do nothing`, because a read and a write are two
|
|
3
|
+
// round trips and two replicas fit between them; the primary key is what decides the race.
|
|
4
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
5
|
+
import { TOTP_DRIFT_STEPS, type TotpReplayGuard, totpStep } from './mfa';
|
|
6
|
+
|
|
7
|
+
/** Applied by the boot, never by an app migration: `SQL_AUTH_LIMIT_TABLES`' rule. */
|
|
8
|
+
export const SQL_TOTP_REPLAY_TABLE = `
|
|
9
|
+
create table if not exists x_totp_steps (
|
|
10
|
+
subject text not null,
|
|
11
|
+
step bigint not null,
|
|
12
|
+
used_at timestamptz not null,
|
|
13
|
+
primary key (subject, step)
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
create index if not exists x_totp_steps_step_idx on x_totp_steps (step);
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
/** `$1` subject, `$2` step, `$3` the caller's clock. A row back is the first use; none, a replay. */
|
|
20
|
+
export const SQL_TOTP_CLAIM = `
|
|
21
|
+
insert into x_totp_steps (subject, step, used_at)
|
|
22
|
+
values ($1, $2::bigint, $3::timestamptz)
|
|
23
|
+
on conflict (subject, step) do nothing
|
|
24
|
+
returning 1 as claimed
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
/** `$1` the lowest step still kept, computed from the CALLER's clock and never `now()`. */
|
|
28
|
+
export const SQL_TOTP_PURGE = `
|
|
29
|
+
with dropped as (
|
|
30
|
+
delete from x_totp_steps where step < $1::bigint returning 1
|
|
31
|
+
)
|
|
32
|
+
select count(*) as removed from dropped
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A minute past the drift window. `verifyTotp` reads its replica's own clock, so a replica running
|
|
37
|
+
* behind the sweep's still offers a step the sweep's clock has written off; a row kept a little
|
|
38
|
+
* longer costs nothing and a row taken early reopens a spent code.
|
|
39
|
+
*/
|
|
40
|
+
const SWEEP_SLACK_STEPS = 2;
|
|
41
|
+
|
|
42
|
+
export interface PostgresTotpReplayGuardOptions {
|
|
43
|
+
readonly executor: PgExecutor;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PostgresTotpReplayGuard extends TotpReplayGuard {
|
|
47
|
+
/**
|
|
48
|
+
* Drop every step no replica can offer again, and answer how many rows went. The table takes one
|
|
49
|
+
* row per second factor and does not bound itself; `@ultimat3/cli`'s hourly sweep calls this.
|
|
50
|
+
*/
|
|
51
|
+
purgeExpired(nowMs: number): Promise<number>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function postgresTotpReplayGuard(
|
|
55
|
+
options: PostgresTotpReplayGuardOptions,
|
|
56
|
+
): PostgresTotpReplayGuard {
|
|
57
|
+
const exec = options.executor;
|
|
58
|
+
return {
|
|
59
|
+
// A rejection from the executor passes through on purpose: `completeMfa` reads it as no verdict.
|
|
60
|
+
async claim(subject, step, at): Promise<boolean> {
|
|
61
|
+
const rows = await exec.query(SQL_TOTP_CLAIM, [subject, step, at.toISOString()]);
|
|
62
|
+
return rows.length > 0;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async purgeExpired(nowMs): Promise<number> {
|
|
66
|
+
const floor = totpStep(new Date(nowMs)) - TOTP_DRIFT_STEPS - SWEEP_SLACK_STEPS;
|
|
67
|
+
const rows = await exec.query<{ readonly removed: number | string }>(SQL_TOTP_PURGE, [floor]);
|
|
68
|
+
return Number(rows[0]?.removed ?? 0);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|