@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.
Files changed (86) hide show
  1. package/dist/modules/admin-account/components/change-password.js +1 -1
  2. package/dist/modules/admin-account/components/container.js +18 -3
  3. package/dist/modules/admin-account/components/container.module.js +2 -0
  4. package/dist/modules/admin-account/components/container_module.css +7 -0
  5. package/dist/modules/admin-account/components/preferences.js +1 -1
  6. package/dist/modules/admin-account/components/update.js +1 -1
  7. package/dist/modules/admin-roles/components/create.js +1 -1
  8. package/dist/modules/admin-roles/components/permissions.js +1 -1
  9. package/dist/modules/admin-roles/components/update.js +1 -1
  10. package/dist/modules/admin-users/abilities.d.ts +1 -0
  11. package/dist/modules/admin-users/abilities.js +8 -1
  12. package/dist/modules/admin-users/components/create.js +1 -1
  13. package/dist/modules/admin-users/components/roles.js +1 -1
  14. package/dist/modules/admin-users/components/set-password.js +1 -1
  15. package/dist/modules/admin-users/components/update.js +1 -1
  16. package/dist/modules/auth/auth-maintenance-repository.d.ts +31 -0
  17. package/dist/modules/auth/auth-maintenance-repository.js +1 -0
  18. package/dist/modules/auth/auth-maintenance.d.ts +11 -0
  19. package/dist/modules/auth/auth-maintenance.js +39 -0
  20. package/dist/modules/auth/auth-maintenance.test.node.d.ts +1 -0
  21. package/dist/modules/auth/components/passkey-browser.d.ts +14 -0
  22. package/dist/modules/auth/components/passkey-browser.js +118 -0
  23. package/dist/modules/auth/components/passkey-browser.test.node.d.ts +1 -0
  24. package/dist/modules/auth/components/passkey-manager.d.ts +26 -0
  25. package/dist/modules/auth/components/passkey-manager.js +406 -0
  26. package/dist/modules/auth/components/passkey-manager.module.js +22 -0
  27. package/dist/modules/auth/components/passkey-manager_module.css +104 -0
  28. package/dist/modules/auth/components/sign-in-form.js +62 -3
  29. package/dist/modules/auth/components/sign-in-form.module.js +5 -1
  30. package/dist/modules/auth/components/sign-in-form_module.css +40 -0
  31. package/dist/modules/auth/index.d.ts +5 -0
  32. package/dist/modules/auth/index.js +3 -0
  33. package/dist/modules/auth/jwt-session-provider.d.ts +12 -4
  34. package/dist/modules/auth/jwt-session-provider.js +135 -94
  35. package/dist/modules/auth/native-passkey-provider.d.ts +72 -0
  36. package/dist/modules/auth/native-passkey-provider.js +618 -0
  37. package/dist/modules/auth/passkey-rate-limiter.d.ts +14 -0
  38. package/dist/modules/auth/passkey-rate-limiter.js +60 -0
  39. package/dist/modules/auth/passkey-rate-limiter.test.node.d.ts +8 -0
  40. package/dist/modules/auth/passkey-storage-validation.d.ts +14 -0
  41. package/dist/modules/auth/passkey-storage-validation.js +59 -0
  42. package/dist/modules/auth/passkeys-repository.d.ts +96 -0
  43. package/dist/modules/auth/passkeys-repository.js +1 -0
  44. package/dist/modules/auth/refresh-tokens-repository.d.ts +1 -1
  45. package/dist/modules/auth/sign-in-rate-limiter.d.ts +2 -0
  46. package/dist/modules/auth/sign-in-rate-limiter.js +1 -1
  47. package/dist/services/admin-services-context.d.ts +1 -1
  48. package/dist/services/admin-services-types.d.ts +60 -0
  49. package/dist/store.d.ts +4 -0
  50. package/package.json +16 -9
  51. package/src/modules/admin-account/components/change-password.tsx +1 -1
  52. package/src/modules/admin-account/components/container.module.css +9 -0
  53. package/src/modules/admin-account/components/container.tsx +12 -3
  54. package/src/modules/admin-account/components/preferences.tsx +1 -1
  55. package/src/modules/admin-account/components/update.tsx +1 -1
  56. package/src/modules/admin-roles/components/create.tsx +1 -1
  57. package/src/modules/admin-roles/components/permissions.tsx +1 -1
  58. package/src/modules/admin-roles/components/update.tsx +1 -1
  59. package/src/modules/admin-users/abilities.ts +7 -0
  60. package/src/modules/admin-users/components/create.tsx +1 -1
  61. package/src/modules/admin-users/components/roles.tsx +1 -1
  62. package/src/modules/admin-users/components/set-password.tsx +1 -1
  63. package/src/modules/admin-users/components/update.tsx +1 -1
  64. package/src/modules/auth/auth-maintenance-repository.ts +26 -0
  65. package/src/modules/auth/auth-maintenance.test.node.ts +110 -0
  66. package/src/modules/auth/auth-maintenance.ts +62 -0
  67. package/src/modules/auth/components/passkey-browser.test.node.ts +188 -0
  68. package/src/modules/auth/components/passkey-browser.ts +139 -0
  69. package/src/modules/auth/components/passkey-manager.module.css +100 -0
  70. package/src/modules/auth/components/passkey-manager.test.tsx +189 -0
  71. package/src/modules/auth/components/passkey-manager.tsx +386 -0
  72. package/src/modules/auth/components/sign-in-form.module.css +37 -0
  73. package/src/modules/auth/components/sign-in-form.test.tsx +92 -0
  74. package/src/modules/auth/components/sign-in-form.tsx +65 -4
  75. package/src/modules/auth/index.ts +15 -0
  76. package/src/modules/auth/jwt-session-provider.ts +116 -44
  77. package/src/modules/auth/native-passkey-provider.ts +710 -0
  78. package/src/modules/auth/passkey-rate-limiter.test.node.ts +83 -0
  79. package/src/modules/auth/passkey-rate-limiter.ts +76 -0
  80. package/src/modules/auth/passkey-storage-validation.ts +108 -0
  81. package/src/modules/auth/passkeys-repository.ts +97 -0
  82. package/src/modules/auth/refresh-tokens-repository.ts +1 -1
  83. package/src/modules/auth/sign-in-rate-limiter.ts +1 -1
  84. package/src/services/admin-services-context.tsx +2 -0
  85. package/src/services/admin-services-types.ts +60 -0
  86. 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
- /** Remove rows whose `expires_at` is in the past. Housekeeping. */
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')
@@ -16,6 +16,8 @@ export type {
16
16
  ChangeAccountPasswordInput,
17
17
  CreateAdminRoleInput,
18
18
  CreateAdminUserInput,
19
+ PasskeyAdminServices,
20
+ PasskeyPageBinding,
19
21
  SetAdminUserPasswordInput,
20
22
  SetRoleAbilitiesInput,
21
23
  SetUserRolesInput,
@@ -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