@byline/admin 6.7.2 → 6.8.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/dist/modules/admin-account/components/change-password.js +1 -1
- package/dist/modules/admin-account/components/container.js +18 -3
- package/dist/modules/admin-account/components/container.module.js +2 -0
- package/dist/modules/admin-account/components/container_module.css +7 -0
- package/dist/modules/admin-account/components/preferences.js +1 -1
- package/dist/modules/admin-account/components/update.js +1 -1
- package/dist/modules/admin-roles/components/create.js +1 -1
- package/dist/modules/admin-roles/components/permissions.js +1 -1
- package/dist/modules/admin-roles/components/update.js +1 -1
- package/dist/modules/admin-users/abilities.d.ts +1 -0
- package/dist/modules/admin-users/abilities.js +8 -1
- package/dist/modules/admin-users/components/create.js +1 -1
- package/dist/modules/admin-users/components/roles.js +1 -1
- package/dist/modules/admin-users/components/set-password.js +1 -1
- package/dist/modules/admin-users/components/update.js +1 -1
- package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
- package/dist/modules/auth/auth-maintenance-repository.js +1 -0
- package/dist/modules/auth/auth-maintenance.d.ts +11 -0
- package/dist/modules/auth/auth-maintenance.js +39 -0
- package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
- package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
- package/dist/modules/auth/components/passkey-browser.js +118 -0
- package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
- package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
- package/dist/modules/auth/components/passkey-manager.js +406 -0
- package/dist/modules/auth/components/passkey-manager.module.js +22 -0
- package/dist/modules/auth/components/passkey-manager_module.css +104 -0
- package/dist/modules/auth/components/sign-in-form.js +62 -3
- package/dist/modules/auth/components/sign-in-form.module.js +5 -1
- package/dist/modules/auth/components/sign-in-form_module.css +40 -0
- package/dist/modules/auth/index.d.ts +5 -0
- package/dist/modules/auth/index.js +3 -0
- package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
- package/dist/modules/auth/jwt-session-provider.js +135 -94
- package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
- package/dist/modules/auth/native-passkey-provider.js +618 -0
- package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
- package/dist/modules/auth/passkey-rate-limiter.js +60 -0
- package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
- package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
- package/dist/modules/auth/passkey-storage-validation.js +59 -0
- package/dist/modules/auth/passkeys-repository.d.ts +96 -0
- package/dist/modules/auth/passkeys-repository.js +1 -0
- package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
- package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
- package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
- package/dist/services/admin-services-context.d.ts +1 -1
- package/dist/services/admin-services-types.d.ts +60 -0
- package/dist/store.d.ts +4 -0
- package/package.json +16 -9
- package/src/modules/admin-account/components/change-password.tsx +1 -1
- package/src/modules/admin-account/components/container.module.css +9 -0
- package/src/modules/admin-account/components/container.tsx +12 -3
- package/src/modules/admin-account/components/preferences.tsx +1 -1
- package/src/modules/admin-account/components/update.tsx +1 -1
- package/src/modules/admin-roles/components/create.tsx +1 -1
- package/src/modules/admin-roles/components/permissions.tsx +1 -1
- package/src/modules/admin-roles/components/update.tsx +1 -1
- package/src/modules/admin-users/abilities.ts +7 -0
- package/src/modules/admin-users/components/create.tsx +1 -1
- package/src/modules/admin-users/components/roles.tsx +1 -1
- package/src/modules/admin-users/components/set-password.tsx +1 -1
- package/src/modules/admin-users/components/update.tsx +1 -1
- package/src/modules/auth/auth-maintenance-repository.ts +26 -0
- package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
- package/src/modules/auth/auth-maintenance.ts +62 -0
- package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
- package/src/modules/auth/components/passkey-browser.ts +139 -0
- package/src/modules/auth/components/passkey-manager.module.css +100 -0
- package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
- package/src/modules/auth/components/passkey-manager.tsx +386 -0
- package/src/modules/auth/components/sign-in-form.module.css +37 -0
- package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
- package/src/modules/auth/components/sign-in-form.tsx +65 -4
- package/src/modules/auth/index.ts +15 -0
- package/src/modules/auth/jwt-session-provider.ts +116 -44
- package/src/modules/auth/native-passkey-provider.ts +710 -0
- package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
- package/src/modules/auth/passkey-rate-limiter.ts +76 -0
- package/src/modules/auth/passkey-storage-validation.ts +108 -0
- package/src/modules/auth/passkeys-repository.ts +97 -0
- package/src/modules/auth/refresh-tokens-repository.ts +1 -1
- package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
- package/src/services/admin-services-context.tsx +2 -0
- package/src/services/admin-services-types.ts +60 -0
- package/src/store.ts +4 -0
|
@@ -0,0 +1,83 @@
|
|
|
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 { afterEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import { createPasskeyRequestLimiter } from './passkey-rate-limiter.js'
|
|
12
|
+
import { createPasswordSignInLimiter, type SignInRateLimitStore } from './sign-in-rate-limiter.js'
|
|
13
|
+
|
|
14
|
+
const secret = 'passkey-counter-secret-at-least-thirty-two-bytes'
|
|
15
|
+
function fixture() {
|
|
16
|
+
const counts = new Map<string, number>()
|
|
17
|
+
const store: SignInRateLimitStore = {
|
|
18
|
+
consume: vi.fn(async (key, limit) => {
|
|
19
|
+
const n = (counts.get(key) ?? 0) + 1
|
|
20
|
+
counts.set(key, n)
|
|
21
|
+
return n <= limit
|
|
22
|
+
}),
|
|
23
|
+
purgeExpired: vi.fn(async () => 0),
|
|
24
|
+
}
|
|
25
|
+
return { store, limiter: createPasskeyRequestLimiter(store, secret, () => 0) }
|
|
26
|
+
}
|
|
27
|
+
afterEach(() => vi.useRealTimers())
|
|
28
|
+
describe('passkey request limiter', () => {
|
|
29
|
+
it('uses four slots without queueing and retains each slot for 100ms including quick denials', async () => {
|
|
30
|
+
vi.useFakeTimers()
|
|
31
|
+
const { limiter } = fixture()
|
|
32
|
+
const leases = await Promise.all(Array.from({ length: 4 }, () => limiter.acquire()))
|
|
33
|
+
expect(leases.every(Boolean)).toBe(true)
|
|
34
|
+
expect(await limiter.acquire()).toBeNull()
|
|
35
|
+
leases[0]?.()
|
|
36
|
+
leases[0]?.()
|
|
37
|
+
await vi.advanceTimersByTimeAsync(99)
|
|
38
|
+
expect(await limiter.acquire()).toBeNull()
|
|
39
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
40
|
+
expect(await limiter.acquire()).toBeTypeOf('function')
|
|
41
|
+
expect(await limiter.acquire()).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
it('shares a 60/minute network bucket across instances with IPv4-mapped equivalence', async () => {
|
|
44
|
+
const { store, limiter } = fixture()
|
|
45
|
+
const another = createPasskeyRequestLimiter(store, secret, () => 0)
|
|
46
|
+
for (let i = 0; i < 60; i++)
|
|
47
|
+
expect((await limiter.consume({ ip: '192.0.2.1' })).allowed).toBe(true)
|
|
48
|
+
expect(await another.consume({ ip: '::ffff:192.0.2.1' })).toEqual({
|
|
49
|
+
allowed: false,
|
|
50
|
+
retryAfterSeconds: 60,
|
|
51
|
+
})
|
|
52
|
+
expect((await another.consume({ ip: '192.0.2.2' })).allowed).toBe(true)
|
|
53
|
+
const key = vi.mocked(store.consume).mock.calls[0]?.[0]
|
|
54
|
+
expect(key).toMatch(/^[a-f0-9]{64}$/)
|
|
55
|
+
expect(key).not.toContain('192.0.2.1')
|
|
56
|
+
})
|
|
57
|
+
it('separates the passkey counter domain and capacity from password sign-in', async () => {
|
|
58
|
+
const { store, limiter } = fixture()
|
|
59
|
+
for (let i = 0; i < 4; i++) await limiter.acquire()
|
|
60
|
+
const password = createPasswordSignInLimiter(store, secret, undefined, () => 0, {
|
|
61
|
+
onEvent: () => {},
|
|
62
|
+
})
|
|
63
|
+
const releasePassword = await password.acquire()
|
|
64
|
+
expect(releasePassword).toBeTypeOf('function')
|
|
65
|
+
await limiter.consume({ ip: '192.0.2.1' })
|
|
66
|
+
const passkeyKey = vi.mocked(store.consume).mock.calls.at(-1)?.[0]
|
|
67
|
+
await password.consume({ email: 'admin@example.test', ip: '192.0.2.1' })
|
|
68
|
+
expect(
|
|
69
|
+
vi
|
|
70
|
+
.mocked(store.consume)
|
|
71
|
+
.mock.calls.slice(1)
|
|
72
|
+
.every(([key]) => key !== passkeyKey)
|
|
73
|
+
).toBe(true)
|
|
74
|
+
releasePassword?.()
|
|
75
|
+
password.dispose()
|
|
76
|
+
})
|
|
77
|
+
it('fails closed on storage errors and rejects invalid addresses', async () => {
|
|
78
|
+
const { store, limiter } = fixture()
|
|
79
|
+
vi.mocked(store.consume).mockRejectedValue(new Error('offline'))
|
|
80
|
+
await expect(limiter.consume({ ip: '192.0.2.1' })).rejects.toThrow('offline')
|
|
81
|
+
await expect(limiter.consume({ ip: 'untrusted' })).rejects.toThrow('Invalid client address')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
|
|
11
|
+
import type { PasskeyRequestLimiter } from '@byline/auth'
|
|
12
|
+
import type { RecurringTaskDefinition } from '@byline/core'
|
|
13
|
+
|
|
14
|
+
import { type SignInRateLimitStore, signInNetwork } from './sign-in-rate-limiter.js'
|
|
15
|
+
|
|
16
|
+
/** Separate WebAuthn capacity and key domain; counter storage and cleanup are shared. */
|
|
17
|
+
export function createPasskeyRequestLimiter(
|
|
18
|
+
store: SignInRateLimitStore,
|
|
19
|
+
secret: string | Uint8Array,
|
|
20
|
+
now: () => number = Date.now
|
|
21
|
+
): PasskeyRequestLimiter & { cleanupTask: RecurringTaskDefinition } {
|
|
22
|
+
const bytes = typeof secret === 'string' ? new TextEncoder().encode(secret) : secret
|
|
23
|
+
if (bytes.byteLength < 32) throw new Error('Passkey HMAC secret must contain at least 32 bytes')
|
|
24
|
+
const key = createHmac('sha256', bytes).update('byline:passkey-requests:v1').digest()
|
|
25
|
+
let active = 0
|
|
26
|
+
const cleanupTask: RecurringTaskDefinition = {
|
|
27
|
+
// Both limiters use the same counter table; register this task only once.
|
|
28
|
+
name: 'auth.sign-in-counters.cleanup',
|
|
29
|
+
intervalMs: 60_000,
|
|
30
|
+
leaseMs: 60_000,
|
|
31
|
+
async run(context) {
|
|
32
|
+
const started = performance.now()
|
|
33
|
+
for (let n = 0; n < 32 && performance.now() - started < 30_000; n++) {
|
|
34
|
+
context.signal.throwIfAborted()
|
|
35
|
+
await context.heartbeat()
|
|
36
|
+
if ((await store.purgeExpired(new Date(now() - 300_000))) < 100)
|
|
37
|
+
return { workRemaining: false }
|
|
38
|
+
}
|
|
39
|
+
return { workRemaining: true }
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
requiredCleanupTask: cleanupTask.name,
|
|
44
|
+
cleanupTask,
|
|
45
|
+
async acquire() {
|
|
46
|
+
if (active >= 4) return null
|
|
47
|
+
active++
|
|
48
|
+
const started = performance.now()
|
|
49
|
+
let released = false
|
|
50
|
+
return () => {
|
|
51
|
+
if (released) return
|
|
52
|
+
released = true
|
|
53
|
+
setTimeout(
|
|
54
|
+
() => {
|
|
55
|
+
active--
|
|
56
|
+
},
|
|
57
|
+
Math.max(0, 100 - (performance.now() - started))
|
|
58
|
+
).unref()
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
async consume({ ip }) {
|
|
62
|
+
const network = signInNetwork(ip)
|
|
63
|
+
const time = now()
|
|
64
|
+
const end = (Math.floor(time / 60_000) + 1) * 60_000
|
|
65
|
+
const digest = createHmac('sha256', key)
|
|
66
|
+
.update(JSON.stringify(['network', network, end]))
|
|
67
|
+
.digest('hex')
|
|
68
|
+
await store.purgeExpired(new Date(time - 300_000))
|
|
69
|
+
const allowed = await store.consume(digest, 60, new Date(end))
|
|
70
|
+
return {
|
|
71
|
+
allowed,
|
|
72
|
+
retryAfterSeconds: allowed ? 0 : Math.max(1, Math.ceil((end - time) / 1000)),
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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 type { PasskeyAttemptRow, PasskeyClaimInput } from './passkeys-repository.js'
|
|
10
|
+
|
|
11
|
+
/** Canonical encoding makes string identity exactly equivalent to WebAuthn byte identity. */
|
|
12
|
+
export function assertPasskeyBytes(value: string, maxBytes: number, exactBytes?: number): void {
|
|
13
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
14
|
+
throw new Error('Invalid passkey byte encoding')
|
|
15
|
+
}
|
|
16
|
+
const bytes = Buffer.from(value, 'base64url')
|
|
17
|
+
if (
|
|
18
|
+
bytes.length > maxBytes ||
|
|
19
|
+
(exactBytes !== undefined && bytes.length !== exactBytes) ||
|
|
20
|
+
bytes.toString('base64url') !== value
|
|
21
|
+
)
|
|
22
|
+
throw new Error('Invalid passkey byte encoding')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function assertPasskeyTokenHash(value: string): void {
|
|
26
|
+
if (!/^[a-f0-9]{64}$/.test(value)) throw new Error('Invalid passkey token hash')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertPasskeyAttempt(input: Omit<PasskeyAttemptRow, 'consumed_at'>): void {
|
|
30
|
+
assertPasskeyTokenHash(input.token_hash)
|
|
31
|
+
const lifetime = input.expires_at.getTime() - input.created_at.getTime()
|
|
32
|
+
if (!Number.isFinite(lifetime) || lifetime <= 0 || lifetime > 300_000) {
|
|
33
|
+
throw new Error('Invalid passkey attempt lifetime')
|
|
34
|
+
}
|
|
35
|
+
if (!['sign-in', 'fresh-authentication', 'enrollment', 'grant'].includes(input.purpose)) {
|
|
36
|
+
throw new Error('Invalid passkey attempt purpose')
|
|
37
|
+
}
|
|
38
|
+
if (input.purpose === 'grant') {
|
|
39
|
+
if (input.challenge !== null) throw new Error('Grants cannot contain challenges')
|
|
40
|
+
} else {
|
|
41
|
+
assertPasskeyBytes(input.challenge ?? '', 32, 32)
|
|
42
|
+
}
|
|
43
|
+
if (input.purpose === 'sign-in') {
|
|
44
|
+
if (
|
|
45
|
+
[
|
|
46
|
+
input.actor_user_id,
|
|
47
|
+
input.actor_login_id,
|
|
48
|
+
input.actor_generation,
|
|
49
|
+
input.action,
|
|
50
|
+
input.target_user_id,
|
|
51
|
+
input.credential_record_id,
|
|
52
|
+
input.proof_credential_id,
|
|
53
|
+
].some((value) => value !== null)
|
|
54
|
+
) {
|
|
55
|
+
throw new Error('Sign-in attempts cannot carry management authority')
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
if (
|
|
59
|
+
!input.actor_user_id ||
|
|
60
|
+
!input.actor_login_id ||
|
|
61
|
+
!Number.isSafeInteger(input.actor_generation) ||
|
|
62
|
+
(input.actor_generation ?? -1) < 0
|
|
63
|
+
) {
|
|
64
|
+
throw new Error('Management attempts require an acting login and generation')
|
|
65
|
+
}
|
|
66
|
+
if (input.action === 'enroll') {
|
|
67
|
+
if (input.target_user_id !== input.actor_user_id || input.credential_record_id !== null) {
|
|
68
|
+
throw new Error('Enrollment must target the actor')
|
|
69
|
+
}
|
|
70
|
+
} else if (input.action === 'remove') {
|
|
71
|
+
if (!input.target_user_id || !input.credential_record_id || input.purpose === 'enrollment') {
|
|
72
|
+
throw new Error('Removal must bind its target account and credential')
|
|
73
|
+
}
|
|
74
|
+
} else throw new Error('Management attempts require an action')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Runtime callers must obey the same purpose/action boundary as typed callers. */
|
|
79
|
+
export function assertPasskeyClaim(input: PasskeyClaimInput): void {
|
|
80
|
+
assertPasskeyTokenHash(input.tokenHash)
|
|
81
|
+
if (input.purpose === 'sign-in') {
|
|
82
|
+
if (
|
|
83
|
+
input.action !== undefined ||
|
|
84
|
+
[
|
|
85
|
+
input.binding.actor_user_id,
|
|
86
|
+
input.binding.actor_login_id,
|
|
87
|
+
input.binding.actor_generation,
|
|
88
|
+
].some((value) => value !== null)
|
|
89
|
+
) {
|
|
90
|
+
throw new Error('Sign-in claims require anonymous binding and no action')
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
if (!['fresh-authentication', 'enrollment', 'grant'].includes(input.purpose)) {
|
|
94
|
+
throw new Error('Invalid passkey claim purpose')
|
|
95
|
+
}
|
|
96
|
+
if (input.action !== 'enroll' && input.action !== 'remove') {
|
|
97
|
+
throw new Error('Authenticated claims require an action')
|
|
98
|
+
}
|
|
99
|
+
if (
|
|
100
|
+
!input.binding.actor_user_id ||
|
|
101
|
+
!input.binding.actor_login_id ||
|
|
102
|
+
!Number.isSafeInteger(input.binding.actor_generation) ||
|
|
103
|
+
input.binding.actor_generation < 0
|
|
104
|
+
) {
|
|
105
|
+
throw new Error('Authenticated claims require an acting login binding')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code Form 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 https://mozilla.org/MPL/2.0/.
|
|
5
|
+
* Copyright (c) Infonomic Company Limited
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Internal persistence records. Never return these through account/editor DTOs. */
|
|
9
|
+
export interface PasskeyCredentialRow {
|
|
10
|
+
id: string
|
|
11
|
+
/** Null after account deletion; the credential identity remains reserved. */
|
|
12
|
+
admin_user_id: string | null
|
|
13
|
+
/** Canonical unpadded base64url; byte-exact uniqueness includes revoked records. */
|
|
14
|
+
credential_id: string
|
|
15
|
+
public_key: string
|
|
16
|
+
counter: number
|
|
17
|
+
transports: string[]
|
|
18
|
+
backup_eligible: boolean
|
|
19
|
+
backed_up: boolean
|
|
20
|
+
name: string
|
|
21
|
+
created_at: Date
|
|
22
|
+
last_used_at: Date | null
|
|
23
|
+
revoked_at: Date | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PasskeyAttemptRow {
|
|
27
|
+
/** SHA-256 hex of the independent bearer identifier; never the identifier itself. */
|
|
28
|
+
token_hash: string
|
|
29
|
+
purpose: 'sign-in' | 'fresh-authentication' | 'enrollment' | 'grant'
|
|
30
|
+
challenge: string | null
|
|
31
|
+
actor_user_id: string | null
|
|
32
|
+
actor_login_id: string | null
|
|
33
|
+
actor_generation: number | null
|
|
34
|
+
action: 'enroll' | 'remove' | null
|
|
35
|
+
target_user_id: string | null
|
|
36
|
+
credential_record_id: string | null
|
|
37
|
+
/** Credential used for fresh proof, if any; revalidated at grant use. */
|
|
38
|
+
proof_credential_id: string | null
|
|
39
|
+
created_at: Date
|
|
40
|
+
expires_at: Date
|
|
41
|
+
consumed_at: Date | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type PasskeyClaimInput = { tokenHash: string; now: Date } & (
|
|
45
|
+
| {
|
|
46
|
+
purpose: 'sign-in'
|
|
47
|
+
binding: { actor_user_id: null; actor_login_id: null; actor_generation: null }
|
|
48
|
+
action?: never
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
purpose: 'fresh-authentication' | 'enrollment' | 'grant'
|
|
52
|
+
binding: { actor_user_id: string; actor_login_id: string; actor_generation: number }
|
|
53
|
+
action: 'enroll' | 'remove'
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
export interface PasskeysRepository {
|
|
58
|
+
/** Target lock required; generation and native-login revocation commit together. */
|
|
59
|
+
invalidateAccountLogins(adminUserId: string, at: Date): Promise<void>
|
|
60
|
+
/** Success requires both account locks; denial/failure records may be written separately. */
|
|
61
|
+
appendRemovalAudit(input: {
|
|
62
|
+
actorId: string
|
|
63
|
+
targetId: string
|
|
64
|
+
credentialId: string
|
|
65
|
+
outcome: 'success' | 'denied' | 'failed'
|
|
66
|
+
}): Promise<void>
|
|
67
|
+
getUserHandle(adminUserId: string): Promise<string | null>
|
|
68
|
+
/** Requires the owning account lock. Allocates once; never resets on credential removal. */
|
|
69
|
+
ensureUserHandle(adminUserId: string, candidate: string): Promise<string>
|
|
70
|
+
/** Includes tombstones: authentication must reject revoked or ownerless records. */
|
|
71
|
+
findCredential(id: string): Promise<PasskeyCredentialRow | null>
|
|
72
|
+
/** Includes tombstones: authentication must reject revoked or ownerless records. */
|
|
73
|
+
findCredentialByCredentialId(credentialId: string): Promise<PasskeyCredentialRow | null>
|
|
74
|
+
listCredentials(adminUserId: string): Promise<PasskeyCredentialRow[]>
|
|
75
|
+
/** All credential writes require the owning account lock and share its transaction. */
|
|
76
|
+
createCredential(
|
|
77
|
+
input: Omit<PasskeyCredentialRow, 'admin_user_id' | 'revoked_at'> & {
|
|
78
|
+
admin_user_id: string
|
|
79
|
+
}
|
|
80
|
+
): Promise<void>
|
|
81
|
+
updateCredential(
|
|
82
|
+
id: string,
|
|
83
|
+
patch: Partial<Pick<PasskeyCredentialRow, 'counter' | 'backed_up' | 'name' | 'last_used_at'>>
|
|
84
|
+
): Promise<void>
|
|
85
|
+
revokeCredential(id: string, at: Date): Promise<void>
|
|
86
|
+
/** Authenticated attempts require the actor lock; sign-in has no account yet. */
|
|
87
|
+
createAttempt(input: Omit<PasskeyAttemptRow, 'consumed_at'>): Promise<void>
|
|
88
|
+
findAttempt(tokenHash: string): Promise<PasskeyAttemptRow | null>
|
|
89
|
+
countPendingForLogin(loginId: string, now: Date): Promise<number>
|
|
90
|
+
/**
|
|
91
|
+
* Root store only: commits consumption in its own short transaction BEFORE returning.
|
|
92
|
+
* Calling through an account-transaction store must throw, never create a savepoint.
|
|
93
|
+
* Exact purpose/binding, unconsumed state and expiry gate the sole winning claim.
|
|
94
|
+
* No automatic retries, even if commit acknowledgement is uncertain.
|
|
95
|
+
*/
|
|
96
|
+
claimAttempt(input: PasskeyClaimInput): Promise<PasskeyAttemptRow | null>
|
|
97
|
+
}
|
|
@@ -66,7 +66,7 @@ export interface RefreshTokensRepository {
|
|
|
66
66
|
revokeChain(startId: string, at?: Date): Promise<number>
|
|
67
67
|
/** Revoke every non-revoked token for a user. Used on password change / sign-out everywhere. */
|
|
68
68
|
revokeAllForUser(adminUserId: string, at?: Date): Promise<number>
|
|
69
|
-
/**
|
|
69
|
+
/** Legacy housekeeping: at most 100 null-sid rows expired for 30 days. Native login members are retained. */
|
|
70
70
|
purgeExpired(now?: Date): Promise<number>
|
|
71
71
|
/** All non-revoked tokens for a user. Primarily for tests. */
|
|
72
72
|
listActiveForUser(adminUserId: string): Promise<RefreshTokenRow[]>
|
|
@@ -48,7 +48,7 @@ const DEFAULT_POLICY: SignInRateLimitPolicy = {
|
|
|
48
48
|
const CLEANUP_TASK_NAME = 'auth.sign-in-counters.cleanup'
|
|
49
49
|
|
|
50
50
|
/** IPv4 uses one address; IPv6 uses its /64. Mapped IPv4 shares its native IPv4 bucket. */
|
|
51
|
-
function signInNetwork(ip: string): string {
|
|
51
|
+
export function signInNetwork(ip: string): string {
|
|
52
52
|
const version = isIP(ip)
|
|
53
53
|
if (version === 4) return ip
|
|
54
54
|
if (version !== 6 || ip.includes('%')) throw new Error('Invalid client address')
|
|
@@ -25,6 +25,15 @@
|
|
|
25
25
|
* in Phase 3 along with the route factories.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import type {
|
|
29
|
+
PasskeyAuthenticationOptions,
|
|
30
|
+
PasskeyCeremony,
|
|
31
|
+
PasskeyCredentialSummary,
|
|
32
|
+
PasskeyManagementAction,
|
|
33
|
+
PasskeyManagementGrant,
|
|
34
|
+
PasskeyRegistrationOptions,
|
|
35
|
+
} from '@byline/auth'
|
|
36
|
+
|
|
28
37
|
import type {
|
|
29
38
|
AccountResponse,
|
|
30
39
|
ChangeAccountPasswordRequest,
|
|
@@ -59,6 +68,56 @@ export interface SignInInput {
|
|
|
59
68
|
*/
|
|
60
69
|
export type SignInResult = unknown
|
|
61
70
|
|
|
71
|
+
/** Captured before any prompt; the host checks it again before submission. */
|
|
72
|
+
export interface PasskeyPageBinding {
|
|
73
|
+
expectedSessionId: string | null
|
|
74
|
+
assertCurrent(): void
|
|
75
|
+
}
|
|
76
|
+
interface PasskeyBoundInput {
|
|
77
|
+
expectedSessionId: string
|
|
78
|
+
}
|
|
79
|
+
export interface PasskeyAdminServices {
|
|
80
|
+
capability(): Promise<{ enabled: boolean }>
|
|
81
|
+
captureLogin(signIn?: boolean): PasskeyPageBinding
|
|
82
|
+
managementContext(
|
|
83
|
+
binding: PasskeyPageBinding
|
|
84
|
+
): Promise<{ id: string; isSuperAdmin: boolean; canManagePasskeys: boolean }>
|
|
85
|
+
signInOptions: AdminServiceCall<
|
|
86
|
+
Record<string, never>,
|
|
87
|
+
PasskeyCeremony<PasskeyAuthenticationOptions>
|
|
88
|
+
>
|
|
89
|
+
signIn(
|
|
90
|
+
args: { data: { ceremonyId: string; response: unknown } },
|
|
91
|
+
binding: PasskeyPageBinding
|
|
92
|
+
): Promise<unknown>
|
|
93
|
+
passwordProof: AdminServiceCall<
|
|
94
|
+
PasskeyBoundInput & { password: string; action: PasskeyManagementAction },
|
|
95
|
+
PasskeyManagementGrant
|
|
96
|
+
>
|
|
97
|
+
proofOptions: AdminServiceCall<
|
|
98
|
+
PasskeyBoundInput & { action: PasskeyManagementAction },
|
|
99
|
+
PasskeyCeremony<PasskeyAuthenticationOptions>
|
|
100
|
+
>
|
|
101
|
+
proof: AdminServiceCall<
|
|
102
|
+
PasskeyBoundInput & { ceremonyId: string; response: unknown },
|
|
103
|
+
PasskeyManagementGrant
|
|
104
|
+
>
|
|
105
|
+
enrollmentOptions: AdminServiceCall<
|
|
106
|
+
PasskeyBoundInput & { grantId: string },
|
|
107
|
+
PasskeyCeremony<PasskeyRegistrationOptions>
|
|
108
|
+
>
|
|
109
|
+
enrollment: AdminServiceCall<
|
|
110
|
+
PasskeyBoundInput & { ceremonyId: string; response: unknown; name: string },
|
|
111
|
+
PasskeyCredentialSummary
|
|
112
|
+
>
|
|
113
|
+
list: AdminServiceCall<PasskeyBoundInput & { targetUserId?: string }, PasskeyCredentialSummary[]>
|
|
114
|
+
rename: AdminServiceCall<
|
|
115
|
+
PasskeyBoundInput & { credentialRecordId: string; name: string },
|
|
116
|
+
PasskeyCredentialSummary
|
|
117
|
+
>
|
|
118
|
+
remove: AdminServiceCall<PasskeyBoundInput & { grantId: string }, { signedOut: boolean }>
|
|
119
|
+
}
|
|
120
|
+
|
|
62
121
|
// --- Account self-service -------------------------------------------------
|
|
63
122
|
|
|
64
123
|
/** Same shape as `UpdateAccountRequest` from `@byline/admin/admin-account`. */
|
|
@@ -155,6 +214,7 @@ export interface WhoHasAbilityInput {
|
|
|
155
214
|
export interface BylineAdminServices {
|
|
156
215
|
// Auth
|
|
157
216
|
adminSignIn: AdminServiceCall<SignInInput, SignInResult>
|
|
217
|
+
passkeys?: PasskeyAdminServices
|
|
158
218
|
|
|
159
219
|
// Account self-service
|
|
160
220
|
updateAccount: AdminServiceCall<UpdateAccountInput, AccountResponse>
|
package/src/store.ts
CHANGED
|
@@ -13,7 +13,9 @@ import type {
|
|
|
13
13
|
AdminUsersRepository,
|
|
14
14
|
AdminUserWithPasswordRow,
|
|
15
15
|
} from './modules/admin-users/repository.js'
|
|
16
|
+
import type { AuthMaintenanceRepository } from './modules/auth/auth-maintenance-repository.js'
|
|
16
17
|
import type { LoginSessionsRepository } from './modules/auth/login-sessions-repository.js'
|
|
18
|
+
import type { PasskeysRepository } from './modules/auth/passkeys-repository.js'
|
|
17
19
|
import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repository.js'
|
|
18
20
|
import type { SignInRateLimitStore } from './modules/auth/sign-in-rate-limiter.js'
|
|
19
21
|
|
|
@@ -44,6 +46,8 @@ export interface AdminStore {
|
|
|
44
46
|
): Promise<T>
|
|
45
47
|
/** Lock distinct account IDs in sorted order in one transaction for account-switch sign-in. */
|
|
46
48
|
withSessionLocks<T>(adminUserIds: string[], work: (store: AdminStore) => Promise<T>): Promise<T>
|
|
49
|
+
authMaintenance: AuthMaintenanceRepository
|
|
50
|
+
passkeys: PasskeysRepository
|
|
47
51
|
loginSessions: LoginSessionsRepository
|
|
48
52
|
signInRateLimits: SignInRateLimitStore
|
|
49
53
|
adminUsers: AdminUsersRepository
|