@draftlab/auth 0.1.3 → 0.1.5

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.
@@ -0,0 +1,104 @@
1
+ import { Provider } from "./provider.js";
2
+ import { AuthenticatorSelectionCriteria, AuthenticatorTransportFuture, Base64URLString, CredentialDeviceType } from "@simplewebauthn/server";
3
+
4
+ //#region src/provider/passkey.d.ts
5
+
6
+ /**
7
+ * User model for passkey authentication.
8
+ * Contains the core user data needed for WebAuthn operations.
9
+ */
10
+ interface UserModel {
11
+ id: string;
12
+ username: string;
13
+ }
14
+ /**
15
+ * Original PasskeyModel structure for in-memory use.
16
+ * Represents a registered credential with public key as Uint8Array.
17
+ */
18
+ interface PasskeyModel {
19
+ id: string;
20
+ publicKey: Uint8Array;
21
+ userId: string;
22
+ webauthnUserID: string;
23
+ counter: number;
24
+ deviceType: CredentialDeviceType;
25
+ backedUp: boolean;
26
+ transports?: AuthenticatorTransportFuture[];
27
+ }
28
+ /**
29
+ * PasskeyModel version for KV storage with publicKey as string.
30
+ * Used for storing credentials in a key-value store.
31
+ */
32
+ interface PasskeyModelStored extends Omit<PasskeyModel, "publicKey"> {
33
+ publicKey: string;
34
+ }
35
+ declare const DEFAULT_COPY: {
36
+ error_user_not_allowed: string;
37
+ };
38
+ /**
39
+ * Configuration for the PasskeyProvider.
40
+ * Defines how the passkey authentication flow should behave.
41
+ */
42
+ interface PasskeyProviderConfig {
43
+ /**
44
+ * Custom authorization handler that generates the UI for authorization.
45
+ */
46
+ authorize: (req: Request) => Promise<Response>;
47
+ /**
48
+ * Custom registration handler that generates the UI for registration.
49
+ */
50
+ register: (req: Request) => Promise<Response>;
51
+ /**
52
+ * The human-readable name of the relying party (your application).
53
+ */
54
+ rpName: string;
55
+ /**
56
+ * The ID of the relying party, typically the domain name without protocol.
57
+ */
58
+ rpID?: string;
59
+ /**
60
+ * The origin URL(s) that are allowed to initiate WebAuthn ceremonies.
61
+ */
62
+ origin?: string | string[];
63
+ /**
64
+ * Optional function to check if a user is allowed to register a passkey.
65
+ */
66
+ userCanRegisterPasskey?: (userId: string, req: Request) => Promise<boolean>;
67
+ /**
68
+ * Optional WebAuthn authenticator selection criteria.
69
+ */
70
+ authenticatorSelection?: AuthenticatorSelectionCriteria;
71
+ /**
72
+ * Optional attestation type.
73
+ */
74
+ attestationType?: "none" | "direct" | "enterprise";
75
+ /**
76
+ * Optional timeout for challenges in milliseconds.
77
+ */
78
+ timeout?: number;
79
+ /**
80
+ * Custom copy texts for error messages and UI elements.
81
+ */
82
+ copy?: Partial<typeof DEFAULT_COPY>;
83
+ }
84
+ /**
85
+ * Creates a passkey (WebAuthn) authentication provider.
86
+ *
87
+ * This provider enables passwordless authentication using biometrics, hardware security
88
+ * keys, or platform authenticators. It implements the Web Authentication (WebAuthn) standard.
89
+ *
90
+ * It handles:
91
+ * - Passkey registration (creating new credentials)
92
+ * - Authentication with existing passkeys
93
+ * - Secure storage of credentials
94
+ * - Challenge verification
95
+ *
96
+ * @param config Configuration options for the passkey provider
97
+ * @returns A Provider instance configured for passkey authentication
98
+ */
99
+ declare const PasskeyProvider: (config: PasskeyProviderConfig) => Provider<{
100
+ userId: string;
101
+ credentialId?: Base64URLString;
102
+ }>;
103
+ //#endregion
104
+ export { PasskeyModel, PasskeyModelStored, PasskeyProvider, PasskeyProviderConfig, UserModel };
@@ -0,0 +1,324 @@
1
+ import { Storage } from "../storage/storage.js";
2
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
3
+
4
+ //#region src/provider/passkey.ts
5
+ /**
6
+ * Converts a Uint8Array to a Base64URL encoded string.
7
+ * This is used to convert binary data for storage in databases or JSON.
8
+ *
9
+ * @param bytes - The Uint8Array to convert
10
+ * @returns Base64URL encoded string
11
+ */
12
+ const uint8ArrayToBase64Url = (bytes) => {
13
+ let str = "";
14
+ for (const charCode of bytes) str += String.fromCharCode(charCode);
15
+ const base64String = btoa(str);
16
+ return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
17
+ };
18
+ /**
19
+ * Converts a Base64URL encoded string back to a Uint8Array.
20
+ * This is used to convert stored data back to binary format for WebAuthn operations.
21
+ *
22
+ * @param base64urlString - The Base64URL encoded string to convert
23
+ * @returns Uint8Array containing the decoded data
24
+ */
25
+ const base64UrlToUint8Array = (base64urlString) => {
26
+ const base64 = base64urlString.replace(/-/g, "+").replace(/_/g, "/");
27
+ /**
28
+ * Pad with '=' until it's a multiple of four
29
+ * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding
30
+ * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding
31
+ * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding
32
+ * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding
33
+ */
34
+ const padLength = (4 - base64.length % 4) % 4;
35
+ const padded = base64.padEnd(base64.length + padLength, "=");
36
+ const binary = atob(padded);
37
+ const buffer = new ArrayBuffer(binary.length);
38
+ const bytes = new Uint8Array(buffer);
39
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
40
+ return bytes;
41
+ };
42
+ const userKey = (userId) => [
43
+ "passkey",
44
+ "user",
45
+ userId
46
+ ];
47
+ const passkeyKey = (userId, credentialId) => [
48
+ "passkey",
49
+ "user",
50
+ userId,
51
+ "credential",
52
+ credentialId,
53
+ "passkey"
54
+ ];
55
+ const optionsKey = (userId) => [
56
+ "passkey",
57
+ "user",
58
+ userId,
59
+ "options"
60
+ ];
61
+ const userPasskeysIndexKey = (userId) => [
62
+ "passkey",
63
+ "user",
64
+ userId,
65
+ "passkeys"
66
+ ];
67
+ const DEFAULT_COPY = { error_user_not_allowed: "There is already an account with this email. Login to add a passkey." };
68
+ /**
69
+ * Creates a passkey (WebAuthn) authentication provider.
70
+ *
71
+ * This provider enables passwordless authentication using biometrics, hardware security
72
+ * keys, or platform authenticators. It implements the Web Authentication (WebAuthn) standard.
73
+ *
74
+ * It handles:
75
+ * - Passkey registration (creating new credentials)
76
+ * - Authentication with existing passkeys
77
+ * - Secure storage of credentials
78
+ * - Challenge verification
79
+ *
80
+ * @param config Configuration options for the passkey provider
81
+ * @returns A Provider instance configured for passkey authentication
82
+ */
83
+ const PasskeyProvider = (config) => {
84
+ const copy = {
85
+ ...DEFAULT_COPY,
86
+ ...config.copy
87
+ };
88
+ return {
89
+ type: "passkey",
90
+ init(routes, ctx) {
91
+ const { rpName, authenticatorSelection, attestationType = "none", timeout = 5 * 60 * 1e3 } = config;
92
+ const getStoredUserById = async (userId) => {
93
+ return await Storage.get(ctx.storage, userKey(userId));
94
+ };
95
+ const saveUser = async (user) => {
96
+ await Storage.set(ctx.storage, userKey(user.id), user);
97
+ };
98
+ const getStoredPasskeyById = async (userId, credentialID) => {
99
+ const storedPasskey = await Storage.get(ctx.storage, passkeyKey(userId, credentialID));
100
+ if (!storedPasskey) return null;
101
+ return {
102
+ ...storedPasskey,
103
+ publicKey: base64UrlToUint8Array(storedPasskey.publicKey)
104
+ };
105
+ };
106
+ const getStoredUserPasskeys = async (userId) => {
107
+ const passkeyIds = await Storage.get(ctx.storage, userPasskeysIndexKey(userId)) || [];
108
+ const passkeys = [];
109
+ for (const id of passkeyIds) {
110
+ const pk = await getStoredPasskeyById(userId, id);
111
+ if (pk) passkeys.push(pk);
112
+ }
113
+ return passkeys;
114
+ };
115
+ const saveNewStoredPasskey = async (passkeyData) => {
116
+ const storablePasskey = {
117
+ ...passkeyData,
118
+ publicKey: uint8ArrayToBase64Url(passkeyData.publicKey)
119
+ };
120
+ await Storage.set(ctx.storage, passkeyKey(passkeyData.userId, passkeyData.id), storablePasskey);
121
+ const passkeyIds = await Storage.get(ctx.storage, userPasskeysIndexKey(passkeyData.userId)) || [];
122
+ if (!passkeyIds.includes(passkeyData.id)) {
123
+ passkeyIds.push(passkeyData.id);
124
+ await Storage.set(ctx.storage, userPasskeysIndexKey(passkeyData.userId), passkeyIds);
125
+ }
126
+ };
127
+ const updateStoredPasskeyCounter = async (userId, credentialID, newCounter) => {
128
+ const passkey = await getStoredPasskeyById(userId, credentialID);
129
+ if (passkey) {
130
+ passkey.counter = newCounter;
131
+ const storablePasskey = {
132
+ ...passkey,
133
+ publicKey: uint8ArrayToBase64Url(passkey.publicKey)
134
+ };
135
+ await Storage.set(ctx.storage, passkeyKey(userId, credentialID), storablePasskey);
136
+ }
137
+ };
138
+ routes.get("/authorize", async (c) => {
139
+ return ctx.forward(c, await config.authorize(c.request));
140
+ });
141
+ routes.get("/register", async (c) => {
142
+ return ctx.forward(c, await config.register(c.request));
143
+ });
144
+ routes.get("/register-request", async (c) => {
145
+ const userId = c.query("userId");
146
+ const rpID = config.rpID || c.query("rpID");
147
+ const otherDevice = c.query("otherDevice") === "true";
148
+ if (!userId) return c.json({ error: "User ID for registration is required." }, { status: 400 });
149
+ if (!rpID) return c.json({ error: "RP ID for registration is required." }, { status: 400 });
150
+ const username = c.query("username") || userId;
151
+ let user = await getStoredUserById(userId);
152
+ if (config.userCanRegisterPasskey) {
153
+ const isAllowed = await config.userCanRegisterPasskey(userId, c.request);
154
+ if (!isAllowed) return c.json({ error: copy.error_user_not_allowed }, { status: 403 });
155
+ }
156
+ if (!user) {
157
+ user = {
158
+ id: userId,
159
+ username
160
+ };
161
+ await saveUser(user);
162
+ }
163
+ const userPasskeys = await getStoredUserPasskeys(user.id);
164
+ const regOptions = await generateRegistrationOptions({
165
+ rpName,
166
+ rpID,
167
+ userName: user.username,
168
+ attestationType,
169
+ excludeCredentials: userPasskeys.map((pk) => ({
170
+ id: pk.id,
171
+ transports: pk.transports
172
+ })),
173
+ authenticatorSelection: authenticatorSelection ?? {
174
+ residentKey: "preferred",
175
+ userVerification: "preferred",
176
+ authenticatorAttachment: otherDevice ? "cross-platform" : "platform"
177
+ },
178
+ timeout
179
+ });
180
+ await Storage.set(ctx.storage, optionsKey(user.id), regOptions);
181
+ return c.json(regOptions);
182
+ });
183
+ routes.post("/register-verify", async (c) => {
184
+ const body = await c.parseJson();
185
+ const userId = c.query("userId");
186
+ const rpID = config.rpID || c.query("rpID");
187
+ const origin = config.origin || c.query("origin");
188
+ if (!userId) return c.json({
189
+ verified: false,
190
+ error: "User ID for verification is required."
191
+ }, { status: 400 });
192
+ if (!rpID) return c.json({ error: "RP ID for verification is required." }, { status: 400 });
193
+ if (!origin) return c.json({ error: "Origin for verification is required." }, { status: 400 });
194
+ const user = await getStoredUserById(userId);
195
+ if (!user) return c.json({
196
+ verified: false,
197
+ error: "User not found during verification."
198
+ }, { status: 404 });
199
+ const regOptions = await Storage.get(ctx.storage, optionsKey(user.id));
200
+ if (!regOptions) return c.json({
201
+ verified: false,
202
+ error: "Registration options not found."
203
+ }, { status: 400 });
204
+ const challenge = regOptions.challenge;
205
+ let verification;
206
+ try {
207
+ verification = await verifyRegistrationResponse({
208
+ response: body,
209
+ expectedChallenge: challenge,
210
+ expectedOrigin: origin,
211
+ expectedRPID: rpID,
212
+ requireUserVerification: authenticatorSelection?.userVerification !== "discouraged"
213
+ });
214
+ } catch (error) {
215
+ console.error("Passkey Registration Verification Error:", error);
216
+ return c.json({
217
+ verified: false,
218
+ error: error.message
219
+ }, { status: 400 });
220
+ }
221
+ const { verified, registrationInfo } = verification;
222
+ if (verified && registrationInfo) {
223
+ const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
224
+ if (credential) {
225
+ const newPasskey = {
226
+ id: credential.id,
227
+ userId: user.id,
228
+ webauthnUserID: regOptions.user.id,
229
+ publicKey: credential.publicKey,
230
+ counter: credential.counter,
231
+ transports: credential.transports,
232
+ deviceType: credentialDeviceType,
233
+ backedUp: credentialBackedUp
234
+ };
235
+ await saveNewStoredPasskey(newPasskey);
236
+ return ctx.success(c, {
237
+ userId: user.id,
238
+ credentialId: newPasskey.id,
239
+ verified: true
240
+ });
241
+ }
242
+ }
243
+ return c.json({
244
+ verified: false,
245
+ error: "Registration verification failed."
246
+ }, { status: 400 });
247
+ });
248
+ routes.get("/authenticate-options", async (c) => {
249
+ const userId = c.query("userId");
250
+ if (!userId) return c.json({ error: "User ID for authentication is required." }, { status: 400 });
251
+ const rpID = config.rpID || c.query("rpID");
252
+ if (!rpID) return c.json({ error: "RP ID for authentication is required." }, { status: 400 });
253
+ const userForAuth = await getStoredUserById(userId);
254
+ if (!userForAuth) return c.json({ error: "User not found for authentication." }, { status: 404 });
255
+ const userPasskeys = await getStoredUserPasskeys(userForAuth.id);
256
+ const allowCredentialsList = userPasskeys.map((pk) => ({
257
+ id: pk.id,
258
+ transports: pk.transports
259
+ }));
260
+ const authOptions = await generateAuthenticationOptions({
261
+ rpID,
262
+ allowCredentials: allowCredentialsList,
263
+ userVerification: authenticatorSelection?.userVerification ?? "preferred",
264
+ timeout
265
+ });
266
+ await Storage.set(ctx.storage, optionsKey(userForAuth.id), authOptions);
267
+ return c.json(authOptions);
268
+ });
269
+ routes.post("/authenticate-verify", async (c) => {
270
+ const body = await c.parseJson();
271
+ const userId = c.query("userId");
272
+ if (!userId) return c.json({ error: "User ID for authentication is required." }, { status: 400 });
273
+ const rpID = config.rpID || c.query("rpID");
274
+ if (!rpID) return c.json({ error: "RP ID for authentication is required." }, { status: 400 });
275
+ const origin = config.origin || c.query("origin");
276
+ if (!origin) return c.json({ error: "Origin for authentication is required." }, { status: 400 });
277
+ const user = await getStoredUserById(userId);
278
+ if (!user) return c.json({
279
+ verified: false,
280
+ error: `User ${userId} not found.`
281
+ }, { status: 404 });
282
+ const authOptions = await Storage.get(ctx.storage, optionsKey(user.id));
283
+ if (!authOptions) return c.json({ error: "Authentication options not found." }, { status: 400 });
284
+ const passkey = await getStoredPasskeyById(userId, body.id);
285
+ if (!passkey) return c.json({
286
+ verified: false,
287
+ error: `Passkey ${body.id} not found for user ${user.username}.`
288
+ }, { status: 400 });
289
+ const { publicKey, counter, transports } = passkey;
290
+ if (!publicKey || typeof counter !== "number" || !transports) return c.json({ error: "Passkey not found for authentication." }, { status: 400 });
291
+ const challenge = authOptions.challenge;
292
+ if (!challenge) return c.json({ error: "Authentication challenge not found." }, { status: 400 });
293
+ const verification = await verifyAuthenticationResponse({
294
+ response: body,
295
+ expectedChallenge: challenge,
296
+ expectedOrigin: origin || "",
297
+ expectedRPID: rpID,
298
+ credential: {
299
+ id: passkey.id,
300
+ publicKey,
301
+ counter,
302
+ transports
303
+ }
304
+ });
305
+ const { verified, authenticationInfo } = verification;
306
+ if (verified) {
307
+ await updateStoredPasskeyCounter(user.id, passkey.id, authenticationInfo.newCounter);
308
+ return ctx.success(c, {
309
+ userId: user.id,
310
+ credentialId: passkey.id,
311
+ verified: true
312
+ });
313
+ }
314
+ return c.json({
315
+ verified: false,
316
+ error: "Authentication verification failed."
317
+ }, { status: 400 });
318
+ });
319
+ }
320
+ };
321
+ };
322
+
323
+ //#endregion
324
+ export { PasskeyProvider };
package/dist/ui/base.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Theme } from "../themes/theme.js";
2
- import * as preact6 from "preact";
2
+ import * as preact0 from "preact";
3
3
  import { ComponentChildren } from "preact";
4
4
 
5
5
  //#region src/ui/base.d.ts
@@ -21,7 +21,7 @@ declare const Layout: ({
21
21
  theme,
22
22
  title,
23
23
  size
24
- }: LayoutProps) => preact6.JSX.Element;
24
+ }: LayoutProps) => preact0.JSX.Element;
25
25
  /**
26
26
  * Helper function to render a Preact component to HTML string
27
27
  */
@@ -0,0 +1,30 @@
1
+ import { PasskeyProviderConfig } from "../provider/passkey.js";
2
+
3
+ //#region src/ui/passkey.d.ts
4
+
5
+ /**
6
+ * Strongly typed copy text configuration for passkey UI
7
+ */
8
+ interface PasskeyUICopy {
9
+ readonly authorize_title: string;
10
+ readonly authorize_description: string;
11
+ readonly register_title: string;
12
+ readonly register_description: string;
13
+ readonly register: string;
14
+ readonly register_with_passkey: string;
15
+ readonly register_other_device: string;
16
+ readonly register_prompt: string;
17
+ readonly login_prompt: string;
18
+ readonly login: string;
19
+ readonly login_with_passkey: string;
20
+ readonly change_prompt: string;
21
+ readonly code_resend: string;
22
+ readonly code_return: string;
23
+ readonly input_email: string;
24
+ }
25
+ interface PasskeyUIOptions extends Omit<PasskeyProviderConfig, "authorize" | "register" | "copy"> {
26
+ readonly copy?: Partial<PasskeyUICopy>;
27
+ }
28
+ declare const PasskeyUI: (options: PasskeyUIOptions) => PasskeyProviderConfig;
29
+ //#endregion
30
+ export { PasskeyUI };
@@ -0,0 +1,341 @@
1
+ import { Layout, renderToHTML } from "./base.js";
2
+ import { FormAlert } from "./form.js";
3
+ import { jsx, jsxs } from "preact/jsx-runtime";
4
+
5
+ //#region src/ui/passkey.tsx
6
+ const DEFAULT_COPY = {
7
+ authorize_title: "Sign in with Passkey",
8
+ authorize_description: "Passkeys are a simple and more secure alternative to passwords. With passkeys, you can log in with your PIN, biometric sensor, or hardware security key.",
9
+ register_title: "Create a Passkey",
10
+ register_description: "Create a passkey to enable secure, passwordless authentication for your account.",
11
+ register: "Register",
12
+ register_with_passkey: "Register With Passkey",
13
+ register_other_device: "Use another device",
14
+ register_prompt: "Don't have an account?",
15
+ login_prompt: "Already have an account?",
16
+ login: "Login",
17
+ login_with_passkey: "Login With Passkey",
18
+ change_prompt: "Forgot password?",
19
+ code_resend: "Resend code",
20
+ code_return: "Back to",
21
+ input_email: "Email"
22
+ };
23
+ const PasskeyUI = (options) => {
24
+ const { rpName, rpID, origin, userCanRegisterPasskey, authenticatorSelection, attestationType, timeout } = options;
25
+ const copy = {
26
+ ...DEFAULT_COPY,
27
+ ...options.copy
28
+ };
29
+ return {
30
+ authorize: async () => {
31
+ const jsx$1 = /* @__PURE__ */ jsxs(Layout, { children: [
32
+ /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: `
33
+ window.addEventListener("load", async () => {
34
+ const { startAuthentication } = SimpleWebAuthnBrowser;
35
+ const authorizeForm = document.getElementById("authorizeForm");
36
+ const origin = window.location.origin;
37
+ const rpID = window.location.hostname;
38
+
39
+ const showMessage = (msg) => {
40
+ const messageEl = document.querySelector("[data-slot='message']");
41
+ if (messageEl) {
42
+ messageEl.innerHTML = msg;
43
+ } else {
44
+ // Create alert if it doesn't exist
45
+ const alertDiv = document.createElement("div");
46
+ alertDiv.setAttribute("data-component", "form-alert");
47
+ alertDiv.setAttribute("role", "alert");
48
+ alertDiv.setAttribute("aria-live", "polite");
49
+ alertDiv.setAttribute("data-color", "error");
50
+ alertDiv.innerHTML = '<span data-slot="message">' + msg + '</span>';
51
+ authorizeForm.insertBefore(alertDiv, authorizeForm.firstChild);
52
+ }
53
+ };
54
+
55
+ const clearMessage = () => {
56
+ const alertDiv = document.querySelector("[data-component='form-alert']");
57
+ if (alertDiv) {
58
+ alertDiv.remove();
59
+ }
60
+ };
61
+
62
+ authorizeForm.addEventListener("submit", async (e) => {
63
+ e.preventDefault();
64
+ const formData = new FormData(authorizeForm);
65
+ const email = formData.get("email");
66
+ clearMessage();
67
+
68
+ // GET authentication options from the endpoint that calls
69
+ // @simplewebauthn/server -> generateAuthenticationOptions()
70
+ const resp = await fetch(
71
+ "./passkey/authenticate-options?userId=" + email + "&rpID=" + rpID
72
+ );
73
+
74
+ const optionsJSON = await resp.json();
75
+
76
+ if (optionsJSON.error) {
77
+ showMessage(optionsJSON.error);
78
+ return;
79
+ }
80
+
81
+ let attResp;
82
+ try {
83
+ // Pass the options to the authenticator and wait for a response
84
+ attResp = await startAuthentication({ optionsJSON });
85
+ } catch (error) {
86
+ showMessage(error);
87
+ throw error;
88
+ }
89
+
90
+ const verificationResp = await fetch(
91
+ "./passkey/authenticate-verify?userId=" +
92
+ email +
93
+ "&rpID=" +
94
+ rpID +
95
+ "&origin=" +
96
+ origin,
97
+ {
98
+ method: "POST",
99
+ headers: {
100
+ "Content-Type": "application/json",
101
+ },
102
+ body: JSON.stringify(attResp),
103
+ }
104
+ );
105
+
106
+ // Check if the request was redirected and the final response is OK
107
+ if (verificationResp.redirected && verificationResp.ok) {
108
+ // Navigate the browser to the final URL
109
+ window.location.href = verificationResp.url;
110
+ } else {
111
+ // Handle errors (e.g., 4xx, 5xx status codes from the final URL)
112
+ console.error(
113
+ "Request failed:",
114
+ verificationResp.status,
115
+ verificationResp.statusText
116
+ );
117
+ try {
118
+ const errorData = await verificationResp.json();
119
+ showMessage(errorData.error);
120
+ } catch (error) {
121
+ showMessage("Something went wrong");
122
+ }
123
+ }
124
+ });
125
+ });
126
+ ` } }),
127
+ /* @__PURE__ */ jsx("h1", { children: copy.authorize_title }),
128
+ /* @__PURE__ */ jsx("p", { children: copy.authorize_description }),
129
+ /* @__PURE__ */ jsxs("form", {
130
+ id: "authorizeForm",
131
+ "data-component": "form",
132
+ children: [
133
+ /* @__PURE__ */ jsx(FormAlert, {}),
134
+ /* @__PURE__ */ jsx("input", {
135
+ "data-component": "input",
136
+ type: "email",
137
+ name: "email",
138
+ required: true,
139
+ placeholder: copy.input_email
140
+ }),
141
+ /* @__PURE__ */ jsx("button", {
142
+ type: "submit",
143
+ id: "btnLogin",
144
+ "data-component": "button",
145
+ children: copy.login_with_passkey
146
+ }),
147
+ /* @__PURE__ */ jsx("div", {
148
+ "data-component": "form-footer",
149
+ children: /* @__PURE__ */ jsxs("span", { children: [
150
+ copy.register_prompt,
151
+ " ",
152
+ /* @__PURE__ */ jsx("a", {
153
+ "data-component": "link",
154
+ href: "./register",
155
+ children: copy.register
156
+ })
157
+ ] })
158
+ })
159
+ ]
160
+ }),
161
+ /* @__PURE__ */ jsx("script", { src: "https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js" })
162
+ ] });
163
+ return new Response(renderToHTML(jsx$1), {
164
+ status: 200,
165
+ headers: { "Content-Type": "text/html" }
166
+ });
167
+ },
168
+ register: async () => {
169
+ const jsx$1 = /* @__PURE__ */ jsxs(Layout, { children: [
170
+ /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: `
171
+ window.addEventListener("load", async () => {
172
+ const { startRegistration } = SimpleWebAuthnBrowser;
173
+ const registerForm = document.getElementById("registerForm");
174
+ const origin = window.location.origin;
175
+ const rpID = window.location.hostname;
176
+
177
+ const showMessage = (msg) => {
178
+ const messageEl = document.querySelector("[data-slot='message']");
179
+ if (messageEl) {
180
+ messageEl.innerHTML = msg;
181
+ } else {
182
+ // Create alert if it doesn't exist
183
+ const alertDiv = document.createElement("div");
184
+ alertDiv.setAttribute("data-component", "form-alert");
185
+ alertDiv.setAttribute("role", "alert");
186
+ alertDiv.setAttribute("aria-live", "polite");
187
+ alertDiv.setAttribute("data-color", "error");
188
+ alertDiv.innerHTML = '<span data-slot="message">' + msg + '</span>';
189
+ registerForm.insertBefore(alertDiv, registerForm.firstChild);
190
+ }
191
+ };
192
+
193
+ const clearMessage = () => {
194
+ const alertDiv = document.querySelector("[data-component='form-alert']");
195
+ if (alertDiv) {
196
+ alertDiv.remove();
197
+ }
198
+ };
199
+
200
+ // Start registration when the user clicks a button
201
+ const register = async (otherDevice = false) => {
202
+ const formData = new FormData(registerForm);
203
+ const email = formData.get("email");
204
+ clearMessage();
205
+
206
+ // GET registration options from the endpoint that calls
207
+ // @simplewebauthn/server -> generateRegistrationOptions()
208
+ const resp = await fetch(
209
+ "./passkey/register-request?userId=" +
210
+ email +
211
+ "&origin=" +
212
+ origin +
213
+ "&rpID=" +
214
+ rpID +
215
+ "&otherDevice=" +
216
+ otherDevice,
217
+ );
218
+ const optionsJSON = await resp.json();
219
+
220
+ if (optionsJSON.error) {
221
+ showMessage(optionsJSON.error);
222
+ return;
223
+ }
224
+
225
+ let attResp;
226
+ try {
227
+ // Pass the options to the authenticator and wait for a response
228
+ attResp = await startRegistration({ optionsJSON });
229
+ } catch (error) {
230
+ showMessage(error);
231
+ throw error;
232
+ }
233
+
234
+ // POST the response to the endpoint that calls
235
+ // @simplewebauthn/server -> verifyRegistrationResponse()
236
+ try {
237
+ const verificationResp = await fetch(
238
+ "./passkey/register-verify?userId=" +
239
+ email +
240
+ "&origin=" +
241
+ origin +
242
+ "&rpID=" +
243
+ rpID,
244
+ {
245
+ method: "POST",
246
+ headers: {
247
+ "Content-Type": "application/json",
248
+ },
249
+ body: JSON.stringify(attResp),
250
+ }
251
+ );
252
+
253
+ // Check if the request was redirected and the final response is OK
254
+ if (verificationResp.redirected && verificationResp.ok) {
255
+ // Navigate the browser to the final URL
256
+ window.location.href = verificationResp.url;
257
+ } else {
258
+ // Handle errors (e.g., 4xx, 5xx status codes from the final URL)
259
+ console.error(
260
+ "Request failed:",
261
+ verificationResp.status,
262
+ verificationResp.statusText
263
+ );
264
+ try {
265
+ const errorData = await verificationResp.json();
266
+ showMessage(errorData.error);
267
+ } catch (error) {
268
+ showMessage("Something went wrong");
269
+ }
270
+ }
271
+ } catch (error) {
272
+ console.error(error);
273
+ showMessage("Something went wrong");
274
+ }
275
+ };
276
+
277
+ registerForm.addEventListener("submit", (e) => {
278
+ e.preventDefault();
279
+ register();
280
+ });
281
+ });
282
+ ` } }),
283
+ /* @__PURE__ */ jsx("h1", { children: copy.register_title }),
284
+ /* @__PURE__ */ jsx("p", { children: copy.register_description }),
285
+ /* @__PURE__ */ jsxs("form", {
286
+ id: "registerForm",
287
+ "data-component": "form",
288
+ children: [
289
+ /* @__PURE__ */ jsx(FormAlert, {}),
290
+ /* @__PURE__ */ jsx("input", {
291
+ "data-component": "input",
292
+ type: "email",
293
+ name: "email",
294
+ required: true,
295
+ placeholder: copy.input_email
296
+ }),
297
+ /* @__PURE__ */ jsx("button", {
298
+ "data-component": "button",
299
+ type: "submit",
300
+ id: "btnRegister",
301
+ children: copy.register_with_passkey
302
+ }),
303
+ /* @__PURE__ */ jsx("button", {
304
+ "data-component": "button",
305
+ type: "submit",
306
+ id: "btnOtherDevice",
307
+ children: copy.register_other_device
308
+ }),
309
+ /* @__PURE__ */ jsx("div", {
310
+ "data-component": "form-footer",
311
+ children: /* @__PURE__ */ jsxs("span", { children: [
312
+ copy.login_prompt,
313
+ " ",
314
+ /* @__PURE__ */ jsx("a", {
315
+ "data-component": "link",
316
+ href: "./authorize",
317
+ children: copy.login
318
+ })
319
+ ] })
320
+ })
321
+ ]
322
+ }),
323
+ /* @__PURE__ */ jsx("script", { src: "https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js" })
324
+ ] });
325
+ return new Response(renderToHTML(jsx$1), {
326
+ status: 200,
327
+ headers: { "Content-Type": "text/html" }
328
+ });
329
+ },
330
+ rpName,
331
+ rpID,
332
+ origin,
333
+ userCanRegisterPasskey,
334
+ authenticatorSelection,
335
+ attestationType,
336
+ timeout
337
+ };
338
+ };
339
+
340
+ //#endregion
341
+ export { PasskeyUI };
package/dist/ui/select.js CHANGED
@@ -26,6 +26,7 @@ const DEFAULT_DISPLAYS = {
26
26
  github: "GitHub",
27
27
  google: "Google",
28
28
  code: "Code",
29
+ passkey: "Passkey",
29
30
  password: "Password"
30
31
  };
31
32
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@draftlab/auth",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Core implementation for @draftlab/auth",
6
6
  "author": "Matheus Pergoli",
@@ -37,7 +37,7 @@
37
37
  ],
38
38
  "license": "MIT",
39
39
  "devDependencies": {
40
- "@types/node": "^24.0.13",
40
+ "@types/node": "^24.0.14",
41
41
  "tsdown": "^0.12.9",
42
42
  "typescript": "^5.8.3",
43
43
  "@draftlab/tsconfig": "0.1.0"
@@ -55,8 +55,9 @@
55
55
  }
56
56
  },
57
57
  "dependencies": {
58
+ "@simplewebauthn/server": "^13.1.2",
58
59
  "@standard-schema/spec": "^1.0.0",
59
- "jose": "^6.0.11",
60
+ "jose": "^6.0.12",
60
61
  "preact": "^10.26.9",
61
62
  "preact-render-to-string": "^6.5.13",
62
63
  "@draftlab/auth-router": "0.0.4"