@remit/auth-service 0.0.16 → 0.1.1
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/package.json +10 -2
- package/src/auth.ts +158 -3
- package/src/config.ts +16 -4
- package/src/identity-store.ts +286 -0
- package/src/index.ts +17 -0
- package/src/kill-switch.test.ts +179 -0
- package/src/people.test.ts +309 -0
- package/src/people.ts +540 -0
- package/src/rate-limit-log.test.ts +14 -7
- package/src/rate-limit.test.ts +14 -7
- package/src/schema/auth-schema-sqlite.ts +45 -1
- package/src/signup.test.ts +29 -1
- package/src/verifier.ts +2 -0
package/src/people.ts
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
Person,
|
|
4
|
+
PersonStatus,
|
|
5
|
+
StepUpAction,
|
|
6
|
+
} from "@remit/api-openapi-types";
|
|
7
|
+
import { deriveAccountConfigId } from "@remit/data-ports/id";
|
|
8
|
+
import { PeopleErrorCode } from "@remit/domain-enums";
|
|
9
|
+
import { calendarFeedTokens } from "@remit/drizzle-sqlite-schema";
|
|
10
|
+
import { and, asc, count, eq, gt, lte, sql } from "drizzle-orm";
|
|
11
|
+
import type { Auth } from "./auth.js";
|
|
12
|
+
import {
|
|
13
|
+
applySignInSwitch,
|
|
14
|
+
findUser,
|
|
15
|
+
HOST_CLIENT_IP,
|
|
16
|
+
type IdentityStore,
|
|
17
|
+
revokeUser,
|
|
18
|
+
type UserRow,
|
|
19
|
+
} from "./identity-store.js";
|
|
20
|
+
import {
|
|
21
|
+
auth_account,
|
|
22
|
+
auth_session,
|
|
23
|
+
auth_user,
|
|
24
|
+
auth_verification,
|
|
25
|
+
identity_audit_event,
|
|
26
|
+
identity_step_up,
|
|
27
|
+
} from "./schema/auth-schema-sqlite.js";
|
|
28
|
+
|
|
29
|
+
export type { Person, PersonStatus, StepUpAction };
|
|
30
|
+
|
|
31
|
+
export interface StepUpGrant {
|
|
32
|
+
stepUpToken: string;
|
|
33
|
+
expiresAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PasswordReset {
|
|
37
|
+
person: Person;
|
|
38
|
+
resetUrl: string;
|
|
39
|
+
expiresAt: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type PeopleErrorCodeValue =
|
|
43
|
+
(typeof PeopleErrorCode)[keyof typeof PeopleErrorCode];
|
|
44
|
+
|
|
45
|
+
export type PeopleRefusal = PeopleErrorCodeValue | "not_found";
|
|
46
|
+
|
|
47
|
+
export interface Refused {
|
|
48
|
+
ok: false;
|
|
49
|
+
refusal: PeopleRefusal;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type PeopleOutcome<T> = { ok: true; value: T } | Refused;
|
|
53
|
+
|
|
54
|
+
export interface PeopleRequest {
|
|
55
|
+
callerId: string;
|
|
56
|
+
clientIp: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TargetedRequest extends PeopleRequest {
|
|
60
|
+
targetId: string;
|
|
61
|
+
stepUpToken: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface StepUpRequest extends PeopleRequest {
|
|
65
|
+
targetId: string;
|
|
66
|
+
action: StepUpAction;
|
|
67
|
+
password: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type GuardRefusal = "RefusedSignInOff" | "RefusedForeignOrigin";
|
|
71
|
+
|
|
72
|
+
export interface PeopleDirectory {
|
|
73
|
+
listPeople(request: PeopleRequest): Promise<PeopleOutcome<Person[]>>;
|
|
74
|
+
createStepUp(request: StepUpRequest): Promise<PeopleOutcome<StepUpGrant>>;
|
|
75
|
+
signOutPerson(request: TargetedRequest): Promise<PeopleOutcome<Person>>;
|
|
76
|
+
updatePerson(
|
|
77
|
+
request: TargetedRequest & { status: PersonStatus },
|
|
78
|
+
): Promise<PeopleOutcome<Person>>;
|
|
79
|
+
createPasswordReset(
|
|
80
|
+
request: TargetedRequest,
|
|
81
|
+
): Promise<PeopleOutcome<PasswordReset>>;
|
|
82
|
+
recordRefusal(
|
|
83
|
+
request: PeopleRequest & { targetId: string; outcome: GuardRefusal },
|
|
84
|
+
): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PeopleAdmin extends PeopleDirectory {
|
|
88
|
+
enableByEmail(email: string): Promise<PeopleOutcome<Person>>;
|
|
89
|
+
resetByEmail(email: string): Promise<PeopleOutcome<PasswordReset>>;
|
|
90
|
+
applySignInSwitch(signInEnabled: boolean): void;
|
|
91
|
+
close(): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface PeopleConfig {
|
|
95
|
+
auth: Auth;
|
|
96
|
+
store: IdentityStore;
|
|
97
|
+
baseURL: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const STEP_UP_TTL_MS = 5 * 60 * 1000;
|
|
101
|
+
const STEP_UP_WINDOW_MS = 15 * 60 * 1000;
|
|
102
|
+
const STEP_UP_MAX_FAILURES = 5;
|
|
103
|
+
const RESET_TTL_MS = 24 * 60 * 60 * 1000;
|
|
104
|
+
const CREDENTIAL_PROVIDER = "credential";
|
|
105
|
+
const SHELL_ACTOR = "shell";
|
|
106
|
+
|
|
107
|
+
const refused = (refusal: PeopleRefusal): Refused => ({ ok: false, refusal });
|
|
108
|
+
|
|
109
|
+
const ok = <T>(value: T): { ok: true; value: T } => ({ ok: true, value });
|
|
110
|
+
|
|
111
|
+
const newSecret = (): string => randomBytes(32).toString("base64url");
|
|
112
|
+
|
|
113
|
+
const digestOf = (value: string): string =>
|
|
114
|
+
createHash("sha256").update(value).digest("base64url");
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The identifier better-auth looks a reset row up by under
|
|
118
|
+
* `verification.storeIdentifier: { overrides: { "reset-password": "hashed" } }`:
|
|
119
|
+
* the SHA-256 of `reset-password:<token>`, base64url without padding.
|
|
120
|
+
*/
|
|
121
|
+
export const resetIdentifierOf = (token: string): string =>
|
|
122
|
+
digestOf(`reset-password:${token}`);
|
|
123
|
+
|
|
124
|
+
const activeSessionCounts = (store: IdentityStore): Map<string, number> =>
|
|
125
|
+
new Map(
|
|
126
|
+
store.db
|
|
127
|
+
.select({ userId: auth_session.userId, sessions: count() })
|
|
128
|
+
.from(auth_session)
|
|
129
|
+
.where(gt(auth_session.expiresAt, new Date()))
|
|
130
|
+
.groupBy(auth_session.userId)
|
|
131
|
+
.all()
|
|
132
|
+
.map((row) => [row.userId, row.sessions]),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const toPerson = (
|
|
136
|
+
user: UserRow,
|
|
137
|
+
callerId: string,
|
|
138
|
+
sessions: Map<string, number>,
|
|
139
|
+
): Person => ({
|
|
140
|
+
userId: user.userId,
|
|
141
|
+
email: user.email,
|
|
142
|
+
createdAt: user.createdAt.getTime(),
|
|
143
|
+
lastSignInAt: user.lastSignInAt.getTime(),
|
|
144
|
+
sessionCount: sessions.get(user.userId) ?? 0,
|
|
145
|
+
status: user.disabled ? "Disabled" : "Active",
|
|
146
|
+
isCaller: user.userId === callerId,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const personOf = (
|
|
150
|
+
store: IdentityStore,
|
|
151
|
+
userId: string,
|
|
152
|
+
callerId: string,
|
|
153
|
+
): Person => {
|
|
154
|
+
const user = findUser(store, userId);
|
|
155
|
+
if (!user)
|
|
156
|
+
throw new Error(`user ${userId} vanished inside its own transaction`);
|
|
157
|
+
return toPerson(user, callerId, activeSessionCounts(store));
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const userByEmail = (
|
|
161
|
+
store: IdentityStore,
|
|
162
|
+
email: string,
|
|
163
|
+
): UserRow | undefined => {
|
|
164
|
+
const row = store.db
|
|
165
|
+
.select({ userId: auth_user.id })
|
|
166
|
+
.from(auth_user)
|
|
167
|
+
.where(eq(sql`lower(${auth_user.email})`, email.trim().toLowerCase()))
|
|
168
|
+
.get();
|
|
169
|
+
return row && findUser(store, row.userId);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether the caller may act at all, read inside the write transaction so two
|
|
174
|
+
* users disabling each other are serialized and the second finds itself
|
|
175
|
+
* disabled. A refusal is audited in the same transaction, which then commits.
|
|
176
|
+
*/
|
|
177
|
+
const callerRefusal = (
|
|
178
|
+
store: IdentityStore,
|
|
179
|
+
request: PeopleRequest,
|
|
180
|
+
target: string,
|
|
181
|
+
): Refused | undefined => {
|
|
182
|
+
const caller = findUser(store, request.callerId);
|
|
183
|
+
if (caller && !caller.disabled) return undefined;
|
|
184
|
+
store.audit({
|
|
185
|
+
actor: request.callerId,
|
|
186
|
+
target,
|
|
187
|
+
action: "PeopleRequestRefused",
|
|
188
|
+
outcome: "RefusedCallerDisabled",
|
|
189
|
+
clientIp: request.clientIp,
|
|
190
|
+
});
|
|
191
|
+
return refused(PeopleErrorCode.CallerDisabled);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const stepUpRefusal = (
|
|
195
|
+
store: IdentityStore,
|
|
196
|
+
request: TargetedRequest,
|
|
197
|
+
action: StepUpAction,
|
|
198
|
+
): Refused | undefined => {
|
|
199
|
+
const spent = store.db
|
|
200
|
+
.delete(identity_step_up)
|
|
201
|
+
.where(
|
|
202
|
+
and(
|
|
203
|
+
eq(identity_step_up.digest, digestOf(request.stepUpToken)),
|
|
204
|
+
eq(identity_step_up.callerUserId, request.callerId),
|
|
205
|
+
eq(identity_step_up.action, action),
|
|
206
|
+
eq(identity_step_up.targetUserId, request.targetId),
|
|
207
|
+
gt(identity_step_up.expiresAt, new Date()),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
.run();
|
|
211
|
+
if (spent.changes === 1) return undefined;
|
|
212
|
+
store.audit({
|
|
213
|
+
actor: request.callerId,
|
|
214
|
+
target: request.targetId,
|
|
215
|
+
action: "PeopleRequestRefused",
|
|
216
|
+
outcome: "RefusedStepUp",
|
|
217
|
+
clientIp: request.clientIp,
|
|
218
|
+
});
|
|
219
|
+
return refused(PeopleErrorCode.StepUpRequired);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const issueReset = (
|
|
223
|
+
store: IdentityStore,
|
|
224
|
+
userId: string,
|
|
225
|
+
scrambledHash: string,
|
|
226
|
+
): { token: string; expiresAt: Date } => {
|
|
227
|
+
const { db } = store;
|
|
228
|
+
const now = new Date();
|
|
229
|
+
const token = newSecret();
|
|
230
|
+
const expiresAt = new Date(now.getTime() + RESET_TTL_MS);
|
|
231
|
+
db.delete(auth_verification).where(eq(auth_verification.value, userId)).run();
|
|
232
|
+
db.insert(auth_verification)
|
|
233
|
+
.values({
|
|
234
|
+
id: randomUUID(),
|
|
235
|
+
identifier: resetIdentifierOf(token),
|
|
236
|
+
value: userId,
|
|
237
|
+
expiresAt,
|
|
238
|
+
createdAt: now,
|
|
239
|
+
updatedAt: now,
|
|
240
|
+
})
|
|
241
|
+
.run();
|
|
242
|
+
db.update(auth_account)
|
|
243
|
+
.set({ password: scrambledHash, updatedAt: now })
|
|
244
|
+
.where(
|
|
245
|
+
and(
|
|
246
|
+
eq(auth_account.userId, userId),
|
|
247
|
+
eq(auth_account.providerId, CREDENTIAL_PROVIDER),
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
.run();
|
|
251
|
+
revokeUser(store, userId);
|
|
252
|
+
return { token, expiresAt };
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const disable = (store: IdentityStore, userId: string): void => {
|
|
256
|
+
store.db
|
|
257
|
+
.update(auth_user)
|
|
258
|
+
.set({ disabled: true })
|
|
259
|
+
.where(eq(auth_user.id, userId))
|
|
260
|
+
.run();
|
|
261
|
+
revokeUser(store, userId);
|
|
262
|
+
store.db
|
|
263
|
+
.delete(calendarFeedTokens)
|
|
264
|
+
.where(
|
|
265
|
+
eq(calendarFeedTokens.accountConfigId, deriveAccountConfigId(userId)),
|
|
266
|
+
)
|
|
267
|
+
.run();
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const enable = (store: IdentityStore, userId: string): void => {
|
|
271
|
+
store.db
|
|
272
|
+
.update(auth_user)
|
|
273
|
+
.set({ disabled: false })
|
|
274
|
+
.where(eq(auth_user.id, userId))
|
|
275
|
+
.run();
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export const createPeopleDirectory = async (
|
|
279
|
+
config: PeopleConfig,
|
|
280
|
+
): Promise<PeopleAdmin> => {
|
|
281
|
+
const { store } = config;
|
|
282
|
+
const { password: passwords } = await config.auth.$context;
|
|
283
|
+
const resetUrlOf = (token: string): string =>
|
|
284
|
+
`${config.baseURL}/reset-password/${token}`;
|
|
285
|
+
const scrambledHash = (): Promise<string> => passwords.hash(newSecret());
|
|
286
|
+
|
|
287
|
+
const credentialHash = (userId: string): string | null | undefined =>
|
|
288
|
+
store.db
|
|
289
|
+
.select({ hash: auth_account.password })
|
|
290
|
+
.from(auth_account)
|
|
291
|
+
.where(
|
|
292
|
+
and(
|
|
293
|
+
eq(auth_account.userId, userId),
|
|
294
|
+
eq(auth_account.providerId, CREDENTIAL_PROVIDER),
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
.get()?.hash;
|
|
298
|
+
|
|
299
|
+
const passwordMatches = async (
|
|
300
|
+
userId: string,
|
|
301
|
+
password: string,
|
|
302
|
+
): Promise<boolean> => {
|
|
303
|
+
const hash = credentialHash(userId);
|
|
304
|
+
if (!hash) return false;
|
|
305
|
+
return passwords.verify({ hash, password });
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const reserveAttempt = (request: StepUpRequest): PeopleOutcome<string> =>
|
|
309
|
+
store.immediate(() => {
|
|
310
|
+
const callerRefused = callerRefusal(store, request, request.targetId);
|
|
311
|
+
if (callerRefused) return callerRefused;
|
|
312
|
+
if (!findUser(store, request.targetId)) return refused("not_found");
|
|
313
|
+
const failures =
|
|
314
|
+
store.db
|
|
315
|
+
.select({ failures: count() })
|
|
316
|
+
.from(identity_audit_event)
|
|
317
|
+
.where(
|
|
318
|
+
and(
|
|
319
|
+
eq(identity_audit_event.actor, request.callerId),
|
|
320
|
+
eq(identity_audit_event.action, "StepUpFailed"),
|
|
321
|
+
gt(
|
|
322
|
+
identity_audit_event.occurredAt,
|
|
323
|
+
new Date(Date.now() - STEP_UP_WINDOW_MS),
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
.get()?.failures ?? 0;
|
|
328
|
+
if (failures >= STEP_UP_MAX_FAILURES)
|
|
329
|
+
return refused(PeopleErrorCode.StepUpLocked);
|
|
330
|
+
return ok(
|
|
331
|
+
store.audit({
|
|
332
|
+
actor: request.callerId,
|
|
333
|
+
target: request.targetId,
|
|
334
|
+
action: "StepUpFailed",
|
|
335
|
+
outcome: "RefusedStepUp",
|
|
336
|
+
clientIp: request.clientIp,
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const grantStepUp = (
|
|
342
|
+
request: StepUpRequest,
|
|
343
|
+
auditEventId: string,
|
|
344
|
+
): PeopleOutcome<StepUpGrant> =>
|
|
345
|
+
store.immediate(() => {
|
|
346
|
+
const callerRefused = callerRefusal(store, request, request.targetId);
|
|
347
|
+
if (callerRefused) return callerRefused;
|
|
348
|
+
const now = new Date();
|
|
349
|
+
const stepUpToken = newSecret();
|
|
350
|
+
const expiresAt = new Date(now.getTime() + STEP_UP_TTL_MS);
|
|
351
|
+
store.relabel(auditEventId, {
|
|
352
|
+
actor: request.callerId,
|
|
353
|
+
target: request.targetId,
|
|
354
|
+
action: "StepUpPassed",
|
|
355
|
+
outcome: "Succeeded",
|
|
356
|
+
clientIp: request.clientIp,
|
|
357
|
+
});
|
|
358
|
+
store.db
|
|
359
|
+
.delete(identity_step_up)
|
|
360
|
+
.where(lte(identity_step_up.expiresAt, now))
|
|
361
|
+
.run();
|
|
362
|
+
store.db
|
|
363
|
+
.insert(identity_step_up)
|
|
364
|
+
.values({
|
|
365
|
+
digest: digestOf(stepUpToken),
|
|
366
|
+
callerUserId: request.callerId,
|
|
367
|
+
action: request.action,
|
|
368
|
+
targetUserId: request.targetId,
|
|
369
|
+
expiresAt,
|
|
370
|
+
})
|
|
371
|
+
.run();
|
|
372
|
+
return ok({ stepUpToken, expiresAt: expiresAt.getTime() });
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const directory: PeopleAdmin = {
|
|
376
|
+
listPeople: async (request) =>
|
|
377
|
+
store.immediate(() => {
|
|
378
|
+
const callerRefused = callerRefusal(store, request, "all");
|
|
379
|
+
if (callerRefused) return callerRefused;
|
|
380
|
+
const sessions = activeSessionCounts(store);
|
|
381
|
+
const users = store.db
|
|
382
|
+
.select({
|
|
383
|
+
userId: auth_user.id,
|
|
384
|
+
email: auth_user.email,
|
|
385
|
+
disabled: auth_user.disabled,
|
|
386
|
+
createdAt: auth_user.createdAt,
|
|
387
|
+
lastSignInAt: auth_user.lastSignInAt,
|
|
388
|
+
})
|
|
389
|
+
.from(auth_user)
|
|
390
|
+
.orderBy(asc(auth_user.email))
|
|
391
|
+
.all();
|
|
392
|
+
return ok(
|
|
393
|
+
users.map((user) => toPerson(user, request.callerId, sessions)),
|
|
394
|
+
);
|
|
395
|
+
}),
|
|
396
|
+
|
|
397
|
+
createStepUp: async (request) => {
|
|
398
|
+
const attempt = reserveAttempt(request);
|
|
399
|
+
if (!attempt.ok) return attempt;
|
|
400
|
+
if (!(await passwordMatches(request.callerId, request.password))) {
|
|
401
|
+
return refused(PeopleErrorCode.WrongPassword);
|
|
402
|
+
}
|
|
403
|
+
return grantStepUp(request, attempt.value);
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
signOutPerson: async (request) =>
|
|
407
|
+
store.immediate(() => {
|
|
408
|
+
const refusal =
|
|
409
|
+
callerRefusal(store, request, request.targetId) ??
|
|
410
|
+
stepUpRefusal(store, request, "SignOut");
|
|
411
|
+
if (refusal) return refusal;
|
|
412
|
+
if (!findUser(store, request.targetId)) return refused("not_found");
|
|
413
|
+
revokeUser(store, request.targetId);
|
|
414
|
+
store.audit({
|
|
415
|
+
actor: request.callerId,
|
|
416
|
+
target: request.targetId,
|
|
417
|
+
action: "SessionsRevoked",
|
|
418
|
+
outcome: "Succeeded",
|
|
419
|
+
clientIp: request.clientIp,
|
|
420
|
+
});
|
|
421
|
+
return ok(personOf(store, request.targetId, request.callerId));
|
|
422
|
+
}),
|
|
423
|
+
|
|
424
|
+
updatePerson: async (request) =>
|
|
425
|
+
store.immediate(() => {
|
|
426
|
+
const disabling = request.status === "Disabled";
|
|
427
|
+
const callerRefused = callerRefusal(store, request, request.targetId);
|
|
428
|
+
if (callerRefused) return callerRefused;
|
|
429
|
+
if (disabling && request.targetId === request.callerId) {
|
|
430
|
+
store.audit({
|
|
431
|
+
actor: request.callerId,
|
|
432
|
+
target: request.targetId,
|
|
433
|
+
action: "UserDisabled",
|
|
434
|
+
outcome: "RefusedSelf",
|
|
435
|
+
clientIp: request.clientIp,
|
|
436
|
+
});
|
|
437
|
+
return refused(PeopleErrorCode.CannotDisableSelf);
|
|
438
|
+
}
|
|
439
|
+
const stepUpRefused = stepUpRefusal(
|
|
440
|
+
store,
|
|
441
|
+
request,
|
|
442
|
+
disabling ? "Disable" : "Enable",
|
|
443
|
+
);
|
|
444
|
+
if (stepUpRefused) return stepUpRefused;
|
|
445
|
+
if (!findUser(store, request.targetId)) return refused("not_found");
|
|
446
|
+
if (disabling) disable(store, request.targetId);
|
|
447
|
+
else enable(store, request.targetId);
|
|
448
|
+
store.audit({
|
|
449
|
+
actor: request.callerId,
|
|
450
|
+
target: request.targetId,
|
|
451
|
+
action: disabling ? "UserDisabled" : "UserEnabled",
|
|
452
|
+
outcome: "Succeeded",
|
|
453
|
+
clientIp: request.clientIp,
|
|
454
|
+
});
|
|
455
|
+
return ok(personOf(store, request.targetId, request.callerId));
|
|
456
|
+
}),
|
|
457
|
+
|
|
458
|
+
createPasswordReset: async (request) => {
|
|
459
|
+
const scrambled = await scrambledHash();
|
|
460
|
+
const issued = store.immediate(
|
|
461
|
+
(): PeopleOutcome<{ token: string; expiresAt: Date }> => {
|
|
462
|
+
const refusal =
|
|
463
|
+
callerRefusal(store, request, request.targetId) ??
|
|
464
|
+
stepUpRefusal(store, request, "ResetPassword");
|
|
465
|
+
if (refusal) return refusal;
|
|
466
|
+
if (!findUser(store, request.targetId)) return refused("not_found");
|
|
467
|
+
const reset = issueReset(store, request.targetId, scrambled);
|
|
468
|
+
store.audit({
|
|
469
|
+
actor: request.callerId,
|
|
470
|
+
target: request.targetId,
|
|
471
|
+
action: "PasswordResetIssued",
|
|
472
|
+
outcome: "Succeeded",
|
|
473
|
+
clientIp: request.clientIp,
|
|
474
|
+
});
|
|
475
|
+
return ok(reset);
|
|
476
|
+
},
|
|
477
|
+
);
|
|
478
|
+
if (!issued.ok) return issued;
|
|
479
|
+
return ok({
|
|
480
|
+
person: personOf(store, request.targetId, request.callerId),
|
|
481
|
+
resetUrl: resetUrlOf(issued.value.token),
|
|
482
|
+
expiresAt: issued.value.expiresAt.getTime(),
|
|
483
|
+
});
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
recordRefusal: (request) => {
|
|
487
|
+
store.immediate(() =>
|
|
488
|
+
store.audit({
|
|
489
|
+
actor: request.callerId,
|
|
490
|
+
target: request.targetId,
|
|
491
|
+
action: "PeopleRequestRefused",
|
|
492
|
+
outcome: request.outcome,
|
|
493
|
+
clientIp: request.clientIp,
|
|
494
|
+
}),
|
|
495
|
+
);
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
enableByEmail: async (email) =>
|
|
499
|
+
store.immediate(() => {
|
|
500
|
+
const user = userByEmail(store, email);
|
|
501
|
+
if (!user) return refused("not_found");
|
|
502
|
+
enable(store, user.userId);
|
|
503
|
+
store.audit({
|
|
504
|
+
actor: SHELL_ACTOR,
|
|
505
|
+
target: user.userId,
|
|
506
|
+
action: "UserEnabled",
|
|
507
|
+
outcome: "Succeeded",
|
|
508
|
+
clientIp: HOST_CLIENT_IP,
|
|
509
|
+
});
|
|
510
|
+
return ok(personOf(store, user.userId, SHELL_ACTOR));
|
|
511
|
+
}),
|
|
512
|
+
|
|
513
|
+
resetByEmail: async (email) => {
|
|
514
|
+
const scrambled = await scrambledHash();
|
|
515
|
+
return store.immediate(() => {
|
|
516
|
+
const user = userByEmail(store, email);
|
|
517
|
+
if (!user) return refused("not_found");
|
|
518
|
+
const reset = issueReset(store, user.userId, scrambled);
|
|
519
|
+
store.audit({
|
|
520
|
+
actor: SHELL_ACTOR,
|
|
521
|
+
target: user.userId,
|
|
522
|
+
action: "PasswordResetIssued",
|
|
523
|
+
outcome: "Succeeded",
|
|
524
|
+
clientIp: HOST_CLIENT_IP,
|
|
525
|
+
});
|
|
526
|
+
return ok({
|
|
527
|
+
person: personOf(store, user.userId, SHELL_ACTOR),
|
|
528
|
+
resetUrl: resetUrlOf(reset.token),
|
|
529
|
+
expiresAt: reset.expiresAt.getTime(),
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
applySignInSwitch: (signInEnabled) =>
|
|
535
|
+
applySignInSwitch(store, signInEnabled),
|
|
536
|
+
|
|
537
|
+
close: () => store.close(),
|
|
538
|
+
};
|
|
539
|
+
return directory;
|
|
540
|
+
};
|
|
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
|
|
|
5
5
|
import { after, before, describe, it } from "node:test";
|
|
6
6
|
import type { Auth } from "./auth.js";
|
|
7
7
|
import { CLIENT_IP_HEADER, createAuth } from "./auth.js";
|
|
8
|
+
import { openIdentityStore } from "./identity-store.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* #441: better-auth answers a rate-limited request from inside its own router,
|
|
@@ -119,13 +120,19 @@ describe("a rate-limited auth response is logged", () => {
|
|
|
119
120
|
rmSync(dir, { recursive: true, force: true });
|
|
120
121
|
});
|
|
121
122
|
|
|
122
|
-
const build = async (name: string): Promise<Auth> =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
const build = async (name: string): Promise<Auth> => {
|
|
124
|
+
const connectionString = await createDatabase(dir, name);
|
|
125
|
+
return createAuth(
|
|
126
|
+
{
|
|
127
|
+
connectionString,
|
|
128
|
+
secret: "rate-limit-log-test-secret-value-32chars",
|
|
129
|
+
baseURL: "http://localhost:3000",
|
|
130
|
+
selfSignUpEnabled: true,
|
|
131
|
+
signInEnabled: true,
|
|
132
|
+
},
|
|
133
|
+
await openIdentityStore(connectionString),
|
|
134
|
+
);
|
|
135
|
+
};
|
|
129
136
|
|
|
130
137
|
it("names the endpoint and the address the limit is keyed on", async () => {
|
|
131
138
|
const handler = withRateLimitLogging(await build("logged"));
|
package/src/rate-limit.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
|
|
|
5
5
|
import { after, before, describe, it } from "node:test";
|
|
6
6
|
import type { Auth } from "./auth.js";
|
|
7
7
|
import { CLIENT_IP_HEADER, createAuth } from "./auth.js";
|
|
8
|
+
import { openIdentityStore } from "./identity-store.js";
|
|
8
9
|
|
|
9
10
|
const SIGN_IN_MAX = 2;
|
|
10
11
|
const MIGRATIONS = resolve(
|
|
@@ -68,13 +69,19 @@ describe("sign-in rate limiting is per client", () => {
|
|
|
68
69
|
rmSync(dir, { recursive: true, force: true });
|
|
69
70
|
});
|
|
70
71
|
|
|
71
|
-
const build = async (name: string): Promise<Auth> =>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
const build = async (name: string): Promise<Auth> => {
|
|
73
|
+
const connectionString = await createDatabase(dir, name);
|
|
74
|
+
return createAuth(
|
|
75
|
+
{
|
|
76
|
+
connectionString,
|
|
77
|
+
secret: "rate-limit-test-secret-value-32chars-min",
|
|
78
|
+
baseURL: "http://localhost:3000",
|
|
79
|
+
selfSignUpEnabled: true,
|
|
80
|
+
signInEnabled: true,
|
|
81
|
+
},
|
|
82
|
+
await openIdentityStore(connectionString),
|
|
83
|
+
);
|
|
84
|
+
};
|
|
78
85
|
|
|
79
86
|
it("locks out only the client that spent the budget", async () => {
|
|
80
87
|
const auth = await build("per-client");
|
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
// every deployed instance and rewrite all five tables. Match better-auth's
|
|
11
11
|
// runtime expectations by hand when a version bump changes them, and let
|
|
12
12
|
// vps-migrations-drift.sqlite.test.ts catch the migration side.
|
|
13
|
-
import {
|
|
13
|
+
import { sql } from "drizzle-orm";
|
|
14
|
+
import {
|
|
15
|
+
index,
|
|
16
|
+
integer,
|
|
17
|
+
sqliteTable,
|
|
18
|
+
text,
|
|
19
|
+
uniqueIndex,
|
|
20
|
+
} from "drizzle-orm/sqlite-core";
|
|
14
21
|
|
|
15
22
|
export const auth_user = sqliteTable("auth_user", {
|
|
16
23
|
id: text("id").primaryKey(),
|
|
@@ -27,6 +34,10 @@ export const auth_user = sqliteTable("auth_user", {
|
|
|
27
34
|
.$defaultFn(() => /* @__PURE__ */ new Date())
|
|
28
35
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
29
36
|
.notNull(),
|
|
37
|
+
disabled: integer("disabled", { mode: "boolean" }).default(false).notNull(),
|
|
38
|
+
lastSignInAt: integer("last_sign_in_at", { mode: "timestamp" })
|
|
39
|
+
.default(sql`0`)
|
|
40
|
+
.notNull(),
|
|
30
41
|
});
|
|
31
42
|
|
|
32
43
|
export const auth_session = sqliteTable(
|
|
@@ -107,3 +118,36 @@ export const auth_jwks = sqliteTable("auth_jwks", {
|
|
|
107
118
|
alg: text("alg"),
|
|
108
119
|
crv: text("crv"),
|
|
109
120
|
});
|
|
121
|
+
|
|
122
|
+
// Not better-auth tables: the self-host People operations own these
|
|
123
|
+
// (packages/auth-service/src/people.ts). Times are epoch milliseconds.
|
|
124
|
+
export const identity_audit_event = sqliteTable(
|
|
125
|
+
"identity_audit_event",
|
|
126
|
+
{
|
|
127
|
+
auditEventId: text("audit_event_id").primaryKey(),
|
|
128
|
+
occurredAt: integer("occurred_at", { mode: "timestamp_ms" }).notNull(),
|
|
129
|
+
actor: text("actor").notNull(),
|
|
130
|
+
target: text("target").notNull(),
|
|
131
|
+
action: text("action").notNull(),
|
|
132
|
+
outcome: text("outcome").notNull(),
|
|
133
|
+
clientIp: text("client_ip").notNull(),
|
|
134
|
+
},
|
|
135
|
+
(table) => [
|
|
136
|
+
index("identity_audit_event_occurred_at_idx").on(table.occurredAt),
|
|
137
|
+
],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
export const identity_step_up = sqliteTable(
|
|
141
|
+
"identity_step_up",
|
|
142
|
+
{
|
|
143
|
+
digest: text("digest").notNull(),
|
|
144
|
+
callerUserId: text("caller_user_id").notNull(),
|
|
145
|
+
action: text("action").notNull(),
|
|
146
|
+
targetUserId: text("target_user_id").notNull(),
|
|
147
|
+
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
|
148
|
+
},
|
|
149
|
+
(table) => [
|
|
150
|
+
uniqueIndex("identity_step_up_digest_idx").on(table.digest),
|
|
151
|
+
index("identity_step_up_caller_idx").on(table.callerUserId),
|
|
152
|
+
],
|
|
153
|
+
);
|