@better-auth/passkey 1.4.6-beta.2 → 1.4.6
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/client.d.mts +4 -3
- package/dist/{index-DhzUw_n7.d.mts → index-HkgxjeuE.d.mts} +33 -69
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +537 -471
- package/package.json +10 -8
package/dist/client.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as Passkey, t as passkey } from "./index-
|
|
1
|
+
import { i as WebAuthnChallengeValue, n as Passkey, r as PasskeyOptions, t as passkey } from "./index-HkgxjeuE.mjs";
|
|
2
2
|
import * as better_auth0 from "better-auth";
|
|
3
3
|
import * as nanostores0 from "nanostores";
|
|
4
4
|
import { atom } from "nanostores";
|
|
@@ -6,6 +6,7 @@ import * as _better_fetch_fetch0 from "@better-fetch/fetch";
|
|
|
6
6
|
import { BetterFetch } from "@better-fetch/fetch";
|
|
7
7
|
import { ClientFetchOption, ClientStore } from "@better-auth/core";
|
|
8
8
|
import { Session, User } from "better-auth/types";
|
|
9
|
+
export * from "@simplewebauthn/server";
|
|
9
10
|
|
|
10
11
|
//#region src/client.d.ts
|
|
11
12
|
declare const getPasskeyActions: ($fetch: BetterFetch, {
|
|
@@ -194,7 +195,7 @@ declare const passkeyClient: () => {
|
|
|
194
195
|
"/passkey/authenticate": "POST";
|
|
195
196
|
};
|
|
196
197
|
atomListeners: ({
|
|
197
|
-
matcher(path: string): path is "/passkey/
|
|
198
|
+
matcher(path: string): path is "/passkey/delete-passkey" | "/passkey/update-passkey" | "/passkey/verify-registration" | "/sign-out";
|
|
198
199
|
signal: "$listPasskeys";
|
|
199
200
|
} | {
|
|
200
201
|
matcher: (path: string) => path is "/passkey/verify-authentication";
|
|
@@ -202,4 +203,4 @@ declare const passkeyClient: () => {
|
|
|
202
203
|
})[];
|
|
203
204
|
};
|
|
204
205
|
//#endregion
|
|
205
|
-
export { getPasskeyActions, passkeyClient };
|
|
206
|
+
export { Passkey, PasskeyOptions, WebAuthnChallengeValue, getPasskeyActions, passkeyClient };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as _simplewebauthn_server0 from "@simplewebauthn/server";
|
|
2
|
-
import {
|
|
2
|
+
import { CredentialDeviceType } from "@simplewebauthn/server";
|
|
3
|
+
import * as better_auth0 from "better-auth";
|
|
3
4
|
import * as better_call0 from "better-call";
|
|
4
|
-
import * as
|
|
5
|
+
import * as zod0 from "zod";
|
|
5
6
|
import { InferOptionSchema } from "better-auth/types";
|
|
6
7
|
|
|
7
8
|
//#region src/schema.d.ts
|
|
@@ -59,7 +60,15 @@ declare const schema: {
|
|
|
59
60
|
};
|
|
60
61
|
//#endregion
|
|
61
62
|
//#region src/types.d.ts
|
|
62
|
-
|
|
63
|
+
/**
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
interface WebAuthnChallengeValue {
|
|
67
|
+
expectedChallenge: string;
|
|
68
|
+
userData: {
|
|
69
|
+
id: string;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
63
72
|
interface PasskeyOptions {
|
|
64
73
|
/**
|
|
65
74
|
* A unique identifier for your website. 'localhost' is okay for
|
|
@@ -147,13 +156,13 @@ declare const passkey: (options?: PasskeyOptions | undefined) => {
|
|
|
147
156
|
};
|
|
148
157
|
};
|
|
149
158
|
}>)[];
|
|
150
|
-
query:
|
|
151
|
-
authenticatorAttachment:
|
|
159
|
+
query: zod0.ZodOptional<zod0.ZodObject<{
|
|
160
|
+
authenticatorAttachment: zod0.ZodOptional<zod0.ZodEnum<{
|
|
152
161
|
platform: "platform";
|
|
153
162
|
"cross-platform": "cross-platform";
|
|
154
163
|
}>>;
|
|
155
|
-
name:
|
|
156
|
-
},
|
|
164
|
+
name: zod0.ZodOptional<zod0.ZodString>;
|
|
165
|
+
}, better_auth0.$strip>>;
|
|
157
166
|
metadata: {
|
|
158
167
|
openapi: {
|
|
159
168
|
operationId: string;
|
|
@@ -273,7 +282,7 @@ declare const passkey: (options?: PasskeyOptions | undefined) => {
|
|
|
273
282
|
};
|
|
274
283
|
} & {
|
|
275
284
|
use: any[];
|
|
276
|
-
}, PublicKeyCredentialCreationOptionsJSON>;
|
|
285
|
+
}, _simplewebauthn_server0.PublicKeyCredentialCreationOptionsJSON>;
|
|
277
286
|
generatePasskeyAuthenticationOptions: better_call0.StrictEndpoint<"/passkey/generate-authenticate-options", {
|
|
278
287
|
method: "GET";
|
|
279
288
|
metadata: {
|
|
@@ -372,10 +381,10 @@ declare const passkey: (options?: PasskeyOptions | undefined) => {
|
|
|
372
381
|
}, _simplewebauthn_server0.PublicKeyCredentialRequestOptionsJSON>;
|
|
373
382
|
verifyPasskeyRegistration: better_call0.StrictEndpoint<"/passkey/verify-registration", {
|
|
374
383
|
method: "POST";
|
|
375
|
-
body:
|
|
376
|
-
response:
|
|
377
|
-
name:
|
|
378
|
-
},
|
|
384
|
+
body: zod0.ZodObject<{
|
|
385
|
+
response: zod0.ZodAny;
|
|
386
|
+
name: zod0.ZodOptional<zod0.ZodString>;
|
|
387
|
+
}, better_auth0.$strip>;
|
|
379
388
|
use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
|
|
380
389
|
session: {
|
|
381
390
|
session: Record<string, any> & {
|
|
@@ -425,9 +434,9 @@ declare const passkey: (options?: PasskeyOptions | undefined) => {
|
|
|
425
434
|
}, Passkey | null>;
|
|
426
435
|
verifyPasskeyAuthentication: better_call0.StrictEndpoint<"/passkey/verify-authentication", {
|
|
427
436
|
method: "POST";
|
|
428
|
-
body:
|
|
429
|
-
response:
|
|
430
|
-
},
|
|
437
|
+
body: zod0.ZodObject<{
|
|
438
|
+
response: zod0.ZodRecord<zod0.ZodAny, zod0.ZodAny>;
|
|
439
|
+
}, better_auth0.$strip>;
|
|
431
440
|
metadata: {
|
|
432
441
|
openapi: {
|
|
433
442
|
operationId: string;
|
|
@@ -455,7 +464,7 @@ declare const passkey: (options?: PasskeyOptions | undefined) => {
|
|
|
455
464
|
};
|
|
456
465
|
$Infer: {
|
|
457
466
|
body: {
|
|
458
|
-
response: AuthenticationResponseJSON;
|
|
467
|
+
response: _simplewebauthn_server0.AuthenticationResponseJSON;
|
|
459
468
|
};
|
|
460
469
|
};
|
|
461
470
|
};
|
|
@@ -473,21 +482,6 @@ declare const passkey: (options?: PasskeyOptions | undefined) => {
|
|
|
473
482
|
userAgent?: string | null | undefined;
|
|
474
483
|
};
|
|
475
484
|
}>;
|
|
476
|
-
/**
|
|
477
|
-
* ### Endpoint
|
|
478
|
-
*
|
|
479
|
-
* GET `/passkey/list-user-passkeys`
|
|
480
|
-
*
|
|
481
|
-
* ### API Methods
|
|
482
|
-
*
|
|
483
|
-
* **server:**
|
|
484
|
-
* `auth.api.listPasskeys`
|
|
485
|
-
*
|
|
486
|
-
* **client:**
|
|
487
|
-
* `authClient.passkey.listUserPasskeys`
|
|
488
|
-
*
|
|
489
|
-
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/passkey#api-method-passkey-list-user-passkeys)
|
|
490
|
-
*/
|
|
491
485
|
listPasskeys: better_call0.StrictEndpoint<"/passkey/list-user-passkeys", {
|
|
492
486
|
method: "GET";
|
|
493
487
|
use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
|
|
@@ -538,26 +532,11 @@ declare const passkey: (options?: PasskeyOptions | undefined) => {
|
|
|
538
532
|
} & {
|
|
539
533
|
use: any[];
|
|
540
534
|
}, Passkey[]>;
|
|
541
|
-
/**
|
|
542
|
-
* ### Endpoint
|
|
543
|
-
*
|
|
544
|
-
* POST `/passkey/delete-passkey`
|
|
545
|
-
*
|
|
546
|
-
* ### API Methods
|
|
547
|
-
*
|
|
548
|
-
* **server:**
|
|
549
|
-
* `auth.api.deletePasskey`
|
|
550
|
-
*
|
|
551
|
-
* **client:**
|
|
552
|
-
* `authClient.passkey.deletePasskey`
|
|
553
|
-
*
|
|
554
|
-
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/passkey#api-method-passkey-delete-passkey)
|
|
555
|
-
*/
|
|
556
535
|
deletePasskey: better_call0.StrictEndpoint<"/passkey/delete-passkey", {
|
|
557
536
|
method: "POST";
|
|
558
|
-
body:
|
|
559
|
-
id:
|
|
560
|
-
},
|
|
537
|
+
body: zod0.ZodObject<{
|
|
538
|
+
id: zod0.ZodString;
|
|
539
|
+
}, better_auth0.$strip>;
|
|
561
540
|
use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
|
|
562
541
|
session: {
|
|
563
542
|
session: Record<string, any> & {
|
|
@@ -610,27 +589,12 @@ declare const passkey: (options?: PasskeyOptions | undefined) => {
|
|
|
610
589
|
}, {
|
|
611
590
|
status: boolean;
|
|
612
591
|
}>;
|
|
613
|
-
/**
|
|
614
|
-
* ### Endpoint
|
|
615
|
-
*
|
|
616
|
-
* POST `/passkey/update-passkey`
|
|
617
|
-
*
|
|
618
|
-
* ### API Methods
|
|
619
|
-
*
|
|
620
|
-
* **server:**
|
|
621
|
-
* `auth.api.updatePasskey`
|
|
622
|
-
*
|
|
623
|
-
* **client:**
|
|
624
|
-
* `authClient.passkey.updatePasskey`
|
|
625
|
-
*
|
|
626
|
-
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/passkey#api-method-passkey-update-passkey)
|
|
627
|
-
*/
|
|
628
592
|
updatePasskey: better_call0.StrictEndpoint<"/passkey/update-passkey", {
|
|
629
593
|
method: "POST";
|
|
630
|
-
body:
|
|
631
|
-
id:
|
|
632
|
-
name:
|
|
633
|
-
},
|
|
594
|
+
body: zod0.ZodObject<{
|
|
595
|
+
id: zod0.ZodString;
|
|
596
|
+
name: zod0.ZodString;
|
|
597
|
+
}, better_auth0.$strip>;
|
|
634
598
|
use: ((inputContext: better_call0.MiddlewareInputContext<better_call0.MiddlewareOptions>) => Promise<{
|
|
635
599
|
session: {
|
|
636
600
|
session: Record<string, any> & {
|
|
@@ -746,4 +710,4 @@ declare const passkey: (options?: PasskeyOptions | undefined) => {
|
|
|
746
710
|
};
|
|
747
711
|
};
|
|
748
712
|
//#endregion
|
|
749
|
-
export { Passkey as n, PasskeyOptions as r, passkey as t };
|
|
713
|
+
export { WebAuthnChallengeValue as i, Passkey as n, PasskeyOptions as r, passkey as t };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { n as Passkey, r as PasskeyOptions, t as passkey } from "./index-
|
|
1
|
+
import { n as Passkey, r as PasskeyOptions, t as passkey } from "./index-HkgxjeuE.mjs";
|
|
2
2
|
export { Passkey, PasskeyOptions, passkey };
|
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { mergeSchema } from "better-auth/db";
|
|
2
|
+
import { defineErrorCodes } from "@better-auth/core/utils";
|
|
1
3
|
import { createAuthEndpoint } from "@better-auth/core/api";
|
|
2
4
|
import { base64 } from "@better-auth/utils/base64";
|
|
3
5
|
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
|
|
@@ -5,10 +7,8 @@ import { generateId } from "better-auth";
|
|
|
5
7
|
import { freshSessionMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
6
8
|
import { setSessionCookie } from "better-auth/cookies";
|
|
7
9
|
import { generateRandomString } from "better-auth/crypto";
|
|
8
|
-
import { mergeSchema } from "better-auth/db";
|
|
9
10
|
import { APIError } from "better-call";
|
|
10
11
|
import * as z from "zod";
|
|
11
|
-
import { defineErrorCodes } from "@better-auth/core/utils";
|
|
12
12
|
|
|
13
13
|
//#region src/error-codes.ts
|
|
14
14
|
const PASSKEY_ERROR_CODES = defineErrorCodes({
|
|
@@ -21,6 +21,530 @@ const PASSKEY_ERROR_CODES = defineErrorCodes({
|
|
|
21
21
|
FAILED_TO_UPDATE_PASSKEY: "Failed to update passkey"
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/utils.ts
|
|
26
|
+
function getRpID(options, baseURL) {
|
|
27
|
+
return options.rpID || (baseURL ? new URL(baseURL).hostname : "localhost");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/routes.ts
|
|
32
|
+
const generatePasskeyQuerySchema = z.object({
|
|
33
|
+
authenticatorAttachment: z.enum(["platform", "cross-platform"]).optional(),
|
|
34
|
+
name: z.string().optional()
|
|
35
|
+
}).optional();
|
|
36
|
+
const generatePasskeyRegistrationOptions = (opts, { maxAgeInSeconds, expirationTime }) => createAuthEndpoint("/passkey/generate-register-options", {
|
|
37
|
+
method: "GET",
|
|
38
|
+
use: [freshSessionMiddleware],
|
|
39
|
+
query: generatePasskeyQuerySchema,
|
|
40
|
+
metadata: { openapi: {
|
|
41
|
+
operationId: "generatePasskeyRegistrationOptions",
|
|
42
|
+
description: "Generate registration options for a new passkey",
|
|
43
|
+
responses: { 200: {
|
|
44
|
+
description: "Success",
|
|
45
|
+
parameters: { query: {
|
|
46
|
+
authenticatorAttachment: {
|
|
47
|
+
description: `Type of authenticator to use for registration.
|
|
48
|
+
"platform" for device-specific authenticators,
|
|
49
|
+
"cross-platform" for authenticators that can be used across devices.`,
|
|
50
|
+
required: false
|
|
51
|
+
},
|
|
52
|
+
name: {
|
|
53
|
+
description: `Optional custom name for the passkey.
|
|
54
|
+
This can help identify the passkey when managing multiple credentials.`,
|
|
55
|
+
required: false
|
|
56
|
+
}
|
|
57
|
+
} },
|
|
58
|
+
content: { "application/json": { schema: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
challenge: { type: "string" },
|
|
62
|
+
rp: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: {
|
|
65
|
+
name: { type: "string" },
|
|
66
|
+
id: { type: "string" }
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
user: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
id: { type: "string" },
|
|
73
|
+
name: { type: "string" },
|
|
74
|
+
displayName: { type: "string" }
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
pubKeyCredParams: {
|
|
78
|
+
type: "array",
|
|
79
|
+
items: {
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: {
|
|
82
|
+
type: { type: "string" },
|
|
83
|
+
alg: { type: "number" }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
timeout: { type: "number" },
|
|
88
|
+
excludeCredentials: {
|
|
89
|
+
type: "array",
|
|
90
|
+
items: {
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
id: { type: "string" },
|
|
94
|
+
type: { type: "string" },
|
|
95
|
+
transports: {
|
|
96
|
+
type: "array",
|
|
97
|
+
items: { type: "string" }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
authenticatorSelection: {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: {
|
|
105
|
+
authenticatorAttachment: { type: "string" },
|
|
106
|
+
requireResidentKey: { type: "boolean" },
|
|
107
|
+
userVerification: { type: "string" }
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
attestation: { type: "string" },
|
|
111
|
+
extensions: { type: "object" }
|
|
112
|
+
}
|
|
113
|
+
} } }
|
|
114
|
+
} }
|
|
115
|
+
} }
|
|
116
|
+
}, async (ctx) => {
|
|
117
|
+
const { session } = ctx.context;
|
|
118
|
+
const userPasskeys = await ctx.context.adapter.findMany({
|
|
119
|
+
model: "passkey",
|
|
120
|
+
where: [{
|
|
121
|
+
field: "userId",
|
|
122
|
+
value: session.user.id
|
|
123
|
+
}]
|
|
124
|
+
});
|
|
125
|
+
const userID = new TextEncoder().encode(generateRandomString(32, "a-z", "0-9"));
|
|
126
|
+
let options;
|
|
127
|
+
options = await generateRegistrationOptions({
|
|
128
|
+
rpName: opts.rpName || ctx.context.appName,
|
|
129
|
+
rpID: getRpID(opts, ctx.context.options.baseURL),
|
|
130
|
+
userID,
|
|
131
|
+
userName: ctx.query?.name || session.user.email || session.user.id,
|
|
132
|
+
userDisplayName: session.user.email || session.user.id,
|
|
133
|
+
attestationType: "none",
|
|
134
|
+
excludeCredentials: userPasskeys.map((passkey$1) => ({
|
|
135
|
+
id: passkey$1.credentialID,
|
|
136
|
+
transports: passkey$1.transports?.split(",")
|
|
137
|
+
})),
|
|
138
|
+
authenticatorSelection: {
|
|
139
|
+
residentKey: "preferred",
|
|
140
|
+
userVerification: "preferred",
|
|
141
|
+
...opts.authenticatorSelection || {},
|
|
142
|
+
...ctx.query?.authenticatorAttachment ? { authenticatorAttachment: ctx.query.authenticatorAttachment } : {}
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
const id = generateId(32);
|
|
146
|
+
const webAuthnCookie = ctx.context.createAuthCookie(opts.advanced.webAuthnChallengeCookie);
|
|
147
|
+
await ctx.setSignedCookie(webAuthnCookie.name, id, ctx.context.secret, {
|
|
148
|
+
...webAuthnCookie.attributes,
|
|
149
|
+
maxAge: maxAgeInSeconds
|
|
150
|
+
});
|
|
151
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
152
|
+
identifier: id,
|
|
153
|
+
value: JSON.stringify({
|
|
154
|
+
expectedChallenge: options.challenge,
|
|
155
|
+
userData: { id: session.user.id }
|
|
156
|
+
}),
|
|
157
|
+
expiresAt: expirationTime
|
|
158
|
+
});
|
|
159
|
+
return ctx.json(options, { status: 200 });
|
|
160
|
+
});
|
|
161
|
+
const generatePasskeyAuthenticationOptions = (opts, { maxAgeInSeconds, expirationTime }) => createAuthEndpoint("/passkey/generate-authenticate-options", {
|
|
162
|
+
method: "GET",
|
|
163
|
+
metadata: { openapi: {
|
|
164
|
+
operationId: "passkeyGenerateAuthenticateOptions",
|
|
165
|
+
description: "Generate authentication options for a passkey",
|
|
166
|
+
responses: { 200: {
|
|
167
|
+
description: "Success",
|
|
168
|
+
content: { "application/json": { schema: {
|
|
169
|
+
type: "object",
|
|
170
|
+
properties: {
|
|
171
|
+
challenge: { type: "string" },
|
|
172
|
+
rp: {
|
|
173
|
+
type: "object",
|
|
174
|
+
properties: {
|
|
175
|
+
name: { type: "string" },
|
|
176
|
+
id: { type: "string" }
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
user: {
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: {
|
|
182
|
+
id: { type: "string" },
|
|
183
|
+
name: { type: "string" },
|
|
184
|
+
displayName: { type: "string" }
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
timeout: { type: "number" },
|
|
188
|
+
allowCredentials: {
|
|
189
|
+
type: "array",
|
|
190
|
+
items: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
id: { type: "string" },
|
|
194
|
+
type: { type: "string" },
|
|
195
|
+
transports: {
|
|
196
|
+
type: "array",
|
|
197
|
+
items: { type: "string" }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
userVerification: { type: "string" },
|
|
203
|
+
authenticatorSelection: {
|
|
204
|
+
type: "object",
|
|
205
|
+
properties: {
|
|
206
|
+
authenticatorAttachment: { type: "string" },
|
|
207
|
+
requireResidentKey: { type: "boolean" },
|
|
208
|
+
userVerification: { type: "string" }
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
extensions: { type: "object" }
|
|
212
|
+
}
|
|
213
|
+
} } }
|
|
214
|
+
} }
|
|
215
|
+
} }
|
|
216
|
+
}, async (ctx) => {
|
|
217
|
+
const session = await getSessionFromCtx(ctx);
|
|
218
|
+
let userPasskeys = [];
|
|
219
|
+
if (session) userPasskeys = await ctx.context.adapter.findMany({
|
|
220
|
+
model: "passkey",
|
|
221
|
+
where: [{
|
|
222
|
+
field: "userId",
|
|
223
|
+
value: session.user.id
|
|
224
|
+
}]
|
|
225
|
+
});
|
|
226
|
+
const options = await generateAuthenticationOptions({
|
|
227
|
+
rpID: getRpID(opts, ctx.context.options.baseURL),
|
|
228
|
+
userVerification: "preferred",
|
|
229
|
+
...userPasskeys.length ? { allowCredentials: userPasskeys.map((passkey$1) => ({
|
|
230
|
+
id: passkey$1.credentialID,
|
|
231
|
+
transports: passkey$1.transports?.split(",")
|
|
232
|
+
})) } : {}
|
|
233
|
+
});
|
|
234
|
+
const data = {
|
|
235
|
+
expectedChallenge: options.challenge,
|
|
236
|
+
userData: { id: session?.user.id || "" }
|
|
237
|
+
};
|
|
238
|
+
const id = generateId(32);
|
|
239
|
+
const webAuthnCookie = ctx.context.createAuthCookie(opts.advanced.webAuthnChallengeCookie);
|
|
240
|
+
await ctx.setSignedCookie(webAuthnCookie.name, id, ctx.context.secret, {
|
|
241
|
+
...webAuthnCookie.attributes,
|
|
242
|
+
maxAge: maxAgeInSeconds
|
|
243
|
+
});
|
|
244
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
245
|
+
identifier: id,
|
|
246
|
+
value: JSON.stringify(data),
|
|
247
|
+
expiresAt: expirationTime
|
|
248
|
+
});
|
|
249
|
+
return ctx.json(options, { status: 200 });
|
|
250
|
+
});
|
|
251
|
+
const verifyPasskeyRegistrationBodySchema = z.object({
|
|
252
|
+
response: z.any(),
|
|
253
|
+
name: z.string().meta({ description: "Name of the passkey" }).optional()
|
|
254
|
+
});
|
|
255
|
+
const verifyPasskeyRegistration = (options) => createAuthEndpoint("/passkey/verify-registration", {
|
|
256
|
+
method: "POST",
|
|
257
|
+
body: verifyPasskeyRegistrationBodySchema,
|
|
258
|
+
use: [freshSessionMiddleware],
|
|
259
|
+
metadata: { openapi: {
|
|
260
|
+
operationId: "passkeyVerifyRegistration",
|
|
261
|
+
description: "Verify registration of a new passkey",
|
|
262
|
+
responses: {
|
|
263
|
+
200: {
|
|
264
|
+
description: "Success",
|
|
265
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/Passkey" } } }
|
|
266
|
+
},
|
|
267
|
+
400: { description: "Bad request" }
|
|
268
|
+
}
|
|
269
|
+
} }
|
|
270
|
+
}, async (ctx) => {
|
|
271
|
+
const origin = options?.origin || ctx.headers?.get("origin") || "";
|
|
272
|
+
if (!origin) return ctx.json(null, { status: 400 });
|
|
273
|
+
const resp = ctx.body.response;
|
|
274
|
+
const webAuthnCookie = ctx.context.createAuthCookie(options.advanced.webAuthnChallengeCookie);
|
|
275
|
+
const challengeId = await ctx.getSignedCookie(webAuthnCookie.name, ctx.context.secret);
|
|
276
|
+
if (!challengeId) throw new APIError("BAD_REQUEST", { message: PASSKEY_ERROR_CODES.CHALLENGE_NOT_FOUND });
|
|
277
|
+
const data = await ctx.context.internalAdapter.findVerificationValue(challengeId);
|
|
278
|
+
if (!data) return ctx.json(null, { status: 400 });
|
|
279
|
+
const { expectedChallenge, userData } = JSON.parse(data.value);
|
|
280
|
+
if (userData.id !== ctx.context.session.user.id) throw new APIError("UNAUTHORIZED", { message: PASSKEY_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY });
|
|
281
|
+
try {
|
|
282
|
+
const { verified, registrationInfo } = await verifyRegistrationResponse({
|
|
283
|
+
response: resp,
|
|
284
|
+
expectedChallenge,
|
|
285
|
+
expectedOrigin: origin,
|
|
286
|
+
expectedRPID: getRpID(options, ctx.context.options.baseURL),
|
|
287
|
+
requireUserVerification: false
|
|
288
|
+
});
|
|
289
|
+
if (!verified || !registrationInfo) return ctx.json(null, { status: 400 });
|
|
290
|
+
const { aaguid, credentialDeviceType, credentialBackedUp, credential, credentialType } = registrationInfo;
|
|
291
|
+
const pubKey = base64.encode(credential.publicKey);
|
|
292
|
+
const newPasskey = {
|
|
293
|
+
name: ctx.body.name,
|
|
294
|
+
userId: userData.id,
|
|
295
|
+
credentialID: credential.id,
|
|
296
|
+
publicKey: pubKey,
|
|
297
|
+
counter: credential.counter,
|
|
298
|
+
deviceType: credentialDeviceType,
|
|
299
|
+
transports: resp.response.transports.join(","),
|
|
300
|
+
backedUp: credentialBackedUp,
|
|
301
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
302
|
+
aaguid
|
|
303
|
+
};
|
|
304
|
+
const newPasskeyRes = await ctx.context.adapter.create({
|
|
305
|
+
model: "passkey",
|
|
306
|
+
data: newPasskey
|
|
307
|
+
});
|
|
308
|
+
return ctx.json(newPasskeyRes, { status: 200 });
|
|
309
|
+
} catch (e) {
|
|
310
|
+
console.log(e);
|
|
311
|
+
throw new APIError("INTERNAL_SERVER_ERROR", { message: PASSKEY_ERROR_CODES.FAILED_TO_VERIFY_REGISTRATION });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
const verifyPasskeyAuthenticationBodySchema = z.object({ response: z.record(z.any(), z.any()) });
|
|
315
|
+
const verifyPasskeyAuthentication = (options) => createAuthEndpoint("/passkey/verify-authentication", {
|
|
316
|
+
method: "POST",
|
|
317
|
+
body: verifyPasskeyAuthenticationBodySchema,
|
|
318
|
+
metadata: {
|
|
319
|
+
openapi: {
|
|
320
|
+
operationId: "passkeyVerifyAuthentication",
|
|
321
|
+
description: "Verify authentication of a passkey",
|
|
322
|
+
responses: { 200: {
|
|
323
|
+
description: "Success",
|
|
324
|
+
content: { "application/json": { schema: {
|
|
325
|
+
type: "object",
|
|
326
|
+
properties: {
|
|
327
|
+
session: { $ref: "#/components/schemas/Session" },
|
|
328
|
+
user: { $ref: "#/components/schemas/User" }
|
|
329
|
+
}
|
|
330
|
+
} } }
|
|
331
|
+
} }
|
|
332
|
+
},
|
|
333
|
+
$Infer: { body: {} }
|
|
334
|
+
}
|
|
335
|
+
}, async (ctx) => {
|
|
336
|
+
const origin = options?.origin || ctx.headers?.get("origin") || "";
|
|
337
|
+
if (!origin) throw new APIError("BAD_REQUEST", { message: "origin missing" });
|
|
338
|
+
const resp = ctx.body.response;
|
|
339
|
+
const webAuthnCookie = ctx.context.createAuthCookie(options.advanced.webAuthnChallengeCookie);
|
|
340
|
+
const challengeId = await ctx.getSignedCookie(webAuthnCookie.name, ctx.context.secret);
|
|
341
|
+
if (!challengeId) throw new APIError("BAD_REQUEST", { message: PASSKEY_ERROR_CODES.CHALLENGE_NOT_FOUND });
|
|
342
|
+
const data = await ctx.context.internalAdapter.findVerificationValue(challengeId);
|
|
343
|
+
if (!data) throw new APIError("BAD_REQUEST", { message: PASSKEY_ERROR_CODES.CHALLENGE_NOT_FOUND });
|
|
344
|
+
const { expectedChallenge } = JSON.parse(data.value);
|
|
345
|
+
const passkey$1 = await ctx.context.adapter.findOne({
|
|
346
|
+
model: "passkey",
|
|
347
|
+
where: [{
|
|
348
|
+
field: "credentialID",
|
|
349
|
+
value: resp.id
|
|
350
|
+
}]
|
|
351
|
+
});
|
|
352
|
+
if (!passkey$1) throw new APIError("UNAUTHORIZED", { message: PASSKEY_ERROR_CODES.PASSKEY_NOT_FOUND });
|
|
353
|
+
try {
|
|
354
|
+
const verification = await verifyAuthenticationResponse({
|
|
355
|
+
response: resp,
|
|
356
|
+
expectedChallenge,
|
|
357
|
+
expectedOrigin: origin,
|
|
358
|
+
expectedRPID: getRpID(options, ctx.context.options.baseURL),
|
|
359
|
+
credential: {
|
|
360
|
+
id: passkey$1.credentialID,
|
|
361
|
+
publicKey: base64.decode(passkey$1.publicKey),
|
|
362
|
+
counter: passkey$1.counter,
|
|
363
|
+
transports: passkey$1.transports?.split(",")
|
|
364
|
+
},
|
|
365
|
+
requireUserVerification: false
|
|
366
|
+
});
|
|
367
|
+
const { verified } = verification;
|
|
368
|
+
if (!verified) throw new APIError("UNAUTHORIZED", { message: PASSKEY_ERROR_CODES.AUTHENTICATION_FAILED });
|
|
369
|
+
await ctx.context.adapter.update({
|
|
370
|
+
model: "passkey",
|
|
371
|
+
where: [{
|
|
372
|
+
field: "id",
|
|
373
|
+
value: passkey$1.id
|
|
374
|
+
}],
|
|
375
|
+
update: { counter: verification.authenticationInfo.newCounter }
|
|
376
|
+
});
|
|
377
|
+
const s = await ctx.context.internalAdapter.createSession(passkey$1.userId);
|
|
378
|
+
if (!s) throw new APIError("INTERNAL_SERVER_ERROR", { message: PASSKEY_ERROR_CODES.UNABLE_TO_CREATE_SESSION });
|
|
379
|
+
const user = await ctx.context.internalAdapter.findUserById(passkey$1.userId);
|
|
380
|
+
if (!user) throw new APIError("INTERNAL_SERVER_ERROR", { message: "User not found" });
|
|
381
|
+
await setSessionCookie(ctx, {
|
|
382
|
+
session: s,
|
|
383
|
+
user
|
|
384
|
+
});
|
|
385
|
+
return ctx.json({ session: s }, { status: 200 });
|
|
386
|
+
} catch (e) {
|
|
387
|
+
ctx.context.logger.error("Failed to verify authentication", e);
|
|
388
|
+
throw new APIError("BAD_REQUEST", { message: PASSKEY_ERROR_CODES.AUTHENTICATION_FAILED });
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
/**
|
|
392
|
+
* ### Endpoint
|
|
393
|
+
*
|
|
394
|
+
* GET `/passkey/list-user-passkeys`
|
|
395
|
+
*
|
|
396
|
+
* ### API Methods
|
|
397
|
+
*
|
|
398
|
+
* **server:**
|
|
399
|
+
* `auth.api.listPasskeys`
|
|
400
|
+
*
|
|
401
|
+
* **client:**
|
|
402
|
+
* `authClient.passkey.listUserPasskeys`
|
|
403
|
+
*
|
|
404
|
+
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/passkey#api-method-passkey-list-user-passkeys)
|
|
405
|
+
*/
|
|
406
|
+
const listPasskeys = createAuthEndpoint("/passkey/list-user-passkeys", {
|
|
407
|
+
method: "GET",
|
|
408
|
+
use: [sessionMiddleware],
|
|
409
|
+
metadata: { openapi: {
|
|
410
|
+
description: "List all passkeys for the authenticated user",
|
|
411
|
+
responses: { "200": {
|
|
412
|
+
description: "Passkeys retrieved successfully",
|
|
413
|
+
content: { "application/json": { schema: {
|
|
414
|
+
type: "array",
|
|
415
|
+
items: {
|
|
416
|
+
$ref: "#/components/schemas/Passkey",
|
|
417
|
+
required: [
|
|
418
|
+
"id",
|
|
419
|
+
"userId",
|
|
420
|
+
"publicKey",
|
|
421
|
+
"createdAt",
|
|
422
|
+
"updatedAt"
|
|
423
|
+
]
|
|
424
|
+
},
|
|
425
|
+
description: "Array of passkey objects associated with the user"
|
|
426
|
+
} } }
|
|
427
|
+
} }
|
|
428
|
+
} }
|
|
429
|
+
}, async (ctx) => {
|
|
430
|
+
const passkeys = await ctx.context.adapter.findMany({
|
|
431
|
+
model: "passkey",
|
|
432
|
+
where: [{
|
|
433
|
+
field: "userId",
|
|
434
|
+
value: ctx.context.session.user.id
|
|
435
|
+
}]
|
|
436
|
+
});
|
|
437
|
+
return ctx.json(passkeys, { status: 200 });
|
|
438
|
+
});
|
|
439
|
+
const deletePasskeyBodySchema = z.object({ id: z.string().meta({ description: "The ID of the passkey to delete. Eg: \"some-passkey-id\"" }) });
|
|
440
|
+
/**
|
|
441
|
+
* ### Endpoint
|
|
442
|
+
*
|
|
443
|
+
* POST `/passkey/delete-passkey`
|
|
444
|
+
*
|
|
445
|
+
* ### API Methods
|
|
446
|
+
*
|
|
447
|
+
* **server:**
|
|
448
|
+
* `auth.api.deletePasskey`
|
|
449
|
+
*
|
|
450
|
+
* **client:**
|
|
451
|
+
* `authClient.passkey.deletePasskey`
|
|
452
|
+
*
|
|
453
|
+
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/passkey#api-method-passkey-delete-passkey)
|
|
454
|
+
*/
|
|
455
|
+
const deletePasskey = createAuthEndpoint("/passkey/delete-passkey", {
|
|
456
|
+
method: "POST",
|
|
457
|
+
body: deletePasskeyBodySchema,
|
|
458
|
+
use: [sessionMiddleware],
|
|
459
|
+
metadata: { openapi: {
|
|
460
|
+
description: "Delete a specific passkey",
|
|
461
|
+
responses: { "200": {
|
|
462
|
+
description: "Passkey deleted successfully",
|
|
463
|
+
content: { "application/json": { schema: {
|
|
464
|
+
type: "object",
|
|
465
|
+
properties: { status: {
|
|
466
|
+
type: "boolean",
|
|
467
|
+
description: "Indicates whether the deletion was successful"
|
|
468
|
+
} },
|
|
469
|
+
required: ["status"]
|
|
470
|
+
} } }
|
|
471
|
+
} }
|
|
472
|
+
} }
|
|
473
|
+
}, async (ctx) => {
|
|
474
|
+
const passkey$1 = await ctx.context.adapter.findOne({
|
|
475
|
+
model: "passkey",
|
|
476
|
+
where: [{
|
|
477
|
+
field: "id",
|
|
478
|
+
value: ctx.body.id
|
|
479
|
+
}]
|
|
480
|
+
});
|
|
481
|
+
if (!passkey$1) throw new APIError("NOT_FOUND", { message: PASSKEY_ERROR_CODES.PASSKEY_NOT_FOUND });
|
|
482
|
+
if (passkey$1.userId !== ctx.context.session.user.id) throw new APIError("UNAUTHORIZED");
|
|
483
|
+
await ctx.context.adapter.delete({
|
|
484
|
+
model: "passkey",
|
|
485
|
+
where: [{
|
|
486
|
+
field: "id",
|
|
487
|
+
value: passkey$1.id
|
|
488
|
+
}]
|
|
489
|
+
});
|
|
490
|
+
return ctx.json({ status: true });
|
|
491
|
+
});
|
|
492
|
+
const updatePassKeyBodySchema = z.object({
|
|
493
|
+
id: z.string().meta({ description: `The ID of the passkey which will be updated. Eg: \"passkey-id\"` }),
|
|
494
|
+
name: z.string().meta({ description: `The new name which the passkey will be updated to. Eg: \"my-new-passkey-name\"` })
|
|
495
|
+
});
|
|
496
|
+
/**
|
|
497
|
+
* ### Endpoint
|
|
498
|
+
*
|
|
499
|
+
* POST `/passkey/update-passkey`
|
|
500
|
+
*
|
|
501
|
+
* ### API Methods
|
|
502
|
+
*
|
|
503
|
+
* **server:**
|
|
504
|
+
* `auth.api.updatePasskey`
|
|
505
|
+
*
|
|
506
|
+
* **client:**
|
|
507
|
+
* `authClient.passkey.updatePasskey`
|
|
508
|
+
*
|
|
509
|
+
* @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/passkey#api-method-passkey-update-passkey)
|
|
510
|
+
*/
|
|
511
|
+
const updatePasskey = createAuthEndpoint("/passkey/update-passkey", {
|
|
512
|
+
method: "POST",
|
|
513
|
+
body: updatePassKeyBodySchema,
|
|
514
|
+
use: [sessionMiddleware],
|
|
515
|
+
metadata: { openapi: {
|
|
516
|
+
description: "Update a specific passkey's name",
|
|
517
|
+
responses: { "200": {
|
|
518
|
+
description: "Passkey updated successfully",
|
|
519
|
+
content: { "application/json": { schema: {
|
|
520
|
+
type: "object",
|
|
521
|
+
properties: { passkey: { $ref: "#/components/schemas/Passkey" } },
|
|
522
|
+
required: ["passkey"]
|
|
523
|
+
} } }
|
|
524
|
+
} }
|
|
525
|
+
} }
|
|
526
|
+
}, async (ctx) => {
|
|
527
|
+
const passkey$1 = await ctx.context.adapter.findOne({
|
|
528
|
+
model: "passkey",
|
|
529
|
+
where: [{
|
|
530
|
+
field: "id",
|
|
531
|
+
value: ctx.body.id
|
|
532
|
+
}]
|
|
533
|
+
});
|
|
534
|
+
if (!passkey$1) throw new APIError("NOT_FOUND", { message: PASSKEY_ERROR_CODES.PASSKEY_NOT_FOUND });
|
|
535
|
+
if (passkey$1.userId !== ctx.context.session.user.id) throw new APIError("UNAUTHORIZED", { message: PASSKEY_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY });
|
|
536
|
+
const updatedPasskey = await ctx.context.adapter.update({
|
|
537
|
+
model: "passkey",
|
|
538
|
+
where: [{
|
|
539
|
+
field: "id",
|
|
540
|
+
value: ctx.body.id
|
|
541
|
+
}],
|
|
542
|
+
update: { name: ctx.body.name }
|
|
543
|
+
});
|
|
544
|
+
if (!updatedPasskey) throw new APIError("INTERNAL_SERVER_ERROR", { message: PASSKEY_ERROR_CODES.FAILED_TO_UPDATE_PASSKEY });
|
|
545
|
+
return ctx.json({ passkey: updatedPasskey }, { status: 200 });
|
|
546
|
+
});
|
|
547
|
+
|
|
24
548
|
//#endregion
|
|
25
549
|
//#region src/schema.ts
|
|
26
550
|
const schema = { passkey: { fields: {
|
|
@@ -72,12 +596,6 @@ const schema = { passkey: { fields: {
|
|
|
72
596
|
}
|
|
73
597
|
} } };
|
|
74
598
|
|
|
75
|
-
//#endregion
|
|
76
|
-
//#region src/utils.ts
|
|
77
|
-
function getRpID(options, baseURL) {
|
|
78
|
-
return options.rpID || (baseURL ? new URL(baseURL).hostname : "localhost");
|
|
79
|
-
}
|
|
80
|
-
|
|
81
599
|
//#endregion
|
|
82
600
|
//#region src/index.ts
|
|
83
601
|
const passkey = (options) => {
|
|
@@ -95,471 +613,19 @@ const passkey = (options) => {
|
|
|
95
613
|
return {
|
|
96
614
|
id: "passkey",
|
|
97
615
|
endpoints: {
|
|
98
|
-
generatePasskeyRegistrationOptions:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
query: z.object({
|
|
102
|
-
authenticatorAttachment: z.enum(["platform", "cross-platform"]).optional(),
|
|
103
|
-
name: z.string().optional()
|
|
104
|
-
}).optional(),
|
|
105
|
-
metadata: { openapi: {
|
|
106
|
-
operationId: "generatePasskeyRegistrationOptions",
|
|
107
|
-
description: "Generate registration options for a new passkey",
|
|
108
|
-
responses: { 200: {
|
|
109
|
-
description: "Success",
|
|
110
|
-
parameters: { query: {
|
|
111
|
-
authenticatorAttachment: {
|
|
112
|
-
description: `Type of authenticator to use for registration.
|
|
113
|
-
"platform" for device-specific authenticators,
|
|
114
|
-
"cross-platform" for authenticators that can be used across devices.`,
|
|
115
|
-
required: false
|
|
116
|
-
},
|
|
117
|
-
name: {
|
|
118
|
-
description: `Optional custom name for the passkey.
|
|
119
|
-
This can help identify the passkey when managing multiple credentials.`,
|
|
120
|
-
required: false
|
|
121
|
-
}
|
|
122
|
-
} },
|
|
123
|
-
content: { "application/json": { schema: {
|
|
124
|
-
type: "object",
|
|
125
|
-
properties: {
|
|
126
|
-
challenge: { type: "string" },
|
|
127
|
-
rp: {
|
|
128
|
-
type: "object",
|
|
129
|
-
properties: {
|
|
130
|
-
name: { type: "string" },
|
|
131
|
-
id: { type: "string" }
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
|
-
user: {
|
|
135
|
-
type: "object",
|
|
136
|
-
properties: {
|
|
137
|
-
id: { type: "string" },
|
|
138
|
-
name: { type: "string" },
|
|
139
|
-
displayName: { type: "string" }
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
pubKeyCredParams: {
|
|
143
|
-
type: "array",
|
|
144
|
-
items: {
|
|
145
|
-
type: "object",
|
|
146
|
-
properties: {
|
|
147
|
-
type: { type: "string" },
|
|
148
|
-
alg: { type: "number" }
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
timeout: { type: "number" },
|
|
153
|
-
excludeCredentials: {
|
|
154
|
-
type: "array",
|
|
155
|
-
items: {
|
|
156
|
-
type: "object",
|
|
157
|
-
properties: {
|
|
158
|
-
id: { type: "string" },
|
|
159
|
-
type: { type: "string" },
|
|
160
|
-
transports: {
|
|
161
|
-
type: "array",
|
|
162
|
-
items: { type: "string" }
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
authenticatorSelection: {
|
|
168
|
-
type: "object",
|
|
169
|
-
properties: {
|
|
170
|
-
authenticatorAttachment: { type: "string" },
|
|
171
|
-
requireResidentKey: { type: "boolean" },
|
|
172
|
-
userVerification: { type: "string" }
|
|
173
|
-
}
|
|
174
|
-
},
|
|
175
|
-
attestation: { type: "string" },
|
|
176
|
-
extensions: { type: "object" }
|
|
177
|
-
}
|
|
178
|
-
} } }
|
|
179
|
-
} }
|
|
180
|
-
} }
|
|
181
|
-
}, async (ctx) => {
|
|
182
|
-
const { session } = ctx.context;
|
|
183
|
-
const userPasskeys = await ctx.context.adapter.findMany({
|
|
184
|
-
model: "passkey",
|
|
185
|
-
where: [{
|
|
186
|
-
field: "userId",
|
|
187
|
-
value: session.user.id
|
|
188
|
-
}]
|
|
189
|
-
});
|
|
190
|
-
const userID = new TextEncoder().encode(generateRandomString(32, "a-z", "0-9"));
|
|
191
|
-
let options$1;
|
|
192
|
-
options$1 = await generateRegistrationOptions({
|
|
193
|
-
rpName: opts.rpName || ctx.context.appName,
|
|
194
|
-
rpID: getRpID(opts, ctx.context.options.baseURL),
|
|
195
|
-
userID,
|
|
196
|
-
userName: ctx.query?.name || session.user.email || session.user.id,
|
|
197
|
-
userDisplayName: session.user.email || session.user.id,
|
|
198
|
-
attestationType: "none",
|
|
199
|
-
excludeCredentials: userPasskeys.map((passkey$1) => ({
|
|
200
|
-
id: passkey$1.credentialID,
|
|
201
|
-
transports: passkey$1.transports?.split(",")
|
|
202
|
-
})),
|
|
203
|
-
authenticatorSelection: {
|
|
204
|
-
residentKey: "preferred",
|
|
205
|
-
userVerification: "preferred",
|
|
206
|
-
...opts.authenticatorSelection || {},
|
|
207
|
-
...ctx.query?.authenticatorAttachment ? { authenticatorAttachment: ctx.query.authenticatorAttachment } : {}
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
const id = generateId(32);
|
|
211
|
-
const webAuthnCookie = ctx.context.createAuthCookie(opts.advanced.webAuthnChallengeCookie);
|
|
212
|
-
await ctx.setSignedCookie(webAuthnCookie.name, id, ctx.context.secret, {
|
|
213
|
-
...webAuthnCookie.attributes,
|
|
214
|
-
maxAge: maxAgeInSeconds
|
|
215
|
-
});
|
|
216
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
217
|
-
identifier: id,
|
|
218
|
-
value: JSON.stringify({
|
|
219
|
-
expectedChallenge: options$1.challenge,
|
|
220
|
-
userData: { id: session.user.id }
|
|
221
|
-
}),
|
|
222
|
-
expiresAt: expirationTime
|
|
223
|
-
});
|
|
224
|
-
return ctx.json(options$1, { status: 200 });
|
|
616
|
+
generatePasskeyRegistrationOptions: generatePasskeyRegistrationOptions(opts, {
|
|
617
|
+
maxAgeInSeconds,
|
|
618
|
+
expirationTime
|
|
225
619
|
}),
|
|
226
|
-
generatePasskeyAuthenticationOptions:
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
operationId: "passkeyGenerateAuthenticateOptions",
|
|
230
|
-
description: "Generate authentication options for a passkey",
|
|
231
|
-
responses: { 200: {
|
|
232
|
-
description: "Success",
|
|
233
|
-
content: { "application/json": { schema: {
|
|
234
|
-
type: "object",
|
|
235
|
-
properties: {
|
|
236
|
-
challenge: { type: "string" },
|
|
237
|
-
rp: {
|
|
238
|
-
type: "object",
|
|
239
|
-
properties: {
|
|
240
|
-
name: { type: "string" },
|
|
241
|
-
id: { type: "string" }
|
|
242
|
-
}
|
|
243
|
-
},
|
|
244
|
-
user: {
|
|
245
|
-
type: "object",
|
|
246
|
-
properties: {
|
|
247
|
-
id: { type: "string" },
|
|
248
|
-
name: { type: "string" },
|
|
249
|
-
displayName: { type: "string" }
|
|
250
|
-
}
|
|
251
|
-
},
|
|
252
|
-
timeout: { type: "number" },
|
|
253
|
-
allowCredentials: {
|
|
254
|
-
type: "array",
|
|
255
|
-
items: {
|
|
256
|
-
type: "object",
|
|
257
|
-
properties: {
|
|
258
|
-
id: { type: "string" },
|
|
259
|
-
type: { type: "string" },
|
|
260
|
-
transports: {
|
|
261
|
-
type: "array",
|
|
262
|
-
items: { type: "string" }
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
},
|
|
267
|
-
userVerification: { type: "string" },
|
|
268
|
-
authenticatorSelection: {
|
|
269
|
-
type: "object",
|
|
270
|
-
properties: {
|
|
271
|
-
authenticatorAttachment: { type: "string" },
|
|
272
|
-
requireResidentKey: { type: "boolean" },
|
|
273
|
-
userVerification: { type: "string" }
|
|
274
|
-
}
|
|
275
|
-
},
|
|
276
|
-
extensions: { type: "object" }
|
|
277
|
-
}
|
|
278
|
-
} } }
|
|
279
|
-
} }
|
|
280
|
-
} }
|
|
281
|
-
}, async (ctx) => {
|
|
282
|
-
const session = await getSessionFromCtx(ctx);
|
|
283
|
-
let userPasskeys = [];
|
|
284
|
-
if (session) userPasskeys = await ctx.context.adapter.findMany({
|
|
285
|
-
model: "passkey",
|
|
286
|
-
where: [{
|
|
287
|
-
field: "userId",
|
|
288
|
-
value: session.user.id
|
|
289
|
-
}]
|
|
290
|
-
});
|
|
291
|
-
const options$1 = await generateAuthenticationOptions({
|
|
292
|
-
rpID: getRpID(opts, ctx.context.options.baseURL),
|
|
293
|
-
userVerification: "preferred",
|
|
294
|
-
...userPasskeys.length ? { allowCredentials: userPasskeys.map((passkey$1) => ({
|
|
295
|
-
id: passkey$1.credentialID,
|
|
296
|
-
transports: passkey$1.transports?.split(",")
|
|
297
|
-
})) } : {}
|
|
298
|
-
});
|
|
299
|
-
const data = {
|
|
300
|
-
expectedChallenge: options$1.challenge,
|
|
301
|
-
userData: { id: session?.user.id || "" }
|
|
302
|
-
};
|
|
303
|
-
const id = generateId(32);
|
|
304
|
-
const webAuthnCookie = ctx.context.createAuthCookie(opts.advanced.webAuthnChallengeCookie);
|
|
305
|
-
await ctx.setSignedCookie(webAuthnCookie.name, id, ctx.context.secret, {
|
|
306
|
-
...webAuthnCookie.attributes,
|
|
307
|
-
maxAge: maxAgeInSeconds
|
|
308
|
-
});
|
|
309
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
310
|
-
identifier: id,
|
|
311
|
-
value: JSON.stringify(data),
|
|
312
|
-
expiresAt: expirationTime
|
|
313
|
-
});
|
|
314
|
-
return ctx.json(options$1, { status: 200 });
|
|
620
|
+
generatePasskeyAuthenticationOptions: generatePasskeyAuthenticationOptions(opts, {
|
|
621
|
+
maxAgeInSeconds,
|
|
622
|
+
expirationTime
|
|
315
623
|
}),
|
|
316
|
-
verifyPasskeyRegistration:
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}),
|
|
322
|
-
use: [freshSessionMiddleware],
|
|
323
|
-
metadata: { openapi: {
|
|
324
|
-
operationId: "passkeyVerifyRegistration",
|
|
325
|
-
description: "Verify registration of a new passkey",
|
|
326
|
-
responses: {
|
|
327
|
-
200: {
|
|
328
|
-
description: "Success",
|
|
329
|
-
content: { "application/json": { schema: { $ref: "#/components/schemas/Passkey" } } }
|
|
330
|
-
},
|
|
331
|
-
400: { description: "Bad request" }
|
|
332
|
-
}
|
|
333
|
-
} }
|
|
334
|
-
}, async (ctx) => {
|
|
335
|
-
const origin = options?.origin || ctx.headers?.get("origin") || "";
|
|
336
|
-
if (!origin) return ctx.json(null, { status: 400 });
|
|
337
|
-
const resp = ctx.body.response;
|
|
338
|
-
const webAuthnCookie = ctx.context.createAuthCookie(opts.advanced.webAuthnChallengeCookie);
|
|
339
|
-
const challengeId = await ctx.getSignedCookie(webAuthnCookie.name, ctx.context.secret);
|
|
340
|
-
if (!challengeId) throw new APIError("BAD_REQUEST", { message: PASSKEY_ERROR_CODES.CHALLENGE_NOT_FOUND });
|
|
341
|
-
const data = await ctx.context.internalAdapter.findVerificationValue(challengeId);
|
|
342
|
-
if (!data) return ctx.json(null, { status: 400 });
|
|
343
|
-
const { expectedChallenge, userData } = JSON.parse(data.value);
|
|
344
|
-
if (userData.id !== ctx.context.session.user.id) throw new APIError("UNAUTHORIZED", { message: PASSKEY_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY });
|
|
345
|
-
try {
|
|
346
|
-
const { verified, registrationInfo } = await verifyRegistrationResponse({
|
|
347
|
-
response: resp,
|
|
348
|
-
expectedChallenge,
|
|
349
|
-
expectedOrigin: origin,
|
|
350
|
-
expectedRPID: getRpID(opts, ctx.context.options.baseURL),
|
|
351
|
-
requireUserVerification: false
|
|
352
|
-
});
|
|
353
|
-
if (!verified || !registrationInfo) return ctx.json(null, { status: 400 });
|
|
354
|
-
const { aaguid, credentialDeviceType, credentialBackedUp, credential, credentialType } = registrationInfo;
|
|
355
|
-
const pubKey = base64.encode(credential.publicKey);
|
|
356
|
-
const newPasskey = {
|
|
357
|
-
name: ctx.body.name,
|
|
358
|
-
userId: userData.id,
|
|
359
|
-
credentialID: credential.id,
|
|
360
|
-
publicKey: pubKey,
|
|
361
|
-
counter: credential.counter,
|
|
362
|
-
deviceType: credentialDeviceType,
|
|
363
|
-
transports: resp.response.transports.join(","),
|
|
364
|
-
backedUp: credentialBackedUp,
|
|
365
|
-
createdAt: /* @__PURE__ */ new Date(),
|
|
366
|
-
aaguid
|
|
367
|
-
};
|
|
368
|
-
const newPasskeyRes = await ctx.context.adapter.create({
|
|
369
|
-
model: "passkey",
|
|
370
|
-
data: newPasskey
|
|
371
|
-
});
|
|
372
|
-
return ctx.json(newPasskeyRes, { status: 200 });
|
|
373
|
-
} catch (e) {
|
|
374
|
-
console.log(e);
|
|
375
|
-
throw new APIError("INTERNAL_SERVER_ERROR", { message: PASSKEY_ERROR_CODES.FAILED_TO_VERIFY_REGISTRATION });
|
|
376
|
-
}
|
|
377
|
-
}),
|
|
378
|
-
verifyPasskeyAuthentication: createAuthEndpoint("/passkey/verify-authentication", {
|
|
379
|
-
method: "POST",
|
|
380
|
-
body: z.object({ response: z.record(z.any(), z.any()) }),
|
|
381
|
-
metadata: {
|
|
382
|
-
openapi: {
|
|
383
|
-
operationId: "passkeyVerifyAuthentication",
|
|
384
|
-
description: "Verify authentication of a passkey",
|
|
385
|
-
responses: { 200: {
|
|
386
|
-
description: "Success",
|
|
387
|
-
content: { "application/json": { schema: {
|
|
388
|
-
type: "object",
|
|
389
|
-
properties: {
|
|
390
|
-
session: { $ref: "#/components/schemas/Session" },
|
|
391
|
-
user: { $ref: "#/components/schemas/User" }
|
|
392
|
-
}
|
|
393
|
-
} } }
|
|
394
|
-
} }
|
|
395
|
-
},
|
|
396
|
-
$Infer: { body: {} }
|
|
397
|
-
}
|
|
398
|
-
}, async (ctx) => {
|
|
399
|
-
const origin = options?.origin || ctx.headers?.get("origin") || "";
|
|
400
|
-
if (!origin) throw new APIError("BAD_REQUEST", { message: "origin missing" });
|
|
401
|
-
const resp = ctx.body.response;
|
|
402
|
-
const webAuthnCookie = ctx.context.createAuthCookie(opts.advanced.webAuthnChallengeCookie);
|
|
403
|
-
const challengeId = await ctx.getSignedCookie(webAuthnCookie.name, ctx.context.secret);
|
|
404
|
-
if (!challengeId) throw new APIError("BAD_REQUEST", { message: PASSKEY_ERROR_CODES.CHALLENGE_NOT_FOUND });
|
|
405
|
-
const data = await ctx.context.internalAdapter.findVerificationValue(challengeId);
|
|
406
|
-
if (!data) throw new APIError("BAD_REQUEST", { message: PASSKEY_ERROR_CODES.CHALLENGE_NOT_FOUND });
|
|
407
|
-
const { expectedChallenge } = JSON.parse(data.value);
|
|
408
|
-
const passkey$1 = await ctx.context.adapter.findOne({
|
|
409
|
-
model: "passkey",
|
|
410
|
-
where: [{
|
|
411
|
-
field: "credentialID",
|
|
412
|
-
value: resp.id
|
|
413
|
-
}]
|
|
414
|
-
});
|
|
415
|
-
if (!passkey$1) throw new APIError("UNAUTHORIZED", { message: PASSKEY_ERROR_CODES.PASSKEY_NOT_FOUND });
|
|
416
|
-
try {
|
|
417
|
-
const verification = await verifyAuthenticationResponse({
|
|
418
|
-
response: resp,
|
|
419
|
-
expectedChallenge,
|
|
420
|
-
expectedOrigin: origin,
|
|
421
|
-
expectedRPID: getRpID(opts, ctx.context.options.baseURL),
|
|
422
|
-
credential: {
|
|
423
|
-
id: passkey$1.credentialID,
|
|
424
|
-
publicKey: base64.decode(passkey$1.publicKey),
|
|
425
|
-
counter: passkey$1.counter,
|
|
426
|
-
transports: passkey$1.transports?.split(",")
|
|
427
|
-
},
|
|
428
|
-
requireUserVerification: false
|
|
429
|
-
});
|
|
430
|
-
const { verified } = verification;
|
|
431
|
-
if (!verified) throw new APIError("UNAUTHORIZED", { message: PASSKEY_ERROR_CODES.AUTHENTICATION_FAILED });
|
|
432
|
-
await ctx.context.adapter.update({
|
|
433
|
-
model: "passkey",
|
|
434
|
-
where: [{
|
|
435
|
-
field: "id",
|
|
436
|
-
value: passkey$1.id
|
|
437
|
-
}],
|
|
438
|
-
update: { counter: verification.authenticationInfo.newCounter }
|
|
439
|
-
});
|
|
440
|
-
const s = await ctx.context.internalAdapter.createSession(passkey$1.userId);
|
|
441
|
-
if (!s) throw new APIError("INTERNAL_SERVER_ERROR", { message: PASSKEY_ERROR_CODES.UNABLE_TO_CREATE_SESSION });
|
|
442
|
-
const user = await ctx.context.internalAdapter.findUserById(passkey$1.userId);
|
|
443
|
-
if (!user) throw new APIError("INTERNAL_SERVER_ERROR", { message: "User not found" });
|
|
444
|
-
await setSessionCookie(ctx, {
|
|
445
|
-
session: s,
|
|
446
|
-
user
|
|
447
|
-
});
|
|
448
|
-
return ctx.json({ session: s }, { status: 200 });
|
|
449
|
-
} catch (e) {
|
|
450
|
-
ctx.context.logger.error("Failed to verify authentication", e);
|
|
451
|
-
throw new APIError("BAD_REQUEST", { message: PASSKEY_ERROR_CODES.AUTHENTICATION_FAILED });
|
|
452
|
-
}
|
|
453
|
-
}),
|
|
454
|
-
listPasskeys: createAuthEndpoint("/passkey/list-user-passkeys", {
|
|
455
|
-
method: "GET",
|
|
456
|
-
use: [sessionMiddleware],
|
|
457
|
-
metadata: { openapi: {
|
|
458
|
-
description: "List all passkeys for the authenticated user",
|
|
459
|
-
responses: { "200": {
|
|
460
|
-
description: "Passkeys retrieved successfully",
|
|
461
|
-
content: { "application/json": { schema: {
|
|
462
|
-
type: "array",
|
|
463
|
-
items: {
|
|
464
|
-
$ref: "#/components/schemas/Passkey",
|
|
465
|
-
required: [
|
|
466
|
-
"id",
|
|
467
|
-
"userId",
|
|
468
|
-
"publicKey",
|
|
469
|
-
"createdAt",
|
|
470
|
-
"updatedAt"
|
|
471
|
-
]
|
|
472
|
-
},
|
|
473
|
-
description: "Array of passkey objects associated with the user"
|
|
474
|
-
} } }
|
|
475
|
-
} }
|
|
476
|
-
} }
|
|
477
|
-
}, async (ctx) => {
|
|
478
|
-
const passkeys = await ctx.context.adapter.findMany({
|
|
479
|
-
model: "passkey",
|
|
480
|
-
where: [{
|
|
481
|
-
field: "userId",
|
|
482
|
-
value: ctx.context.session.user.id
|
|
483
|
-
}]
|
|
484
|
-
});
|
|
485
|
-
return ctx.json(passkeys, { status: 200 });
|
|
486
|
-
}),
|
|
487
|
-
deletePasskey: createAuthEndpoint("/passkey/delete-passkey", {
|
|
488
|
-
method: "POST",
|
|
489
|
-
body: z.object({ id: z.string().meta({ description: "The ID of the passkey to delete. Eg: \"some-passkey-id\"" }) }),
|
|
490
|
-
use: [sessionMiddleware],
|
|
491
|
-
metadata: { openapi: {
|
|
492
|
-
description: "Delete a specific passkey",
|
|
493
|
-
responses: { "200": {
|
|
494
|
-
description: "Passkey deleted successfully",
|
|
495
|
-
content: { "application/json": { schema: {
|
|
496
|
-
type: "object",
|
|
497
|
-
properties: { status: {
|
|
498
|
-
type: "boolean",
|
|
499
|
-
description: "Indicates whether the deletion was successful"
|
|
500
|
-
} },
|
|
501
|
-
required: ["status"]
|
|
502
|
-
} } }
|
|
503
|
-
} }
|
|
504
|
-
} }
|
|
505
|
-
}, async (ctx) => {
|
|
506
|
-
const passkey$1 = await ctx.context.adapter.findOne({
|
|
507
|
-
model: "passkey",
|
|
508
|
-
where: [{
|
|
509
|
-
field: "id",
|
|
510
|
-
value: ctx.body.id
|
|
511
|
-
}]
|
|
512
|
-
});
|
|
513
|
-
if (!passkey$1) throw new APIError("NOT_FOUND", { message: PASSKEY_ERROR_CODES.PASSKEY_NOT_FOUND });
|
|
514
|
-
if (passkey$1.userId !== ctx.context.session.user.id) throw new APIError("UNAUTHORIZED");
|
|
515
|
-
await ctx.context.adapter.delete({
|
|
516
|
-
model: "passkey",
|
|
517
|
-
where: [{
|
|
518
|
-
field: "id",
|
|
519
|
-
value: passkey$1.id
|
|
520
|
-
}]
|
|
521
|
-
});
|
|
522
|
-
return ctx.json({ status: true });
|
|
523
|
-
}),
|
|
524
|
-
updatePasskey: createAuthEndpoint("/passkey/update-passkey", {
|
|
525
|
-
method: "POST",
|
|
526
|
-
body: z.object({
|
|
527
|
-
id: z.string().meta({ description: `The ID of the passkey which will be updated. Eg: \"passkey-id\"` }),
|
|
528
|
-
name: z.string().meta({ description: `The new name which the passkey will be updated to. Eg: \"my-new-passkey-name\"` })
|
|
529
|
-
}),
|
|
530
|
-
use: [sessionMiddleware],
|
|
531
|
-
metadata: { openapi: {
|
|
532
|
-
description: "Update a specific passkey's name",
|
|
533
|
-
responses: { "200": {
|
|
534
|
-
description: "Passkey updated successfully",
|
|
535
|
-
content: { "application/json": { schema: {
|
|
536
|
-
type: "object",
|
|
537
|
-
properties: { passkey: { $ref: "#/components/schemas/Passkey" } },
|
|
538
|
-
required: ["passkey"]
|
|
539
|
-
} } }
|
|
540
|
-
} }
|
|
541
|
-
} }
|
|
542
|
-
}, async (ctx) => {
|
|
543
|
-
const passkey$1 = await ctx.context.adapter.findOne({
|
|
544
|
-
model: "passkey",
|
|
545
|
-
where: [{
|
|
546
|
-
field: "id",
|
|
547
|
-
value: ctx.body.id
|
|
548
|
-
}]
|
|
549
|
-
});
|
|
550
|
-
if (!passkey$1) throw new APIError("NOT_FOUND", { message: PASSKEY_ERROR_CODES.PASSKEY_NOT_FOUND });
|
|
551
|
-
if (passkey$1.userId !== ctx.context.session.user.id) throw new APIError("UNAUTHORIZED", { message: PASSKEY_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY });
|
|
552
|
-
const updatedPasskey = await ctx.context.adapter.update({
|
|
553
|
-
model: "passkey",
|
|
554
|
-
where: [{
|
|
555
|
-
field: "id",
|
|
556
|
-
value: ctx.body.id
|
|
557
|
-
}],
|
|
558
|
-
update: { name: ctx.body.name }
|
|
559
|
-
});
|
|
560
|
-
if (!updatedPasskey) throw new APIError("INTERNAL_SERVER_ERROR", { message: PASSKEY_ERROR_CODES.FAILED_TO_UPDATE_PASSKEY });
|
|
561
|
-
return ctx.json({ passkey: updatedPasskey }, { status: 200 });
|
|
562
|
-
})
|
|
624
|
+
verifyPasskeyRegistration: verifyPasskeyRegistration(opts),
|
|
625
|
+
verifyPasskeyAuthentication: verifyPasskeyAuthentication(opts),
|
|
626
|
+
listPasskeys,
|
|
627
|
+
deletePasskey,
|
|
628
|
+
updatePasskey
|
|
563
629
|
},
|
|
564
630
|
schema: mergeSchema(schema, options?.schema),
|
|
565
631
|
$ERROR_CODES: PASSKEY_ERROR_CODES
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/passkey",
|
|
3
|
-
"version": "1.4.6
|
|
3
|
+
"version": "1.4.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Passkey plugin for Better Auth",
|
|
6
6
|
"main": "dist/index.mjs",
|
|
7
7
|
"module": "dist/index.mjs",
|
|
8
|
+
"types": "dist/index.d.mts",
|
|
8
9
|
"publishConfig": {
|
|
9
10
|
"access": "public"
|
|
10
11
|
},
|
|
@@ -31,9 +32,9 @@
|
|
|
31
32
|
}
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
|
-
"tsdown": "^0.
|
|
35
|
-
"
|
|
36
|
-
"better-auth": "1.4.6
|
|
35
|
+
"tsdown": "^0.17.0",
|
|
36
|
+
"better-auth": "1.4.6",
|
|
37
|
+
"@better-auth/core": "1.4.6"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
40
|
"@simplewebauthn/browser": "^13.1.2",
|
|
@@ -43,17 +44,17 @@
|
|
|
43
44
|
"peerDependencies": {
|
|
44
45
|
"@better-auth/utils": "0.3.0",
|
|
45
46
|
"@better-fetch/fetch": "1.1.18",
|
|
46
|
-
"better-call": "1.1.
|
|
47
|
+
"better-call": "1.1.5",
|
|
47
48
|
"nanostores": "^1.0.1",
|
|
48
|
-
"@better-auth/core": "1.4.6
|
|
49
|
-
"better-auth": "1.4.6
|
|
49
|
+
"@better-auth/core": "1.4.6",
|
|
50
|
+
"better-auth": "1.4.6"
|
|
50
51
|
},
|
|
51
52
|
"files": [
|
|
52
53
|
"dist"
|
|
53
54
|
],
|
|
54
55
|
"repository": {
|
|
55
56
|
"type": "git",
|
|
56
|
-
"url": "https://github.com/better-auth/better-auth",
|
|
57
|
+
"url": "git+https://github.com/better-auth/better-auth.git",
|
|
57
58
|
"directory": "packages/passkey"
|
|
58
59
|
},
|
|
59
60
|
"homepage": "https://www.better-auth.com/docs/plugins/passkey",
|
|
@@ -66,6 +67,7 @@
|
|
|
66
67
|
"license": "MIT",
|
|
67
68
|
"scripts": {
|
|
68
69
|
"test": "vitest",
|
|
70
|
+
"coverage": "vitest run --coverage",
|
|
69
71
|
"lint:package": "publint run --strict",
|
|
70
72
|
"build": "tsdown",
|
|
71
73
|
"dev": "tsdown --watch",
|