@meith/accounts 0.34.0 → 0.35.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 +3 -3
- package/src/federation/catalog.ts +5 -0
- package/src/federation/oidc.ts +17 -3
- package/src/memory-repos.ts +31 -5
- package/src/ports.ts +7 -1
- package/src/service.ts +16 -21
- package/src/session-service.ts +5 -11
- package/src/webauthn/service.ts +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/accounts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"hash-wasm": "^4.12.0",
|
|
23
|
-
"@meith/i18n": "0.
|
|
24
|
-
"@meith/subscriptions": "0.
|
|
23
|
+
"@meith/i18n": "0.35.1",
|
|
24
|
+
"@meith/subscriptions": "0.35.1"
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -25,6 +25,7 @@ const GOOGLE_ISSUER = 'https://accounts.google.com'
|
|
|
25
25
|
|
|
26
26
|
export interface ProviderBuildDeps {
|
|
27
27
|
readonly fetcher?: Fetcher
|
|
28
|
+
readonly allowPrivateHosts?: boolean
|
|
28
29
|
readonly clock?: () => Date
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -65,6 +66,9 @@ export function providerFor(
|
|
|
65
66
|
clientSecret: options.google.clientSecret,
|
|
66
67
|
scopes: DEFAULT_OIDC_SCOPES,
|
|
67
68
|
...(deps.fetcher === undefined ? {} : { fetcher: deps.fetcher }),
|
|
69
|
+
...(deps.allowPrivateHosts === undefined
|
|
70
|
+
? {}
|
|
71
|
+
: { allowPrivateHosts: deps.allowPrivateHosts }),
|
|
68
72
|
...(deps.clock === undefined ? {} : { clock: deps.clock }),
|
|
69
73
|
})
|
|
70
74
|
}
|
|
@@ -79,6 +83,7 @@ export function providerFor(
|
|
|
79
83
|
clientSecret: options.oidc.clientSecret,
|
|
80
84
|
scopes: parseScopes(options.oidc.scopes),
|
|
81
85
|
...(deps.fetcher === undefined ? {} : { fetcher: deps.fetcher }),
|
|
86
|
+
...(deps.allowPrivateHosts === undefined ? {} : { allowPrivateHosts: deps.allowPrivateHosts }),
|
|
82
87
|
...(deps.clock === undefined ? {} : { clock: deps.clock }),
|
|
83
88
|
})
|
|
84
89
|
}
|
package/src/federation/oidc.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConfigurationError, ValidationError } from '@meith/core'
|
|
2
|
+
import { assertAllowedUrl } from '@meith/core/outbound'
|
|
2
3
|
import { msg } from '@meith/i18n'
|
|
3
4
|
|
|
4
5
|
import { fetchJson, readBoolean, readString } from './http'
|
|
@@ -21,9 +22,18 @@ export interface OidcProviderConfig {
|
|
|
21
22
|
readonly clientSecret: string
|
|
22
23
|
readonly scopes: readonly string[]
|
|
23
24
|
readonly fetcher?: Fetcher
|
|
25
|
+
readonly allowPrivateHosts?: boolean
|
|
24
26
|
readonly clock?: () => Date
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
function guardedFetcher(allowPrivateHosts: boolean): Fetcher {
|
|
30
|
+
return async (input, init) => {
|
|
31
|
+
const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
|
32
|
+
assertAllowedUrl(target, { allowPrivateHosts })
|
|
33
|
+
return fetch(input, { ...init, redirect: 'error' })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
interface Discovery {
|
|
28
38
|
readonly issuer: string
|
|
29
39
|
readonly authorizationEndpoint: string
|
|
@@ -35,7 +45,7 @@ interface Discovery {
|
|
|
35
45
|
export const DEFAULT_OIDC_SCOPES = ['openid', 'email', 'profile'] as const
|
|
36
46
|
|
|
37
47
|
export function oidcProvider(config: OidcProviderConfig): IdentityProvider {
|
|
38
|
-
const fetcher = config.fetcher ??
|
|
48
|
+
const fetcher = config.fetcher ?? guardedFetcher(config.allowPrivateHosts ?? false)
|
|
39
49
|
const clock = config.clock ?? (() => new Date())
|
|
40
50
|
const issuer = config.issuer.replace(/\/$/, '')
|
|
41
51
|
|
|
@@ -135,10 +145,14 @@ function profileFrom(claims: IdTokenClaims, userinfo: IdTokenClaims): ProviderPr
|
|
|
135
145
|
throw new ValidationError(msg('error.accounts.identity-provider-sent-account-with'))
|
|
136
146
|
}
|
|
137
147
|
|
|
148
|
+
const emailFromToken = stringClaim(claims, 'email')
|
|
149
|
+
const emailSource = emailFromToken !== null ? claims : userinfo
|
|
150
|
+
const email = emailFromToken ?? stringClaim(userinfo, 'email')
|
|
151
|
+
|
|
138
152
|
return {
|
|
139
153
|
subject,
|
|
140
|
-
email
|
|
141
|
-
emailVerified:
|
|
154
|
+
email,
|
|
155
|
+
emailVerified: email !== null && readBoolean(emailSource, 'email_verified'),
|
|
142
156
|
username:
|
|
143
157
|
stringClaim(merged, 'preferred_username') ??
|
|
144
158
|
stringClaim(merged, 'nickname') ??
|
package/src/memory-repos.ts
CHANGED
|
@@ -414,19 +414,44 @@ class MemoryCredentialTokens implements CredentialTokenRepository {
|
|
|
414
414
|
}
|
|
415
415
|
|
|
416
416
|
interface Attempt {
|
|
417
|
+
id: number
|
|
417
418
|
succeeded: boolean
|
|
418
419
|
at: Date
|
|
419
420
|
}
|
|
420
421
|
|
|
421
422
|
class MemoryLoginAttempts implements LoginAttemptRepository {
|
|
422
423
|
private readonly buckets = new Map<string, Attempt[]>()
|
|
424
|
+
private nextId = 1
|
|
423
425
|
|
|
424
426
|
async record(bucket: string, succeeded: boolean, at: Date): Promise<void> {
|
|
425
427
|
const list = this.buckets.get(bucket) ?? []
|
|
426
|
-
list.push({ succeeded, at })
|
|
428
|
+
list.push({ id: this.nextId++, succeeded, at })
|
|
427
429
|
this.buckets.set(bucket, list)
|
|
428
430
|
}
|
|
429
431
|
|
|
432
|
+
async recordFailureAndCount(
|
|
433
|
+
bucket: string,
|
|
434
|
+
since: Date,
|
|
435
|
+
at: Date,
|
|
436
|
+
): Promise<{ readonly id: number; readonly count: number }> {
|
|
437
|
+
const list = this.buckets.get(bucket) ?? []
|
|
438
|
+
const id = this.nextId++
|
|
439
|
+
list.push({ id, succeeded: false, at })
|
|
440
|
+
this.buckets.set(bucket, list)
|
|
441
|
+
const count = list.filter((a) => !a.succeeded && a.at.getTime() > since.getTime()).length
|
|
442
|
+
return { id, count }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async removeAttempt(id: number): Promise<void> {
|
|
446
|
+
for (const list of this.buckets.values()) {
|
|
447
|
+
const index = list.findIndex((a) => a.id === id)
|
|
448
|
+
if (index !== -1) {
|
|
449
|
+
list.splice(index, 1)
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
430
455
|
async countFailuresSince(bucket: string, since: Date): Promise<number> {
|
|
431
456
|
const list = this.buckets.get(bucket) ?? []
|
|
432
457
|
return list.filter((a) => !a.succeeded && a.at.getTime() >= since.getTime()).length
|
|
@@ -524,11 +549,12 @@ class MemoryPasskeys implements PasskeyRepository {
|
|
|
524
549
|
return true
|
|
525
550
|
}
|
|
526
551
|
|
|
527
|
-
async markUsed(passkeyId: number, signCount: number, now: Date): Promise<
|
|
552
|
+
async markUsed(passkeyId: number, signCount: number, now: Date): Promise<boolean> {
|
|
528
553
|
const record = this.byId.get(passkeyId)
|
|
529
|
-
if (record
|
|
530
|
-
|
|
531
|
-
}
|
|
554
|
+
if (record === undefined) return false
|
|
555
|
+
if (signCount !== 0 && signCount <= record.signCount) return false
|
|
556
|
+
this.byId.set(passkeyId, { ...record, signCount, lastUsedAt: now })
|
|
557
|
+
return true
|
|
532
558
|
}
|
|
533
559
|
}
|
|
534
560
|
|
package/src/ports.ts
CHANGED
|
@@ -230,7 +230,7 @@ export interface PasskeyRepository {
|
|
|
230
230
|
listForUser(userId: number): Promise<readonly PasskeyRecord[]>
|
|
231
231
|
create(input: NewPasskey): Promise<PasskeyRecord>
|
|
232
232
|
remove(userId: number, passkeyId: number): Promise<boolean>
|
|
233
|
-
markUsed(passkeyId: number, signCount: number, now: Date): Promise<
|
|
233
|
+
markUsed(passkeyId: number, signCount: number, now: Date): Promise<boolean>
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
export interface TwoFactorRecord {
|
|
@@ -316,6 +316,12 @@ export interface AuthEventRepository {
|
|
|
316
316
|
|
|
317
317
|
export interface LoginAttemptRepository {
|
|
318
318
|
record(bucket: string, succeeded: boolean, at: Date): Promise<void>
|
|
319
|
+
recordFailureAndCount(
|
|
320
|
+
bucket: string,
|
|
321
|
+
since: Date,
|
|
322
|
+
at: Date,
|
|
323
|
+
): Promise<{ readonly id: number; readonly count: number }>
|
|
324
|
+
removeAttempt(id: number): Promise<void>
|
|
319
325
|
countFailuresSince(bucket: string, since: Date): Promise<number>
|
|
320
326
|
clear(bucket: string): Promise<void>
|
|
321
327
|
}
|
package/src/service.ts
CHANGED
|
@@ -338,11 +338,18 @@ export class IdentityService {
|
|
|
338
338
|
await this.assertNotFiltered({ ip: context.ip })
|
|
339
339
|
|
|
340
340
|
const since = new Date(at.getTime() - this.config.lockoutMinutes * 60_000)
|
|
341
|
+
const spent: { readonly key: string; readonly id: number; readonly clearOnSuccess: boolean }[] =
|
|
342
|
+
[]
|
|
341
343
|
for (const counter of counters) {
|
|
342
344
|
const max = counter.max ?? this.config.maxLoginAttempts
|
|
343
345
|
if (max <= 0) continue
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
+
const { id, count } = await this.store.loginAttempts.recordFailureAndCount(
|
|
347
|
+
counter.key,
|
|
348
|
+
since,
|
|
349
|
+
at,
|
|
350
|
+
)
|
|
351
|
+
spent.push({ key: counter.key, id, clearOnSuccess: counter.clearOnSuccess !== false })
|
|
352
|
+
if (count > max) {
|
|
346
353
|
throw new ForbiddenError(msg('error.accounts.too-many-failed-attempts-please'))
|
|
347
354
|
}
|
|
348
355
|
}
|
|
@@ -355,29 +362,20 @@ export class IdentityService {
|
|
|
355
362
|
const encoded = account?.passwordHash ?? (await dummyHash())
|
|
356
363
|
const ok = await verifyPassword(password, encoded)
|
|
357
364
|
|
|
358
|
-
const recordFailure = async (): Promise<void> => {
|
|
359
|
-
for (const counter of counters) {
|
|
360
|
-
await this.store.loginAttempts.record(counter.key, false, at)
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
365
|
if (!account || !ok || account.passwordHash === null) {
|
|
365
|
-
await recordFailure()
|
|
366
366
|
throw new ValidationError(msg('error.accounts.incorrect-username-password'))
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
const refusal = await this.signInRefusal(account)
|
|
370
370
|
if (refusal !== null) {
|
|
371
|
-
await recordFailure()
|
|
372
371
|
throw new ForbiddenError(refusal)
|
|
373
372
|
}
|
|
374
373
|
|
|
375
374
|
await this.assertNotFiltered({ username: account.username, email: account.email })
|
|
376
375
|
|
|
377
|
-
for (const
|
|
378
|
-
if (
|
|
379
|
-
await this.store.loginAttempts.
|
|
380
|
-
await this.store.loginAttempts.clear(counter.key)
|
|
376
|
+
for (const entry of spent) {
|
|
377
|
+
if (entry.clearOnSuccess) await this.store.loginAttempts.clear(entry.key)
|
|
378
|
+
else await this.store.loginAttempts.removeAttempt(entry.id)
|
|
381
379
|
}
|
|
382
380
|
|
|
383
381
|
if (needsRehash(encoded)) {
|
|
@@ -448,25 +446,22 @@ export class IdentityService {
|
|
|
448
446
|
await this.store.tokens.revokeAllForUser(userId, 'second_factor')
|
|
449
447
|
}
|
|
450
448
|
|
|
451
|
-
async
|
|
449
|
+
async spendSecondFactorAttempt(userId: number): Promise<void> {
|
|
452
450
|
const max = this.config.maxLoginAttempts
|
|
453
451
|
if (max <= 0) return
|
|
454
452
|
|
|
455
453
|
const since = new Date(this.now().getTime() - this.config.lockoutMinutes * 60_000)
|
|
456
|
-
const
|
|
454
|
+
const { count } = await this.store.loginAttempts.recordFailureAndCount(
|
|
457
455
|
secondFactorBucket(userId),
|
|
458
456
|
since,
|
|
457
|
+
this.now(),
|
|
459
458
|
)
|
|
460
459
|
|
|
461
|
-
if (
|
|
460
|
+
if (count > max) {
|
|
462
461
|
throw new ForbiddenError(msg('error.accounts.too-many-wrong-codes-please'))
|
|
463
462
|
}
|
|
464
463
|
}
|
|
465
464
|
|
|
466
|
-
async recordSecondFactorFailure(userId: number): Promise<void> {
|
|
467
|
-
await this.store.loginAttempts.record(secondFactorBucket(userId), false, this.now())
|
|
468
|
-
}
|
|
469
|
-
|
|
470
465
|
async clearSecondFactorFailures(userId: number): Promise<void> {
|
|
471
466
|
await this.store.loginAttempts.clear(secondFactorBucket(userId))
|
|
472
467
|
}
|
package/src/session-service.ts
CHANGED
|
@@ -49,25 +49,19 @@ export class SessionService {
|
|
|
49
49
|
return this.mintSession(userId, this.now(), context)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
async
|
|
52
|
+
async issueRemember(
|
|
53
|
+
userId: number,
|
|
54
|
+
): Promise<{ readonly rememberToken: string; readonly rememberExpiresAt: Date }> {
|
|
53
55
|
const at = this.now()
|
|
54
|
-
const familyId = generateToken()
|
|
55
56
|
const rememberToken = generateToken()
|
|
56
57
|
const rememberExpiresAt = new Date(at.getTime() + this.rememberDays * DAY_MS)
|
|
57
58
|
await this.store.remember.issue({
|
|
58
59
|
tokenHash: await hashToken(rememberToken),
|
|
59
|
-
familyId,
|
|
60
|
+
familyId: generateToken(),
|
|
60
61
|
userId,
|
|
61
62
|
expiresAt: rememberExpiresAt,
|
|
62
63
|
})
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
userId,
|
|
66
|
-
sessionToken: session.token,
|
|
67
|
-
sessionExpiresAt: session.expiresAt,
|
|
68
|
-
rememberToken,
|
|
69
|
-
rememberExpiresAt,
|
|
70
|
-
}
|
|
64
|
+
return { rememberToken, rememberExpiresAt }
|
|
71
65
|
}
|
|
72
66
|
|
|
73
67
|
async resume(rememberToken: string, context: RequestContext = {}): Promise<ResumeOutcome> {
|
package/src/webauthn/service.ts
CHANGED
|
@@ -194,8 +194,11 @@ export class PasskeyService {
|
|
|
194
194
|
throw new ForbiddenError(msg('error.accounts.account-behind-passkey-longer-exists'))
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
if (!(await this.passkeys.markUsed(passkey.id, verified.signCount, this.now()))) {
|
|
198
|
+
throw new ForbiddenError(msg('error.accounts.passkey-replayed-counter-board-already'))
|
|
199
|
+
}
|
|
200
|
+
|
|
197
201
|
const login = await this.identity.startSessionFor(account, input.context ?? {})
|
|
198
|
-
await this.passkeys.markUsed(passkey.id, verified.signCount, this.now())
|
|
199
202
|
|
|
200
203
|
return { account, login }
|
|
201
204
|
}
|
|
@@ -218,8 +221,7 @@ export class PasskeyService {
|
|
|
218
221
|
storedSignCount: passkey.signCount,
|
|
219
222
|
})
|
|
220
223
|
|
|
221
|
-
|
|
222
|
-
return true
|
|
224
|
+
return this.passkeys.markUsed(passkey.id, verified.signCount, this.now())
|
|
223
225
|
}
|
|
224
226
|
|
|
225
227
|
async remove(input: {
|