work-in-progress atproto PDS
typescript atproto pds atcute

feat: WebAuthn passwordless support

mary.my.id d68615ec 7fa59b39

verified
+801 -263
+18 -11
packages/danaus/drizzle/accounts/20260117065047_stormy_marvel_zombies/migration.sql packages/danaus/drizzle/accounts/20260117080253_flaky_dark_beast/migration.sql
··· 61 CONSTRAINT `fk_legacy_session_next_id_legacy_session_id_fk` FOREIGN KEY (`next_id`) REFERENCES `legacy_session`(`id`) ON DELETE CASCADE 62 ); 63 --> statement-breakpoint 64 CREATE TABLE `recovery_code` ( 65 `id` integer PRIMARY KEY AUTOINCREMENT, 66 `did` text NOT NULL, ··· 103 CONSTRAINT `fk_web_session_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE 104 ); 105 --> statement-breakpoint 106 - CREATE TABLE `webauthn_challenge` ( 107 - `token` text PRIMARY KEY, 108 - `did` text NOT NULL, 109 - `challenge` text NOT NULL, 110 - `created_at` integer NOT NULL, 111 - `expires_at` integer NOT NULL, 112 - CONSTRAINT `fk_webauthn_challenge_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE 113 - ); 114 - --> statement-breakpoint 115 CREATE TABLE `webauthn_credential` ( 116 `id` integer PRIMARY KEY AUTOINCREMENT, 117 `did` text NOT NULL, ··· 126 CONSTRAINT `webauthn_credential_did_name_unique` UNIQUE(`did`,`name`) 127 ); 128 --> statement-breakpoint 129 CREATE INDEX `account_created_at_did_idx` ON `account` (`created_at`,`did`);--> statement-breakpoint 130 CREATE UNIQUE INDEX `account_handle_lower_idx` ON `account` (lower("handle"));--> statement-breakpoint 131 CREATE UNIQUE INDEX `account_email_lower_idx` ON `account` (lower("email"));--> statement-breakpoint 132 CREATE INDEX `legacy_session_did_idx` ON `legacy_session` (`did`);--> statement-breakpoint 133 CREATE INDEX `recovery_code_did_idx` ON `recovery_code` (`did`);--> statement-breakpoint 134 CREATE INDEX `totp_credential_did_idx` ON `totp_credential` (`did`);--> statement-breakpoint 135 CREATE INDEX `verify_challenge_expires_idx` ON `verify_challenge` (`expires_at`);--> statement-breakpoint 136 CREATE INDEX `web_session_did_idx` ON `web_session` (`did`);--> statement-breakpoint 137 - CREATE INDEX `webauthn_challenge_expires_idx` ON `webauthn_challenge` (`expires_at`);--> statement-breakpoint 138 CREATE INDEX `webauthn_credential_did_idx` ON `webauthn_credential` (`did`);--> statement-breakpoint 139 - CREATE UNIQUE INDEX `webauthn_credential_id_idx` ON `webauthn_credential` (`credential_id`);
··· 61 CONSTRAINT `fk_legacy_session_next_id_legacy_session_id_fk` FOREIGN KEY (`next_id`) REFERENCES `legacy_session`(`id`) ON DELETE CASCADE 62 ); 63 --> statement-breakpoint 64 + CREATE TABLE `passkey_login_challenge` ( 65 + `challenge` text PRIMARY KEY, 66 + `created_at` integer NOT NULL, 67 + `expires_at` integer NOT NULL 68 + ); 69 + --> statement-breakpoint 70 CREATE TABLE `recovery_code` ( 71 `id` integer PRIMARY KEY AUTOINCREMENT, 72 `did` text NOT NULL, ··· 109 CONSTRAINT `fk_web_session_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, ··· 123 CONSTRAINT `webauthn_credential_did_name_unique` UNIQUE(`did`,`name`) 124 ); 125 --> statement-breakpoint 126 + CREATE TABLE `webauthn_registration_challenge` ( 127 + `token` text PRIMARY KEY, 128 + `did` text NOT NULL, 129 + `challenge` text NOT NULL, 130 + `created_at` integer NOT NULL, 131 + `expires_at` integer NOT NULL, 132 + CONSTRAINT `fk_webauthn_registration_challenge_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE 133 + ); 134 + --> statement-breakpoint 135 CREATE INDEX `account_created_at_did_idx` ON `account` (`created_at`,`did`);--> statement-breakpoint 136 CREATE UNIQUE INDEX `account_handle_lower_idx` ON `account` (lower("handle"));--> statement-breakpoint 137 CREATE UNIQUE INDEX `account_email_lower_idx` ON `account` (lower("email"));--> statement-breakpoint 138 CREATE INDEX `legacy_session_did_idx` ON `legacy_session` (`did`);--> statement-breakpoint 139 + CREATE INDEX `passkey_login_challenge_expires_idx` ON `passkey_login_challenge` (`expires_at`);--> statement-breakpoint 140 CREATE INDEX `recovery_code_did_idx` ON `recovery_code` (`did`);--> statement-breakpoint 141 CREATE INDEX `totp_credential_did_idx` ON `totp_credential` (`did`);--> statement-breakpoint 142 CREATE INDEX `verify_challenge_expires_idx` ON `verify_challenge` (`expires_at`);--> statement-breakpoint 143 CREATE INDEX `web_session_did_idx` ON `web_session` (`did`);--> statement-breakpoint 144 CREATE INDEX `webauthn_credential_did_idx` ON `webauthn_credential` (`did`);--> statement-breakpoint 145 + CREATE UNIQUE INDEX `webauthn_credential_id_idx` ON `webauthn_credential` (`credential_id`);--> statement-breakpoint 146 + CREATE INDEX `webauthn_registration_challenge_expires_idx` ON `webauthn_registration_challenge` (`expires_at`);
+124 -67
packages/danaus/drizzle/accounts/20260117065047_stormy_marvel_zombies/snapshot.json packages/danaus/drizzle/accounts/20260117080253_flaky_dark_beast/snapshot.json
··· 1 { 2 "version": "7", 3 "dialect": "sqlite", 4 - "id": "a52583cf-1f20-4ce8-bc86-96528e2d13e1", 5 "prevIds": [ 6 "00000000-0000-0000-0000-000000000000" 7 ], ··· 31 "entityType": "tables" 32 }, 33 { 34 "name": "recovery_code", 35 "entityType": "tables" 36 }, ··· 47 "entityType": "tables" 48 }, 49 { 50 - "name": "webauthn_challenge", 51 "entityType": "tables" 52 }, 53 { 54 - "name": "webauthn_credential", 55 "entityType": "tables" 56 }, 57 { ··· 405 "table": "legacy_session" 406 }, 407 { 408 "type": "integer", 409 "notNull": false, 410 "autoincrement": true, ··· 645 "table": "web_session" 646 }, 647 { 648 - "type": "text", 649 "notNull": false, 650 - "autoincrement": false, 651 "default": null, 652 "generated": null, 653 - "name": "token", 654 "entityType": "columns", 655 - "table": "webauthn_challenge" 656 }, 657 { 658 "type": "text", ··· 662 "generated": null, 663 "name": "did", 664 "entityType": "columns", 665 - "table": "webauthn_challenge" 666 }, 667 { 668 - "type": "text", 669 "notNull": true, 670 "autoincrement": false, 671 "default": null, 672 "generated": null, 673 - "name": "challenge", 674 "entityType": "columns", 675 - "table": "webauthn_challenge" 676 }, 677 { 678 - "type": "integer", 679 "notNull": true, 680 "autoincrement": false, 681 "default": null, 682 "generated": null, 683 - "name": "created_at", 684 "entityType": "columns", 685 - "table": "webauthn_challenge" 686 }, 687 { 688 - "type": "integer", 689 "notNull": true, 690 "autoincrement": false, 691 "default": null, 692 "generated": null, 693 - "name": "expires_at", 694 "entityType": "columns", 695 - "table": "webauthn_challenge" 696 }, 697 { 698 - "type": "integer", 699 - "notNull": false, 700 - "autoincrement": true, 701 "default": null, 702 "generated": null, 703 - "name": "id", 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": "did", 714 "entityType": "columns", 715 "table": "webauthn_credential" 716 }, 717 { 718 - "type": "integer", 719 - "notNull": true, 720 "autoincrement": false, 721 "default": null, 722 "generated": null, 723 - "name": "type", 724 "entityType": "columns", 725 "table": "webauthn_credential" 726 }, 727 { 728 - "type": "text", 729 "notNull": true, 730 "autoincrement": false, 731 "default": null, 732 "generated": null, 733 - "name": "name", 734 "entityType": "columns", 735 "table": "webauthn_credential" 736 }, 737 { 738 "type": "text", 739 - "notNull": true, 740 "autoincrement": false, 741 "default": null, 742 "generated": null, 743 - "name": "credential_id", 744 "entityType": "columns", 745 - "table": "webauthn_credential" 746 }, 747 { 748 - "type": "blob", 749 "notNull": true, 750 "autoincrement": false, 751 "default": null, 752 "generated": null, 753 - "name": "public_key", 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": "counter", 764 "entityType": "columns", 765 - "table": "webauthn_credential" 766 }, 767 { 768 - "type": "text", 769 - "notNull": false, 770 "autoincrement": false, 771 "default": null, 772 "generated": null, 773 - "name": "transports", 774 "entityType": "columns", 775 - "table": "webauthn_credential" 776 }, 777 { 778 "type": "integer", ··· 780 "autoincrement": false, 781 "default": null, 782 "generated": null, 783 - "name": "created_at", 784 "entityType": "columns", 785 - "table": "webauthn_credential" 786 }, 787 { 788 "columns": [ ··· 975 "onUpdate": "NO ACTION", 976 "onDelete": "CASCADE", 977 "nameExplicit": false, 978 - "name": "fk_webauthn_challenge_did_account_did_fk", 979 "entityType": "fks", 980 - "table": "webauthn_challenge" 981 }, 982 { 983 "columns": [ ··· 990 "onUpdate": "NO ACTION", 991 "onDelete": "CASCADE", 992 "nameExplicit": false, 993 - "name": "fk_webauthn_credential_did_account_did_fk", 994 "entityType": "fks", 995 - "table": "webauthn_credential" 996 }, 997 { 998 "columns": [ ··· 1052 }, 1053 { 1054 "columns": [ 1055 "id" 1056 ], 1057 "nameExplicit": false, ··· 1088 }, 1089 { 1090 "columns": [ 1091 - "token" 1092 ], 1093 "nameExplicit": false, 1094 - "name": "webauthn_challenge_pk", 1095 - "table": "webauthn_challenge", 1096 "entityType": "pks" 1097 }, 1098 { 1099 "columns": [ 1100 - "id" 1101 ], 1102 "nameExplicit": false, 1103 - "name": "webauthn_credential_pk", 1104 - "table": "webauthn_credential", 1105 "entityType": "pks" 1106 }, 1107 { ··· 1167 { 1168 "columns": [ 1169 { 1170 "value": "did", 1171 "isExpression": false 1172 } ··· 1223 { 1224 "columns": [ 1225 { 1226 - "value": "expires_at", 1227 - "isExpression": false 1228 - } 1229 - ], 1230 - "isUnique": false, 1231 - "where": null, 1232 - "origin": "manual", 1233 - "name": "webauthn_challenge_expires_idx", 1234 - "entityType": "indexes", 1235 - "table": "webauthn_challenge" 1236 - }, 1237 - { 1238 - "columns": [ 1239 - { 1240 "value": "did", 1241 "isExpression": false 1242 } ··· 1261 "name": "webauthn_credential_id_idx", 1262 "entityType": "indexes", 1263 "table": "webauthn_credential" 1264 }, 1265 { 1266 "columns": [
··· 1 { 2 "version": "7", 3 "dialect": "sqlite", 4 + "id": "00552ae3-c924-4b4e-a44c-1e7bcd17914c", 5 "prevIds": [ 6 "00000000-0000-0000-0000-000000000000" 7 ], ··· 31 "entityType": "tables" 32 }, 33 { 34 + "name": "passkey_login_challenge", 35 + "entityType": "tables" 36 + }, 37 + { 38 "name": "recovery_code", 39 "entityType": "tables" 40 }, ··· 51 "entityType": "tables" 52 }, 53 { 54 + "name": "webauthn_credential", 55 "entityType": "tables" 56 }, 57 { 58 + "name": "webauthn_registration_challenge", 59 "entityType": "tables" 60 }, 61 { ··· 409 "table": "legacy_session" 410 }, 411 { 412 + "type": "text", 413 + "notNull": false, 414 + "autoincrement": false, 415 + "default": null, 416 + "generated": null, 417 + "name": "challenge", 418 + "entityType": "columns", 419 + "table": "passkey_login_challenge" 420 + }, 421 + { 422 + "type": "integer", 423 + "notNull": true, 424 + "autoincrement": false, 425 + "default": null, 426 + "generated": null, 427 + "name": "created_at", 428 + "entityType": "columns", 429 + "table": "passkey_login_challenge" 430 + }, 431 + { 432 + "type": "integer", 433 + "notNull": true, 434 + "autoincrement": false, 435 + "default": null, 436 + "generated": null, 437 + "name": "expires_at", 438 + "entityType": "columns", 439 + "table": "passkey_login_challenge" 440 + }, 441 + { 442 "type": "integer", 443 "notNull": false, 444 "autoincrement": true, ··· 679 "table": "web_session" 680 }, 681 { 682 + "type": "integer", 683 "notNull": false, 684 + "autoincrement": true, 685 "default": null, 686 "generated": null, 687 + "name": "id", 688 "entityType": "columns", 689 + "table": "webauthn_credential" 690 }, 691 { 692 "type": "text", ··· 696 "generated": null, 697 "name": "did", 698 "entityType": "columns", 699 + "table": "webauthn_credential" 700 }, 701 { 702 + "type": "integer", 703 "notNull": true, 704 "autoincrement": false, 705 "default": null, 706 "generated": null, 707 + "name": "type", 708 "entityType": "columns", 709 + "table": "webauthn_credential" 710 }, 711 { 712 + "type": "text", 713 "notNull": true, 714 "autoincrement": false, 715 "default": null, 716 "generated": null, 717 + "name": "name", 718 "entityType": "columns", 719 + "table": "webauthn_credential" 720 }, 721 { 722 + "type": "text", 723 "notNull": true, 724 "autoincrement": false, 725 "default": null, 726 "generated": null, 727 + "name": "credential_id", 728 "entityType": "columns", 729 + "table": "webauthn_credential" 730 }, 731 { 732 + "type": "blob", 733 + "notNull": true, 734 + "autoincrement": false, 735 "default": null, 736 "generated": null, 737 + "name": "public_key", 738 "entityType": "columns", 739 "table": "webauthn_credential" 740 }, 741 { 742 + "type": "integer", 743 "notNull": true, 744 "autoincrement": false, 745 "default": null, 746 "generated": null, 747 + "name": "counter", 748 "entityType": "columns", 749 "table": "webauthn_credential" 750 }, 751 { 752 + "type": "text", 753 + "notNull": false, 754 "autoincrement": false, 755 "default": null, 756 "generated": null, 757 + "name": "transports", 758 "entityType": "columns", 759 "table": "webauthn_credential" 760 }, 761 { 762 + "type": "integer", 763 "notNull": true, 764 "autoincrement": false, 765 "default": null, 766 "generated": null, 767 + "name": "created_at", 768 "entityType": "columns", 769 "table": "webauthn_credential" 770 }, 771 { 772 "type": "text", 773 + "notNull": false, 774 "autoincrement": false, 775 "default": null, 776 "generated": null, 777 + "name": "token", 778 "entityType": "columns", 779 + "table": "webauthn_registration_challenge" 780 }, 781 { 782 + "type": "text", 783 "notNull": true, 784 "autoincrement": false, 785 "default": null, 786 "generated": null, 787 + "name": "did", 788 "entityType": "columns", 789 + "table": "webauthn_registration_challenge" 790 }, 791 { 792 + "type": "text", 793 "notNull": true, 794 "autoincrement": false, 795 "default": null, 796 "generated": null, 797 + "name": "challenge", 798 "entityType": "columns", 799 + "table": "webauthn_registration_challenge" 800 }, 801 { 802 + "type": "integer", 803 + "notNull": true, 804 "autoincrement": false, 805 "default": null, 806 "generated": null, 807 + "name": "created_at", 808 "entityType": "columns", 809 + "table": "webauthn_registration_challenge" 810 }, 811 { 812 "type": "integer", ··· 814 "autoincrement": false, 815 "default": null, 816 "generated": null, 817 + "name": "expires_at", 818 "entityType": "columns", 819 + "table": "webauthn_registration_challenge" 820 }, 821 { 822 "columns": [ ··· 1009 "onUpdate": "NO ACTION", 1010 "onDelete": "CASCADE", 1011 "nameExplicit": false, 1012 + "name": "fk_webauthn_credential_did_account_did_fk", 1013 "entityType": "fks", 1014 + "table": "webauthn_credential" 1015 }, 1016 { 1017 "columns": [ ··· 1024 "onUpdate": "NO ACTION", 1025 "onDelete": "CASCADE", 1026 "nameExplicit": false, 1027 + "name": "fk_webauthn_registration_challenge_did_account_did_fk", 1028 "entityType": "fks", 1029 + "table": "webauthn_registration_challenge" 1030 }, 1031 { 1032 "columns": [ ··· 1086 }, 1087 { 1088 "columns": [ 1089 + "challenge" 1090 + ], 1091 + "nameExplicit": false, 1092 + "name": "passkey_login_challenge_pk", 1093 + "table": "passkey_login_challenge", 1094 + "entityType": "pks" 1095 + }, 1096 + { 1097 + "columns": [ 1098 "id" 1099 ], 1100 "nameExplicit": false, ··· 1131 }, 1132 { 1133 "columns": [ 1134 + "id" 1135 ], 1136 "nameExplicit": false, 1137 + "name": "webauthn_credential_pk", 1138 + "table": "webauthn_credential", 1139 "entityType": "pks" 1140 }, 1141 { 1142 "columns": [ 1143 + "token" 1144 ], 1145 "nameExplicit": false, 1146 + "name": "webauthn_registration_challenge_pk", 1147 + "table": "webauthn_registration_challenge", 1148 "entityType": "pks" 1149 }, 1150 { ··· 1210 { 1211 "columns": [ 1212 { 1213 + "value": "expires_at", 1214 + "isExpression": false 1215 + } 1216 + ], 1217 + "isUnique": false, 1218 + "where": null, 1219 + "origin": "manual", 1220 + "name": "passkey_login_challenge_expires_idx", 1221 + "entityType": "indexes", 1222 + "table": "passkey_login_challenge" 1223 + }, 1224 + { 1225 + "columns": [ 1226 + { 1227 "value": "did", 1228 "isExpression": false 1229 } ··· 1280 { 1281 "columns": [ 1282 { 1283 "value": "did", 1284 "isExpression": false 1285 } ··· 1304 "name": "webauthn_credential_id_idx", 1305 "entityType": "indexes", 1306 "table": "webauthn_credential" 1307 + }, 1308 + { 1309 + "columns": [ 1310 + { 1311 + "value": "expires_at", 1312 + "isExpression": false 1313 + } 1314 + ], 1315 + "isUnique": false, 1316 + "where": null, 1317 + "origin": "manual", 1318 + "name": "webauthn_registration_challenge_expires_idx", 1319 + "entityType": "indexes", 1320 + "table": "webauthn_registration_challenge" 1321 }, 1322 { 1323 "columns": [
+18 -5
packages/danaus/src/accounts/db/schema.ts
··· 259 // #region WebAuthn credentials 260 261 /** WebAuthn credential types */ 262 - export const enum WebAuthnCredentialType { 263 /** security key - non-discoverable, 2FA only */ 264 SecurityKey = 0, 265 - /** passkey - discoverable, can be used for passwordless (future) */ 266 Passkey = 1, 267 } 268 ··· 301 ); 302 303 /** WebAuthn registration challenges */ 304 - export const webauthnChallenge = sqliteTable( 305 - 'webauthn_challenge', 306 { 307 token: text().primaryKey(), 308 ··· 317 created_at: integer({ mode: 'timestamp' }).notNull(), 318 expires_at: integer({ mode: 'timestamp' }).notNull(), 319 }, 320 - (t) => [index('webauthn_challenge_expires_idx').on(t.expires_at)], 321 ); 322 323 // #endregion
··· 259 // #region WebAuthn credentials 260 261 /** WebAuthn credential types */ 262 + export enum WebAuthnCredentialType { 263 /** security key - non-discoverable, 2FA only */ 264 SecurityKey = 0, 265 + /** passkey - discoverable, can be used for passwordless */ 266 Passkey = 1, 267 } 268 ··· 301 ); 302 303 /** WebAuthn registration challenges */ 304 + export const webauthnRegistrationChallenge = sqliteTable( 305 + 'webauthn_registration_challenge', 306 { 307 token: text().primaryKey(), 308 ··· 317 created_at: integer({ mode: 'timestamp' }).notNull(), 318 expires_at: integer({ mode: 'timestamp' }).notNull(), 319 }, 320 + (t) => [index('webauthn_registration_challenge_expires_idx').on(t.expires_at)], 321 + ); 322 + 323 + /** challenges for passkey (passwordless) login - no DID since user is unknown */ 324 + export const passkeyLoginChallenge = sqliteTable( 325 + 'passkey_login_challenge', 326 + { 327 + /** base64url challenge (used as primary key) */ 328 + challenge: text().primaryKey(), 329 + 330 + created_at: integer({ mode: 'timestamp' }).notNull(), 331 + expires_at: integer({ mode: 'timestamp' }).notNull(), 332 + }, 333 + (t) => [index('passkey_login_challenge_expires_idx').on(t.expires_at)], 334 ); 335 336 // #endregion
+66 -14
packages/danaus/src/accounts/manager.ts
··· 43 export type BackupCode = typeof t.recoveryCode.$inferSelect; 44 export type VerifyChallenge = typeof t.verifyChallenge.$inferSelect; 45 export type WebauthnCredential = typeof t.webauthnCredential.$inferSelect; 46 - export type WebauthnChallenge = typeof t.webauthnChallenge.$inferSelect; 47 48 /** MFA status for an account */ 49 export interface MfaStatus { ··· 1483 /** 1484 * set the WebAuthn challenge on an existing verification challenge. 1485 * @param token verify challenge token 1486 - * @param webauthnChallenge base64url WebAuthn challenge 1487 */ 1488 - setVerifyChallengeWebAuthn(token: string, webauthnChallenge: string): void { 1489 this.db 1490 .update(t.verifyChallenge) 1491 - .set({ webauthn_challenge: webauthnChallenge }) 1492 .where(eq(t.verifyChallenge.token, token)) 1493 .run(); 1494 } ··· 1720 * @param challenge base64url challenge 1721 * @returns token for retrieving the challenge 1722 */ 1723 - createWebAuthnChallenge(did: Did, challenge: string): string { 1724 const token = nanoid(32); 1725 const now = new Date(); 1726 const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS); 1727 1728 this.db 1729 - .insert(t.webauthnChallenge) 1730 .values({ 1731 token: token, 1732 did: did, ··· 1744 * @param token the token 1745 * @returns WebAuthn challenge or null if expired/not found 1746 */ 1747 - getWebAuthnChallenge(token: string): WebauthnChallenge | null { 1748 const challenge = this.db 1749 .select() 1750 - .from(t.webauthnChallenge) 1751 - .where(eq(t.webauthnChallenge.token, token)) 1752 .get(); 1753 1754 if (!challenge) { ··· 1757 1758 const now = new Date(); 1759 if (challenge.expires_at <= now) { 1760 - this.db.delete(t.webauthnChallenge).where(eq(t.webauthnChallenge.token, token)).run(); 1761 return null; 1762 } 1763 ··· 1768 * delete a WebAuthn registration challenge. 1769 * @param token the token 1770 */ 1771 - deleteWebAuthnChallenge(token: string): void { 1772 - this.db.delete(t.webauthnChallenge).where(eq(t.webauthnChallenge.token, token)).run(); 1773 } 1774 1775 /** 1776 * clean up expired WebAuthn registration challenges. 1777 */ 1778 - cleanupExpiredWebAuthnChallenges(): void { 1779 const now = new Date(); 1780 - this.db.delete(t.webauthnChallenge).where(lte(t.webauthnChallenge.expires_at, now)).run(); 1781 } 1782 1783 // #endregion
··· 43 export type BackupCode = typeof t.recoveryCode.$inferSelect; 44 export type VerifyChallenge = typeof t.verifyChallenge.$inferSelect; 45 export type WebauthnCredential = typeof t.webauthnCredential.$inferSelect; 46 + export type WebauthnRegistrationChallenge = typeof t.webauthnRegistrationChallenge.$inferSelect; 47 48 /** MFA status for an account */ 49 export interface MfaStatus { ··· 1483 /** 1484 * set the WebAuthn challenge on an existing verification challenge. 1485 * @param token verify challenge token 1486 + * @param webauthnRegistrationChallenge base64url WebAuthn challenge 1487 */ 1488 + setVerifyChallengeWebAuthn(token: string, webauthnRegistrationChallenge: string): void { 1489 this.db 1490 .update(t.verifyChallenge) 1491 + .set({ webauthn_challenge: webauthnRegistrationChallenge }) 1492 .where(eq(t.verifyChallenge.token, token)) 1493 .run(); 1494 } ··· 1720 * @param challenge base64url challenge 1721 * @returns token for retrieving the challenge 1722 */ 1723 + createWebAuthnRegistrationChallenge(did: Did, challenge: string): string { 1724 const token = nanoid(32); 1725 const now = new Date(); 1726 const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS); 1727 1728 this.db 1729 + .insert(t.webauthnRegistrationChallenge) 1730 .values({ 1731 token: token, 1732 did: did, ··· 1744 * @param token the token 1745 * @returns WebAuthn challenge or null if expired/not found 1746 */ 1747 + getWebAuthnRegistrationChallenge(token: string): WebauthnRegistrationChallenge | null { 1748 const challenge = this.db 1749 .select() 1750 + .from(t.webauthnRegistrationChallenge) 1751 + .where(eq(t.webauthnRegistrationChallenge.token, token)) 1752 .get(); 1753 1754 if (!challenge) { ··· 1757 1758 const now = new Date(); 1759 if (challenge.expires_at <= now) { 1760 + this.db 1761 + .delete(t.webauthnRegistrationChallenge) 1762 + .where(eq(t.webauthnRegistrationChallenge.token, token)) 1763 + .run(); 1764 return null; 1765 } 1766 ··· 1771 * delete a WebAuthn registration challenge. 1772 * @param token the token 1773 */ 1774 + deleteWebAuthnRegistrationChallenge(token: string): void { 1775 + this.db 1776 + .delete(t.webauthnRegistrationChallenge) 1777 + .where(eq(t.webauthnRegistrationChallenge.token, token)) 1778 + .run(); 1779 } 1780 1781 /** 1782 * clean up expired WebAuthn registration challenges. 1783 */ 1784 + cleanupExpiredWebAuthnRegistrationChallenges(): void { 1785 const now = new Date(); 1786 + this.db 1787 + .delete(t.webauthnRegistrationChallenge) 1788 + .where(lte(t.webauthnRegistrationChallenge.expires_at, now)) 1789 + .run(); 1790 + } 1791 + 1792 + // #endregion 1793 + 1794 + // #region passkey login challenges 1795 + 1796 + /** 1797 + * create a passkey login challenge for passwordless authentication. 1798 + * @param challenge base64url challenge string 1799 + */ 1800 + createPasskeyLoginChallenge(challenge: string): void { 1801 + const now = new Date(); 1802 + const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS); 1803 + 1804 + this.db 1805 + .insert(t.passkeyLoginChallenge) 1806 + .values({ 1807 + challenge: challenge, 1808 + created_at: now, 1809 + expires_at: expiresAt, 1810 + }) 1811 + .run(); 1812 + } 1813 + 1814 + /** 1815 + * consume a passkey login challenge (delete and return if valid). 1816 + * @param challenge base64url challenge string 1817 + * @returns true if challenge was valid and consumed 1818 + */ 1819 + consumePasskeyLoginChallenge(challenge: string): boolean { 1820 + const now = new Date(); 1821 + 1822 + // clean up expired challenges 1823 + this.db.delete(t.passkeyLoginChallenge).where(lte(t.passkeyLoginChallenge.expires_at, now)).run(); 1824 + 1825 + // try to delete the challenge (returns the deleted row if it existed) 1826 + const result = this.db 1827 + .delete(t.passkeyLoginChallenge) 1828 + .where(eq(t.passkeyLoginChallenge.challenge, challenge)) 1829 + .returning() 1830 + .get(); 1831 + 1832 + return result != null; 1833 } 1834 1835 // #endregion
+17 -10
packages/danaus/src/accounts/webauthn.ts
··· 10 type VerifiedRegistrationResponse, 11 } from '@simplewebauthn/server'; 12 13 import type { WebauthnCredential } from './manager'; 14 15 // #region constants ··· 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 (params: GenerateRegistrationOptionsParams) => { 46 - const { rpId, rpName, userId, userName, excludeCredentials = [] } = params; 47 48 return await generateRegistrationOptions({ 49 rpName, ··· 56 transports: cred.transports ?? undefined, 57 })), 58 authenticatorSelection: { 59 - // non-discoverable for security keys (2FA only) 60 - residentKey: 'discouraged', 61 - // password already verified, no need for PIN/biometric 62 - userVerification: 'discouraged', 63 }, 64 }); 65 }; ··· 100 export interface GenerateAuthenticationOptionsParams { 101 /** relying party ID (domain) */ 102 rpId: string; 103 - /** allowed credentials */ 104 allowCredentials?: WebauthnCredential[]; 105 } 106 107 /** ··· 110 * @returns authentication options to send to the client 111 */ 112 export const generateWebAuthnAuthenticationOptions = async (params: GenerateAuthenticationOptionsParams) => { 113 - const { rpId, allowCredentials = [] } = params; 114 115 return await generateAuthenticationOptions({ 116 rpID: rpId, 117 - userVerification: 'discouraged', 118 - allowCredentials: allowCredentials.map((cred) => ({ 119 id: cred.credential_id, 120 transports: cred.transports ?? undefined, 121 })),
··· 10 type VerifiedRegistrationResponse, 11 } from '@simplewebauthn/server'; 12 13 + import { WebAuthnCredentialType } from './db/schema.ts'; 14 import type { WebauthnCredential } from './manager'; 15 16 // #region constants ··· 36 userName: string; 37 /** existing credentials to exclude */ 38 excludeCredentials?: WebauthnCredential[]; 39 + /** credential type to register */ 40 + credentialType: WebAuthnCredentialType; 41 } 42 43 /** 44 + * generates WebAuthn registration options for creating a new credential. 45 * @param params registration parameters 46 * @returns registration options to send to the client 47 */ 48 export const generateWebAuthnRegistrationOptions = async (params: GenerateRegistrationOptionsParams) => { 49 + const { rpId, rpName, userId, userName, excludeCredentials = [], credentialType } = params; 50 + 51 + // passkeys require discoverable credentials and user verification 52 + // security keys are non-discoverable 2FA only 53 + const isPasskey = credentialType === WebAuthnCredentialType.Passkey; 54 55 return await generateRegistrationOptions({ 56 rpName, ··· 63 transports: cred.transports ?? undefined, 64 })), 65 authenticatorSelection: { 66 + residentKey: isPasskey ? 'required' : 'discouraged', 67 + userVerification: isPasskey ? 'required' : 'discouraged', 68 }, 69 }); 70 }; ··· 105 export interface GenerateAuthenticationOptionsParams { 106 /** relying party ID (domain) */ 107 rpId: string; 108 + /** allowed credentials (omit for discoverable/passkey flow) */ 109 allowCredentials?: WebauthnCredential[]; 110 + /** whether user verification is required (true for passkey login) */ 111 + userVerificationRequired?: boolean; 112 } 113 114 /** ··· 117 * @returns authentication options to send to the client 118 */ 119 export const generateWebAuthnAuthenticationOptions = async (params: GenerateAuthenticationOptionsParams) => { 120 + const { rpId, allowCredentials, userVerificationRequired = false } = params; 121 122 return await generateAuthenticationOptions({ 123 rpID: rpId, 124 + userVerification: userVerificationRequired ? 'required' : 'discouraged', 125 + allowCredentials: allowCredentials?.map((cred) => ({ 126 id: cred.credential_id, 127 transports: cred.transports ?? undefined, 128 })),
+3
packages/danaus/src/jsx.d.ts
··· 10 'data-options': string; 11 'data-auto-submit'?: 'true' | 'false'; 12 }; 13 } 14 } 15 }
··· 10 'data-options': string; 11 'data-auto-submit'?: 'true' | 'false'; 12 }; 13 + 'danaus-passkey-login': HTMLAttributes & { 14 + 'data-challenge-url': string; 15 + }; 16 } 17 } 18 }
+39 -4
packages/danaus/src/web/controllers/account/security/overview.tsx
··· 28 did, 29 WebAuthnCredentialType.SecurityKey, 30 ); 31 const hasMfa = totpCredentials.length > 0 || securityKeys.length > 0; 32 33 return render( ··· 46 account={account} 47 totpCredentials={totpCredentials} 48 securityKeys={securityKeys} 49 /> 50 51 {hasMfa && <RecoverySection />} ··· 95 account, 96 totpCredentials, 97 securityKeys, 98 }: { 99 account: Account; 100 totpCredentials: TotpCredential[]; 101 securityKeys: WebauthnCredential[]; 102 }) => { 103 return ( 104 <div class="flex flex-col gap-2"> ··· 191 </div> 192 ))} 193 194 - {/* Passkeys placeholder (future) */} 195 196 {/* Add another way to sign in */} 197 <button ··· 241 </div> 242 </a> 243 244 - <button disabled class="flex items-center gap-4 rounded-md px-4 py-3 text-left opacity-50"> 245 <PasskeysOutlined size={24} class="shrink-0" /> 246 247 <div class="min-w-0 grow"> 248 <p class="text-base-300 font-medium">Passkey</p> 249 <p class="text-base-300 text-neutral-foreground-3"> 250 - Use Face ID, Touch ID, or Windows Hello (coming soon) 251 </p> 252 </div> 253 - </button> 254 </Dialog.Content> 255 256 <Dialog.Actions>
··· 28 did, 29 WebAuthnCredentialType.SecurityKey, 30 ); 31 + const passkeys = accountManager.listWebAuthnCredentialsByType(did, WebAuthnCredentialType.Passkey); 32 const hasMfa = totpCredentials.length > 0 || securityKeys.length > 0; 33 34 return render( ··· 47 account={account} 48 totpCredentials={totpCredentials} 49 securityKeys={securityKeys} 50 + passkeys={passkeys} 51 /> 52 53 {hasMfa && <RecoverySection />} ··· 97 account, 98 totpCredentials, 99 securityKeys, 100 + passkeys, 101 }: { 102 account: Account; 103 totpCredentials: TotpCredential[]; 104 securityKeys: WebauthnCredential[]; 105 + passkeys: WebauthnCredential[]; 106 }) => { 107 return ( 108 <div class="flex flex-col gap-2"> ··· 195 </div> 196 ))} 197 198 + {/* Passkeys */} 199 + {passkeys.map((key) => ( 200 + <div class="flex items-center gap-4 px-4 py-3"> 201 + <PasskeysOutlined size={24} class="shrink-0" /> 202 + 203 + <div class="min-w-0 grow"> 204 + <p class="text-base-300 font-medium wrap-break-word">{key.name}</p> 205 + <p class="text-base-300 text-neutral-foreground-3"> 206 + Passkey · Added {key.created_at.toLocaleDateString()} 207 + </p> 208 + </div> 209 + 210 + <Menu.Root> 211 + <Menu.Trigger> 212 + <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"> 213 + <DotGrid1x3HorizontalOutlined size={16} /> 214 + </button> 215 + </Menu.Trigger> 216 + 217 + <Menu.Popover> 218 + <Menu.List> 219 + <Menu.Item href={routes.account.security.webauthn.remove.href({ id: key.id })}> 220 + Remove 221 + </Menu.Item> 222 + </Menu.List> 223 + </Menu.Popover> 224 + </Menu.Root> 225 + </div> 226 + ))} 227 228 {/* Add another way to sign in */} 229 <button ··· 273 </div> 274 </a> 275 276 + <a 277 + href={routes.account.security.webauthn.register.href(undefined, { type: 'passkey' })} 278 + 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" 279 + > 280 <PasskeysOutlined size={24} class="shrink-0" /> 281 282 <div class="min-w-0 grow"> 283 <p class="text-base-300 font-medium">Passkey</p> 284 <p class="text-base-300 text-neutral-foreground-3"> 285 + Use Face ID, Touch ID, or Windows Hello 286 </p> 287 </div> 288 + </a> 289 </Dialog.Content> 290 291 <Dialog.Actions>
+2 -6
packages/danaus/src/web/controllers/account/security/recovery.tsx
··· 107 </Dialog.Content> 108 109 <Dialog.Actions> 110 - <Button type="button" href={routes.account.security.overview.href()}> 111 - Cancel 112 - </Button> 113 114 <Button type="submit" variant="primary"> 115 Regenerate ··· 165 </Dialog.Content> 166 167 <Dialog.Actions> 168 - <Button type="button" href={routes.account.security.overview.href()}> 169 - Cancel 170 - </Button> 171 172 <Button type="submit" variant="primary"> 173 Delete
··· 107 </Dialog.Content> 108 109 <Dialog.Actions> 110 + <Button href={routes.account.security.overview.href()}>Cancel</Button> 111 112 <Button type="submit" variant="primary"> 113 Regenerate ··· 163 </Dialog.Content> 164 165 <Dialog.Actions> 166 + <Button href={routes.account.security.overview.href()}>Cancel</Button> 167 168 <Button type="submit" variant="primary"> 169 Delete
+2 -6
packages/danaus/src/web/controllers/account/security/totp.tsx
··· 133 </Dialog.Content> 134 135 <Dialog.Actions> 136 - <Button type="button" href={routes.account.security.overview.href()}> 137 - Cancel 138 - </Button> 139 140 <Button type="submit" variant="primary"> 141 Save ··· 197 </Dialog.Content> 198 199 <Dialog.Actions> 200 - <Button type="button" href={routes.account.security.overview.href()}> 201 - Cancel 202 - </Button> 203 204 <Button type="submit" variant="primary"> 205 Remove
··· 133 </Dialog.Content> 134 135 <Dialog.Actions> 136 + <Button href={routes.account.security.overview.href()}>Cancel</Button> 137 138 <Button type="submit" variant="primary"> 139 Save ··· 195 </Dialog.Content> 196 197 <Dialog.Actions> 198 + <Button href={routes.account.security.overview.href()}>Cancel</Button> 199 200 <Button type="submit" variant="primary"> 201 Remove
+77 -59
packages/danaus/src/web/controllers/account/security/webauthn.tsx
··· 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, Dialog, Field, Input } from '#web/primitives/index.ts'; 12 import { routes } from '#web/routes.ts'; 13 14 import { completeWebAuthnForm, initiateWebAuthnRegistration, removeWebAuthnForm } from './webauthn/lib/forms'; ··· 24 25 // require sudo mode 26 if (!accountManager.isSessionElevated(session)) { 27 - redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 28 } 29 30 const account = accountManager.getAccount(session.did)!; 31 32 const { fields } = completeWebAuthnForm; 33 34 // check if we have an existing token (form was submitted but failed) ··· 37 38 if (token) { 39 // try to get existing challenge 40 - const existingChallenge = accountManager.getWebAuthnChallenge(token); 41 if (existingChallenge) { 42 // regenerate options with the same challenge 43 - const state = await initiateWebAuthnRegistration(session.did, account.handle ?? session.did); 44 // delete old challenge and use new one 45 - accountManager.deleteWebAuthnChallenge(token); 46 token = state.token; 47 options = state.options; 48 } ··· 50 51 if (!options) { 52 // generate new registration 53 - const state = await initiateWebAuthnRegistration(session.did, account.handle ?? session.did); 54 token = state.token; 55 options = state.options; 56 } 57 58 const generalError = fields.issues()?.at(0); 59 60 return render( 61 <BaseLayout> 62 - <title>Set up security key - Danaus</title> 63 64 <script type="module" src={routes.assets.href({ path: 'webauthn-register.js' })} /> 65 66 - <div class="flex flex-1 items-center justify-center p-4"> 67 <div class="w-full max-w-120 rounded-xl bg-neutral-background-1 shadow-64"> 68 <danaus-webauthn-register class="contents" data-options={JSON.stringify(options)}> 69 - <form {...completeWebAuthnForm} class="contents"> 70 - <Dialog.Body> 71 - <Dialog.Title>Set up security key</Dialog.Title> 72 73 - <Dialog.Content class="flex flex-col gap-4"> 74 - <p class="text-base-300"> 75 - Insert your security key and follow your browser's prompts to register it. 76 - </p> 77 - 78 - <input {...fields.token.as('hidden', token!)} /> 79 80 - <p 81 - data-target="webauthn-register.status" 82 - class="text-base-300 text-neutral-foreground-3" 83 - > 84 - Initializing... 85 - </p> 86 87 - <input 88 - {...fields.response.as('hidden', '')} 89 - data-target="webauthn-register.response" 90 - /> 91 92 - <Field 93 - label="Name" 94 - hint="Give this security key a name to help you identify it" 95 - validationMessageText={fields.name.issues()?.at(0)?.message} 96 - > 97 - <Input 98 - {...fields.name.as('text')} 99 - placeholder={accountManager.generateWebAuthnName( 100 - session.did, 101 - WebAuthnCredentialType.SecurityKey, 102 - )} 103 /> 104 - </Field> 105 106 - {generalError && ( 107 - <p role="alert" class="text-base-300 text-status-danger-foreground-1"> 108 - {generalError.message} 109 - </p> 110 - )} 111 - </Dialog.Content> 112 113 - <Dialog.Actions> 114 - <Button type="button" href={routes.account.security.overview.href()}> 115 - Cancel 116 - </Button> 117 118 - <Button type="submit" variant="primary" disabled data-target="webauthn-register.submit"> 119 - Save 120 - </Button> 121 - </Dialog.Actions> 122 - </Dialog.Body> 123 - </form> 124 - </danaus-webauthn-register> 125 </div> 126 </div> 127 </BaseLayout>, ··· 178 </Dialog.Content> 179 180 <Dialog.Actions> 181 - <Button type="button" href={routes.account.security.overview.href()}> 182 - Cancel 183 - </Button> 184 185 <Button type="submit" variant="primary"> 186 Remove
··· 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, Dialog, Field, Input, MessageBar } from '#web/primitives/index.ts'; 12 import { routes } from '#web/routes.ts'; 13 14 import { completeWebAuthnForm, initiateWebAuthnRegistration, removeWebAuthnForm } from './webauthn/lib/forms'; ··· 24 25 // require sudo mode 26 if (!accountManager.isSessionElevated(session)) { 27 + redirect(routes.verify.index.href(undefined, { redirect: url.pathname + url.search })); 28 } 29 30 const account = accountManager.getAccount(session.did)!; 31 32 + // determine credential type from query param 33 + const typeParam = url.searchParams.get('type'); 34 + const credentialType = 35 + typeParam === 'passkey' ? WebAuthnCredentialType.Passkey : WebAuthnCredentialType.SecurityKey; 36 + const isPasskey = credentialType === WebAuthnCredentialType.Passkey; 37 + 38 const { fields } = completeWebAuthnForm; 39 40 // check if we have an existing token (form was submitted but failed) ··· 43 44 if (token) { 45 // try to get existing challenge 46 + const existingChallenge = accountManager.getWebAuthnRegistrationChallenge(token); 47 if (existingChallenge) { 48 // regenerate options with the same challenge 49 + const state = await initiateWebAuthnRegistration( 50 + session.did, 51 + account.handle ?? session.did, 52 + credentialType, 53 + ); 54 // delete old challenge and use new one 55 + accountManager.deleteWebAuthnRegistrationChallenge(token); 56 token = state.token; 57 options = state.options; 58 } ··· 60 61 if (!options) { 62 // generate new registration 63 + const state = await initiateWebAuthnRegistration( 64 + session.did, 65 + account.handle ?? session.did, 66 + credentialType, 67 + ); 68 token = state.token; 69 options = state.options; 70 } 71 72 const generalError = fields.issues()?.at(0); 73 74 + const credentialLabel = isPasskey ? 'passkey' : 'security key'; 75 + 76 return render( 77 <BaseLayout> 78 + <title>Set up {credentialLabel} - Danaus</title> 79 80 <script type="module" src={routes.assets.href({ path: 'webauthn-register.js' })} /> 81 82 + <div class="flex flex-1 flex-col items-center justify-center gap-4 p-4"> 83 + <noscript> 84 + <MessageBar.Root intent="warning" layout="singleline" class="w-full max-w-120"> 85 + <MessageBar.Body>JavaScript is required to set up {credentialLabel}s.</MessageBar.Body> 86 + </MessageBar.Root> 87 + </noscript> 88 + 89 <div class="w-full max-w-120 rounded-xl bg-neutral-background-1 shadow-64"> 90 <danaus-webauthn-register class="contents" data-options={JSON.stringify(options)}> 91 + <form {...completeWebAuthnForm} class="contents"> 92 + <Dialog.Body> 93 + <Dialog.Title>Set up {credentialLabel}</Dialog.Title> 94 95 + <Dialog.Content class="flex flex-col gap-4"> 96 + <p class="text-base-300"> 97 + {isPasskey 98 + ? "Follow your browser's prompts to register your passkey." 99 + : "Insert your security key and follow your browser's prompts to register it."} 100 + </p> 101 102 + <input {...fields.token.as('hidden', token!)} /> 103 + <input 104 + {...fields.credentialType.as('hidden', isPasskey ? 'passkey' : 'security-key')} 105 + /> 106 107 + <p 108 + data-target="webauthn-register.status" 109 + class="text-base-300 text-neutral-foreground-3 empty:hidden" 110 + /> 111 112 + <input 113 + {...fields.response.as('hidden', '')} 114 + data-target="webauthn-register.response" 115 /> 116 117 + <Field 118 + label="Name" 119 + hint={`Give this ${credentialLabel} a name to help you identify it`} 120 + validationMessageText={fields.name.issues()?.at(0)?.message} 121 + > 122 + <Input 123 + {...fields.name.as('text')} 124 + placeholder={accountManager.generateWebAuthnName(session.did, credentialType)} 125 + /> 126 + </Field> 127 128 + {generalError && ( 129 + <p role="alert" class="text-base-300 text-status-danger-foreground-1"> 130 + {generalError.message} 131 + </p> 132 + )} 133 + </Dialog.Content> 134 135 + <Dialog.Actions> 136 + <Button href={routes.account.security.overview.href()}>Cancel</Button> 137 + 138 + <Button variant="primary" data-target="webauthn-register.start" disabled> 139 + Register {credentialLabel} 140 + </Button> 141 + </Dialog.Actions> 142 + </Dialog.Body> 143 + </form> 144 + </danaus-webauthn-register> 145 </div> 146 </div> 147 </BaseLayout>, ··· 198 </Dialog.Content> 199 200 <Dialog.Actions> 201 + <Button href={routes.account.security.overview.href()}>Cancel</Button> 202 203 <Button type="submit" variant="primary"> 204 Remove
+11 -4
packages/danaus/src/web/controllers/account/security/webauthn/lib/forms.ts
··· 23 * initiates WebAuthn registration by generating a challenge. 24 * @param did account DID 25 * @param userName user display name (handle) 26 * @returns registration state with token and options 27 */ 28 export const initiateWebAuthnRegistration = async ( 29 did: Did, 30 userName: string, 31 ): Promise<WebAuthnRegistrationState> => { 32 const { accountManager, config } = getAppContext(); 33 ··· 39 userId: did, 40 userName: userName, 41 excludeCredentials: existingCredentials, 42 }); 43 44 // store the challenge 45 - const token = accountManager.createWebAuthnChallenge(did, options.challenge); 46 47 return { token, options }; 48 }; ··· 53 export const completeWebAuthnForm = form( 54 v.object({ 55 token: v.pipe(v.string(), v.minLength(1)), 56 name: v.optional(v.pipe(v.string(), normalizeWhitespace, v.maxLength(32, `Name is too long`))), 57 response: v.pipe( 58 v.string(), ··· 78 const { did } = getSession(); 79 80 // get the challenge 81 - const challenge = accountManager.getWebAuthnChallenge(data.token); 82 if (!challenge) { 83 invalid(`Registration expired, please try again`); 84 } ··· 106 } 107 108 // delete the challenge 109 - accountManager.deleteWebAuthnChallenge(data.token); 110 111 requireSudo(); 112 113 // store the credential 114 const { registrationInfo } = verification; 115 try { 116 accountManager.createWebAuthnCredential({ 117 did: did, 118 - type: WebAuthnCredentialType.SecurityKey, 119 name: data.name, 120 credentialId: registrationInfo.credential.id, 121 publicKey: registrationInfo.credential.publicKey,
··· 23 * initiates WebAuthn registration by generating a challenge. 24 * @param did account DID 25 * @param userName user display name (handle) 26 + * @param credentialType type of credential to register 27 * @returns registration state with token and options 28 */ 29 export const initiateWebAuthnRegistration = async ( 30 did: Did, 31 userName: string, 32 + credentialType: WebAuthnCredentialType, 33 ): Promise<WebAuthnRegistrationState> => { 34 const { accountManager, config } = getAppContext(); 35 ··· 41 userId: did, 42 userName: userName, 43 excludeCredentials: existingCredentials, 44 + credentialType, 45 }); 46 47 // store the challenge 48 + const token = accountManager.createWebAuthnRegistrationChallenge(did, options.challenge); 49 50 return { token, options }; 51 }; ··· 56 export const completeWebAuthnForm = form( 57 v.object({ 58 token: v.pipe(v.string(), v.minLength(1)), 59 + credentialType: v.picklist(['security-key', 'passkey']), 60 name: v.optional(v.pipe(v.string(), normalizeWhitespace, v.maxLength(32, `Name is too long`))), 61 response: v.pipe( 62 v.string(), ··· 82 const { did } = getSession(); 83 84 // get the challenge 85 + const challenge = accountManager.getWebAuthnRegistrationChallenge(data.token); 86 if (!challenge) { 87 invalid(`Registration expired, please try again`); 88 } ··· 110 } 111 112 // delete the challenge 113 + accountManager.deleteWebAuthnRegistrationChallenge(data.token); 114 115 requireSudo(); 116 117 // store the credential 118 const { registrationInfo } = verification; 119 + const credentialType = 120 + data.credentialType === 'passkey' ? WebAuthnCredentialType.Passkey : WebAuthnCredentialType.SecurityKey; 121 + 122 try { 123 accountManager.createWebAuthnCredential({ 124 did: did, 125 + type: credentialType, 126 name: data.name, 127 credentialId: registrationInfo.credential.id, 128 publicKey: registrationInfo.credential.publicKey,
+104 -46
packages/danaus/src/web/controllers/login.tsx
··· 1 - import type { BuildAction } from '@oomfware/fetch-router'; 2 import { forms } from '@oomfware/forms'; 3 import { render } from '@oomfware/jsx'; 4 5 import { BaseLayout } from '#web/layouts/base.tsx'; 6 import { Button, Checkbox, Field, Input } from '#web/primitives/index.ts'; 7 import { routes } from '#web/routes.ts'; 8 9 - import { loginForm } from './login/lib/forms.ts'; 10 11 export default { 12 - middleware: [forms({ loginForm })], 13 - action({ url }) { 14 - const { fields } = loginForm; 15 16 - const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 17 18 - return render( 19 - <BaseLayout> 20 - <title>Sign in - Danaus</title> 21 22 - <div class="flex flex-1 items-center justify-center p-4"> 23 - <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 24 - <form {...loginForm} class="flex flex-col gap-6"> 25 - <h1 class="text-base-500 font-semibold">Sign in to your account</h1> 26 27 - <input {...fields.redirect.as('hidden', redirectUrl ?? routes.account.overview.href())} /> 28 29 - <Field 30 - label="Handle or email" 31 - required 32 - validationMessageText={fields.identifier.issues()?.[0]!.message} 33 - > 34 - <Input 35 - {...fields.identifier.as('text')} 36 - autocomplete="username" 37 - placeholder="alice.bsky.social" 38 - required 39 - autofocus 40 - /> 41 - </Field> 42 43 - <Field 44 - label="Password" 45 - required 46 - validationMessageText={fields._password.issues()?.[0]!.message} 47 - > 48 - <Input {...fields._password.as('password')} autocomplete="current-password" required /> 49 - </Field> 50 51 - <Checkbox name="remember" value="true"> 52 - Remember this device 53 - </Checkbox> 54 55 - <Button type="submit" variant="primary"> 56 - Sign in 57 - </Button> 58 - </form> 59 - </div> 60 - </div> 61 - </BaseLayout>, 62 - ); 63 }, 64 - } satisfies BuildAction<'ANY', typeof routes.login>;
··· 1 + import type { Controller } from '@oomfware/fetch-router'; 2 import { forms } from '@oomfware/forms'; 3 import { render } from '@oomfware/jsx'; 4 5 + import { generateWebAuthnAuthenticationOptions } from '#app/accounts/webauthn.ts'; 6 + 7 import { BaseLayout } from '#web/layouts/base.tsx'; 8 + import { getAppContext } from '#web/middlewares/app-context.ts'; 9 import { Button, Checkbox, Field, Input } from '#web/primitives/index.ts'; 10 import { routes } from '#web/routes.ts'; 11 12 + import { loginForm, passkeyLoginForm } from './login/lib/forms.ts'; 13 14 export default { 15 + middleware: [], 16 + actions: { 17 + index: { 18 + middleware: [forms({ loginForm, passkeyLoginForm })], 19 + action({ url }) { 20 + const { fields } = loginForm; 21 + const passkeyFields = passkeyLoginForm.fields; 22 23 + const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 24 25 + return render( 26 + <BaseLayout> 27 + <title>Sign in - Danaus</title> 28 29 + <script type="module" src={routes.assets.href({ path: 'webauthn-passkey-login.js' })} /> 30 31 + <div class="flex flex-1 items-center justify-center p-4"> 32 + <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 33 + <form {...loginForm} class="flex flex-col gap-6"> 34 + <h1 class="text-base-500 font-semibold">Sign in to your account</h1> 35 36 + <input {...fields.redirect.as('hidden', redirectUrl ?? routes.account.overview.href())} /> 37 38 + <Field 39 + label="Handle or email" 40 + required 41 + validationMessageText={fields.identifier.issues()?.[0]?.message} 42 + > 43 + <Input 44 + {...fields.identifier.as('text')} 45 + autocomplete="username" 46 + placeholder="alice.bsky.social" 47 + required 48 + autofocus 49 + /> 50 + </Field> 51 52 + <Field 53 + label="Password" 54 + required 55 + validationMessageText={fields._password.issues()?.[0]?.message} 56 + > 57 + <Input {...fields._password.as('password')} autocomplete="current-password" required /> 58 + </Field> 59 60 + <Checkbox name="remember" value="true"> 61 + Remember this device 62 + </Checkbox> 63 + 64 + <Button type="submit" variant="primary"> 65 + Sign in 66 + </Button> 67 + </form> 68 + 69 + <danaus-passkey-login 70 + class="contents" 71 + data-challenge-url={routes.login.passkey.challenge.href()} 72 + > 73 + <div class="flex items-center gap-4 py-4"> 74 + <div class="h-px flex-1 bg-neutral-stroke-2" /> 75 + <span class="text-base-200 text-neutral-foreground-3">or</span> 76 + <div class="h-px flex-1 bg-neutral-stroke-2" /> 77 + </div> 78 + 79 + <form {...passkeyLoginForm} class="contents" data-target="passkey-login.form"> 80 + <input 81 + {...passkeyFields.redirect.as('hidden', redirectUrl ?? routes.account.overview.href())} 82 + /> 83 + <input 84 + {...passkeyFields.response.as('hidden', '')} 85 + data-target="passkey-login.response" 86 + /> 87 + 88 + <Button disabled data-target="passkey-login.start"> 89 + Sign in with passkey 90 + </Button> 91 + 92 + <p 93 + data-target="passkey-login.status" 94 + class="mt-2 text-center text-base-200 text-neutral-foreground-3 empty:hidden" 95 + /> 96 + </form> 97 + </danaus-passkey-login> 98 + </div> 99 + </div> 100 + </BaseLayout>, 101 + ); 102 + }, 103 + }, 104 + passkey: { 105 + async challenge() { 106 + const { accountManager, config } = getAppContext(); 107 + 108 + // generate discoverable authentication options (no allowCredentials) 109 + const options = await generateWebAuthnAuthenticationOptions({ 110 + rpId: config.service.hostname, 111 + userVerificationRequired: true, 112 + }); 113 + 114 + // store challenge for verification (using null DID since we don't know the user yet) 115 + // we'll use the challenge itself as a lookup key 116 + accountManager.createPasskeyLoginChallenge(options.challenge); 117 + 118 + return Response.json(options); 119 + }, 120 + }, 121 }, 122 + } satisfies Controller<typeof routes.login>;
+95 -2
packages/danaus/src/web/controllers/login/lib/forms.ts
··· 171 172 const challenge = accountManager.getVerifyChallenge(data.challenge); 173 if (challenge === null) { 174 - redirect(routes.login.href(undefined, { redirect: data.redirect })); 175 } 176 177 const isSudo = challenge.session_id !== null; ··· 220 }, 221 ); 222 223 export const verifyWebAuthnForm = form( 224 v.object({ 225 challenge: v.string(), ··· 256 257 const challenge = accountManager.getVerifyChallenge(data.challenge); 258 if (challenge === null) { 259 - redirect(routes.login.href(undefined, { redirect: data.redirect })); 260 } 261 262 if (!challenge.webauthn_challenge) {
··· 171 172 const challenge = accountManager.getVerifyChallenge(data.challenge); 173 if (challenge === null) { 174 + redirect(routes.login.index.href(undefined, { redirect: data.redirect })); 175 } 176 177 const isSudo = challenge.session_id !== null; ··· 220 }, 221 ); 222 223 + export const passkeyLoginForm = form( 224 + v.object({ 225 + response: v.pipe( 226 + v.string(), 227 + v.parseJson(), 228 + v.object({ 229 + id: v.string(), 230 + rawId: v.string(), 231 + response: v.object({ 232 + clientDataJSON: v.string(), 233 + authenticatorData: v.string(), 234 + signature: v.string(), 235 + userHandle: v.optional(v.string()), 236 + }), 237 + authenticatorAttachment: v.optional(v.picklist(['cross-platform', 'platform'])), 238 + clientExtensionResults: v.object({ 239 + appid: v.optional(v.boolean()), 240 + credProps: v.optional( 241 + v.object({ 242 + rk: v.optional(v.boolean()), 243 + }), 244 + ), 245 + hmacCreateSecret: v.optional(v.boolean()), 246 + }), 247 + type: v.literal('public-key'), 248 + }), 249 + ), 250 + redirect: v.string(), 251 + }), 252 + async (data) => { 253 + const { accountManager, config } = getAppContext(); 254 + const { request } = getContext(); 255 + 256 + // find the credential by ID (discoverable flow) 257 + const credential = accountManager.getWebAuthnCredentialByCredentialId(data.response.id); 258 + if (credential === null) { 259 + invalid(`Passkey not recognized`); 260 + } 261 + 262 + // extract challenge from clientDataJSON and verify it's one we issued 263 + const clientDataJSON = JSON.parse( 264 + Buffer.from(data.response.response.clientDataJSON, 'base64url').toString('utf-8'), 265 + ); 266 + const challenge = clientDataJSON.challenge; 267 + 268 + if (!accountManager.consumePasskeyLoginChallenge(challenge)) { 269 + invalid(`Invalid or expired challenge`); 270 + } 271 + 272 + // verify the authentication response 273 + const { verifyWebAuthnAuthentication } = await import('#app/accounts/webauthn.ts'); 274 + 275 + try { 276 + const verification = await verifyWebAuthnAuthentication({ 277 + response: data.response, 278 + expectedChallenge: challenge, 279 + expectedOrigin: config.service.publicUrl, 280 + expectedRpId: new URL(config.service.publicUrl).hostname, 281 + credential, 282 + }); 283 + 284 + if (!verification.verified) { 285 + invalid(`Passkey verification failed`); 286 + } 287 + 288 + // update counter 289 + accountManager.updateWebAuthnCredentialCounter( 290 + credential.id, 291 + verification.authenticationInfo.newCounter, 292 + ); 293 + } catch { 294 + invalid(`Passkey verification failed`); 295 + } 296 + 297 + // create session 298 + const { session, token } = await accountManager.createWebSession({ 299 + did: credential.did, 300 + remember: true, // passkey login implies trusted device 301 + userAgent: request.headers.get('user-agent') ?? undefined, 302 + ip: getServer().requestIP(request)?.address, 303 + }); 304 + 305 + setWebSessionToken(request, token, { 306 + expires: session.expires_at, 307 + httpOnly: true, 308 + sameSite: 'lax', 309 + path: '/', 310 + }); 311 + 312 + redirect(data.redirect); 313 + }, 314 + ); 315 + 316 export const verifyWebAuthnForm = form( 317 v.object({ 318 challenge: v.string(), ··· 349 350 const challenge = accountManager.getVerifyChallenge(data.challenge); 351 if (challenge === null) { 352 + redirect(routes.login.index.href(undefined, { redirect: data.redirect })); 353 } 354 355 if (!challenge.webauthn_challenge) {
+20 -10
packages/danaus/src/web/controllers/verify.tsx
··· 15 import { BaseLayout } from '#web/layouts/base.tsx'; 16 import { getAppContext } from '#web/middlewares/app-context.ts'; 17 import { tryGetSession } from '#web/middlewares/session.ts'; 18 - import { Button, Field, Input, Menu } from '#web/primitives/index.ts'; 19 import { routes } from '#web/routes.ts'; 20 21 import { verifyForm, verifyWebAuthnForm, type AuthFactor } from './login/lib/forms.ts'; ··· 48 const challenge = accountManager.getVerifyChallenge(tokenParam); 49 if (challenge === null) { 50 // invalid or expired token → redirect to login (don't fall back to sudo) 51 - redirect(routes.login.href(undefined, { redirect: redirectUrl })); 52 } 53 54 const mfaStatus = accountManager.getMfaStatus(challenge.did); 55 if (mfaStatus === null) { 56 // no MFA configured (shouldn't happen, but handle it) 57 - redirect(routes.login.href(undefined, { redirect: redirectUrl })); 58 } 59 60 return { ··· 68 // mode 2: sudo - no token, but has session 69 const session = tryGetSession(); 70 if (session === null) { 71 - redirect(routes.login.href(undefined, { redirect: redirectUrl })); 72 } 73 74 // already elevated? redirect directly to target ··· 101 102 // for MFA login without mfaStatus, this shouldn't happen but redirect to login 103 if (ctx.mfaStatus === null) { 104 - redirect(routes.login.href(undefined, { redirect: ctx.redirectUrl })); 105 } 106 107 // redirect to preferred method ··· 198 199 <script src="/assets/webauthn-authenticate.js" type="module" /> 200 201 - <div class="flex flex-1 items-center justify-center p-4"> 202 <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 203 <danaus-webauthn-authenticate class="contents" data-options={JSON.stringify(options)}> 204 - <form {...verifyWebAuthnForm} class="flex flex-col gap-6" data-target="webauthn-authenticate.form"> 205 <input {...fields.challenge.as('hidden', ctx.challenge.token)} /> 206 <input {...fields.redirect.as('hidden', ctx.redirectUrl)} /> 207 ··· 217 218 <input {...fields.response.as('hidden', '')} data-target="webauthn-authenticate.response" /> 219 220 - <Button data-target="webauthn-authenticate.start" type="button" variant="primary"> 221 Use security key 222 </Button> 223 224 <div 225 data-target="webauthn-authenticate.status" 226 - class="text-center text-base-300 text-neutral-foreground-3" 227 /> 228 229 <OtherMethodsMenu ··· 293 294 // password is only allowed in sudo mode for non-MFA users 295 if (!ctx.isSudo) { 296 - redirect(routes.login.href(undefined, { redirect: ctx.redirectUrl })); 297 } 298 299 // MFA users must use MFA methods
··· 15 import { BaseLayout } from '#web/layouts/base.tsx'; 16 import { getAppContext } from '#web/middlewares/app-context.ts'; 17 import { tryGetSession } from '#web/middlewares/session.ts'; 18 + import { Button, Field, Input, Menu, MessageBar } from '#web/primitives/index.ts'; 19 import { routes } from '#web/routes.ts'; 20 21 import { verifyForm, verifyWebAuthnForm, type AuthFactor } from './login/lib/forms.ts'; ··· 48 const challenge = accountManager.getVerifyChallenge(tokenParam); 49 if (challenge === null) { 50 // invalid or expired token → redirect to login (don't fall back to sudo) 51 + redirect(routes.login.index.href(undefined, { redirect: redirectUrl })); 52 } 53 54 const mfaStatus = accountManager.getMfaStatus(challenge.did); 55 if (mfaStatus === null) { 56 // no MFA configured (shouldn't happen, but handle it) 57 + redirect(routes.login.index.href(undefined, { redirect: redirectUrl })); 58 } 59 60 return { ··· 68 // mode 2: sudo - no token, but has session 69 const session = tryGetSession(); 70 if (session === null) { 71 + redirect(routes.login.index.href(undefined, { redirect: redirectUrl })); 72 } 73 74 // already elevated? redirect directly to target ··· 101 102 // for MFA login without mfaStatus, this shouldn't happen but redirect to login 103 if (ctx.mfaStatus === null) { 104 + redirect(routes.login.index.href(undefined, { redirect: ctx.redirectUrl })); 105 } 106 107 // redirect to preferred method ··· 198 199 <script src="/assets/webauthn-authenticate.js" type="module" /> 200 201 + <div class="flex flex-1 flex-col items-center justify-center gap-4 p-4"> 202 + <noscript> 203 + <MessageBar.Root intent="warning" layout="singleline" class="w-full max-w-96"> 204 + <MessageBar.Body>JavaScript is required to use security keys.</MessageBar.Body> 205 + </MessageBar.Root> 206 + </noscript> 207 + 208 <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 209 <danaus-webauthn-authenticate class="contents" data-options={JSON.stringify(options)}> 210 + <form 211 + {...verifyWebAuthnForm} 212 + class="flex flex-col gap-6" 213 + data-target="webauthn-authenticate.form" 214 + > 215 <input {...fields.challenge.as('hidden', ctx.challenge.token)} /> 216 <input {...fields.redirect.as('hidden', ctx.redirectUrl)} /> 217 ··· 227 228 <input {...fields.response.as('hidden', '')} data-target="webauthn-authenticate.response" /> 229 230 + <Button data-target="webauthn-authenticate.start" variant="primary" disabled> 231 Use security key 232 </Button> 233 234 <div 235 data-target="webauthn-authenticate.status" 236 + class="text-center text-base-300 text-neutral-foreground-3 empty:hidden" 237 /> 238 239 <OtherMethodsMenu ··· 303 304 // password is only allowed in sudo mode for non-MFA users 305 if (!ctx.isSudo) { 306 + redirect(routes.login.index.href(undefined, { redirect: ctx.redirectUrl })); 307 } 308 309 // MFA users must use MFA methods
+1 -1
packages/danaus/src/web/middlewares/session.ts
··· 19 const { accountManager, config } = getAppContext(); 20 const path = url.pathname; 21 22 - const redirectUrl = routes.login.href(undefined, { redirect: path }); 23 24 const token = readWebSessionToken(request); 25 if (!token) {
··· 19 const { accountManager, config } = getAppContext(); 20 const path = url.pathname; 21 22 + const redirectUrl = routes.login.index.href(undefined, { redirect: path }); 23 24 const token = readWebSessionToken(request); 25 if (!token) {
+7 -2
packages/danaus/src/web/routes.ts
··· 13 }, 14 }, 15 16 - // login route 17 - login: '/account/login', 18 19 // verification routes - handles both MFA login (?token) and sudo (session-based) 20 verify: {
··· 13 }, 14 }, 15 16 + // login routes 17 + login: { 18 + index: '/account/login', 19 + passkey: { 20 + challenge: '/account/login/passkey', 21 + }, 22 + }, 23 24 // verification routes - handles both MFA login (?token) and sudo (session-based) 25 verify: {
+2
packages/danaus/src/web/scripts/webauthn-authenticate.js
··· 41 42 const startButton = this.startButton; 43 if (startButton) { 44 startButton.addEventListener('click', (e) => { 45 e.preventDefault(); 46 this.#handleAuthentication();
··· 41 42 const startButton = this.startButton; 43 if (startButton) { 44 + // enable the button now that JS is loaded 45 + startButton.disabled = false; 46 startButton.addEventListener('click', (e) => { 47 e.preventDefault(); 48 this.#handleAuthentication();
+150
packages/danaus/src/web/scripts/webauthn-passkey-login.js
···
··· 1 + // @ts-check 2 + 3 + import { fromBase64Url, toBase64Url } from './base64url.js'; 4 + 5 + /** 6 + * Passkey login element - fetches challenge and handles discoverable credential authentication. 7 + * 8 + * @attr {string} data-challenge-url - URL to fetch authentication challenge from 9 + */ 10 + class PasskeyLoginElement extends HTMLElement { 11 + /** @type {HTMLButtonElement | null} */ 12 + get startButton() { 13 + return this.querySelector('[data-target="passkey-login.start"]'); 14 + } 15 + 16 + /** @type {HTMLInputElement | null} */ 17 + get responseInput() { 18 + return this.querySelector('[data-target="passkey-login.response"]'); 19 + } 20 + 21 + /** @type {HTMLElement | null} */ 22 + get statusElement() { 23 + return this.querySelector('[data-target="passkey-login.status"]'); 24 + } 25 + 26 + /** @type {HTMLFormElement | null} */ 27 + get formElement() { 28 + return this.querySelector('[data-target="passkey-login.form"]'); 29 + } 30 + 31 + connectedCallback() { 32 + const challengeUrl = this.dataset.challengeUrl; 33 + if (!challengeUrl) { 34 + return; 35 + } 36 + 37 + const startButton = this.startButton; 38 + if (startButton) { 39 + // enable the button now that JS is loaded 40 + startButton.disabled = false; 41 + startButton.addEventListener('click', (e) => { 42 + e.preventDefault(); 43 + this.#handlePasskeyLogin(challengeUrl); 44 + }); 45 + } 46 + } 47 + 48 + /** 49 + * @param {string} challengeUrl 50 + */ 51 + async #handlePasskeyLogin(challengeUrl) { 52 + const status = this.statusElement; 53 + const responseInput = this.responseInput; 54 + const startButton = this.startButton; 55 + 56 + if (!status || !responseInput) { 57 + console.error('Passkey login: missing required elements'); 58 + return; 59 + } 60 + 61 + try { 62 + if (startButton) { 63 + startButton.disabled = true; 64 + } 65 + status.textContent = 'Fetching challenge...'; 66 + 67 + // fetch challenge options from server 68 + const challengeResponse = await fetch(challengeUrl); 69 + if (!challengeResponse.ok) { 70 + throw new Error('Failed to fetch challenge'); 71 + } 72 + 73 + /** @type {PublicKeyCredentialRequestOptionsJSON} */ 74 + const options = await challengeResponse.json(); 75 + 76 + status.textContent = 'Waiting for passkey...'; 77 + 78 + // convert options to the format expected by navigator.credentials.get 79 + // omit allowCredentials for discoverable flow 80 + const { allowCredentials: _, ...rest } = options; 81 + 82 + /** @type {PublicKeyCredentialRequestOptions} */ 83 + const publicKeyOptions = { 84 + ...rest, 85 + challenge: fromBase64Url(options.challenge), 86 + }; 87 + 88 + const credential = /** @type {PublicKeyCredential | null} */ ( 89 + await navigator.credentials.get({ publicKey: publicKeyOptions }) 90 + ); 91 + 92 + if (!credential) { 93 + status.textContent = 'Authentication cancelled'; 94 + if (startButton) { 95 + startButton.disabled = false; 96 + } 97 + return; 98 + } 99 + 100 + const response = /** @type {AuthenticatorAssertionResponse} */ (credential.response); 101 + 102 + // serialize the response for the server 103 + // TODO: investigate if we can avoid double JSON encoding (stringified JSON in form field) 104 + const serialized = JSON.stringify({ 105 + id: credential.id, 106 + rawId: toBase64Url(credential.rawId), 107 + type: credential.type, 108 + response: { 109 + clientDataJSON: toBase64Url(response.clientDataJSON), 110 + authenticatorData: toBase64Url(response.authenticatorData), 111 + signature: toBase64Url(response.signature), 112 + userHandle: response.userHandle ? toBase64Url(response.userHandle) : null, 113 + }, 114 + clientExtensionResults: credential.getClientExtensionResults(), 115 + }); 116 + 117 + responseInput.value = serialized; 118 + status.textContent = 'Passkey verified!'; 119 + 120 + // submit the form 121 + this.formElement?.submit(); 122 + } catch (err) { 123 + if (startButton) { 124 + startButton.disabled = false; 125 + } 126 + 127 + if (err instanceof Error) { 128 + if (err.name === 'NotAllowedError') { 129 + status.textContent = 'Authentication was cancelled or timed out. Please try again.'; 130 + } else { 131 + status.textContent = `Authentication failed: ${err.message}`; 132 + } 133 + } else { 134 + status.textContent = 'Authentication failed. Please try again.'; 135 + } 136 + console.error('Passkey login error:', err); 137 + } 138 + } 139 + } 140 + 141 + customElements.define('danaus-passkey-login', PasskeyLoginElement); 142 + 143 + /** 144 + * @typedef {object} PublicKeyCredentialRequestOptionsJSON 145 + * @property {string} challenge 146 + * @property {number} [timeout] 147 + * @property {string} [rpId] 148 + * @property {Array<{id: string, type: 'public-key', transports?: AuthenticatorTransport[]}>} [allowCredentials] 149 + * @property {UserVerificationRequirement} [userVerification] 150 + */
+45 -16
packages/danaus/src/web/scripts/webauthn-register.js
··· 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 ··· 67 68 if (!credential) { 69 status.textContent = 'Registration cancelled'; 70 return; 71 } 72 ··· 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.';
··· 8 * @attr {string} data-options - JSON PublicKeyCredentialCreationOptions 9 */ 10 class WebAuthnRegisterElement extends HTMLElement { 11 + /** @type {PublicKeyCredentialCreationOptionsJSON | null} */ 12 + #options = null; 13 + 14 + /** @type {HTMLButtonElement | null} */ 15 + get startButton() { 16 + return this.querySelector('[data-target="webauthn-register.start"]'); 17 + } 18 + 19 /** @type {HTMLInputElement | null} */ 20 get responseInput() { 21 return this.querySelector('[data-target="webauthn-register.response"]'); 22 } 23 24 /** @type {HTMLElement | null} */ 25 get statusElement() { 26 return this.querySelector('[data-target="webauthn-register.status"]'); 27 } 28 29 + /** @type {HTMLFormElement | null} */ 30 + get formElement() { 31 + return this.querySelector('form'); 32 + } 33 + 34 connectedCallback() { 35 + const optionsJson = this.dataset.options; 36 + if (!optionsJson) { 37 + return; 38 + } 39 + 40 + this.#options = JSON.parse(optionsJson); 41 + 42 + const startButton = this.startButton; 43 + if (startButton) { 44 + startButton.disabled = false; 45 + startButton.addEventListener('click', (e) => { 46 + e.preventDefault(); 47 + this.#handleRegistration(); 48 + }); 49 } 50 } 51 52 + async #handleRegistration() { 53 + const options = this.#options; 54 const status = this.statusElement; 55 + const startButton = this.startButton; 56 const responseInput = this.responseInput; 57 58 + if (!options || !status || !responseInput) { 59 console.error('WebAuthn register: missing required elements'); 60 return; 61 } 62 63 try { 64 + if (startButton) { 65 + startButton.disabled = true; 66 + } 67 status.textContent = 'Waiting for security key...'; 68 69 // convert options to the format expected by navigator.credentials.create ··· 87 88 if (!credential) { 89 status.textContent = 'Registration cancelled'; 90 + if (startButton) { 91 + startButton.disabled = false; 92 + } 93 return; 94 } 95 ··· 109 }); 110 111 responseInput.value = serialized; 112 + status.textContent = 'Security key registered!'; 113 + 114 + // auto-submit the form 115 + this.formElement?.submit(); 116 } catch (err) { 117 + if (startButton) { 118 + startButton.disabled = false; 119 + } 120 + 121 if (err instanceof Error) { 122 if (err.name === 'NotAllowedError') { 123 status.textContent = 'Registration was cancelled or timed out. Please try again.';