@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,710 @@
|
|
|
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
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type AdminAuth,
|
|
12
|
+
AuthError,
|
|
13
|
+
AuthErrorCodes,
|
|
14
|
+
ERR_FORBIDDEN,
|
|
15
|
+
ERR_INVALID_CREDENTIALS,
|
|
16
|
+
ERR_INVALID_TOKEN,
|
|
17
|
+
ERR_SESSION_CHANGED,
|
|
18
|
+
type PasskeyAdmissionReason,
|
|
19
|
+
type PasskeyAuthenticationOptions,
|
|
20
|
+
type PasskeyCeremony,
|
|
21
|
+
type PasskeyCredentialSummary,
|
|
22
|
+
type PasskeyManagementAction,
|
|
23
|
+
type PasskeyManagementGrant,
|
|
24
|
+
type PasskeyOperation,
|
|
25
|
+
type PasskeyProvider,
|
|
26
|
+
type PasskeyRegistrationOptions,
|
|
27
|
+
type PasskeyRequestMetadata,
|
|
28
|
+
type PasskeySession,
|
|
29
|
+
type SignInResult,
|
|
30
|
+
type SignInWithPasswordArgs,
|
|
31
|
+
} from '@byline/auth'
|
|
32
|
+
import { type PasskeyConfig, resolvePasskeyConfig } from '@byline/core'
|
|
33
|
+
import { passwordSignInSchema } from '@byline/core/validation'
|
|
34
|
+
import {
|
|
35
|
+
type AuthenticationResponseJSON,
|
|
36
|
+
generateAuthenticationOptions,
|
|
37
|
+
generateRegistrationOptions,
|
|
38
|
+
type RegistrationResponseJSON,
|
|
39
|
+
verifyAuthenticationResponse,
|
|
40
|
+
verifyRegistrationResponse,
|
|
41
|
+
} from '@simplewebauthn/server'
|
|
42
|
+
import { v7 as uuidv7, validate as validUuid } from 'uuid'
|
|
43
|
+
|
|
44
|
+
import { ADMIN_USERS_ABILITIES } from '../admin-users/abilities.js'
|
|
45
|
+
import { assertPasskeyBytes } from './passkey-storage-validation.js'
|
|
46
|
+
import { verifyPassword } from './password.js'
|
|
47
|
+
import type { AdminStore } from '../../store.js'
|
|
48
|
+
import type { AdminUserWithPasswordRow } from '../admin-users/repository.js'
|
|
49
|
+
import type { NativeSessionEvent } from './jwt-session-provider.js'
|
|
50
|
+
import type { PasskeyAttemptRow, PasskeyCredentialRow } from './passkeys-repository.js'
|
|
51
|
+
|
|
52
|
+
type Authenticated = { actor: AdminAuth; user: AdminUserWithPasswordRow; sessionId: string }
|
|
53
|
+
type SignInArgs = Pick<
|
|
54
|
+
SignInWithPasswordArgs,
|
|
55
|
+
'previousAccessToken' | 'previousRefreshToken' | 'ip' | 'userAgent'
|
|
56
|
+
>
|
|
57
|
+
type PasskeyEvent = Extract<NativeSessionEvent, { purpose: string }>
|
|
58
|
+
interface NativeDependencies {
|
|
59
|
+
store: AdminStore
|
|
60
|
+
now(): Date
|
|
61
|
+
authenticate(session: PasskeySession, store?: AdminStore): Promise<Authenticated>
|
|
62
|
+
/** Private constructor closure supplied only by JwtSessionProvider; never publicly exported. */
|
|
63
|
+
signIn(
|
|
64
|
+
snapshot: AdminUserWithPasswordRow,
|
|
65
|
+
args: SignInArgs,
|
|
66
|
+
recheck: (store: AdminStore, user: AdminUserWithPasswordRow) => Promise<void>
|
|
67
|
+
): Promise<SignInResult>
|
|
68
|
+
emit(event: PasskeyEvent): void
|
|
69
|
+
}
|
|
70
|
+
const MAX_PENDING = 5
|
|
71
|
+
const MAX_CREDENTIALS = 10
|
|
72
|
+
const LIFETIME_MS = 300_000
|
|
73
|
+
|
|
74
|
+
/** Internal native implementation. Public callers enter through SessionProvider.passkeys. */
|
|
75
|
+
export class NativePasskeyProvider implements PasskeyProvider {
|
|
76
|
+
readonly #trust: PasskeyConfig
|
|
77
|
+
constructor(
|
|
78
|
+
trust: PasskeyConfig,
|
|
79
|
+
private readonly deps: NativeDependencies
|
|
80
|
+
) {
|
|
81
|
+
const resolved = resolvePasskeyConfig(trust, {
|
|
82
|
+
capabilities: { passkeys: true, passwordChange: true, magicLink: false, sso: false },
|
|
83
|
+
passkeys: this,
|
|
84
|
+
})
|
|
85
|
+
if (!resolved) throw new Error('Passkey trust configuration is required')
|
|
86
|
+
this.#trust = resolved
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
recordAdmissionDenied(purpose: PasskeyOperation, reason: PasskeyAdmissionReason): void {
|
|
90
|
+
this.deps.emit({ type: 'passkey_admission_denied', purpose, reason })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
signInOptions(
|
|
94
|
+
_metadata: PasskeyRequestMetadata
|
|
95
|
+
): Promise<PasskeyCeremony<PasskeyAuthenticationOptions>> {
|
|
96
|
+
return this.#run('sign-in-options', async () => {
|
|
97
|
+
const { raw, row } = this.#newAttempt('sign-in')
|
|
98
|
+
const options = await this.#authenticationOptions(row.challenge!)
|
|
99
|
+
await this.deps.store.passkeys.createAttempt(row)
|
|
100
|
+
return { ceremonyId: raw, expiresAt: row.expires_at.toISOString(), options }
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
verifySignIn(
|
|
105
|
+
args: SignInArgs & { ceremonyId: string; response: unknown }
|
|
106
|
+
): Promise<SignInResult> {
|
|
107
|
+
return this.#run('sign-in', async () => {
|
|
108
|
+
const attempt = await this.deps.store.passkeys.claimAttempt({
|
|
109
|
+
tokenHash: hashBearer(args.ceremonyId),
|
|
110
|
+
purpose: 'sign-in',
|
|
111
|
+
binding: { actor_user_id: null, actor_login_id: null, actor_generation: null },
|
|
112
|
+
now: this.deps.now(),
|
|
113
|
+
})
|
|
114
|
+
if (!attempt) throw expired()
|
|
115
|
+
const response = parseResponse(args.response, 'webauthn.get') as AuthenticationResponseJSON
|
|
116
|
+
const key = active(await this.deps.store.passkeys.findCredentialByCredentialId(response.id))
|
|
117
|
+
const user = await this.deps.store.adminUsers.getByIdForSignIn(key.admin_user_id)
|
|
118
|
+
if (!user?.is_enabled) throw invalidProof()
|
|
119
|
+
const verified = await this.#verifyAuthentication(response, key, attempt)
|
|
120
|
+
return this.deps.signIn(user, args, async (store) => {
|
|
121
|
+
this.#unexpired(attempt)
|
|
122
|
+
await this.#applyVerifiedKey(store, key, verified)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
passwordProof(
|
|
128
|
+
args: PasskeySession & { password: string; action: PasskeyManagementAction }
|
|
129
|
+
): Promise<PasskeyManagementGrant> {
|
|
130
|
+
return this.#run('password-proof', async () => {
|
|
131
|
+
const observed = await this.deps.authenticate(args)
|
|
132
|
+
const parsed = passwordSignInSchema.safeParse({
|
|
133
|
+
email: observed.user.email,
|
|
134
|
+
password: args.password,
|
|
135
|
+
})
|
|
136
|
+
if (
|
|
137
|
+
!parsed.success ||
|
|
138
|
+
!(await verifyPassword(parsed.data.password, observed.user.password_hash))
|
|
139
|
+
)
|
|
140
|
+
throw invalidProof()
|
|
141
|
+
return this.deps.store.withSessionLock(observed.user.id, async (store) => {
|
|
142
|
+
const current = await this.deps.authenticate(args, store)
|
|
143
|
+
if (
|
|
144
|
+
current.user.password_hash !== observed.user.password_hash ||
|
|
145
|
+
current.user.session_version !== observed.user.session_version
|
|
146
|
+
)
|
|
147
|
+
throw invalidProof()
|
|
148
|
+
const action = await this.#authorizeAction(args.action, current, store)
|
|
149
|
+
return this.#grant(store, current, action, null)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
proofOptions(
|
|
155
|
+
args: PasskeySession & { action: PasskeyManagementAction }
|
|
156
|
+
): Promise<PasskeyCeremony<PasskeyAuthenticationOptions>> {
|
|
157
|
+
return this.#run('proof-options', async () => {
|
|
158
|
+
const observed = await this.deps.authenticate(args)
|
|
159
|
+
return this.deps.store.withSessionLock(observed.user.id, async (store) => {
|
|
160
|
+
const current = await this.deps.authenticate(args, store)
|
|
161
|
+
const action = await this.#authorizeAction(args.action, current, store)
|
|
162
|
+
await this.#capacity(store, current.sessionId)
|
|
163
|
+
const { raw, row } = this.#newAttempt('fresh-authentication', current, action)
|
|
164
|
+
// Discoverable proof requires a returned user handle, just like sign-in.
|
|
165
|
+
const options = await this.#authenticationOptions(row.challenge!)
|
|
166
|
+
await store.passkeys.createAttempt(row)
|
|
167
|
+
return { ceremonyId: raw, expiresAt: row.expires_at.toISOString(), options }
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
verifyProof(
|
|
173
|
+
args: PasskeySession & { ceremonyId: string; response: unknown }
|
|
174
|
+
): Promise<PasskeyManagementGrant> {
|
|
175
|
+
return this.#run('proof', async () => {
|
|
176
|
+
const observed = await this.deps.authenticate(args)
|
|
177
|
+
const attempt = await this.#claimAuthenticated(
|
|
178
|
+
args.ceremonyId,
|
|
179
|
+
'fresh-authentication',
|
|
180
|
+
observed
|
|
181
|
+
)
|
|
182
|
+
const response = parseResponse(args.response, 'webauthn.get') as AuthenticationResponseJSON
|
|
183
|
+
const key = active(await this.deps.store.passkeys.findCredentialByCredentialId(response.id))
|
|
184
|
+
if (key.admin_user_id !== observed.user.id) throw invalidProof()
|
|
185
|
+
const verified = await this.#verifyAuthentication(response, key, attempt)
|
|
186
|
+
return this.deps.store.withSessionLock(observed.user.id, async (store) => {
|
|
187
|
+
const current = await this.deps.authenticate(args, store)
|
|
188
|
+
await this.#recheckAuthority(store, attempt, current)
|
|
189
|
+
const action = await this.#authorizeAction(actionOf(attempt), current, store)
|
|
190
|
+
await this.#applyVerifiedKey(store, key, verified)
|
|
191
|
+
return this.#grant(store, current, action, key.id)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
enrollmentOptions(
|
|
197
|
+
args: PasskeySession & { grantId: string }
|
|
198
|
+
): Promise<PasskeyCeremony<PasskeyRegistrationOptions>> {
|
|
199
|
+
return this.#run('enrollment-options', async () => {
|
|
200
|
+
const observed = await this.deps.authenticate(args)
|
|
201
|
+
const grant = await this.#claimAuthenticated(args.grantId, 'grant', observed, 'enroll')
|
|
202
|
+
return this.deps.store.withSessionLock(observed.user.id, async (store) => {
|
|
203
|
+
const current = await this.deps.authenticate(args, store)
|
|
204
|
+
await this.#recheckAuthority(store, grant, current)
|
|
205
|
+
await this.#authorizeAction(actionOf(grant), current, store)
|
|
206
|
+
await this.#capacity(store, current.sessionId)
|
|
207
|
+
const existing = await store.passkeys.listCredentials(current.user.id)
|
|
208
|
+
if (existing.length >= MAX_CREDENTIALS) throw limit()
|
|
209
|
+
const handle = await store.passkeys.ensureUserHandle(current.user.id, opaque())
|
|
210
|
+
const { raw, row } = this.#newAttempt('enrollment', current, {
|
|
211
|
+
action: 'enroll',
|
|
212
|
+
target_user_id: current.user.id,
|
|
213
|
+
credential_record_id: null,
|
|
214
|
+
})
|
|
215
|
+
row.expires_at = grant.expires_at
|
|
216
|
+
row.proof_credential_id = grant.proof_credential_id
|
|
217
|
+
const generated = await generateRegistrationOptions({
|
|
218
|
+
rpID: this.#trust.rpId,
|
|
219
|
+
rpName: this.#trust.rpName,
|
|
220
|
+
userName: current.user.email,
|
|
221
|
+
userID: Buffer.from(handle, 'base64url'),
|
|
222
|
+
userDisplayName: current.user.given_name || current.user.email,
|
|
223
|
+
challenge: Buffer.from(row.challenge!, 'base64url'),
|
|
224
|
+
timeout: Math.max(1, grant.expires_at.getTime() - this.deps.now().getTime()),
|
|
225
|
+
attestationType: 'none',
|
|
226
|
+
authenticatorSelection: {
|
|
227
|
+
residentKey: 'required',
|
|
228
|
+
requireResidentKey: true,
|
|
229
|
+
userVerification: 'required',
|
|
230
|
+
},
|
|
231
|
+
excludeCredentials: existing.map((key) => ({ id: key.credential_id })),
|
|
232
|
+
extensions: { credProps: true },
|
|
233
|
+
})
|
|
234
|
+
const options: PasskeyRegistrationOptions = {
|
|
235
|
+
...generated,
|
|
236
|
+
excludeCredentials: existing.map((key) => ({
|
|
237
|
+
id: key.credential_id,
|
|
238
|
+
type: 'public-key' as const,
|
|
239
|
+
})),
|
|
240
|
+
rp: { id: this.#trust.rpId, name: this.#trust.rpName },
|
|
241
|
+
attestation: 'none',
|
|
242
|
+
authenticatorSelection: {
|
|
243
|
+
residentKey: 'required',
|
|
244
|
+
requireResidentKey: true,
|
|
245
|
+
userVerification: 'required',
|
|
246
|
+
},
|
|
247
|
+
extensions: { credProps: true },
|
|
248
|
+
}
|
|
249
|
+
this.#unexpired(row)
|
|
250
|
+
await store.passkeys.createAttempt(row)
|
|
251
|
+
return { ceremonyId: raw, expiresAt: row.expires_at.toISOString(), options }
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
verifyEnrollment(
|
|
257
|
+
args: PasskeySession & { ceremonyId: string; response: unknown; name: string }
|
|
258
|
+
): Promise<PasskeyCredentialSummary> {
|
|
259
|
+
return this.#run('enrollment', async () => {
|
|
260
|
+
const observed = await this.deps.authenticate(args)
|
|
261
|
+
const attempt = await this.#claimAuthenticated(
|
|
262
|
+
args.ceremonyId,
|
|
263
|
+
'enrollment',
|
|
264
|
+
observed,
|
|
265
|
+
'enroll'
|
|
266
|
+
)
|
|
267
|
+
const name = displayName(args.name)
|
|
268
|
+
const response = parseResponse(args.response, 'webauthn.create') as RegistrationResponseJSON
|
|
269
|
+
// residentKey: 'required' / requireResidentKey enforce discoverability during create().
|
|
270
|
+
// credProps reports client properties and may be omitted; only an explicit false contradicts it.
|
|
271
|
+
if (response.clientExtensionResults.credProps?.rk === false) throw invalidProof()
|
|
272
|
+
const verified = await verifyRegistrationResponse({
|
|
273
|
+
response,
|
|
274
|
+
expectedChallenge: attempt.challenge!,
|
|
275
|
+
expectedOrigin: [...this.#trust.origins],
|
|
276
|
+
expectedRPID: this.#trust.rpId,
|
|
277
|
+
expectedType: 'webauthn.create',
|
|
278
|
+
requireUserVerification: true,
|
|
279
|
+
requireUserPresence: true,
|
|
280
|
+
}).catch(() => {
|
|
281
|
+
throw invalidProof()
|
|
282
|
+
})
|
|
283
|
+
if (
|
|
284
|
+
!verified.verified ||
|
|
285
|
+
!verified.registrationInfo.userVerified ||
|
|
286
|
+
verified.registrationInfo.credential.id !== response.id
|
|
287
|
+
)
|
|
288
|
+
throw invalidProof()
|
|
289
|
+
const info = verified.registrationInfo
|
|
290
|
+
return this.deps.store.withSessionLock(observed.user.id, async (store) => {
|
|
291
|
+
const current = await this.deps.authenticate(args, store)
|
|
292
|
+
await this.#recheckAuthority(store, attempt, current)
|
|
293
|
+
await this.#authorizeAction(actionOf(attempt), current, store)
|
|
294
|
+
if ((await store.passkeys.listCredentials(current.user.id)).length >= MAX_CREDENTIALS)
|
|
295
|
+
throw limit()
|
|
296
|
+
if (await store.passkeys.findCredentialByCredentialId(info.credential.id))
|
|
297
|
+
throw invalidProof()
|
|
298
|
+
const row = {
|
|
299
|
+
id: uuidv7(),
|
|
300
|
+
admin_user_id: current.user.id,
|
|
301
|
+
credential_id: info.credential.id,
|
|
302
|
+
public_key: Buffer.from(info.credential.publicKey).toString('base64url'),
|
|
303
|
+
counter: info.credential.counter,
|
|
304
|
+
transports: info.credential.transports ?? [],
|
|
305
|
+
backup_eligible: info.credentialDeviceType === 'multiDevice',
|
|
306
|
+
backed_up: info.credentialBackedUp,
|
|
307
|
+
name,
|
|
308
|
+
created_at: this.deps.now(),
|
|
309
|
+
last_used_at: null,
|
|
310
|
+
revoked_at: null,
|
|
311
|
+
}
|
|
312
|
+
await store.passkeys.createCredential(row)
|
|
313
|
+
return summary(row)
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
listCredentials(
|
|
319
|
+
args: PasskeySession & { targetUserId?: string }
|
|
320
|
+
): Promise<PasskeyCredentialSummary[]> {
|
|
321
|
+
return this.#run('list', async () => {
|
|
322
|
+
const observed = await this.deps.authenticate(args)
|
|
323
|
+
const targetId = args.targetUserId ?? observed.user.id
|
|
324
|
+
if (!validUuid(targetId)) throw forbidden()
|
|
325
|
+
return this.deps.store.withSessionLocks([observed.user.id, targetId], async (store) => {
|
|
326
|
+
const current = await this.deps.authenticate(args, store)
|
|
327
|
+
await this.#authorizeTarget(targetId, current, store)
|
|
328
|
+
return (await store.passkeys.listCredentials(targetId)).map(summary)
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
renameCredential(
|
|
334
|
+
args: PasskeySession & { credentialRecordId: string; name: string }
|
|
335
|
+
): Promise<PasskeyCredentialSummary> {
|
|
336
|
+
return this.#run('rename', async () => {
|
|
337
|
+
const observed = await this.deps.authenticate(args)
|
|
338
|
+
const name = displayName(args.name)
|
|
339
|
+
if (!validUuid(args.credentialRecordId)) throw forbidden()
|
|
340
|
+
return this.deps.store.withSessionLock(observed.user.id, async (store) => {
|
|
341
|
+
const current = await this.deps.authenticate(args, store)
|
|
342
|
+
const key = active(await store.passkeys.findCredential(args.credentialRecordId))
|
|
343
|
+
if (key.admin_user_id !== current.user.id) throw forbidden()
|
|
344
|
+
await store.passkeys.updateCredential(key.id, { name })
|
|
345
|
+
return summary({ ...key, name })
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
removeCredential(args: PasskeySession & { grantId: string }): Promise<{ signedOut: boolean }> {
|
|
351
|
+
return this.#run('remove', async () => {
|
|
352
|
+
const observed = await this.deps.authenticate(args)
|
|
353
|
+
const grant = await this.#claimAuthenticated(args.grantId, 'grant', observed, 'remove')
|
|
354
|
+
const action = actionOf(grant)
|
|
355
|
+
if (action.purpose !== 'remove') throw expired()
|
|
356
|
+
const audit = {
|
|
357
|
+
actorId: observed.user.id,
|
|
358
|
+
targetId: action.targetUserId,
|
|
359
|
+
credentialId: action.credentialRecordId,
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
return await this.deps.store.withSessionLocks(
|
|
363
|
+
[observed.user.id, action.targetUserId],
|
|
364
|
+
async (store) => {
|
|
365
|
+
const current = await this.deps.authenticate(args, store)
|
|
366
|
+
await this.#recheckAuthority(store, grant, current)
|
|
367
|
+
await this.#authorizeAction(action, current, store)
|
|
368
|
+
await store.passkeys.revokeCredential(action.credentialRecordId, this.deps.now())
|
|
369
|
+
await store.passkeys.invalidateAccountLogins(action.targetUserId, this.deps.now())
|
|
370
|
+
if (current.user.id !== action.targetUserId)
|
|
371
|
+
await store.passkeys.appendRemovalAudit({ ...audit, outcome: 'success' })
|
|
372
|
+
return { signedOut: current.user.id === action.targetUserId }
|
|
373
|
+
}
|
|
374
|
+
)
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (observed.user.id !== action.targetUserId) {
|
|
377
|
+
await this.deps.store.passkeys
|
|
378
|
+
.appendRemovalAudit({
|
|
379
|
+
...audit,
|
|
380
|
+
outcome:
|
|
381
|
+
error instanceof AuthError && error.code === AuthErrorCodes.FORBIDDEN
|
|
382
|
+
? 'denied'
|
|
383
|
+
: 'failed',
|
|
384
|
+
})
|
|
385
|
+
.catch(() => {})
|
|
386
|
+
}
|
|
387
|
+
throw error
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async #run<T>(purpose: PasskeyEvent['purpose'], work: () => Promise<T>): Promise<T> {
|
|
393
|
+
this.deps.emit({ type: 'passkey_attempted', purpose })
|
|
394
|
+
try {
|
|
395
|
+
const result = await work()
|
|
396
|
+
this.deps.emit({ type: 'passkey_completed', purpose })
|
|
397
|
+
return result
|
|
398
|
+
} catch (error) {
|
|
399
|
+
const reason =
|
|
400
|
+
error instanceof AuthError
|
|
401
|
+
? error.code === AuthErrorCodes.FORBIDDEN
|
|
402
|
+
? 'forbidden'
|
|
403
|
+
: error.code === AuthErrorCodes.PASSKEY_LIMIT
|
|
404
|
+
? 'limit'
|
|
405
|
+
: error.code === AuthErrorCodes.INVALID_CREDENTIALS
|
|
406
|
+
? 'invalid-proof'
|
|
407
|
+
: 'stale-authority'
|
|
408
|
+
: 'unavailable'
|
|
409
|
+
this.deps.emit({ type: 'passkey_failed', purpose, reason })
|
|
410
|
+
// Never propagate driver errors or verification-library details containing credential material.
|
|
411
|
+
if (error instanceof AuthError) throw new AuthError(error.code, { message: error.message })
|
|
412
|
+
throw new AuthError(AuthErrorCodes.PASSKEY_UNAVAILABLE, {
|
|
413
|
+
message: 'Passkey service unavailable',
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async #authenticationOptions(challenge: string): Promise<PasskeyAuthenticationOptions> {
|
|
419
|
+
const generated = await generateAuthenticationOptions({
|
|
420
|
+
rpID: this.#trust.rpId,
|
|
421
|
+
challenge: Buffer.from(challenge, 'base64url'),
|
|
422
|
+
timeout: LIFETIME_MS,
|
|
423
|
+
userVerification: 'required',
|
|
424
|
+
})
|
|
425
|
+
return {
|
|
426
|
+
challenge: generated.challenge,
|
|
427
|
+
timeout: generated.timeout,
|
|
428
|
+
rpId: this.#trust.rpId,
|
|
429
|
+
userVerification: 'required',
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#newAttempt(
|
|
434
|
+
purpose: PasskeyAttemptRow['purpose'],
|
|
435
|
+
actor?: Authenticated,
|
|
436
|
+
action?: Pick<PasskeyAttemptRow, 'action' | 'target_user_id' | 'credential_record_id'>
|
|
437
|
+
) {
|
|
438
|
+
const raw = opaque()
|
|
439
|
+
const now = this.deps.now()
|
|
440
|
+
const row: Omit<PasskeyAttemptRow, 'consumed_at'> = {
|
|
441
|
+
token_hash: hashBearer(raw),
|
|
442
|
+
purpose,
|
|
443
|
+
challenge: purpose === 'grant' ? null : opaque(),
|
|
444
|
+
actor_user_id: actor?.user.id ?? null,
|
|
445
|
+
actor_login_id: actor?.sessionId ?? null,
|
|
446
|
+
actor_generation: actor?.user.session_version ?? null,
|
|
447
|
+
action: action?.action ?? null,
|
|
448
|
+
target_user_id: action?.target_user_id ?? null,
|
|
449
|
+
credential_record_id: action?.credential_record_id ?? null,
|
|
450
|
+
proof_credential_id: null,
|
|
451
|
+
created_at: now,
|
|
452
|
+
expires_at: new Date(now.getTime() + LIFETIME_MS),
|
|
453
|
+
}
|
|
454
|
+
return { raw, row }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async #claimAuthenticated(
|
|
458
|
+
raw: string,
|
|
459
|
+
purpose: 'fresh-authentication' | 'enrollment' | 'grant',
|
|
460
|
+
actor: Authenticated,
|
|
461
|
+
requiredAction?: 'enroll' | 'remove'
|
|
462
|
+
) {
|
|
463
|
+
const tokenHash = hashBearer(raw)
|
|
464
|
+
const observed = await this.deps.store.passkeys.findAttempt(tokenHash)
|
|
465
|
+
if (!observed?.action || observed.purpose !== purpose) throw expired()
|
|
466
|
+
const row = await this.deps.store.passkeys.claimAttempt({
|
|
467
|
+
tokenHash,
|
|
468
|
+
purpose,
|
|
469
|
+
action: requiredAction ?? observed.action,
|
|
470
|
+
binding: {
|
|
471
|
+
actor_user_id: actor.user.id,
|
|
472
|
+
actor_login_id: actor.sessionId,
|
|
473
|
+
actor_generation: actor.user.session_version,
|
|
474
|
+
},
|
|
475
|
+
now: this.deps.now(),
|
|
476
|
+
})
|
|
477
|
+
if (!row) throw expired()
|
|
478
|
+
return row
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
#unexpired(row: Pick<PasskeyAttemptRow, 'expires_at'>): void {
|
|
482
|
+
if (row.expires_at.getTime() <= this.deps.now().getTime()) throw expired()
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async #recheckAuthority(
|
|
486
|
+
store: AdminStore,
|
|
487
|
+
row: PasskeyAttemptRow,
|
|
488
|
+
actor: Authenticated
|
|
489
|
+
): Promise<void> {
|
|
490
|
+
this.#unexpired(row)
|
|
491
|
+
if (
|
|
492
|
+
row.actor_user_id !== actor.user.id ||
|
|
493
|
+
row.actor_login_id !== actor.sessionId ||
|
|
494
|
+
row.actor_generation !== actor.user.session_version
|
|
495
|
+
) {
|
|
496
|
+
throw ERR_SESSION_CHANGED({ message: 'Fresh proof authority changed; start again' })
|
|
497
|
+
}
|
|
498
|
+
if (row.proof_credential_id) {
|
|
499
|
+
const key = active(await store.passkeys.findCredential(row.proof_credential_id))
|
|
500
|
+
if (key.admin_user_id !== actor.user.id) throw invalidProof()
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async #authorizeTarget(targetId: string, actor: Authenticated, store: AdminStore) {
|
|
505
|
+
if (!validUuid(targetId)) throw forbidden()
|
|
506
|
+
const target = await store.adminUsers.getById(targetId)
|
|
507
|
+
if (!target) throw forbidden()
|
|
508
|
+
if (targetId !== actor.user.id) {
|
|
509
|
+
actor.actor.assertAbility(ADMIN_USERS_ABILITIES.managePasskeys)
|
|
510
|
+
if (target.is_super_admin && !actor.actor.isSuperAdmin) throw forbidden()
|
|
511
|
+
}
|
|
512
|
+
return target
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async #authorizeAction(action: PasskeyManagementAction, actor: Authenticated, store: AdminStore) {
|
|
516
|
+
if (action?.purpose === 'enroll')
|
|
517
|
+
return {
|
|
518
|
+
action: 'enroll' as const,
|
|
519
|
+
target_user_id: actor.user.id,
|
|
520
|
+
credential_record_id: null,
|
|
521
|
+
}
|
|
522
|
+
if (action?.purpose !== 'remove' || !validUuid(action.credentialRecordId)) throw forbidden()
|
|
523
|
+
await this.#authorizeTarget(action.targetUserId, actor, store)
|
|
524
|
+
const key = active(await store.passkeys.findCredential(action.credentialRecordId))
|
|
525
|
+
if (key.admin_user_id !== action.targetUserId) throw forbidden()
|
|
526
|
+
return {
|
|
527
|
+
action: 'remove' as const,
|
|
528
|
+
target_user_id: action.targetUserId,
|
|
529
|
+
credential_record_id: key.id,
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async #capacity(store: AdminStore, sessionId: string) {
|
|
534
|
+
if ((await store.passkeys.countPendingForLogin(sessionId, this.deps.now())) >= MAX_PENDING)
|
|
535
|
+
throw limit()
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async #grant(
|
|
539
|
+
store: AdminStore,
|
|
540
|
+
actor: Authenticated,
|
|
541
|
+
action: Pick<PasskeyAttemptRow, 'action' | 'target_user_id' | 'credential_record_id'>,
|
|
542
|
+
proofCredential: string | null
|
|
543
|
+
): Promise<PasskeyManagementGrant> {
|
|
544
|
+
await this.#capacity(store, actor.sessionId)
|
|
545
|
+
const { raw, row } = this.#newAttempt('grant', actor, action)
|
|
546
|
+
row.proof_credential_id = proofCredential
|
|
547
|
+
await store.passkeys.createAttempt(row)
|
|
548
|
+
return { grantId: raw, expiresAt: row.expires_at.toISOString() }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async #verifyAuthentication(
|
|
552
|
+
response: AuthenticationResponseJSON,
|
|
553
|
+
key: PasskeyCredentialRow & { admin_user_id: string },
|
|
554
|
+
attempt: PasskeyAttemptRow
|
|
555
|
+
) {
|
|
556
|
+
const handle = await this.deps.store.passkeys.getUserHandle(key.admin_user_id)
|
|
557
|
+
if (!handle || response.response.userHandle !== handle) throw invalidProof()
|
|
558
|
+
const result = await verifyAuthenticationResponse({
|
|
559
|
+
response,
|
|
560
|
+
expectedChallenge: attempt.challenge!,
|
|
561
|
+
expectedOrigin: [...this.#trust.origins],
|
|
562
|
+
expectedRPID: this.#trust.rpId,
|
|
563
|
+
expectedType: 'webauthn.get',
|
|
564
|
+
requireUserVerification: true,
|
|
565
|
+
credential: {
|
|
566
|
+
id: key.credential_id,
|
|
567
|
+
publicKey: Buffer.from(key.public_key, 'base64url'),
|
|
568
|
+
counter: key.counter,
|
|
569
|
+
},
|
|
570
|
+
}).catch(() => {
|
|
571
|
+
throw invalidProof()
|
|
572
|
+
})
|
|
573
|
+
if (
|
|
574
|
+
!result.verified ||
|
|
575
|
+
!result.authenticationInfo.userVerified ||
|
|
576
|
+
(result.authenticationInfo.credentialDeviceType === 'multiDevice') !== key.backup_eligible
|
|
577
|
+
)
|
|
578
|
+
throw invalidProof()
|
|
579
|
+
return result.authenticationInfo
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async #applyVerifiedKey(
|
|
583
|
+
store: AdminStore,
|
|
584
|
+
snapshot: PasskeyCredentialRow & { admin_user_id: string },
|
|
585
|
+
verified: { newCounter: number; credentialBackedUp: boolean }
|
|
586
|
+
) {
|
|
587
|
+
const current = active(await store.passkeys.findCredential(snapshot.id))
|
|
588
|
+
for (const field of [
|
|
589
|
+
'admin_user_id',
|
|
590
|
+
'credential_id',
|
|
591
|
+
'public_key',
|
|
592
|
+
'counter',
|
|
593
|
+
'backup_eligible',
|
|
594
|
+
'backed_up',
|
|
595
|
+
] as const) {
|
|
596
|
+
if (current[field] !== snapshot[field]) throw invalidProof()
|
|
597
|
+
}
|
|
598
|
+
await store.passkeys.updateCredential(current.id, {
|
|
599
|
+
counter: verified.newCounter,
|
|
600
|
+
backed_up: verified.credentialBackedUp,
|
|
601
|
+
last_used_at: this.deps.now(),
|
|
602
|
+
})
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function opaque() {
|
|
607
|
+
return randomBytes(32).toString('base64url')
|
|
608
|
+
}
|
|
609
|
+
function hashBearer(raw: string) {
|
|
610
|
+
try {
|
|
611
|
+
assertPasskeyBytes(raw, 32, 32)
|
|
612
|
+
} catch {
|
|
613
|
+
throw expired()
|
|
614
|
+
}
|
|
615
|
+
return createHash('sha256').update(raw).digest('hex')
|
|
616
|
+
}
|
|
617
|
+
function expired() {
|
|
618
|
+
return ERR_INVALID_TOKEN({ message: 'Passkey attempt expired or already used; start again' })
|
|
619
|
+
}
|
|
620
|
+
function invalidProof() {
|
|
621
|
+
return ERR_INVALID_CREDENTIALS({ message: 'Passkey verification failed; start again' })
|
|
622
|
+
}
|
|
623
|
+
function forbidden() {
|
|
624
|
+
return ERR_FORBIDDEN({ message: 'Passkey management is not permitted' })
|
|
625
|
+
}
|
|
626
|
+
function limit() {
|
|
627
|
+
return new AuthError(AuthErrorCodes.PASSKEY_LIMIT, {
|
|
628
|
+
message: 'Passkey credential or pending-operation limit reached',
|
|
629
|
+
})
|
|
630
|
+
}
|
|
631
|
+
function active(
|
|
632
|
+
row: PasskeyCredentialRow | null
|
|
633
|
+
): PasskeyCredentialRow & { admin_user_id: string } {
|
|
634
|
+
if (!row || row.revoked_at !== null || row.admin_user_id === null) throw invalidProof()
|
|
635
|
+
return row as PasskeyCredentialRow & { admin_user_id: string }
|
|
636
|
+
}
|
|
637
|
+
function displayName(value: string) {
|
|
638
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 80) throw forbidden()
|
|
639
|
+
return value
|
|
640
|
+
}
|
|
641
|
+
function summary(row: PasskeyCredentialRow): PasskeyCredentialSummary {
|
|
642
|
+
return {
|
|
643
|
+
id: row.id,
|
|
644
|
+
name: row.name,
|
|
645
|
+
createdAt: row.created_at.toISOString(),
|
|
646
|
+
lastUsedAt: row.last_used_at?.toISOString() ?? null,
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
function actionOf(row: PasskeyAttemptRow): PasskeyManagementAction {
|
|
650
|
+
if (row.action === 'enroll') return { purpose: 'enroll' }
|
|
651
|
+
if (row.action === 'remove' && row.target_user_id && row.credential_record_id) {
|
|
652
|
+
return {
|
|
653
|
+
purpose: 'remove',
|
|
654
|
+
targetUserId: row.target_user_id,
|
|
655
|
+
credentialRecordId: row.credential_record_id,
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
throw expired()
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Bound structure and reject iframe ceremonies without changing the signed bytes. */
|
|
662
|
+
function parseResponse(
|
|
663
|
+
value: unknown,
|
|
664
|
+
expectedType: 'webauthn.get' | 'webauthn.create'
|
|
665
|
+
): AuthenticationResponseJSON | RegistrationResponseJSON {
|
|
666
|
+
try {
|
|
667
|
+
if (
|
|
668
|
+
!value ||
|
|
669
|
+
typeof value !== 'object' ||
|
|
670
|
+
Array.isArray(value) ||
|
|
671
|
+
Buffer.byteLength(JSON.stringify(value)) > 64 * 1024
|
|
672
|
+
)
|
|
673
|
+
throw invalidProof()
|
|
674
|
+
const response = value as AuthenticationResponseJSON | RegistrationResponseJSON
|
|
675
|
+
if (
|
|
676
|
+
response.type !== 'public-key' ||
|
|
677
|
+
response.id !== response.rawId ||
|
|
678
|
+
!response.response ||
|
|
679
|
+
typeof response.response !== 'object' ||
|
|
680
|
+
!response.clientExtensionResults ||
|
|
681
|
+
typeof response.clientExtensionResults !== 'object'
|
|
682
|
+
)
|
|
683
|
+
throw invalidProof()
|
|
684
|
+
assertPasskeyBytes(response.id, 1024)
|
|
685
|
+
assertPasskeyBytes(response.response.clientDataJSON, 16 * 1024)
|
|
686
|
+
const client = JSON.parse(
|
|
687
|
+
Buffer.from(response.response.clientDataJSON, 'base64url').toString('utf8')
|
|
688
|
+
)
|
|
689
|
+
if (
|
|
690
|
+
!client ||
|
|
691
|
+
client.type !== expectedType ||
|
|
692
|
+
(client.crossOrigin !== undefined && client.crossOrigin !== false)
|
|
693
|
+
)
|
|
694
|
+
throw invalidProof()
|
|
695
|
+
if (expectedType === 'webauthn.get') {
|
|
696
|
+
const auth = response as AuthenticationResponseJSON
|
|
697
|
+
assertPasskeyBytes(auth.response.authenticatorData, 16 * 1024)
|
|
698
|
+
assertPasskeyBytes(auth.response.signature, 16 * 1024)
|
|
699
|
+
assertPasskeyBytes(auth.response.userHandle ?? '', 32, 32)
|
|
700
|
+
} else {
|
|
701
|
+
assertPasskeyBytes(
|
|
702
|
+
(response as RegistrationResponseJSON).response.attestationObject,
|
|
703
|
+
48 * 1024
|
|
704
|
+
)
|
|
705
|
+
}
|
|
706
|
+
return response
|
|
707
|
+
} catch {
|
|
708
|
+
throw invalidProof()
|
|
709
|
+
}
|
|
710
|
+
}
|