@ultimat3/auth 30.8.0 → 31.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 +55 -109
- package/README.md +100 -25
- package/package.json +5 -4
- package/src/adapter-subpath.ts +34 -0
- package/src/api-keys.ts +14 -1
- package/src/auth.ts +3 -3
- package/src/authorization-server-clients.ts +19 -1
- package/src/authorization-server-config.ts +13 -4
- package/src/authorization-server-installed.ts +34 -0
- package/src/authorization-server-postgres.ts +4 -2
- package/src/authorization-server-store.ts +14 -4
- package/src/authorization-server.ts +30 -10
- package/src/client-scope.ts +1 -1
- package/src/errors.ts +9 -1
- package/src/grant-authorization-code.ts +21 -16
- package/src/grant-device.ts +2 -2
- package/src/identity-provider-cookie.ts +89 -0
- package/src/identity-provider-oidc.ts +142 -0
- package/src/identity-provider-route-core.ts +253 -0
- package/src/identity-provider-routes.ts +78 -0
- package/src/identity-provider-status.ts +45 -0
- package/src/identity-provider.ts +112 -0
- package/src/index.ts +34 -54
- package/src/jwks.ts +2 -2
- package/src/kdf-gate.ts +24 -14
- package/src/limiter-install.ts +26 -16
- package/src/memory-adapter.ts +5 -5
- package/src/mfa.ts +1 -1
- package/src/oauth-discovery.ts +1 -1
- package/src/oauth-errors.ts +26 -2
- package/src/oauth-exchange.ts +2 -2
- package/src/oauth-paths.ts +5 -2
- package/src/oauth-route.ts +60 -297
- package/src/rate-limit.ts +7 -2
- package/src/saml-assertion.ts +156 -0
- package/src/saml-c14n.ts +118 -0
- package/src/saml-certificate.ts +47 -0
- package/src/saml-dom.ts +66 -0
- package/src/saml-dsig.ts +165 -0
- package/src/saml-email-trust.ts +49 -0
- package/src/saml-errors.ts +96 -0
- package/src/saml-metadata.ts +80 -0
- package/src/saml-provider.ts +179 -0
- package/src/saml-request.ts +51 -0
- package/src/saml-response.ts +131 -0
- package/src/saml-xml.ts +328 -0
package/src/index.ts
CHANGED
|
@@ -36,7 +36,7 @@ export type {
|
|
|
36
36
|
VerifiedApiKey,
|
|
37
37
|
} from './api-keys';
|
|
38
38
|
export {
|
|
39
|
-
|
|
39
|
+
API_KEY_TOUCH_MS,
|
|
40
40
|
apiKeyActor,
|
|
41
41
|
describeApiKey,
|
|
42
42
|
directGrants,
|
|
@@ -55,19 +55,14 @@ export type {
|
|
|
55
55
|
RegisterInput,
|
|
56
56
|
} from './auth';
|
|
57
57
|
export {
|
|
58
|
-
AccountSchema,
|
|
59
58
|
authenticate,
|
|
60
|
-
DEFAULT_MFA_ISSUER,
|
|
61
59
|
defineAuth,
|
|
62
60
|
login,
|
|
63
61
|
logout,
|
|
64
62
|
register,
|
|
65
|
-
SessionSchema,
|
|
66
|
-
UserSchema,
|
|
67
|
-
VerificationSchema,
|
|
68
63
|
} from './auth';
|
|
69
|
-
export type { AuthorizationServer } from './authorization-server';
|
|
70
|
-
export {
|
|
64
|
+
export type { AuthorizationServer, ReadRequestOptions } from './authorization-server';
|
|
65
|
+
export { authorizationServer } from './authorization-server';
|
|
71
66
|
export type {
|
|
72
67
|
RegisterClientInput,
|
|
73
68
|
RegisteredClient,
|
|
@@ -80,13 +75,8 @@ export type {
|
|
|
80
75
|
export type { PostgresAuthServerStoreOptions } from './authorization-server-postgres';
|
|
81
76
|
export { postgresAuthServerStore } from './authorization-server-postgres';
|
|
82
77
|
export type { ReadRequestResult } from './authorization-server-request';
|
|
83
|
-
export { AUTH_SERVER_METADATA_WELL_KNOWN } from './authorization-server-routes';
|
|
84
78
|
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';
|
|
79
|
+
export type { AuthServerStore } from './authorization-server-store';
|
|
90
80
|
export { memoryAuthServerStore } from './authorization-server-store';
|
|
91
81
|
export type {
|
|
92
82
|
AuthCodeRecord,
|
|
@@ -94,11 +84,9 @@ export type {
|
|
|
94
84
|
AuthorizationRequest,
|
|
95
85
|
AuthServerClient,
|
|
96
86
|
AuthServerGrant,
|
|
97
|
-
AuthTokenKind,
|
|
98
87
|
AuthTokenRecord,
|
|
99
88
|
ClientAuthMethod,
|
|
100
89
|
DeviceCodeRecord,
|
|
101
|
-
DeviceStatus,
|
|
102
90
|
ResolvedGrant,
|
|
103
91
|
} from './authorization-server-types';
|
|
104
92
|
export { DEVICE_GRANT } from './authorization-server-types';
|
|
@@ -119,21 +107,14 @@ export { normaliseEmail } from './email';
|
|
|
119
107
|
export type { AuthErrorCode, AuthThrowCode } from './errors';
|
|
120
108
|
export {
|
|
121
109
|
AUTH_BORROWED_ERROR_CODES,
|
|
122
|
-
AUTH_ERROR_CODES,
|
|
123
|
-
AUTH_ERROR_TITLES,
|
|
124
110
|
AuthError,
|
|
125
111
|
accountLocked,
|
|
126
112
|
apiKeyInvalid,
|
|
127
113
|
authForbidden,
|
|
128
|
-
authLimiterNotShared,
|
|
129
|
-
authLimiterPolicyMismatch,
|
|
130
114
|
authUnauthenticated,
|
|
131
115
|
authUniqueViolation,
|
|
132
116
|
authWriteFailed,
|
|
133
117
|
kdfOverloaded,
|
|
134
|
-
mfaRequiredUnenforceable,
|
|
135
|
-
mfaSecretInvalid,
|
|
136
|
-
mfaSecretUnsealed,
|
|
137
118
|
passwordWeak,
|
|
138
119
|
sessionExpired,
|
|
139
120
|
sessionUnknown,
|
|
@@ -143,11 +124,27 @@ export type { DeviceReading } from './grant-device';
|
|
|
143
124
|
export { currentActor, requireActor } from './guards';
|
|
144
125
|
export type { IdTokenClaims, VerifyIdTokenInput } from './id-token';
|
|
145
126
|
export {
|
|
146
|
-
decodeIdToken,
|
|
147
127
|
ID_TOKEN_CLOCK_SKEW_MS,
|
|
148
|
-
idTokenEmailVerified,
|
|
149
128
|
verifyIdToken,
|
|
150
129
|
} from './id-token';
|
|
130
|
+
export type {
|
|
131
|
+
IdentityBeginInput,
|
|
132
|
+
IdentityCompleteInput,
|
|
133
|
+
IdentityProvider,
|
|
134
|
+
IdentityRedirect,
|
|
135
|
+
IdentitySignInOptions,
|
|
136
|
+
ResolveIdentityGrants,
|
|
137
|
+
VerifiedIdentity,
|
|
138
|
+
} from './identity-provider';
|
|
139
|
+
export { signInWithIdentity } from './identity-provider';
|
|
140
|
+
export type { OidcIdentityProviderConfig } from './identity-provider-oidc';
|
|
141
|
+
export { oidcIdentityProvider } from './identity-provider-oidc';
|
|
142
|
+
export type {
|
|
143
|
+
IdentityProviderRoutes,
|
|
144
|
+
IdentityProviderRoutesOptions,
|
|
145
|
+
} from './identity-provider-routes';
|
|
146
|
+
export { identityProviderRoutes } from './identity-provider-routes';
|
|
147
|
+
export { OAUTH_ROUTE_STATUS } from './identity-provider-status';
|
|
151
148
|
export type {
|
|
152
149
|
IdTokenKeys,
|
|
153
150
|
JwksClientOptions,
|
|
@@ -163,26 +160,17 @@ export {
|
|
|
163
160
|
} from './jwks';
|
|
164
161
|
export type {
|
|
165
162
|
JwtBearerCaller,
|
|
166
|
-
JwtBearerCheck,
|
|
167
163
|
JwtBearerResolverOptions,
|
|
168
164
|
} from './jwt-bearer-resolver';
|
|
169
165
|
export { jwtBearerResolver } from './jwt-bearer-resolver';
|
|
170
166
|
export type { KdfGate, KdfLimits } from './kdf-gate';
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
configureKdfGate,
|
|
174
|
-
kdfGate,
|
|
175
|
-
} from './kdf-gate';
|
|
176
|
-
export type { AuthLimiterFactory, TotpReplayFactory } from './limiter-install';
|
|
167
|
+
// `installKdfGate` / `resetKdfGate` are `@ultimat3/auth/adapter`'s: a boot widens the gate.
|
|
168
|
+
export { boundedKdfGate, kdfGate } from './kdf-gate';
|
|
177
169
|
// `installedAuthLimiter` is deliberately absent: `defineAuth` is the one reader, and a second
|
|
178
170
|
// caller building limiters out of band would be a second answer to where failures are counted.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
purgeAuthLimits,
|
|
183
|
-
resetAuthLimiters,
|
|
184
|
-
resetTotpReplay,
|
|
185
|
-
} from './limiter-install';
|
|
171
|
+
// The installers and their factory types are `@ultimat3/auth/adapter`'s, and only its: the boot
|
|
172
|
+
// calls them with what `apps/<app>/runtime.ts` named.
|
|
173
|
+
export { purgeAuthLimits } from './limiter-install';
|
|
186
174
|
export type { HandleDirectory } from './login-identity';
|
|
187
175
|
export { normaliseHandle } from './login-identity';
|
|
188
176
|
export { type MemoryAuthAdapter, memoryAuthAdapter } from './memory-adapter';
|
|
@@ -272,10 +260,9 @@ export { discoverOAuthProvider, discoveryUrl } from './oauth-discovery';
|
|
|
272
260
|
// from that module, below.
|
|
273
261
|
export type { OAuthExchangeFailure } from './oauth-errors';
|
|
274
262
|
export {
|
|
263
|
+
oauthDenied,
|
|
275
264
|
oauthExchangeFailed,
|
|
276
265
|
oauthProviderUnknown,
|
|
277
|
-
oauthStateInvalid,
|
|
278
|
-
oauthTokenInvalid,
|
|
279
266
|
restartAt,
|
|
280
267
|
} from './oauth-errors';
|
|
281
268
|
export type {
|
|
@@ -295,8 +282,6 @@ export type {
|
|
|
295
282
|
export {
|
|
296
283
|
completeOAuthLogin,
|
|
297
284
|
emailVerifiedNotStored,
|
|
298
|
-
oauthAccountNotLinked,
|
|
299
|
-
oauthLinkingDisabled,
|
|
300
285
|
signInWithOAuth,
|
|
301
286
|
} from './oauth-login';
|
|
302
287
|
export {
|
|
@@ -310,30 +295,26 @@ export type { OAuthProfile, OAuthProfileOptions } from './oauth-profile';
|
|
|
310
295
|
export { oauthProfile } from './oauth-profile';
|
|
311
296
|
export {
|
|
312
297
|
hasOAuthProvider,
|
|
313
|
-
oauthProviderDuplicate,
|
|
314
298
|
oauthProviderIds,
|
|
315
299
|
providerFor,
|
|
316
300
|
registerOAuthProvider,
|
|
317
301
|
} from './oauth-registry';
|
|
318
302
|
export type { AuthRouteDescriptor, OAuthLoginOptions, OAuthLoginRoutes } from './oauth-route';
|
|
319
|
-
export {
|
|
303
|
+
export { oauthLogin } from './oauth-route';
|
|
320
304
|
// Passkeys (WebAuthn): sign-in and digest-bound step-up. The browser half is `@ultimat3/auth/client`.
|
|
321
305
|
export type {
|
|
322
306
|
PasskeyPolicy,
|
|
323
307
|
PasskeyPolicyInput,
|
|
324
308
|
PasskeyRelyingParty,
|
|
325
309
|
} from './passkey-config';
|
|
326
|
-
export { DEFAULT_PASSKEY_CHALLENGE_TTL_MS } from './passkey-config';
|
|
327
310
|
export { verifyPasskeyEvidence } from './passkey-evidence';
|
|
328
311
|
export type { PasskeyLogin } from './passkey-login';
|
|
329
312
|
export { passkeyLogin } from './passkey-login';
|
|
330
313
|
export type { PasskeyStepUp, PasskeyStepUpOptions } from './passkey-step-up';
|
|
331
314
|
export { passkeyStepUp } from './passkey-step-up';
|
|
332
315
|
export type {
|
|
333
|
-
PasskeyAssertionResponse,
|
|
334
316
|
PasskeyCreationOptions,
|
|
335
317
|
PasskeyCredentialDescriptor,
|
|
336
|
-
PasskeyRegistrationResponse,
|
|
337
318
|
PasskeyRequestOptions,
|
|
338
319
|
} from './passkey-wire';
|
|
339
320
|
export type {
|
|
@@ -345,7 +326,6 @@ export type {
|
|
|
345
326
|
} from './password';
|
|
346
327
|
export {
|
|
347
328
|
checkPasswordStrength,
|
|
348
|
-
DEFAULT_PASSWORD_PARAMS,
|
|
349
329
|
DEFAULT_PASSWORD_POLICY,
|
|
350
330
|
hashPassword,
|
|
351
331
|
needsRehash,
|
|
@@ -398,13 +378,16 @@ export {
|
|
|
398
378
|
revokeSessionsCreatedBefore,
|
|
399
379
|
revokeUserSessions,
|
|
400
380
|
} from './revocation';
|
|
381
|
+
export type { SamlIdpMetadata } from './saml-metadata';
|
|
382
|
+
export { parseSamlMetadata } from './saml-metadata';
|
|
383
|
+
export type { SamlIdentityProviderConfig } from './saml-provider';
|
|
384
|
+
export { samlIdentityProvider } from './saml-provider';
|
|
401
385
|
export type {
|
|
402
386
|
CookieJar,
|
|
403
387
|
CreateSessionInput,
|
|
404
388
|
IssuedSession,
|
|
405
389
|
RequestLike,
|
|
406
390
|
SessionCookieOptions,
|
|
407
|
-
SessionDevice,
|
|
408
391
|
SessionExpiry,
|
|
409
392
|
SessionPolicy,
|
|
410
393
|
SessionRuntime,
|
|
@@ -414,7 +397,6 @@ export {
|
|
|
414
397
|
createSession,
|
|
415
398
|
DEFAULT_SESSION_POLICY,
|
|
416
399
|
idleSlideMs,
|
|
417
|
-
listDevices,
|
|
418
400
|
readSessionCookie,
|
|
419
401
|
remainingMaxAgeSeconds,
|
|
420
402
|
revokeOtherSessions,
|
|
@@ -432,7 +414,7 @@ export type {
|
|
|
432
414
|
AuthSubjectTarget,
|
|
433
415
|
AuthSubjectTargetOptions,
|
|
434
416
|
} from './subject-target';
|
|
435
|
-
export {
|
|
417
|
+
export { authSubjectTarget } from './subject-target';
|
|
436
418
|
|
|
437
419
|
export {
|
|
438
420
|
AUTH_TABLE_NAMES,
|
|
@@ -442,8 +424,6 @@ export {
|
|
|
442
424
|
randomToken,
|
|
443
425
|
sha256Hex,
|
|
444
426
|
} from './tokens';
|
|
445
|
-
export type { VerificationPurposeDef } from './verification-purposes';
|
|
446
|
-
export { verificationPurposeNames } from './verification-purposes';
|
|
447
427
|
export type {
|
|
448
428
|
ConsumeVerificationInput,
|
|
449
429
|
DefineVerificationPurposeInput,
|
package/src/jwks.ts
CHANGED
|
@@ -10,11 +10,11 @@ import type { Clock, Scheduler } from '@ultimat3/core';
|
|
|
10
10
|
import {
|
|
11
11
|
generationFence,
|
|
12
12
|
isFixShellSafe,
|
|
13
|
+
liveClock,
|
|
13
14
|
logger,
|
|
14
15
|
renderFixShellArg,
|
|
15
16
|
renderThrowable,
|
|
16
17
|
singleFlight,
|
|
17
|
-
systemClock,
|
|
18
18
|
} from '@ultimat3/core';
|
|
19
19
|
import { outboundResponse } from '@ultimat3/core/outbound';
|
|
20
20
|
import { decodeJwtSegment, isRecord } from './json';
|
|
@@ -134,7 +134,7 @@ const KID_LESS = '\u0000';
|
|
|
134
134
|
* key already held answering for `JWKS_STALE_IF_ERROR_MS`.
|
|
135
135
|
*/
|
|
136
136
|
export function jwksClient(options: JwksClientOptions): JwksKeySource {
|
|
137
|
-
const clock = options.clock ??
|
|
137
|
+
const clock = options.clock ?? liveClock;
|
|
138
138
|
const ttlMs = options.ttlMs ?? DEFAULT_JWKS_TTL_MS;
|
|
139
139
|
assertFiniteAuthCount(
|
|
140
140
|
'jwks.ttlMs',
|
package/src/kdf-gate.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Single responsibility: bounding how many argon2 hashes this process runs at once.
|
|
2
2
|
//
|
|
3
3
|
// WHY it is not covered by the limiters that already exist: `rate-limit.ts`'s `ipKey(ip)` and
|
|
4
|
-
// `@ultimat3/http`'s `auth` bucket are
|
|
5
|
-
// mints a fresh key per attempt
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// `@ultimat3/http`'s `auth` bucket are keyed by SOURCE NETWORK (an IPv6 /64 is one key), so a
|
|
5
|
+
// botnet of many networks still mints a fresh key per attempt; and both cap ATTEMPTS, while what
|
|
6
|
+
// argon2id costs is MEMORY — 19 MiB per hash at the OWASP floor. The only remaining backstop was
|
|
7
|
+
// `http.maxInflight` (1000), i.e. roughly 19 GB of arenas queued on one box.
|
|
8
8
|
|
|
9
9
|
import { flightGate } from '@ultimat3/core';
|
|
10
|
+
import { adapterSlot } from '@ultimat3/core/adapter';
|
|
10
11
|
import { kdfOverloaded } from './errors';
|
|
11
12
|
import { assertFiniteAuthCount } from './policy-numbers';
|
|
12
13
|
|
|
@@ -71,22 +72,31 @@ export function boundedKdfGate(limits: KdfLimits = DEFAULT_KDF_LIMITS): KdfGate
|
|
|
71
72
|
});
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
/** Core's one slot shape (`adapter-slot.ts`): installs stack, each release removes its own frame. */
|
|
76
|
+
const gates = /* @__PURE__ */ adapterSlot<KdfGate | undefined>(() => undefined);
|
|
77
|
+
/** The shipped width, built once on first use: a module import must not build a gate it may not use. */
|
|
78
|
+
let shipped: KdfGate | undefined;
|
|
75
79
|
|
|
76
80
|
/** The process-wide gate every `hashPassword`/`verifyPassword` passes through. */
|
|
77
|
-
export const kdfGate = (): KdfGate =>
|
|
81
|
+
export const kdfGate = (): KdfGate => {
|
|
82
|
+
const installed = gates.current();
|
|
83
|
+
if (installed !== undefined) return installed;
|
|
84
|
+
shipped ??= boundedKdfGate();
|
|
85
|
+
return shipped;
|
|
86
|
+
};
|
|
78
87
|
|
|
79
88
|
/**
|
|
80
|
-
* The ONE install point
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
89
|
+
* The ONE install point — on `@ultimat3/auth/adapter`, never the barrel (31.0.0): a box with more
|
|
90
|
+
* memory may widen the ceiling, and a test needs a narrow one. Not a `defineAuth` key, because
|
|
91
|
+
* this is a property of the MACHINE, not of the app's auth policy — the same app config runs on a
|
|
92
|
+
* 512 MiB PaaS dyno and a 64 GiB node. Screened before the frame is pushed; answers the release
|
|
93
|
+
* that removes THIS install and no other, in any order.
|
|
84
94
|
*/
|
|
85
|
-
export function
|
|
86
|
-
|
|
95
|
+
export function installKdfGate(limits: KdfLimits): () => void {
|
|
96
|
+
return gates.install(boundedKdfGate(limits));
|
|
87
97
|
}
|
|
88
98
|
|
|
89
|
-
/** Back to the shipped defaults.
|
|
99
|
+
/** Back to the shipped defaults, whatever is stacked. A test seam, on `./adapter` beside the installer. */
|
|
90
100
|
export function resetKdfGate(): void {
|
|
91
|
-
|
|
101
|
+
gates.reset();
|
|
92
102
|
}
|
package/src/limiter-install.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// make every app that tunes its own numbers fail at boot with `X_AUTH_LIMITER_POLICY_MISMATCH`.
|
|
16
16
|
// The factory is called WITH the resolved policy, so the two halves cannot disagree.
|
|
17
17
|
|
|
18
|
+
import { adapterSlot } from '@ultimat3/core/adapter';
|
|
18
19
|
import type { TotpReplayGuard } from './mfa';
|
|
19
20
|
import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
|
|
20
21
|
|
|
@@ -25,7 +26,8 @@ import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
|
|
|
25
26
|
*/
|
|
26
27
|
export type AuthLimiterFactory = (policy: AuthRateLimitPolicy) => AuthLimiter;
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
/** Core's one slot shape: installs stack, each release removes its own, in any order. */
|
|
30
|
+
const factory = /* @__PURE__ */ adapterSlot<AuthLimiterFactory | undefined>(() => undefined);
|
|
29
31
|
/**
|
|
30
32
|
* The limiters a purge can still reach, ONE per distinct window.
|
|
31
33
|
*
|
|
@@ -42,21 +44,27 @@ let factory: AuthLimiterFactory | undefined;
|
|
|
42
44
|
let built = new Map<number, AuthLimiter>();
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
|
-
* The ONE install point
|
|
46
|
-
*
|
|
47
|
-
*
|
|
47
|
+
* The ONE install point: a host that owns the database connection says where failed attempts are
|
|
48
|
+
* counted, and every `defineAuth` in the process picks it up without the app declaring anything.
|
|
49
|
+
* On `@ultimat3/auth/adapter` only — the boot calls it with what `apps/<app>/runtime.ts` named.
|
|
48
50
|
*
|
|
49
51
|
* A second install replaces the first and forgets what the first built — a limiter over a pool
|
|
50
|
-
* the previous boot has closed is not something a purge should still be sweeping through.
|
|
52
|
+
* the previous boot has closed is not something a purge should still be sweeping through. Answers
|
|
53
|
+
* the release that removes THIS install and no other, in any order, once (core's `adapterSlot`);
|
|
54
|
+
* the limiters built so far are forgotten with it.
|
|
51
55
|
*/
|
|
52
|
-
export function
|
|
53
|
-
|
|
56
|
+
export function installAuthLimiters(next: AuthLimiterFactory): () => void {
|
|
57
|
+
const release = factory.install(next);
|
|
54
58
|
built = new Map();
|
|
59
|
+
return () => {
|
|
60
|
+
release();
|
|
61
|
+
built = new Map();
|
|
62
|
+
};
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
/** Back to `memoryAuthLimiter`, the per-process default. A host that installs one calls this on stop. */
|
|
58
66
|
export function resetAuthLimiters(): void {
|
|
59
|
-
factory
|
|
67
|
+
factory.reset();
|
|
60
68
|
built = new Map();
|
|
61
69
|
}
|
|
62
70
|
|
|
@@ -67,8 +75,9 @@ export function resetAuthLimiters(): void {
|
|
|
67
75
|
* back to the in-memory limiter.
|
|
68
76
|
*/
|
|
69
77
|
export function installedAuthLimiter(policy: AuthRateLimitPolicy): AuthLimiter | undefined {
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
const current = factory.current();
|
|
79
|
+
if (current === undefined) return undefined;
|
|
80
|
+
const limiter = current(policy);
|
|
72
81
|
if (!built.has(policy.windowMs)) built.set(policy.windowMs, limiter);
|
|
73
82
|
return limiter;
|
|
74
83
|
}
|
|
@@ -116,20 +125,21 @@ export async function purgeAuthLimits(): Promise<number> {
|
|
|
116
125
|
/** Build the guard an `Auth` keeps its spent TOTP steps in. Called once per `defineAuth`. */
|
|
117
126
|
export type TotpReplayFactory = () => TotpReplayGuard;
|
|
118
127
|
|
|
119
|
-
|
|
128
|
+
const replayFactory = /* @__PURE__ */ adapterSlot<TotpReplayFactory | undefined>(() => undefined);
|
|
120
129
|
|
|
121
130
|
/**
|
|
122
131
|
* The host's install point for where spent steps are kept, for the limiter's reason: `defineAuth`
|
|
123
132
|
* 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
|
|
133
|
+
* filled this, one code signed in once per replica. A second install stacks over the first; its
|
|
134
|
+
* release removes THIS install and no other, in any order, once (core's `adapterSlot`).
|
|
125
135
|
*/
|
|
126
|
-
export function
|
|
127
|
-
replayFactory
|
|
136
|
+
export function installTotpReplay(next: TotpReplayFactory): () => void {
|
|
137
|
+
return replayFactory.install(next);
|
|
128
138
|
}
|
|
129
139
|
|
|
130
140
|
/** Back to `memoryTotpReplayGuard`, the per-process default. A host that installs one calls this on stop. */
|
|
131
141
|
export function resetTotpReplay(): void {
|
|
132
|
-
replayFactory
|
|
142
|
+
replayFactory.reset();
|
|
133
143
|
}
|
|
134
144
|
|
|
135
145
|
/**
|
|
@@ -137,5 +147,5 @@ export function resetTotpReplay(): void {
|
|
|
137
147
|
* reason. `undefined` means no host installed one, and the caller falls back to memory.
|
|
138
148
|
*/
|
|
139
149
|
export function installedTotpReplay(): TotpReplayGuard | undefined {
|
|
140
|
-
return replayFactory?.();
|
|
150
|
+
return replayFactory.current()?.();
|
|
141
151
|
}
|
package/src/memory-adapter.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// database exists and the one every test in this package runs against — the same interface
|
|
3
3
|
// Postgres and Better Auth implement, so a flow that works here works there or the seam is wrong.
|
|
4
4
|
|
|
5
|
-
import { type Clock,
|
|
5
|
+
import { type Clock, liveClock, timingSafeEqual } from '@ultimat3/core';
|
|
6
6
|
import type {
|
|
7
7
|
AuthAccount,
|
|
8
8
|
AuthAdapter,
|
|
@@ -68,9 +68,9 @@ export class MemoryAuthAdapter implements AuthAdapter {
|
|
|
68
68
|
/**
|
|
69
69
|
* The clock every instant this adapter stamps comes from — one argument, because a stamp is a
|
|
70
70
|
* fact about WHEN a call happened and a test that cannot move it can only assert a range.
|
|
71
|
-
* Defaults to `
|
|
71
|
+
* Defaults to the `clock` port (`liveClock`, read per call), so `installClock` reaches it.
|
|
72
72
|
*/
|
|
73
|
-
constructor(clock: Clock =
|
|
73
|
+
constructor(clock: Clock = liveClock) {
|
|
74
74
|
this.#clock = clock;
|
|
75
75
|
}
|
|
76
76
|
|
|
@@ -98,7 +98,7 @@ export class MemoryAuthAdapter implements AuthAdapter {
|
|
|
98
98
|
* address, and the second was unreachable forever, since `findUserByEmail` returns the first.
|
|
99
99
|
*
|
|
100
100
|
* Over the STORED string, exactly as Postgres compares it. No case folding: that is the
|
|
101
|
-
* divergence `
|
|
101
|
+
* divergence `authAdapterConformance`'s first case pins, and `normaliseEmail` above the seam is
|
|
102
102
|
* what makes two spellings one address.
|
|
103
103
|
*/
|
|
104
104
|
async createUser(input: CreateUserInput): Promise<AuthUser> {
|
|
@@ -370,6 +370,6 @@ export class MemoryAuthAdapter implements AuthAdapter {
|
|
|
370
370
|
* The one way to build the in-memory adapter — the twin of `postgresAuthAdapter()`. The class is a
|
|
371
371
|
* type in the barrel only (`X_FACTORY_NAME_SPELLING`), so `new` is never a second spelling.
|
|
372
372
|
*/
|
|
373
|
-
export function memoryAuthAdapter(clock: Clock =
|
|
373
|
+
export function memoryAuthAdapter(clock: Clock = liveClock): MemoryAuthAdapter {
|
|
374
374
|
return new MemoryAuthAdapter(clock);
|
|
375
375
|
}
|
package/src/mfa.ts
CHANGED
|
@@ -241,7 +241,7 @@ const newestStep = (steps: ReadonlySet<number>): number => {
|
|
|
241
241
|
|
|
242
242
|
/**
|
|
243
243
|
* One process' table: the default when no host installed a guard, so a code is single-use per
|
|
244
|
-
* replica. A framework boot installs `postgresTotpReplayGuard` instead (`
|
|
244
|
+
* replica. A framework boot installs `postgresTotpReplayGuard` instead (`installTotpReplay`).
|
|
245
245
|
* Steps older than the drift window are dropped — nothing outside it can be replayed anyway.
|
|
246
246
|
*
|
|
247
247
|
* Bounded, because the subject map only ever grew: pruning happened inside one subject's `Set`
|
package/src/oauth-discovery.ts
CHANGED
|
@@ -30,7 +30,7 @@ export const discoveryUrl = (issuer: string): string =>
|
|
|
30
30
|
`${bareIssuer(issuer)}/.well-known/openid-configuration`;
|
|
31
31
|
|
|
32
32
|
export interface DiscoverOAuthProviderInput {
|
|
33
|
-
/** The id the URL segment carries: `/auth/
|
|
33
|
+
/** The id the URL segment carries: `/auth/sso/<id>`. Also names the two env vars. */
|
|
34
34
|
readonly id: string;
|
|
35
35
|
readonly issuer: string;
|
|
36
36
|
/** Defaults to the OIDC minimum this package needs to identify somebody. */
|
package/src/oauth-errors.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single responsibility: the OAuth refusals more than one OAuth module raises, and the one phrase
|
|
2
|
-
// every "start over" fix is built from. A refusal with one thrower lives beside it (
|
|
3
|
-
// in `oauth-
|
|
2
|
+
// every "start over" fix is built from. A refusal with one thrower lives beside it (the linking ones
|
|
3
|
+
// in `oauth-login.ts`); the codes, titles and the single
|
|
4
4
|
// `registerErrorCodes()` call stay in `errors.ts`, so this file adds no code and registers nothing.
|
|
5
5
|
|
|
6
6
|
import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
|
|
@@ -96,3 +96,27 @@ export const oauthTokenInvalid = (provider: string, reason: string, fix: string)
|
|
|
96
96
|
fix,
|
|
97
97
|
meta: { provider },
|
|
98
98
|
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The provider came back with `error=` and no code — almost always the user pressing Cancel.
|
|
102
|
+
* A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
|
|
103
|
+
* misconfigured, and folding the single commonest non-success outcome of a login into the code
|
|
104
|
+
* that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
|
|
105
|
+
*/
|
|
106
|
+
export const oauthDenied = (
|
|
107
|
+
provider: string,
|
|
108
|
+
reason: string,
|
|
109
|
+
description: string | null,
|
|
110
|
+
): AuthError =>
|
|
111
|
+
new AuthError({
|
|
112
|
+
code: 'X_OAUTH_DENIED',
|
|
113
|
+
// `reason` and `description` are query parameters off the callback URL — whatever the browser
|
|
114
|
+
// was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
|
|
115
|
+
// string literals, so a forged `error_description` cannot forge a second log line or break the
|
|
116
|
+
// sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
|
|
117
|
+
cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
|
|
118
|
+
description === null ? '' : ` (${renderCauseValue(description)})`
|
|
119
|
+
}`,
|
|
120
|
+
fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
|
|
121
|
+
meta: { provider, reason },
|
|
122
|
+
});
|
package/src/oauth-exchange.ts
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
import type { Clock } from '@ultimat3/core';
|
|
7
7
|
import {
|
|
8
8
|
EnvMissingError,
|
|
9
|
+
liveClock,
|
|
9
10
|
logger,
|
|
10
11
|
renderCauseValue,
|
|
11
12
|
renderFixShellArg,
|
|
12
13
|
renderThrowable,
|
|
13
|
-
systemClock,
|
|
14
14
|
} from '@ultimat3/core';
|
|
15
15
|
import { outboundResponse } from '@ultimat3/core/outbound';
|
|
16
16
|
import { type IdTokenClaims, verifyIdToken } from './id-token';
|
|
@@ -326,7 +326,7 @@ export async function exchangeOAuthCode(
|
|
|
326
326
|
): Promise<OAuthTokens> {
|
|
327
327
|
assertOAuthCallback(handshake, callback);
|
|
328
328
|
const provider = providerFor(handshake.provider);
|
|
329
|
-
const clock = options.clock ??
|
|
329
|
+
const clock = options.clock ?? liveClock;
|
|
330
330
|
|
|
331
331
|
const body = new URLSearchParams({
|
|
332
332
|
grant_type: 'authorization_code',
|
package/src/oauth-paths.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
// while no route was mounted anywhere. Deriving both the mount and the fix from here makes a fix
|
|
5
5
|
// line that names a route nothing serves impossible rather than merely discouraged.
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Not configurable, on purpose: a movable base path is a `fix:` line that can go stale again.
|
|
9
|
+
* `/auth/sso` since 31.0.0: every `IdentityProvider` — OIDC and SAML — signs in through it.
|
|
10
|
+
*/
|
|
11
|
+
export const OAUTH_BASE_PATH = '/auth/sso';
|
|
9
12
|
|
|
10
13
|
/** The pattern a router mounts. `:provider` is the segment `oauthStartPath` fills in. */
|
|
11
14
|
export const OAUTH_START_ROUTE_PATH = `${OAUTH_BASE_PATH}/:provider` as const;
|