@korajs/auth 1.0.0-beta.11 → 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,597 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
import { fromBase64Url, toBase64Url } from '../device/device-identity'
|
|
3
|
+
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// Passkey-specific errors
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Thrown when a passkey operation fails (registration, authentication,
|
|
10
|
+
* or browser API interaction).
|
|
11
|
+
*/
|
|
12
|
+
export class PasskeyError extends KoraError {
|
|
13
|
+
constructor(message: string, context?: Record<string, unknown>) {
|
|
14
|
+
super(message, 'PASSKEY_ERROR', context)
|
|
15
|
+
this.name = 'PasskeyError'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Thrown when the browser does not support WebAuthn.
|
|
21
|
+
* Passkey authentication requires a modern browser with the
|
|
22
|
+
* Web Authentication API (navigator.credentials).
|
|
23
|
+
*/
|
|
24
|
+
export class PasskeyUnsupportedError extends KoraError {
|
|
25
|
+
constructor() {
|
|
26
|
+
super(
|
|
27
|
+
'WebAuthn is not supported in this browser. Passkey authentication requires a modern browser with WebAuthn support.',
|
|
28
|
+
'PASSKEY_UNSUPPORTED',
|
|
29
|
+
)
|
|
30
|
+
this.name = 'PasskeyUnsupportedError'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Passkey registration response
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/** Response from a passkey registration (credential creation). */
|
|
39
|
+
export interface PasskeyRegistrationResponse {
|
|
40
|
+
/** Base64url-encoded credential ID */
|
|
41
|
+
credentialId: string
|
|
42
|
+
/** Base64url-encoded public key (COSE format) */
|
|
43
|
+
publicKey: string
|
|
44
|
+
/** Base64url-encoded clientDataJSON */
|
|
45
|
+
clientDataJSON: string
|
|
46
|
+
/** Base64url-encoded attestation object */
|
|
47
|
+
attestationObject: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Passkey authentication response
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
/** Response from a passkey authentication (assertion). */
|
|
55
|
+
export interface PasskeyAuthenticationResponse {
|
|
56
|
+
/** Base64url-encoded credential ID */
|
|
57
|
+
credentialId: string
|
|
58
|
+
/** Base64url-encoded authenticator data */
|
|
59
|
+
authenticatorData: string
|
|
60
|
+
/** Base64url-encoded clientDataJSON */
|
|
61
|
+
clientDataJSON: string
|
|
62
|
+
/** Base64url-encoded ECDSA signature */
|
|
63
|
+
signature: string
|
|
64
|
+
/** Base64url-encoded user handle (may be null for non-resident keys) */
|
|
65
|
+
userHandle: string | null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// Feature detection
|
|
70
|
+
// ============================================================================
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if WebAuthn/passkeys are supported in the current environment.
|
|
74
|
+
*
|
|
75
|
+
* Returns true if the `navigator.credentials` API is available and supports
|
|
76
|
+
* the `create` and `get` methods required for WebAuthn.
|
|
77
|
+
*
|
|
78
|
+
* @returns `true` if WebAuthn is available, `false` otherwise
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```typescript
|
|
82
|
+
* if (isPasskeySupported()) {
|
|
83
|
+
* // Show passkey login option
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function isPasskeySupported(): boolean {
|
|
88
|
+
return (
|
|
89
|
+
typeof globalThis.navigator !== 'undefined' &&
|
|
90
|
+
typeof globalThis.navigator.credentials !== 'undefined' &&
|
|
91
|
+
typeof globalThis.navigator.credentials.create === 'function' &&
|
|
92
|
+
typeof globalThis.navigator.credentials.get === 'function'
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check if a platform authenticator (biometric) is available.
|
|
98
|
+
*
|
|
99
|
+
* A platform authenticator is built into the device (Touch ID, Face ID,
|
|
100
|
+
* Windows Hello). Returns false if only roaming authenticators (security
|
|
101
|
+
* keys) are available, or if WebAuthn is not supported.
|
|
102
|
+
*
|
|
103
|
+
* @returns `true` if a platform authenticator is available
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* if (await isPlatformAuthenticatorAvailable()) {
|
|
108
|
+
* // Show "Sign in with Touch ID" button
|
|
109
|
+
* }
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
export async function isPlatformAuthenticatorAvailable(): Promise<boolean> {
|
|
113
|
+
if (!isPasskeySupported()) {
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable is
|
|
118
|
+
// the standard way to check for biometric/platform authenticator support.
|
|
119
|
+
if (
|
|
120
|
+
typeof PublicKeyCredential !== 'undefined' &&
|
|
121
|
+
typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function'
|
|
122
|
+
) {
|
|
123
|
+
try {
|
|
124
|
+
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
|
125
|
+
} catch {
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// Registration (credential creation)
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Create a passkey credential (registration flow).
|
|
139
|
+
*
|
|
140
|
+
* Called during user registration. The server provides a challenge and user info,
|
|
141
|
+
* the browser prompts for biometric verification, and this function returns the
|
|
142
|
+
* credential data to send back to the server for verification.
|
|
143
|
+
*
|
|
144
|
+
* @param options - Registration options from the server
|
|
145
|
+
* @param options.challenge - Base64url-encoded challenge from server
|
|
146
|
+
* @param options.rpId - Relying party ID (your domain, e.g. "example.com")
|
|
147
|
+
* @param options.rpName - Relying party display name (e.g. "My App")
|
|
148
|
+
* @param options.userId - User ID as base64url-encoded opaque bytes
|
|
149
|
+
* @param options.userName - User's email or username for display
|
|
150
|
+
* @param options.userDisplayName - Human-readable display name
|
|
151
|
+
* @param options.excludeCredentialIds - Credential IDs to exclude (prevents re-registration)
|
|
152
|
+
* @param options.authenticatorSelection - Authenticator selection criteria
|
|
153
|
+
* @returns The credential response to send to the server for verification
|
|
154
|
+
* @throws {PasskeyUnsupportedError} If WebAuthn is not available
|
|
155
|
+
* @throws {PasskeyError} If credential creation fails or is cancelled by the user
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```typescript
|
|
159
|
+
* const credential = await createPasskeyCredential({
|
|
160
|
+
* challenge: serverOptions.challenge,
|
|
161
|
+
* rpId: 'example.com',
|
|
162
|
+
* rpName: 'My App',
|
|
163
|
+
* userId: serverOptions.userId,
|
|
164
|
+
* userName: 'alice@example.com',
|
|
165
|
+
* userDisplayName: 'Alice',
|
|
166
|
+
* })
|
|
167
|
+
* // Send `credential` to server for verification
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export async function createPasskeyCredential(options: {
|
|
171
|
+
challenge: string
|
|
172
|
+
rpId: string
|
|
173
|
+
rpName: string
|
|
174
|
+
userId: string
|
|
175
|
+
userName: string
|
|
176
|
+
userDisplayName: string
|
|
177
|
+
excludeCredentialIds?: string[]
|
|
178
|
+
authenticatorSelection?: {
|
|
179
|
+
authenticatorAttachment?: 'platform' | 'cross-platform'
|
|
180
|
+
residentKey?: 'required' | 'preferred' | 'discouraged'
|
|
181
|
+
userVerification?: 'required' | 'preferred' | 'discouraged'
|
|
182
|
+
}
|
|
183
|
+
}): Promise<PasskeyRegistrationResponse> {
|
|
184
|
+
if (!isPasskeySupported()) {
|
|
185
|
+
throw new PasskeyUnsupportedError()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Build the excludeCredentials list from base64url credential IDs
|
|
189
|
+
const excludeCredentials: PublicKeyCredentialDescriptor[] = (
|
|
190
|
+
options.excludeCredentialIds ?? []
|
|
191
|
+
).map((id) => ({
|
|
192
|
+
type: 'public-key' as const,
|
|
193
|
+
id: fromBase64Url(id).buffer as unknown as ArrayBuffer,
|
|
194
|
+
}))
|
|
195
|
+
|
|
196
|
+
// Build the authenticator selection criteria with sensible defaults
|
|
197
|
+
const authenticatorSelection: AuthenticatorSelectionCriteria = {
|
|
198
|
+
authenticatorAttachment: options.authenticatorSelection?.authenticatorAttachment ?? 'platform',
|
|
199
|
+
residentKey: options.authenticatorSelection?.residentKey ?? 'preferred',
|
|
200
|
+
userVerification: options.authenticatorSelection?.userVerification ?? 'required',
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// If residentKey is 'required', requireResidentKey must also be true
|
|
204
|
+
// for backwards compatibility with older browsers.
|
|
205
|
+
if (authenticatorSelection.residentKey === 'required') {
|
|
206
|
+
authenticatorSelection.requireResidentKey = true
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const publicKeyOptions: PublicKeyCredentialCreationOptions = {
|
|
210
|
+
challenge: fromBase64Url(options.challenge).buffer as unknown as ArrayBuffer,
|
|
211
|
+
rp: {
|
|
212
|
+
id: options.rpId,
|
|
213
|
+
name: options.rpName,
|
|
214
|
+
},
|
|
215
|
+
user: {
|
|
216
|
+
id: fromBase64Url(options.userId).buffer as unknown as ArrayBuffer,
|
|
217
|
+
name: options.userName,
|
|
218
|
+
displayName: options.userDisplayName,
|
|
219
|
+
},
|
|
220
|
+
pubKeyCredParams: [
|
|
221
|
+
// ES256 (ECDSA w/ SHA-256) — the most widely supported algorithm
|
|
222
|
+
{ type: 'public-key', alg: -7 },
|
|
223
|
+
// RS256 (RSASSA-PKCS1-v1_5 w/ SHA-256) — fallback for older authenticators
|
|
224
|
+
{ type: 'public-key', alg: -257 },
|
|
225
|
+
],
|
|
226
|
+
excludeCredentials,
|
|
227
|
+
authenticatorSelection,
|
|
228
|
+
timeout: 60000,
|
|
229
|
+
attestation: 'none',
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let credential: PublicKeyCredential
|
|
233
|
+
try {
|
|
234
|
+
const result = await navigator.credentials.create({
|
|
235
|
+
publicKey: publicKeyOptions,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
if (result === null) {
|
|
239
|
+
throw new PasskeyError(
|
|
240
|
+
'Credential creation returned null. The user may have cancelled the operation.',
|
|
241
|
+
{ rpId: options.rpId },
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
credential = result as PublicKeyCredential
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error instanceof PasskeyError) {
|
|
248
|
+
throw error
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Map common WebAuthn DOMException names to user-friendly messages
|
|
252
|
+
const domError = error as DOMException
|
|
253
|
+
if (domError.name === 'NotAllowedError') {
|
|
254
|
+
throw new PasskeyError(
|
|
255
|
+
'Passkey creation was cancelled or not allowed. The user may have dismissed the prompt or the operation timed out.',
|
|
256
|
+
{ rpId: options.rpId, errorName: domError.name },
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
if (domError.name === 'InvalidStateError') {
|
|
260
|
+
throw new PasskeyError(
|
|
261
|
+
'A passkey already exists for this user on this authenticator. Use the existing passkey to sign in.',
|
|
262
|
+
{ rpId: options.rpId, errorName: domError.name },
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
throw new PasskeyError(
|
|
267
|
+
`Passkey creation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
268
|
+
{
|
|
269
|
+
rpId: options.rpId,
|
|
270
|
+
errorName: error instanceof Error ? error.name : undefined,
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const response = credential.response as AuthenticatorAttestationResponse
|
|
276
|
+
|
|
277
|
+
// Extract the public key from the attestation response.
|
|
278
|
+
// getPublicKey() returns the SubjectPublicKeyInfo (SPKI) encoded public key.
|
|
279
|
+
// We also need the raw COSE public key from the attestation for server-side storage.
|
|
280
|
+
// The attestation object contains the credential public key in COSE format.
|
|
281
|
+
const attestationObject = new Uint8Array(response.attestationObject)
|
|
282
|
+
const clientDataJSON = new Uint8Array(response.clientDataJSON)
|
|
283
|
+
|
|
284
|
+
// Extract the COSE public key from the authenticator data within the attestation object.
|
|
285
|
+
// The attestation object is CBOR-encoded and contains authData which includes the
|
|
286
|
+
// credential public key in COSE_Key format.
|
|
287
|
+
const publicKeyBytes = extractPublicKeyFromAttestationObject(attestationObject)
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
credentialId: toBase64Url(credential.rawId),
|
|
291
|
+
publicKey: toBase64Url(publicKeyBytes.buffer as unknown as ArrayBuffer),
|
|
292
|
+
clientDataJSON: toBase64Url(clientDataJSON.buffer as unknown as ArrayBuffer),
|
|
293
|
+
attestationObject: toBase64Url(attestationObject.buffer as unknown as ArrayBuffer),
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ============================================================================
|
|
298
|
+
// Authentication (assertion)
|
|
299
|
+
// ============================================================================
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Authenticate with a passkey (assertion flow).
|
|
303
|
+
*
|
|
304
|
+
* Called during login. The server provides a challenge, the browser prompts
|
|
305
|
+
* for biometric verification, and this function returns the signed assertion
|
|
306
|
+
* to send back to the server for verification.
|
|
307
|
+
*
|
|
308
|
+
* @param options - Authentication options from the server
|
|
309
|
+
* @param options.challenge - Base64url-encoded challenge from server
|
|
310
|
+
* @param options.rpId - Relying party ID (your domain)
|
|
311
|
+
* @param options.allowCredentialIds - Limit authentication to specific credentials
|
|
312
|
+
* @param options.userVerification - User verification requirement (default: 'preferred')
|
|
313
|
+
* @param options.timeout - Timeout in milliseconds (default: 60000)
|
|
314
|
+
* @returns The assertion response to send to the server for verification
|
|
315
|
+
* @throws {PasskeyUnsupportedError} If WebAuthn is not available
|
|
316
|
+
* @throws {PasskeyError} If authentication fails or is cancelled by the user
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* ```typescript
|
|
320
|
+
* const assertion = await authenticateWithPasskey({
|
|
321
|
+
* challenge: serverOptions.challenge,
|
|
322
|
+
* rpId: 'example.com',
|
|
323
|
+
* })
|
|
324
|
+
* // Send `assertion` to server for verification
|
|
325
|
+
* ```
|
|
326
|
+
*/
|
|
327
|
+
export async function authenticateWithPasskey(options: {
|
|
328
|
+
challenge: string
|
|
329
|
+
rpId: string
|
|
330
|
+
allowCredentialIds?: string[]
|
|
331
|
+
userVerification?: 'required' | 'preferred' | 'discouraged'
|
|
332
|
+
timeout?: number
|
|
333
|
+
}): Promise<PasskeyAuthenticationResponse> {
|
|
334
|
+
if (!isPasskeySupported()) {
|
|
335
|
+
throw new PasskeyUnsupportedError()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Build allowCredentials list from base64url credential IDs
|
|
339
|
+
const allowCredentials: PublicKeyCredentialDescriptor[] | undefined =
|
|
340
|
+
options.allowCredentialIds?.map((id) => ({
|
|
341
|
+
type: 'public-key' as const,
|
|
342
|
+
id: fromBase64Url(id).buffer as unknown as ArrayBuffer,
|
|
343
|
+
}))
|
|
344
|
+
|
|
345
|
+
const publicKeyOptions: PublicKeyCredentialRequestOptions = {
|
|
346
|
+
challenge: fromBase64Url(options.challenge).buffer as unknown as ArrayBuffer,
|
|
347
|
+
rpId: options.rpId,
|
|
348
|
+
allowCredentials,
|
|
349
|
+
userVerification: options.userVerification ?? 'preferred',
|
|
350
|
+
timeout: options.timeout ?? 60000,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let credential: PublicKeyCredential
|
|
354
|
+
try {
|
|
355
|
+
const result = await navigator.credentials.get({
|
|
356
|
+
publicKey: publicKeyOptions,
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
if (result === null) {
|
|
360
|
+
throw new PasskeyError(
|
|
361
|
+
'Authentication returned null. The user may have cancelled the operation.',
|
|
362
|
+
{ rpId: options.rpId },
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
credential = result as PublicKeyCredential
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error instanceof PasskeyError) {
|
|
369
|
+
throw error
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const domError = error as DOMException
|
|
373
|
+
if (domError.name === 'NotAllowedError') {
|
|
374
|
+
throw new PasskeyError(
|
|
375
|
+
'Passkey authentication was cancelled or not allowed. The user may have dismissed the prompt or the operation timed out.',
|
|
376
|
+
{ rpId: options.rpId, errorName: domError.name },
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
throw new PasskeyError(
|
|
381
|
+
`Passkey authentication failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
382
|
+
{
|
|
383
|
+
rpId: options.rpId,
|
|
384
|
+
errorName: error instanceof Error ? error.name : undefined,
|
|
385
|
+
},
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const response = credential.response as AuthenticatorAssertionResponse
|
|
390
|
+
|
|
391
|
+
const authenticatorData = new Uint8Array(response.authenticatorData)
|
|
392
|
+
const clientDataJSON = new Uint8Array(response.clientDataJSON)
|
|
393
|
+
const signature = new Uint8Array(response.signature)
|
|
394
|
+
const userHandle =
|
|
395
|
+
response.userHandle !== null && response.userHandle.byteLength > 0
|
|
396
|
+
? toBase64Url(response.userHandle)
|
|
397
|
+
: null
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
credentialId: toBase64Url(credential.rawId),
|
|
401
|
+
authenticatorData: toBase64Url(authenticatorData.buffer as unknown as ArrayBuffer),
|
|
402
|
+
clientDataJSON: toBase64Url(clientDataJSON.buffer as unknown as ArrayBuffer),
|
|
403
|
+
signature: toBase64Url(signature.buffer as unknown as ArrayBuffer),
|
|
404
|
+
userHandle,
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ============================================================================
|
|
409
|
+
// Internal: Extract public key from attestation object
|
|
410
|
+
// ============================================================================
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Extracts the COSE-encoded public key from a CBOR-encoded attestation object.
|
|
414
|
+
*
|
|
415
|
+
* The attestation object structure (CBOR map):
|
|
416
|
+
* - "fmt": attestation format (e.g. "none")
|
|
417
|
+
* - "attStmt": attestation statement (empty map for "none")
|
|
418
|
+
* - "authData": authenticator data (byte string)
|
|
419
|
+
*
|
|
420
|
+
* The authenticator data structure:
|
|
421
|
+
* - rpIdHash (32 bytes)
|
|
422
|
+
* - flags (1 byte)
|
|
423
|
+
* - signCount (4 bytes, big-endian)
|
|
424
|
+
* - [if flags.AT set] attestedCredentialData:
|
|
425
|
+
* - aaguid (16 bytes)
|
|
426
|
+
* - credentialIdLength (2 bytes, big-endian)
|
|
427
|
+
* - credentialId (credentialIdLength bytes)
|
|
428
|
+
* - credentialPublicKey (CBOR-encoded COSE_Key, remaining bytes)
|
|
429
|
+
*/
|
|
430
|
+
function extractPublicKeyFromAttestationObject(attestationObject: Uint8Array): Uint8Array {
|
|
431
|
+
// Decode the top-level CBOR map to get authData
|
|
432
|
+
const decoded = decodeCbor(attestationObject, 0)
|
|
433
|
+
const topMap = decoded.value as Map<string, unknown>
|
|
434
|
+
const authData = topMap.get('authData')
|
|
435
|
+
|
|
436
|
+
if (!(authData instanceof Uint8Array)) {
|
|
437
|
+
throw new PasskeyError('Invalid attestation object: authData is missing or not a byte string.')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Parse authenticator data to find the credential public key
|
|
441
|
+
let offset = 0
|
|
442
|
+
|
|
443
|
+
// rpIdHash: 32 bytes
|
|
444
|
+
offset += 32
|
|
445
|
+
|
|
446
|
+
// flags: 1 byte
|
|
447
|
+
const flags = authData[offset] as number
|
|
448
|
+
offset += 1
|
|
449
|
+
|
|
450
|
+
// signCount: 4 bytes
|
|
451
|
+
offset += 4
|
|
452
|
+
|
|
453
|
+
// Check if attestedCredentialData is present (bit 6 of flags)
|
|
454
|
+
const hasAttestedCredentialData = (flags & 0x40) !== 0
|
|
455
|
+
if (!hasAttestedCredentialData) {
|
|
456
|
+
throw new PasskeyError(
|
|
457
|
+
'Attestation object does not contain attested credential data. ' +
|
|
458
|
+
'The authenticator did not include a public key.',
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// aaguid: 16 bytes
|
|
463
|
+
offset += 16
|
|
464
|
+
|
|
465
|
+
// credentialIdLength: 2 bytes, big-endian
|
|
466
|
+
const credentialIdLength = ((authData[offset] as number) << 8) | (authData[offset + 1] as number)
|
|
467
|
+
offset += 2
|
|
468
|
+
|
|
469
|
+
// credentialId: credentialIdLength bytes
|
|
470
|
+
offset += credentialIdLength
|
|
471
|
+
|
|
472
|
+
// The remaining bytes in authData from this offset are the CBOR-encoded
|
|
473
|
+
// COSE public key. We need to extract exactly those bytes.
|
|
474
|
+
// To find the exact length, we decode the CBOR value and use the consumed byte count.
|
|
475
|
+
const coseKeyResult = decodeCbor(authData, offset)
|
|
476
|
+
const coseKeyLength = coseKeyResult.offset - offset
|
|
477
|
+
|
|
478
|
+
return authData.slice(offset, offset + coseKeyLength)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ============================================================================
|
|
482
|
+
// Minimal CBOR decoder
|
|
483
|
+
// ============================================================================
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Minimal CBOR decoder supporting only the types needed for WebAuthn:
|
|
487
|
+
* - Major type 0: Unsigned integer
|
|
488
|
+
* - Major type 1: Negative integer
|
|
489
|
+
* - Major type 2: Byte string
|
|
490
|
+
* - Major type 3: Text string
|
|
491
|
+
* - Major type 4: Array
|
|
492
|
+
* - Major type 5: Map
|
|
493
|
+
*
|
|
494
|
+
* This decoder handles the subset of CBOR used in WebAuthn attestation
|
|
495
|
+
* objects and COSE keys. It does not support tags, floats, or indefinite-length
|
|
496
|
+
* encodings, which are not used in the WebAuthn specification.
|
|
497
|
+
*/
|
|
498
|
+
|
|
499
|
+
interface CborDecodeResult {
|
|
500
|
+
value: unknown
|
|
501
|
+
/** Byte offset after the decoded value (used for sequential decoding) */
|
|
502
|
+
offset: number
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function decodeCbor(data: Uint8Array, offset: number): CborDecodeResult {
|
|
506
|
+
let pos = offset
|
|
507
|
+
|
|
508
|
+
if (pos >= data.length) {
|
|
509
|
+
throw new PasskeyError('CBOR decode error: unexpected end of data.')
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const initialByte = data[pos] as number
|
|
513
|
+
const majorType = initialByte >> 5
|
|
514
|
+
const additionalInfo = initialByte & 0x1f
|
|
515
|
+
pos += 1
|
|
516
|
+
|
|
517
|
+
// Decode the argument (length or value) based on additionalInfo
|
|
518
|
+
let argument: number
|
|
519
|
+
if (additionalInfo < 24) {
|
|
520
|
+
argument = additionalInfo
|
|
521
|
+
} else if (additionalInfo === 24) {
|
|
522
|
+
argument = data[pos] as number
|
|
523
|
+
pos += 1
|
|
524
|
+
} else if (additionalInfo === 25) {
|
|
525
|
+
argument = ((data[pos] as number) << 8) | (data[pos + 1] as number)
|
|
526
|
+
pos += 2
|
|
527
|
+
} else if (additionalInfo === 26) {
|
|
528
|
+
argument =
|
|
529
|
+
((data[pos] as number) << 24) |
|
|
530
|
+
((data[pos + 1] as number) << 16) |
|
|
531
|
+
((data[pos + 2] as number) << 8) |
|
|
532
|
+
(data[pos + 3] as number)
|
|
533
|
+
// Handle unsigned 32-bit properly (bitwise ops produce signed 32-bit in JS)
|
|
534
|
+
argument = argument >>> 0
|
|
535
|
+
pos += 4
|
|
536
|
+
} else {
|
|
537
|
+
throw new PasskeyError(
|
|
538
|
+
`CBOR decode error: unsupported additional info ${additionalInfo} at byte ${pos - 1}. This CBOR decoder only supports definite-length encodings.`,
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
switch (majorType) {
|
|
543
|
+
// Major type 0: Unsigned integer
|
|
544
|
+
case 0:
|
|
545
|
+
return { value: argument, offset: pos }
|
|
546
|
+
|
|
547
|
+
// Major type 1: Negative integer (-1 - argument)
|
|
548
|
+
case 1:
|
|
549
|
+
return { value: -1 - argument, offset: pos }
|
|
550
|
+
|
|
551
|
+
// Major type 2: Byte string
|
|
552
|
+
case 2: {
|
|
553
|
+
const bytes = data.slice(pos, pos + argument)
|
|
554
|
+
return { value: bytes, offset: pos + argument }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Major type 3: Text string (UTF-8)
|
|
558
|
+
case 3: {
|
|
559
|
+
const textBytes = data.slice(pos, pos + argument)
|
|
560
|
+
const text = new TextDecoder().decode(textBytes)
|
|
561
|
+
return { value: text, offset: pos + argument }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Major type 4: Array
|
|
565
|
+
case 4: {
|
|
566
|
+
const arr: unknown[] = []
|
|
567
|
+
let currentOffset = pos
|
|
568
|
+
for (let i = 0; i < argument; i++) {
|
|
569
|
+
const item = decodeCbor(data, currentOffset)
|
|
570
|
+
arr.push(item.value)
|
|
571
|
+
currentOffset = item.offset
|
|
572
|
+
}
|
|
573
|
+
return { value: arr, offset: currentOffset }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Major type 5: Map
|
|
577
|
+
case 5: {
|
|
578
|
+
const map = new Map<string | number, unknown>()
|
|
579
|
+
let currentOffset = pos
|
|
580
|
+
for (let i = 0; i < argument; i++) {
|
|
581
|
+
const keyResult = decodeCbor(data, currentOffset)
|
|
582
|
+
const valResult = decodeCbor(data, keyResult.offset)
|
|
583
|
+
map.set(keyResult.value as string | number, valResult.value)
|
|
584
|
+
currentOffset = valResult.offset
|
|
585
|
+
}
|
|
586
|
+
return { value: map, offset: currentOffset }
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
default:
|
|
590
|
+
throw new PasskeyError(
|
|
591
|
+
`CBOR decode error: unsupported major type ${majorType} at byte ${pos - 1}. This CBOR decoder only supports types 0-5 (integers, byte/text strings, arrays, maps).`,
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Export the CBOR decoder for testing purposes (used by passkey-server too)
|
|
597
|
+
export { decodeCbor, type CborDecodeResult }
|