@byline/admin 6.7.2 → 6.9.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/fields/blocks/blocks-field.js +33 -3
- package/dist/fields/blocks/blocks-field.module.js +1 -0
- package/dist/fields/blocks/blocks-field_module.css +19 -0
- package/dist/forms/form-page-chrome.d.ts +3 -2
- package/dist/forms/form-page-chrome.js +13 -1
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +23 -17
- package/dist/forms/unavailable-blocks.d.ts +15 -0
- package/dist/forms/unavailable-blocks.js +38 -0
- 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/fields/blocks/blocks-field.module.css +23 -0
- package/src/fields/blocks/blocks-field.tsx +26 -2
- package/src/forms/form-page-chrome.tsx +19 -1
- package/src/forms/form-renderer-capabilities.test.tsx +6 -6
- package/src/forms/form-renderer.tsx +24 -15
- package/src/forms/unavailable-blocks.test.tsx +152 -0
- package/src/forms/unavailable-blocks.tsx +58 -0
- 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,62 @@
|
|
|
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 { RecurringTaskDefinition } from '@byline/core'
|
|
10
|
+
|
|
11
|
+
import type { AdminStore } from '../../store.js'
|
|
12
|
+
|
|
13
|
+
const DAY = 86_400_000
|
|
14
|
+
|
|
15
|
+
/** Register for native sessions even on password-only sites; configuration never runs cleanup. */
|
|
16
|
+
export function createAuthMaintenanceTasks(
|
|
17
|
+
store: AdminStore,
|
|
18
|
+
now: () => Date = () => new Date()
|
|
19
|
+
): RecurringTaskDefinition[] {
|
|
20
|
+
const task = (
|
|
21
|
+
name: string,
|
|
22
|
+
retention: number,
|
|
23
|
+
batch: (cutoff: Date) => Promise<boolean>
|
|
24
|
+
): RecurringTaskDefinition => ({
|
|
25
|
+
name: `auth.maintenance.${name}`,
|
|
26
|
+
intervalMs: 60_000,
|
|
27
|
+
leaseMs: 60_000,
|
|
28
|
+
async run(context) {
|
|
29
|
+
const started = performance.now()
|
|
30
|
+
const cutoff = new Date(now().getTime() - retention)
|
|
31
|
+
for (let n = 0; n < 32 && performance.now() - started < 30_000; n++) {
|
|
32
|
+
context.signal.throwIfAborted()
|
|
33
|
+
await context.heartbeat()
|
|
34
|
+
context.signal.throwIfAborted()
|
|
35
|
+
if (!(await batch(cutoff))) return { workRemaining: false }
|
|
36
|
+
}
|
|
37
|
+
return { workRemaining: true }
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
return [
|
|
41
|
+
task(
|
|
42
|
+
'attempts',
|
|
43
|
+
300_000,
|
|
44
|
+
async (cutoff) => (await store.authMaintenance.purgeAttempts(cutoff)) === 100
|
|
45
|
+
),
|
|
46
|
+
task('logins', 30 * DAY, async (cutoff) => {
|
|
47
|
+
const candidate = await store.authMaintenance.findTerminalLogin(cutoff)
|
|
48
|
+
if (!candidate) return false
|
|
49
|
+
await store.withSessionLock(candidate.adminUserId, async (scoped) => {
|
|
50
|
+
await scoped.authMaintenance.pruneTerminalLogin({ ...candidate, before: cutoff })
|
|
51
|
+
})
|
|
52
|
+
// A candidate is not a cursor. Re-scan after each bounded transaction so
|
|
53
|
+
// a login with many refresh predecessors drains without a long-held lock.
|
|
54
|
+
return true
|
|
55
|
+
}),
|
|
56
|
+
task(
|
|
57
|
+
'legacy-refresh',
|
|
58
|
+
30 * DAY,
|
|
59
|
+
async (cutoff) => (await store.authMaintenance.purgeLegacyRefreshTokens(cutoff)) === 100
|
|
60
|
+
),
|
|
61
|
+
]
|
|
62
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
PasskeyAdminServices,
|
|
5
|
+
PasskeyPageBinding,
|
|
6
|
+
} from '../../../services/admin-services-types.js'
|
|
7
|
+
|
|
8
|
+
const browser = vi.hoisted(() => ({
|
|
9
|
+
supported: vi.fn(),
|
|
10
|
+
authentication: vi.fn(),
|
|
11
|
+
registration: vi.fn(),
|
|
12
|
+
}))
|
|
13
|
+
vi.mock('@simplewebauthn/browser', () => ({
|
|
14
|
+
browserSupportsWebAuthn: browser.supported,
|
|
15
|
+
startAuthentication: browser.authentication,
|
|
16
|
+
startRegistration: browser.registration,
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
enrollPasskey,
|
|
21
|
+
passkeyErrorKey,
|
|
22
|
+
provePasskeyAction,
|
|
23
|
+
signInWithPasskey,
|
|
24
|
+
} from './passkey-browser.js'
|
|
25
|
+
|
|
26
|
+
let services: PasskeyAdminServices
|
|
27
|
+
let binding: PasskeyPageBinding
|
|
28
|
+
const expiresAt = () => new Date(Date.now() + 60_000).toISOString()
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.resetAllMocks()
|
|
31
|
+
browser.supported.mockReturnValue(true)
|
|
32
|
+
browser.authentication.mockResolvedValue({ id: 'assertion' })
|
|
33
|
+
browser.registration.mockResolvedValue({ id: 'attestation' })
|
|
34
|
+
binding = { expectedSessionId: 'login-A', assertCurrent: vi.fn() }
|
|
35
|
+
const ceremony = () => ({ ceremonyId: 'attempt', expiresAt: expiresAt(), options: {} })
|
|
36
|
+
const grant = () => ({ grantId: 'grant', expiresAt: expiresAt() })
|
|
37
|
+
services = {
|
|
38
|
+
capability: vi.fn(),
|
|
39
|
+
captureLogin: vi.fn(() => binding),
|
|
40
|
+
managementContext: vi.fn(),
|
|
41
|
+
signInOptions: vi.fn(async () => ceremony()),
|
|
42
|
+
signIn: vi.fn(),
|
|
43
|
+
passwordProof: vi.fn(async () => grant()),
|
|
44
|
+
proofOptions: vi.fn(async () => ceremony()),
|
|
45
|
+
proof: vi.fn(async () => grant()),
|
|
46
|
+
enrollmentOptions: vi.fn(async () => ceremony()),
|
|
47
|
+
enrollment: vi.fn(),
|
|
48
|
+
list: vi.fn(),
|
|
49
|
+
rename: vi.fn(),
|
|
50
|
+
remove: vi.fn(),
|
|
51
|
+
} as unknown as PasskeyAdminServices
|
|
52
|
+
})
|
|
53
|
+
describe('browser passkey ceremonies', () => {
|
|
54
|
+
it('does not enter the cookie-changing submission until the prompt finishes', async () => {
|
|
55
|
+
let finish!: (value: unknown) => void
|
|
56
|
+
browser.authentication.mockImplementation(
|
|
57
|
+
() =>
|
|
58
|
+
new Promise((resolve) => {
|
|
59
|
+
finish = resolve
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
const pending = signInWithPasskey(services)
|
|
63
|
+
await vi.waitFor(() => expect(browser.authentication).toHaveBeenCalledOnce())
|
|
64
|
+
expect(services.signIn).not.toHaveBeenCalled()
|
|
65
|
+
finish({ id: 'verified' })
|
|
66
|
+
await pending
|
|
67
|
+
expect(services.signIn).toHaveBeenCalledWith(
|
|
68
|
+
{ data: { ceremonyId: 'attempt', response: { id: 'verified' } } },
|
|
69
|
+
expect.objectContaining({ expectedSessionId: 'login-A' })
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
it.each(['AbortError', 'NotAllowedError'])(
|
|
73
|
+
'cancellation %s does not submit or retry',
|
|
74
|
+
async (name) => {
|
|
75
|
+
browser.authentication.mockRejectedValue(Object.assign(new Error('cancelled'), { name }))
|
|
76
|
+
await expect(signInWithPasskey(services)).rejects.toThrow('cancelled')
|
|
77
|
+
expect(services.signInOptions).toHaveBeenCalledOnce()
|
|
78
|
+
expect(services.signIn).not.toHaveBeenCalled()
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
it('rejects a login switch during the authenticator prompt', async () => {
|
|
82
|
+
browser.authentication.mockImplementation(async () => {
|
|
83
|
+
vi.mocked(binding.assertCurrent).mockImplementation(() => {
|
|
84
|
+
throw new Error('session changed')
|
|
85
|
+
})
|
|
86
|
+
return { id: 'other-account' }
|
|
87
|
+
})
|
|
88
|
+
await expect(signInWithPasskey(services)).rejects.toThrow('session changed')
|
|
89
|
+
expect(services.signIn).not.toHaveBeenCalled()
|
|
90
|
+
})
|
|
91
|
+
it('abandons a completed prompt when the sign-in page has unmounted', async () => {
|
|
92
|
+
let active = true
|
|
93
|
+
browser.authentication.mockImplementation(async () => {
|
|
94
|
+
active = false
|
|
95
|
+
return {}
|
|
96
|
+
})
|
|
97
|
+
await expect(signInWithPasskey(services, () => active)).rejects.toThrow('page closed')
|
|
98
|
+
expect(services.signIn).not.toHaveBeenCalled()
|
|
99
|
+
})
|
|
100
|
+
it('does not open a device prompt for an expired challenge', async () => {
|
|
101
|
+
vi.mocked(services.signInOptions).mockResolvedValue({
|
|
102
|
+
ceremonyId: 'expired',
|
|
103
|
+
expiresAt: new Date(0).toISOString(),
|
|
104
|
+
options: {} as any,
|
|
105
|
+
})
|
|
106
|
+
await expect(signInWithPasskey(services)).rejects.toMatchObject({ code: 'ERR_INVALID_TOKEN' })
|
|
107
|
+
expect(browser.authentication).not.toHaveBeenCalled()
|
|
108
|
+
})
|
|
109
|
+
it('binds password proof and enrollment to the page login, without email input', async () => {
|
|
110
|
+
await enrollPasskey(services, binding, 'Laptop', 'password')
|
|
111
|
+
expect(services.passwordProof).toHaveBeenCalledWith({
|
|
112
|
+
data: { expectedSessionId: 'login-A', action: { purpose: 'enroll' }, password: 'password' },
|
|
113
|
+
})
|
|
114
|
+
expect(services.enrollmentOptions).toHaveBeenCalledWith({
|
|
115
|
+
data: { expectedSessionId: 'login-A', grantId: 'grant' },
|
|
116
|
+
})
|
|
117
|
+
expect(services.enrollment).toHaveBeenCalledWith({
|
|
118
|
+
data: {
|
|
119
|
+
expectedSessionId: 'login-A',
|
|
120
|
+
ceremonyId: 'attempt',
|
|
121
|
+
response: { id: 'attestation' },
|
|
122
|
+
name: 'Laptop',
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
it('does not enroll after a login change during registration', async () => {
|
|
127
|
+
browser.registration.mockImplementation(async () => {
|
|
128
|
+
vi.mocked(binding.assertCurrent).mockImplementation(() => {
|
|
129
|
+
throw new Error('session changed')
|
|
130
|
+
})
|
|
131
|
+
return { id: 'attestation' }
|
|
132
|
+
})
|
|
133
|
+
await expect(enrollPasskey(services, binding, 'Laptop', 'password')).rejects.toThrow(
|
|
134
|
+
'session changed'
|
|
135
|
+
)
|
|
136
|
+
expect(services.enrollment).not.toHaveBeenCalled()
|
|
137
|
+
})
|
|
138
|
+
it('passes a scoped removal action and never accepts a wrong-account proof', async () => {
|
|
139
|
+
const action = {
|
|
140
|
+
purpose: 'remove' as const,
|
|
141
|
+
targetUserId: 'target',
|
|
142
|
+
credentialRecordId: 'record',
|
|
143
|
+
}
|
|
144
|
+
vi.mocked(services.proof).mockRejectedValue(
|
|
145
|
+
Object.assign(new Error('wrong account'), { code: 'ERR_INVALID_CREDENTIALS' })
|
|
146
|
+
)
|
|
147
|
+
await expect(provePasskeyAction(services, binding, action)).rejects.toThrow('wrong account')
|
|
148
|
+
expect(services.proofOptions).toHaveBeenCalledWith({
|
|
149
|
+
data: { expectedSessionId: 'login-A', action },
|
|
150
|
+
})
|
|
151
|
+
expect(services.remove).not.toHaveBeenCalled()
|
|
152
|
+
expect(services.proof).toHaveBeenCalledOnce()
|
|
153
|
+
})
|
|
154
|
+
it('does not retry ambiguous enrollment completion', async () => {
|
|
155
|
+
vi.mocked(services.enrollment).mockRejectedValue(new Error('connection lost'))
|
|
156
|
+
await expect(enrollPasskey(services, binding, 'Laptop', 'password')).rejects.toThrow(
|
|
157
|
+
'connection lost'
|
|
158
|
+
)
|
|
159
|
+
expect(services.enrollment).toHaveBeenCalledOnce()
|
|
160
|
+
expect(services.enrollmentOptions).toHaveBeenCalledOnce()
|
|
161
|
+
})
|
|
162
|
+
it('keeps password proof available without WebAuthn support', async () => {
|
|
163
|
+
browser.supported.mockReturnValue(false)
|
|
164
|
+
await expect(
|
|
165
|
+
provePasskeyAction(
|
|
166
|
+
services,
|
|
167
|
+
binding,
|
|
168
|
+
{ purpose: 'remove', targetUserId: 'target', credentialRecordId: 'key' },
|
|
169
|
+
'password'
|
|
170
|
+
)
|
|
171
|
+
).resolves.toMatchObject({ grantId: 'grant' })
|
|
172
|
+
await expect(signInWithPasskey(services)).rejects.toMatchObject({ code: 'PASSKEY_UNSUPPORTED' })
|
|
173
|
+
})
|
|
174
|
+
it.each([
|
|
175
|
+
[{ code: 'ERR_INVALID_TOKEN' }, 'expired'],
|
|
176
|
+
[{ code: 'ERR_SESSION_CHANGED' }, 'sessionChanged'],
|
|
177
|
+
[{ code: 'ERR_INVALID_CREDENTIALS' }, 'proof'],
|
|
178
|
+
[{ code: 'ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT' }, 'unsupported'],
|
|
179
|
+
[{ code: 'ERROR_AUTHENTICATOR_GENERAL_ERROR' }, 'device'],
|
|
180
|
+
[
|
|
181
|
+
{ code: 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY', cause: { name: 'NotAllowedError' } },
|
|
182
|
+
'cancelled',
|
|
183
|
+
],
|
|
184
|
+
[{ status: 429 }, 'rateLimited'],
|
|
185
|
+
])('maps bounded public error %j to usable copy', (error, key) => {
|
|
186
|
+
expect(passkeyErrorKey(error)).toBe(`passkeys.errors.${key}`)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
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 { PasskeyManagementAction } from '@byline/auth'
|
|
10
|
+
import {
|
|
11
|
+
browserSupportsWebAuthn,
|
|
12
|
+
type PublicKeyCredentialCreationOptionsJSON,
|
|
13
|
+
type PublicKeyCredentialRequestOptionsJSON,
|
|
14
|
+
startAuthentication,
|
|
15
|
+
startRegistration,
|
|
16
|
+
} from '@simplewebauthn/browser'
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
PasskeyAdminServices,
|
|
20
|
+
PasskeyPageBinding,
|
|
21
|
+
} from '../../../services/admin-services-types.js'
|
|
22
|
+
|
|
23
|
+
function ready(binding: PasskeyPageBinding, expiresAt?: string) {
|
|
24
|
+
binding.assertCurrent()
|
|
25
|
+
if (expiresAt && Date.parse(expiresAt) <= Date.now())
|
|
26
|
+
throw Object.assign(new Error('Expired ceremony'), { code: 'ERR_INVALID_TOKEN' })
|
|
27
|
+
}
|
|
28
|
+
function supported() {
|
|
29
|
+
if (!browserSupportsWebAuthn())
|
|
30
|
+
throw Object.assign(new Error('WebAuthn unavailable'), { code: 'PASSKEY_UNSUPPORTED' })
|
|
31
|
+
}
|
|
32
|
+
export async function signInWithPasskey(
|
|
33
|
+
services: PasskeyAdminServices,
|
|
34
|
+
isActive: () => boolean = () => true
|
|
35
|
+
) {
|
|
36
|
+
supported()
|
|
37
|
+
const captured = services.captureLogin(true)
|
|
38
|
+
const binding = {
|
|
39
|
+
...captured,
|
|
40
|
+
assertCurrent: () => {
|
|
41
|
+
if (!isActive()) throw new Error('Sign-in page closed')
|
|
42
|
+
captured.assertCurrent()
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
const ceremony = await services.signInOptions({ data: {} })
|
|
46
|
+
ready(binding, ceremony.expiresAt)
|
|
47
|
+
const response = await startAuthentication({
|
|
48
|
+
optionsJSON: ceremony.options as PublicKeyCredentialRequestOptionsJSON,
|
|
49
|
+
})
|
|
50
|
+
ready(binding, ceremony.expiresAt)
|
|
51
|
+
return services.signIn({ data: { ceremonyId: ceremony.ceremonyId, response } }, binding)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function provePasskeyAction(
|
|
55
|
+
services: PasskeyAdminServices,
|
|
56
|
+
binding: PasskeyPageBinding,
|
|
57
|
+
action: PasskeyManagementAction,
|
|
58
|
+
password?: string
|
|
59
|
+
) {
|
|
60
|
+
ready(binding)
|
|
61
|
+
const expectedSessionId = binding.expectedSessionId
|
|
62
|
+
if (!expectedSessionId)
|
|
63
|
+
throw Object.assign(new Error('Login changed'), { code: 'ERR_SESSION_CHANGED' })
|
|
64
|
+
if (password !== undefined) {
|
|
65
|
+
const grant = await services.passwordProof({ data: { expectedSessionId, action, password } })
|
|
66
|
+
ready(binding, grant.expiresAt)
|
|
67
|
+
return grant
|
|
68
|
+
}
|
|
69
|
+
supported()
|
|
70
|
+
const ceremony = await services.proofOptions({ data: { expectedSessionId, action } })
|
|
71
|
+
ready(binding, ceremony.expiresAt)
|
|
72
|
+
const response = await startAuthentication({
|
|
73
|
+
optionsJSON: ceremony.options as PublicKeyCredentialRequestOptionsJSON,
|
|
74
|
+
})
|
|
75
|
+
ready(binding, ceremony.expiresAt)
|
|
76
|
+
const grant = await services.proof({
|
|
77
|
+
data: { expectedSessionId, ceremonyId: ceremony.ceremonyId, response },
|
|
78
|
+
})
|
|
79
|
+
ready(binding, grant.expiresAt)
|
|
80
|
+
return grant
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function enrollPasskey(
|
|
84
|
+
services: PasskeyAdminServices,
|
|
85
|
+
binding: PasskeyPageBinding,
|
|
86
|
+
name: string,
|
|
87
|
+
password?: string
|
|
88
|
+
) {
|
|
89
|
+
supported()
|
|
90
|
+
const grant = await provePasskeyAction(services, binding, { purpose: 'enroll' }, password)
|
|
91
|
+
const expectedSessionId = binding.expectedSessionId!
|
|
92
|
+
const ceremony = await services.enrollmentOptions({
|
|
93
|
+
data: { expectedSessionId, grantId: grant.grantId },
|
|
94
|
+
})
|
|
95
|
+
ready(binding, ceremony.expiresAt)
|
|
96
|
+
const response = await startRegistration({
|
|
97
|
+
optionsJSON: ceremony.options as PublicKeyCredentialCreationOptionsJSON,
|
|
98
|
+
})
|
|
99
|
+
ready(binding, ceremony.expiresAt)
|
|
100
|
+
return services.enrollment({
|
|
101
|
+
data: { expectedSessionId, ceremonyId: ceremony.ceremonyId, response, name },
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Render fixed translated messages, never provider payloads or authenticator identifiers. */
|
|
106
|
+
export function passkeyErrorKey(error: unknown): string {
|
|
107
|
+
const value = error as {
|
|
108
|
+
code?: string
|
|
109
|
+
name?: string
|
|
110
|
+
cause?: { name?: string }
|
|
111
|
+
status?: number
|
|
112
|
+
} | null
|
|
113
|
+
const name = value?.cause?.name ?? value?.name
|
|
114
|
+
if (
|
|
115
|
+
name === 'AbortError' ||
|
|
116
|
+
name === 'NotAllowedError' ||
|
|
117
|
+
value?.code === 'ERROR_CEREMONY_ABORTED'
|
|
118
|
+
)
|
|
119
|
+
return 'passkeys.errors.cancelled'
|
|
120
|
+
if (
|
|
121
|
+
name === 'ConstraintError' ||
|
|
122
|
+
name === 'NotSupportedError' ||
|
|
123
|
+
value?.code === 'PASSKEY_UNSUPPORTED' ||
|
|
124
|
+
value?.code?.startsWith('ERROR_AUTHENTICATOR_MISSING_') ||
|
|
125
|
+
value?.code === 'ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG'
|
|
126
|
+
)
|
|
127
|
+
return 'passkeys.errors.unsupported'
|
|
128
|
+
if (name === 'UnknownError' || value?.code === 'ERROR_AUTHENTICATOR_GENERAL_ERROR')
|
|
129
|
+
return 'passkeys.errors.device'
|
|
130
|
+
if (value?.code === 'ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED')
|
|
131
|
+
return 'passkeys.errors.duplicate'
|
|
132
|
+
if (value?.code === 'ERR_INVALID_TOKEN') return 'passkeys.errors.expired'
|
|
133
|
+
if (value?.code === 'ERR_SESSION_CHANGED') return 'passkeys.errors.sessionChanged'
|
|
134
|
+
if (value?.code === 'ERR_PASSKEY_LIMIT') return 'passkeys.errors.limit'
|
|
135
|
+
if (value?.code === 'ERR_FORBIDDEN') return 'passkeys.errors.forbidden'
|
|
136
|
+
if (value?.code === 'ERR_INVALID_CREDENTIALS') return 'passkeys.errors.proof'
|
|
137
|
+
if (value?.status === 429) return 'passkeys.errors.rateLimited'
|
|
138
|
+
return 'passkeys.errors.unavailable'
|
|
139
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
.manager {
|
|
2
|
+
padding: 1.25rem;
|
|
3
|
+
border: 1px solid var(--border-color, #8885);
|
|
4
|
+
border-radius: 0.5rem;
|
|
5
|
+
}
|
|
6
|
+
.list {
|
|
7
|
+
list-style: none;
|
|
8
|
+
padding: 0;
|
|
9
|
+
margin: 1rem 0;
|
|
10
|
+
}
|
|
11
|
+
.empty + .add {
|
|
12
|
+
margin-top: 0.25rem;
|
|
13
|
+
}
|
|
14
|
+
.row {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: space-between;
|
|
18
|
+
flex-wrap: wrap;
|
|
19
|
+
gap: 0.75rem;
|
|
20
|
+
padding: 0.75rem 0;
|
|
21
|
+
border-bottom: 1px solid var(--border-color, #8885);
|
|
22
|
+
overflow-wrap: anywhere;
|
|
23
|
+
}
|
|
24
|
+
.row p {
|
|
25
|
+
font-size: 0.875rem;
|
|
26
|
+
margin: 0.25rem 0;
|
|
27
|
+
}
|
|
28
|
+
.actions {
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-wrap: wrap;
|
|
31
|
+
gap: 0.5rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/*
|
|
35
|
+
* Add and rename open in a drawer, matching the other account and user edit
|
|
36
|
+
* panels; remove opens a modal, matching other destructive confirmations.
|
|
37
|
+
* Both share the action row below: below 40rem the actions stack full-width
|
|
38
|
+
* (touch-sized), with the primary action on top and Cancel last. From 40rem
|
|
39
|
+
* the small action buttons sit in one right-aligned row, which fits every
|
|
40
|
+
* bundled locale. The modal is capped at 540px, and the drawer is 540px from
|
|
41
|
+
* 48rem (40px wider than the account drawers) so the three proof actions fit
|
|
42
|
+
* one row in French.
|
|
43
|
+
*/
|
|
44
|
+
.modal {
|
|
45
|
+
width: calc(100% - 2 * var(--spacing-8));
|
|
46
|
+
max-width: 540px;
|
|
47
|
+
margin-bottom: var(--spacing-8);
|
|
48
|
+
}
|
|
49
|
+
.drawer-body {
|
|
50
|
+
padding: var(--spacing-8);
|
|
51
|
+
}
|
|
52
|
+
.drawer-form {
|
|
53
|
+
display: flex;
|
|
54
|
+
flex-direction: column;
|
|
55
|
+
gap: var(--spacing-16);
|
|
56
|
+
padding-top: var(--spacing-8);
|
|
57
|
+
}
|
|
58
|
+
.drawer-actions {
|
|
59
|
+
display: flex;
|
|
60
|
+
justify-content: flex-end;
|
|
61
|
+
gap: var(--spacing-12);
|
|
62
|
+
margin-top: var(--spacing-8);
|
|
63
|
+
}
|
|
64
|
+
.drawer-skip {
|
|
65
|
+
position: absolute;
|
|
66
|
+
width: 1px;
|
|
67
|
+
height: 1px;
|
|
68
|
+
padding: 0;
|
|
69
|
+
margin: -1px;
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
clip: rect(0, 0, 0, 0);
|
|
72
|
+
white-space: nowrap;
|
|
73
|
+
border: 0;
|
|
74
|
+
}
|
|
75
|
+
.modal-actions {
|
|
76
|
+
flex-direction: column-reverse;
|
|
77
|
+
align-items: stretch;
|
|
78
|
+
}
|
|
79
|
+
@media (max-width: 39.99rem) {
|
|
80
|
+
.modal-actions > * {
|
|
81
|
+
min-height: 2.5rem;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
@media (min-width: 40rem) {
|
|
85
|
+
.modal {
|
|
86
|
+
width: 100%;
|
|
87
|
+
margin-bottom: 0;
|
|
88
|
+
}
|
|
89
|
+
.modal-actions {
|
|
90
|
+
flex-direction: row;
|
|
91
|
+
flex-wrap: wrap;
|
|
92
|
+
align-items: center;
|
|
93
|
+
gap: var(--spacing-8);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
@media (min-width: 48rem) {
|
|
97
|
+
.drawer {
|
|
98
|
+
width: 540px;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { act, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({ services: {} as any, proof: vi.fn(), enroll: vi.fn() }))
|
|
7
|
+
vi.mock('../../../services/admin-services-context.js', () => ({
|
|
8
|
+
useBylineAdminServices: () => ({ passkeys: mocks.services }),
|
|
9
|
+
}))
|
|
10
|
+
vi.mock('@byline/i18n/react', () => ({
|
|
11
|
+
useTranslation: () => ({
|
|
12
|
+
t: (key: string, values?: { name?: string }) =>
|
|
13
|
+
`${key}${values?.name ? ` ${values.name}` : ''}`,
|
|
14
|
+
}),
|
|
15
|
+
}))
|
|
16
|
+
vi.mock('@byline/core', () => ({ getAdminConfig: () => ({ routes: { signIn: '/sign-in' } }) }))
|
|
17
|
+
vi.mock('./passkey-browser.js', () => ({
|
|
18
|
+
provePasskeyAction: mocks.proof,
|
|
19
|
+
enrollPasskey: mocks.enroll,
|
|
20
|
+
passkeyErrorKey: () => 'passkeys.errors.proof',
|
|
21
|
+
}))
|
|
22
|
+
vi.mock('@byline/ui/react', () => {
|
|
23
|
+
const Box = ({ children }: { children: ReactNode }) => <div>{children}</div>
|
|
24
|
+
const Modal = Object.assign(
|
|
25
|
+
({ children, isOpen }: { children: ReactNode; isOpen: boolean }) =>
|
|
26
|
+
isOpen ? <div role="dialog">{children}</div> : null,
|
|
27
|
+
{ Container: Box, Header: Box, Content: Box, Actions: Box }
|
|
28
|
+
)
|
|
29
|
+
const Drawer = Object.assign(
|
|
30
|
+
({ children, isOpen }: { children: ReactNode; isOpen: boolean }) =>
|
|
31
|
+
isOpen ? <div role="dialog">{children}</div> : null,
|
|
32
|
+
{ Container: Box, TopActions: Box, Header: Box, Content: Box }
|
|
33
|
+
)
|
|
34
|
+
return {
|
|
35
|
+
Modal,
|
|
36
|
+
Drawer,
|
|
37
|
+
CloseIcon: () => null,
|
|
38
|
+
IconButton: ({ children, onClick, ...rest }: any) => (
|
|
39
|
+
<button type="button" onClick={onClick} aria-label={rest['aria-label']}>
|
|
40
|
+
{children}
|
|
41
|
+
</button>
|
|
42
|
+
),
|
|
43
|
+
Alert: ({ children }: { children: ReactNode }) => <div role="alert">{children}</div>,
|
|
44
|
+
Button: ({ children, onClick, disabled, ...rest }: any) => (
|
|
45
|
+
<button type="button" disabled={disabled} onClick={onClick} aria-label={rest['aria-label']}>
|
|
46
|
+
{children}
|
|
47
|
+
</button>
|
|
48
|
+
),
|
|
49
|
+
Input: ({ label, ...rest }: any) => <input aria-label={label} {...rest} />,
|
|
50
|
+
InputPassword: ({
|
|
51
|
+
label,
|
|
52
|
+
showPasswordLabel: _show,
|
|
53
|
+
hidePasswordLabel: _hide,
|
|
54
|
+
...rest
|
|
55
|
+
}: any) => <input type="password" aria-label={label} {...rest} />,
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
import { PasskeyManager } from './passkey-manager.js'
|
|
60
|
+
|
|
61
|
+
let root: Root
|
|
62
|
+
let container: HTMLDivElement
|
|
63
|
+
const key = {
|
|
64
|
+
id: 'record',
|
|
65
|
+
name: 'Security key',
|
|
66
|
+
createdAt: new Date(0).toISOString(),
|
|
67
|
+
lastUsedAt: null,
|
|
68
|
+
}
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
vi.resetAllMocks()
|
|
71
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
72
|
+
mocks.services = {
|
|
73
|
+
capability: vi.fn().mockResolvedValue({ enabled: true }),
|
|
74
|
+
captureLogin: vi.fn(() => ({ expectedSessionId: 'login', assertCurrent: vi.fn() })),
|
|
75
|
+
managementContext: vi
|
|
76
|
+
.fn()
|
|
77
|
+
.mockResolvedValue({ id: 'self', isSuperAdmin: false, canManagePasskeys: false }),
|
|
78
|
+
list: vi.fn().mockResolvedValue([key]),
|
|
79
|
+
rename: vi.fn(),
|
|
80
|
+
remove: vi.fn(),
|
|
81
|
+
}
|
|
82
|
+
mocks.proof.mockResolvedValue({
|
|
83
|
+
grantId: 'grant',
|
|
84
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
85
|
+
})
|
|
86
|
+
mocks.services.remove.mockResolvedValue({ signedOut: false })
|
|
87
|
+
container = document.createElement('div')
|
|
88
|
+
document.body.appendChild(container)
|
|
89
|
+
root = createRoot(container)
|
|
90
|
+
})
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
await act(async () => root.unmount())
|
|
93
|
+
container.remove()
|
|
94
|
+
vi.unstubAllGlobals()
|
|
95
|
+
})
|
|
96
|
+
const render = async (targetUserId = 'self', targetIsSuperAdmin = false) => {
|
|
97
|
+
await act(async () =>
|
|
98
|
+
root.render(
|
|
99
|
+
<PasskeyManager targetUserId={targetUserId} targetIsSuperAdmin={targetIsSuperAdmin} />
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
const click = async (label: string) => {
|
|
104
|
+
const button = [...container.querySelectorAll('button')].find(
|
|
105
|
+
(button) => button.textContent === label
|
|
106
|
+
)
|
|
107
|
+
expect(button).toBeTruthy()
|
|
108
|
+
await act(async () => button!.click())
|
|
109
|
+
}
|
|
110
|
+
describe('passkey manager controls', () => {
|
|
111
|
+
it('hides the flow when passkeys are disabled', async () => {
|
|
112
|
+
mocks.services.capability.mockResolvedValue({ enabled: false })
|
|
113
|
+
await render()
|
|
114
|
+
expect(container.innerHTML).toBe('')
|
|
115
|
+
expect(mocks.services.list).not.toHaveBeenCalled()
|
|
116
|
+
})
|
|
117
|
+
it('allows self-service enrollment and rename', async () => {
|
|
118
|
+
await render()
|
|
119
|
+
expect(container.textContent).toContain('passkeys.add')
|
|
120
|
+
expect(container.textContent).toContain('passkeys.rename')
|
|
121
|
+
expect(container.textContent).toContain('Security key')
|
|
122
|
+
})
|
|
123
|
+
it('keeps the admin view for the signed-in user when an admin screen passes a notice', async () => {
|
|
124
|
+
await act(async () =>
|
|
125
|
+
root.render(
|
|
126
|
+
<PasskeyManager targetUserId="self" ownAccountNotice={<p>own-account-notice</p>} />
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
expect(container.textContent).toContain('own-account-notice')
|
|
130
|
+
expect(container.textContent).toContain('Security key')
|
|
131
|
+
expect(container.textContent).toContain('passkeys.remove')
|
|
132
|
+
expect(container.textContent).not.toContain('passkeys.add')
|
|
133
|
+
expect(container.textContent).not.toContain('passkeys.rename')
|
|
134
|
+
expect(container.textContent).not.toContain('passkeys.description')
|
|
135
|
+
})
|
|
136
|
+
it('hides recovery without the ability and protects super-admin targets', async () => {
|
|
137
|
+
await render('other')
|
|
138
|
+
expect(container.innerHTML).toBe('')
|
|
139
|
+
expect(mocks.services.list).not.toHaveBeenCalled()
|
|
140
|
+
mocks.services.managementContext.mockResolvedValue({
|
|
141
|
+
id: 'self',
|
|
142
|
+
isSuperAdmin: false,
|
|
143
|
+
canManagePasskeys: true,
|
|
144
|
+
})
|
|
145
|
+
await render('super', true)
|
|
146
|
+
expect(container.innerHTML).toBe('')
|
|
147
|
+
})
|
|
148
|
+
it('offers only removal for an authorized recovery target', async () => {
|
|
149
|
+
mocks.services.managementContext.mockResolvedValue({
|
|
150
|
+
id: 'self',
|
|
151
|
+
isSuperAdmin: false,
|
|
152
|
+
canManagePasskeys: true,
|
|
153
|
+
})
|
|
154
|
+
await render('other')
|
|
155
|
+
expect(container.textContent).toContain('passkeys.remove')
|
|
156
|
+
expect(container.textContent).not.toContain('passkeys.add')
|
|
157
|
+
expect(container.textContent).not.toContain('passkeys.rename')
|
|
158
|
+
})
|
|
159
|
+
it('requires explicit confirmation before requesting proof and allows cancellation', async () => {
|
|
160
|
+
await render()
|
|
161
|
+
await click('passkeys.remove')
|
|
162
|
+
expect(container.querySelector('[role="dialog"]')?.textContent).toContain(
|
|
163
|
+
'passkeys.removeWarning Security key'
|
|
164
|
+
)
|
|
165
|
+
expect(mocks.proof).not.toHaveBeenCalled()
|
|
166
|
+
await click('common.actions.cancel')
|
|
167
|
+
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
|
168
|
+
expect(mocks.services.remove).not.toHaveBeenCalled()
|
|
169
|
+
})
|
|
170
|
+
it('never removes a key after a failed or wrong-account fresh proof', async () => {
|
|
171
|
+
mocks.proof.mockRejectedValue(new Error('wrong account'))
|
|
172
|
+
await render()
|
|
173
|
+
await click('passkeys.remove')
|
|
174
|
+
await click('passkeys.usePasskey')
|
|
175
|
+
expect(container.querySelector('[role="alert"]')?.textContent).toBe('passkeys.errors.proof')
|
|
176
|
+
expect(mocks.services.remove).not.toHaveBeenCalled()
|
|
177
|
+
expect(mocks.proof).toHaveBeenCalledOnce()
|
|
178
|
+
})
|
|
179
|
+
it('shows completion only after confirmed removal and retains the actor view', async () => {
|
|
180
|
+
await render()
|
|
181
|
+
await click('passkeys.remove')
|
|
182
|
+
await click('passkeys.usePasskey')
|
|
183
|
+
expect(mocks.services.remove).toHaveBeenCalledWith({
|
|
184
|
+
data: { expectedSessionId: 'login', grantId: 'grant' },
|
|
185
|
+
})
|
|
186
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe('passkeys.removed')
|
|
187
|
+
expect(container.textContent).not.toContain('Security key')
|
|
188
|
+
})
|
|
189
|
+
})
|