@byline/admin 5.0.0 → 5.1.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.
Files changed (34) hide show
  1. package/dist/fields/field-services-context.d.ts +1 -1
  2. package/dist/forms/available-locales-widget.d.ts +1 -1
  3. package/dist/forms/document-actions.d.ts +1 -1
  4. package/dist/forms/form-renderer.d.ts +3 -3
  5. package/dist/forms/form-status-display.d.ts +1 -1
  6. package/dist/forms/upload-executor.d.ts +2 -2
  7. package/dist/modules/admin-account/components/change-password.js +18 -0
  8. package/dist/modules/admin-account/service.d.ts +2 -6
  9. package/dist/modules/admin-account/service.js +2 -1
  10. package/dist/modules/admin-users/repository.d.ts +10 -1
  11. package/dist/modules/auth/index.d.ts +3 -1
  12. package/dist/modules/auth/index.js +1 -0
  13. package/dist/modules/auth/jwt-session-provider.d.ts +8 -2
  14. package/dist/modules/auth/jwt-session-provider.js +222 -70
  15. package/dist/modules/auth/login-sessions-repository.d.ts +23 -0
  16. package/dist/modules/auth/login-sessions-repository.js +1 -0
  17. package/dist/modules/auth/refresh-tokens-repository.d.ts +12 -9
  18. package/dist/modules/auth/resolve-actor.d.ts +3 -0
  19. package/dist/modules/auth/resolve-actor.js +5 -2
  20. package/dist/modules/auth/sign-in-rate-limiter.d.ts +48 -0
  21. package/dist/modules/auth/sign-in-rate-limiter.js +229 -0
  22. package/dist/store.d.ts +15 -2
  23. package/package.json +17 -17
  24. package/src/modules/admin-account/components/change-password.test.tsx +72 -0
  25. package/src/modules/admin-account/components/change-password.tsx +15 -4
  26. package/src/modules/admin-account/service.ts +8 -7
  27. package/src/modules/admin-users/repository.ts +10 -1
  28. package/src/modules/auth/index.ts +13 -1
  29. package/src/modules/auth/jwt-session-provider.ts +276 -91
  30. package/src/modules/auth/login-sessions-repository.ts +25 -0
  31. package/src/modules/auth/refresh-tokens-repository.ts +12 -9
  32. package/src/modules/auth/resolve-actor.ts +10 -1
  33. package/src/modules/auth/sign-in-rate-limiter.ts +240 -0
  34. package/src/store.ts +22 -2
@@ -0,0 +1,240 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { createHmac } from 'node:crypto'
10
+ import { isIP } from 'node:net'
11
+
12
+ import type { PasswordSignInLimiter } from '@byline/auth'
13
+ import type { RecurringTaskDefinition } from '@byline/core'
14
+
15
+ export interface SignInRateLimitStore {
16
+ /** Atomically increment, saturating at limit + 1; true only for the first limit calls. */
17
+ consume(key: string, limit: number, expiresAt: Date): Promise<boolean>
18
+ /** Remove at most 100 expired counters and return the number removed. */
19
+ purgeExpired(before: Date): Promise<number>
20
+ }
21
+
22
+ export interface SignInRateLimitPolicy {
23
+ account: { limit: number; windowSeconds: number }
24
+ ip: { limit: number; windowSeconds: number }
25
+ }
26
+
27
+ export interface SignInSecurityEvent {
28
+ type: 'admitted' | 'denied' | 'capacity' | 'store-error' | 'cleanup-error' | 'success' | 'failure'
29
+ scope?: 'account' | 'ip'
30
+ /** Stable, pseudonymous digests for cross-network correlation. Never passwords or raw IPs. */
31
+ account?: string
32
+ network?: string
33
+ }
34
+
35
+ export interface SignInLimiterOptions {
36
+ onEvent?: (event: SignInSecurityEvent) => void
37
+ slots?: number
38
+ maxQueue?: number
39
+ queueTimeoutMs?: number
40
+ /** Also bounds counter creation when the provider or a denial completes very quickly. */
41
+ minimumSlotMs?: number
42
+ }
43
+
44
+ const DEFAULT_POLICY: SignInRateLimitPolicy = {
45
+ account: { limit: 10, windowSeconds: 15 * 60 },
46
+ ip: { limit: 60, windowSeconds: 60 },
47
+ }
48
+ const CLEANUP_TASK_NAME = 'auth.sign-in-counters.cleanup'
49
+
50
+ /** IPv4 uses one address; IPv6 uses its /64. Mapped IPv4 shares its native IPv4 bucket. */
51
+ function signInNetwork(ip: string): string {
52
+ const version = isIP(ip)
53
+ if (version === 4) return ip
54
+ if (version !== 6 || ip.includes('%')) throw new Error('Invalid client address')
55
+ const canonical = new URL(`http://[${ip}]/`).hostname.slice(1, -1)
56
+ const mapped = /^::ffff:([0-9a-f]+):([0-9a-f]+)$/.exec(canonical)
57
+ if (mapped) {
58
+ const high = Number.parseInt(mapped[1]!, 16)
59
+ const low = Number.parseInt(mapped[2]!, 16)
60
+ return [high >>> 8, high & 255, low >>> 8, low & 255].join('.')
61
+ }
62
+ const [left, right] = canonical.split('::')
63
+ const head = left ? left.split(':') : []
64
+ const tail = right ? right.split(':') : []
65
+ const words =
66
+ right === undefined
67
+ ? head
68
+ : [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail]
69
+ return `${words.slice(0, 4).join(':')}::/64`
70
+ }
71
+
72
+ /**
73
+ * Instantiate once per process. Acquire before consume and release in finally after verification.
74
+ * Network admission precedes account-plus-network admission. There is no account-wide lockout.
75
+ */
76
+ export function createPasswordSignInLimiter(
77
+ store: SignInRateLimitStore,
78
+ secret: string | Uint8Array,
79
+ policy: SignInRateLimitPolicy = DEFAULT_POLICY,
80
+ now: () => number = Date.now,
81
+ options: SignInLimiterOptions = {}
82
+ ): PasswordSignInLimiter & { cleanupTask: RecurringTaskDefinition; dispose(): void } {
83
+ const secretBytes = typeof secret === 'string' ? new TextEncoder().encode(secret) : secret
84
+ if (secretBytes.byteLength < 32)
85
+ throw new Error('Sign-in HMAC secret must contain at least 32 bytes')
86
+ // Domain separation permits reuse of the installation JWT secret without reusing its signing key.
87
+ const key = createHmac('sha256', secretBytes).update('byline:password-sign-in:v1').digest()
88
+ const digest = (value: unknown) =>
89
+ createHmac('sha256', key).update(JSON.stringify(value)).digest('hex')
90
+ const rules = structuredClone(policy)
91
+ for (const rule of Object.values(rules)) {
92
+ if (
93
+ !Number.isSafeInteger(rule.limit) ||
94
+ rule.limit < 1 ||
95
+ rule.limit > 1_000_000 ||
96
+ !Number.isSafeInteger(rule.windowSeconds) ||
97
+ rule.windowSeconds < 1 ||
98
+ rule.windowSeconds > 86400
99
+ )
100
+ throw new Error('Invalid password sign-in rate limit policy')
101
+ }
102
+ const slots = options.slots ?? 1
103
+ const maxQueue = options.maxQueue ?? 4
104
+ const queueTimeoutMs = options.queueTimeoutMs ?? 250
105
+ const minimumSlotMs = options.minimumSlotMs ?? 100
106
+ for (const value of [slots, queueTimeoutMs, minimumSlotMs])
107
+ if (!Number.isSafeInteger(value) || value < 1 || value > 60_000)
108
+ throw new Error('Invalid sign-in capacity policy')
109
+ if (!Number.isSafeInteger(maxQueue) || maxQueue < 0 || maxQueue > 1000)
110
+ throw new Error('Invalid sign-in queue size')
111
+ const diagnostics = new Map<string, { last: number; count: number }>()
112
+ const emit = (event: SignInSecurityEvent) => {
113
+ // Observability must not change admission or strand a capacity lease.
114
+ try {
115
+ if (options.onEvent) options.onEvent(event)
116
+ else if (event.type !== 'admitted' && event.type !== 'success') {
117
+ const entry = diagnostics.get(event.type) ?? { last: -Infinity, count: 0 }
118
+ entry.count++
119
+ if (performance.now() - entry.last >= 10_000) {
120
+ console.warn('[byline:password-sign-in]', { ...event, count: entry.count })
121
+ entry.last = performance.now()
122
+ entry.count = 0
123
+ }
124
+ diagnostics.set(event.type, entry)
125
+ }
126
+ } catch {
127
+ /* Host telemetry is best effort. */
128
+ }
129
+ }
130
+ const identifiers = ({ email, ip }: { email: string; ip: string }) => ({
131
+ account: digest(['account', email.trim().toLowerCase()]),
132
+ network: digest(['network', signInNetwork(ip)]),
133
+ })
134
+ let active = 0
135
+ let disposed = false
136
+ const queue: Array<{ grant: () => void; cancel: () => void }> = []
137
+ const lease = (): (() => void) => {
138
+ active++
139
+ const started = performance.now()
140
+ let released = false
141
+ return () => {
142
+ if (released) return
143
+ released = true
144
+ // Keep the slot occupied for at least this interval, including fast denials.
145
+ setTimeout(
146
+ () => {
147
+ active--
148
+ queue.shift()?.grant()
149
+ },
150
+ Math.max(0, minimumSlotMs - (performance.now() - started))
151
+ ).unref()
152
+ }
153
+ }
154
+ const cleanupTask: RecurringTaskDefinition = {
155
+ name: CLEANUP_TASK_NAME,
156
+ intervalMs: 60_000,
157
+ leaseMs: 60_000,
158
+ async run(context) {
159
+ const cutoff = new Date(now() - 300_000)
160
+ try {
161
+ for (let batch = 0; batch < 32; batch++) {
162
+ context.signal.throwIfAborted()
163
+ await context.heartbeat()
164
+ if ((await store.purgeExpired(cutoff)) < 100) return { workRemaining: false }
165
+ }
166
+ return { workRemaining: true }
167
+ } catch (error) {
168
+ emit({ type: 'cleanup-error' })
169
+ // Let the scheduler record the failure and apply its retry/backoff policy.
170
+ throw error
171
+ }
172
+ },
173
+ }
174
+ return {
175
+ requiredCleanupTask: CLEANUP_TASK_NAME,
176
+ cleanupTask,
177
+ dispose() {
178
+ disposed = true
179
+ for (const waiter of queue.splice(0)) waiter.cancel()
180
+ },
181
+ async acquire() {
182
+ if (disposed) throw new Error('Sign-in limiter disposed')
183
+ if (active < slots) return lease()
184
+ if (queue.length >= maxQueue) {
185
+ emit({ type: 'capacity' })
186
+ return null
187
+ }
188
+ return new Promise<(() => void) | null>((resolve) => {
189
+ const waiter = {
190
+ grant: () => {
191
+ clearTimeout(timeout)
192
+ resolve(lease())
193
+ },
194
+ cancel: () => {
195
+ clearTimeout(timeout)
196
+ emit({ type: 'capacity' })
197
+ resolve(null)
198
+ },
199
+ }
200
+ const timeout = setTimeout(() => {
201
+ queue.splice(queue.indexOf(waiter), 1)
202
+ waiter.cancel()
203
+ }, queueTimeoutMs)
204
+ queue.push(waiter)
205
+ })
206
+ },
207
+ recordResult(input, outcome) {
208
+ emit({ type: outcome, ...identifiers(input) })
209
+ },
210
+ async consume(input) {
211
+ const time = now()
212
+ const network = signInNetwork(input.ip)
213
+ const ids = identifiers(input)
214
+ try {
215
+ // Load-proportional cleanup stays on the request path; the scheduler drains idle residue.
216
+ await store.purgeExpired(new Date(time - 300_000))
217
+ for (const [scope, identity] of [
218
+ ['ip', network],
219
+ ['account', [input.email.trim().toLowerCase(), network]],
220
+ ] as const) {
221
+ const rule = rules[scope]
222
+ const windowMs = rule.windowSeconds * 1000
223
+ const end = (Math.floor(time / windowMs) + 1) * windowMs
224
+ if (!(await store.consume(digest([scope, identity, end]), rule.limit, new Date(end)))) {
225
+ emit({ type: 'denied', scope, ...ids })
226
+ return {
227
+ allowed: false,
228
+ retryAfterSeconds: Math.max(1, Math.ceil((end - time) / 1000)),
229
+ }
230
+ }
231
+ }
232
+ emit({ type: 'admitted', ...ids })
233
+ return { allowed: true, retryAfterSeconds: 0 }
234
+ } catch (error) {
235
+ emit({ type: 'store-error', ...ids })
236
+ throw error
237
+ }
238
+ },
239
+ }
240
+ }
package/src/store.ts CHANGED
@@ -9,8 +9,13 @@
9
9
  import type { AdminPermissionsRepository } from './modules/admin-permissions/repository.js'
10
10
  import type { AdminPreferencesRepository } from './modules/admin-preferences/repository.js'
11
11
  import type { AdminRolesRepository } from './modules/admin-roles/repository.js'
12
- import type { AdminUsersRepository } from './modules/admin-users/repository.js'
12
+ import type {
13
+ AdminUsersRepository,
14
+ AdminUserWithPasswordRow,
15
+ } from './modules/admin-users/repository.js'
16
+ import type { LoginSessionsRepository } from './modules/auth/login-sessions-repository.js'
13
17
  import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repository.js'
18
+ import type { SignInRateLimitStore } from './modules/auth/sign-in-rate-limiter.js'
14
19
 
15
20
  /**
16
21
  * The bundle of repositories that `@byline/admin` needs from the DB
@@ -21,11 +26,26 @@ import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repo
21
26
  * `JwtSessionProvider`, to `seedSuperAdmin`, and (later) to admin-user
22
27
  * and admin-role commands.
23
28
  *
24
- * Keeping the five repositories together as a single argument avoids
29
+ * Keeping the repositories together as a single argument avoids
25
30
  * exploding constructor signatures and makes "needs admin DB access" a
26
31
  * single, recognisable type.
27
32
  */
33
+
28
34
  export interface AdminStore {
35
+ /**
36
+ * Serialize native issuance/revocation against account mutations. Lock the
37
+ * account row first, then operate on refresh rows through the scoped store.
38
+ * The callback and all its writes commit together or roll back together.
39
+ * Never retain the scoped repositories beyond the callback. No automatic retries.
40
+ */
41
+ withSessionLock<T>(
42
+ adminUserId: string,
43
+ work: (store: AdminStore, user: AdminUserWithPasswordRow | null) => Promise<T>
44
+ ): Promise<T>
45
+ /** Lock distinct account IDs in sorted order in one transaction for account-switch sign-in. */
46
+ withSessionLocks<T>(adminUserIds: string[], work: (store: AdminStore) => Promise<T>): Promise<T>
47
+ loginSessions: LoginSessionsRepository
48
+ signInRateLimits: SignInRateLimitStore
29
49
  adminUsers: AdminUsersRepository
30
50
  adminRoles: AdminRolesRepository
31
51
  adminPermissions: AdminPermissionsRepository