@ultimat3/auth 1.2.0 → 3.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 +275 -0
- package/README.md +438 -35
- package/package.json +5 -4
- package/src/adapter.ts +69 -4
- package/src/auth.ts +122 -18
- package/src/builtin-adapter.ts +83 -6
- package/src/directory.ts +77 -0
- package/src/email.ts +17 -0
- package/src/errors.ts +239 -14
- package/src/guards.ts +6 -26
- package/src/id-token.ts +48 -26
- package/src/index.ts +109 -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/mfa.ts +104 -9
- 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 +65 -13
- 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/rate-limit.ts
CHANGED
|
@@ -4,68 +4,198 @@
|
|
|
4
4
|
// bypassable. `loginFailed()` lives here so the throttle and the message can never drift apart.
|
|
5
5
|
|
|
6
6
|
import type { Clock } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
7
|
+
import { normaliseEmail } from './email';
|
|
8
|
+
import {
|
|
9
|
+
AuthError,
|
|
10
|
+
accountLocked,
|
|
11
|
+
authLimiterNotShared,
|
|
12
|
+
authLimiterPolicyMismatch,
|
|
13
|
+
} from './errors';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where a limiter's counters live. A limiter says which it provides; `AuthRateLimitPolicy` says
|
|
17
|
+
* which the deployment requires, and the two are checked against each other once, at `defineAuth`.
|
|
18
|
+
*/
|
|
19
|
+
export type AuthLimiterScope = 'process' | 'shared';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hard bound on tracked keys. A key is one identity — `account:<email>` or `ip:<addr>` — so the
|
|
23
|
+
* natural cardinality is lower than `@ultimat3/http`'s per-route buckets, and so is the cap.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_MAX_AUTH_LIMIT_KEYS = 10_000;
|
|
8
26
|
|
|
9
27
|
export interface AuthRateLimitPolicy {
|
|
10
28
|
/** Failures inside `windowMs` before the key is locked. */
|
|
11
29
|
readonly maxAttempts: number;
|
|
12
30
|
readonly windowMs: number;
|
|
13
31
|
readonly lockoutMs: number;
|
|
32
|
+
/**
|
|
33
|
+
* The third bucket: failures inside `windowMs` against one TENANT, across every member and
|
|
34
|
+
* every source address. Per-IP buckets each grant their own quota and per-account buckets
|
|
35
|
+
* protect one person, so a misconfigured integration spraying one org's logins from 400
|
|
36
|
+
* addresses is capped by neither — it saturates the shared limiter and every other tenant's
|
|
37
|
+
* logins slow down behind it.
|
|
38
|
+
*
|
|
39
|
+
* A separate number and not `maxAttempts`, because a whole tenant sharing five attempts is a
|
|
40
|
+
* denial of service against that tenant. Defaults to `maxAttempts * ORG_ATTEMPT_FACTOR`.
|
|
41
|
+
*/
|
|
42
|
+
readonly orgMaxAttempts?: number | undefined;
|
|
43
|
+
/** Bound on the in-memory table. Defaults to `DEFAULT_MAX_AUTH_LIMIT_KEYS`. */
|
|
44
|
+
readonly maxKeys?: number | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* What this deployment requires of the limiter. `'shared'` says `maxAttempts` is the whole
|
|
47
|
+
* fleet's allowance, and a per-process limiter then refuses to build — because N replicas each
|
|
48
|
+
* counting their own failures let an account survive `maxAttempts × N` guesses, and a lockout
|
|
49
|
+
* one replica established is invisible to the rest.
|
|
50
|
+
*/
|
|
51
|
+
readonly scope: AuthLimiterScope;
|
|
14
52
|
}
|
|
15
53
|
|
|
54
|
+
/** One tenant is worth this many individuals' allowances before it is throttled as a tenant. */
|
|
55
|
+
export const ORG_ATTEMPT_FACTOR = 20;
|
|
56
|
+
|
|
16
57
|
export const DEFAULT_AUTH_RATE_LIMIT: AuthRateLimitPolicy = Object.freeze({
|
|
17
58
|
maxAttempts: 5,
|
|
18
59
|
windowMs: 15 * 60 * 1000,
|
|
19
60
|
lockoutMs: 15 * 60 * 1000,
|
|
61
|
+
orgMaxAttempts: 5 * ORG_ATTEMPT_FACTOR,
|
|
62
|
+
maxKeys: DEFAULT_MAX_AUTH_LIMIT_KEYS,
|
|
63
|
+
// One process is the only thing this package can promise without being told.
|
|
64
|
+
scope: 'process',
|
|
20
65
|
});
|
|
21
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Async on every member, so a shared implementation can exist at all: a lockout that holds across
|
|
69
|
+
* replicas is a network round trip, and a synchronous signature has no way to wait for one. The
|
|
70
|
+
* in-memory limiter below answers immediately — the cost is one already-settled promise per call,
|
|
71
|
+
* on a path that is about to run a KDF.
|
|
72
|
+
*/
|
|
22
73
|
export interface AuthLimiter {
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
74
|
+
/**
|
|
75
|
+
* The limits this limiter actually enforces, including where it keeps its counters. Stated
|
|
76
|
+
* rather than assumed, because `defineAuth` compares it against the app's declaration: a
|
|
77
|
+
* limiter counting to 50 under a policy that says 5 makes `Auth.rateLimit` a number the
|
|
78
|
+
* operator reads and nothing enforces.
|
|
79
|
+
*/
|
|
80
|
+
readonly policy: AuthRateLimitPolicy;
|
|
81
|
+
/** Rejects with `X_ACCOUNT_LOCKED` if the key is inside a lockout. Call before any KDF work. */
|
|
82
|
+
assertAllowed(key: string): Promise<void>;
|
|
83
|
+
recordFailure(key: string): Promise<void>;
|
|
26
84
|
/** A success clears the window: a legitimate user is not punished for a typo yesterday. */
|
|
27
|
-
recordSuccess(key: string): void
|
|
28
|
-
lockedUntil(key: string): Date | null
|
|
29
|
-
reset(): void
|
|
85
|
+
recordSuccess(key: string): Promise<void>;
|
|
86
|
+
lockedUntil(key: string): Promise<Date | null>;
|
|
87
|
+
reset(): Promise<void>;
|
|
30
88
|
}
|
|
31
89
|
|
|
32
|
-
|
|
90
|
+
/** What `createAuthLimiter` returns: the interface, plus the bound it keeps, observable. */
|
|
91
|
+
export interface MemoryAuthLimiter extends AuthLimiter {
|
|
92
|
+
readonly size: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The SAME normalisation the lookup uses, from the one declaration — a bucket keyed differently
|
|
97
|
+
* from the row it protects hands a sprayer a fresh attempt budget per spelling of one address.
|
|
98
|
+
*/
|
|
99
|
+
export const accountKey = (email: string): string => `account:${normaliseEmail(email)}`;
|
|
33
100
|
|
|
34
101
|
export const ipKey = (ip: string): string => `ip:${ip}`;
|
|
35
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The tenant bucket's key shape, declared here even though the general noisy-neighbour case lives
|
|
105
|
+
* in `@ultimat3/http` and `@ultimat3/jobs`: three limiters that spell one tenant three ways cannot
|
|
106
|
+
* be read together during an incident.
|
|
107
|
+
*/
|
|
108
|
+
export const orgKey = (orgId: string): string => `org:${orgId}`;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The policy the tenant limiter enforces — the same window and lockout, a wider allowance. One
|
|
112
|
+
* derivation, read by `defineAuth` when it builds the limiter and again when it checks an
|
|
113
|
+
* injected one, so the two cannot disagree about what was declared.
|
|
114
|
+
*/
|
|
115
|
+
export function orgRateLimit(policy: AuthRateLimitPolicy): AuthRateLimitPolicy {
|
|
116
|
+
return {
|
|
117
|
+
...policy,
|
|
118
|
+
maxAttempts: policy.orgMaxAttempts ?? policy.maxAttempts * ORG_ATTEMPT_FACTOR,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
36
122
|
interface Bucket {
|
|
37
123
|
failures: number[];
|
|
38
124
|
lockedUntilMs: number;
|
|
125
|
+
/**
|
|
126
|
+
* The instant this entry becomes indistinguishable from a missing one: the window has emptied
|
|
127
|
+
* and any lockout has expired, so it answers exactly as a first-ever attempt does.
|
|
128
|
+
*/
|
|
129
|
+
forgetAtMs: number;
|
|
39
130
|
}
|
|
40
131
|
|
|
132
|
+
/** An idle limiter still sweeps this often, so one spray's state does not sit until the next. */
|
|
133
|
+
const SWEEP_EVERY_MS = 60_000;
|
|
134
|
+
|
|
41
135
|
/**
|
|
42
136
|
* Sliding window over an injected `Clock` — never `Date.now()`, so a lockout test is
|
|
43
137
|
* deterministic instead of a sleep. In-memory per process; a multi-process deployment passes
|
|
44
138
|
* a shared implementation of the same interface.
|
|
139
|
+
*
|
|
140
|
+
* Bounded, because half the keys are attacker-chosen: `ipKey` mints one per source address, so
|
|
141
|
+
* a spray from an IPv6 /64 is a fresh key per attempt and an unbounded map is an OOM. Two rules
|
|
142
|
+
* keep it flat. An entry whose window has emptied and whose lockout has expired is *forgotten*,
|
|
143
|
+
* not evicted — it answers exactly as a missing one, so dropping it changes no decision. Only if
|
|
144
|
+
* that is not enough does the cap evict live state, and then the entries nearest to being
|
|
145
|
+
* forgotten anyway go first: a locked account is the last key to go, so filling the table is not
|
|
146
|
+
* a way to buy back attempts against one.
|
|
45
147
|
*/
|
|
46
148
|
export function createAuthLimiter(
|
|
47
149
|
clock: Clock,
|
|
48
150
|
policy: AuthRateLimitPolicy = DEFAULT_AUTH_RATE_LIMIT,
|
|
49
|
-
):
|
|
151
|
+
): MemoryAuthLimiter {
|
|
50
152
|
const buckets = new Map<string, Bucket>();
|
|
153
|
+
const maxKeys = Math.max(1, Math.floor(policy.maxKeys ?? DEFAULT_MAX_AUTH_LIMIT_KEYS));
|
|
154
|
+
const evictTo = Math.max(1, Math.floor(maxKeys * 0.9));
|
|
155
|
+
let lastSweepMs = Number.NEGATIVE_INFINITY;
|
|
51
156
|
|
|
52
157
|
const bucketFor = (key: string): Bucket => {
|
|
53
158
|
const existing = buckets.get(key);
|
|
54
159
|
if (existing !== undefined) return existing;
|
|
55
|
-
const fresh: Bucket = { failures: [], lockedUntilMs: 0 };
|
|
160
|
+
const fresh: Bucket = { failures: [], lockedUntilMs: 0, forgetAtMs: 0 };
|
|
56
161
|
buckets.set(key, fresh);
|
|
57
162
|
return fresh;
|
|
58
163
|
};
|
|
59
164
|
|
|
165
|
+
const sweep = (nowMs: number): void => {
|
|
166
|
+
lastSweepMs = nowMs;
|
|
167
|
+
for (const [key, bucket] of buckets) {
|
|
168
|
+
if (bucket.forgetAtMs <= nowMs) buckets.delete(key);
|
|
169
|
+
}
|
|
170
|
+
if (buckets.size <= maxKeys) return;
|
|
171
|
+
// Batched down to `evictTo` so this sort is paid once per 10% of the cap, not per failure.
|
|
172
|
+
// A live lockout outranks its deadline: two entries recorded a second apart are otherwise
|
|
173
|
+
// ordered by recency, which would let a spray evict the account it just locked.
|
|
174
|
+
const locked = (bucket: Bucket): number => (bucket.lockedUntilMs > nowMs ? 1 : 0);
|
|
175
|
+
const nearestForgotten = [...buckets.entries()].sort(
|
|
176
|
+
(a, b) => locked(a[1]) - locked(b[1]) || a[1].forgetAtMs - b[1].forgetAtMs,
|
|
177
|
+
);
|
|
178
|
+
for (const [key] of nearestForgotten) {
|
|
179
|
+
if (buckets.size <= evictTo) break;
|
|
180
|
+
buckets.delete(key);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
60
184
|
return {
|
|
61
|
-
|
|
185
|
+
// The resolved policy, not the argument: `maxKeys` is normalized above, and a limiter that
|
|
186
|
+
// reported an unresolved bound would be reporting something it does not enforce.
|
|
187
|
+
policy: { ...policy, maxKeys, scope: 'process' },
|
|
188
|
+
get size() {
|
|
189
|
+
return buckets.size;
|
|
190
|
+
},
|
|
191
|
+
async assertAllowed(key) {
|
|
62
192
|
const bucket = buckets.get(key);
|
|
63
193
|
if (bucket === undefined) return;
|
|
64
194
|
const nowMs = clock.now().getTime();
|
|
65
195
|
if (bucket.lockedUntilMs <= nowMs) return;
|
|
66
196
|
throw accountLocked(key, Math.ceil((bucket.lockedUntilMs - nowMs) / 1000));
|
|
67
197
|
},
|
|
68
|
-
recordFailure(key) {
|
|
198
|
+
async recordFailure(key) {
|
|
69
199
|
const nowMs = clock.now().getTime();
|
|
70
200
|
const bucket = bucketFor(key);
|
|
71
201
|
bucket.failures = bucket.failures.filter((at) => at > nowMs - policy.windowMs);
|
|
@@ -73,21 +203,54 @@ export function createAuthLimiter(
|
|
|
73
203
|
if (bucket.failures.length >= policy.maxAttempts) {
|
|
74
204
|
bucket.lockedUntilMs = nowMs + policy.lockoutMs;
|
|
75
205
|
}
|
|
206
|
+
// The newest failure leaves the window last, so that is the earliest this entry is free —
|
|
207
|
+
// unless a lockout outlives it. Recorded here because this is the only growth path.
|
|
208
|
+
bucket.forgetAtMs = Math.max(bucket.lockedUntilMs, nowMs + policy.windowMs);
|
|
209
|
+
if (buckets.size > maxKeys || nowMs - lastSweepMs >= SWEEP_EVERY_MS) sweep(nowMs);
|
|
76
210
|
},
|
|
77
|
-
recordSuccess(key) {
|
|
211
|
+
async recordSuccess(key) {
|
|
78
212
|
buckets.delete(key);
|
|
79
213
|
},
|
|
80
|
-
lockedUntil(key) {
|
|
214
|
+
async lockedUntil(key) {
|
|
81
215
|
const bucket = buckets.get(key);
|
|
82
216
|
if (bucket === undefined || bucket.lockedUntilMs <= clock.now().getTime()) return null;
|
|
83
217
|
return new Date(bucket.lockedUntilMs);
|
|
84
218
|
},
|
|
85
|
-
reset() {
|
|
219
|
+
async reset() {
|
|
86
220
|
buckets.clear();
|
|
87
221
|
},
|
|
88
222
|
};
|
|
89
223
|
}
|
|
90
224
|
|
|
225
|
+
/**
|
|
226
|
+
* The numbers a limiter is compared on. `maxKeys` is absent deliberately: it bounds one process'
|
|
227
|
+
* table, so a shared limiter has no opinion on it and comparing it would refuse a correct pairing.
|
|
228
|
+
*/
|
|
229
|
+
const ENFORCED_LIMITS = ['maxAttempts', 'windowMs', 'lockoutMs'] as const;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* At `defineAuth`, never at the first login. Two ways the declared policy and the limiter in use
|
|
233
|
+
* can disagree, and both end the same way — an operator reading `Auth.rateLimit` and believing a
|
|
234
|
+
* number nothing enforces:
|
|
235
|
+
*
|
|
236
|
+
* - a per-node limiter under a `'shared'` declaration is a lockout worth `maxAttempts × replicas`;
|
|
237
|
+
* - a limiter counting to its own numbers makes every other field of the policy decorative.
|
|
238
|
+
*
|
|
239
|
+
* The declaration stays the app's — this package cannot see how many processes the image runs,
|
|
240
|
+
* and inferring it would be wrong the first time the app scaled — so the framework's job is to
|
|
241
|
+
* refuse the pairing, not to guess either half of it.
|
|
242
|
+
*/
|
|
243
|
+
export function assertAuthLimiterPolicy(declared: AuthRateLimitPolicy, limiter: AuthLimiter): void {
|
|
244
|
+
if (declared.scope === 'shared' && limiter.policy.scope !== 'shared') {
|
|
245
|
+
throw authLimiterNotShared(limiter.policy.scope);
|
|
246
|
+
}
|
|
247
|
+
for (const field of ENFORCED_LIMITS) {
|
|
248
|
+
if (declared[field] !== limiter.policy[field]) {
|
|
249
|
+
throw authLimiterPolicyMismatch(field, declared[field], limiter.policy[field]);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
91
254
|
/**
|
|
92
255
|
* The single generic failure. Every credential path — unknown email, wrong password, disabled
|
|
93
256
|
* account, unverified address — throws exactly this object shape, so the rendered error is
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Single responsibility: taking a credential away, at the three blast radii an incident actually
|
|
2
|
+
// has — one person, one tenant, everything issued before an instant. `deleteOtherSessions` was the
|
|
3
|
+
// only one of the three the seam could express, so "kill every session in this org, now" was a
|
|
4
|
+
// per-user loop over an enumeration the adapter could not do, or a `TRUNCATE` that killed every
|
|
5
|
+
// other tenant with it. Doing nothing meant thirty days, which is the absolute TTL.
|
|
6
|
+
|
|
7
|
+
import { logger } from '@ultimat3/core';
|
|
8
|
+
import type { AuthUser } from './adapter';
|
|
9
|
+
import type { Auth } from './auth';
|
|
10
|
+
import { authNotImplemented, authWriteFailed } from './errors';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every revocation is logged before it runs, with the reason the caller gave. An incident review
|
|
14
|
+
* asks "who killed these sessions and why" and a `delete` with no line answers neither; the reason
|
|
15
|
+
* is a required argument for the same rule `crossTenant()` requires one.
|
|
16
|
+
*/
|
|
17
|
+
const record = (operation: string, scope: string, reason: string): void => {
|
|
18
|
+
logger.warn('auth.revocation', { operation, scope, reason });
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const unsupported = (method: string, adapterName: string) =>
|
|
22
|
+
authNotImplemented(
|
|
23
|
+
`${adapterName}.${method}()`,
|
|
24
|
+
`implement ${method}() on your AuthAdapter — BuiltinAdapter (Postgres) and MemoryAdapter are the two reference implementations, in packages/auth/src`,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/** Every session this user holds, their current one included. Returns how many died. */
|
|
28
|
+
export async function revokeUserSessions(
|
|
29
|
+
auth: Auth,
|
|
30
|
+
userId: string,
|
|
31
|
+
reason: string,
|
|
32
|
+
): Promise<number> {
|
|
33
|
+
const remove = auth.adapter.deleteSessionsForUser?.bind(auth.adapter);
|
|
34
|
+
if (remove === undefined) throw unsupported('deleteSessionsForUser', auth.adapter.name);
|
|
35
|
+
record('revokeUserSessions', userId, reason);
|
|
36
|
+
return await remove(userId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every session held by every member of one org. The 03:00 answer to a confirmed credential
|
|
41
|
+
* compromise in one tenant, and the one thing the seam could not express at all.
|
|
42
|
+
*/
|
|
43
|
+
export async function revokeOrgSessions(
|
|
44
|
+
auth: Auth,
|
|
45
|
+
orgId: string,
|
|
46
|
+
reason: string,
|
|
47
|
+
): Promise<number> {
|
|
48
|
+
const remove = auth.adapter.deleteSessionsForOrg?.bind(auth.adapter);
|
|
49
|
+
if (remove === undefined) throw unsupported('deleteSessionsForOrg', auth.adapter.name);
|
|
50
|
+
record('revokeOrgSessions', orgId, reason);
|
|
51
|
+
return await remove(orgId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Everything minted before an instant. The sweep that follows a rotated `SESSION_SECRET` or a
|
|
56
|
+
* suspected dump: it needs no enumeration of users, so it is the one that works when the list of
|
|
57
|
+
* affected accounts is exactly what is not known yet.
|
|
58
|
+
*/
|
|
59
|
+
export async function revokeSessionsCreatedBefore(
|
|
60
|
+
auth: Auth,
|
|
61
|
+
before: Date,
|
|
62
|
+
reason: string,
|
|
63
|
+
): Promise<number> {
|
|
64
|
+
const remove = auth.adapter.deleteSessionsCreatedBefore?.bind(auth.adapter);
|
|
65
|
+
if (remove === undefined) throw unsupported('deleteSessionsCreatedBefore', auth.adapter.name);
|
|
66
|
+
record('revokeSessionsCreatedBefore', before.toISOString(), reason);
|
|
67
|
+
return await remove(before);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DisabledUser {
|
|
71
|
+
readonly user: AuthUser;
|
|
72
|
+
readonly sessionsRevoked: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `disabledAt` is read by `login`, by `authenticate` and by the OAuth path — and until this
|
|
77
|
+
* function existed, no code path anywhere ever SET it. Offboarding was an UPDATE somebody typed.
|
|
78
|
+
*
|
|
79
|
+
* Stamping the column is not enough on its own: `authenticate` re-reads the user row on every
|
|
80
|
+
* request, so a disabled account stops working on its next request, but the live session row is
|
|
81
|
+
* still there and still slides. The sessions go in the same call, in that order — stamp first, so
|
|
82
|
+
* a request racing the revocation finds the row already disabled.
|
|
83
|
+
*/
|
|
84
|
+
export async function disableUser(
|
|
85
|
+
auth: Auth,
|
|
86
|
+
userId: string,
|
|
87
|
+
reason: string,
|
|
88
|
+
): Promise<DisabledUser> {
|
|
89
|
+
const user = await auth.adapter.updateUser(userId, { disabledAt: auth.clock.now() });
|
|
90
|
+
// No row came back: either there is no such user, or the adapter did not return the update.
|
|
91
|
+
// Both mean the account is not known to be disabled, and reporting success would be a lie.
|
|
92
|
+
if (user === null) throw authWriteFailed('updateUser', 'x_users');
|
|
93
|
+
record('disableUser', userId, reason);
|
|
94
|
+
return { user, sessionsRevoked: await revokeUserSessions(auth, userId, reason) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The inverse. No sessions are restored — a re-enabled account signs in again, deliberately. */
|
|
98
|
+
export async function enableUser(auth: Auth, userId: string): Promise<AuthUser | null> {
|
|
99
|
+
return await auth.adapter.updateUser(userId, { disabledAt: null });
|
|
100
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -12,13 +12,34 @@ import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
|
|
|
12
12
|
export interface SessionPolicy {
|
|
13
13
|
/** Hard ceiling from creation. Never extended. */
|
|
14
14
|
readonly absoluteTtlMs: number;
|
|
15
|
-
/** Measured from `lastSeenAt`, refreshed
|
|
15
|
+
/** Measured from `lastSeenAt`, refreshed at most once per `idleSlideMs`. */
|
|
16
16
|
readonly idleTtlMs: number;
|
|
17
|
+
/**
|
|
18
|
+
* How stale `lastSeenAt` may get before a verified request writes it forward. Absent means
|
|
19
|
+
* `idleTtlMs / IDLE_SLIDE_DIVISOR`, derived rather than a constant so an app that shortens
|
|
20
|
+
* `idleTtlMs` does not silently get a slide longer than its own idle window — which would
|
|
21
|
+
* pin every session to its creation time and expire it on the dot.
|
|
22
|
+
*
|
|
23
|
+
* It exists because `verifySession` used to write on EVERY authenticated request: one request
|
|
24
|
+
* was a SELECT, an `UPDATE … RETURNING *` and a second SELECT, before the app's own first
|
|
25
|
+
* query. At 20k rps that is 20k writes a second on one hot table, autovacuum falls behind, and
|
|
26
|
+
* the incident reads as "the database is slow" rather than "authentication is a write path".
|
|
27
|
+
* The trade is bounded: idle expiry is now precise to within one `idleSlideMs`.
|
|
28
|
+
*/
|
|
29
|
+
readonly idleSlideMs?: number | undefined;
|
|
17
30
|
readonly cookieName: string;
|
|
18
31
|
/** Mint a new session id whenever roles/scopes change — closes session fixation. */
|
|
19
32
|
readonly rotateOnPrivilegeChange: boolean;
|
|
20
33
|
}
|
|
21
34
|
|
|
35
|
+
/** 20 → roughly 5% of the idle window, so the write rate falls ~20× and the drift stays small. */
|
|
36
|
+
export const IDLE_SLIDE_DIVISOR = 20;
|
|
37
|
+
|
|
38
|
+
/** The resolved slide, wherever it is read. One derivation, so the two readers cannot disagree. */
|
|
39
|
+
export function idleSlideMs(policy: SessionPolicy): number {
|
|
40
|
+
return Math.max(0, policy.idleSlideMs ?? Math.floor(policy.idleTtlMs / IDLE_SLIDE_DIVISOR));
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
export const DEFAULT_SESSION_POLICY: SessionPolicy = Object.freeze({
|
|
23
44
|
absoluteTtlMs: 30 * 24 * 60 * 60 * 1000,
|
|
24
45
|
idleTtlMs: 7 * 24 * 60 * 60 * 1000,
|
|
@@ -134,10 +155,18 @@ export async function verifySession(
|
|
|
134
155
|
throw sessionExpired(expiry.absoluteExpired ? 'absolute' : 'idle', session.id);
|
|
135
156
|
}
|
|
136
157
|
|
|
158
|
+
const ip = observed?.ip ?? session.ip;
|
|
159
|
+
const userAgent = observed?.userAgent ?? session.userAgent;
|
|
160
|
+
// The window slides only when it has actually moved. A second request inside the slide issues
|
|
161
|
+
// no write at all — the read path stays a read — while a changed address or user agent is
|
|
162
|
+
// written immediately, because that is the row a device list and an incident review read.
|
|
163
|
+
const stale = now.getTime() - session.lastSeenAt.getTime() >= idleSlideMs(runtime.policy);
|
|
164
|
+
if (!stale && ip === session.ip && userAgent === session.userAgent) return session;
|
|
165
|
+
|
|
137
166
|
const touched = await runtime.store.updateSession(session.id, {
|
|
138
|
-
lastSeenAt: now,
|
|
139
|
-
ip
|
|
140
|
-
userAgent
|
|
167
|
+
lastSeenAt: stale ? now : session.lastSeenAt,
|
|
168
|
+
ip,
|
|
169
|
+
userAgent,
|
|
141
170
|
});
|
|
142
171
|
return touched ?? session;
|
|
143
172
|
}
|
package/src/tables.ts
CHANGED
|
@@ -10,11 +10,26 @@ export const X_USERS_TABLE = `create table if not exists x_users (
|
|
|
10
10
|
org_id uuid,
|
|
11
11
|
roles text[] not null default '{}',
|
|
12
12
|
permissions text[] not null default '{}',
|
|
13
|
+
scopes text[] not null default '{}',
|
|
14
|
+
external_id text unique,
|
|
13
15
|
mfa_secret text,
|
|
14
16
|
recovery_code_hashes text[] not null default '{}',
|
|
15
17
|
disabled_at timestamptz,
|
|
16
18
|
created_at timestamptz not null default now()
|
|
17
|
-
)
|
|
19
|
+
);
|
|
20
|
+
create index if not exists x_users_org_id_idx on x_users (org_id)`;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The two columns `x_users` gained in 1.3.0, as the statements an app already running 1.2 runs
|
|
24
|
+
* once. Both are additive and both have a default, so the migration is not a rewrite and takes no
|
|
25
|
+
* exclusive lock beyond the catalog update.
|
|
26
|
+
*/
|
|
27
|
+
export const X_USERS_MIGRATION_1_3: readonly string[] = Object.freeze([
|
|
28
|
+
`alter table x_users add column if not exists scopes text[] not null default '{}'`,
|
|
29
|
+
'alter table x_users add column if not exists external_id text',
|
|
30
|
+
'create unique index if not exists x_users_external_id_key on x_users (external_id)',
|
|
31
|
+
'create index if not exists x_users_org_id_idx on x_users (org_id)',
|
|
32
|
+
]);
|
|
18
33
|
|
|
19
34
|
// `id` is the public half of the cookie; `token_hash` is sha256 of the secret half.
|
|
20
35
|
export const X_SESSIONS_TABLE = `create table if not exists x_sessions (
|
|
@@ -29,7 +44,8 @@ export const X_SESSIONS_TABLE = `create table if not exists x_sessions (
|
|
|
29
44
|
mfa_satisfied boolean not null default false
|
|
30
45
|
);
|
|
31
46
|
create index if not exists x_sessions_user_id_idx on x_sessions (user_id);
|
|
32
|
-
create index if not exists x_sessions_absolute_expires_at_idx on x_sessions (absolute_expires_at)
|
|
47
|
+
create index if not exists x_sessions_absolute_expires_at_idx on x_sessions (absolute_expires_at);
|
|
48
|
+
create index if not exists x_sessions_created_at_idx on x_sessions (created_at)`;
|
|
33
49
|
|
|
34
50
|
export const X_ACCOUNTS_TABLE = `create table if not exists x_accounts (
|
|
35
51
|
id uuid primary key,
|
package/src/tokens.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// Single responsibility: the secret primitives every other file in this package shares —
|
|
2
|
-
// CSPRNG tokens
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// CSPRNG tokens and SHA-256 hashing. Centralised so no call site can quietly reach for `===` on
|
|
3
|
+
// a secret, which leaks the shared prefix length one request at a time. The constant-time
|
|
4
|
+
// comparison itself lives in `@ultimat3/core` (`timingSafeEqual`) — `@ultimat3/storage` needs the
|
|
5
|
+
// exact same one, and re-exporting it here keeps every existing `from '@ultimat3/auth'` import
|
|
6
|
+
// working.
|
|
7
|
+
|
|
8
|
+
import { timingSafeEqual } from '@ultimat3/core';
|
|
9
|
+
|
|
10
|
+
export { timingSafeEqual };
|
|
5
11
|
|
|
6
12
|
const BASE64URL_UNSAFE = /[+/=]/g;
|
|
7
13
|
const BASE64URL_REPLACEMENTS: Readonly<Record<string, string>> = { '+': '-', '/': '_', '=': '' };
|
|
@@ -23,6 +29,23 @@ export function randomToken(byteLength = 32): string {
|
|
|
23
29
|
return base64Url(randomBytes(byteLength));
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The inverse of `base64Url`, for the segments of a JWT. Answers `null` rather than throwing
|
|
34
|
+
* because every caller is reading an attacker-supplied string — a `URIError` or an `InvalidCharacterError`
|
|
35
|
+
* escaping from here would turn a coded refusal into a 500, the same reason `readCookie` never throws.
|
|
36
|
+
*/
|
|
37
|
+
export function base64UrlBytes(segment: string): Uint8Array<ArrayBuffer> | null {
|
|
38
|
+
const padded = segment
|
|
39
|
+
.replaceAll('-', '+')
|
|
40
|
+
.replaceAll('_', '/')
|
|
41
|
+
.padEnd(Math.ceil(segment.length / 4) * 4, '=');
|
|
42
|
+
try {
|
|
43
|
+
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
export function sha256Hex(value: string): string {
|
|
27
50
|
return new Bun.CryptoHasher('sha256').update(value).digest('hex');
|
|
28
51
|
}
|
|
@@ -31,20 +54,6 @@ export function sha256Bytes(value: string): Uint8Array {
|
|
|
31
54
|
return Uint8Array.from(new Bun.CryptoHasher('sha256').update(value).digest());
|
|
32
55
|
}
|
|
33
56
|
|
|
34
|
-
/**
|
|
35
|
-
* Length is compared first and non-constant-time on purpose: every secret this package
|
|
36
|
-
* compares is a fixed-width hash or token, so the length carries no information, and the
|
|
37
|
-
* XOR accumulator below is what has to be branch-free.
|
|
38
|
-
*/
|
|
39
|
-
export function timingSafeEqual(a: string, b: string): boolean {
|
|
40
|
-
if (a.length !== b.length) return false;
|
|
41
|
-
let diff = 0;
|
|
42
|
-
for (let index = 0; index < a.length; index += 1) {
|
|
43
|
-
diff |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
44
|
-
}
|
|
45
|
-
return diff === 0;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
57
|
/** Hash-then-compare. The plaintext never has to be held next to the stored value. */
|
|
49
58
|
export function matchesHash(plaintext: string, storedHash: string): boolean {
|
|
50
59
|
return timingSafeEqual(sha256Hex(plaintext), storedHash);
|
package/src/verify.ts
CHANGED
|
@@ -108,19 +108,26 @@ export interface ConsumeVerificationInput {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
|
-
* The
|
|
112
|
-
*
|
|
111
|
+
* The hash goes INTO the consume, it is not compared after one: the store consumes the row only
|
|
112
|
+
* when the hash is the live row's, atomically, so a second redemption finds nothing even if it
|
|
113
|
+
* races the first *and* a wrong guess leaves the row live. Comparing afterwards made an
|
|
114
|
+
* unauthenticated POST with any token at all destroy the victim's emailed link — one request per
|
|
115
|
+
* address for permanent password-reset denial.
|
|
113
116
|
*/
|
|
114
117
|
export async function consumeVerification(
|
|
115
118
|
runtime: VerificationRuntime,
|
|
116
119
|
input: ConsumeVerificationInput,
|
|
117
120
|
): Promise<AuthVerification> {
|
|
118
|
-
const
|
|
121
|
+
const tokenHash = sha256Hex(input.token);
|
|
122
|
+
const record = await runtime.store.takeVerification(input.purpose, input.identifier, tokenHash);
|
|
119
123
|
if (record === null) throw verificationInvalid(input.purpose);
|
|
120
|
-
|
|
124
|
+
// Kept for the seam, not for the blessed adapters: `VerificationStore` is implementable by an
|
|
125
|
+
// app, and one that ignores the third argument would otherwise redeem any token. Constant-time
|
|
126
|
+
// because this package never compares a secret — or a digest of one — with `===`.
|
|
127
|
+
if (!timingSafeEqual(tokenHash, record.tokenHash)) {
|
|
121
128
|
throw verificationInvalid(input.purpose);
|
|
122
129
|
}
|
|
123
|
-
if (
|
|
130
|
+
if (runtime.clock.now().getTime() >= record.expiresAt.getTime()) {
|
|
124
131
|
throw verificationInvalid(input.purpose);
|
|
125
132
|
}
|
|
126
133
|
return record;
|