@meith/accounts 0.16.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/LICENSE.md +165 -0
- package/package.json +25 -0
- package/src/ban-filter.ts +127 -0
- package/src/ban-service.ts +71 -0
- package/src/case-fold.ts +3 -0
- package/src/credential-proof.ts +17 -0
- package/src/crypto/base64url.ts +53 -0
- package/src/crypto/legacy.ts +127 -0
- package/src/crypto/password.ts +92 -0
- package/src/crypto/tokens.ts +28 -0
- package/src/federation/catalog.ts +115 -0
- package/src/federation/github.ts +117 -0
- package/src/federation/http.ts +48 -0
- package/src/federation/jwt.ts +198 -0
- package/src/federation/oidc.ts +175 -0
- package/src/federation/pkce.ts +20 -0
- package/src/federation/service.ts +198 -0
- package/src/federation/types.ts +50 -0
- package/src/index.ts +223 -0
- package/src/member-settings.ts +390 -0
- package/src/memory-bans.ts +88 -0
- package/src/memory-repos.ts +665 -0
- package/src/policy.ts +104 -0
- package/src/ports.ts +415 -0
- package/src/register-fields.ts +17 -0
- package/src/service.ts +684 -0
- package/src/session-service.ts +134 -0
- package/src/test-support.fixture.ts +8 -0
- package/src/totp/base32.ts +52 -0
- package/src/totp/secret-box.ts +83 -0
- package/src/totp/service.ts +252 -0
- package/src/totp/totp.ts +120 -0
- package/src/webauthn/authenticator.fixture.ts +268 -0
- package/src/webauthn/cbor.ts +110 -0
- package/src/webauthn/cose.ts +130 -0
- package/src/webauthn/service.ts +266 -0
- package/src/webauthn/verify.ts +231 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccountRecord,
|
|
3
|
+
AccountRepository,
|
|
4
|
+
AccountState,
|
|
5
|
+
AccountStore,
|
|
6
|
+
ActiveSessionRecord,
|
|
7
|
+
AuthEventRecord,
|
|
8
|
+
AuthEventRepository,
|
|
9
|
+
CredentialPurpose,
|
|
10
|
+
CredentialTokenRepository,
|
|
11
|
+
LinkIdentityInput,
|
|
12
|
+
LoginAttemptRepository,
|
|
13
|
+
NewAccount,
|
|
14
|
+
NewAuthEvent,
|
|
15
|
+
NewPasskey,
|
|
16
|
+
PasskeyRecord,
|
|
17
|
+
PasskeyRepository,
|
|
18
|
+
RecoveryCodeRepository,
|
|
19
|
+
RememberRotation,
|
|
20
|
+
RememberTokenRepository,
|
|
21
|
+
SessionLocation,
|
|
22
|
+
SessionRecord,
|
|
23
|
+
SessionRepository,
|
|
24
|
+
TwoFactorRecord,
|
|
25
|
+
TwoFactorRepository,
|
|
26
|
+
UserIdentityRecord,
|
|
27
|
+
UserIdentityRepository,
|
|
28
|
+
} from './ports'
|
|
29
|
+
import { withinRotationGrace } from './ports'
|
|
30
|
+
|
|
31
|
+
class MemoryAccounts implements AccountRepository {
|
|
32
|
+
private readonly byId = new Map<number, AccountRecord>()
|
|
33
|
+
private readonly ipPrefixes = new Map<
|
|
34
|
+
number,
|
|
35
|
+
{ registration: string | null; lastVisit: string | null }
|
|
36
|
+
>()
|
|
37
|
+
private seq = 0
|
|
38
|
+
|
|
39
|
+
async findById(id: number): Promise<AccountRecord | null> {
|
|
40
|
+
return this.byId.get(id) ?? null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async findByUsernameLower(usernameLower: string): Promise<AccountRecord | null> {
|
|
44
|
+
for (const a of this.byId.values()) {
|
|
45
|
+
if (a.usernameLower === usernameLower) return a
|
|
46
|
+
}
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async findByEmailLower(emailLower: string): Promise<AccountRecord | null> {
|
|
51
|
+
for (const a of this.byId.values()) {
|
|
52
|
+
if (a.emailLower === emailLower) return a
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async create(input: NewAccount): Promise<AccountRecord> {
|
|
58
|
+
const record: AccountRecord = {
|
|
59
|
+
id: ++this.seq,
|
|
60
|
+
username: input.username,
|
|
61
|
+
usernameLower: input.usernameLower,
|
|
62
|
+
email: input.email,
|
|
63
|
+
emailLower: input.emailLower,
|
|
64
|
+
passwordHash: input.passwordHash,
|
|
65
|
+
passwordAlgo: input.passwordAlgo,
|
|
66
|
+
state: input.state,
|
|
67
|
+
emailVerifiedAt: null,
|
|
68
|
+
primaryGroupId: input.primaryGroupId,
|
|
69
|
+
}
|
|
70
|
+
this.byId.set(record.id, record)
|
|
71
|
+
this.ipPrefixes.set(record.id, {
|
|
72
|
+
registration: input.registrationIpPrefix ?? null,
|
|
73
|
+
lastVisit: null,
|
|
74
|
+
})
|
|
75
|
+
return record
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async recordLastIpPrefix(userId: number, prefix: string): Promise<void> {
|
|
79
|
+
const current = this.ipPrefixes.get(userId)
|
|
80
|
+
if (current === undefined) return
|
|
81
|
+
this.ipPrefixes.set(userId, { ...current, lastVisit: prefix })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async updatePassword(userId: number, passwordHash: string, passwordAlgo: string): Promise<void> {
|
|
85
|
+
const cur = this.byId.get(userId)
|
|
86
|
+
if (cur) this.byId.set(userId, { ...cur, passwordHash, passwordAlgo })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async setState(userId: number, state: AccountState): Promise<void> {
|
|
90
|
+
const cur = this.byId.get(userId)
|
|
91
|
+
if (cur) this.byId.set(userId, { ...cur, state })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async markEmailVerified(
|
|
95
|
+
userId: number,
|
|
96
|
+
at: Date,
|
|
97
|
+
activate: boolean,
|
|
98
|
+
): Promise<AccountState | null> {
|
|
99
|
+
const cur = this.byId.get(userId)
|
|
100
|
+
if (cur === undefined) return null
|
|
101
|
+
|
|
102
|
+
this.byId.set(userId, {
|
|
103
|
+
...cur,
|
|
104
|
+
emailVerifiedAt: cur.emailVerifiedAt ?? at,
|
|
105
|
+
state: activate && cur.state === 'awaiting_activation' ? 'active' : cur.state,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return cur.state
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private readonly lastActive = new Map<number, Date>()
|
|
112
|
+
|
|
113
|
+
async touchLastActive(userId: number, now: Date, windowSeconds: number): Promise<boolean> {
|
|
114
|
+
if (!this.byId.has(userId)) return false
|
|
115
|
+
|
|
116
|
+
const previous = this.lastActive.get(userId)
|
|
117
|
+
if (previous !== undefined && now.getTime() - previous.getTime() < windowSeconds * 1000) {
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.lastActive.set(userId, now)
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
class MemorySessions implements SessionRepository {
|
|
127
|
+
private readonly byId = new Map<number, SessionRecord>()
|
|
128
|
+
private readonly byHash = new Map<string, number>()
|
|
129
|
+
private readonly lastLocationWrite = new Map<number, number>()
|
|
130
|
+
private readonly devices = new Map<
|
|
131
|
+
number,
|
|
132
|
+
{ ipPrefix: string | null; userAgent: string | null; createdAt: Date }
|
|
133
|
+
>()
|
|
134
|
+
private seq = 0
|
|
135
|
+
|
|
136
|
+
async create(input: {
|
|
137
|
+
tokenHash: string
|
|
138
|
+
userId: number
|
|
139
|
+
expiresAt: Date
|
|
140
|
+
ipPrefix?: string | null
|
|
141
|
+
userAgent?: string | null
|
|
142
|
+
}): Promise<SessionRecord> {
|
|
143
|
+
const record: SessionRecord = {
|
|
144
|
+
id: ++this.seq,
|
|
145
|
+
userId: input.userId,
|
|
146
|
+
expiresAt: input.expiresAt,
|
|
147
|
+
revokedAt: null,
|
|
148
|
+
supersededBySessionId: null,
|
|
149
|
+
credentialProvedAt: null,
|
|
150
|
+
lastSeenAt: new Date(),
|
|
151
|
+
}
|
|
152
|
+
this.byId.set(record.id, record)
|
|
153
|
+
this.byHash.set(input.tokenHash, record.id)
|
|
154
|
+
this.devices.set(record.id, {
|
|
155
|
+
ipPrefix: input.ipPrefix ?? null,
|
|
156
|
+
userAgent: input.userAgent ?? null,
|
|
157
|
+
createdAt: record.lastSeenAt,
|
|
158
|
+
})
|
|
159
|
+
return record
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async findByTokenHash(tokenHash: string): Promise<SessionRecord | null> {
|
|
163
|
+
const id = this.byHash.get(tokenHash)
|
|
164
|
+
return id === undefined ? null : (this.byId.get(id) ?? null)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async markCredentialProved(sessionId: number, userId: number, at: Date): Promise<boolean> {
|
|
168
|
+
const current = this.byId.get(sessionId)
|
|
169
|
+
if (
|
|
170
|
+
current === undefined ||
|
|
171
|
+
current.userId !== userId ||
|
|
172
|
+
current.revokedAt !== null ||
|
|
173
|
+
current.supersededBySessionId !== null ||
|
|
174
|
+
current.expiresAt.getTime() <= at.getTime()
|
|
175
|
+
) {
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.byId.set(sessionId, { ...current, credentialProvedAt: at })
|
|
180
|
+
return true
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async listActiveForUser(userId: number, now: Date): Promise<readonly ActiveSessionRecord[]> {
|
|
184
|
+
return [...this.byId.values()]
|
|
185
|
+
.filter(
|
|
186
|
+
(session) =>
|
|
187
|
+
session.userId === userId &&
|
|
188
|
+
session.revokedAt === null &&
|
|
189
|
+
session.expiresAt.getTime() > now.getTime(),
|
|
190
|
+
)
|
|
191
|
+
.map((session) => {
|
|
192
|
+
const device = this.devices.get(session.id)
|
|
193
|
+
return {
|
|
194
|
+
id: session.id,
|
|
195
|
+
createdAt: device?.createdAt ?? session.lastSeenAt,
|
|
196
|
+
lastSeenAt: session.lastSeenAt,
|
|
197
|
+
expiresAt: session.expiresAt,
|
|
198
|
+
ipPrefix: device?.ipPrefix ?? null,
|
|
199
|
+
userAgent: device?.userAgent ?? null,
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
.sort((a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime())
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async revoke(sessionId: number): Promise<void> {
|
|
206
|
+
const cur = this.byId.get(sessionId)
|
|
207
|
+
if (cur && cur.revokedAt === null) {
|
|
208
|
+
this.byId.set(sessionId, { ...cur, revokedAt: new Date() })
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async revokeOwned(userId: number, sessionId: number, now: Date): Promise<boolean> {
|
|
213
|
+
const cur = this.byId.get(sessionId)
|
|
214
|
+
if (!cur || cur.userId !== userId || cur.revokedAt !== null) return false
|
|
215
|
+
|
|
216
|
+
this.byId.set(sessionId, { ...cur, revokedAt: now })
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async revokeAllForUserExcept(userId: number, sessionId: number | null): Promise<number> {
|
|
221
|
+
let revoked = 0
|
|
222
|
+
const now = new Date()
|
|
223
|
+
|
|
224
|
+
for (const [id, session] of this.byId) {
|
|
225
|
+
if (session.userId !== userId || session.revokedAt !== null) continue
|
|
226
|
+
if (id === sessionId) continue
|
|
227
|
+
|
|
228
|
+
this.byId.set(id, { ...session, revokedAt: now })
|
|
229
|
+
revoked += 1
|
|
230
|
+
}
|
|
231
|
+
return revoked
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async revokeAllForUser(userId: number): Promise<void> {
|
|
235
|
+
for (const [id, s] of this.byId) {
|
|
236
|
+
if (s.userId === userId && s.revokedAt === null) {
|
|
237
|
+
this.byId.set(id, { ...s, revokedAt: new Date() })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async supersede(oldSessionId: number, newSessionId: number, now: Date): Promise<void> {
|
|
243
|
+
const cur = this.byId.get(oldSessionId)
|
|
244
|
+
if (cur) {
|
|
245
|
+
this.byId.set(oldSessionId, {
|
|
246
|
+
...cur,
|
|
247
|
+
supersededBySessionId: newSessionId,
|
|
248
|
+
revokedAt: cur.revokedAt ?? now,
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async touchLocation(
|
|
254
|
+
sessionId: number,
|
|
255
|
+
_location: SessionLocation,
|
|
256
|
+
now: Date,
|
|
257
|
+
windowSeconds: number,
|
|
258
|
+
): Promise<boolean> {
|
|
259
|
+
const cur = this.byId.get(sessionId)
|
|
260
|
+
if (!cur) return false
|
|
261
|
+
const last = this.lastLocationWrite.get(sessionId)
|
|
262
|
+
if (last !== undefined && (now.getTime() - last) / 1000 < windowSeconds) {
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
this.lastLocationWrite.set(sessionId, now.getTime())
|
|
266
|
+
this.byId.set(sessionId, { ...cur, lastSeenAt: now })
|
|
267
|
+
return true
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
interface StoredRemember {
|
|
272
|
+
familyId: string
|
|
273
|
+
userId: number
|
|
274
|
+
expiresAt: Date
|
|
275
|
+
usedAt: Date | null
|
|
276
|
+
revokedAt: Date | null
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
class MemoryRememberTokens implements RememberTokenRepository {
|
|
280
|
+
private readonly byHash = new Map<string, StoredRemember>()
|
|
281
|
+
|
|
282
|
+
async issue(input: {
|
|
283
|
+
tokenHash: string
|
|
284
|
+
familyId: string
|
|
285
|
+
userId: number
|
|
286
|
+
expiresAt: Date
|
|
287
|
+
}): Promise<void> {
|
|
288
|
+
this.byHash.set(input.tokenHash, {
|
|
289
|
+
familyId: input.familyId,
|
|
290
|
+
userId: input.userId,
|
|
291
|
+
expiresAt: input.expiresAt,
|
|
292
|
+
usedAt: null,
|
|
293
|
+
revokedAt: null,
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async rotate(input: {
|
|
298
|
+
presentedHash: string
|
|
299
|
+
nextHash: string
|
|
300
|
+
now: Date
|
|
301
|
+
nextExpiresAt: Date
|
|
302
|
+
}): Promise<RememberRotation> {
|
|
303
|
+
const t = this.byHash.get(input.presentedHash)
|
|
304
|
+
if (!t) return { status: 'invalid' }
|
|
305
|
+
if (t.expiresAt.getTime() <= input.now.getTime()) return { status: 'invalid' }
|
|
306
|
+
if (t.usedAt !== null || t.revokedAt !== null) {
|
|
307
|
+
if (!withinRotationGrace(t, input.now)) {
|
|
308
|
+
return { status: 'reuse', userId: t.userId, familyId: t.familyId }
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
this.byHash.set(input.presentedHash, { ...t, usedAt: input.now })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
this.byHash.set(input.nextHash, {
|
|
315
|
+
familyId: t.familyId,
|
|
316
|
+
userId: t.userId,
|
|
317
|
+
expiresAt: input.nextExpiresAt,
|
|
318
|
+
usedAt: null,
|
|
319
|
+
revokedAt: null,
|
|
320
|
+
})
|
|
321
|
+
return { status: 'rotated', userId: t.userId, familyId: t.familyId }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async revokeFamily(familyId: string, _reason: string, now: Date): Promise<void> {
|
|
325
|
+
for (const [hash, t] of this.byHash) {
|
|
326
|
+
if (t.familyId === familyId && t.revokedAt === null) {
|
|
327
|
+
this.byHash.set(hash, { ...t, revokedAt: now })
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async revokeAllForUser(userId: number, _reason: string, now: Date): Promise<void> {
|
|
333
|
+
for (const [hash, t] of this.byHash) {
|
|
334
|
+
if (t.userId === userId && t.revokedAt === null) {
|
|
335
|
+
this.byHash.set(hash, { ...t, revokedAt: now })
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async findByTokenHash(tokenHash: string): Promise<{
|
|
341
|
+
familyId: string
|
|
342
|
+
userId: number
|
|
343
|
+
usedAt: Date | null
|
|
344
|
+
revokedAt: Date | null
|
|
345
|
+
} | null> {
|
|
346
|
+
const t = this.byHash.get(tokenHash)
|
|
347
|
+
if (!t) return null
|
|
348
|
+
return { familyId: t.familyId, userId: t.userId, usedAt: t.usedAt, revokedAt: t.revokedAt }
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
interface StoredToken {
|
|
353
|
+
userId: number
|
|
354
|
+
purpose: CredentialPurpose
|
|
355
|
+
payload: string | null
|
|
356
|
+
expiresAt: Date
|
|
357
|
+
consumedAt: Date | null
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
class MemoryCredentialTokens implements CredentialTokenRepository {
|
|
361
|
+
private readonly byHash = new Map<string, StoredToken>()
|
|
362
|
+
|
|
363
|
+
async issue(input: {
|
|
364
|
+
tokenHash: string
|
|
365
|
+
userId: number
|
|
366
|
+
purpose: CredentialPurpose
|
|
367
|
+
payload?: string | null
|
|
368
|
+
expiresAt: Date
|
|
369
|
+
}): Promise<void> {
|
|
370
|
+
this.byHash.set(input.tokenHash, {
|
|
371
|
+
userId: input.userId,
|
|
372
|
+
purpose: input.purpose,
|
|
373
|
+
payload: input.payload ?? null,
|
|
374
|
+
expiresAt: input.expiresAt,
|
|
375
|
+
consumedAt: null,
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async consume(
|
|
380
|
+
tokenHash: string,
|
|
381
|
+
purpose: CredentialPurpose,
|
|
382
|
+
now: Date,
|
|
383
|
+
): Promise<{ userId: number; payload: string | null } | null> {
|
|
384
|
+
const found = await this.peek(tokenHash, purpose, now)
|
|
385
|
+
if (found === null) return null
|
|
386
|
+
|
|
387
|
+
const t = this.byHash.get(tokenHash)!
|
|
388
|
+
this.byHash.set(tokenHash, { ...t, consumedAt: now })
|
|
389
|
+
return found
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async peek(
|
|
393
|
+
tokenHash: string,
|
|
394
|
+
purpose: CredentialPurpose,
|
|
395
|
+
now: Date,
|
|
396
|
+
): Promise<{ userId: number; payload: string | null } | null> {
|
|
397
|
+
const t = this.byHash.get(tokenHash)
|
|
398
|
+
if (!t) return null
|
|
399
|
+
if (t.purpose !== purpose) return null
|
|
400
|
+
if (t.consumedAt !== null) return null
|
|
401
|
+
if (t.expiresAt.getTime() <= now.getTime()) return null
|
|
402
|
+
|
|
403
|
+
return { userId: t.userId, payload: t.payload }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async revokeAllForUser(userId: number, purpose: CredentialPurpose): Promise<void> {
|
|
407
|
+
const now = new Date()
|
|
408
|
+
for (const [hash, t] of this.byHash) {
|
|
409
|
+
if (t.userId === userId && t.purpose === purpose && t.consumedAt === null) {
|
|
410
|
+
this.byHash.set(hash, { ...t, consumedAt: now })
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
interface Attempt {
|
|
417
|
+
succeeded: boolean
|
|
418
|
+
at: Date
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
class MemoryLoginAttempts implements LoginAttemptRepository {
|
|
422
|
+
private readonly buckets = new Map<string, Attempt[]>()
|
|
423
|
+
|
|
424
|
+
async record(bucket: string, succeeded: boolean, at: Date): Promise<void> {
|
|
425
|
+
const list = this.buckets.get(bucket) ?? []
|
|
426
|
+
list.push({ succeeded, at })
|
|
427
|
+
this.buckets.set(bucket, list)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async countFailuresSince(bucket: string, since: Date): Promise<number> {
|
|
431
|
+
const list = this.buckets.get(bucket) ?? []
|
|
432
|
+
return list.filter((a) => !a.succeeded && a.at.getTime() >= since.getTime()).length
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async clear(bucket: string): Promise<void> {
|
|
436
|
+
this.buckets.delete(bucket)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
class MemoryUserIdentities implements UserIdentityRepository {
|
|
441
|
+
private readonly byId = new Map<number, UserIdentityRecord>()
|
|
442
|
+
private seq = 0
|
|
443
|
+
|
|
444
|
+
async findBySubject(provider: string, subject: string): Promise<UserIdentityRecord | null> {
|
|
445
|
+
for (const record of this.byId.values()) {
|
|
446
|
+
if (record.provider === provider && record.subject === subject) return record
|
|
447
|
+
}
|
|
448
|
+
return null
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async listForUser(userId: number): Promise<readonly UserIdentityRecord[]> {
|
|
452
|
+
return [...this.byId.values()]
|
|
453
|
+
.filter((record) => record.userId === userId)
|
|
454
|
+
.sort((a, b) => a.id - b.id)
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async link(input: LinkIdentityInput): Promise<UserIdentityRecord> {
|
|
458
|
+
const existing = await this.findBySubject(input.provider, input.subject)
|
|
459
|
+
if (existing !== null) return existing
|
|
460
|
+
|
|
461
|
+
const record: UserIdentityRecord = {
|
|
462
|
+
id: ++this.seq,
|
|
463
|
+
userId: input.userId,
|
|
464
|
+
provider: input.provider,
|
|
465
|
+
subject: input.subject,
|
|
466
|
+
label: input.label,
|
|
467
|
+
linkedAt: input.now,
|
|
468
|
+
lastUsedAt: null,
|
|
469
|
+
}
|
|
470
|
+
this.byId.set(record.id, record)
|
|
471
|
+
return record
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async unlink(userId: number, identityId: number): Promise<boolean> {
|
|
475
|
+
const record = this.byId.get(identityId)
|
|
476
|
+
if (record === undefined || record.userId !== userId) return false
|
|
477
|
+
this.byId.delete(identityId)
|
|
478
|
+
return true
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async markUsed(identityId: number, now: Date): Promise<void> {
|
|
482
|
+
const record = this.byId.get(identityId)
|
|
483
|
+
if (record !== undefined) this.byId.set(identityId, { ...record, lastUsedAt: now })
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
class MemoryPasskeys implements PasskeyRepository {
|
|
488
|
+
private readonly byId = new Map<number, PasskeyRecord>()
|
|
489
|
+
private seq = 0
|
|
490
|
+
|
|
491
|
+
async findByCredentialId(credentialId: string): Promise<PasskeyRecord | null> {
|
|
492
|
+
for (const record of this.byId.values()) {
|
|
493
|
+
if (record.credentialId === credentialId) return record
|
|
494
|
+
}
|
|
495
|
+
return null
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async listForUser(userId: number): Promise<readonly PasskeyRecord[]> {
|
|
499
|
+
return [...this.byId.values()]
|
|
500
|
+
.filter((record) => record.userId === userId)
|
|
501
|
+
.sort((a, b) => a.id - b.id)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async create(input: NewPasskey): Promise<PasskeyRecord> {
|
|
505
|
+
const record: PasskeyRecord = {
|
|
506
|
+
id: ++this.seq,
|
|
507
|
+
userId: input.userId,
|
|
508
|
+
credentialId: input.credentialId,
|
|
509
|
+
publicKey: input.publicKey,
|
|
510
|
+
signCount: input.signCount,
|
|
511
|
+
label: input.label,
|
|
512
|
+
transports: input.transports,
|
|
513
|
+
createdAt: input.now,
|
|
514
|
+
lastUsedAt: null,
|
|
515
|
+
}
|
|
516
|
+
this.byId.set(record.id, record)
|
|
517
|
+
return record
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async remove(userId: number, passkeyId: number): Promise<boolean> {
|
|
521
|
+
const record = this.byId.get(passkeyId)
|
|
522
|
+
if (record === undefined || record.userId !== userId) return false
|
|
523
|
+
this.byId.delete(passkeyId)
|
|
524
|
+
return true
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async markUsed(passkeyId: number, signCount: number, now: Date): Promise<void> {
|
|
528
|
+
const record = this.byId.get(passkeyId)
|
|
529
|
+
if (record !== undefined) {
|
|
530
|
+
this.byId.set(passkeyId, { ...record, signCount, lastUsedAt: now })
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
class MemoryTwoFactor implements TwoFactorRepository {
|
|
536
|
+
private readonly byUser = new Map<number, TwoFactorRecord>()
|
|
537
|
+
|
|
538
|
+
async find(userId: number): Promise<TwoFactorRecord | null> {
|
|
539
|
+
return this.byUser.get(userId) ?? null
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async startEnrolment(input: {
|
|
543
|
+
userId: number
|
|
544
|
+
sealedSecret: string
|
|
545
|
+
now: Date
|
|
546
|
+
}): Promise<TwoFactorRecord> {
|
|
547
|
+
const record: TwoFactorRecord = {
|
|
548
|
+
userId: input.userId,
|
|
549
|
+
sealedSecret: input.sealedSecret,
|
|
550
|
+
confirmedAt: null,
|
|
551
|
+
lastStep: null,
|
|
552
|
+
createdAt: input.now,
|
|
553
|
+
}
|
|
554
|
+
this.byUser.set(input.userId, record)
|
|
555
|
+
return record
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async confirm(userId: number, step: number, now: Date): Promise<boolean> {
|
|
559
|
+
const record = this.byUser.get(userId)
|
|
560
|
+
if (record === undefined || record.confirmedAt !== null) return false
|
|
561
|
+
|
|
562
|
+
this.byUser.set(userId, { ...record, confirmedAt: now, lastStep: step })
|
|
563
|
+
return true
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async spendStep(userId: number, step: number): Promise<boolean> {
|
|
567
|
+
const record = this.byUser.get(userId)
|
|
568
|
+
if (record === undefined) return false
|
|
569
|
+
if (record.lastStep !== null && step <= record.lastStep) return false
|
|
570
|
+
|
|
571
|
+
this.byUser.set(userId, { ...record, lastStep: step })
|
|
572
|
+
return true
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async remove(userId: number): Promise<boolean> {
|
|
576
|
+
return this.byUser.delete(userId)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
class MemoryRecoveryCodes implements RecoveryCodeRepository {
|
|
581
|
+
private readonly byUser = new Map<number, Map<string, Date | null>>()
|
|
582
|
+
|
|
583
|
+
async replaceAll(userId: number, hashes: readonly string[], _now: Date): Promise<void> {
|
|
584
|
+
this.byUser.set(userId, new Map(hashes.map((hash) => [hash, null])))
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async spend(userId: number, hash: string, now: Date): Promise<boolean> {
|
|
588
|
+
const codes = this.byUser.get(userId)
|
|
589
|
+
if (codes === undefined) return false
|
|
590
|
+
if (!codes.has(hash) || codes.get(hash) !== null) return false
|
|
591
|
+
|
|
592
|
+
codes.set(hash, now)
|
|
593
|
+
return true
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async countUnused(userId: number): Promise<number> {
|
|
597
|
+
const codes = this.byUser.get(userId)
|
|
598
|
+
if (codes === undefined) return 0
|
|
599
|
+
return [...codes.values()].filter((usedAt) => usedAt === null).length
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async removeAll(userId: number): Promise<void> {
|
|
603
|
+
this.byUser.delete(userId)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
class MemoryAuthEvents implements AuthEventRepository {
|
|
608
|
+
private readonly events: AuthEventRecord[] = []
|
|
609
|
+
private seq = 0
|
|
610
|
+
|
|
611
|
+
async record(event: NewAuthEvent): Promise<void> {
|
|
612
|
+
this.events.push({
|
|
613
|
+
id: ++this.seq,
|
|
614
|
+
userId: event.userId,
|
|
615
|
+
kind: event.kind,
|
|
616
|
+
ipPrefix: event.ipPrefix ?? null,
|
|
617
|
+
userAgent: event.userAgent ?? null,
|
|
618
|
+
detail: event.detail ?? {},
|
|
619
|
+
at: event.at,
|
|
620
|
+
})
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async listForUser(userId: number, limit: number): Promise<readonly AuthEventRecord[]> {
|
|
624
|
+
return this.events
|
|
625
|
+
.filter((event) => event.userId === userId)
|
|
626
|
+
.sort((a, b) => b.id - a.id)
|
|
627
|
+
.slice(0, limit)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async listRecent(input: {
|
|
631
|
+
limit: number
|
|
632
|
+
before?: number | undefined
|
|
633
|
+
kind?: AuthEventRecord['kind'] | undefined
|
|
634
|
+
}): Promise<readonly AuthEventRecord[]> {
|
|
635
|
+
return this.events
|
|
636
|
+
.filter((event) => input.kind === undefined || event.kind === input.kind)
|
|
637
|
+
.filter((event) => input.before === undefined || event.id < input.before)
|
|
638
|
+
.sort((a, b) => b.id - a.id)
|
|
639
|
+
.slice(0, input.limit)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async pruneBefore(cutoff: Date, limit = 5000): Promise<number> {
|
|
643
|
+
const doomed = this.events
|
|
644
|
+
.filter((event) => event.at.getTime() < cutoff.getTime())
|
|
645
|
+
.slice(0, limit)
|
|
646
|
+
|
|
647
|
+
for (const event of doomed) this.events.splice(this.events.indexOf(event), 1)
|
|
648
|
+
return doomed.length
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function createMemoryStore(): AccountStore {
|
|
653
|
+
return {
|
|
654
|
+
accounts: new MemoryAccounts(),
|
|
655
|
+
sessions: new MemorySessions(),
|
|
656
|
+
tokens: new MemoryCredentialTokens(),
|
|
657
|
+
loginAttempts: new MemoryLoginAttempts(),
|
|
658
|
+
remember: new MemoryRememberTokens(),
|
|
659
|
+
identities: new MemoryUserIdentities(),
|
|
660
|
+
passkeys: new MemoryPasskeys(),
|
|
661
|
+
twoFactor: new MemoryTwoFactor(),
|
|
662
|
+
recoveryCodes: new MemoryRecoveryCodes(),
|
|
663
|
+
authEvents: new MemoryAuthEvents(),
|
|
664
|
+
}
|
|
665
|
+
}
|