@draftlab/auth 0.2.3 → 0.2.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,120 @@
1
+ import { Provider } from "./provider.js";
2
+
3
+ //#region src/provider/totp.d.ts
4
+
5
+ /**
6
+ * TOTP data model stored in the database.
7
+ * Contains the user's TOTP configuration and backup codes.
8
+ */
9
+ interface TOTPModel {
10
+ /** Base32-encoded secret key */
11
+ secret: string;
12
+ /** Whether TOTP is enabled for this user */
13
+ enabled: boolean;
14
+ /** Array of one-time backup/recovery codes */
15
+ backupCodes: string[];
16
+ /** Timestamp when TOTP was first set up */
17
+ createdAt: string;
18
+ /** Optional user label for the TOTP */
19
+ label?: string;
20
+ }
21
+ /**
22
+ * Configuration for the TOTPProvider.
23
+ * Defines how the TOTP authentication flow should behave.
24
+ */
25
+ interface TOTPProviderConfig {
26
+ /**
27
+ * The human-readable name of the issuer (your application).
28
+ * This appears in authenticator apps next to the TOTP entry.
29
+ */
30
+ issuer: string;
31
+ /**
32
+ * Custom authorize handler that generates the UI for TOTP login.
33
+ * Called when user wants to login with TOTP (main page).
34
+ *
35
+ * @param req - The HTTP request object
36
+ * @param error - Optional error message to display
37
+ */
38
+ authorize: (req: Request, error?: string) => Promise<Response>;
39
+ /**
40
+ * Custom register handler that generates the UI for TOTP setup.
41
+ * Called when user is setting up TOTP for the first time.
42
+ *
43
+ * @param req - The HTTP request object
44
+ * @param qrCodeUrl - The otpauth:// URL for QR code generation
45
+ * @param secret - The raw secret (for manual entry)
46
+ * @param backupCodes - Array of backup/recovery codes
47
+ * @param error - Optional error message to display
48
+ */
49
+ register: (req: Request, qrCodeUrl: string, secret: string, backupCodes: string[], error?: string, email?: string) => Promise<Response>;
50
+ /**
51
+ * Custom verification handler that generates the UI for TOTP verification.
52
+ * Called when user needs to enter their TOTP code.
53
+ *
54
+ * @param req - The HTTP request object
55
+ * @param error - Optional error message to display
56
+ */
57
+ verify: (req: Request, error?: string) => Promise<Response>;
58
+ /**
59
+ * Custom recovery handler that generates the UI for backup code entry.
60
+ * Called when user wants to use a recovery code instead of TOTP.
61
+ *
62
+ * @param req - The HTTP request object
63
+ * @param error - Optional error message to display
64
+ */
65
+ recovery: (req: Request, error?: string) => Promise<Response>;
66
+ /**
67
+ * Optional TOTP algorithm. Defaults to SHA1 for maximum compatibility.
68
+ * Most authenticator apps support SHA1, fewer support SHA256/SHA512.
69
+ */
70
+ algorithm?: "SHA1" | "SHA256" | "SHA512";
71
+ /**
72
+ * Optional number of digits in TOTP codes. Defaults to 6.
73
+ * Some apps support 8 digits for increased security.
74
+ */
75
+ digits?: 6 | 8;
76
+ /**
77
+ * Optional validity period for TOTP codes in seconds. Defaults to 30.
78
+ * Standard is 30 seconds, some high-security apps use 60.
79
+ */
80
+ period?: number;
81
+ /**
82
+ * Optional time window tolerance for clock drift. Defaults to 1.
83
+ * Allows tokens from previous/next time window to be valid.
84
+ */
85
+ window?: number;
86
+ /**
87
+ * Optional number of backup codes to generate. Defaults to 10.
88
+ */
89
+ backupCodesCount?: number;
90
+ /**
91
+ * Optional function to check if a user is allowed to set up TOTP.
92
+ */
93
+ userCanSetupTOTP?: (userId: string, req: Request) => Promise<boolean>;
94
+ /**
95
+ * Optional custom label generator for TOTP entries.
96
+ * Defaults to using the userId as the label.
97
+ */
98
+ generateLabel?: (userId: string) => Promise<string>;
99
+ }
100
+ /**
101
+ * Creates a TOTP (Time-based One-Time Password) authentication provider.
102
+ *
103
+ * TOTP tokens. Users can set up TOTP using any compatible authenticator app
104
+ * and use backup codes when their primary device is unavailable.
105
+ *
106
+ * It handles:
107
+ * - TOTP secret generation and QR code creation
108
+ * - Token verification with timing attack protection
109
+ * - Backup code generation and one-time usage validation
110
+ * - Complete setup, verification, and recovery flows
111
+ *
112
+ * @param config Configuration options for the TOTP provider
113
+ * @returns A Provider instance configured for TOTP authentication
114
+ */
115
+ declare const TOTPProvider: (config: TOTPProviderConfig) => Provider<{
116
+ email: string;
117
+ method: "totp" | "recovery";
118
+ }>;
119
+ //#endregion
120
+ export { TOTPModel, TOTPProvider, TOTPProviderConfig };
@@ -0,0 +1,191 @@
1
+ import { generateSecureToken } from "../random.js";
2
+ import { Storage } from "../storage/storage.js";
3
+ import { Secret, TOTP } from "otpauth";
4
+
5
+ //#region src/provider/totp.ts
6
+ const totpKey = (userId) => [
7
+ "totp",
8
+ "user",
9
+ userId
10
+ ];
11
+ const DEFAULT_CONFIG = {
12
+ algorithm: "SHA1",
13
+ digits: 6,
14
+ period: 30,
15
+ window: 1,
16
+ backupCodesCount: 4,
17
+ qrSize: 200
18
+ };
19
+ /**
20
+ * Creates a TOTP (Time-based One-Time Password) authentication provider.
21
+ *
22
+ * TOTP tokens. Users can set up TOTP using any compatible authenticator app
23
+ * and use backup codes when their primary device is unavailable.
24
+ *
25
+ * It handles:
26
+ * - TOTP secret generation and QR code creation
27
+ * - Token verification with timing attack protection
28
+ * - Backup code generation and one-time usage validation
29
+ * - Complete setup, verification, and recovery flows
30
+ *
31
+ * @param config Configuration options for the TOTP provider
32
+ * @returns A Provider instance configured for TOTP authentication
33
+ */
34
+ const TOTPProvider = (config) => {
35
+ const { issuer, algorithm = DEFAULT_CONFIG.algorithm, digits = DEFAULT_CONFIG.digits, period = DEFAULT_CONFIG.period, window = DEFAULT_CONFIG.window, backupCodesCount = DEFAULT_CONFIG.backupCodesCount } = config;
36
+ return {
37
+ type: "totp",
38
+ init(routes, ctx) {
39
+ const getTOTPData = async (userId) => {
40
+ return await Storage.get(ctx.storage, totpKey(userId));
41
+ };
42
+ const saveTOTPData = async (userId, data) => {
43
+ await Storage.set(ctx.storage, totpKey(userId), data);
44
+ };
45
+ const deleteTOTPData = async (userId) => {
46
+ await Storage.remove(ctx.storage, totpKey(userId));
47
+ };
48
+ const generateBackupCodes = (count) => {
49
+ const codes = [];
50
+ for (let i = 0; i < count; i++) {
51
+ const code = generateSecureToken().slice(0, 8).toUpperCase();
52
+ codes.push(`${code.slice(0, 4)}-${code.slice(4)}`);
53
+ }
54
+ return codes;
55
+ };
56
+ const createTOTPInstance = (secret, label) => {
57
+ return new TOTP({
58
+ issuer,
59
+ label,
60
+ algorithm,
61
+ digits,
62
+ period,
63
+ secret
64
+ });
65
+ };
66
+ routes.get("/register", async (c) => {
67
+ return ctx.forward(c, await config.register(c.request, "", "", []));
68
+ });
69
+ routes.post("/register-verify", async (c) => {
70
+ const formData = await c.formData();
71
+ const email = formData.get("email")?.toString();
72
+ const action = formData.get("action")?.toString();
73
+ if (!email) return ctx.forward(c, await config.register(c.request, "", "", [], "Email is required"));
74
+ if (action === "generate") {
75
+ const secret = new Secret({ size: 20 });
76
+ const label = config.generateLabel ? await config.generateLabel(email) : email;
77
+ const backupCodes = generateBackupCodes(backupCodesCount);
78
+ const totp$1 = createTOTPInstance(secret.base32, label);
79
+ const qrCodeUrl$1 = totp$1.toString();
80
+ const totpData$1 = {
81
+ secret: secret.base32,
82
+ enabled: false,
83
+ backupCodes,
84
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
85
+ label
86
+ };
87
+ await saveTOTPData(email, totpData$1);
88
+ return ctx.forward(c, await config.register(c.request, qrCodeUrl$1, secret.base32, backupCodes, void 0, email));
89
+ }
90
+ const token = formData.get("token")?.toString();
91
+ if (!token) return ctx.forward(c, await config.register(c.request, "", "", [], "Verification code is required"));
92
+ const totpData = await getTOTPData(email);
93
+ if (!totpData) return ctx.forward(c, await config.register(c.request, "", "", [], "TOTP setup session not found"));
94
+ const totp = createTOTPInstance(totpData.secret, totpData.label || email);
95
+ const delta = totp.validate({
96
+ token,
97
+ window
98
+ });
99
+ if (delta !== null) {
100
+ totpData.enabled = true;
101
+ await saveTOTPData(email, totpData);
102
+ return ctx.success(c, {
103
+ email,
104
+ method: "totp"
105
+ });
106
+ }
107
+ const qrCodeUrl = totp.toString();
108
+ return ctx.forward(c, await config.register(c.request, qrCodeUrl, totpData.secret, totpData.backupCodes, "Invalid verification code. Please try again."));
109
+ });
110
+ routes.get("/authorize", async (c) => {
111
+ return ctx.forward(c, await config.authorize(c.request));
112
+ });
113
+ routes.post("/verify", async (c) => {
114
+ const formData = await c.formData();
115
+ const email = formData.get("email")?.toString();
116
+ const token = formData.get("token")?.toString();
117
+ if (!email || !token) return ctx.forward(c, await config.verify(c.request, "Email and verification code are required"));
118
+ const totpData = await getTOTPData(email);
119
+ if (!totpData || !totpData.enabled) return ctx.forward(c, await config.verify(c.request, "TOTP is not set up for this email"));
120
+ const totp = createTOTPInstance(totpData.secret, totpData.label || email);
121
+ const delta = totp.validate({
122
+ token,
123
+ window
124
+ });
125
+ if (delta !== null) return ctx.success(c, {
126
+ email,
127
+ method: "totp"
128
+ });
129
+ return ctx.forward(c, await config.verify(c.request, "Invalid verification code"));
130
+ });
131
+ routes.get("/recovery", async (c) => {
132
+ return ctx.forward(c, await config.recovery(c.request));
133
+ });
134
+ routes.post("/recovery-verify", async (c) => {
135
+ const formData = await c.formData();
136
+ const email = formData.get("email")?.toString();
137
+ const code = formData.get("code")?.toString()?.toUpperCase();
138
+ if (!email || !code) return ctx.forward(c, await config.recovery(c.request, "Email and recovery code are required"));
139
+ const totpData = await getTOTPData(email);
140
+ if (!totpData || !totpData.enabled) return ctx.forward(c, await config.recovery(c.request, "TOTP is not set up for this email"));
141
+ const codeIndex = totpData.backupCodes.indexOf(code);
142
+ if (codeIndex !== -1) {
143
+ totpData.backupCodes.splice(codeIndex, 1);
144
+ await saveTOTPData(email, totpData);
145
+ return ctx.success(c, {
146
+ email,
147
+ method: "recovery"
148
+ });
149
+ }
150
+ return ctx.forward(c, await config.recovery(c.request, "Invalid or already used recovery code"));
151
+ });
152
+ routes.post("/disable", async (c) => {
153
+ const userId = c.query("userId");
154
+ if (!userId) return c.json({ error: "User ID is required" }, { status: 400 });
155
+ await deleteTOTPData(userId);
156
+ return c.json({
157
+ success: true,
158
+ message: "TOTP has been disabled"
159
+ });
160
+ });
161
+ routes.get("/status", async (c) => {
162
+ const userId = c.query("userId");
163
+ if (!userId) return c.json({ error: "User ID is required" }, { status: 400 });
164
+ const totpData = await getTOTPData(userId);
165
+ return c.json({
166
+ enabled: totpData?.enabled || false,
167
+ hasBackupCodes: (totpData?.backupCodes?.length || 0) > 0,
168
+ backupCodesCount: totpData?.backupCodes?.length || 0,
169
+ setupDate: totpData?.createdAt
170
+ });
171
+ });
172
+ routes.post("/regenerate-backup-codes", async (c) => {
173
+ const userId = c.query("userId");
174
+ if (!userId) return c.json({ error: "User ID is required" }, { status: 400 });
175
+ const totpData = await getTOTPData(userId);
176
+ if (!totpData || !totpData.enabled) return c.json({ error: "TOTP is not enabled for this user" }, { status: 400 });
177
+ const newBackupCodes = generateBackupCodes(backupCodesCount);
178
+ totpData.backupCodes = newBackupCodes;
179
+ await saveTOTPData(userId, totpData);
180
+ return c.json({
181
+ success: true,
182
+ backupCodes: newBackupCodes,
183
+ message: "New backup codes generated"
184
+ });
185
+ });
186
+ }
187
+ };
188
+ };
189
+
190
+ //#endregion
191
+ export { TOTPProvider };
package/dist/ui/base.js CHANGED
@@ -78,7 +78,6 @@ const css = `@import url("https://unpkg.com/tailwindcss@3.4.15/src/css/preflight
78
78
  align-items: center;
79
79
  justify-content: center;
80
80
  flex-direction: column;
81
- user-select: none;
82
81
  color: var(--color-high);
83
82
  }
84
83
 
@@ -1,4 +1,5 @@
1
1
  import { Layout, renderToHTML } from "./base.js";
2
+ import { FormAlert } from "./form.js";
2
3
  import { Fragment, jsx, jsxs } from "preact/jsx-runtime";
3
4
 
4
5
  //#region src/ui/password.tsx
@@ -31,80 +32,6 @@ const DEFAULT_COPY = {
31
32
  logo: "A"
32
33
  };
33
34
  /**
34
- * FormAlert component for displaying error messages
35
- */
36
- const FormAlert = ({ message, color = "danger" }) => {
37
- if (!message) return null;
38
- return /* @__PURE__ */ jsxs("div", {
39
- "data-component": "form-alert",
40
- "data-color": color,
41
- children: [/* @__PURE__ */ jsx("i", {
42
- "data-slot": color === "success" ? "icon-success" : "icon-danger",
43
- children: color === "success" ? /* @__PURE__ */ jsx("svg", {
44
- fill: "none",
45
- stroke: "currentColor",
46
- viewBox: "0 0 24 24",
47
- xmlns: "http://www.w3.org/2000/svg",
48
- "aria-label": "Success",
49
- role: "img",
50
- children: /* @__PURE__ */ jsx("path", {
51
- strokeLinecap: "round",
52
- strokeLinejoin: "round",
53
- strokeWidth: 2,
54
- d: "M5 13l4 4L19 7"
55
- })
56
- }) : /* @__PURE__ */ jsx("svg", {
57
- fill: "none",
58
- stroke: "currentColor",
59
- viewBox: "0 0 24 24",
60
- xmlns: "http://www.w3.org/2000/svg",
61
- "aria-label": "Error",
62
- role: "img",
63
- children: /* @__PURE__ */ jsx("path", {
64
- strokeLinecap: "round",
65
- strokeLinejoin: "round",
66
- strokeWidth: 2,
67
- d: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.232 16.5c-.77.833.192 2.5 1.732 2.5z"
68
- })
69
- })
70
- }), /* @__PURE__ */ jsx("span", {
71
- "data-slot": "message",
72
- children: message
73
- })]
74
- });
75
- };
76
- /**
77
- * Input component with consistent styling
78
- */
79
- const Input = ({ type, name, placeholder, value, required, autoComplete,...props }) => /* @__PURE__ */ jsx("input", {
80
- type,
81
- name,
82
- placeholder,
83
- value,
84
- required,
85
- autoComplete,
86
- "data-component": "input",
87
- ...props
88
- });
89
- /**
90
- * Button component with consistent styling
91
- */
92
- const Button = ({ type = "submit", children,...props }) => /* @__PURE__ */ jsx("button", {
93
- type,
94
- "data-component": "button",
95
- ...props,
96
- children
97
- });
98
- /**
99
- * Link component with consistent styling
100
- */
101
- const Link = ({ href, children,...props }) => /* @__PURE__ */ jsx("a", {
102
- href,
103
- "data-component": "link",
104
- ...props,
105
- children
106
- });
107
- /**
108
35
  * Creates a complete UI configuration for password-based authentication
109
36
  */
110
37
  const PasswordUI = (options) => {
@@ -129,22 +56,25 @@ const PasswordUI = (options) => {
129
56
  method: "post",
130
57
  children: [
131
58
  /* @__PURE__ */ jsx(FormAlert, { message: getErrorMessage(error) }),
132
- /* @__PURE__ */ jsx(Input, {
59
+ /* @__PURE__ */ jsx("input", {
133
60
  type: "email",
134
61
  name: "email",
135
62
  placeholder: copy.input_email,
136
63
  value: form?.get("email")?.toString() || "",
137
64
  autoComplete: "email",
65
+ "data-component": "input",
138
66
  required: true
139
67
  }),
140
- /* @__PURE__ */ jsx(Input, {
68
+ /* @__PURE__ */ jsx("input", {
141
69
  type: "password",
142
70
  name: "password",
143
71
  placeholder: copy.input_password,
144
72
  autoComplete: "current-password",
73
+ "data-component": "input",
145
74
  required: true
146
75
  }),
147
- /* @__PURE__ */ jsx(Button, {
76
+ /* @__PURE__ */ jsx("button", {
77
+ "data-component": "button",
148
78
  type: "submit",
149
79
  children: copy.button_continue
150
80
  }),
@@ -153,11 +83,13 @@ const PasswordUI = (options) => {
153
83
  children: [/* @__PURE__ */ jsxs("span", { children: [
154
84
  copy.register_prompt,
155
85
  " ",
156
- /* @__PURE__ */ jsx(Link, {
86
+ /* @__PURE__ */ jsx("a", {
87
+ "data-component": "link",
157
88
  href: "./register",
158
89
  children: copy.register
159
90
  })
160
- ] }), /* @__PURE__ */ jsx(Link, {
91
+ ] }), /* @__PURE__ */ jsx("a", {
92
+ "data-component": "link",
161
93
  href: "./change",
162
94
  children: copy.change_prompt
163
95
  })]
@@ -184,30 +116,34 @@ const PasswordUI = (options) => {
184
116
  type: "hidden",
185
117
  value: "register"
186
118
  }),
187
- /* @__PURE__ */ jsx(Input, {
119
+ /* @__PURE__ */ jsx("input", {
188
120
  type: "email",
189
121
  name: "email",
190
122
  placeholder: copy.input_email,
191
123
  value: emailError ? "" : form?.get("email")?.toString() || "",
192
124
  autoComplete: "email",
125
+ "data-component": "input",
193
126
  required: true
194
127
  }),
195
- /* @__PURE__ */ jsx(Input, {
128
+ /* @__PURE__ */ jsx("input", {
196
129
  type: "password",
197
130
  name: "password",
198
131
  placeholder: copy.input_password,
199
132
  value: passwordError ? "" : form?.get("password")?.toString() || "",
200
133
  autoComplete: "new-password",
134
+ "data-component": "input",
201
135
  required: true
202
136
  }),
203
- /* @__PURE__ */ jsx(Input, {
137
+ /* @__PURE__ */ jsx("input", {
204
138
  type: "password",
205
139
  name: "repeat",
206
140
  placeholder: copy.input_repeat,
207
141
  autoComplete: "new-password",
142
+ "data-component": "input",
208
143
  required: true
209
144
  }),
210
- /* @__PURE__ */ jsx(Button, {
145
+ /* @__PURE__ */ jsx("button", {
146
+ "data-component": "button",
211
147
  type: "submit",
212
148
  children: copy.button_continue
213
149
  }),
@@ -216,7 +152,8 @@ const PasswordUI = (options) => {
216
152
  children: /* @__PURE__ */ jsxs("span", { children: [
217
153
  copy.login_prompt,
218
154
  " ",
219
- /* @__PURE__ */ jsx(Link, {
155
+ /* @__PURE__ */ jsx("a", {
156
+ "data-component": "link",
220
157
  href: "./authorize",
221
158
  children: copy.login
222
159
  })
@@ -233,20 +170,21 @@ const PasswordUI = (options) => {
233
170
  type: "hidden",
234
171
  value: "verify"
235
172
  }),
236
- /* @__PURE__ */ jsx(Input, {
173
+ /* @__PURE__ */ jsx("input", {
237
174
  type: "text",
238
175
  name: "code",
239
176
  placeholder: copy.input_code,
240
177
  "aria-label": "6-digit verification code",
241
178
  autoComplete: "one-time-code",
179
+ "data-component": "input",
242
180
  inputMode: "numeric",
243
181
  maxLength: 6,
244
182
  minLength: 6,
245
183
  pattern: "[0-9]{6}",
246
- autoFocus: true,
247
184
  required: true
248
185
  }),
249
- /* @__PURE__ */ jsx(Button, {
186
+ /* @__PURE__ */ jsx("button", {
187
+ "data-component": "button",
250
188
  type: "submit",
251
189
  children: copy.button_continue
252
190
  })
@@ -272,15 +210,17 @@ const PasswordUI = (options) => {
272
210
  type: "hidden",
273
211
  value: "code"
274
212
  }),
275
- /* @__PURE__ */ jsx(Input, {
213
+ /* @__PURE__ */ jsx("input", {
276
214
  type: "email",
277
215
  name: "email",
278
216
  placeholder: copy.input_email,
279
217
  value: form?.get("email")?.toString() || "",
280
218
  autoComplete: "email",
219
+ "data-component": "input",
281
220
  required: true
282
221
  }),
283
- /* @__PURE__ */ jsx(Button, {
222
+ /* @__PURE__ */ jsx("button", {
223
+ "data-component": "button",
284
224
  type: "submit",
285
225
  children: copy.button_continue
286
226
  })
@@ -295,7 +235,7 @@ const PasswordUI = (options) => {
295
235
  type: "hidden",
296
236
  value: "verify"
297
237
  }),
298
- /* @__PURE__ */ jsx(Input, {
238
+ /* @__PURE__ */ jsx("input", {
299
239
  type: "text",
300
240
  name: "code",
301
241
  placeholder: copy.input_code,
@@ -304,11 +244,12 @@ const PasswordUI = (options) => {
304
244
  inputMode: "numeric",
305
245
  maxLength: 6,
306
246
  minLength: 6,
247
+ "data-component": "input",
307
248
  pattern: "[0-9]{6}",
308
- autoFocus: true,
309
249
  required: true
310
250
  }),
311
- /* @__PURE__ */ jsx(Button, {
251
+ /* @__PURE__ */ jsx("button", {
252
+ "data-component": "button",
312
253
  type: "submit",
313
254
  children: copy.button_continue
314
255
  })
@@ -331,11 +272,12 @@ const PasswordUI = (options) => {
331
272
  children: [/* @__PURE__ */ jsxs("span", { children: [
332
273
  copy.code_return,
333
274
  " ",
334
- /* @__PURE__ */ jsx(Link, {
275
+ /* @__PURE__ */ jsx("a", {
276
+ "data-component": "link",
335
277
  href: "./authorize",
336
278
  children: copy.login
337
279
  })
338
- ] }), /* @__PURE__ */ jsx(Button, {
280
+ ] }), /* @__PURE__ */ jsx("button", {
339
281
  type: "submit",
340
282
  "data-component": "link",
341
283
  children: copy.code_resend
@@ -352,23 +294,26 @@ const PasswordUI = (options) => {
352
294
  type: "hidden",
353
295
  value: "update"
354
296
  }),
355
- /* @__PURE__ */ jsx(Input, {
297
+ /* @__PURE__ */ jsx("input", {
356
298
  type: "password",
357
299
  name: "password",
358
300
  placeholder: copy.input_password,
359
301
  value: passwordError ? "" : form?.get("password")?.toString() || "",
360
302
  autoComplete: "new-password",
303
+ "data-component": "input",
361
304
  required: true
362
305
  }),
363
- /* @__PURE__ */ jsx(Input, {
306
+ /* @__PURE__ */ jsx("input", {
364
307
  type: "password",
365
308
  name: "repeat",
366
309
  placeholder: copy.input_repeat,
367
310
  value: passwordError ? "" : form?.get("repeat")?.toString() || "",
368
311
  autoComplete: "new-password",
312
+ "data-component": "input",
369
313
  required: true
370
314
  }),
371
- /* @__PURE__ */ jsx(Button, {
315
+ /* @__PURE__ */ jsx("button", {
316
+ "data-component": "button",
372
317
  type: "submit",
373
318
  children: copy.button_continue
374
319
  })
@@ -0,0 +1,40 @@
1
+ import { TOTPProviderConfig } from "../provider/totp.js";
2
+
3
+ //#region src/ui/totp.d.ts
4
+
5
+ /**
6
+ * Strongly typed copy text configuration for TOTP UI
7
+ */
8
+ interface TOTPUICopy {
9
+ readonly setup_title: string;
10
+ readonly setup_description: string;
11
+ readonly verify_title: string;
12
+ readonly verify_description: string;
13
+ readonly recovery_title: string;
14
+ readonly recovery_description: string;
15
+ readonly setup_manual_entry: string;
16
+ readonly setup_backup_codes_title: string;
17
+ readonly setup_backup_codes_description: string;
18
+ readonly button_continue: string;
19
+ readonly link_use_recovery: string;
20
+ readonly link_back_to_totp: string;
21
+ readonly input_token: string;
22
+ readonly input_recovery_code: string;
23
+ }
24
+ /**
25
+ * Configuration options for TOTP UI
26
+ */
27
+ interface TOTPUIOptions {
28
+ /** Custom copy text overrides */
29
+ readonly copy?: Partial<TOTPUICopy>;
30
+ /** QR code image size in pixels */
31
+ readonly qrSize?: number;
32
+ /** Whether to show manual secret entry option */
33
+ readonly showManualEntry?: boolean;
34
+ }
35
+ /**
36
+ * Creates a complete UI configuration for TOTP authentication
37
+ */
38
+ declare const TOTPUI: (options?: TOTPUIOptions) => Omit<TOTPProviderConfig, "issuer">;
39
+ //#endregion
40
+ export { TOTPUI };
@@ -0,0 +1,323 @@
1
+ import { Layout, renderToHTML } from "./base.js";
2
+ import { FormAlert } from "./form.js";
3
+ import { jsx, jsxs } from "preact/jsx-runtime";
4
+ import QRCode from "qrcode";
5
+
6
+ //#region src/ui/totp.tsx
7
+ const DEFAULT_COPY = {
8
+ setup_title: "Set up Two-Factor Authentication",
9
+ setup_description: "Scan the QR code with your authenticator app, then enter the verification code.",
10
+ verify_title: "Enter authentication code",
11
+ verify_description: "Open your authenticator app and enter the current 6-digit code.",
12
+ recovery_title: "Use backup code",
13
+ recovery_description: "Enter one of your backup codes. Each code can only be used once.",
14
+ setup_manual_entry: "Can't scan? Enter this code manually:",
15
+ setup_backup_codes_title: "Save your backup codes",
16
+ setup_backup_codes_description: "Store these backup codes in a safe place. You can use them to access your account if you lose your device.",
17
+ button_continue: "Continue",
18
+ link_use_recovery: "Use backup code instead",
19
+ link_back_to_totp: "Back to authenticator code",
20
+ input_token: "000000",
21
+ input_recovery_code: "XXXX-XXXX"
22
+ };
23
+ /**
24
+ * Creates a complete UI configuration for TOTP authentication
25
+ */
26
+ const TOTPUI = (options = {}) => {
27
+ const { qrSize = 200, showManualEntry = true } = options;
28
+ const copy = {
29
+ ...DEFAULT_COPY,
30
+ ...options.copy
31
+ };
32
+ /**
33
+ * Generates QR code as data URL using the qrcode library
34
+ */
35
+ const generateQRCode = async (text) => {
36
+ try {
37
+ return await QRCode.toDataURL(text, {
38
+ width: qrSize,
39
+ margin: 1,
40
+ color: {
41
+ dark: "#000000",
42
+ light: "#FFFFFF"
43
+ }
44
+ });
45
+ } catch (error) {
46
+ console.error("QR Code generation failed:", error);
47
+ return "";
48
+ }
49
+ };
50
+ /**
51
+ * Renders the setup form with QR code and backup codes
52
+ */
53
+ const renderRegister = async (qrCodeUrl, secret, backupCodes, error, email) => {
54
+ if (!qrCodeUrl) return /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
55
+ "data-component": "form",
56
+ method: "post",
57
+ action: "./register-verify",
58
+ children: [
59
+ /* @__PURE__ */ jsx(FormAlert, { message: error }),
60
+ /* @__PURE__ */ jsx("input", {
61
+ type: "email",
62
+ name: "email",
63
+ placeholder: "Email",
64
+ autoComplete: "email",
65
+ "data-component": "input",
66
+ required: true
67
+ }),
68
+ /* @__PURE__ */ jsx("input", {
69
+ type: "hidden",
70
+ name: "action",
71
+ value: "generate"
72
+ }),
73
+ /* @__PURE__ */ jsx("button", {
74
+ type: "submit",
75
+ "data-component": "button",
76
+ children: "Generate QR Code"
77
+ }),
78
+ /* @__PURE__ */ jsx("div", {
79
+ "data-component": "form-footer",
80
+ children: /* @__PURE__ */ jsxs("span", { children: [
81
+ "Already have TOTP?",
82
+ " ",
83
+ /* @__PURE__ */ jsx("a", {
84
+ href: "./authorize",
85
+ "data-component": "link",
86
+ children: "Login"
87
+ })
88
+ ] })
89
+ })
90
+ ]
91
+ }) });
92
+ const qrCodeDataUrl = await generateQRCode(qrCodeUrl);
93
+ return /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
94
+ "data-component": "form",
95
+ method: "post",
96
+ action: "./register-verify",
97
+ children: [
98
+ /* @__PURE__ */ jsx(FormAlert, { message: error }),
99
+ email && /* @__PURE__ */ jsx("input", {
100
+ type: "hidden",
101
+ name: "email",
102
+ value: email
103
+ }),
104
+ qrCodeDataUrl && /* @__PURE__ */ jsx("img", {
105
+ src: qrCodeDataUrl,
106
+ alt: "TOTP QR Code",
107
+ width: qrSize,
108
+ height: qrSize,
109
+ style: {
110
+ display: "block",
111
+ margin: "0 auto"
112
+ }
113
+ }),
114
+ showManualEntry && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("p", {
115
+ "data-component": "description",
116
+ children: copy.setup_manual_entry
117
+ }), /* @__PURE__ */ jsx("code", {
118
+ style: {
119
+ display: "block",
120
+ textAlign: "center",
121
+ margin: "8px 0"
122
+ },
123
+ children: secret
124
+ })] }),
125
+ /* @__PURE__ */ jsx("input", {
126
+ type: "text",
127
+ name: "token",
128
+ placeholder: copy.input_token,
129
+ pattern: "[0-9]{6}",
130
+ maxLength: 6,
131
+ minLength: 6,
132
+ autoComplete: "one-time-code",
133
+ "data-component": "input",
134
+ required: true
135
+ }),
136
+ /* @__PURE__ */ jsx("button", {
137
+ type: "submit",
138
+ "data-component": "button",
139
+ children: copy.button_continue
140
+ }),
141
+ backupCodes.length > 0 && /* @__PURE__ */ jsxs("div", { children: [
142
+ /* @__PURE__ */ jsx("h3", {
143
+ style: { textAlign: "center" },
144
+ children: copy.setup_backup_codes_title
145
+ }),
146
+ /* @__PURE__ */ jsx("p", {
147
+ "data-component": "description",
148
+ children: copy.setup_backup_codes_description
149
+ }),
150
+ /* @__PURE__ */ jsx("div", {
151
+ style: {
152
+ display: "grid",
153
+ gridTemplateColumns: "repeat(2, 1fr)",
154
+ gap: "8px",
155
+ margin: "16px 0"
156
+ },
157
+ children: backupCodes.map((code, index) => /* @__PURE__ */ jsx("code", {
158
+ "data-component": "button",
159
+ children: code
160
+ }, `${code}-${index + Math.random()}`))
161
+ })
162
+ ] })
163
+ ]
164
+ }) });
165
+ };
166
+ /**
167
+ * Renders the authorize form (main TOTP login page following passkey pattern)
168
+ */
169
+ const renderAuthorize = (error) => /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
170
+ "data-component": "form",
171
+ method: "post",
172
+ action: "./verify",
173
+ children: [
174
+ /* @__PURE__ */ jsx(FormAlert, { message: error }),
175
+ /* @__PURE__ */ jsx("input", {
176
+ type: "email",
177
+ name: "email",
178
+ placeholder: "Email",
179
+ autoComplete: "email",
180
+ "data-component": "input",
181
+ required: true
182
+ }),
183
+ /* @__PURE__ */ jsx("input", {
184
+ type: "text",
185
+ name: "token",
186
+ placeholder: copy.input_token,
187
+ pattern: "[0-9]{6}",
188
+ maxLength: 6,
189
+ minLength: 6,
190
+ autoComplete: "one-time-code",
191
+ "data-component": "input",
192
+ required: true
193
+ }),
194
+ /* @__PURE__ */ jsx("button", {
195
+ type: "submit",
196
+ "data-component": "button",
197
+ children: copy.button_continue
198
+ }),
199
+ /* @__PURE__ */ jsxs("div", {
200
+ "data-component": "form-footer",
201
+ children: [/* @__PURE__ */ jsxs("span", { children: [
202
+ "Don't have TOTP setup?",
203
+ " ",
204
+ /* @__PURE__ */ jsx("a", {
205
+ href: "./register",
206
+ "data-component": "link",
207
+ children: "Register"
208
+ })
209
+ ] }), /* @__PURE__ */ jsx("a", {
210
+ href: "./recovery",
211
+ "data-component": "link",
212
+ children: copy.link_use_recovery
213
+ })]
214
+ })
215
+ ]
216
+ }) });
217
+ /**
218
+ * Renders the verification form
219
+ */
220
+ const renderVerify = (error) => /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
221
+ "data-component": "form",
222
+ method: "post",
223
+ action: "./verify",
224
+ children: [
225
+ /* @__PURE__ */ jsx(FormAlert, { message: error }),
226
+ /* @__PURE__ */ jsx("input", {
227
+ type: "email",
228
+ name: "email",
229
+ placeholder: "Email",
230
+ autoComplete: "email",
231
+ "data-component": "input",
232
+ required: true
233
+ }),
234
+ /* @__PURE__ */ jsx("input", {
235
+ type: "text",
236
+ name: "token",
237
+ placeholder: copy.input_token,
238
+ pattern: "[0-9]{6}",
239
+ maxLength: 6,
240
+ minLength: 6,
241
+ autoComplete: "one-time-code",
242
+ "data-component": "input",
243
+ required: true
244
+ }),
245
+ /* @__PURE__ */ jsx("button", {
246
+ type: "submit",
247
+ "data-component": "button",
248
+ children: copy.button_continue
249
+ }),
250
+ /* @__PURE__ */ jsx("div", {
251
+ "data-component": "form-footer",
252
+ children: /* @__PURE__ */ jsx("a", {
253
+ href: "./recovery",
254
+ "data-component": "link",
255
+ children: copy.link_use_recovery
256
+ })
257
+ })
258
+ ]
259
+ }) });
260
+ /**
261
+ * Renders the recovery form
262
+ */
263
+ const renderRecovery = (error) => /* @__PURE__ */ jsx(Layout, { children: /* @__PURE__ */ jsxs("form", {
264
+ "data-component": "form",
265
+ method: "post",
266
+ action: "./recovery-verify",
267
+ children: [
268
+ /* @__PURE__ */ jsx(FormAlert, { message: error }),
269
+ /* @__PURE__ */ jsx("input", {
270
+ type: "email",
271
+ name: "email",
272
+ placeholder: "Email",
273
+ autoComplete: "email",
274
+ "data-component": "input",
275
+ required: true
276
+ }),
277
+ /* @__PURE__ */ jsx("input", {
278
+ type: "text",
279
+ name: "code",
280
+ placeholder: copy.input_recovery_code,
281
+ pattern: "[A-Z0-9]{4}-[A-Z0-9]{4}",
282
+ maxLength: 9,
283
+ autoComplete: "off",
284
+ "data-component": "input",
285
+ required: true
286
+ }),
287
+ /* @__PURE__ */ jsx("button", {
288
+ type: "submit",
289
+ "data-component": "button",
290
+ children: copy.button_continue
291
+ }),
292
+ /* @__PURE__ */ jsx("div", {
293
+ "data-component": "form-footer",
294
+ children: /* @__PURE__ */ jsx("a", {
295
+ href: "./authorize",
296
+ "data-component": "link",
297
+ children: copy.link_back_to_totp
298
+ })
299
+ })
300
+ ]
301
+ }) });
302
+ return {
303
+ authorize: async (_req, error) => {
304
+ const jsx$1 = renderAuthorize(error);
305
+ return new Response(renderToHTML(jsx$1), { headers: { "Content-Type": "text/html" } });
306
+ },
307
+ register: async (_req, qrCodeUrl, secret, backupCodes, error, email) => {
308
+ const jsx$1 = await renderRegister(qrCodeUrl, secret, backupCodes, error, email);
309
+ return new Response(renderToHTML(jsx$1), { headers: { "Content-Type": "text/html" } });
310
+ },
311
+ verify: async (_req, error) => {
312
+ const jsx$1 = renderVerify(error);
313
+ return new Response(renderToHTML(jsx$1), { headers: { "Content-Type": "text/html" } });
314
+ },
315
+ recovery: async (_req, error) => {
316
+ const jsx$1 = renderRecovery(error);
317
+ return new Response(renderToHTML(jsx$1), { headers: { "Content-Type": "text/html" } });
318
+ }
319
+ };
320
+ };
321
+
322
+ //#endregion
323
+ export { TOTPUI };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@draftlab/auth",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "Core implementation for @draftlab/auth",
6
6
  "author": "Matheus Pergoli",
@@ -38,6 +38,7 @@
38
38
  "license": "MIT",
39
39
  "devDependencies": {
40
40
  "@types/node": "^24.1.0",
41
+ "@types/qrcode": "^1.5.5",
41
42
  "tsdown": "^0.13.0",
42
43
  "typescript": "^5.8.3",
43
44
  "@draftlab/tsconfig": "0.1.0"
@@ -58,8 +59,10 @@
58
59
  "@simplewebauthn/server": "^13.1.2",
59
60
  "@standard-schema/spec": "^1.0.0",
60
61
  "jose": "^6.0.12",
62
+ "otpauth": "^9.4.0",
61
63
  "preact": "^10.26.9",
62
64
  "preact-render-to-string": "^6.5.13",
65
+ "qrcode": "^1.5.4",
63
66
  "@draftlab/auth-router": "0.0.4"
64
67
  },
65
68
  "engines": {