work-in-progress atproto PDS
typescript atproto pds atcute
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: WebAuthn security key support

Mary 1d3a8a01 26fe3556

+2460 -125
+30 -2
packages/danaus/drizzle/accounts/20260111233752_furry_franklin_storm/migration.sql packages/danaus/drizzle/accounts/20260117055732_omniscient_malice/migration.sql
··· 8 8 `password_hash` text NOT NULL, 9 9 `password_updated_at` integer, 10 10 `email` text NOT NULL, 11 - `email_confirmed_at` integer 11 + `email_confirmed_at` integer, 12 + `preferred_mfa` integer 12 13 ); 13 14 --> statement-breakpoint 14 15 CREATE TABLE `app_password` ( ··· 63 64 CREATE TABLE `mfa_challenge` ( 64 65 `token` text PRIMARY KEY, 65 66 `did` text NOT NULL, 67 + `webauthn_challenge` text, 66 68 `created_at` integer NOT NULL, 67 69 `expires_at` integer NOT NULL, 68 70 CONSTRAINT `fk_mfa_challenge_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE ··· 98 100 CONSTRAINT `fk_web_session_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE 99 101 ); 100 102 --> statement-breakpoint 103 + CREATE TABLE `webauthn_challenge` ( 104 + `token` text PRIMARY KEY, 105 + `did` text NOT NULL, 106 + `challenge` text NOT NULL, 107 + `created_at` integer NOT NULL, 108 + `expires_at` integer NOT NULL, 109 + CONSTRAINT `fk_webauthn_challenge_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE 110 + ); 111 + --> statement-breakpoint 112 + CREATE TABLE `webauthn_credential` ( 113 + `id` integer PRIMARY KEY AUTOINCREMENT, 114 + `did` text NOT NULL, 115 + `type` integer NOT NULL, 116 + `name` text NOT NULL, 117 + `credential_id` text NOT NULL, 118 + `public_key` blob NOT NULL, 119 + `counter` integer NOT NULL, 120 + `transports` text, 121 + `created_at` integer NOT NULL, 122 + CONSTRAINT `fk_webauthn_credential_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE, 123 + CONSTRAINT `webauthn_credential_did_name_unique` UNIQUE(`did`,`name`) 124 + ); 125 + --> statement-breakpoint 101 126 CREATE INDEX `account_created_at_did_idx` ON `account` (`created_at`,`did`);--> statement-breakpoint 102 127 CREATE UNIQUE INDEX `account_handle_lower_idx` ON `account` (lower("handle"));--> statement-breakpoint 103 128 CREATE UNIQUE INDEX `account_email_lower_idx` ON `account` (lower("email"));--> statement-breakpoint ··· 105 130 CREATE INDEX `mfa_challenge_expires_idx` ON `mfa_challenge` (`expires_at`);--> statement-breakpoint 106 131 CREATE INDEX `recovery_code_did_idx` ON `recovery_code` (`did`);--> statement-breakpoint 107 132 CREATE INDEX `totp_credential_did_idx` ON `totp_credential` (`did`);--> statement-breakpoint 108 - CREATE INDEX `web_session_did_idx` ON `web_session` (`did`); 133 + CREATE INDEX `web_session_did_idx` ON `web_session` (`did`);--> statement-breakpoint 134 + CREATE INDEX `webauthn_challenge_expires_idx` ON `webauthn_challenge` (`expires_at`);--> statement-breakpoint 135 + CREATE INDEX `webauthn_credential_did_idx` ON `webauthn_credential` (`did`);--> statement-breakpoint 136 + CREATE UNIQUE INDEX `webauthn_credential_id_idx` ON `webauthn_credential` (`credential_id`);
+269 -1
packages/danaus/drizzle/accounts/20260111233752_furry_franklin_storm/snapshot.json packages/danaus/drizzle/accounts/20260117055732_omniscient_malice/snapshot.json
··· 1 1 { 2 2 "version": "7", 3 3 "dialect": "sqlite", 4 - "id": "5f7c0714-f369-47f1-88ab-4294528beada", 4 + "id": "ac52481d-ca5d-445c-b696-ae857bf5467f", 5 5 "prevIds": [ 6 6 "00000000-0000-0000-0000-000000000000" 7 7 ], ··· 44 44 }, 45 45 { 46 46 "name": "web_session", 47 + "entityType": "tables" 48 + }, 49 + { 50 + "name": "webauthn_challenge", 51 + "entityType": "tables" 52 + }, 53 + { 54 + "name": "webauthn_credential", 47 55 "entityType": "tables" 48 56 }, 49 57 { ··· 149 157 { 150 158 "type": "integer", 151 159 "notNull": false, 160 + "autoincrement": false, 161 + "default": null, 162 + "generated": null, 163 + "name": "preferred_mfa", 164 + "entityType": "columns", 165 + "table": "account" 166 + }, 167 + { 168 + "type": "integer", 169 + "notNull": false, 152 170 "autoincrement": true, 153 171 "default": null, 154 172 "generated": null, ··· 407 425 "table": "mfa_challenge" 408 426 }, 409 427 { 428 + "type": "text", 429 + "notNull": false, 430 + "autoincrement": false, 431 + "default": null, 432 + "generated": null, 433 + "name": "webauthn_challenge", 434 + "entityType": "columns", 435 + "table": "mfa_challenge" 436 + }, 437 + { 410 438 "type": "integer", 411 439 "notNull": true, 412 440 "autoincrement": false, ··· 597 625 "table": "web_session" 598 626 }, 599 627 { 628 + "type": "text", 629 + "notNull": false, 630 + "autoincrement": false, 631 + "default": null, 632 + "generated": null, 633 + "name": "token", 634 + "entityType": "columns", 635 + "table": "webauthn_challenge" 636 + }, 637 + { 638 + "type": "text", 639 + "notNull": true, 640 + "autoincrement": false, 641 + "default": null, 642 + "generated": null, 643 + "name": "did", 644 + "entityType": "columns", 645 + "table": "webauthn_challenge" 646 + }, 647 + { 648 + "type": "text", 649 + "notNull": true, 650 + "autoincrement": false, 651 + "default": null, 652 + "generated": null, 653 + "name": "challenge", 654 + "entityType": "columns", 655 + "table": "webauthn_challenge" 656 + }, 657 + { 658 + "type": "integer", 659 + "notNull": true, 660 + "autoincrement": false, 661 + "default": null, 662 + "generated": null, 663 + "name": "created_at", 664 + "entityType": "columns", 665 + "table": "webauthn_challenge" 666 + }, 667 + { 668 + "type": "integer", 669 + "notNull": true, 670 + "autoincrement": false, 671 + "default": null, 672 + "generated": null, 673 + "name": "expires_at", 674 + "entityType": "columns", 675 + "table": "webauthn_challenge" 676 + }, 677 + { 678 + "type": "integer", 679 + "notNull": false, 680 + "autoincrement": true, 681 + "default": null, 682 + "generated": null, 683 + "name": "id", 684 + "entityType": "columns", 685 + "table": "webauthn_credential" 686 + }, 687 + { 688 + "type": "text", 689 + "notNull": true, 690 + "autoincrement": false, 691 + "default": null, 692 + "generated": null, 693 + "name": "did", 694 + "entityType": "columns", 695 + "table": "webauthn_credential" 696 + }, 697 + { 698 + "type": "integer", 699 + "notNull": true, 700 + "autoincrement": false, 701 + "default": null, 702 + "generated": null, 703 + "name": "type", 704 + "entityType": "columns", 705 + "table": "webauthn_credential" 706 + }, 707 + { 708 + "type": "text", 709 + "notNull": true, 710 + "autoincrement": false, 711 + "default": null, 712 + "generated": null, 713 + "name": "name", 714 + "entityType": "columns", 715 + "table": "webauthn_credential" 716 + }, 717 + { 718 + "type": "text", 719 + "notNull": true, 720 + "autoincrement": false, 721 + "default": null, 722 + "generated": null, 723 + "name": "credential_id", 724 + "entityType": "columns", 725 + "table": "webauthn_credential" 726 + }, 727 + { 728 + "type": "blob", 729 + "notNull": true, 730 + "autoincrement": false, 731 + "default": null, 732 + "generated": null, 733 + "name": "public_key", 734 + "entityType": "columns", 735 + "table": "webauthn_credential" 736 + }, 737 + { 738 + "type": "integer", 739 + "notNull": true, 740 + "autoincrement": false, 741 + "default": null, 742 + "generated": null, 743 + "name": "counter", 744 + "entityType": "columns", 745 + "table": "webauthn_credential" 746 + }, 747 + { 748 + "type": "text", 749 + "notNull": false, 750 + "autoincrement": false, 751 + "default": null, 752 + "generated": null, 753 + "name": "transports", 754 + "entityType": "columns", 755 + "table": "webauthn_credential" 756 + }, 757 + { 758 + "type": "integer", 759 + "notNull": true, 760 + "autoincrement": false, 761 + "default": null, 762 + "generated": null, 763 + "name": "created_at", 764 + "entityType": "columns", 765 + "table": "webauthn_credential" 766 + }, 767 + { 600 768 "columns": [ 601 769 "did" 602 770 ], ··· 763 931 }, 764 932 { 765 933 "columns": [ 934 + "did" 935 + ], 936 + "tableTo": "account", 937 + "columnsTo": [ 938 + "did" 939 + ], 940 + "onUpdate": "NO ACTION", 941 + "onDelete": "CASCADE", 942 + "nameExplicit": false, 943 + "name": "fk_webauthn_challenge_did_account_did_fk", 944 + "entityType": "fks", 945 + "table": "webauthn_challenge" 946 + }, 947 + { 948 + "columns": [ 949 + "did" 950 + ], 951 + "tableTo": "account", 952 + "columnsTo": [ 953 + "did" 954 + ], 955 + "onUpdate": "NO ACTION", 956 + "onDelete": "CASCADE", 957 + "nameExplicit": false, 958 + "name": "fk_webauthn_credential_did_account_did_fk", 959 + "entityType": "fks", 960 + "table": "webauthn_credential" 961 + }, 962 + { 963 + "columns": [ 766 964 "did", 767 965 "purpose" 768 966 ], ··· 855 1053 }, 856 1054 { 857 1055 "columns": [ 1056 + "token" 1057 + ], 1058 + "nameExplicit": false, 1059 + "name": "webauthn_challenge_pk", 1060 + "table": "webauthn_challenge", 1061 + "entityType": "pks" 1062 + }, 1063 + { 1064 + "columns": [ 1065 + "id" 1066 + ], 1067 + "nameExplicit": false, 1068 + "name": "webauthn_credential_pk", 1069 + "table": "webauthn_credential", 1070 + "entityType": "pks" 1071 + }, 1072 + { 1073 + "columns": [ 858 1074 { 859 1075 "value": "created_at", 860 1076 "isExpression": false ··· 971 1187 }, 972 1188 { 973 1189 "columns": [ 1190 + { 1191 + "value": "expires_at", 1192 + "isExpression": false 1193 + } 1194 + ], 1195 + "isUnique": false, 1196 + "where": null, 1197 + "origin": "manual", 1198 + "name": "webauthn_challenge_expires_idx", 1199 + "entityType": "indexes", 1200 + "table": "webauthn_challenge" 1201 + }, 1202 + { 1203 + "columns": [ 1204 + { 1205 + "value": "did", 1206 + "isExpression": false 1207 + } 1208 + ], 1209 + "isUnique": false, 1210 + "where": null, 1211 + "origin": "manual", 1212 + "name": "webauthn_credential_did_idx", 1213 + "entityType": "indexes", 1214 + "table": "webauthn_credential" 1215 + }, 1216 + { 1217 + "columns": [ 1218 + { 1219 + "value": "credential_id", 1220 + "isExpression": false 1221 + } 1222 + ], 1223 + "isUnique": true, 1224 + "where": null, 1225 + "origin": "manual", 1226 + "name": "webauthn_credential_id_idx", 1227 + "entityType": "indexes", 1228 + "table": "webauthn_credential" 1229 + }, 1230 + { 1231 + "columns": [ 974 1232 "did", 975 1233 "name" 976 1234 ], ··· 988 1246 "name": "totp_credential_did_name_unique", 989 1247 "entityType": "uniques", 990 1248 "table": "totp_credential" 1249 + }, 1250 + { 1251 + "columns": [ 1252 + "did", 1253 + "name" 1254 + ], 1255 + "nameExplicit": false, 1256 + "name": "webauthn_credential_did_name_unique", 1257 + "entityType": "uniques", 1258 + "table": "webauthn_credential" 991 1259 } 992 1260 ], 993 1261 "renames": []
+2 -1
packages/danaus/package.json
··· 45 45 "@kelinci/danaus-lexicons": "workspace:*", 46 46 "@oomfware/fetch-router": "^0.2.1", 47 47 "@oomfware/forms": "^0.2.2", 48 - "@oomfware/jsx": "^0.1.4", 48 + "@oomfware/jsx": "^0.1.5", 49 + "@simplewebauthn/server": "^13.2.2", 49 50 "cva": "1.0.0-beta.4", 50 51 "drizzle-orm": "1.0.0-beta.6-4414a19", 51 52 "get-port": "^7.1.0",
+81
packages/danaus/src/accounts/db/schema.ts
··· 1 1 import type { Did, Handle } from '@atcute/lexicons/syntax'; 2 2 3 3 import { sql } from 'drizzle-orm'; 4 + import type { AuthenticatorTransportFuture } from '@simplewebauthn/server'; 4 5 import { 5 6 blob, 6 7 foreignKey, ··· 13 14 uniqueIndex, 14 15 } from 'drizzle-orm/sqlite-core'; 15 16 17 + /** preferred MFA method */ 18 + export const enum PreferredMfa { 19 + /** TOTP authenticator app */ 20 + Totp = 0, 21 + /** WebAuthn security key */ 22 + WebAuthn = 1, 23 + } 24 + 16 25 /** user accounts */ 17 26 export const account = sqliteTable( 18 27 'account', ··· 30 39 31 40 email: text().notNull(), 32 41 email_confirmed_at: integer({ mode: 'timestamp' }), 42 + 43 + /** preferred MFA method (null = no MFA configured) */ 44 + preferred_mfa: integer().$type<PreferredMfa>(), 33 45 }, 34 46 (t) => [ 35 47 index('account_created_at_did_idx').on(t.created_at, t.did), ··· 223 235 .notNull() 224 236 .references(() => account.did, { onDelete: 'cascade' }), 225 237 238 + /** WebAuthn challenge (base64url) for authentication */ 239 + webauthn_challenge: text(), 240 + 226 241 created_at: integer({ mode: 'timestamp' }).notNull(), 227 242 expires_at: integer({ mode: 'timestamp' }).notNull(), 228 243 }, ··· 230 245 ); 231 246 232 247 // #endregion 248 + 249 + // #region WebAuthn credentials 250 + 251 + /** WebAuthn credential types */ 252 + export const enum WebAuthnCredentialType { 253 + /** security key - non-discoverable, 2FA only */ 254 + SecurityKey = 0, 255 + /** passkey - discoverable, can be used for passwordless (future) */ 256 + Passkey = 1, 257 + } 258 + 259 + /** WebAuthn credentials for security keys and passkeys */ 260 + export const webauthnCredential = sqliteTable( 261 + 'webauthn_credential', 262 + { 263 + id: integer().primaryKey({ autoIncrement: true }), 264 + 265 + did: text() 266 + .$type<Did>() 267 + .notNull() 268 + .references(() => account.did, { onDelete: 'cascade' }), 269 + 270 + /** credential type: security key or passkey */ 271 + type: integer().$type<WebAuthnCredentialType>().notNull(), 272 + /** user-provided name */ 273 + name: text().notNull(), 274 + 275 + /** base64url-encoded credential ID */ 276 + credential_id: text().notNull(), 277 + /** COSE public key (binary) */ 278 + public_key: blob({ mode: 'buffer' }).notNull(), 279 + /** signature counter for replay detection */ 280 + counter: integer().notNull(), 281 + /** transport hints */ 282 + transports: text({ mode: 'json' }).$type<AuthenticatorTransportFuture[]>(), 283 + 284 + created_at: integer({ mode: 'timestamp' }).notNull(), 285 + }, 286 + (t) => [ 287 + index('webauthn_credential_did_idx').on(t.did), 288 + unique().on(t.did, t.name), 289 + uniqueIndex('webauthn_credential_id_idx').on(t.credential_id), 290 + ], 291 + ); 292 + 293 + /** WebAuthn registration challenges */ 294 + export const webauthnChallenge = sqliteTable( 295 + 'webauthn_challenge', 296 + { 297 + token: text().primaryKey(), 298 + 299 + did: text() 300 + .$type<Did>() 301 + .notNull() 302 + .references(() => account.did, { onDelete: 'cascade' }), 303 + 304 + /** base64url challenge */ 305 + challenge: text().notNull(), 306 + 307 + created_at: integer({ mode: 'timestamp' }).notNull(), 308 + expires_at: integer({ mode: 'timestamp' }).notNull(), 309 + }, 310 + (t) => [index('webauthn_challenge_expires_idx').on(t.expires_at)], 311 + ); 312 + 313 + // #endregion
+370 -13
packages/danaus/src/accounts/manager.ts
··· 15 15 import { DAY, HOUR } from '#app/utils/times.ts'; 16 16 import { generateAppPassword, generateInviteCode } from '#app/utils/token.ts'; 17 17 18 + import type { AuthenticatorTransportFuture } from '@simplewebauthn/server'; 19 + 18 20 import { getAccountDb, t, type AccountDb } from './db'; 19 - import { AppPasswordPrivilege, EmailTokenPurpose } from './db/schema'; 21 + import { AppPasswordPrivilege, EmailTokenPurpose, PreferredMfa, WebAuthnCredentialType } from './db/schema'; 20 22 import { isServiceDomain, isValidTld } from './handle'; 21 23 import { hashPassword, verifyPassword } from './passwords'; 22 24 import { generateBackupCodes, MAX_TOTP_CREDENTIALS, verifyTotpCode } from './totp'; 23 25 import { AccountStatus, formatAccountStatus } from './types'; 26 + import { MAX_WEBAUTHN_CREDENTIALS, WEBAUTHN_CHALLENGE_TTL_MS } from './webauthn'; 24 27 25 28 const WEB_SESSION_TTL_MS = 7 * DAY; 26 29 const WEB_SESSION_LONG_TTL_MS = 365 * DAY; ··· 40 43 export type TotpCredential = typeof t.totpCredential.$inferSelect; 41 44 export type BackupCode = typeof t.recoveryCode.$inferSelect; 42 45 export type MfaChallenge = typeof t.mfaChallenge.$inferSelect; 46 + export type WebauthnCredential = typeof t.webauthnCredential.$inferSelect; 47 + export type WebauthnChallenge = typeof t.webauthnChallenge.$inferSelect; 48 + 49 + /** MFA status for an account */ 50 + export interface MfaStatus { 51 + /** preferred MFA method */ 52 + preferred: PreferredMfa; 53 + /** has TOTP credentials */ 54 + hasTotp: boolean; 55 + /** has WebAuthn security keys */ 56 + hasWebAuthn: boolean; 57 + /** has recovery codes */ 58 + hasRecoveryCodes: boolean; 59 + } 43 60 44 61 export interface InviteCodeWithUses extends InviteCode { 45 62 uses: InviteCodeUse[]; ··· 1054 1071 * @returns created credential 1055 1072 */ 1056 1073 createTotpCredential(options: CreateTotpCredentialOptions): TotpCredential { 1057 - const count = this.countTotpCredentials(options.did); 1074 + const count = this.#countTotpCredentials(options.did); 1058 1075 if (count >= MAX_TOTP_CREDENTIALS) { 1059 1076 throw new InvalidRequestError({ 1060 1077 error: 'TooManyTotpCredentials', ··· 1094 1111 throw new Error(`failed to create TOTP credential`); 1095 1112 } 1096 1113 1114 + this.#syncPreferredMfa(options.did); 1115 + 1097 1116 return inserted; 1098 1117 } 1099 1118 ··· 1132 1151 .delete(t.totpCredential) 1133 1152 .where(and(eq(t.totpCredential.did, did), eq(t.totpCredential.id, id))) 1134 1153 .run(); 1154 + 1155 + this.#syncPreferredMfa(did); 1135 1156 } 1136 1157 1137 1158 /** 1138 - * check if MFA is enabled for an account. 1159 + * sync preferred_mfa to reflect current MFA credentials. 1160 + * - if null and credentials exist → set to first available type 1161 + * - if set but that type has no credentials → switch to another type or clear 1162 + */ 1163 + #syncPreferredMfa(did: Did): void { 1164 + const account = this.db 1165 + .select({ preferred_mfa: t.account.preferred_mfa }) 1166 + .from(t.account) 1167 + .where(eq(t.account.did, did)) 1168 + .get(); 1169 + 1170 + if (!account) { 1171 + return; 1172 + } 1173 + 1174 + const hasTotp = this.#countTotpCredentials(did) > 0; 1175 + const hasWebAuthn = this.countWebAuthnCredentials(did) > 0; 1176 + 1177 + // check if current preference is still valid 1178 + if (account.preferred_mfa === PreferredMfa.Totp && hasTotp) { 1179 + return; 1180 + } 1181 + if (account.preferred_mfa === PreferredMfa.WebAuthn && hasWebAuthn) { 1182 + return; 1183 + } 1184 + 1185 + // need to set or switch: prefer the type that was just added (TOTP first for backwards compat) 1186 + let newPreferred: PreferredMfa | null = null; 1187 + if (hasTotp) { 1188 + newPreferred = PreferredMfa.Totp; 1189 + } else if (hasWebAuthn) { 1190 + newPreferred = PreferredMfa.WebAuthn; 1191 + } 1192 + 1193 + if (newPreferred !== account.preferred_mfa) { 1194 + this.db.update(t.account).set({ preferred_mfa: newPreferred }).where(eq(t.account.did, did)).run(); 1195 + } 1196 + } 1197 + 1198 + /** 1199 + * get MFA status for an account. 1139 1200 * @param did account did 1140 - * @returns true if at least one MFA credential exists 1201 + * @returns MFA status with preferred method and available methods, or null if no MFA configured 1141 1202 */ 1142 - isMfaEnabled(did: Did): boolean { 1143 - const count = 1144 - this.db 1145 - .select({ count: sql<number>`count(*)` }) 1146 - .from(t.totpCredential) 1147 - .where(eq(t.totpCredential.did, did)) 1148 - .get()?.count ?? 0; 1203 + getMfaStatus(did: Did): MfaStatus | null { 1204 + const account = this.db 1205 + .select({ preferred_mfa: t.account.preferred_mfa }) 1206 + .from(t.account) 1207 + .where(eq(t.account.did, did)) 1208 + .get(); 1209 + 1210 + if (!account || account.preferred_mfa == null) { 1211 + return null; 1212 + } 1149 1213 1150 - return count > 0; 1214 + return { 1215 + preferred: account.preferred_mfa, 1216 + hasTotp: this.#countTotpCredentials(did) > 0, 1217 + hasWebAuthn: this.countWebAuthnCredentials(did) > 0, 1218 + hasRecoveryCodes: this.getRecoveryCodeCount(did) > 0, 1219 + }; 1151 1220 } 1152 1221 1153 1222 /** ··· 1155 1224 * @param did account did 1156 1225 * @returns number of credentials 1157 1226 */ 1158 - countTotpCredentials(did: Did): number { 1227 + #countTotpCredentials(did: Did): number { 1159 1228 return ( 1160 1229 this.db 1161 1230 .select({ count: sql<number>`count(*)` }) ··· 1390 1459 1391 1460 // #endregion 1392 1461 1462 + // #region WebAuthn credentials 1463 + 1464 + /** 1465 + * create a WebAuthn credential for an account. 1466 + * @param options credential options 1467 + * @returns created credential 1468 + */ 1469 + createWebAuthnCredential(options: CreateWebAuthnCredentialOptions): WebauthnCredential { 1470 + const count = this.countWebAuthnCredentials(options.did); 1471 + if (count >= MAX_WEBAUTHN_CREDENTIALS) { 1472 + throw new InvalidRequestError({ 1473 + error: 'TooManyWebAuthnCredentials', 1474 + description: `cannot have more than ${MAX_WEBAUTHN_CREDENTIALS} security keys`, 1475 + }); 1476 + } 1477 + 1478 + const name = options.name?.trim() || this.generateWebAuthnName(options.did, options.type); 1479 + 1480 + // check for duplicate name 1481 + const existing = this.db 1482 + .select() 1483 + .from(t.webauthnCredential) 1484 + .where(and(eq(t.webauthnCredential.did, options.did), eq(t.webauthnCredential.name, name))) 1485 + .get(); 1486 + 1487 + if (existing) { 1488 + throw new InvalidRequestError({ 1489 + error: 'DuplicateWebAuthnName', 1490 + description: `a credential with this name already exists`, 1491 + }); 1492 + } 1493 + 1494 + // check for duplicate credential ID 1495 + const existingCredId = this.db 1496 + .select() 1497 + .from(t.webauthnCredential) 1498 + .where(eq(t.webauthnCredential.credential_id, options.credentialId)) 1499 + .get(); 1500 + 1501 + if (existingCredId) { 1502 + throw new InvalidRequestError({ 1503 + error: 'DuplicateCredentialId', 1504 + description: `this security key is already registered`, 1505 + }); 1506 + } 1507 + 1508 + const inserted = this.db 1509 + .insert(t.webauthnCredential) 1510 + .values({ 1511 + did: options.did, 1512 + type: options.type, 1513 + name: name, 1514 + credential_id: options.credentialId, 1515 + public_key: Buffer.from(options.publicKey), 1516 + counter: options.counter, 1517 + transports: options.transports, 1518 + created_at: new Date(), 1519 + }) 1520 + .returning() 1521 + .get(); 1522 + 1523 + if (!inserted) { 1524 + throw new Error(`failed to create WebAuthn credential`); 1525 + } 1526 + 1527 + // sync preferred MFA (only for security keys, not passkeys) 1528 + if (options.type === WebAuthnCredentialType.SecurityKey) { 1529 + this.#syncPreferredMfa(options.did); 1530 + } 1531 + 1532 + return inserted; 1533 + } 1534 + 1535 + /** 1536 + * list WebAuthn credentials for an account. 1537 + * @param did account did 1538 + * @returns WebAuthn credentials 1539 + */ 1540 + listWebAuthnCredentials(did: Did): WebauthnCredential[] { 1541 + return this.db.select().from(t.webauthnCredential).where(eq(t.webauthnCredential.did, did)).all(); 1542 + } 1543 + 1544 + /** 1545 + * list WebAuthn credentials for an account filtered by type. 1546 + * @param did account did 1547 + * @param type credential type 1548 + * @returns WebAuthn credentials of the specified type 1549 + */ 1550 + listWebAuthnCredentialsByType(did: Did, type: WebAuthnCredentialType): WebauthnCredential[] { 1551 + return this.db 1552 + .select() 1553 + .from(t.webauthnCredential) 1554 + .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.type, type))) 1555 + .all(); 1556 + } 1557 + 1558 + /** 1559 + * get a WebAuthn credential by id. 1560 + * @param did account did 1561 + * @param id credential id 1562 + * @returns WebAuthn credential or null 1563 + */ 1564 + getWebAuthnCredential(did: Did, id: number): WebauthnCredential | null { 1565 + const credential = this.db 1566 + .select() 1567 + .from(t.webauthnCredential) 1568 + .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.id, id))) 1569 + .get(); 1570 + 1571 + return credential ?? null; 1572 + } 1573 + 1574 + /** 1575 + * get a WebAuthn credential by credential ID. 1576 + * @param credentialId base64url credential ID 1577 + * @returns WebAuthn credential or null 1578 + */ 1579 + getWebAuthnCredentialByCredentialId(credentialId: string): WebauthnCredential | null { 1580 + const credential = this.db 1581 + .select() 1582 + .from(t.webauthnCredential) 1583 + .where(eq(t.webauthnCredential.credential_id, credentialId)) 1584 + .get(); 1585 + 1586 + return credential ?? null; 1587 + } 1588 + 1589 + /** 1590 + * delete a WebAuthn credential. 1591 + * @param did account did 1592 + * @param id credential id 1593 + */ 1594 + deleteWebAuthnCredential(did: Did, id: number): void { 1595 + this.db 1596 + .delete(t.webauthnCredential) 1597 + .where(and(eq(t.webauthnCredential.did, did), eq(t.webauthnCredential.id, id))) 1598 + .run(); 1599 + 1600 + this.#syncPreferredMfa(did); 1601 + } 1602 + 1603 + /** 1604 + * count WebAuthn credentials for an account. 1605 + * @param did account did 1606 + * @returns number of credentials 1607 + */ 1608 + countWebAuthnCredentials(did: Did): number { 1609 + return ( 1610 + this.db 1611 + .select({ count: sql<number>`count(*)` }) 1612 + .from(t.webauthnCredential) 1613 + .where(eq(t.webauthnCredential.did, did)) 1614 + .get()?.count ?? 0 1615 + ); 1616 + } 1617 + 1618 + /** 1619 + * update the counter for a WebAuthn credential. 1620 + * @param id credential id 1621 + * @param counter new counter value 1622 + */ 1623 + updateWebAuthnCredentialCounter(id: number, counter: number): void { 1624 + this.db.update(t.webauthnCredential).set({ counter }).where(eq(t.webauthnCredential.id, id)).run(); 1625 + } 1626 + 1627 + /** 1628 + * generate a unique name for a new WebAuthn credential. 1629 + * @param did account did 1630 + * @param type credential type 1631 + * @returns generated name like "Security Key" or "Security Key 2" 1632 + */ 1633 + generateWebAuthnName(did: Did, type: WebAuthnCredentialType): string { 1634 + const existing = this.listWebAuthnCredentialsByType(did, type); 1635 + const baseName = type === WebAuthnCredentialType.SecurityKey ? 'Security Key' : 'Passkey'; 1636 + 1637 + if (existing.length === 0) { 1638 + return baseName; 1639 + } 1640 + 1641 + // find the next available number 1642 + const existingNames = new Set(existing.map((c) => c.name)); 1643 + let num = 2; 1644 + while (existingNames.has(`${baseName} ${num}`)) { 1645 + num++; 1646 + } 1647 + 1648 + return `${baseName} ${num}`; 1649 + } 1650 + 1651 + // #endregion 1652 + 1653 + // #region WebAuthn registration challenges 1654 + 1655 + /** 1656 + * create a WebAuthn registration challenge. 1657 + * @param did account did 1658 + * @param challenge base64url challenge 1659 + * @returns token for retrieving the challenge 1660 + */ 1661 + createWebAuthnChallenge(did: Did, challenge: string): string { 1662 + const token = nanoid(32); 1663 + const now = new Date(); 1664 + const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS); 1665 + 1666 + this.db 1667 + .insert(t.webauthnChallenge) 1668 + .values({ 1669 + token: token, 1670 + did: did, 1671 + challenge: challenge, 1672 + created_at: now, 1673 + expires_at: expiresAt, 1674 + }) 1675 + .run(); 1676 + 1677 + return token; 1678 + } 1679 + 1680 + /** 1681 + * get a WebAuthn registration challenge by token. 1682 + * @param token the token 1683 + * @returns WebAuthn challenge or null if expired/not found 1684 + */ 1685 + getWebAuthnChallenge(token: string): WebauthnChallenge | null { 1686 + const challenge = this.db 1687 + .select() 1688 + .from(t.webauthnChallenge) 1689 + .where(eq(t.webauthnChallenge.token, token)) 1690 + .get(); 1691 + 1692 + if (!challenge) { 1693 + return null; 1694 + } 1695 + 1696 + const now = new Date(); 1697 + if (challenge.expires_at <= now) { 1698 + this.db.delete(t.webauthnChallenge).where(eq(t.webauthnChallenge.token, token)).run(); 1699 + return null; 1700 + } 1701 + 1702 + return challenge; 1703 + } 1704 + 1705 + /** 1706 + * delete a WebAuthn registration challenge. 1707 + * @param token the token 1708 + */ 1709 + deleteWebAuthnChallenge(token: string): void { 1710 + this.db.delete(t.webauthnChallenge).where(eq(t.webauthnChallenge.token, token)).run(); 1711 + } 1712 + 1713 + /** 1714 + * clean up expired WebAuthn registration challenges. 1715 + */ 1716 + cleanupExpiredWebAuthnChallenges(): void { 1717 + const now = new Date(); 1718 + this.db.delete(t.webauthnChallenge).where(lte(t.webauthnChallenge.expires_at, now)).run(); 1719 + } 1720 + 1721 + // #endregion 1722 + 1723 + // #region MFA challenge WebAuthn support 1724 + 1725 + /** 1726 + * set the WebAuthn challenge on an existing MFA challenge. 1727 + * @param token MFA challenge token 1728 + * @param webauthnChallenge base64url WebAuthn challenge 1729 + */ 1730 + setMfaChallengeWebAuthn(token: string, webauthnChallenge: string): void { 1731 + this.db 1732 + .update(t.mfaChallenge) 1733 + .set({ webauthn_challenge: webauthnChallenge }) 1734 + .where(eq(t.mfaChallenge.token, token)) 1735 + .run(); 1736 + } 1737 + 1738 + // #endregion 1739 + 1393 1740 async importAccount(_options: ImportAccountOptions) {} 1394 1741 1395 1742 private resolveIdentifier(identifier: string, options: AccountAvailabilityOptions): Account | null { ··· 1513 1860 secret: Uint8Array; 1514 1861 lastUsedCounter: number; 1515 1862 } 1863 + 1864 + interface CreateWebAuthnCredentialOptions { 1865 + did: Did; 1866 + type: WebAuthnCredentialType; 1867 + name?: string; 1868 + credentialId: string; 1869 + publicKey: Uint8Array; 1870 + counter: number; 1871 + transports?: AuthenticatorTransportFuture[]; 1872 + }
+172
packages/danaus/src/accounts/webauthn.ts
··· 1 + import { 2 + generateAuthenticationOptions, 3 + generateRegistrationOptions, 4 + verifyAuthenticationResponse, 5 + verifyRegistrationResponse, 6 + type AuthenticationResponseJSON, 7 + type AuthenticatorTransportFuture, 8 + type RegistrationResponseJSON, 9 + type VerifiedAuthenticationResponse, 10 + type VerifiedRegistrationResponse, 11 + } from '@simplewebauthn/server'; 12 + 13 + import type { WebauthnCredential } from './manager'; 14 + 15 + // #region constants 16 + 17 + /** maximum WebAuthn credentials per account */ 18 + export const MAX_WEBAUTHN_CREDENTIALS = 10; 19 + 20 + /** WebAuthn challenge TTL in milliseconds (5 minutes) */ 21 + export const WEBAUTHN_CHALLENGE_TTL_MS = 5 * 60 * 1000; 22 + 23 + // #endregion 24 + 25 + // #region registration 26 + 27 + export interface GenerateRegistrationOptionsParams { 28 + /** relying party ID (domain) */ 29 + rpId: string; 30 + /** relying party name */ 31 + rpName: string; 32 + /** user identifier (DID) */ 33 + userId: string; 34 + /** user display name (handle) */ 35 + userName: string; 36 + /** existing credentials to exclude */ 37 + excludeCredentials?: WebauthnCredential[]; 38 + } 39 + 40 + /** 41 + * generates WebAuthn registration options for creating a new security key credential. 42 + * @param params registration parameters 43 + * @returns registration options to send to the client 44 + */ 45 + export const generateWebAuthnRegistrationOptions = async ( 46 + params: GenerateRegistrationOptionsParams, 47 + ) => { 48 + const { rpId, rpName, userId, userName, excludeCredentials = [] } = params; 49 + 50 + return await generateRegistrationOptions({ 51 + rpName, 52 + rpID: rpId, 53 + userName, 54 + userID: new TextEncoder().encode(userId), 55 + attestationType: 'none', 56 + excludeCredentials: excludeCredentials.map((cred) => ({ 57 + id: cred.credential_id, 58 + transports: cred.transports ?? undefined, 59 + })), 60 + authenticatorSelection: { 61 + // non-discoverable for security keys (2FA only) 62 + residentKey: 'discouraged', 63 + // password already verified, no need for PIN/biometric 64 + userVerification: 'discouraged', 65 + }, 66 + }); 67 + }; 68 + 69 + export interface VerifyRegistrationParams { 70 + /** the response from the authenticator */ 71 + response: RegistrationResponseJSON; 72 + /** the expected challenge (base64url) */ 73 + expectedChallenge: string; 74 + /** the expected origin */ 75 + expectedOrigin: string; 76 + /** the expected relying party ID */ 77 + expectedRpId: string; 78 + } 79 + 80 + /** 81 + * verifies a WebAuthn registration response. 82 + * @param params verification parameters 83 + * @returns verification result 84 + */ 85 + export const verifyWebAuthnRegistration = async ( 86 + params: VerifyRegistrationParams, 87 + ): Promise<VerifiedRegistrationResponse> => { 88 + const { response, expectedChallenge, expectedOrigin, expectedRpId } = params; 89 + 90 + return await verifyRegistrationResponse({ 91 + response, 92 + expectedChallenge, 93 + expectedOrigin, 94 + expectedRPID: expectedRpId, 95 + }); 96 + }; 97 + 98 + // #endregion 99 + 100 + // #region authentication 101 + 102 + export interface GenerateAuthenticationOptionsParams { 103 + /** relying party ID (domain) */ 104 + rpId: string; 105 + /** allowed credentials */ 106 + allowCredentials?: WebauthnCredential[]; 107 + } 108 + 109 + /** 110 + * generates WebAuthn authentication options for verifying with an existing credential. 111 + * @param params authentication parameters 112 + * @returns authentication options to send to the client 113 + */ 114 + export const generateWebAuthnAuthenticationOptions = async ( 115 + params: GenerateAuthenticationOptionsParams, 116 + ) => { 117 + const { rpId, allowCredentials = [] } = params; 118 + 119 + return await generateAuthenticationOptions({ 120 + rpID: rpId, 121 + userVerification: 'discouraged', 122 + allowCredentials: allowCredentials.map((cred) => ({ 123 + id: cred.credential_id, 124 + transports: cred.transports ?? undefined, 125 + })), 126 + }); 127 + }; 128 + 129 + export interface VerifyAuthenticationParams { 130 + /** the response from the authenticator */ 131 + response: AuthenticationResponseJSON; 132 + /** the expected challenge (base64url) */ 133 + expectedChallenge: string; 134 + /** the expected origin */ 135 + expectedOrigin: string; 136 + /** the expected relying party ID */ 137 + expectedRpId: string; 138 + /** the credential being verified */ 139 + credential: WebauthnCredential; 140 + } 141 + 142 + /** 143 + * verifies a WebAuthn authentication response. 144 + * @param params verification parameters 145 + * @returns verification result 146 + */ 147 + export const verifyWebAuthnAuthentication = async ( 148 + params: VerifyAuthenticationParams, 149 + ): Promise<VerifiedAuthenticationResponse> => { 150 + const { response, expectedChallenge, expectedOrigin, expectedRpId, credential } = params; 151 + 152 + return await verifyAuthenticationResponse({ 153 + response, 154 + expectedChallenge, 155 + expectedOrigin, 156 + expectedRPID: expectedRpId, 157 + credential: { 158 + id: credential.credential_id, 159 + publicKey: new Uint8Array(credential.public_key), 160 + counter: credential.counter, 161 + transports: credential.transports ?? undefined, 162 + }, 163 + }); 164 + }; 165 + 166 + // #endregion 167 + 168 + // #region types re-export 169 + 170 + export type { AuthenticationResponseJSON, AuthenticatorTransportFuture, RegistrationResponseJSON }; 171 + 172 + // #endregion
+6 -1
packages/danaus/src/env.d.ts
··· 1 1 declare module '*.css' { 2 - const url: string; 2 + const url: URL | string; 3 + export default url; 4 + } 5 + 6 + declare module '*.js' { 7 + const url: URL | string; 3 8 export default url; 4 9 }
+15
packages/danaus/src/jsx.d.ts
··· 1 + import { HTMLAttributes } from '@oomfware/jsx'; 2 + 3 + declare module '@oomfware/jsx' { 4 + namespace JSX { 5 + interface IntrinsicElements { 6 + 'danaus-webauthn-register': HTMLAttributes & { 7 + 'data-options': string; 8 + }; 9 + 'danaus-webauthn-authenticate': HTMLAttributes & { 10 + 'data-options': string; 11 + 'data-auto-submit'?: 'true' | 'false'; 12 + }; 13 + } 14 + } 15 + }
+8 -2
packages/danaus/src/pds-server.ts
··· 3 3 import { createBunWebSocket } from '@atcute/xrpc-server-bun'; 4 4 import { cors } from '@atcute/xrpc-server/middlewares/cors'; 5 5 6 + import webauthnAuthenticateScript from '#web/scripts/webauthn-authenticate.js' with { type: 'file' }; 7 + import webauthnRegisterScript from '#web/scripts/webauthn-register.js' with { type: 'file' }; 8 + import styles from '#web/styles/main.out.css' with { type: 'file' }; 9 + 6 10 import { appBsky } from './api/app.bsky/index.ts'; 7 11 import { comAtproto } from './api/com.atproto/index.ts'; 8 12 import { localDanaus } from './api/local.danaus/index.ts'; 9 13 import type { AppConfig } from './config.ts'; 10 14 import { createAppContext, type AppContext } from './context.ts'; 11 15 import { createWebRouter } from './web/router.ts'; 12 - import styles from './web/styles/main.out.css' with { type: 'file' }; 13 16 14 17 export interface PdsServerOptions { 15 18 config: AppConfig; ··· 117 120 ), 118 121 '/xrpc/*': wrapped.fetch, 119 122 120 - '/assets/style.css': new Response(Bun.file(styles), { headers: { 'cache-control': 'no-cache' } }), 123 + '/assets/style.css': new Response(Bun.file(styles)), 124 + '/assets/webauthn-register.js': new Response(Bun.file(webauthnRegisterScript)), 125 + '/assets/webauthn-authenticate.js': new Response(Bun.file(webauthnAuthenticateScript)), 126 + 121 127 '/*': (request) => web.fetch(request), 122 128 }, 123 129 });
+4
packages/danaus/src/utils/schema.ts
··· 59 59 (input) => typeof input === 'string' && isKeyDid(input), 60 60 `must be a did:key`, 61 61 ); 62 + 63 + export const normalizeWhitespace = v.transform<string, string>((input) => { 64 + return input.replace(/\s+/, ' ').trim(); 65 + });
+2
packages/danaus/src/web/controllers/account/security.tsx
··· 5 5 import overview from './security/overview.tsx'; 6 6 import recovery from './security/recovery.tsx'; 7 7 import totp from './security/totp.tsx'; 8 + import webauthn from './security/webauthn.tsx'; 8 9 9 10 export default { 10 11 middleware: [], 11 12 actions: { 12 13 overview, 13 14 totp, 15 + webauthn, 14 16 recovery, 15 17 }, 16 18 } satisfies Controller<typeof routes.account.security>;
+48 -8
packages/danaus/src/web/controllers/account/security/overview.tsx
··· 1 1 import type { BuildAction } from '@oomfware/fetch-router'; 2 2 import { render } from '@oomfware/jsx'; 3 3 4 - import type { Account, TotpCredential } from '#app/accounts/manager.ts'; 4 + import type { Account, TotpCredential, WebauthnCredential } from '#app/accounts/manager.ts'; 5 + import { WebAuthnCredentialType } from '#app/accounts/db/schema.ts'; 5 6 6 7 import DotGrid1x3HorizontalOutlined from '#web/icons/central/dot-grid-1x3-horizontal-outlined.tsx'; 7 8 import PasskeysOutlined from '#web/icons/central/passkeys-outlined.tsx'; ··· 36 37 const account = accountManager.getAccount(did)!; 37 38 38 39 const totpCredentials = accountManager.listTotpCredentials(did); 39 - const hasTotp = totpCredentials.length > 0; 40 + const securityKeys = accountManager.listWebAuthnCredentialsByType(did, WebAuthnCredentialType.SecurityKey); 41 + const hasMfa = totpCredentials.length > 0 || securityKeys.length > 0; 40 42 41 43 return render( 42 44 <AccountLayout> ··· 50 52 <div class="flex flex-col gap-8"> 51 53 <InformationSection account={account} /> 52 54 53 - <AuthenticationSection account={account} totpCredentials={totpCredentials} /> 55 + <AuthenticationSection 56 + account={account} 57 + totpCredentials={totpCredentials} 58 + securityKeys={securityKeys} 59 + /> 54 60 55 - {hasTotp && <RecoverySection />} 61 + {hasMfa && <RecoverySection />} 56 62 </div> 57 63 </div> 58 64 </AccountLayout>, ··· 98 104 const AuthenticationSection = ({ 99 105 account, 100 106 totpCredentials, 107 + securityKeys, 101 108 }: { 102 109 account: Account; 103 110 totpCredentials: TotpCredential[]; 111 + securityKeys: WebauthnCredential[]; 104 112 }) => { 105 113 return ( 106 114 <div class="flex flex-col gap-2"> ··· 161 169 </div> 162 170 ))} 163 171 164 - {/* Security keys placeholder (future) */} 172 + {/* Security keys */} 173 + {securityKeys.map((key) => ( 174 + <div class="flex items-center gap-4 px-4 py-3"> 175 + <UsbOutlined size={24} class="shrink-0" /> 176 + 177 + <div class="min-w-0 grow"> 178 + <p class="text-base-300 font-medium wrap-break-word">{key.name}</p> 179 + <p class="text-base-300 text-neutral-foreground-3"> 180 + Security key · Added {key.created_at.toLocaleDateString()} 181 + </p> 182 + </div> 183 + 184 + <Menu> 185 + <MenuTrigger> 186 + <button class="grid h-6 w-6 shrink-0 place-items-center rounded-md bg-subtle-background text-neutral-foreground-3 outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover hover:text-neutral-foreground-3-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active active:text-neutral-foreground-3-active"> 187 + <DotGrid1x3HorizontalOutlined size={16} /> 188 + </button> 189 + </MenuTrigger> 190 + 191 + <MenuPopover> 192 + <MenuList> 193 + <MenuItem href={routes.account.security.webauthn.remove.href({ id: key.id })}> 194 + Remove 195 + </MenuItem> 196 + </MenuList> 197 + </MenuPopover> 198 + </Menu> 199 + </div> 200 + ))} 201 + 165 202 {/* Passkeys placeholder (future) */} 166 203 167 204 {/* Add another way to sign in */} ··· 200 237 </div> 201 238 </a> 202 239 203 - <button disabled class="flex items-center gap-4 rounded-md px-4 py-3 text-left opacity-50"> 240 + <a 241 + href={routes.account.security.webauthn.register.href()} 242 + class="flex items-center gap-4 rounded-md px-4 py-3 text-left outline-2 -outline-offset-2 outline-transparent transition hover:bg-subtle-background-hover focus-visible:outline-stroke-focus-2 active:bg-subtle-background-active" 243 + > 204 244 <UsbOutlined size={24} class="shrink-0" /> 205 245 206 246 <div class="min-w-0 grow"> 207 247 <p class="text-base-300 font-medium">Security key</p> 208 248 <p class="text-base-300 text-neutral-foreground-3"> 209 - Use a hardware key like YubiKey (coming soon) 249 + Use a hardware key like YubiKey 210 250 </p> 211 251 </div> 212 - </button> 252 + </a> 213 253 214 254 <button disabled class="flex items-center gap-4 rounded-md px-4 py-3 text-left opacity-50"> 215 255 <PasskeysOutlined size={24} class="shrink-0" />
+3 -3
packages/danaus/src/web/controllers/account/security/recovery.tsx
··· 21 21 const { accountManager } = getAppContext(); 22 22 const session = getSession(); 23 23 24 - if (!accountManager.isMfaEnabled(session.did)) { 24 + if (accountManager.getMfaStatus(session.did) === null) { 25 25 redirect(routes.account.security.overview.href()); 26 26 } 27 27 ··· 75 75 const { accountManager } = getAppContext(); 76 76 const session = getSession(); 77 77 78 - if (!accountManager.isMfaEnabled(session.did)) { 78 + if (accountManager.getMfaStatus(session.did) === null) { 79 79 redirect(routes.account.security.overview.href()); 80 80 } 81 81 ··· 133 133 const { accountManager } = getAppContext(); 134 134 const session = getSession(); 135 135 136 - if (!accountManager.isMfaEnabled(session.did)) { 136 + if (accountManager.getMfaStatus(session.did) === null) { 137 137 redirect(routes.account.security.overview.href()); 138 138 } 139 139
+219
packages/danaus/src/web/controllers/account/security/webauthn.tsx
··· 1 + import { redirect, type Controller } from '@oomfware/fetch-router'; 2 + import { forms } from '@oomfware/forms'; 3 + import { render } from '@oomfware/jsx'; 4 + 5 + import { WebAuthnCredentialType } from '#app/accounts/db/schema.ts'; 6 + import { coerceToInteger } from '#app/web/lib/coerce.ts'; 7 + 8 + import { BaseLayout } from '#web/layouts/base.tsx'; 9 + import { getAppContext } from '#web/middlewares/app-context.ts'; 10 + import { getSession } from '#web/middlewares/session.ts'; 11 + import Button from '#web/primitives/button.tsx'; 12 + import DialogActions from '#web/primitives/dialog-actions.tsx'; 13 + import DialogBody from '#web/primitives/dialog-body.tsx'; 14 + import DialogContent from '#web/primitives/dialog-content.tsx'; 15 + import DialogTitle from '#web/primitives/dialog-title.tsx'; 16 + import Field from '#web/primitives/field.tsx'; 17 + import Input from '#web/primitives/input.tsx'; 18 + import { routes } from '#web/routes.ts'; 19 + 20 + import { 21 + completeWebAuthnForm, 22 + initiateWebAuthnRegistration, 23 + removeWebAuthnForm, 24 + } from './webauthn/lib/forms'; 25 + 26 + export default { 27 + middleware: [], 28 + actions: { 29 + register: { 30 + middleware: [forms({ completeWebAuthnForm })], 31 + async action({ url }) { 32 + const { accountManager } = getAppContext(); 33 + const session = getSession(); 34 + 35 + // require sudo mode 36 + if (!accountManager.isSessionElevated(session)) { 37 + redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 38 + } 39 + 40 + const account = accountManager.getAccount(session.did)!; 41 + 42 + const { fields } = completeWebAuthnForm; 43 + 44 + // check if we have an existing token (form was submitted but failed) 45 + let token = fields.token.value(); 46 + let options; 47 + 48 + if (token) { 49 + // try to get existing challenge 50 + const existingChallenge = accountManager.getWebAuthnChallenge(token); 51 + if (existingChallenge) { 52 + // regenerate options with the same challenge 53 + const state = await initiateWebAuthnRegistration( 54 + session.did, 55 + account.handle ?? session.did, 56 + ); 57 + // delete old challenge and use new one 58 + accountManager.deleteWebAuthnChallenge(token); 59 + token = state.token; 60 + options = state.options; 61 + } 62 + } 63 + 64 + if (!options) { 65 + // generate new registration 66 + const state = await initiateWebAuthnRegistration( 67 + session.did, 68 + account.handle ?? session.did, 69 + ); 70 + token = state.token; 71 + options = state.options; 72 + } 73 + 74 + const generalError = fields.issues()?.at(0); 75 + 76 + return render( 77 + <BaseLayout> 78 + <title>Set up security key - Danaus</title> 79 + 80 + <script type="module" src={routes.assets.href({ path: 'webauthn-register.js' })} /> 81 + 82 + <div class="flex flex-1 items-center justify-center p-4"> 83 + <div class="w-full max-w-120 rounded-xl bg-neutral-background-1 shadow-64"> 84 + <form {...completeWebAuthnForm} class="contents"> 85 + <DialogBody> 86 + <DialogTitle>Set up security key</DialogTitle> 87 + 88 + <DialogContent class="flex flex-col gap-4"> 89 + <p class="text-base-300"> 90 + Insert your security key and follow your browser's prompts to register it. 91 + </p> 92 + 93 + <input {...fields.token.as('hidden', token!)} /> 94 + 95 + <danaus-webauthn-register data-options={JSON.stringify(options)}> 96 + <p 97 + data-target="webauthn-register.status" 98 + class="text-base-300 text-neutral-foreground-3" 99 + > 100 + Initializing... 101 + </p> 102 + 103 + <input 104 + {...fields.response.as('hidden', '')} 105 + data-target="webauthn-register.response" 106 + /> 107 + 108 + <Field 109 + label="Name" 110 + hint="Give this security key a name to help you identify it" 111 + validationMessageText={fields.name.issues()?.at(0)?.message} 112 + > 113 + <Input 114 + {...fields.name.as('text')} 115 + placeholder={accountManager.generateWebAuthnName( 116 + session.did, 117 + WebAuthnCredentialType.SecurityKey, 118 + )} 119 + /> 120 + </Field> 121 + </danaus-webauthn-register> 122 + 123 + {generalError && ( 124 + <p role="alert" class="text-base-300 text-status-danger-foreground-1"> 125 + {generalError.message} 126 + </p> 127 + )} 128 + </DialogContent> 129 + 130 + <DialogActions> 131 + <Button type="button" href={routes.account.security.overview.href()}> 132 + Cancel 133 + </Button> 134 + 135 + <Button 136 + type="submit" 137 + variant="primary" 138 + disabled 139 + data-target="webauthn-register.submit" 140 + > 141 + Save 142 + </Button> 143 + </DialogActions> 144 + </DialogBody> 145 + </form> 146 + </div> 147 + </div> 148 + </BaseLayout>, 149 + ); 150 + }, 151 + }, 152 + remove: { 153 + middleware: [forms({ removeWebAuthnForm })], 154 + action({ url, params }) { 155 + const { accountManager } = getAppContext(); 156 + const session = getSession(); 157 + 158 + const id = coerceToInteger(params.id); 159 + if (id === null) { 160 + redirect(routes.account.security.overview.href()); 161 + } 162 + 163 + const credential = accountManager.getWebAuthnCredential(session.did, id); 164 + if (credential === null) { 165 + redirect(routes.account.security.overview.href()); 166 + } 167 + 168 + // require sudo mode 169 + if (!accountManager.isSessionElevated(session)) { 170 + redirect(routes.login.sudo.index.href(undefined, { redirect: url.pathname })); 171 + } 172 + 173 + const { fields } = removeWebAuthnForm; 174 + 175 + const error = fields.allIssues()?.at(0); 176 + 177 + return render( 178 + <BaseLayout> 179 + <title>Remove "{credential.name}" security key? - Danaus</title> 180 + 181 + <div class="flex flex-1 items-center justify-center p-4"> 182 + <div class="w-full max-w-120 rounded-xl bg-neutral-background-1 shadow-64"> 183 + <form {...removeWebAuthnForm} class="contents"> 184 + <input {...fields.id.as('hidden', params.id)} /> 185 + 186 + <DialogBody> 187 + <DialogTitle>Remove this security key?</DialogTitle> 188 + 189 + <DialogContent> 190 + <p class="text-base-300"> 191 + You'll no longer be able to use "{credential.name}" to sign in. 192 + </p> 193 + 194 + {error && ( 195 + <p role="alert" class="text-base-300 text-status-danger-foreground-1"> 196 + {error.message} 197 + </p> 198 + )} 199 + </DialogContent> 200 + 201 + <DialogActions> 202 + <Button type="button" href={routes.account.security.overview.href()}> 203 + Cancel 204 + </Button> 205 + 206 + <Button type="submit" variant="primary"> 207 + Remove 208 + </Button> 209 + </DialogActions> 210 + </DialogBody> 211 + </form> 212 + </div> 213 + </div> 214 + </BaseLayout>, 215 + ); 216 + }, 217 + }, 218 + }, 219 + } satisfies Controller<typeof routes.account.security.webauthn>;
+173
packages/danaus/src/web/controllers/account/security/webauthn/lib/forms.ts
··· 1 + import type { Did } from '@atcute/lexicons/syntax'; 2 + import { XRPCError } from '@atcute/xrpc-server'; 3 + import { redirect } from '@oomfware/fetch-router'; 4 + import { form, invalid } from '@oomfware/forms'; 5 + 6 + import * as v from 'valibot'; 7 + 8 + import { WebAuthnCredentialType } from '#app/accounts/db/schema.ts'; 9 + import { 10 + generateWebAuthnRegistrationOptions, 11 + verifyWebAuthnRegistration, 12 + } from '#app/accounts/webauthn.ts'; 13 + import { requireSudo } from '#app/web/lib/forms.ts'; 14 + 15 + import { getAppContext } from '#web/middlewares/app-context.ts'; 16 + import { getSession } from '#web/middlewares/session.ts'; 17 + import { routes } from '#web/routes.ts'; 18 + 19 + export interface WebAuthnRegistrationState { 20 + token: string; 21 + options: Awaited<ReturnType<typeof generateWebAuthnRegistrationOptions>>; 22 + } 23 + 24 + // valibot schema for WebAuthn registration response 25 + const authenticatorTransportSchema = v.picklist([ 26 + 'ble', 27 + 'cable', 28 + 'hybrid', 29 + 'internal', 30 + 'nfc', 31 + 'smart-card', 32 + 'usb', 33 + ]); 34 + 35 + const authenticatorAttachmentSchema = v.picklist(['cross-platform', 'platform']); 36 + 37 + const registrationResponseSchema = v.object({ 38 + id: v.string(), 39 + rawId: v.string(), 40 + type: v.literal('public-key'), 41 + response: v.object({ 42 + clientDataJSON: v.string(), 43 + attestationObject: v.string(), 44 + transports: v.optional(v.array(authenticatorTransportSchema)), 45 + }), 46 + clientExtensionResults: v.record(v.string(), v.unknown()), 47 + authenticatorAttachment: v.optional(authenticatorAttachmentSchema), 48 + }); 49 + 50 + /** 51 + * initiates WebAuthn registration by generating a challenge. 52 + * @param did account DID 53 + * @param userName user display name (handle) 54 + * @returns registration state with token and options 55 + */ 56 + export const initiateWebAuthnRegistration = async ( 57 + did: Did, 58 + userName: string, 59 + ): Promise<WebAuthnRegistrationState> => { 60 + const { accountManager, config } = getAppContext(); 61 + 62 + const existingCredentials = accountManager.listWebAuthnCredentials(did); 63 + 64 + const options = await generateWebAuthnRegistrationOptions({ 65 + rpId: config.service.hostname, 66 + rpName: config.service.branding.name, 67 + userId: did, 68 + userName: userName, 69 + excludeCredentials: existingCredentials, 70 + }); 71 + 72 + // store the challenge 73 + const token = accountManager.createWebAuthnChallenge(did, options.challenge); 74 + 75 + return { token, options }; 76 + }; 77 + 78 + /** 79 + * completes WebAuthn registration by verifying the response and storing the credential. 80 + */ 81 + export const completeWebAuthnForm = form( 82 + v.object({ 83 + token: v.pipe(v.string(), v.minLength(1)), 84 + name: v.optional(v.pipe(v.string(), v.maxLength(32, `Name is too long`))), 85 + response: v.pipe(v.string(), v.minLength(1), v.parseJson(), registrationResponseSchema), 86 + }), 87 + async (data, issue) => { 88 + const { accountManager, config } = getAppContext(); 89 + const { did } = getSession(); 90 + 91 + // get the challenge 92 + const challenge = accountManager.getWebAuthnChallenge(data.token); 93 + if (!challenge) { 94 + invalid(`Registration expired, please try again`); 95 + } 96 + 97 + if (challenge.did !== did) { 98 + invalid(`Invalid registration`); 99 + } 100 + 101 + // verify the registration 102 + let verification; 103 + try { 104 + verification = await verifyWebAuthnRegistration({ 105 + response: data.response, 106 + expectedChallenge: challenge.challenge, 107 + expectedOrigin: config.service.publicUrl, 108 + expectedRpId: config.service.hostname, 109 + }); 110 + } catch (err) { 111 + console.error('WebAuthn verification error:', err); 112 + invalid(`Registration failed, please try again`); 113 + } 114 + 115 + if (!verification.verified || !verification.registrationInfo) { 116 + invalid(`Registration failed, please try again`); 117 + } 118 + 119 + // delete the challenge 120 + accountManager.deleteWebAuthnChallenge(data.token); 121 + 122 + requireSudo(); 123 + 124 + // store the credential 125 + const { registrationInfo } = verification; 126 + try { 127 + accountManager.createWebAuthnCredential({ 128 + did: did, 129 + type: WebAuthnCredentialType.SecurityKey, 130 + name: data.name, 131 + credentialId: registrationInfo.credential.id, 132 + publicKey: registrationInfo.credential.publicKey, 133 + counter: registrationInfo.credential.counter, 134 + transports: registrationInfo.credential.transports, 135 + }); 136 + } catch (err) { 137 + if (err instanceof XRPCError && err.status === 400) { 138 + switch (err.error) { 139 + case 'DuplicateWebAuthnName': { 140 + invalid(issue.name(`A security key with this name already exists`)); 141 + } 142 + case 'DuplicateCredentialId': { 143 + invalid(`This security key is already registered`); 144 + } 145 + case 'TooManyWebAuthnCredentials': { 146 + invalid(`You've reached the maximum number of security keys allowed`); 147 + } 148 + } 149 + } 150 + throw err; 151 + } 152 + 153 + redirect(routes.account.security.overview.href()); 154 + }, 155 + ); 156 + 157 + /** 158 + * removes a WebAuthn credential. requires sudo mode. 159 + */ 160 + export const removeWebAuthnForm = form( 161 + v.object({ 162 + id: v.pipe(v.string(), v.toNumber(), v.safeInteger()), 163 + }), 164 + async (data) => { 165 + const { accountManager } = getAppContext(); 166 + const { did } = getSession(); 167 + 168 + requireSudo(); 169 + accountManager.deleteWebAuthnCredential(did, data.id); 170 + 171 + redirect(routes.account.security.overview.href()); 172 + }, 173 + );
+177 -4
packages/danaus/src/web/controllers/login/lib/forms.ts
··· 15 15 import { getSession } from '#web/middlewares/session.ts'; 16 16 import { routes } from '#web/routes.ts'; 17 17 18 - export type AuthFactor = 'totp' | 'recovery' | 'password'; 18 + export type AuthFactor = 'totp' | 'recovery' | 'password' | 'webauthn'; 19 19 20 20 interface VerifyFactorOptions { 21 21 did: Did; ··· 129 129 accountManager.cleanupExpiredMfaChallenges(); 130 130 131 131 // check if MFA is enabled 132 - if (accountManager.isMfaEnabled(account.did)) { 132 + if (accountManager.getMfaStatus(account.did) !== null) { 133 133 // create MFA challenge and redirect 134 134 const token = accountManager.createMfaChallenge(account.did); 135 135 ··· 196 196 }, 197 197 ); 198 198 199 - const SUDO_ALLOWED_MFA_FACTORS: AuthFactor[] = ['totp', 'recovery']; 199 + const authenticationResponseSchema = v.object({ 200 + id: v.string(), 201 + rawId: v.string(), 202 + response: v.object({ 203 + clientDataJSON: v.string(), 204 + authenticatorData: v.string(), 205 + signature: v.string(), 206 + userHandle: v.optional(v.string()), 207 + }), 208 + authenticatorAttachment: v.optional(v.picklist(['cross-platform', 'platform'])), 209 + clientExtensionResults: v.object({ 210 + appid: v.optional(v.boolean()), 211 + credProps: v.optional( 212 + v.object({ 213 + rk: v.optional(v.boolean()), 214 + }), 215 + ), 216 + hmacCreateSecret: v.optional(v.boolean()), 217 + }), 218 + type: v.literal('public-key'), 219 + }); 220 + 221 + export const verifyWebAuthnMfaForm = form( 222 + v.object({ 223 + challenge: v.string(), 224 + response: v.pipe(v.string(), v.minLength(1), v.parseJson(), authenticationResponseSchema), 225 + remember: v.optional(v.boolean(), false), 226 + redirect: v.string(), 227 + }), 228 + async (data) => { 229 + const { accountManager, config } = getAppContext(); 230 + const { request } = getContext(); 231 + 232 + const mfaChallenge = accountManager.getMfaChallenge(data.challenge); 233 + if (mfaChallenge === null) { 234 + redirect(routes.login.show.href(undefined, { redirect: data.redirect })); 235 + } 236 + 237 + if (!mfaChallenge.webauthn_challenge) { 238 + invalid(`WebAuthn not initiated for this session`); 239 + } 240 + 241 + // find the credential being used 242 + const credential = accountManager.getWebAuthnCredentialByCredentialId(data.response.id); 243 + if (credential === null || credential.did !== mfaChallenge.did) { 244 + invalid(`Invalid security key`); 245 + } 246 + 247 + // verify the authentication response 248 + const { verifyWebAuthnAuthentication } = await import('#app/accounts/webauthn.ts'); 249 + 250 + try { 251 + const verification = await verifyWebAuthnAuthentication({ 252 + response: data.response, 253 + expectedChallenge: mfaChallenge.webauthn_challenge, 254 + expectedOrigin: config.service.publicUrl, 255 + expectedRpId: new URL(config.service.publicUrl).hostname, 256 + credential, 257 + }); 258 + 259 + if (!verification.verified) { 260 + invalid(`Security key verification failed`); 261 + } 262 + 263 + // update counter 264 + accountManager.updateWebAuthnCredentialCounter( 265 + credential.id, 266 + verification.authenticationInfo.newCounter, 267 + ); 268 + } catch { 269 + invalid(`Security key verification failed`); 270 + } 271 + 272 + accountManager.deleteMfaChallenge(data.challenge); 273 + 274 + const { session, token } = await accountManager.createWebSession({ 275 + did: mfaChallenge.did, 276 + remember: data.remember ?? false, 277 + userAgent: request.headers.get('user-agent') ?? undefined, 278 + }); 279 + 280 + setWebSessionToken(request, token, { 281 + expires: session.expires_at, 282 + httpOnly: true, 283 + sameSite: 'lax', 284 + path: '/', 285 + }); 286 + 287 + redirect(data.redirect); 288 + }, 289 + ); 290 + 291 + const SUDO_ALLOWED_MFA_FACTORS: AuthFactor[] = ['totp', 'webauthn', 'recovery']; 200 292 const SUDO_ALLOWED_OFA_FACTORS: AuthFactor[] = ['password']; 201 293 202 294 export const verifySudoForm = form( ··· 210 302 const session = getSession(); 211 303 212 304 // determine allowed factors based on MFA status 213 - const hasMfa = accountManager.isMfaEnabled(session.did); 305 + const hasMfa = accountManager.getMfaStatus(session.did) !== null; 214 306 const allowedFactors = hasMfa ? SUDO_ALLOWED_MFA_FACTORS : SUDO_ALLOWED_OFA_FACTORS; 215 307 216 308 await verifyFactor({ ··· 225 317 redirect(data.redirect); 226 318 }, 227 319 ); 320 + 321 + const sudoAuthenticationResponseSchema = v.object({ 322 + id: v.string(), 323 + rawId: v.string(), 324 + response: v.object({ 325 + clientDataJSON: v.string(), 326 + authenticatorData: v.string(), 327 + signature: v.string(), 328 + userHandle: v.optional(v.string()), 329 + }), 330 + authenticatorAttachment: v.optional(v.picklist(['cross-platform', 'platform'])), 331 + clientExtensionResults: v.object({ 332 + appid: v.optional(v.boolean()), 333 + credProps: v.optional( 334 + v.object({ 335 + rk: v.optional(v.boolean()), 336 + }), 337 + ), 338 + hmacCreateSecret: v.optional(v.boolean()), 339 + }), 340 + type: v.literal('public-key'), 341 + }); 342 + 343 + export const verifyWebAuthnSudoForm = form( 344 + v.object({ 345 + challenge: v.string(), 346 + response: v.pipe(v.string(), v.minLength(1), v.parseJson(), sudoAuthenticationResponseSchema), 347 + redirect: v.pipe(v.string(), v.minLength(1)), 348 + }), 349 + async (data) => { 350 + const { accountManager, config } = getAppContext(); 351 + const session = getSession(); 352 + 353 + if (accountManager.getMfaStatus(session.did) === null) { 354 + redirect(routes.login.sudo.index.href(undefined, { redirect: data.redirect })); 355 + } 356 + 357 + const sudoChallenge = accountManager.getWebAuthnChallenge(data.challenge); 358 + if (sudoChallenge === null || sudoChallenge.did !== session.did) { 359 + invalid(`Invalid or expired challenge`); 360 + } 361 + 362 + // find the credential being used 363 + const credential = accountManager.getWebAuthnCredentialByCredentialId(data.response.id); 364 + if (credential === null || credential.did !== session.did) { 365 + invalid(`Invalid security key`); 366 + } 367 + 368 + // verify the authentication response 369 + const { verifyWebAuthnAuthentication } = await import('#app/accounts/webauthn.ts'); 370 + 371 + try { 372 + const verification = await verifyWebAuthnAuthentication({ 373 + response: data.response, 374 + expectedChallenge: sudoChallenge.challenge, 375 + expectedOrigin: config.service.publicUrl, 376 + expectedRpId: new URL(config.service.publicUrl).hostname, 377 + credential, 378 + }); 379 + 380 + if (!verification.verified) { 381 + invalid(`Security key verification failed`); 382 + } 383 + 384 + // update counter 385 + accountManager.updateWebAuthnCredentialCounter( 386 + credential.id, 387 + verification.authenticationInfo.newCounter, 388 + ); 389 + } catch { 390 + invalid(`Security key verification failed`); 391 + } 392 + 393 + // clean up the challenge 394 + accountManager.deleteWebAuthnChallenge(data.challenge); 395 + 396 + // elevate session and redirect 397 + accountManager.elevateSession(session.id); 398 + redirect(data.redirect); 399 + }, 400 + );
+192 -40
packages/danaus/src/web/controllers/login/mfa.tsx
··· 2 2 import { forms } from '@oomfware/forms'; 3 3 import { render, type JSXNode } from '@oomfware/jsx'; 4 4 5 + import { PreferredMfa } from '#app/accounts/db/schema.ts'; 6 + import type { MfaStatus } from '#app/accounts/manager.ts'; 5 7 import { 6 8 RECOVERY_CODE_LENGTH, 7 9 RECOVERY_CODE_RE, 8 10 TOTP_CODE_LENGTH, 9 11 TOTP_CODE_RE, 10 12 } from '#app/accounts/totp.ts'; 13 + import { generateWebAuthnAuthenticationOptions } from '#app/accounts/webauthn.ts'; 11 14 12 15 import { BaseLayout } from '#web/layouts/base.tsx'; 13 16 import { getAppContext } from '#web/middlewares/app-context.ts'; ··· 21 24 import Menu from '#web/primitives/menu.tsx'; 22 25 import { routes } from '#web/routes.ts'; 23 26 24 - import { verifyMfaLoginForm, type AuthFactor } from './lib/forms.ts'; 27 + import { verifyMfaLoginForm, verifyWebAuthnMfaForm, type AuthFactor } from './lib/forms.ts'; 25 28 26 29 export default { 27 - middleware: [forms({ verifyMfaLoginForm })], 30 + middleware: [forms({ verifyMfaLoginForm, verifyWebAuthnMfaForm })], 28 31 actions: { 29 32 index({ url }) { 30 33 const { accountManager } = getAppContext(); 31 34 32 35 const redirectUrl = url.searchParams.get('redirect'); 33 - const challenge = url.searchParams.get('token'); 34 - if (challenge === null || accountManager.getMfaChallenge(challenge) === null) { 36 + const token = url.searchParams.get('token'); 37 + if (token === null) { 38 + redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 39 + } 40 + 41 + const mfaChallenge = accountManager.getMfaChallenge(token); 42 + if (mfaChallenge === null) { 43 + redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 44 + } 45 + 46 + const mfaStatus = accountManager.getMfaStatus(mfaChallenge.did); 47 + if (mfaStatus === null) { 35 48 redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 36 49 } 37 50 38 - redirect(routes.login.mfa.totp.href(undefined, { token: challenge, redirect: redirectUrl })); 51 + switch (mfaStatus.preferred) { 52 + case PreferredMfa.WebAuthn: { 53 + redirect(routes.login.mfa.webauthn.href(undefined, { token: token, redirect: redirectUrl })); 54 + } 55 + case PreferredMfa.Totp: { 56 + redirect(routes.login.mfa.totp.href(undefined, { token: token, redirect: redirectUrl })); 57 + } 58 + } 39 59 }, 40 60 totp({ url }) { 41 61 const { accountManager } = getAppContext(); ··· 44 64 45 65 const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 46 66 const challenge = url.searchParams.get('token') ?? fields.challenge.value(); 47 - if (challenge == null || accountManager.getMfaChallenge(challenge) === null) { 67 + if (challenge == null) { 68 + redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 69 + } 70 + 71 + const mfaChallenge = accountManager.getMfaChallenge(challenge); 72 + if (mfaChallenge === null) { 73 + redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 74 + } 75 + 76 + const mfaStatus = accountManager.getMfaStatus(mfaChallenge.did); 77 + if (mfaStatus === null) { 48 78 redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 49 79 } 50 80 51 81 return render( 52 - <BaseForm factor="totp" challenge={challenge} redirectUrl={redirectUrl}> 82 + <BaseForm factor="totp" challenge={challenge} redirectUrl={redirectUrl} mfaStatus={mfaStatus}> 53 83 <div class="flex flex-col gap-2"> 54 84 <h1 class="text-base-500 font-semibold">Two-factor authentication</h1> 55 85 <p class="text-base-300 text-neutral-foreground-3"> ··· 77 107 </BaseForm>, 78 108 ); 79 109 }, 80 - webauthn() { 81 - redirect(routes.login.show.href()); 110 + async webauthn({ url }) { 111 + const { accountManager, config } = getAppContext(); 112 + 113 + const { fields } = verifyWebAuthnMfaForm; 114 + 115 + const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 116 + const challenge = url.searchParams.get('token') ?? fields.challenge.value(); 117 + if (challenge == null || accountManager.getMfaChallenge(challenge) === null) { 118 + redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 119 + } 120 + 121 + const mfaChallenge = accountManager.getMfaChallenge(challenge)!; 122 + 123 + // get user's WebAuthn credentials (security keys or passkeys) 124 + const webauthnCredentials = accountManager.listWebAuthnCredentials(mfaChallenge.did); 125 + if (webauthnCredentials.length === 0) { 126 + // no WebAuthn credentials, redirect to TOTP 127 + redirect(routes.login.mfa.totp.href(undefined, { token: challenge, redirect: redirectUrl })); 128 + } 129 + 130 + // generate authentication options 131 + const options = await generateWebAuthnAuthenticationOptions({ 132 + rpId: new URL(config.service.publicUrl).hostname, 133 + allowCredentials: webauthnCredentials, 134 + }); 135 + 136 + // store the challenge for verification 137 + accountManager.setMfaChallengeWebAuthn(challenge, options.challenge); 138 + 139 + return render( 140 + <BaseLayout> 141 + <title>Two-factor authentication - Danaus</title> 142 + 143 + <script src="/assets/webauthn-authenticate.js" type="module" /> 144 + 145 + <div class="flex flex-1 items-center justify-center p-4"> 146 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 147 + <form {...verifyWebAuthnMfaForm} class="flex flex-col gap-6"> 148 + <input {...fields.challenge.as('hidden', challenge)} /> 149 + <input {...fields.redirect.as('hidden', redirectUrl ?? routes.account.overview.href())} /> 150 + 151 + <div class="flex flex-col gap-2"> 152 + <h1 class="text-base-500 font-semibold">Two-factor authentication</h1> 153 + <p class="text-base-300 text-neutral-foreground-3"> 154 + Insert your security key and touch it to verify your identity. 155 + </p> 156 + </div> 157 + 158 + <danaus-webauthn-authenticate data-options={JSON.stringify(options)}> 159 + <input {...fields.response.as('hidden', '')} data-target="webauthn-authenticate.response" /> 160 + 161 + <Button data-target="webauthn-authenticate.start" type="button" variant="primary"> 162 + Use security key 163 + </Button> 164 + 165 + <div 166 + data-target="webauthn-authenticate.status" 167 + class="text-center text-base-300 text-neutral-foreground-3" 168 + /> 169 + </danaus-webauthn-authenticate> 170 + 171 + <Menu> 172 + <MenuTrigger> 173 + <Button>Show other methods</Button> 174 + </MenuTrigger> 175 + 176 + <MenuPopover> 177 + <MenuList> 178 + <MenuItem 179 + href={routes.login.mfa.totp.href(undefined, { 180 + token: challenge, 181 + redirect: redirectUrl, 182 + })} 183 + > 184 + Use authenticator app 185 + </MenuItem> 186 + 187 + <MenuItem 188 + href={routes.login.mfa.recovery.href(undefined, { 189 + token: challenge, 190 + redirect: redirectUrl, 191 + })} 192 + > 193 + Use 2FA recovery code 194 + </MenuItem> 195 + </MenuList> 196 + </MenuPopover> 197 + </Menu> 198 + </form> 199 + </div> 200 + </div> 201 + </BaseLayout>, 202 + ); 82 203 }, 83 204 recovery({ url }) { 84 205 const { accountManager } = getAppContext(); ··· 87 208 88 209 const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 89 210 const challenge = url.searchParams.get('token') ?? fields.challenge.value(); 90 - if (challenge == null || accountManager.getMfaChallenge(challenge) === null) { 211 + if (challenge == null) { 212 + redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 213 + } 214 + 215 + const mfaChallenge = accountManager.getMfaChallenge(challenge); 216 + if (mfaChallenge === null) { 217 + redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 218 + } 219 + 220 + const mfaStatus = accountManager.getMfaStatus(mfaChallenge.did); 221 + if (mfaStatus === null) { 91 222 redirect(routes.login.show.href(undefined, { redirect: redirectUrl })); 92 223 } 93 224 94 225 return render( 95 - <BaseForm factor="recovery" challenge={challenge} redirectUrl={redirectUrl}> 226 + <BaseForm factor="recovery" challenge={challenge} redirectUrl={redirectUrl} mfaStatus={mfaStatus}> 96 227 <div class="flex flex-col gap-2"> 97 228 <h1 class="text-base-500 font-semibold">Two-factor authentication</h1> 98 229 <p class="text-base-300 text-neutral-foreground-3"> ··· 125 256 factor: AuthFactor; 126 257 challenge: string; 127 258 redirectUrl: string | undefined; 259 + mfaStatus: MfaStatus; 128 260 children: JSXNode; 129 261 }) => { 130 262 const { fields } = verifyMfaLoginForm; 131 263 132 264 const challenge = props.challenge; 133 265 const redirectUrl = props.redirectUrl ?? routes.account.overview.href(); 266 + const { mfaStatus } = props; 267 + 268 + // count how many other methods are available 269 + const otherMethodsCount = 270 + (props.factor !== 'webauthn' && mfaStatus.hasWebAuthn ? 1 : 0) + 271 + (props.factor !== 'totp' && mfaStatus.hasTotp ? 1 : 0) + 272 + (props.factor !== 'recovery' && mfaStatus.hasRecoveryCodes ? 1 : 0); 134 273 135 274 return ( 136 275 <BaseLayout> ··· 145 284 146 285 {props.children} 147 286 148 - <Menu> 149 - <MenuTrigger> 150 - <Button>Show other methods</Button> 151 - </MenuTrigger> 287 + {otherMethodsCount > 0 && ( 288 + <Menu> 289 + <MenuTrigger> 290 + <Button>Show other methods</Button> 291 + </MenuTrigger> 152 292 153 - <MenuPopover> 154 - <MenuList> 155 - {props.factor !== 'totp' && ( 156 - <MenuItem 157 - href={routes.login.mfa.totp.href(undefined, { 158 - token: challenge, 159 - redirect: redirectUrl, 160 - })} 161 - > 162 - Use authenticator app 163 - </MenuItem> 164 - )} 293 + <MenuPopover> 294 + <MenuList> 295 + {props.factor !== 'webauthn' && mfaStatus.hasWebAuthn && ( 296 + <MenuItem 297 + href={routes.login.mfa.webauthn.href(undefined, { 298 + token: challenge, 299 + redirect: redirectUrl, 300 + })} 301 + > 302 + Use security key 303 + </MenuItem> 304 + )} 165 305 166 - {props.factor !== 'recovery' && ( 167 - <MenuItem 168 - href={routes.login.mfa.recovery.href(undefined, { 169 - token: challenge, 170 - redirect: redirectUrl, 171 - })} 172 - > 173 - Use 2FA recovery code 174 - </MenuItem> 175 - )} 176 - </MenuList> 177 - </MenuPopover> 178 - </Menu> 306 + {props.factor !== 'totp' && mfaStatus.hasTotp && ( 307 + <MenuItem 308 + href={routes.login.mfa.totp.href(undefined, { 309 + token: challenge, 310 + redirect: redirectUrl, 311 + })} 312 + > 313 + Use authenticator app 314 + </MenuItem> 315 + )} 316 + 317 + {props.factor !== 'recovery' && mfaStatus.hasRecoveryCodes && ( 318 + <MenuItem 319 + href={routes.login.mfa.recovery.href(undefined, { 320 + token: challenge, 321 + redirect: redirectUrl, 322 + })} 323 + > 324 + Use 2FA recovery code 325 + </MenuItem> 326 + )} 327 + </MenuList> 328 + </MenuPopover> 329 + </Menu> 330 + )} 179 331 </form> 180 332 </div> 181 333 </div>
+119 -15
packages/danaus/src/web/controllers/login/sudo.tsx
··· 2 2 import { forms } from '@oomfware/forms'; 3 3 import { render, type JSXNode } from '@oomfware/jsx'; 4 4 5 + import { PreferredMfa } from '#app/accounts/db/schema.ts'; 5 6 import { 6 7 RECOVERY_CODE_LENGTH, 7 8 RECOVERY_CODE_RE, 8 9 TOTP_CODE_LENGTH, 9 10 TOTP_CODE_RE, 10 11 } from '#app/accounts/totp.ts'; 12 + import { generateWebAuthnAuthenticationOptions } from '#app/accounts/webauthn.ts'; 11 13 12 14 import { BaseLayout } from '#web/layouts/base.tsx'; 13 15 import { getAppContext } from '#web/middlewares/app-context.ts'; ··· 22 24 import Menu from '#web/primitives/menu.tsx'; 23 25 import { routes } from '#web/routes.ts'; 24 26 25 - import { verifySudoForm, type AuthFactor } from './lib/forms.ts'; 27 + import { verifySudoForm, verifyWebAuthnSudoForm, type AuthFactor } from './lib/forms.ts'; 26 28 27 29 export default { 28 - middleware: [requireSession(), forms({ verifySudoForm })], 30 + middleware: [requireSession(), forms({ verifySudoForm, verifyWebAuthnSudoForm })], 29 31 actions: { 30 - index({ url }) { 32 + index({ url }): never { 31 33 const { accountManager } = getAppContext(); 32 34 const session = getSession(); 33 35 ··· 41 43 redirect(redirectUrl); 42 44 } 43 45 44 - const hasMfa = accountManager.isMfaEnabled(session.did); 45 - if (hasMfa) { 46 - // TODO: redirect to preferred MFA 47 - redirect(routes.login.sudo.totp.href(undefined, { redirect: redirectUrl })); 48 - } else { 46 + const mfaStatus = accountManager.getMfaStatus(session.did); 47 + if (mfaStatus === null) { 49 48 redirect(routes.login.sudo.password.href(undefined, { redirect: redirectUrl })); 50 49 } 50 + 51 + switch (mfaStatus.preferred) { 52 + case PreferredMfa.WebAuthn: { 53 + redirect(routes.login.sudo.webauthn.href(undefined, { redirect: redirectUrl })); 54 + } 55 + case PreferredMfa.Totp: { 56 + redirect(routes.login.sudo.totp.href(undefined, { redirect: redirectUrl })); 57 + } 58 + } 51 59 }, 52 60 totp({ url }) { 53 61 const { accountManager } = getAppContext(); ··· 60 68 redirect(routes.account.overview.href()); 61 69 } 62 70 63 - if (!accountManager.isMfaEnabled(session.did)) { 71 + if (accountManager.getMfaStatus(session.did) === null) { 64 72 redirect(routes.login.sudo.index.href(undefined, { redirect: redirectUrl })); 65 73 } 66 74 ··· 104 112 redirect(routes.account.overview.href()); 105 113 } 106 114 107 - if (!accountManager.isMfaEnabled(session.did)) { 115 + if (accountManager.getMfaStatus(session.did) === null) { 108 116 redirect(routes.login.sudo.index.href(undefined, { redirect: redirectUrl })); 109 117 } 110 118 ··· 135 143 </BaseForm>, 136 144 ); 137 145 }, 146 + async webauthn({ url }) { 147 + const { accountManager, config } = getAppContext(); 148 + const session = getSession(); 149 + 150 + const { fields } = verifyWebAuthnSudoForm; 151 + 152 + const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 153 + if (!redirectUrl) { 154 + redirect(routes.account.overview.href()); 155 + } 156 + 157 + if (accountManager.getMfaStatus(session.did) === null) { 158 + redirect(routes.login.sudo.index.href(undefined, { redirect: redirectUrl })); 159 + } 160 + 161 + // get user's WebAuthn credentials (security keys or passkeys) 162 + const webauthnCredentials = accountManager.listWebAuthnCredentials(session.did); 163 + if (webauthnCredentials.length === 0) { 164 + // no WebAuthn credentials, redirect to TOTP 165 + redirect(routes.login.sudo.totp.href(undefined, { redirect: redirectUrl })); 166 + } 167 + 168 + // generate authentication options 169 + const options = await generateWebAuthnAuthenticationOptions({ 170 + rpId: new URL(config.service.publicUrl).hostname, 171 + allowCredentials: webauthnCredentials, 172 + }); 173 + 174 + // store the challenge for verification (reuse webauthn challenge table) 175 + const challengeToken = accountManager.createWebAuthnChallenge(session.did, options.challenge); 176 + 177 + return render( 178 + <BaseLayout> 179 + <title>Confirm your identity - Danaus</title> 180 + 181 + <script src="/assets/webauthn-authenticate.js" type="module" /> 182 + 183 + <div class="flex flex-1 items-center justify-center p-4"> 184 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 185 + <form {...verifyWebAuthnSudoForm} class="flex flex-col gap-6"> 186 + <input {...fields.challenge.as('hidden', challengeToken)} /> 187 + <input {...fields.redirect.as('hidden', redirectUrl)} /> 188 + 189 + <div class="flex flex-col gap-2"> 190 + <h1 class="text-base-500 font-semibold">Confirm your identity</h1> 191 + <p class="text-base-300 text-neutral-foreground-3"> 192 + Insert your security key and touch it to continue. 193 + </p> 194 + </div> 195 + 196 + <danaus-webauthn-authenticate data-options={JSON.stringify(options)}> 197 + <input {...fields.response.as('hidden', '')} data-target="webauthn-authenticate.response" /> 198 + 199 + <Button data-target="webauthn-authenticate.start" type="button" variant="primary"> 200 + Use security key 201 + </Button> 202 + 203 + <div 204 + data-target="webauthn-authenticate.status" 205 + class="text-center text-base-300 text-neutral-foreground-3" 206 + /> 207 + </danaus-webauthn-authenticate> 208 + 209 + <Menu> 210 + <MenuTrigger> 211 + <Button>Show other methods</Button> 212 + </MenuTrigger> 213 + 214 + <MenuPopover> 215 + <MenuList> 216 + <MenuItem href={routes.login.sudo.totp.href(undefined, { redirect: redirectUrl })}> 217 + Use authenticator app 218 + </MenuItem> 219 + 220 + <MenuItem href={routes.login.sudo.recovery.href(undefined, { redirect: redirectUrl })}> 221 + Use 2FA recovery code 222 + </MenuItem> 223 + </MenuList> 224 + </MenuPopover> 225 + </Menu> 226 + </form> 227 + </div> 228 + </div> 229 + </BaseLayout>, 230 + ); 231 + }, 138 232 password({ url }) { 139 233 const { accountManager } = getAppContext(); 140 234 const session = getSession(); ··· 146 240 redirect(routes.account.overview.href()); 147 241 } 148 242 149 - if (accountManager.isMfaEnabled(session.did)) { 243 + if (accountManager.getMfaStatus(session.did) !== null) { 150 244 redirect(routes.login.sudo.index.href(undefined, { redirect: redirectUrl })); 151 245 } 152 246 ··· 176 270 177 271 const { fields } = verifySudoForm; 178 272 179 - const hasMfa = accountManager.isMfaEnabled(did); 273 + const mfaStatus = accountManager.getMfaStatus(did); 180 274 181 275 return ( 182 276 <BaseLayout> ··· 190 284 191 285 {props.children} 192 286 193 - {hasMfa && props.factor !== 'password' && ( 287 + {mfaStatus !== null && ( 194 288 <Menu> 195 289 <MenuTrigger> 196 290 <Button>Show other methods</Button> ··· 198 292 199 293 <MenuPopover> 200 294 <MenuList> 201 - {props.factor !== 'totp' && ( 295 + {props.factor !== 'webauthn' && mfaStatus.hasWebAuthn && ( 296 + <MenuItem 297 + href={routes.login.sudo.webauthn.href(undefined, { 298 + redirect: props.redirectUrl, 299 + })} 300 + > 301 + Use security key 302 + </MenuItem> 303 + )} 304 + 305 + {props.factor !== 'totp' && mfaStatus.hasTotp && ( 202 306 <MenuItem 203 307 href={routes.login.sudo.totp.href(undefined, { 204 308 redirect: props.redirectUrl, ··· 208 312 </MenuItem> 209 313 )} 210 314 211 - {props.factor !== 'recovery' && ( 315 + {props.factor !== 'recovery' && mfaStatus.hasRecoveryCodes && ( 212 316 <MenuItem 213 317 href={routes.login.sudo.recovery.href(undefined, { 214 318 redirect: props.redirectUrl,
+2 -1
packages/danaus/src/web/layouts/base.tsx
··· 1 1 import type { JSXNode } from '@oomfware/jsx'; 2 2 3 3 import { IdProvider } from '../components/id.tsx'; 4 + import { routes } from '../routes.ts'; 4 5 5 6 export interface BaseLayoutProps { 6 7 children?: JSXNode; ··· 17 18 <head> 18 19 <meta charset="utf-8" /> 19 20 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 20 - <link rel="stylesheet" href="/assets/style.css" /> 21 + <link rel="stylesheet" href={routes.assets.href({ path: 'style.css' })} /> 21 22 </head> 22 23 23 24 <body>
+7
packages/danaus/src/web/routes.ts
··· 3 3 export const routes = route({ 4 4 home: '/', 5 5 6 + assets: '/assets/*path', 7 + 6 8 admin: { 7 9 dashboard: '/admin', 8 10 accounts: { ··· 25 27 sudo: { 26 28 index: '/account/sudo', 27 29 totp: '/account/sudo/totp', 30 + webauthn: '/account/sudo/webauthn', 28 31 recovery: '/account/sudo/recovery', 29 32 password: '/account/sudo/password', 30 33 }, ··· 39 42 totp: { 40 43 register: '/account/security/totp/register', 41 44 remove: '/account/security/totp/:id/remove', 45 + }, 46 + webauthn: { 47 + register: '/account/security/webauthn/register', 48 + remove: '/account/security/webauthn/:id/remove', 42 49 }, 43 50 recovery: { 44 51 show: '/account/security/recovery',
+23
packages/danaus/src/web/scripts/base64url.js
··· 1 + // @ts-nocheck 2 + 3 + /** 4 + * decode a base64url string to a Uint8Array. 5 + * @param {string} str 6 + * @returns {Uint8Array<ArrayBuffer>} 7 + */ 8 + export const fromBase64Url = (str) => { 9 + return Uint8Array.fromBase64(str, { alphabet: 'base64url' }); 10 + }; 11 + 12 + /** 13 + * encode an ArrayBuffer to a base64url string. 14 + * @param {ArrayBuffer | Uint8Array} buffer 15 + * @returns {string} 16 + */ 17 + export const toBase64Url = (buffer) => { 18 + if (buffer instanceof ArrayBuffer) { 19 + buffer = new Uint8Array(buffer); 20 + } 21 + 22 + return buffer.toBase64({ alphabet: 'base64url' }); 23 + };
+135
packages/danaus/src/web/scripts/webauthn-authenticate.js
··· 1 + // @ts-check 2 + 3 + import { fromBase64Url, toBase64Url } from './base64url.js'; 4 + 5 + /** 6 + * WebAuthn authentication element. 7 + * 8 + * @attr {string} data-options - JSON PublicKeyCredentialRequestOptions 9 + */ 10 + class WebAuthnAuthenticateElement extends HTMLElement { 11 + /** @type {PublicKeyCredentialRequestOptionsJSON | null} */ 12 + #options = null; 13 + 14 + /** @type {HTMLButtonElement | null} */ 15 + get startButton() { 16 + return this.querySelector('[data-target="webauthn-authenticate.start"]'); 17 + } 18 + 19 + /** @type {HTMLInputElement | null} */ 20 + get responseInput() { 21 + return this.querySelector('[data-target="webauthn-authenticate.response"]'); 22 + } 23 + 24 + /** @type {HTMLElement | null} */ 25 + get statusElement() { 26 + return this.querySelector('[data-target="webauthn-authenticate.status"]'); 27 + } 28 + 29 + connectedCallback() { 30 + const optionsJson = this.dataset.options; 31 + if (!optionsJson) { 32 + return; 33 + } 34 + 35 + this.#options = JSON.parse(optionsJson); 36 + 37 + const startButton = this.startButton; 38 + if (startButton) { 39 + startButton.addEventListener('click', (e) => { 40 + e.preventDefault(); 41 + this.#handleAuthentication(); 42 + }); 43 + } 44 + } 45 + 46 + async #handleAuthentication() { 47 + const options = this.#options; 48 + const status = this.statusElement; 49 + const responseInput = this.responseInput; 50 + const startButton = this.startButton; 51 + 52 + if (!options || !status || !responseInput) { 53 + console.error('WebAuthn authenticate: missing required elements'); 54 + return; 55 + } 56 + 57 + try { 58 + if (startButton) { 59 + startButton.disabled = true; 60 + } 61 + status.textContent = 'Waiting for security key...'; 62 + 63 + // convert options to the format expected by navigator.credentials.get 64 + /** @type {PublicKeyCredentialRequestOptions} */ 65 + const publicKeyOptions = { 66 + ...options, 67 + challenge: fromBase64Url(options.challenge), 68 + allowCredentials: options.allowCredentials?.map((cred) => ({ 69 + ...cred, 70 + id: fromBase64Url(cred.id), 71 + })), 72 + }; 73 + 74 + const credential = /** @type {PublicKeyCredential | null} */ ( 75 + await navigator.credentials.get({ publicKey: publicKeyOptions }) 76 + ); 77 + 78 + if (!credential) { 79 + status.textContent = 'Authentication cancelled'; 80 + if (startButton) { 81 + startButton.disabled = false; 82 + } 83 + return; 84 + } 85 + 86 + const response = /** @type {AuthenticatorAssertionResponse} */ (credential.response); 87 + 88 + // serialize the response for the server 89 + const serialized = JSON.stringify({ 90 + id: credential.id, 91 + rawId: toBase64Url(credential.rawId), 92 + type: credential.type, 93 + response: { 94 + clientDataJSON: toBase64Url(response.clientDataJSON), 95 + authenticatorData: toBase64Url(response.authenticatorData), 96 + signature: toBase64Url(response.signature), 97 + userHandle: response.userHandle ? toBase64Url(response.userHandle) : null, 98 + }, 99 + clientExtensionResults: credential.getClientExtensionResults(), 100 + }); 101 + 102 + responseInput.value = serialized; 103 + status.textContent = 'Security key verified!'; 104 + 105 + // auto-submit the form 106 + this.closest('form')?.submit(); 107 + } catch (err) { 108 + if (startButton) { 109 + startButton.disabled = false; 110 + } 111 + 112 + if (err instanceof Error) { 113 + if (err.name === 'NotAllowedError') { 114 + status.textContent = 'Authentication was cancelled or timed out. Please try again.'; 115 + } else { 116 + status.textContent = `Authentication failed: ${err.message}`; 117 + } 118 + } else { 119 + status.textContent = 'Authentication failed. Please try again.'; 120 + } 121 + console.error('WebAuthn authentication error:', err); 122 + } 123 + } 124 + } 125 + 126 + customElements.define('danaus-webauthn-authenticate', WebAuthnAuthenticateElement); 127 + 128 + /** 129 + * @typedef {object} PublicKeyCredentialRequestOptionsJSON 130 + * @property {string} challenge 131 + * @property {number} [timeout] 132 + * @property {string} [rpId] 133 + * @property {Array<{id: string, type: 'public-key', transports?: AuthenticatorTransport[]}>} [allowCredentials] 134 + * @property {UserVerificationRequirement} [userVerification] 135 + */
+125
packages/danaus/src/web/scripts/webauthn-register.js
··· 1 + // @ts-check 2 + 3 + import { fromBase64Url, toBase64Url } from './base64url.js'; 4 + 5 + /** 6 + * WebAuthn registration element. 7 + * 8 + * @attr {string} data-options - JSON PublicKeyCredentialCreationOptions 9 + */ 10 + class WebAuthnRegisterElement extends HTMLElement { 11 + /** @type {HTMLInputElement | null} */ 12 + get responseInput() { 13 + return this.querySelector('[data-target="webauthn-register.response"]'); 14 + } 15 + 16 + /** @type {HTMLButtonElement | null} */ 17 + get submitButton() { 18 + return this.querySelector('[data-target="webauthn-register.submit"]'); 19 + } 20 + 21 + /** @type {HTMLElement | null} */ 22 + get statusElement() { 23 + return this.querySelector('[data-target="webauthn-register.status"]'); 24 + } 25 + 26 + connectedCallback() { 27 + const options = this.dataset.options; 28 + if (options) { 29 + this.#handleRegistration(JSON.parse(options)); 30 + } 31 + } 32 + 33 + /** 34 + * @param {PublicKeyCredentialCreationOptionsJSON} options 35 + */ 36 + async #handleRegistration(options) { 37 + const status = this.statusElement; 38 + const submitButton = this.submitButton; 39 + const responseInput = this.responseInput; 40 + 41 + if (!status || !submitButton || !responseInput) { 42 + console.error('WebAuthn register: missing required elements'); 43 + return; 44 + } 45 + 46 + try { 47 + status.textContent = 'Waiting for security key...'; 48 + 49 + // convert options to the format expected by navigator.credentials.create 50 + /** @type {PublicKeyCredentialCreationOptions} */ 51 + const publicKeyOptions = { 52 + ...options, 53 + challenge: fromBase64Url(options.challenge), 54 + user: { 55 + ...options.user, 56 + id: fromBase64Url(options.user.id), 57 + }, 58 + excludeCredentials: options.excludeCredentials?.map((cred) => ({ 59 + ...cred, 60 + id: fromBase64Url(cred.id), 61 + })), 62 + }; 63 + 64 + const credential = /** @type {PublicKeyCredential | null} */ ( 65 + await navigator.credentials.create({ publicKey: publicKeyOptions }) 66 + ); 67 + 68 + if (!credential) { 69 + status.textContent = 'Registration cancelled'; 70 + return; 71 + } 72 + 73 + const response = /** @type {AuthenticatorAttestationResponse} */ (credential.response); 74 + 75 + // serialize the response for the server 76 + const serialized = JSON.stringify({ 77 + id: credential.id, 78 + rawId: toBase64Url(credential.rawId), 79 + type: credential.type, 80 + response: { 81 + clientDataJSON: toBase64Url(response.clientDataJSON), 82 + attestationObject: toBase64Url(response.attestationObject), 83 + transports: response.getTransports?.() ?? [], 84 + }, 85 + clientExtensionResults: credential.getClientExtensionResults(), 86 + }); 87 + 88 + responseInput.value = serialized; 89 + submitButton.disabled = false; 90 + status.textContent = 'Security key registered! Click Save to continue.'; 91 + } catch (err) { 92 + if (err instanceof Error) { 93 + if (err.name === 'NotAllowedError') { 94 + status.textContent = 'Registration was cancelled or timed out. Please try again.'; 95 + } else if (err.name === 'InvalidStateError') { 96 + status.textContent = 'This security key is already registered.'; 97 + } else { 98 + status.textContent = `Registration failed: ${err.message}`; 99 + } 100 + } else { 101 + status.textContent = 'Registration failed. Please try again.'; 102 + } 103 + console.error('WebAuthn registration error:', err); 104 + } 105 + } 106 + } 107 + 108 + customElements.define('danaus-webauthn-register', WebAuthnRegisterElement); 109 + 110 + /** 111 + * @typedef {object} PublicKeyCredentialCreationOptionsJSON 112 + * @property {string} challenge 113 + * @property {object} rp 114 + * @property {string} rp.name 115 + * @property {string} [rp.id] 116 + * @property {object} user 117 + * @property {string} user.id 118 + * @property {string} user.name 119 + * @property {string} user.displayName 120 + * @property {PublicKeyCredentialParameters[]} pubKeyCredParams 121 + * @property {number} [timeout] 122 + * @property {Array<{id: string, type: 'public-key', transports?: AuthenticatorTransport[]}>} [excludeCredentials] 123 + * @property {AuthenticatorSelectionCriteria} [authenticatorSelection] 124 + * @property {AttestationConveyancePreference} [attestation] 125 + */
+31
packages/danaus/tsconfig.client.json
··· 1 + { 2 + "compilerOptions": { 3 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.client.tsbuildinfo", 4 + "target": "ESNext", 5 + "module": "ESNext", 6 + "lib": ["ESNext", "DOM"], 7 + "types": [], 8 + "skipLibCheck": true, 9 + 10 + /* Bundler mode */ 11 + "moduleResolution": "bundler", 12 + "verbatimModuleSyntax": true, 13 + "moduleDetection": "force", 14 + "noEmit": true, 15 + "composite": true, 16 + 17 + /* Check JS files with JSDoc (only files with // @ts-check) */ 18 + "allowJs": true, 19 + "checkJs": false, 20 + 21 + /* Linting */ 22 + "strict": true, 23 + "noFallthroughCasesInSwitch": true, 24 + "noImplicitOverride": true, 25 + "noUncheckedIndexedAccess": true, 26 + "noUncheckedSideEffectImports": true, 27 + "noUnusedLocals": true, 28 + "noUnusedParameters": true 29 + }, 30 + "include": ["src/web/scripts/**/*"] 31 + }
+5 -29
packages/danaus/tsconfig.json
··· 1 1 { 2 - "compilerOptions": { 3 - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", 4 - "target": "ESNext", 5 - "module": "ESNext", 6 - "lib": ["ESNext"], 7 - "types": ["bun"], 8 - "skipLibCheck": true, 9 - 10 - /* Bundler mode */ 11 - "moduleResolution": "bundler", 12 - "allowImportingTsExtensions": true, 13 - "verbatimModuleSyntax": true, 14 - "moduleDetection": "force", 15 - "noEmit": true, 16 - 17 - /* JSX */ 18 - "jsx": "react-jsx", 19 - "jsxImportSource": "@oomfware/jsx", 20 - 21 - /* Linting */ 22 - "strict": true, 23 - "noFallthroughCasesInSwitch": true, 24 - "noImplicitOverride": true, 25 - "noUncheckedIndexedAccess": true, 26 - "noUncheckedSideEffectImports": true, 27 - "noUnusedLocals": true, 28 - "noUnusedParameters": true 29 - }, 30 - "include": ["src/**/*", "tests/**/*"] 2 + "files": [], 3 + "references": [ 4 + { "path": "./tsconfig.server.json" }, 5 + { "path": "./tsconfig.client.json" } 6 + ] 31 7 }
+33
packages/danaus/tsconfig.server.json
··· 1 + { 2 + "compilerOptions": { 3 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo", 4 + "target": "ESNext", 5 + "module": "ESNext", 6 + "lib": ["ESNext"], 7 + "types": ["bun"], 8 + "skipLibCheck": true, 9 + 10 + /* Bundler mode */ 11 + "moduleResolution": "bundler", 12 + "allowImportingTsExtensions": true, 13 + "verbatimModuleSyntax": true, 14 + "moduleDetection": "force", 15 + "noEmit": true, 16 + "composite": true, 17 + 18 + /* JSX */ 19 + "jsx": "react-jsx", 20 + "jsxImportSource": "@oomfware/jsx", 21 + 22 + /* Linting */ 23 + "strict": true, 24 + "noFallthroughCasesInSwitch": true, 25 + "noImplicitOverride": true, 26 + "noUncheckedIndexedAccess": true, 27 + "noUncheckedSideEffectImports": true, 28 + "noUnusedLocals": true, 29 + "noUnusedParameters": true 30 + }, 31 + "include": ["src/**/*", "tests/**/*"], 32 + "exclude": ["src/web/scripts/**/*"] 33 + }
+209 -5
pnpm-lock.yaml
··· 104 104 specifier: ^0.2.2 105 105 version: 0.2.2(@oomfware/fetch-router@0.2.1) 106 106 '@oomfware/jsx': 107 - specifier: ^0.1.4 108 - version: 0.1.4 107 + specifier: ^0.1.5 108 + version: 0.1.5 109 + '@simplewebauthn/server': 110 + specifier: ^13.2.2 111 + version: 13.2.2 109 112 cva: 110 113 specifier: 1.0.0-beta.4 111 114 version: 1.0.0-beta.4(typescript@5.9.3) ··· 718 721 '@hapi/hoek@11.0.7': 719 722 resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} 720 723 724 + '@hexagon/base64@1.1.28': 725 + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} 726 + 721 727 '@ianvs/prettier-plugin-sort-imports@4.7.0': 722 728 resolution: {integrity: sha512-soa2bPUJAFruLL4z/CnMfSEKGznm5ebz29fIa9PxYtu8HHyLKNE1NXAs6dylfw1jn/ilEIfO2oLLN6uAafb7DA==} 723 729 peerDependencies: ··· 873 879 resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} 874 880 engines: {node: '>=12'} 875 881 882 + '@levischuck/tiny-cbor@0.2.11': 883 + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} 884 + 876 885 '@napi-rs/wasm-runtime@1.1.1': 877 886 resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} 878 887 ··· 898 907 peerDependencies: 899 908 '@oomfware/fetch-router': ^0.2.1 900 909 901 - '@oomfware/jsx@0.1.4': 902 - resolution: {integrity: sha512-3mY2Iqdjl+mE1ni3i6x9TdmgYTndfgiK4hpqBpHfvZHLtUddLBPWT8+AEidC5EZRXS1E2B1AZvtHFPESdkscfQ==} 910 + '@oomfware/jsx@0.1.5': 911 + resolution: {integrity: sha512-Hq0f74iarKWsEpfCTV3zWdH2Bh169Y6gOh84Lsp0uSRls6IksVXZfv6ZtkZGAr9pMvrHf06n3vnoNJ8xcqsCtw==} 903 912 904 913 '@optique/core@0.6.11': 905 914 resolution: {integrity: sha512-GVLFihzBA1j78NFlkU5N1Lu0jRqET0k6Z66WK8VQKG/a3cxmCInVGSKMIdQG8i6pgC8wD5OizF6Y3QMztmhAxg==} ··· 1222 1231 '@parcel/watcher@2.5.1': 1223 1232 resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} 1224 1233 engines: {node: '>= 10.0.0'} 1234 + 1235 + '@peculiar/asn1-android@2.6.0': 1236 + resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} 1237 + 1238 + '@peculiar/asn1-cms@2.6.0': 1239 + resolution: {integrity: sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==} 1240 + 1241 + '@peculiar/asn1-csr@2.6.0': 1242 + resolution: {integrity: sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==} 1243 + 1244 + '@peculiar/asn1-ecc@2.6.0': 1245 + resolution: {integrity: sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==} 1246 + 1247 + '@peculiar/asn1-pfx@2.6.0': 1248 + resolution: {integrity: sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==} 1249 + 1250 + '@peculiar/asn1-pkcs8@2.6.0': 1251 + resolution: {integrity: sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==} 1252 + 1253 + '@peculiar/asn1-pkcs9@2.6.0': 1254 + resolution: {integrity: sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==} 1255 + 1256 + '@peculiar/asn1-rsa@2.6.0': 1257 + resolution: {integrity: sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==} 1258 + 1259 + '@peculiar/asn1-schema@2.6.0': 1260 + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} 1261 + 1262 + '@peculiar/asn1-x509-attr@2.6.0': 1263 + resolution: {integrity: sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==} 1264 + 1265 + '@peculiar/asn1-x509@2.6.0': 1266 + resolution: {integrity: sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==} 1267 + 1268 + '@peculiar/x509@1.14.3': 1269 + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} 1270 + engines: {node: '>=20.0.0'} 1225 1271 1226 1272 '@poppinss/ts-exec@1.4.1': 1227 1273 resolution: {integrity: sha512-KA1gjEeKoYVZSK+pmasrIfq6xpRCRujBfOmVRfCD7jv+vci/kb+5ymvVuR8XsvbP9Ar8NQexeaT3IDuELHY1Rw==} ··· 1264 1310 '@remix-run/route-pattern@0.16.0': 1265 1311 resolution: {integrity: sha512-Co6bPtODF7cLYVBweayRXfEb31ybz45WqwT/u72eDQJZgRSVKFf0Ps9fqinSaiX0Xp7jvkRCBAbSUgLuLLjzuw==} 1266 1312 1313 + '@simplewebauthn/server@13.2.2': 1314 + resolution: {integrity: sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==} 1315 + engines: {node: '>=20.0.0'} 1316 + 1267 1317 '@standard-schema/spec@1.1.0': 1268 1318 resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 1269 1319 ··· 1590 1640 1591 1641 asn1.js@5.4.1: 1592 1642 resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} 1643 + 1644 + asn1js@3.0.7: 1645 + resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} 1646 + engines: {node: '>=12.0.0'} 1593 1647 1594 1648 asynckit@0.4.0: 1595 1649 resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} ··· 2714 2768 proxy-from-env@1.1.0: 2715 2769 resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} 2716 2770 2771 + pvtsutils@1.3.6: 2772 + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} 2773 + 2774 + pvutils@1.1.5: 2775 + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} 2776 + engines: {node: '>=16.0.0'} 2777 + 2717 2778 qrcode@1.5.4: 2718 2779 resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} 2719 2780 engines: {node: '>=10.13.0'} ··· 2752 2813 redis-parser@3.0.0: 2753 2814 resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} 2754 2815 engines: {node: '>=4'} 2816 + 2817 + reflect-metadata@0.2.2: 2818 + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} 2755 2819 2756 2820 require-directory@2.1.1: 2757 2821 resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} ··· 2935 2999 resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} 2936 3000 hasBin: true 2937 3001 3002 + tslib@1.14.1: 3003 + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} 3004 + 2938 3005 tslib@2.8.1: 2939 3006 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 3007 + 3008 + tsyringe@4.10.0: 3009 + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} 3010 + engines: {node: '>= 6.0.0'} 2940 3011 2941 3012 type-fest@2.19.0: 2942 3013 resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} ··· 3841 3912 '@hapi/hoek': 11.0.7 3842 3913 3843 3914 '@hapi/hoek@11.0.7': {} 3915 + 3916 + '@hexagon/base64@1.1.28': {} 3844 3917 3845 3918 '@ianvs/prettier-plugin-sort-imports@4.7.0(@prettier/plugin-oxc@0.1.3)(@vue/compiler-sfc@3.5.26)(prettier@3.7.4)': 3846 3919 dependencies: ··· 3965 4038 dependencies: 3966 4039 jsbi: 4.3.2 3967 4040 4041 + '@levischuck/tiny-cbor@0.2.11': {} 4042 + 3968 4043 '@napi-rs/wasm-runtime@1.1.1': 3969 4044 dependencies: 3970 4045 '@emnapi/core': 1.8.1 ··· 3991 4066 '@oomfware/fetch-router': 0.2.1 3992 4067 '@standard-schema/spec': 1.1.0 3993 4068 3994 - '@oomfware/jsx@0.1.4': {} 4069 + '@oomfware/jsx@0.1.5': 4070 + dependencies: 4071 + '@atcute/uint8array': 1.0.6 3995 4072 3996 4073 '@optique/core@0.6.11': {} 3997 4074 ··· 4194 4271 '@parcel/watcher-win32-ia32': 2.5.1 4195 4272 '@parcel/watcher-win32-x64': 2.5.1 4196 4273 4274 + '@peculiar/asn1-android@2.6.0': 4275 + dependencies: 4276 + '@peculiar/asn1-schema': 2.6.0 4277 + asn1js: 3.0.7 4278 + tslib: 2.8.1 4279 + 4280 + '@peculiar/asn1-cms@2.6.0': 4281 + dependencies: 4282 + '@peculiar/asn1-schema': 2.6.0 4283 + '@peculiar/asn1-x509': 2.6.0 4284 + '@peculiar/asn1-x509-attr': 2.6.0 4285 + asn1js: 3.0.7 4286 + tslib: 2.8.1 4287 + 4288 + '@peculiar/asn1-csr@2.6.0': 4289 + dependencies: 4290 + '@peculiar/asn1-schema': 2.6.0 4291 + '@peculiar/asn1-x509': 2.6.0 4292 + asn1js: 3.0.7 4293 + tslib: 2.8.1 4294 + 4295 + '@peculiar/asn1-ecc@2.6.0': 4296 + dependencies: 4297 + '@peculiar/asn1-schema': 2.6.0 4298 + '@peculiar/asn1-x509': 2.6.0 4299 + asn1js: 3.0.7 4300 + tslib: 2.8.1 4301 + 4302 + '@peculiar/asn1-pfx@2.6.0': 4303 + dependencies: 4304 + '@peculiar/asn1-cms': 2.6.0 4305 + '@peculiar/asn1-pkcs8': 2.6.0 4306 + '@peculiar/asn1-rsa': 2.6.0 4307 + '@peculiar/asn1-schema': 2.6.0 4308 + asn1js: 3.0.7 4309 + tslib: 2.8.1 4310 + 4311 + '@peculiar/asn1-pkcs8@2.6.0': 4312 + dependencies: 4313 + '@peculiar/asn1-schema': 2.6.0 4314 + '@peculiar/asn1-x509': 2.6.0 4315 + asn1js: 3.0.7 4316 + tslib: 2.8.1 4317 + 4318 + '@peculiar/asn1-pkcs9@2.6.0': 4319 + dependencies: 4320 + '@peculiar/asn1-cms': 2.6.0 4321 + '@peculiar/asn1-pfx': 2.6.0 4322 + '@peculiar/asn1-pkcs8': 2.6.0 4323 + '@peculiar/asn1-schema': 2.6.0 4324 + '@peculiar/asn1-x509': 2.6.0 4325 + '@peculiar/asn1-x509-attr': 2.6.0 4326 + asn1js: 3.0.7 4327 + tslib: 2.8.1 4328 + 4329 + '@peculiar/asn1-rsa@2.6.0': 4330 + dependencies: 4331 + '@peculiar/asn1-schema': 2.6.0 4332 + '@peculiar/asn1-x509': 2.6.0 4333 + asn1js: 3.0.7 4334 + tslib: 2.8.1 4335 + 4336 + '@peculiar/asn1-schema@2.6.0': 4337 + dependencies: 4338 + asn1js: 3.0.7 4339 + pvtsutils: 1.3.6 4340 + tslib: 2.8.1 4341 + 4342 + '@peculiar/asn1-x509-attr@2.6.0': 4343 + dependencies: 4344 + '@peculiar/asn1-schema': 2.6.0 4345 + '@peculiar/asn1-x509': 2.6.0 4346 + asn1js: 3.0.7 4347 + tslib: 2.8.1 4348 + 4349 + '@peculiar/asn1-x509@2.6.0': 4350 + dependencies: 4351 + '@peculiar/asn1-schema': 2.6.0 4352 + asn1js: 3.0.7 4353 + pvtsutils: 1.3.6 4354 + tslib: 2.8.1 4355 + 4356 + '@peculiar/x509@1.14.3': 4357 + dependencies: 4358 + '@peculiar/asn1-cms': 2.6.0 4359 + '@peculiar/asn1-csr': 2.6.0 4360 + '@peculiar/asn1-ecc': 2.6.0 4361 + '@peculiar/asn1-pkcs9': 2.6.0 4362 + '@peculiar/asn1-rsa': 2.6.0 4363 + '@peculiar/asn1-schema': 2.6.0 4364 + '@peculiar/asn1-x509': 2.6.0 4365 + pvtsutils: 1.3.6 4366 + reflect-metadata: 0.2.2 4367 + tslib: 2.8.1 4368 + tsyringe: 4.10.0 4369 + 4197 4370 '@poppinss/ts-exec@1.4.1': 4198 4371 dependencies: 4199 4372 '@swc/core': 1.13.3 ··· 4229 4402 '@protobufjs/utf8@1.1.0': {} 4230 4403 4231 4404 '@remix-run/route-pattern@0.16.0': {} 4405 + 4406 + '@simplewebauthn/server@13.2.2': 4407 + dependencies: 4408 + '@hexagon/base64': 1.1.28 4409 + '@levischuck/tiny-cbor': 0.2.11 4410 + '@peculiar/asn1-android': 2.6.0 4411 + '@peculiar/asn1-ecc': 2.6.0 4412 + '@peculiar/asn1-rsa': 2.6.0 4413 + '@peculiar/asn1-schema': 2.6.0 4414 + '@peculiar/asn1-x509': 2.6.0 4415 + '@peculiar/x509': 1.14.3 4232 4416 4233 4417 '@standard-schema/spec@1.1.0': {} 4234 4418 ··· 4537 4721 inherits: 2.0.4 4538 4722 minimalistic-assert: 1.0.1 4539 4723 safer-buffer: 2.1.2 4724 + 4725 + asn1js@3.0.7: 4726 + dependencies: 4727 + pvtsutils: 1.3.6 4728 + pvutils: 1.1.5 4729 + tslib: 2.8.1 4540 4730 4541 4731 asynckit@0.4.0: {} 4542 4732 ··· 5543 5733 5544 5734 proxy-from-env@1.1.0: {} 5545 5735 5736 + pvtsutils@1.3.6: 5737 + dependencies: 5738 + tslib: 2.8.1 5739 + 5740 + pvutils@1.1.5: {} 5741 + 5546 5742 qrcode@1.5.4: 5547 5743 dependencies: 5548 5744 dijkstrajs: 1.0.3 ··· 5581 5777 redis-parser@3.0.0: 5582 5778 dependencies: 5583 5779 redis-errors: 1.2.0 5780 + 5781 + reflect-metadata@0.2.2: {} 5584 5782 5585 5783 require-directory@2.1.1: {} 5586 5784 ··· 5812 6010 5813 6011 tree-kill@1.2.2: {} 5814 6012 6013 + tslib@1.14.1: {} 6014 + 5815 6015 tslib@2.8.1: {} 6016 + 6017 + tsyringe@4.10.0: 6018 + dependencies: 6019 + tslib: 1.14.1 5816 6020 5817 6021 type-fest@2.19.0: {} 5818 6022