@korajs/auth 1.0.0-beta.12 → 1.0.0-beta.13
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/README.md +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +334 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- package/src/vue.ts +10 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { KoraError } from '@korajs/core'
|
|
3
|
+
import { fromBase64Url, toBase64Url } from '../device/device-identity'
|
|
4
|
+
import { decodeCbor } from './passkey-client'
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Server-side passkey errors
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Thrown when server-side passkey verification fails.
|
|
12
|
+
*/
|
|
13
|
+
export class PasskeyVerificationError extends KoraError {
|
|
14
|
+
constructor(message: string, context?: Record<string, unknown>) {
|
|
15
|
+
super(message, 'PASSKEY_VERIFICATION_ERROR', context)
|
|
16
|
+
this.name = 'PasskeyVerificationError'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Registration options generation
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
/** Options returned by generateRegistrationOptions for the client. */
|
|
25
|
+
export interface RegistrationOptions {
|
|
26
|
+
/** Base64url-encoded random challenge (32 bytes) */
|
|
27
|
+
challenge: string
|
|
28
|
+
/** Relying party ID (domain) */
|
|
29
|
+
rpId: string
|
|
30
|
+
/** Relying party display name */
|
|
31
|
+
rpName: string
|
|
32
|
+
/** Base64url-encoded user ID */
|
|
33
|
+
userId: string
|
|
34
|
+
/** User's email or username */
|
|
35
|
+
userName: string
|
|
36
|
+
/** Human-readable display name */
|
|
37
|
+
userDisplayName: string
|
|
38
|
+
/** Credential IDs to exclude (prevents re-registration) */
|
|
39
|
+
excludeCredentialIds: string[]
|
|
40
|
+
/** Authenticator selection criteria */
|
|
41
|
+
authenticatorSelection: {
|
|
42
|
+
authenticatorAttachment: 'platform'
|
|
43
|
+
residentKey: 'preferred'
|
|
44
|
+
userVerification: 'required'
|
|
45
|
+
}
|
|
46
|
+
/** Timeout in milliseconds */
|
|
47
|
+
timeout: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate registration options for creating a new passkey.
|
|
52
|
+
*
|
|
53
|
+
* Creates a cryptographically random challenge and assembles the options
|
|
54
|
+
* object that should be sent to the client for `createPasskeyCredential()`.
|
|
55
|
+
*
|
|
56
|
+
* The server must store the challenge (keyed by user session or similar)
|
|
57
|
+
* for later verification when the client responds.
|
|
58
|
+
*
|
|
59
|
+
* @param params - Registration parameters
|
|
60
|
+
* @param params.rpId - Relying party ID (your domain, e.g. "example.com")
|
|
61
|
+
* @param params.rpName - Relying party display name
|
|
62
|
+
* @param params.userId - Unique user identifier
|
|
63
|
+
* @param params.userName - User's email or username
|
|
64
|
+
* @param params.userDisplayName - Human-readable display name
|
|
65
|
+
* @param params.existingCredentialIds - Base64url credential IDs to exclude
|
|
66
|
+
* @returns Registration options to send to the client, including the challenge
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```typescript
|
|
70
|
+
* const options = generateRegistrationOptions({
|
|
71
|
+
* rpId: 'example.com',
|
|
72
|
+
* rpName: 'My App',
|
|
73
|
+
* userId: user.id,
|
|
74
|
+
* userName: user.email,
|
|
75
|
+
* userDisplayName: user.name,
|
|
76
|
+
* })
|
|
77
|
+
* // Store options.challenge in session for later verification
|
|
78
|
+
* // Send options to client
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function generateRegistrationOptions(params: {
|
|
82
|
+
rpId: string
|
|
83
|
+
rpName: string
|
|
84
|
+
userId: string
|
|
85
|
+
userName: string
|
|
86
|
+
userDisplayName: string
|
|
87
|
+
existingCredentialIds?: string[]
|
|
88
|
+
}): RegistrationOptions {
|
|
89
|
+
// Generate a 32-byte cryptographically random challenge
|
|
90
|
+
const challengeBytes = randomBytes(32)
|
|
91
|
+
const challenge = toBase64Url(
|
|
92
|
+
challengeBytes.buffer.slice(
|
|
93
|
+
challengeBytes.byteOffset,
|
|
94
|
+
challengeBytes.byteOffset + challengeBytes.byteLength,
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
challenge,
|
|
100
|
+
rpId: params.rpId,
|
|
101
|
+
rpName: params.rpName,
|
|
102
|
+
userId: params.userId,
|
|
103
|
+
userName: params.userName,
|
|
104
|
+
userDisplayName: params.userDisplayName,
|
|
105
|
+
excludeCredentialIds: params.existingCredentialIds ?? [],
|
|
106
|
+
authenticatorSelection: {
|
|
107
|
+
authenticatorAttachment: 'platform',
|
|
108
|
+
residentKey: 'preferred',
|
|
109
|
+
userVerification: 'required',
|
|
110
|
+
},
|
|
111
|
+
timeout: 60000,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ============================================================================
|
|
116
|
+
// Registration verification
|
|
117
|
+
// ============================================================================
|
|
118
|
+
|
|
119
|
+
/** Result of verifying a registration response. */
|
|
120
|
+
export interface RegistrationVerificationResult {
|
|
121
|
+
/** Whether the registration response was verified successfully */
|
|
122
|
+
verified: boolean
|
|
123
|
+
/** Base64url-encoded credential ID */
|
|
124
|
+
credentialId: string
|
|
125
|
+
/** Base64url-encoded COSE public key (store this for future authentication) */
|
|
126
|
+
publicKey: string
|
|
127
|
+
/** Initial signature counter from the authenticator */
|
|
128
|
+
signCount: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Verify a registration response from the client.
|
|
133
|
+
*
|
|
134
|
+
* Validates the attestation object and clientDataJSON returned by the browser's
|
|
135
|
+
* `navigator.credentials.create()` call. Extracts and returns the public key
|
|
136
|
+
* and credential ID to store in your database.
|
|
137
|
+
*
|
|
138
|
+
* This implementation supports the "none" attestation format, which is the most
|
|
139
|
+
* common and does not require trust in any attestation CA. For higher assurance
|
|
140
|
+
* scenarios, extend this to verify packed/tpm/android attestation formats.
|
|
141
|
+
*
|
|
142
|
+
* @param params - Verification parameters
|
|
143
|
+
* @param params.credential - The credential response from the client
|
|
144
|
+
* @param params.expectedChallenge - The challenge that was sent to the client (base64url)
|
|
145
|
+
* @param params.expectedOrigin - The expected origin (e.g. "https://example.com")
|
|
146
|
+
* @param params.expectedRpId - The expected relying party ID (e.g. "example.com")
|
|
147
|
+
* @returns Verification result with the credential ID and public key to store
|
|
148
|
+
* @throws {PasskeyVerificationError} If the response is invalid or tampered with
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```typescript
|
|
152
|
+
* const result = await verifyRegistrationResponse({
|
|
153
|
+
* credential: clientResponse,
|
|
154
|
+
* expectedChallenge: storedChallenge,
|
|
155
|
+
* expectedOrigin: 'https://example.com',
|
|
156
|
+
* expectedRpId: 'example.com',
|
|
157
|
+
* })
|
|
158
|
+
* if (result.verified) {
|
|
159
|
+
* // Store result.credentialId, result.publicKey, result.signCount
|
|
160
|
+
* }
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
export async function verifyRegistrationResponse(params: {
|
|
164
|
+
credential: {
|
|
165
|
+
credentialId: string
|
|
166
|
+
publicKey: string
|
|
167
|
+
clientDataJSON: string
|
|
168
|
+
attestationObject: string
|
|
169
|
+
}
|
|
170
|
+
expectedChallenge: string
|
|
171
|
+
expectedOrigin: string
|
|
172
|
+
expectedRpId: string
|
|
173
|
+
/**
|
|
174
|
+
* Require the User Verified (UV) flag. Kora's options request
|
|
175
|
+
* `userVerification: 'required'`, so the response must honour it.
|
|
176
|
+
* @default true
|
|
177
|
+
*/
|
|
178
|
+
requireUserVerification?: boolean
|
|
179
|
+
}): Promise<RegistrationVerificationResult> {
|
|
180
|
+
const { credential, expectedChallenge, expectedOrigin, expectedRpId } = params
|
|
181
|
+
const requireUserVerification = params.requireUserVerification ?? true
|
|
182
|
+
|
|
183
|
+
// Step 1: Decode and verify clientDataJSON
|
|
184
|
+
const clientDataBytes = fromBase64Url(credential.clientDataJSON)
|
|
185
|
+
const clientDataText = new TextDecoder().decode(clientDataBytes)
|
|
186
|
+
let clientData: { type: string; challenge: string; origin: string }
|
|
187
|
+
try {
|
|
188
|
+
clientData = JSON.parse(clientDataText) as {
|
|
189
|
+
type: string
|
|
190
|
+
challenge: string
|
|
191
|
+
origin: string
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
throw new PasskeyVerificationError(
|
|
195
|
+
'Failed to parse clientDataJSON. The response may be malformed.',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Verify the type is "webauthn.create"
|
|
200
|
+
if (clientData.type !== 'webauthn.create') {
|
|
201
|
+
throw new PasskeyVerificationError(
|
|
202
|
+
`Expected clientData.type "webauthn.create" but received "${clientData.type}".`,
|
|
203
|
+
{ type: clientData.type },
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Verify the challenge matches what we sent
|
|
208
|
+
if (clientData.challenge !== expectedChallenge) {
|
|
209
|
+
throw new PasskeyVerificationError(
|
|
210
|
+
'Challenge mismatch. The response does not match the expected challenge. ' +
|
|
211
|
+
'This may indicate a replay attack or session mismatch.',
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Verify the origin matches
|
|
216
|
+
if (clientData.origin !== expectedOrigin) {
|
|
217
|
+
throw new PasskeyVerificationError(
|
|
218
|
+
`Origin mismatch. Expected "${expectedOrigin}" but received "${clientData.origin}".`,
|
|
219
|
+
{ expected: expectedOrigin, received: clientData.origin },
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Step 2: Decode the attestation object (CBOR)
|
|
224
|
+
const attestationBytes = fromBase64Url(credential.attestationObject)
|
|
225
|
+
const attestationResult = decodeCbor(attestationBytes, 0)
|
|
226
|
+
const attestationMap = attestationResult.value as Map<string, unknown>
|
|
227
|
+
|
|
228
|
+
// Verify attestation format
|
|
229
|
+
const fmt = attestationMap.get('fmt')
|
|
230
|
+
if (fmt !== 'none') {
|
|
231
|
+
// For Phase 3, we only support "none" attestation.
|
|
232
|
+
// Other formats (packed, tpm, android-key, etc.) can be added later.
|
|
233
|
+
throw new PasskeyVerificationError(
|
|
234
|
+
`Unsupported attestation format "${String(fmt)}". Only "none" attestation is currently supported.`,
|
|
235
|
+
{ format: String(fmt) },
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Step 3: Parse the authenticator data
|
|
240
|
+
const authData = attestationMap.get('authData')
|
|
241
|
+
if (!(authData instanceof Uint8Array)) {
|
|
242
|
+
throw new PasskeyVerificationError(
|
|
243
|
+
'Invalid attestation object: authData is missing or not a byte string.',
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Verify the RP ID hash (first 32 bytes of authData)
|
|
248
|
+
const rpIdHash = authData.slice(0, 32)
|
|
249
|
+
const expectedRpIdHash = await sha256(new TextEncoder().encode(expectedRpId))
|
|
250
|
+
if (!constantTimeEqual(rpIdHash, new Uint8Array(expectedRpIdHash))) {
|
|
251
|
+
throw new PasskeyVerificationError(
|
|
252
|
+
'RP ID hash mismatch. The authenticator data does not match the expected relying party.',
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Parse flags (byte 32)
|
|
257
|
+
const flags = authData[32] as number
|
|
258
|
+
|
|
259
|
+
// Bit 0: User Present (UP) - must be set
|
|
260
|
+
if ((flags & 0x01) === 0) {
|
|
261
|
+
throw new PasskeyVerificationError(
|
|
262
|
+
'User Present flag is not set in authenticator data. ' +
|
|
263
|
+
'The authenticator did not confirm user presence.',
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Bit 2: User Verified (UV) - required unless explicitly relaxed (AUTH-14)
|
|
268
|
+
if (requireUserVerification && (flags & 0x04) === 0) {
|
|
269
|
+
throw new PasskeyVerificationError(
|
|
270
|
+
'User Verified flag is not set in authenticator data, but user verification is required.',
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Bit 6: Attested Credential Data (AT) - must be set for registration
|
|
275
|
+
if ((flags & 0x40) === 0) {
|
|
276
|
+
throw new PasskeyVerificationError(
|
|
277
|
+
'Attested Credential Data flag is not set. ' +
|
|
278
|
+
'The authenticator did not include credential data.',
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Parse sign count (bytes 33-36, big-endian uint32)
|
|
283
|
+
const signCount =
|
|
284
|
+
((authData[33] as number) << 24) |
|
|
285
|
+
((authData[34] as number) << 16) |
|
|
286
|
+
((authData[35] as number) << 8) |
|
|
287
|
+
(authData[36] as number)
|
|
288
|
+
|
|
289
|
+
// Parse attested credential data
|
|
290
|
+
// Skip rpIdHash (32) + flags (1) + signCount (4) = 37 bytes
|
|
291
|
+
let offset = 37
|
|
292
|
+
|
|
293
|
+
// aaguid: 16 bytes (we skip it — not needed for "none" attestation)
|
|
294
|
+
offset += 16
|
|
295
|
+
|
|
296
|
+
// credentialIdLength: 2 bytes, big-endian
|
|
297
|
+
const credentialIdLength = ((authData[offset] as number) << 8) | (authData[offset + 1] as number)
|
|
298
|
+
offset += 2
|
|
299
|
+
|
|
300
|
+
// credentialId: credentialIdLength bytes
|
|
301
|
+
const credentialIdBytes = authData.slice(offset, offset + credentialIdLength)
|
|
302
|
+
offset += credentialIdLength
|
|
303
|
+
|
|
304
|
+
// Verify the credential ID matches what the client sent
|
|
305
|
+
const expectedCredentialId = toBase64Url(credentialIdBytes.buffer as unknown as ArrayBuffer)
|
|
306
|
+
if (expectedCredentialId !== credential.credentialId) {
|
|
307
|
+
throw new PasskeyVerificationError(
|
|
308
|
+
'Credential ID mismatch between attestation object and client response.',
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The remaining bytes are the COSE-encoded public key
|
|
313
|
+
const coseKeyResult = decodeCbor(authData, offset)
|
|
314
|
+
const coseKeyBytes = authData.slice(offset, coseKeyResult.offset)
|
|
315
|
+
|
|
316
|
+
// Verify the public key matches what the client sent
|
|
317
|
+
const publicKeyFromAttestation = toBase64Url(coseKeyBytes.buffer as unknown as ArrayBuffer)
|
|
318
|
+
if (publicKeyFromAttestation !== credential.publicKey) {
|
|
319
|
+
throw new PasskeyVerificationError(
|
|
320
|
+
'Public key mismatch between attestation object and client response.',
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
verified: true,
|
|
326
|
+
credentialId: credential.credentialId,
|
|
327
|
+
publicKey: credential.publicKey,
|
|
328
|
+
signCount: signCount >>> 0,
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ============================================================================
|
|
333
|
+
// Authentication options generation
|
|
334
|
+
// ============================================================================
|
|
335
|
+
|
|
336
|
+
/** Options returned by generateAuthenticationOptions for the client. */
|
|
337
|
+
export interface AuthenticationOptions {
|
|
338
|
+
/** Base64url-encoded random challenge (32 bytes) */
|
|
339
|
+
challenge: string
|
|
340
|
+
/** Relying party ID */
|
|
341
|
+
rpId: string
|
|
342
|
+
/** Credential IDs to allow (limit to specific credentials) */
|
|
343
|
+
allowCredentialIds?: string[]
|
|
344
|
+
/** User verification requirement */
|
|
345
|
+
userVerification: 'preferred'
|
|
346
|
+
/** Timeout in milliseconds */
|
|
347
|
+
timeout: number
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Generate authentication options for signing in with a passkey.
|
|
352
|
+
*
|
|
353
|
+
* Creates a cryptographically random challenge and assembles the options
|
|
354
|
+
* object that should be sent to the client for `authenticateWithPasskey()`.
|
|
355
|
+
*
|
|
356
|
+
* The server must store the challenge for later verification.
|
|
357
|
+
*
|
|
358
|
+
* @param params - Authentication parameters
|
|
359
|
+
* @param params.rpId - Relying party ID (your domain)
|
|
360
|
+
* @param params.allowCredentialIds - Base64url credential IDs to allow (optional)
|
|
361
|
+
* @returns Authentication options to send to the client
|
|
362
|
+
*
|
|
363
|
+
* @example
|
|
364
|
+
* ```typescript
|
|
365
|
+
* const options = generateAuthenticationOptions({
|
|
366
|
+
* rpId: 'example.com',
|
|
367
|
+
* allowCredentialIds: user.credentialIds,
|
|
368
|
+
* })
|
|
369
|
+
* // Store options.challenge in session
|
|
370
|
+
* // Send options to client
|
|
371
|
+
* ```
|
|
372
|
+
*/
|
|
373
|
+
export function generateAuthenticationOptions(params: {
|
|
374
|
+
rpId: string
|
|
375
|
+
allowCredentialIds?: string[]
|
|
376
|
+
}): AuthenticationOptions {
|
|
377
|
+
const challengeBytes = randomBytes(32)
|
|
378
|
+
const challenge = toBase64Url(
|
|
379
|
+
challengeBytes.buffer.slice(
|
|
380
|
+
challengeBytes.byteOffset,
|
|
381
|
+
challengeBytes.byteOffset + challengeBytes.byteLength,
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
challenge,
|
|
387
|
+
rpId: params.rpId,
|
|
388
|
+
allowCredentialIds: params.allowCredentialIds,
|
|
389
|
+
userVerification: 'preferred',
|
|
390
|
+
timeout: 60000,
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ============================================================================
|
|
395
|
+
// Authentication verification
|
|
396
|
+
// ============================================================================
|
|
397
|
+
|
|
398
|
+
/** Result of verifying an authentication response. */
|
|
399
|
+
export interface AuthenticationVerificationResult {
|
|
400
|
+
/** Whether the authentication response was verified successfully */
|
|
401
|
+
verified: boolean
|
|
402
|
+
/** Updated signature counter (store this to detect cloned authenticators) */
|
|
403
|
+
newSignCount: number
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Verify an authentication response from the client.
|
|
408
|
+
*
|
|
409
|
+
* Validates the signed assertion returned by the browser's
|
|
410
|
+
* `navigator.credentials.get()` call. Checks the signature against the
|
|
411
|
+
* stored public key, verifies the challenge and origin, and validates
|
|
412
|
+
* the signature counter to detect cloned authenticators.
|
|
413
|
+
*
|
|
414
|
+
* This implementation supports ECDSA P-256 (ES256, COSE algorithm -7)
|
|
415
|
+
* signatures, which is the most common algorithm used by platform
|
|
416
|
+
* authenticators (Touch ID, Face ID, Windows Hello).
|
|
417
|
+
*
|
|
418
|
+
* @param params - Verification parameters
|
|
419
|
+
* @param params.assertion - The assertion response from the client
|
|
420
|
+
* @param params.expectedChallenge - The challenge that was sent to the client (base64url)
|
|
421
|
+
* @param params.expectedOrigin - The expected origin (e.g. "https://example.com")
|
|
422
|
+
* @param params.expectedRpId - The expected relying party ID
|
|
423
|
+
* @param params.publicKey - The stored COSE public key (base64url, from registration)
|
|
424
|
+
* @param params.previousSignCount - The previously stored signature counter
|
|
425
|
+
* @returns Verification result with the new signature counter
|
|
426
|
+
* @throws {PasskeyVerificationError} If the assertion is invalid
|
|
427
|
+
*
|
|
428
|
+
* @example
|
|
429
|
+
* ```typescript
|
|
430
|
+
* const result = await verifyAuthenticationResponse({
|
|
431
|
+
* assertion: clientAssertion,
|
|
432
|
+
* expectedChallenge: storedChallenge,
|
|
433
|
+
* expectedOrigin: 'https://example.com',
|
|
434
|
+
* expectedRpId: 'example.com',
|
|
435
|
+
* publicKey: storedCredential.publicKey,
|
|
436
|
+
* previousSignCount: storedCredential.signCount,
|
|
437
|
+
* })
|
|
438
|
+
* if (result.verified) {
|
|
439
|
+
* // Update stored sign count: storedCredential.signCount = result.newSignCount
|
|
440
|
+
* // Issue session tokens
|
|
441
|
+
* }
|
|
442
|
+
* ```
|
|
443
|
+
*/
|
|
444
|
+
export async function verifyAuthenticationResponse(params: {
|
|
445
|
+
assertion: {
|
|
446
|
+
credentialId: string
|
|
447
|
+
authenticatorData: string
|
|
448
|
+
clientDataJSON: string
|
|
449
|
+
signature: string
|
|
450
|
+
userHandle: string | null
|
|
451
|
+
}
|
|
452
|
+
expectedChallenge: string
|
|
453
|
+
expectedOrigin: string
|
|
454
|
+
expectedRpId: string
|
|
455
|
+
publicKey: string
|
|
456
|
+
previousSignCount: number
|
|
457
|
+
/**
|
|
458
|
+
* Require the User Verified (UV) flag. Kora's options request
|
|
459
|
+
* `userVerification: 'required'`, so the assertion must honour it.
|
|
460
|
+
* @default true
|
|
461
|
+
*/
|
|
462
|
+
requireUserVerification?: boolean
|
|
463
|
+
}): Promise<AuthenticationVerificationResult> {
|
|
464
|
+
const {
|
|
465
|
+
assertion,
|
|
466
|
+
expectedChallenge,
|
|
467
|
+
expectedOrigin,
|
|
468
|
+
expectedRpId,
|
|
469
|
+
publicKey,
|
|
470
|
+
previousSignCount,
|
|
471
|
+
} = params
|
|
472
|
+
|
|
473
|
+
// Step 1: Decode and verify clientDataJSON
|
|
474
|
+
const clientDataBytes = fromBase64Url(assertion.clientDataJSON)
|
|
475
|
+
const clientDataText = new TextDecoder().decode(clientDataBytes)
|
|
476
|
+
let clientData: { type: string; challenge: string; origin: string }
|
|
477
|
+
try {
|
|
478
|
+
clientData = JSON.parse(clientDataText) as {
|
|
479
|
+
type: string
|
|
480
|
+
challenge: string
|
|
481
|
+
origin: string
|
|
482
|
+
}
|
|
483
|
+
} catch {
|
|
484
|
+
throw new PasskeyVerificationError(
|
|
485
|
+
'Failed to parse clientDataJSON. The assertion may be malformed.',
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Verify the type is "webauthn.get"
|
|
490
|
+
if (clientData.type !== 'webauthn.get') {
|
|
491
|
+
throw new PasskeyVerificationError(
|
|
492
|
+
`Expected clientData.type "webauthn.get" but received "${clientData.type}".`,
|
|
493
|
+
{ type: clientData.type },
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Verify the challenge matches
|
|
498
|
+
if (clientData.challenge !== expectedChallenge) {
|
|
499
|
+
throw new PasskeyVerificationError(
|
|
500
|
+
'Challenge mismatch. The assertion does not match the expected challenge.',
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Verify the origin matches
|
|
505
|
+
if (clientData.origin !== expectedOrigin) {
|
|
506
|
+
throw new PasskeyVerificationError(
|
|
507
|
+
`Origin mismatch. Expected "${expectedOrigin}" but received "${clientData.origin}".`,
|
|
508
|
+
{ expected: expectedOrigin, received: clientData.origin },
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Step 2: Parse authenticator data
|
|
513
|
+
const authDataBytes = fromBase64Url(assertion.authenticatorData)
|
|
514
|
+
|
|
515
|
+
// Verify RP ID hash (first 32 bytes)
|
|
516
|
+
const rpIdHash = authDataBytes.slice(0, 32)
|
|
517
|
+
const expectedRpIdHash = await sha256(new TextEncoder().encode(expectedRpId))
|
|
518
|
+
if (!constantTimeEqual(rpIdHash, new Uint8Array(expectedRpIdHash))) {
|
|
519
|
+
throw new PasskeyVerificationError(
|
|
520
|
+
'RP ID hash mismatch. The authenticator data does not match the expected relying party.',
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Parse flags (byte 32)
|
|
525
|
+
const flags = authDataBytes[32] as number
|
|
526
|
+
|
|
527
|
+
// Bit 0: User Present (UP) - must be set
|
|
528
|
+
if ((flags & 0x01) === 0) {
|
|
529
|
+
throw new PasskeyVerificationError('User Present flag is not set in authenticator data.')
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Bit 2: User Verified (UV) - required unless explicitly relaxed (AUTH-14)
|
|
533
|
+
if ((params.requireUserVerification ?? true) && (flags & 0x04) === 0) {
|
|
534
|
+
throw new PasskeyVerificationError(
|
|
535
|
+
'User Verified flag is not set in authenticator data, but user verification is required.',
|
|
536
|
+
)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Parse sign count (bytes 33-36, big-endian uint32)
|
|
540
|
+
const signCount =
|
|
541
|
+
(((authDataBytes[33] as number) << 24) |
|
|
542
|
+
((authDataBytes[34] as number) << 16) |
|
|
543
|
+
((authDataBytes[35] as number) << 8) |
|
|
544
|
+
(authDataBytes[36] as number)) >>>
|
|
545
|
+
0
|
|
546
|
+
|
|
547
|
+
// Step 3: Validate sign count to detect cloned authenticators
|
|
548
|
+
// If both are 0, the authenticator doesn't support counters — skip check.
|
|
549
|
+
// If the new count is not greater than the previous, it may be cloned.
|
|
550
|
+
if (previousSignCount > 0 || signCount > 0) {
|
|
551
|
+
if (signCount <= previousSignCount) {
|
|
552
|
+
throw new PasskeyVerificationError(
|
|
553
|
+
`Signature counter did not increase. This may indicate a cloned authenticator. Previous count: ${previousSignCount}, received count: ${signCount}.`,
|
|
554
|
+
{
|
|
555
|
+
previousSignCount,
|
|
556
|
+
receivedSignCount: signCount,
|
|
557
|
+
},
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Step 4: Verify the signature
|
|
563
|
+
// The signature is over: authData || SHA-256(clientDataJSON)
|
|
564
|
+
const clientDataHash = await sha256(clientDataBytes)
|
|
565
|
+
const signedData = new Uint8Array(authDataBytes.length + clientDataHash.byteLength)
|
|
566
|
+
signedData.set(authDataBytes, 0)
|
|
567
|
+
signedData.set(new Uint8Array(clientDataHash), authDataBytes.length)
|
|
568
|
+
|
|
569
|
+
// Decode the COSE public key to get the raw EC key parameters
|
|
570
|
+
const coseKeyBytes = fromBase64Url(publicKey)
|
|
571
|
+
const coseKeyResult = decodeCbor(coseKeyBytes, 0)
|
|
572
|
+
const coseKeyMap = coseKeyResult.value as Map<number, unknown>
|
|
573
|
+
|
|
574
|
+
// COSE key map labels:
|
|
575
|
+
// 1: kty (key type) — 2 = EC2
|
|
576
|
+
// 3: alg (algorithm) — -7 = ES256
|
|
577
|
+
// -1: crv (curve) — 1 = P-256
|
|
578
|
+
// -2: x coordinate (byte string, 32 bytes)
|
|
579
|
+
// -3: y coordinate (byte string, 32 bytes)
|
|
580
|
+
|
|
581
|
+
const kty = coseKeyMap.get(1)
|
|
582
|
+
const alg = coseKeyMap.get(3)
|
|
583
|
+
|
|
584
|
+
if (kty !== 2) {
|
|
585
|
+
throw new PasskeyVerificationError(
|
|
586
|
+
`Unsupported COSE key type ${String(kty)}. Only EC2 (kty=2) is supported.`,
|
|
587
|
+
{ kty: String(kty) },
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (alg !== -7) {
|
|
592
|
+
throw new PasskeyVerificationError(
|
|
593
|
+
`Unsupported COSE algorithm ${String(alg)}. Only ES256 (alg=-7) is supported.`,
|
|
594
|
+
{ alg: String(alg) },
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const xCoord = coseKeyMap.get(-2) as Uint8Array
|
|
599
|
+
const yCoord = coseKeyMap.get(-3) as Uint8Array
|
|
600
|
+
|
|
601
|
+
if (
|
|
602
|
+
!(xCoord instanceof Uint8Array) ||
|
|
603
|
+
!(yCoord instanceof Uint8Array) ||
|
|
604
|
+
xCoord.length !== 32 ||
|
|
605
|
+
yCoord.length !== 32
|
|
606
|
+
) {
|
|
607
|
+
throw new PasskeyVerificationError(
|
|
608
|
+
'Invalid COSE public key: x and y coordinates must be 32-byte arrays.',
|
|
609
|
+
)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Import the public key as an ECDSA P-256 key for verification.
|
|
613
|
+
// We use the "raw" format: 0x04 || x || y (uncompressed point).
|
|
614
|
+
const rawPublicKey = new Uint8Array(65)
|
|
615
|
+
rawPublicKey[0] = 0x04 // Uncompressed point indicator
|
|
616
|
+
rawPublicKey.set(xCoord, 1)
|
|
617
|
+
rawPublicKey.set(yCoord, 33)
|
|
618
|
+
|
|
619
|
+
let cryptoKey: CryptoKey
|
|
620
|
+
try {
|
|
621
|
+
cryptoKey = await globalThis.crypto.subtle.importKey(
|
|
622
|
+
'raw',
|
|
623
|
+
rawPublicKey.buffer as unknown as ArrayBuffer,
|
|
624
|
+
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
625
|
+
false,
|
|
626
|
+
['verify'],
|
|
627
|
+
)
|
|
628
|
+
} catch (error) {
|
|
629
|
+
throw new PasskeyVerificationError(
|
|
630
|
+
'Failed to import COSE public key for signature verification.',
|
|
631
|
+
{ cause: error instanceof Error ? error.message : String(error) },
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// The WebAuthn signature is in ASN.1 DER format.
|
|
636
|
+
// Web Crypto's ECDSA verify expects the signature in IEEE P1363 format (r || s).
|
|
637
|
+
// Convert from DER to P1363.
|
|
638
|
+
const signatureBytes = fromBase64Url(assertion.signature)
|
|
639
|
+
const p1363Signature = derToP1363(signatureBytes, 32)
|
|
640
|
+
|
|
641
|
+
let verified: boolean
|
|
642
|
+
try {
|
|
643
|
+
verified = await globalThis.crypto.subtle.verify(
|
|
644
|
+
{ name: 'ECDSA', hash: { name: 'SHA-256' } },
|
|
645
|
+
cryptoKey,
|
|
646
|
+
p1363Signature.buffer as unknown as ArrayBuffer,
|
|
647
|
+
signedData.buffer as unknown as ArrayBuffer,
|
|
648
|
+
)
|
|
649
|
+
} catch (error) {
|
|
650
|
+
throw new PasskeyVerificationError('Signature verification operation failed.', {
|
|
651
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
652
|
+
})
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (!verified) {
|
|
656
|
+
return { verified: false, newSignCount: signCount }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return { verified: true, newSignCount: signCount }
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ============================================================================
|
|
663
|
+
// Internal helpers
|
|
664
|
+
// ============================================================================
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Compute SHA-256 hash of the given data using Web Crypto API.
|
|
668
|
+
*/
|
|
669
|
+
async function sha256(data: Uint8Array): Promise<ArrayBuffer> {
|
|
670
|
+
return globalThis.crypto.subtle.digest('SHA-256', data as unknown as ArrayBuffer)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Constant-time comparison of two byte arrays.
|
|
675
|
+
* Prevents timing attacks when comparing hashes or signatures.
|
|
676
|
+
*/
|
|
677
|
+
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
678
|
+
if (a.length !== b.length) {
|
|
679
|
+
return false
|
|
680
|
+
}
|
|
681
|
+
let result = 0
|
|
682
|
+
for (let i = 0; i < a.length; i++) {
|
|
683
|
+
result |= (a[i] as number) ^ (b[i] as number)
|
|
684
|
+
}
|
|
685
|
+
return result === 0
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Convert an ASN.1 DER-encoded ECDSA signature to IEEE P1363 format.
|
|
690
|
+
*
|
|
691
|
+
* DER format: 0x30 <len> 0x02 <r-len> <r> 0x02 <s-len> <s>
|
|
692
|
+
* P1363 format: <r-padded-to-n-bytes> <s-padded-to-n-bytes>
|
|
693
|
+
*
|
|
694
|
+
* This conversion is necessary because WebAuthn authenticators produce
|
|
695
|
+
* DER-encoded signatures, but the Web Crypto API expects P1363 format.
|
|
696
|
+
*
|
|
697
|
+
* @param derSignature - The DER-encoded signature bytes
|
|
698
|
+
* @param componentLength - The expected length of each component (32 for P-256)
|
|
699
|
+
* @returns The P1363-formatted signature
|
|
700
|
+
*/
|
|
701
|
+
function derToP1363(derSignature: Uint8Array, componentLength: number): Uint8Array {
|
|
702
|
+
// Parse the DER structure
|
|
703
|
+
let offset = 0
|
|
704
|
+
|
|
705
|
+
// SEQUENCE tag (0x30)
|
|
706
|
+
if (derSignature[offset] !== 0x30) {
|
|
707
|
+
throw new PasskeyVerificationError('Invalid DER signature: expected SEQUENCE tag (0x30).')
|
|
708
|
+
}
|
|
709
|
+
offset += 1
|
|
710
|
+
|
|
711
|
+
// SEQUENCE length (may be 1 or 2 bytes)
|
|
712
|
+
if ((derSignature[offset] as number) & 0x80) {
|
|
713
|
+
// Long form: the lower 7 bits give the number of length bytes
|
|
714
|
+
const lengthBytes = (derSignature[offset] as number) & 0x7f
|
|
715
|
+
offset += 1 + lengthBytes
|
|
716
|
+
} else {
|
|
717
|
+
offset += 1
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// First INTEGER (r)
|
|
721
|
+
if (derSignature[offset] !== 0x02) {
|
|
722
|
+
throw new PasskeyVerificationError(
|
|
723
|
+
'Invalid DER signature: expected INTEGER tag (0x02) for r component.',
|
|
724
|
+
)
|
|
725
|
+
}
|
|
726
|
+
offset += 1
|
|
727
|
+
|
|
728
|
+
const rLength = derSignature[offset] as number
|
|
729
|
+
offset += 1
|
|
730
|
+
|
|
731
|
+
const rBytes = derSignature.slice(offset, offset + rLength)
|
|
732
|
+
offset += rLength
|
|
733
|
+
|
|
734
|
+
// Second INTEGER (s)
|
|
735
|
+
if (derSignature[offset] !== 0x02) {
|
|
736
|
+
throw new PasskeyVerificationError(
|
|
737
|
+
'Invalid DER signature: expected INTEGER tag (0x02) for s component.',
|
|
738
|
+
)
|
|
739
|
+
}
|
|
740
|
+
offset += 1
|
|
741
|
+
|
|
742
|
+
const sLength = derSignature[offset] as number
|
|
743
|
+
offset += 1
|
|
744
|
+
|
|
745
|
+
const sBytes = derSignature.slice(offset, offset + sLength)
|
|
746
|
+
|
|
747
|
+
// Pad or trim r and s to componentLength bytes.
|
|
748
|
+
// DER integers may have a leading 0x00 byte to indicate positive sign,
|
|
749
|
+
// or may be shorter than componentLength if the leading bytes are zero.
|
|
750
|
+
const result = new Uint8Array(componentLength * 2)
|
|
751
|
+
copyComponentToP1363(rBytes, result, 0, componentLength)
|
|
752
|
+
copyComponentToP1363(sBytes, result, componentLength, componentLength)
|
|
753
|
+
|
|
754
|
+
return result
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Copy a DER integer component into a fixed-width P1363 buffer.
|
|
759
|
+
* Handles leading zero padding (DER sign byte) and right-alignment.
|
|
760
|
+
*/
|
|
761
|
+
function copyComponentToP1363(
|
|
762
|
+
component: Uint8Array,
|
|
763
|
+
target: Uint8Array,
|
|
764
|
+
targetOffset: number,
|
|
765
|
+
componentLength: number,
|
|
766
|
+
): void {
|
|
767
|
+
if (component.length === componentLength) {
|
|
768
|
+
// Exact fit
|
|
769
|
+
target.set(component, targetOffset)
|
|
770
|
+
} else if (component.length > componentLength) {
|
|
771
|
+
// DER may have a leading 0x00 sign byte — strip it
|
|
772
|
+
const excess = component.length - componentLength
|
|
773
|
+
target.set(component.slice(excess), targetOffset)
|
|
774
|
+
} else {
|
|
775
|
+
// Component is shorter — right-align with zero padding
|
|
776
|
+
const padding = componentLength - component.length
|
|
777
|
+
target.set(component, targetOffset + padding)
|
|
778
|
+
}
|
|
779
|
+
}
|