@erwininteractive/mvc 0.3.8 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,7 @@ A lightweight, full-featured MVC framework for Node.js 20+ built with TypeScript
9
9
  - **Optional Database** - Add Prisma + PostgreSQL when you need it
10
10
  - **Optional Redis Sessions** - Scalable session management
11
11
  - **JWT Authentication** - Secure token-based auth with bcrypt password hashing
12
+ - **WebAuthn (Passkeys)** - Passwordless authentication with security keys (YubiKey, Touch ID, Face ID)
12
13
  - **CLI Tools** - Scaffold apps and generate models/controllers
13
14
 
14
15
  ## Quick Start
@@ -280,6 +281,45 @@ app.get("/protected", authenticate, (req, res) => {
280
281
 
281
282
  ---
282
283
 
284
+ ### WebAuthn (Passkeys)
285
+
286
+ Passwordless authentication with security keys:
287
+
288
+ ```bash
289
+ npx erwinmvc webauthn
290
+ ```
291
+
292
+ This generates:
293
+ - `src/controllers/WebAuthnController.ts` - Registration and login handlers
294
+ - `src/views/webauthn/` - EJS views for register/login pages
295
+ - Prisma `WebAuthnCredential` model for storing security key data
296
+
297
+ ```typescript
298
+ import {
299
+ startRegistration,
300
+ completeRegistration,
301
+ startAuthentication,
302
+ completeAuthentication,
303
+ getRPConfig,
304
+ } from "@erwininteractive/mvc";
305
+
306
+ const { rpID, rpName } = getRPConfig();
307
+
308
+ const options = await startRegistration(req, res);
309
+ await completeRegistration(req, res);
310
+
311
+ const options = await startAuthentication(req, res);
312
+ await completeAuthentication(req, res);
313
+ ```
314
+
315
+ Environment variables:
316
+ - `WEBAUTHN_RP_ID` - Your domain (e.g., "localhost" or "example.com")
317
+ - `WEBAUTHN_RP_NAME` - Your app name (e.g., "ErwinMVC App")
318
+
319
+ Note: WebAuthn requires HTTPS or localhost.
320
+
321
+ ---
322
+
283
323
  ## CLI Commands
284
324
 
285
325
  | Command | Description |
@@ -288,6 +328,7 @@ app.get("/protected", authenticate, (req, res) => {
288
328
  | `npx erwinmvc generate resource <name>` | Generate model + controller + views |
289
329
  | `npx erwinmvc generate controller <name>` | Generate a CRUD controller |
290
330
  | `npx erwinmvc generate model <name>` | Generate a database model |
331
+ | `npx erwinmvc webauthn` | Generate WebAuthn authentication (security keys) |
291
332
 
292
333
  ### Init Options
293
334
 
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ const initApp_1 = require("./generators/initApp");
6
6
  const generateModel_1 = require("./generators/generateModel");
7
7
  const generateController_1 = require("./generators/generateController");
8
8
  const generateResource_1 = require("./generators/generateResource");
9
+ const generateWebAuthn_1 = require("./generators/generateWebAuthn");
9
10
  const program = new commander_1.Command();
10
11
  program
11
12
  .name("erwinmvc")
@@ -78,4 +79,19 @@ generate
78
79
  process.exit(1);
79
80
  }
80
81
  });
82
+ // WebAuthn command - generate WebAuthn authentication views and controller
83
+ program
84
+ .command("webauthn")
85
+ .alias("w")
86
+ .description("Generate WebAuthn authentication (security key login)")
87
+ .option("--skip-migrate", "Skip running Prisma migrate")
88
+ .action(async (options) => {
89
+ try {
90
+ await (0, generateWebAuthn_1.generateWebAuthn)(options);
91
+ }
92
+ catch (err) {
93
+ console.error("Error:", err instanceof Error ? err.message : err);
94
+ process.exit(1);
95
+ }
96
+ });
81
97
  program.parse();
@@ -0,0 +1,10 @@
1
+ import { type PublicKeyCredentialCreationOptionsJSON, type PublicKeyCredentialRequestOptionsJSON } from "@simplewebauthn/server";
2
+ import type { Request, Response } from "express";
3
+ export declare function startRegistration(req: Request, res: Response): Promise<PublicKeyCredentialCreationOptionsJSON>;
4
+ export declare function completeRegistration(req: Request, res: Response): Promise<void>;
5
+ export declare function startAuthentication(req: Request, res: Response): Promise<PublicKeyCredentialRequestOptionsJSON>;
6
+ export declare function completeAuthentication(req: Request, res: Response): Promise<void>;
7
+ export declare function getRPConfig(): {
8
+ rpID: string;
9
+ rpName: string;
10
+ };
@@ -0,0 +1,240 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.startRegistration = startRegistration;
7
+ exports.completeRegistration = completeRegistration;
8
+ exports.startAuthentication = startAuthentication;
9
+ exports.completeAuthentication = completeAuthentication;
10
+ exports.getRPConfig = getRPConfig;
11
+ const server_1 = require("@simplewebauthn/server");
12
+ const base64url_1 = __importDefault(require("base64url"));
13
+ const RP_ID = process.env.WEBAUTHN_RP_ID || "localhost";
14
+ const RP_NAME = process.env.WEBAUTHN_RP_NAME || "ErwinMVC";
15
+ function getOrigin(req) {
16
+ const protocol = req.protocol;
17
+ const host = req.get("host") || "localhost";
18
+ return `${protocol}://${host}`;
19
+ }
20
+ function bufferToBase64URL(buffer) {
21
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
22
+ return base64url_1.default.encode(buf);
23
+ }
24
+ function base64URLToBuffer(str) {
25
+ return new Uint8Array(base64url_1.default.toBuffer(str));
26
+ }
27
+ async function startRegistration(req, res) {
28
+ const { username, displayName } = req.body;
29
+ if (!username || !displayName) {
30
+ res.status(400).json({ error: "Username and display name are required" });
31
+ throw new Error("Validation failed");
32
+ }
33
+ const options = await (0, server_1.generateRegistrationOptions)({
34
+ rpName: RP_NAME,
35
+ rpID: RP_ID,
36
+ userName: username,
37
+ userDisplayName: displayName,
38
+ timeout: 60000,
39
+ attestationType: "none",
40
+ authenticatorSelection: {
41
+ residentKey: "preferred",
42
+ userVerification: "preferred",
43
+ },
44
+ });
45
+ req.session.webauthnRegistration = {
46
+ username,
47
+ challenge: options.challenge,
48
+ };
49
+ return options;
50
+ }
51
+ async function completeRegistration(req, res) {
52
+ const prisma = getPrismaClient();
53
+ const { username, challenge } = req.session.webauthnRegistration || {};
54
+ if (!username || !challenge) {
55
+ res.status(400).json({ error: "Registration session expired" });
56
+ return;
57
+ }
58
+ const credential = req.body;
59
+ if (!credential || !credential.id) {
60
+ res.status(400).json({ error: "Invalid credential data" });
61
+ return;
62
+ }
63
+ let verified;
64
+ try {
65
+ verified = await (0, server_1.verifyRegistrationResponse)({
66
+ response: credential,
67
+ expectedChallenge: challenge,
68
+ expectedOrigin: getOrigin(req),
69
+ expectedRPID: RP_ID,
70
+ });
71
+ }
72
+ catch (err) {
73
+ res.status(400).json({ error: err instanceof Error ? err.message : "Verification failed" });
74
+ return;
75
+ }
76
+ const { verified: isVerified, registrationInfo } = verified;
77
+ if (!isVerified || !registrationInfo) {
78
+ res.status(400).json({ error: "Registration verification failed" });
79
+ return;
80
+ }
81
+ const cred = registrationInfo.credential;
82
+ const { id: credentialID, publicKey: credentialPublicKey, counter } = cred;
83
+ try {
84
+ await prisma.user.create({
85
+ data: {
86
+ username,
87
+ email: `${username}@${RP_ID}`,
88
+ hashedPassword: "",
89
+ webauthnCredentials: {
90
+ create: {
91
+ credentialID: credentialID,
92
+ credentialPublicKey: bufferToBase64URL(credentialPublicKey),
93
+ counter: counter.toString(),
94
+ transports: JSON.stringify(credential.transports || []),
95
+ },
96
+ },
97
+ },
98
+ });
99
+ }
100
+ catch (err) {
101
+ res.status(400).json({ error: err instanceof Error ? err.message : "Failed to save credential" });
102
+ return;
103
+ }
104
+ delete req.session.webauthnRegistration;
105
+ res.json({ success: true });
106
+ }
107
+ async function startAuthentication(req, res) {
108
+ const prisma = getPrismaClient();
109
+ const users = await prisma.user.findMany({
110
+ select: {
111
+ id: true,
112
+ username: true,
113
+ webauthnCredentials: {
114
+ select: {
115
+ id: true,
116
+ credentialID: true,
117
+ counter: true,
118
+ transports: true,
119
+ },
120
+ },
121
+ },
122
+ });
123
+ const allowCredentials = users
124
+ .map((user) => {
125
+ return user.webauthnCredentials.map((cred) => ({
126
+ id: cred.credentialID,
127
+ transports: JSON.parse(cred.transports || "[]"),
128
+ }));
129
+ })
130
+ .flat();
131
+ const options = await (0, server_1.generateAuthenticationOptions)({
132
+ rpID: RP_ID,
133
+ timeout: 60000,
134
+ userVerification: "preferred",
135
+ allowCredentials: allowCredentials.length > 0 ? allowCredentials : undefined,
136
+ });
137
+ req.session.webauthnAuthentication = {
138
+ challenge: options.challenge,
139
+ };
140
+ return options;
141
+ }
142
+ async function completeAuthentication(req, res) {
143
+ const prisma = getPrismaClient();
144
+ const { challenge } = req.session.webauthnAuthentication || {};
145
+ if (!challenge) {
146
+ res.status(400).json({ error: "Authentication session expired" });
147
+ return;
148
+ }
149
+ const credential = req.body;
150
+ if (!credential || !credential.id) {
151
+ res.status(400).json({ error: "Invalid credential data" });
152
+ return;
153
+ }
154
+ const credentialID = credential.id;
155
+ const user = await prisma.user.findFirst({
156
+ where: {
157
+ webauthnCredentials: {
158
+ some: {
159
+ credentialID,
160
+ },
161
+ },
162
+ },
163
+ select: {
164
+ id: true,
165
+ username: true,
166
+ webauthnCredentials: {
167
+ where: {
168
+ credentialID,
169
+ },
170
+ select: {
171
+ id: true,
172
+ credentialID: true,
173
+ credentialPublicKey: true,
174
+ counter: true,
175
+ },
176
+ },
177
+ },
178
+ });
179
+ if (!user || user.webauthnCredentials.length === 0) {
180
+ res.status(401).json({ error: "Credential not found" });
181
+ return;
182
+ }
183
+ const webauthnCred = user.webauthnCredentials[0];
184
+ const credentialData = {
185
+ id: webauthnCred.credentialID,
186
+ publicKey: base64URLToBuffer(webauthnCred.credentialPublicKey),
187
+ counter: parseInt(webauthnCred.counter, 10),
188
+ };
189
+ let verified;
190
+ try {
191
+ verified = await (0, server_1.verifyAuthenticationResponse)({
192
+ response: credential,
193
+ expectedChallenge: challenge,
194
+ expectedOrigin: getOrigin(req),
195
+ expectedRPID: RP_ID,
196
+ credential: credentialData,
197
+ });
198
+ }
199
+ catch (err) {
200
+ res.status(400).json({ error: err instanceof Error ? err.message : "Verification failed" });
201
+ return;
202
+ }
203
+ const { verified: isVerified, authenticationInfo } = verified;
204
+ if (!isVerified) {
205
+ res.status(401).json({ error: "Authentication verification failed" });
206
+ return;
207
+ }
208
+ await prisma.webAuthnCredential.update({
209
+ where: { id: webauthnCred.id },
210
+ data: {
211
+ counter: authenticationInfo.newCounter.toString(),
212
+ },
213
+ });
214
+ req.session.userId = user.id;
215
+ req.session.username = user.username;
216
+ delete req.session.webauthnAuthentication;
217
+ res.json({ success: true, username: user.username });
218
+ }
219
+ let _prisma = null;
220
+ let _PrismaClient = null;
221
+ function getPrismaClient() {
222
+ if (!_prisma) {
223
+ if (!_PrismaClient) {
224
+ try {
225
+ _PrismaClient = require("@prisma/client").PrismaClient;
226
+ }
227
+ catch {
228
+ throw new Error("Prisma is not installed. Run 'npm install @prisma/client prisma' to use database features.");
229
+ }
230
+ }
231
+ _prisma = new _PrismaClient();
232
+ }
233
+ return _prisma;
234
+ }
235
+ function getRPConfig() {
236
+ return {
237
+ rpID: RP_ID,
238
+ rpName: RP_NAME,
239
+ };
240
+ }
@@ -2,4 +2,5 @@ export { createMvcApp, startServer } from "./App";
2
2
  export type { MvcAppOptions, MvcApp } from "./App";
3
3
  export { getPrismaClient, disconnectPrisma } from "./db";
4
4
  export { hashPassword, verifyPassword, signToken, verifyToken, authenticate, } from "./Auth";
5
+ export { startRegistration, completeRegistration, startAuthentication, completeAuthentication, getRPConfig, } from "./WebAuthn";
5
6
  export { registerControllers, registerController } from "./Router";
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.registerController = exports.registerControllers = exports.authenticate = exports.verifyToken = exports.signToken = exports.verifyPassword = exports.hashPassword = exports.disconnectPrisma = exports.getPrismaClient = exports.startServer = exports.createMvcApp = void 0;
3
+ exports.registerController = exports.registerControllers = exports.getRPConfig = exports.completeAuthentication = exports.startAuthentication = exports.completeRegistration = exports.startRegistration = exports.authenticate = exports.verifyToken = exports.signToken = exports.verifyPassword = exports.hashPassword = exports.disconnectPrisma = exports.getPrismaClient = exports.startServer = exports.createMvcApp = void 0;
4
4
  // App factory and server
5
5
  var App_1 = require("./App");
6
6
  Object.defineProperty(exports, "createMvcApp", { enumerable: true, get: function () { return App_1.createMvcApp; } });
@@ -16,6 +16,13 @@ Object.defineProperty(exports, "verifyPassword", { enumerable: true, get: functi
16
16
  Object.defineProperty(exports, "signToken", { enumerable: true, get: function () { return Auth_1.signToken; } });
17
17
  Object.defineProperty(exports, "verifyToken", { enumerable: true, get: function () { return Auth_1.verifyToken; } });
18
18
  Object.defineProperty(exports, "authenticate", { enumerable: true, get: function () { return Auth_1.authenticate; } });
19
+ // WebAuthn
20
+ var WebAuthn_1 = require("./WebAuthn");
21
+ Object.defineProperty(exports, "startRegistration", { enumerable: true, get: function () { return WebAuthn_1.startRegistration; } });
22
+ Object.defineProperty(exports, "completeRegistration", { enumerable: true, get: function () { return WebAuthn_1.completeRegistration; } });
23
+ Object.defineProperty(exports, "startAuthentication", { enumerable: true, get: function () { return WebAuthn_1.startAuthentication; } });
24
+ Object.defineProperty(exports, "completeAuthentication", { enumerable: true, get: function () { return WebAuthn_1.completeAuthentication; } });
25
+ Object.defineProperty(exports, "getRPConfig", { enumerable: true, get: function () { return WebAuthn_1.getRPConfig; } });
19
26
  // Routing
20
27
  var Router_1 = require("./Router");
21
28
  Object.defineProperty(exports, "registerControllers", { enumerable: true, get: function () { return Router_1.registerControllers; } });
@@ -0,0 +1,7 @@
1
+ export interface GenerateWebAuthnOptions {
2
+ skipMigrate?: boolean;
3
+ }
4
+ /**
5
+ * Generate WebAuthn authentication views and controller.
6
+ */
7
+ export declare function generateWebAuthn(options?: GenerateWebAuthnOptions): Promise<void>;
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.generateWebAuthn = generateWebAuthn;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const ejs_1 = __importDefault(require("ejs"));
10
+ const child_process_1 = require("child_process");
11
+ const paths_1 = require("./paths");
12
+ /**
13
+ * Generate WebAuthn authentication views and controller.
14
+ */
15
+ async function generateWebAuthn(options = {}) {
16
+ console.log("Generating WebAuthn authentication...");
17
+ const controllersDir = path_1.default.resolve("src/controllers");
18
+ if (!fs_1.default.existsSync(controllersDir)) {
19
+ fs_1.default.mkdirSync(controllersDir, { recursive: true });
20
+ }
21
+ const viewsDir = path_1.default.resolve("src/views/webauthn");
22
+ if (!fs_1.default.existsSync(viewsDir)) {
23
+ fs_1.default.mkdirSync(viewsDir, { recursive: true });
24
+ }
25
+ // Generate controller
26
+ const controllerTemplatePath = path_1.default.join((0, paths_1.getTemplatesDir)(), "webauthnController.ts.ejs");
27
+ const controllerTemplate = fs_1.default.readFileSync(controllerTemplatePath, "utf-8");
28
+ const controllerContent = ejs_1.default.render(controllerTemplate, {
29
+ controllerName: "WebAuthnController",
30
+ resourcePath: "webauthn",
31
+ });
32
+ fs_1.default.writeFileSync(path_1.default.join(controllersDir, "WebAuthnController.ts"), controllerContent);
33
+ console.log("Created src/controllers/WebAuthnController.ts");
34
+ // Generate views
35
+ const viewTemplatePath = path_1.default.join((0, paths_1.getTemplatesDir)(), "webauthnView.ejs.ejs");
36
+ const views = [
37
+ { name: "register", title: "Register Security Key" },
38
+ { name: "login", title: "Log in with Security Key" },
39
+ { name: "authenticate", title: "Two-Factor Authentication" },
40
+ ];
41
+ for (const view of views) {
42
+ const viewTemplate = fs_1.default.readFileSync(viewTemplatePath, "utf-8");
43
+ const viewContent = ejs_1.default.render(viewTemplate, {
44
+ title: view.title,
45
+ viewName: view.name,
46
+ });
47
+ fs_1.default.writeFileSync(path_1.default.join(viewsDir, `${view.name}.ejs`), viewContent);
48
+ console.log(`Created src/views/webauthn/${view.name}.ejs`);
49
+ }
50
+ // Run migrations if prisma exists
51
+ if (!options.skipMigrate) {
52
+ const schemaPath = path_1.default.resolve("prisma/schema.prisma");
53
+ if (fs_1.default.existsSync(schemaPath)) {
54
+ console.log("\nRunning Prisma migrate...");
55
+ try {
56
+ (0, child_process_1.execSync)("npx prisma migrate dev --name add_webauthn_credentials", {
57
+ stdio: "inherit",
58
+ });
59
+ }
60
+ catch {
61
+ console.error("Migration failed. You may need to run it manually.");
62
+ }
63
+ console.log("\nGenerating Prisma client...");
64
+ try {
65
+ (0, child_process_1.execSync)("npx prisma generate", { stdio: "inherit" });
66
+ }
67
+ catch {
68
+ console.error("Failed to generate Prisma client.");
69
+ }
70
+ }
71
+ }
72
+ console.log(`
73
+ WebAuthn authentication created successfully!
74
+
75
+ Routes:
76
+ GET /webauthn/register -> register (display registration form)
77
+ POST /webauthn/register -> completeRegistration (process registration)
78
+ GET /webauthn/login -> login (display login form)
79
+ POST /webauthn/login -> completeAuthentication (process login)
80
+ GET /webauthn/authenticate -> authenticate (2FA challenge)
81
+ POST /webauthn/authenticate -> completeAuthentication (2FA completion)
82
+
83
+ Required environment variables:
84
+ WEBAUTHN_RP_ID - Your domain (e.g., "localhost" or "example.com")
85
+ WEBAUTHN_RP_NAME - Your app name (e.g., "ErwinMVC App")
86
+
87
+ Note: WebAuthn requires a secure context (HTTPS or localhost).
88
+ `);
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erwininteractive/mvc",
3
- "version": "0.3.8",
3
+ "version": "0.4.2",
4
4
  "description": "A lightweight, full-featured MVC framework for Node.js with Express, Prisma, and EJS",
5
5
  "main": "dist/framework/index.js",
6
6
  "types": "dist/framework/index.d.ts",
@@ -39,6 +39,8 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@prisma/client": "^6.0.0",
42
+ "@simplewebauthn/server": "^13.3.0",
43
+ "base64url": "^3.0.1",
42
44
  "bcryptjs": "^2.4.3",
43
45
  "commander": "^12.1.0",
44
46
  "connect-redis": "^7.1.1",
@@ -12,8 +12,28 @@ model User {
12
12
  email String @unique
13
13
  hashedPassword String
14
14
  role String @default("user")
15
+ username String? @unique
15
16
  createdAt DateTime @default(now())
16
17
  updatedAt DateTime @updatedAt
17
18
 
19
+ webauthnCredentials WebAuthnCredential[]
20
+
18
21
  @@map("users")
19
22
  }
23
+
24
+ model WebAuthnCredential {
25
+ id Int @id @default(autoincrement())
26
+ credentialID String @unique
27
+ credentialPublicKey String
28
+ counter Int
29
+ attestationType String @default("none")
30
+ aaguid String?
31
+ transports String?
32
+ createdAt DateTime @default(now())
33
+ updatedAt DateTime @updatedAt
34
+
35
+ user User @relation(fields: [userID], references: [id], onDelete: Cascade)
36
+ userID Int
37
+
38
+ @@map("webauthn_credentials")
39
+ }
@@ -0,0 +1,53 @@
1
+ import type { Request, Response } from "express";
2
+ import {
3
+ startRegistration,
4
+ completeRegistration,
5
+ startAuthentication,
6
+ completeAuthentication,
7
+ } from "@erwininteractive/mvc";
8
+
9
+ /**
10
+ * Display the registration form
11
+ */
12
+ export async function register(req: Request, res: Response): Promise<void> {
13
+ res.render("webauthn/register", {
14
+ title: "Register Security Key",
15
+ success: req.query.success === "true",
16
+ error: req.query.error || null,
17
+ });
18
+ }
19
+
20
+ /**
21
+ * Process registration response
22
+ */
23
+ export async function completeRegister(req: Request, res: Response): Promise<void> {
24
+ try {
25
+ await completeRegistration(req, res);
26
+ } catch (err) {
27
+ const error = err instanceof Error ? err.message : err;
28
+ res.redirect("/webauthn/register?error=" + encodeURIComponent(error));
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Display the login form
34
+ */
35
+ export async function login(req: Request, res: Response): Promise<void> {
36
+ res.render("webauthn/login", {
37
+ title: "Log in with Security Key",
38
+ error: req.query.error || null,
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Process authentication response
44
+ */
45
+ export async function completeLogin(req: Request, res: Response): Promise<void> {
46
+ try {
47
+ await completeAuthentication(req, res);
48
+ res.redirect("/dashboard");
49
+ } catch (err) {
50
+ const error = err instanceof Error ? err.message : err;
51
+ res.redirect("/webauthn/login?error=" + encodeURIComponent(error));
52
+ }
53
+ }
@@ -0,0 +1,61 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title><%= title %></title>
7
+ <script src="https://cdn.jsdelivr.net/npm/@simplewebauthn/browser@9"></script>
8
+ <style>
9
+ body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
10
+ .form-group { margin-bottom: 15px; }
11
+ label { display: block; margin-bottom: 5px; }
12
+ input { width: 100%; padding: 8px; box-sizing: border-box; }
13
+ button { padding: 10px 20px; cursor: pointer; }
14
+ .error { color: red; margin-bottom: 15px; }
15
+ .success { color: green; margin-bottom: 15px; }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <h1><%= title %></h1>
20
+
21
+ <% if (error) { %>
22
+ <div class="error"><%= error %></div>
23
+ <% } %>
24
+
25
+ <form id="webauthnForm" method="POST">
26
+ <div class="form-group">
27
+ <label for="username">Username:</label>
28
+ <input type="text" id="username" name="username" required>
29
+ </div>
30
+
31
+ <button type="submit"><%= title %></button>
32
+ </form>
33
+
34
+ <script>
35
+ document.getElementById('webauthnForm').addEventListener('submit', async function(e) {
36
+ e.preventDefault();
37
+
38
+ const username = document.getElementById('username').value;
39
+ const response = await fetch('/webauthn/start-authentication', {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify({ username })
43
+ });
44
+
45
+ const options = await response.json();
46
+
47
+ if (options.error) {
48
+ alert(options.error);
49
+ return;
50
+ }
51
+
52
+ try {
53
+ const credential = await startAuthentication(options);
54
+ window.location.href = '/webauthn/complete?token=' + encodeURIComponent(JSON.stringify(credential));
55
+ } catch (err) {
56
+ alert('Authentication failed: ' + err.message);
57
+ }
58
+ });
59
+ </script>
60
+ </body>
61
+ </html>