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 61 CONSTRAINT `fk_legacy_session_next_id_legacy_session_id_fk` FOREIGN KEY (`next_id`) REFERENCES `legacy_session`(`id`) ON DELETE CASCADE 62 62 ); 63 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 64 70 CREATE TABLE `recovery_code` ( 65 71 `id` integer PRIMARY KEY AUTOINCREMENT, 66 72 `did` text NOT NULL, ··· 103 109 CONSTRAINT `fk_web_session_did_account_did_fk` FOREIGN KEY (`did`) REFERENCES `account`(`did`) ON DELETE CASCADE 104 110 ); 105 111 --> 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 112 CREATE TABLE `webauthn_credential` ( 116 113 `id` integer PRIMARY KEY AUTOINCREMENT, 117 114 `did` text NOT NULL, ··· 126 123 CONSTRAINT `webauthn_credential_did_name_unique` UNIQUE(`did`,`name`) 127 124 ); 128 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 129 135 CREATE INDEX `account_created_at_did_idx` ON `account` (`created_at`,`did`);--> statement-breakpoint 130 136 CREATE UNIQUE INDEX `account_handle_lower_idx` ON `account` (lower("handle"));--> statement-breakpoint 131 137 CREATE UNIQUE INDEX `account_email_lower_idx` ON `account` (lower("email"));--> statement-breakpoint 132 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 133 140 CREATE INDEX `recovery_code_did_idx` ON `recovery_code` (`did`);--> statement-breakpoint 134 141 CREATE INDEX `totp_credential_did_idx` ON `totp_credential` (`did`);--> statement-breakpoint 135 142 CREATE INDEX `verify_challenge_expires_idx` ON `verify_challenge` (`expires_at`);--> statement-breakpoint 136 143 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 144 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`); 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 1 { 2 2 "version": "7", 3 3 "dialect": "sqlite", 4 - "id": "a52583cf-1f20-4ce8-bc86-96528e2d13e1", 4 + "id": "00552ae3-c924-4b4e-a44c-1e7bcd17914c", 5 5 "prevIds": [ 6 6 "00000000-0000-0000-0000-000000000000" 7 7 ], ··· 31 31 "entityType": "tables" 32 32 }, 33 33 { 34 + "name": "passkey_login_challenge", 35 + "entityType": "tables" 36 + }, 37 + { 34 38 "name": "recovery_code", 35 39 "entityType": "tables" 36 40 }, ··· 47 51 "entityType": "tables" 48 52 }, 49 53 { 50 - "name": "webauthn_challenge", 54 + "name": "webauthn_credential", 51 55 "entityType": "tables" 52 56 }, 53 57 { 54 - "name": "webauthn_credential", 58 + "name": "webauthn_registration_challenge", 55 59 "entityType": "tables" 56 60 }, 57 61 { ··· 405 409 "table": "legacy_session" 406 410 }, 407 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 + { 408 442 "type": "integer", 409 443 "notNull": false, 410 444 "autoincrement": true, ··· 645 679 "table": "web_session" 646 680 }, 647 681 { 648 - "type": "text", 682 + "type": "integer", 649 683 "notNull": false, 650 - "autoincrement": false, 684 + "autoincrement": true, 651 685 "default": null, 652 686 "generated": null, 653 - "name": "token", 687 + "name": "id", 654 688 "entityType": "columns", 655 - "table": "webauthn_challenge" 689 + "table": "webauthn_credential" 656 690 }, 657 691 { 658 692 "type": "text", ··· 662 696 "generated": null, 663 697 "name": "did", 664 698 "entityType": "columns", 665 - "table": "webauthn_challenge" 699 + "table": "webauthn_credential" 666 700 }, 667 701 { 668 - "type": "text", 702 + "type": "integer", 669 703 "notNull": true, 670 704 "autoincrement": false, 671 705 "default": null, 672 706 "generated": null, 673 - "name": "challenge", 707 + "name": "type", 674 708 "entityType": "columns", 675 - "table": "webauthn_challenge" 709 + "table": "webauthn_credential" 676 710 }, 677 711 { 678 - "type": "integer", 712 + "type": "text", 679 713 "notNull": true, 680 714 "autoincrement": false, 681 715 "default": null, 682 716 "generated": null, 683 - "name": "created_at", 717 + "name": "name", 684 718 "entityType": "columns", 685 - "table": "webauthn_challenge" 719 + "table": "webauthn_credential" 686 720 }, 687 721 { 688 - "type": "integer", 722 + "type": "text", 689 723 "notNull": true, 690 724 "autoincrement": false, 691 725 "default": null, 692 726 "generated": null, 693 - "name": "expires_at", 727 + "name": "credential_id", 694 728 "entityType": "columns", 695 - "table": "webauthn_challenge" 729 + "table": "webauthn_credential" 696 730 }, 697 731 { 698 - "type": "integer", 699 - "notNull": false, 700 - "autoincrement": true, 732 + "type": "blob", 733 + "notNull": true, 734 + "autoincrement": false, 701 735 "default": null, 702 736 "generated": null, 703 - "name": "id", 737 + "name": "public_key", 704 738 "entityType": "columns", 705 739 "table": "webauthn_credential" 706 740 }, 707 741 { 708 - "type": "text", 742 + "type": "integer", 709 743 "notNull": true, 710 744 "autoincrement": false, 711 745 "default": null, 712 746 "generated": null, 713 - "name": "did", 747 + "name": "counter", 714 748 "entityType": "columns", 715 749 "table": "webauthn_credential" 716 750 }, 717 751 { 718 - "type": "integer", 719 - "notNull": true, 752 + "type": "text", 753 + "notNull": false, 720 754 "autoincrement": false, 721 755 "default": null, 722 756 "generated": null, 723 - "name": "type", 757 + "name": "transports", 724 758 "entityType": "columns", 725 759 "table": "webauthn_credential" 726 760 }, 727 761 { 728 - "type": "text", 762 + "type": "integer", 729 763 "notNull": true, 730 764 "autoincrement": false, 731 765 "default": null, 732 766 "generated": null, 733 - "name": "name", 767 + "name": "created_at", 734 768 "entityType": "columns", 735 769 "table": "webauthn_credential" 736 770 }, 737 771 { 738 772 "type": "text", 739 - "notNull": true, 773 + "notNull": false, 740 774 "autoincrement": false, 741 775 "default": null, 742 776 "generated": null, 743 - "name": "credential_id", 777 + "name": "token", 744 778 "entityType": "columns", 745 - "table": "webauthn_credential" 779 + "table": "webauthn_registration_challenge" 746 780 }, 747 781 { 748 - "type": "blob", 782 + "type": "text", 749 783 "notNull": true, 750 784 "autoincrement": false, 751 785 "default": null, 752 786 "generated": null, 753 - "name": "public_key", 787 + "name": "did", 754 788 "entityType": "columns", 755 - "table": "webauthn_credential" 789 + "table": "webauthn_registration_challenge" 756 790 }, 757 791 { 758 - "type": "integer", 792 + "type": "text", 759 793 "notNull": true, 760 794 "autoincrement": false, 761 795 "default": null, 762 796 "generated": null, 763 - "name": "counter", 797 + "name": "challenge", 764 798 "entityType": "columns", 765 - "table": "webauthn_credential" 799 + "table": "webauthn_registration_challenge" 766 800 }, 767 801 { 768 - "type": "text", 769 - "notNull": false, 802 + "type": "integer", 803 + "notNull": true, 770 804 "autoincrement": false, 771 805 "default": null, 772 806 "generated": null, 773 - "name": "transports", 807 + "name": "created_at", 774 808 "entityType": "columns", 775 - "table": "webauthn_credential" 809 + "table": "webauthn_registration_challenge" 776 810 }, 777 811 { 778 812 "type": "integer", ··· 780 814 "autoincrement": false, 781 815 "default": null, 782 816 "generated": null, 783 - "name": "created_at", 817 + "name": "expires_at", 784 818 "entityType": "columns", 785 - "table": "webauthn_credential" 819 + "table": "webauthn_registration_challenge" 786 820 }, 787 821 { 788 822 "columns": [ ··· 975 1009 "onUpdate": "NO ACTION", 976 1010 "onDelete": "CASCADE", 977 1011 "nameExplicit": false, 978 - "name": "fk_webauthn_challenge_did_account_did_fk", 1012 + "name": "fk_webauthn_credential_did_account_did_fk", 979 1013 "entityType": "fks", 980 - "table": "webauthn_challenge" 1014 + "table": "webauthn_credential" 981 1015 }, 982 1016 { 983 1017 "columns": [ ··· 990 1024 "onUpdate": "NO ACTION", 991 1025 "onDelete": "CASCADE", 992 1026 "nameExplicit": false, 993 - "name": "fk_webauthn_credential_did_account_did_fk", 1027 + "name": "fk_webauthn_registration_challenge_did_account_did_fk", 994 1028 "entityType": "fks", 995 - "table": "webauthn_credential" 1029 + "table": "webauthn_registration_challenge" 996 1030 }, 997 1031 { 998 1032 "columns": [ ··· 1052 1086 }, 1053 1087 { 1054 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": [ 1055 1098 "id" 1056 1099 ], 1057 1100 "nameExplicit": false, ··· 1088 1131 }, 1089 1132 { 1090 1133 "columns": [ 1091 - "token" 1134 + "id" 1092 1135 ], 1093 1136 "nameExplicit": false, 1094 - "name": "webauthn_challenge_pk", 1095 - "table": "webauthn_challenge", 1137 + "name": "webauthn_credential_pk", 1138 + "table": "webauthn_credential", 1096 1139 "entityType": "pks" 1097 1140 }, 1098 1141 { 1099 1142 "columns": [ 1100 - "id" 1143 + "token" 1101 1144 ], 1102 1145 "nameExplicit": false, 1103 - "name": "webauthn_credential_pk", 1104 - "table": "webauthn_credential", 1146 + "name": "webauthn_registration_challenge_pk", 1147 + "table": "webauthn_registration_challenge", 1105 1148 "entityType": "pks" 1106 1149 }, 1107 1150 { ··· 1167 1210 { 1168 1211 "columns": [ 1169 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 + { 1170 1227 "value": "did", 1171 1228 "isExpression": false 1172 1229 } ··· 1223 1280 { 1224 1281 "columns": [ 1225 1282 { 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 1283 "value": "did", 1241 1284 "isExpression": false 1242 1285 } ··· 1261 1304 "name": "webauthn_credential_id_idx", 1262 1305 "entityType": "indexes", 1263 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" 1264 1321 }, 1265 1322 { 1266 1323 "columns": [
+18 -5
packages/danaus/src/accounts/db/schema.ts
··· 259 259 // #region WebAuthn credentials 260 260 261 261 /** WebAuthn credential types */ 262 - export const enum WebAuthnCredentialType { 262 + export enum WebAuthnCredentialType { 263 263 /** security key - non-discoverable, 2FA only */ 264 264 SecurityKey = 0, 265 - /** passkey - discoverable, can be used for passwordless (future) */ 265 + /** passkey - discoverable, can be used for passwordless */ 266 266 Passkey = 1, 267 267 } 268 268 ··· 301 301 ); 302 302 303 303 /** WebAuthn registration challenges */ 304 - export const webauthnChallenge = sqliteTable( 305 - 'webauthn_challenge', 304 + export const webauthnRegistrationChallenge = sqliteTable( 305 + 'webauthn_registration_challenge', 306 306 { 307 307 token: text().primaryKey(), 308 308 ··· 317 317 created_at: integer({ mode: 'timestamp' }).notNull(), 318 318 expires_at: integer({ mode: 'timestamp' }).notNull(), 319 319 }, 320 - (t) => [index('webauthn_challenge_expires_idx').on(t.expires_at)], 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)], 321 334 ); 322 335 323 336 // #endregion
+66 -14
packages/danaus/src/accounts/manager.ts
··· 43 43 export type BackupCode = typeof t.recoveryCode.$inferSelect; 44 44 export type VerifyChallenge = typeof t.verifyChallenge.$inferSelect; 45 45 export type WebauthnCredential = typeof t.webauthnCredential.$inferSelect; 46 - export type WebauthnChallenge = typeof t.webauthnChallenge.$inferSelect; 46 + export type WebauthnRegistrationChallenge = typeof t.webauthnRegistrationChallenge.$inferSelect; 47 47 48 48 /** MFA status for an account */ 49 49 export interface MfaStatus { ··· 1483 1483 /** 1484 1484 * set the WebAuthn challenge on an existing verification challenge. 1485 1485 * @param token verify challenge token 1486 - * @param webauthnChallenge base64url WebAuthn challenge 1486 + * @param webauthnRegistrationChallenge base64url WebAuthn challenge 1487 1487 */ 1488 - setVerifyChallengeWebAuthn(token: string, webauthnChallenge: string): void { 1488 + setVerifyChallengeWebAuthn(token: string, webauthnRegistrationChallenge: string): void { 1489 1489 this.db 1490 1490 .update(t.verifyChallenge) 1491 - .set({ webauthn_challenge: webauthnChallenge }) 1491 + .set({ webauthn_challenge: webauthnRegistrationChallenge }) 1492 1492 .where(eq(t.verifyChallenge.token, token)) 1493 1493 .run(); 1494 1494 } ··· 1720 1720 * @param challenge base64url challenge 1721 1721 * @returns token for retrieving the challenge 1722 1722 */ 1723 - createWebAuthnChallenge(did: Did, challenge: string): string { 1723 + createWebAuthnRegistrationChallenge(did: Did, challenge: string): string { 1724 1724 const token = nanoid(32); 1725 1725 const now = new Date(); 1726 1726 const expiresAt = new Date(now.getTime() + WEBAUTHN_CHALLENGE_TTL_MS); 1727 1727 1728 1728 this.db 1729 - .insert(t.webauthnChallenge) 1729 + .insert(t.webauthnRegistrationChallenge) 1730 1730 .values({ 1731 1731 token: token, 1732 1732 did: did, ··· 1744 1744 * @param token the token 1745 1745 * @returns WebAuthn challenge or null if expired/not found 1746 1746 */ 1747 - getWebAuthnChallenge(token: string): WebauthnChallenge | null { 1747 + getWebAuthnRegistrationChallenge(token: string): WebauthnRegistrationChallenge | null { 1748 1748 const challenge = this.db 1749 1749 .select() 1750 - .from(t.webauthnChallenge) 1751 - .where(eq(t.webauthnChallenge.token, token)) 1750 + .from(t.webauthnRegistrationChallenge) 1751 + .where(eq(t.webauthnRegistrationChallenge.token, token)) 1752 1752 .get(); 1753 1753 1754 1754 if (!challenge) { ··· 1757 1757 1758 1758 const now = new Date(); 1759 1759 if (challenge.expires_at <= now) { 1760 - this.db.delete(t.webauthnChallenge).where(eq(t.webauthnChallenge.token, token)).run(); 1760 + this.db 1761 + .delete(t.webauthnRegistrationChallenge) 1762 + .where(eq(t.webauthnRegistrationChallenge.token, token)) 1763 + .run(); 1761 1764 return null; 1762 1765 } 1763 1766 ··· 1768 1771 * delete a WebAuthn registration challenge. 1769 1772 * @param token the token 1770 1773 */ 1771 - deleteWebAuthnChallenge(token: string): void { 1772 - this.db.delete(t.webauthnChallenge).where(eq(t.webauthnChallenge.token, token)).run(); 1774 + deleteWebAuthnRegistrationChallenge(token: string): void { 1775 + this.db 1776 + .delete(t.webauthnRegistrationChallenge) 1777 + .where(eq(t.webauthnRegistrationChallenge.token, token)) 1778 + .run(); 1773 1779 } 1774 1780 1775 1781 /** 1776 1782 * clean up expired WebAuthn registration challenges. 1777 1783 */ 1778 - cleanupExpiredWebAuthnChallenges(): void { 1784 + cleanupExpiredWebAuthnRegistrationChallenges(): void { 1779 1785 const now = new Date(); 1780 - this.db.delete(t.webauthnChallenge).where(lte(t.webauthnChallenge.expires_at, now)).run(); 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; 1781 1833 } 1782 1834 1783 1835 // #endregion
+17 -10
packages/danaus/src/accounts/webauthn.ts
··· 10 10 type VerifiedRegistrationResponse, 11 11 } from '@simplewebauthn/server'; 12 12 13 + import { WebAuthnCredentialType } from './db/schema.ts'; 13 14 import type { WebauthnCredential } from './manager'; 14 15 15 16 // #region constants ··· 35 36 userName: string; 36 37 /** existing credentials to exclude */ 37 38 excludeCredentials?: WebauthnCredential[]; 39 + /** credential type to register */ 40 + credentialType: WebAuthnCredentialType; 38 41 } 39 42 40 43 /** 41 - * generates WebAuthn registration options for creating a new security key credential. 44 + * generates WebAuthn registration options for creating a new credential. 42 45 * @param params registration parameters 43 46 * @returns registration options to send to the client 44 47 */ 45 48 export const generateWebAuthnRegistrationOptions = async (params: GenerateRegistrationOptionsParams) => { 46 - const { rpId, rpName, userId, userName, excludeCredentials = [] } = params; 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; 47 54 48 55 return await generateRegistrationOptions({ 49 56 rpName, ··· 56 63 transports: cred.transports ?? undefined, 57 64 })), 58 65 authenticatorSelection: { 59 - // non-discoverable for security keys (2FA only) 60 - residentKey: 'discouraged', 61 - // password already verified, no need for PIN/biometric 62 - userVerification: 'discouraged', 66 + residentKey: isPasskey ? 'required' : 'discouraged', 67 + userVerification: isPasskey ? 'required' : 'discouraged', 63 68 }, 64 69 }); 65 70 }; ··· 100 105 export interface GenerateAuthenticationOptionsParams { 101 106 /** relying party ID (domain) */ 102 107 rpId: string; 103 - /** allowed credentials */ 108 + /** allowed credentials (omit for discoverable/passkey flow) */ 104 109 allowCredentials?: WebauthnCredential[]; 110 + /** whether user verification is required (true for passkey login) */ 111 + userVerificationRequired?: boolean; 105 112 } 106 113 107 114 /** ··· 110 117 * @returns authentication options to send to the client 111 118 */ 112 119 export const generateWebAuthnAuthenticationOptions = async (params: GenerateAuthenticationOptionsParams) => { 113 - const { rpId, allowCredentials = [] } = params; 120 + const { rpId, allowCredentials, userVerificationRequired = false } = params; 114 121 115 122 return await generateAuthenticationOptions({ 116 123 rpID: rpId, 117 - userVerification: 'discouraged', 118 - allowCredentials: allowCredentials.map((cred) => ({ 124 + userVerification: userVerificationRequired ? 'required' : 'discouraged', 125 + allowCredentials: allowCredentials?.map((cred) => ({ 119 126 id: cred.credential_id, 120 127 transports: cred.transports ?? undefined, 121 128 })),
+3
packages/danaus/src/jsx.d.ts
··· 10 10 'data-options': string; 11 11 'data-auto-submit'?: 'true' | 'false'; 12 12 }; 13 + 'danaus-passkey-login': HTMLAttributes & { 14 + 'data-challenge-url': string; 15 + }; 13 16 } 14 17 } 15 18 }
+39 -4
packages/danaus/src/web/controllers/account/security/overview.tsx
··· 28 28 did, 29 29 WebAuthnCredentialType.SecurityKey, 30 30 ); 31 + const passkeys = accountManager.listWebAuthnCredentialsByType(did, WebAuthnCredentialType.Passkey); 31 32 const hasMfa = totpCredentials.length > 0 || securityKeys.length > 0; 32 33 33 34 return render( ··· 46 47 account={account} 47 48 totpCredentials={totpCredentials} 48 49 securityKeys={securityKeys} 50 + passkeys={passkeys} 49 51 /> 50 52 51 53 {hasMfa && <RecoverySection />} ··· 95 97 account, 96 98 totpCredentials, 97 99 securityKeys, 100 + passkeys, 98 101 }: { 99 102 account: Account; 100 103 totpCredentials: TotpCredential[]; 101 104 securityKeys: WebauthnCredential[]; 105 + passkeys: WebauthnCredential[]; 102 106 }) => { 103 107 return ( 104 108 <div class="flex flex-col gap-2"> ··· 191 195 </div> 192 196 ))} 193 197 194 - {/* Passkeys placeholder (future) */} 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 + ))} 195 227 196 228 {/* Add another way to sign in */} 197 229 <button ··· 241 273 </div> 242 274 </a> 243 275 244 - <button disabled class="flex items-center gap-4 rounded-md px-4 py-3 text-left opacity-50"> 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 + > 245 280 <PasskeysOutlined size={24} class="shrink-0" /> 246 281 247 282 <div class="min-w-0 grow"> 248 283 <p class="text-base-300 font-medium">Passkey</p> 249 284 <p class="text-base-300 text-neutral-foreground-3"> 250 - Use Face ID, Touch ID, or Windows Hello (coming soon) 285 + Use Face ID, Touch ID, or Windows Hello 251 286 </p> 252 287 </div> 253 - </button> 288 + </a> 254 289 </Dialog.Content> 255 290 256 291 <Dialog.Actions>
+2 -6
packages/danaus/src/web/controllers/account/security/recovery.tsx
··· 107 107 </Dialog.Content> 108 108 109 109 <Dialog.Actions> 110 - <Button type="button" href={routes.account.security.overview.href()}> 111 - Cancel 112 - </Button> 110 + <Button href={routes.account.security.overview.href()}>Cancel</Button> 113 111 114 112 <Button type="submit" variant="primary"> 115 113 Regenerate ··· 165 163 </Dialog.Content> 166 164 167 165 <Dialog.Actions> 168 - <Button type="button" href={routes.account.security.overview.href()}> 169 - Cancel 170 - </Button> 166 + <Button href={routes.account.security.overview.href()}>Cancel</Button> 171 167 172 168 <Button type="submit" variant="primary"> 173 169 Delete
+2 -6
packages/danaus/src/web/controllers/account/security/totp.tsx
··· 133 133 </Dialog.Content> 134 134 135 135 <Dialog.Actions> 136 - <Button type="button" href={routes.account.security.overview.href()}> 137 - Cancel 138 - </Button> 136 + <Button href={routes.account.security.overview.href()}>Cancel</Button> 139 137 140 138 <Button type="submit" variant="primary"> 141 139 Save ··· 197 195 </Dialog.Content> 198 196 199 197 <Dialog.Actions> 200 - <Button type="button" href={routes.account.security.overview.href()}> 201 - Cancel 202 - </Button> 198 + <Button href={routes.account.security.overview.href()}>Cancel</Button> 203 199 204 200 <Button type="submit" variant="primary"> 205 201 Remove
+77 -59
packages/danaus/src/web/controllers/account/security/webauthn.tsx
··· 8 8 import { BaseLayout } from '#web/layouts/base.tsx'; 9 9 import { getAppContext } from '#web/middlewares/app-context.ts'; 10 10 import { getSession } from '#web/middlewares/session.ts'; 11 - import { Button, Dialog, Field, Input } from '#web/primitives/index.ts'; 11 + import { Button, Dialog, Field, Input, MessageBar } from '#web/primitives/index.ts'; 12 12 import { routes } from '#web/routes.ts'; 13 13 14 14 import { completeWebAuthnForm, initiateWebAuthnRegistration, removeWebAuthnForm } from './webauthn/lib/forms'; ··· 24 24 25 25 // require sudo mode 26 26 if (!accountManager.isSessionElevated(session)) { 27 - redirect(routes.verify.index.href(undefined, { redirect: url.pathname })); 27 + redirect(routes.verify.index.href(undefined, { redirect: url.pathname + url.search })); 28 28 } 29 29 30 30 const account = accountManager.getAccount(session.did)!; 31 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 + 32 38 const { fields } = completeWebAuthnForm; 33 39 34 40 // check if we have an existing token (form was submitted but failed) ··· 37 43 38 44 if (token) { 39 45 // try to get existing challenge 40 - const existingChallenge = accountManager.getWebAuthnChallenge(token); 46 + const existingChallenge = accountManager.getWebAuthnRegistrationChallenge(token); 41 47 if (existingChallenge) { 42 48 // regenerate options with the same challenge 43 - const state = await initiateWebAuthnRegistration(session.did, account.handle ?? session.did); 49 + const state = await initiateWebAuthnRegistration( 50 + session.did, 51 + account.handle ?? session.did, 52 + credentialType, 53 + ); 44 54 // delete old challenge and use new one 45 - accountManager.deleteWebAuthnChallenge(token); 55 + accountManager.deleteWebAuthnRegistrationChallenge(token); 46 56 token = state.token; 47 57 options = state.options; 48 58 } ··· 50 60 51 61 if (!options) { 52 62 // generate new registration 53 - const state = await initiateWebAuthnRegistration(session.did, account.handle ?? session.did); 63 + const state = await initiateWebAuthnRegistration( 64 + session.did, 65 + account.handle ?? session.did, 66 + credentialType, 67 + ); 54 68 token = state.token; 55 69 options = state.options; 56 70 } 57 71 58 72 const generalError = fields.issues()?.at(0); 59 73 74 + const credentialLabel = isPasskey ? 'passkey' : 'security key'; 75 + 60 76 return render( 61 77 <BaseLayout> 62 - <title>Set up security key - Danaus</title> 78 + <title>Set up {credentialLabel} - Danaus</title> 63 79 64 80 <script type="module" src={routes.assets.href({ path: 'webauthn-register.js' })} /> 65 81 66 - <div class="flex flex-1 items-center justify-center p-4"> 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 + 67 89 <div class="w-full max-w-120 rounded-xl bg-neutral-background-1 shadow-64"> 68 90 <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> 91 + <form {...completeWebAuthnForm} class="contents"> 92 + <Dialog.Body> 93 + <Dialog.Title>Set up {credentialLabel}</Dialog.Title> 72 94 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!)} /> 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> 79 101 80 - <p 81 - data-target="webauthn-register.status" 82 - class="text-base-300 text-neutral-foreground-3" 83 - > 84 - Initializing... 85 - </p> 102 + <input {...fields.token.as('hidden', token!)} /> 103 + <input 104 + {...fields.credentialType.as('hidden', isPasskey ? 'passkey' : 'security-key')} 105 + /> 86 106 87 - <input 88 - {...fields.response.as('hidden', '')} 89 - data-target="webauthn-register.response" 90 - /> 107 + <p 108 + data-target="webauthn-register.status" 109 + class="text-base-300 text-neutral-foreground-3 empty:hidden" 110 + /> 91 111 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 - )} 112 + <input 113 + {...fields.response.as('hidden', '')} 114 + data-target="webauthn-register.response" 103 115 /> 104 - </Field> 105 116 106 - {generalError && ( 107 - <p role="alert" class="text-base-300 text-status-danger-foreground-1"> 108 - {generalError.message} 109 - </p> 110 - )} 111 - </Dialog.Content> 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> 112 127 113 - <Dialog.Actions> 114 - <Button type="button" href={routes.account.security.overview.href()}> 115 - Cancel 116 - </Button> 128 + {generalError && ( 129 + <p role="alert" class="text-base-300 text-status-danger-foreground-1"> 130 + {generalError.message} 131 + </p> 132 + )} 133 + </Dialog.Content> 117 134 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> 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> 125 145 </div> 126 146 </div> 127 147 </BaseLayout>, ··· 178 198 </Dialog.Content> 179 199 180 200 <Dialog.Actions> 181 - <Button type="button" href={routes.account.security.overview.href()}> 182 - Cancel 183 - </Button> 201 + <Button href={routes.account.security.overview.href()}>Cancel</Button> 184 202 185 203 <Button type="submit" variant="primary"> 186 204 Remove
+11 -4
packages/danaus/src/web/controllers/account/security/webauthn/lib/forms.ts
··· 23 23 * initiates WebAuthn registration by generating a challenge. 24 24 * @param did account DID 25 25 * @param userName user display name (handle) 26 + * @param credentialType type of credential to register 26 27 * @returns registration state with token and options 27 28 */ 28 29 export const initiateWebAuthnRegistration = async ( 29 30 did: Did, 30 31 userName: string, 32 + credentialType: WebAuthnCredentialType, 31 33 ): Promise<WebAuthnRegistrationState> => { 32 34 const { accountManager, config } = getAppContext(); 33 35 ··· 39 41 userId: did, 40 42 userName: userName, 41 43 excludeCredentials: existingCredentials, 44 + credentialType, 42 45 }); 43 46 44 47 // store the challenge 45 - const token = accountManager.createWebAuthnChallenge(did, options.challenge); 48 + const token = accountManager.createWebAuthnRegistrationChallenge(did, options.challenge); 46 49 47 50 return { token, options }; 48 51 }; ··· 53 56 export const completeWebAuthnForm = form( 54 57 v.object({ 55 58 token: v.pipe(v.string(), v.minLength(1)), 59 + credentialType: v.picklist(['security-key', 'passkey']), 56 60 name: v.optional(v.pipe(v.string(), normalizeWhitespace, v.maxLength(32, `Name is too long`))), 57 61 response: v.pipe( 58 62 v.string(), ··· 78 82 const { did } = getSession(); 79 83 80 84 // get the challenge 81 - const challenge = accountManager.getWebAuthnChallenge(data.token); 85 + const challenge = accountManager.getWebAuthnRegistrationChallenge(data.token); 82 86 if (!challenge) { 83 87 invalid(`Registration expired, please try again`); 84 88 } ··· 106 110 } 107 111 108 112 // delete the challenge 109 - accountManager.deleteWebAuthnChallenge(data.token); 113 + accountManager.deleteWebAuthnRegistrationChallenge(data.token); 110 114 111 115 requireSudo(); 112 116 113 117 // store the credential 114 118 const { registrationInfo } = verification; 119 + const credentialType = 120 + data.credentialType === 'passkey' ? WebAuthnCredentialType.Passkey : WebAuthnCredentialType.SecurityKey; 121 + 115 122 try { 116 123 accountManager.createWebAuthnCredential({ 117 124 did: did, 118 - type: WebAuthnCredentialType.SecurityKey, 125 + type: credentialType, 119 126 name: data.name, 120 127 credentialId: registrationInfo.credential.id, 121 128 publicKey: registrationInfo.credential.publicKey,
+104 -46
packages/danaus/src/web/controllers/login.tsx
··· 1 - import type { BuildAction } from '@oomfware/fetch-router'; 1 + import type { Controller } from '@oomfware/fetch-router'; 2 2 import { forms } from '@oomfware/forms'; 3 3 import { render } from '@oomfware/jsx'; 4 4 5 + import { generateWebAuthnAuthenticationOptions } from '#app/accounts/webauthn.ts'; 6 + 5 7 import { BaseLayout } from '#web/layouts/base.tsx'; 8 + import { getAppContext } from '#web/middlewares/app-context.ts'; 6 9 import { Button, Checkbox, Field, Input } from '#web/primitives/index.ts'; 7 10 import { routes } from '#web/routes.ts'; 8 11 9 - import { loginForm } from './login/lib/forms.ts'; 12 + import { loginForm, passkeyLoginForm } from './login/lib/forms.ts'; 10 13 11 14 export default { 12 - middleware: [forms({ loginForm })], 13 - action({ url }) { 14 - const { fields } = loginForm; 15 + middleware: [], 16 + actions: { 17 + index: { 18 + middleware: [forms({ loginForm, passkeyLoginForm })], 19 + action({ url }) { 20 + const { fields } = loginForm; 21 + const passkeyFields = passkeyLoginForm.fields; 15 22 16 - const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 23 + const redirectUrl = url.searchParams.get('redirect') ?? fields.redirect.value(); 17 24 18 - return render( 19 - <BaseLayout> 20 - <title>Sign in - Danaus</title> 25 + return render( 26 + <BaseLayout> 27 + <title>Sign in - Danaus</title> 21 28 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> 29 + <script type="module" src={routes.assets.href({ path: 'webauthn-passkey-login.js' })} /> 26 30 27 - <input {...fields.redirect.as('hidden', redirectUrl ?? routes.account.overview.href())} /> 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> 28 35 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> 36 + <input {...fields.redirect.as('hidden', redirectUrl ?? routes.account.overview.href())} /> 42 37 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> 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> 50 51 51 - <Checkbox name="remember" value="true"> 52 - Remember this device 53 - </Checkbox> 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> 54 59 55 - <Button type="submit" variant="primary"> 56 - Sign in 57 - </Button> 58 - </form> 59 - </div> 60 - </div> 61 - </BaseLayout>, 62 - ); 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 + }, 63 121 }, 64 - } satisfies BuildAction<'ANY', typeof routes.login>; 122 + } satisfies Controller<typeof routes.login>;
+95 -2
packages/danaus/src/web/controllers/login/lib/forms.ts
··· 171 171 172 172 const challenge = accountManager.getVerifyChallenge(data.challenge); 173 173 if (challenge === null) { 174 - redirect(routes.login.href(undefined, { redirect: data.redirect })); 174 + redirect(routes.login.index.href(undefined, { redirect: data.redirect })); 175 175 } 176 176 177 177 const isSudo = challenge.session_id !== null; ··· 220 220 }, 221 221 ); 222 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 + 223 316 export const verifyWebAuthnForm = form( 224 317 v.object({ 225 318 challenge: v.string(), ··· 256 349 257 350 const challenge = accountManager.getVerifyChallenge(data.challenge); 258 351 if (challenge === null) { 259 - redirect(routes.login.href(undefined, { redirect: data.redirect })); 352 + redirect(routes.login.index.href(undefined, { redirect: data.redirect })); 260 353 } 261 354 262 355 if (!challenge.webauthn_challenge) {
+20 -10
packages/danaus/src/web/controllers/verify.tsx
··· 15 15 import { BaseLayout } from '#web/layouts/base.tsx'; 16 16 import { getAppContext } from '#web/middlewares/app-context.ts'; 17 17 import { tryGetSession } from '#web/middlewares/session.ts'; 18 - import { Button, Field, Input, Menu } from '#web/primitives/index.ts'; 18 + import { Button, Field, Input, Menu, MessageBar } from '#web/primitives/index.ts'; 19 19 import { routes } from '#web/routes.ts'; 20 20 21 21 import { verifyForm, verifyWebAuthnForm, type AuthFactor } from './login/lib/forms.ts'; ··· 48 48 const challenge = accountManager.getVerifyChallenge(tokenParam); 49 49 if (challenge === null) { 50 50 // invalid or expired token → redirect to login (don't fall back to sudo) 51 - redirect(routes.login.href(undefined, { redirect: redirectUrl })); 51 + redirect(routes.login.index.href(undefined, { redirect: redirectUrl })); 52 52 } 53 53 54 54 const mfaStatus = accountManager.getMfaStatus(challenge.did); 55 55 if (mfaStatus === null) { 56 56 // no MFA configured (shouldn't happen, but handle it) 57 - redirect(routes.login.href(undefined, { redirect: redirectUrl })); 57 + redirect(routes.login.index.href(undefined, { redirect: redirectUrl })); 58 58 } 59 59 60 60 return { ··· 68 68 // mode 2: sudo - no token, but has session 69 69 const session = tryGetSession(); 70 70 if (session === null) { 71 - redirect(routes.login.href(undefined, { redirect: redirectUrl })); 71 + redirect(routes.login.index.href(undefined, { redirect: redirectUrl })); 72 72 } 73 73 74 74 // already elevated? redirect directly to target ··· 101 101 102 102 // for MFA login without mfaStatus, this shouldn't happen but redirect to login 103 103 if (ctx.mfaStatus === null) { 104 - redirect(routes.login.href(undefined, { redirect: ctx.redirectUrl })); 104 + redirect(routes.login.index.href(undefined, { redirect: ctx.redirectUrl })); 105 105 } 106 106 107 107 // redirect to preferred method ··· 198 198 199 199 <script src="/assets/webauthn-authenticate.js" type="module" /> 200 200 201 - <div class="flex flex-1 items-center justify-center p-4"> 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 + 202 208 <div class="w-full max-w-96 rounded-xl bg-neutral-background-1 p-6 shadow-16"> 203 209 <danaus-webauthn-authenticate class="contents" data-options={JSON.stringify(options)}> 204 - <form {...verifyWebAuthnForm} class="flex flex-col gap-6" data-target="webauthn-authenticate.form"> 210 + <form 211 + {...verifyWebAuthnForm} 212 + class="flex flex-col gap-6" 213 + data-target="webauthn-authenticate.form" 214 + > 205 215 <input {...fields.challenge.as('hidden', ctx.challenge.token)} /> 206 216 <input {...fields.redirect.as('hidden', ctx.redirectUrl)} /> 207 217 ··· 217 227 218 228 <input {...fields.response.as('hidden', '')} data-target="webauthn-authenticate.response" /> 219 229 220 - <Button data-target="webauthn-authenticate.start" type="button" variant="primary"> 230 + <Button data-target="webauthn-authenticate.start" variant="primary" disabled> 221 231 Use security key 222 232 </Button> 223 233 224 234 <div 225 235 data-target="webauthn-authenticate.status" 226 - class="text-center text-base-300 text-neutral-foreground-3" 236 + class="text-center text-base-300 text-neutral-foreground-3 empty:hidden" 227 237 /> 228 238 229 239 <OtherMethodsMenu ··· 293 303 294 304 // password is only allowed in sudo mode for non-MFA users 295 305 if (!ctx.isSudo) { 296 - redirect(routes.login.href(undefined, { redirect: ctx.redirectUrl })); 306 + redirect(routes.login.index.href(undefined, { redirect: ctx.redirectUrl })); 297 307 } 298 308 299 309 // MFA users must use MFA methods
+1 -1
packages/danaus/src/web/middlewares/session.ts
··· 19 19 const { accountManager, config } = getAppContext(); 20 20 const path = url.pathname; 21 21 22 - const redirectUrl = routes.login.href(undefined, { redirect: path }); 22 + const redirectUrl = routes.login.index.href(undefined, { redirect: path }); 23 23 24 24 const token = readWebSessionToken(request); 25 25 if (!token) {
+7 -2
packages/danaus/src/web/routes.ts
··· 13 13 }, 14 14 }, 15 15 16 - // login route 17 - login: '/account/login', 16 + // login routes 17 + login: { 18 + index: '/account/login', 19 + passkey: { 20 + challenge: '/account/login/passkey', 21 + }, 22 + }, 18 23 19 24 // verification routes - handles both MFA login (?token) and sudo (session-based) 20 25 verify: {
+2
packages/danaus/src/web/scripts/webauthn-authenticate.js
··· 41 41 42 42 const startButton = this.startButton; 43 43 if (startButton) { 44 + // enable the button now that JS is loaded 45 + startButton.disabled = false; 44 46 startButton.addEventListener('click', (e) => { 45 47 e.preventDefault(); 46 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 8 * @attr {string} data-options - JSON PublicKeyCredentialCreationOptions 9 9 */ 10 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 + 11 19 /** @type {HTMLInputElement | null} */ 12 20 get responseInput() { 13 21 return this.querySelector('[data-target="webauthn-register.response"]'); 14 22 } 15 23 16 - /** @type {HTMLButtonElement | null} */ 17 - get submitButton() { 18 - return this.querySelector('[data-target="webauthn-register.submit"]'); 19 - } 20 - 21 24 /** @type {HTMLElement | null} */ 22 25 get statusElement() { 23 26 return this.querySelector('[data-target="webauthn-register.status"]'); 24 27 } 25 28 29 + /** @type {HTMLFormElement | null} */ 30 + get formElement() { 31 + return this.querySelector('form'); 32 + } 33 + 26 34 connectedCallback() { 27 - const options = this.dataset.options; 28 - if (options) { 29 - this.#handleRegistration(JSON.parse(options)); 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 + }); 30 49 } 31 50 } 32 51 33 - /** 34 - * @param {PublicKeyCredentialCreationOptionsJSON} options 35 - */ 36 - async #handleRegistration(options) { 52 + async #handleRegistration() { 53 + const options = this.#options; 37 54 const status = this.statusElement; 38 - const submitButton = this.submitButton; 55 + const startButton = this.startButton; 39 56 const responseInput = this.responseInput; 40 57 41 - if (!status || !submitButton || !responseInput) { 58 + if (!options || !status || !responseInput) { 42 59 console.error('WebAuthn register: missing required elements'); 43 60 return; 44 61 } 45 62 46 63 try { 64 + if (startButton) { 65 + startButton.disabled = true; 66 + } 47 67 status.textContent = 'Waiting for security key...'; 48 68 49 69 // convert options to the format expected by navigator.credentials.create ··· 67 87 68 88 if (!credential) { 69 89 status.textContent = 'Registration cancelled'; 90 + if (startButton) { 91 + startButton.disabled = false; 92 + } 70 93 return; 71 94 } 72 95 ··· 86 109 }); 87 110 88 111 responseInput.value = serialized; 89 - submitButton.disabled = false; 90 - status.textContent = 'Security key registered! Click Save to continue.'; 112 + status.textContent = 'Security key registered!'; 113 + 114 + // auto-submit the form 115 + this.formElement?.submit(); 91 116 } catch (err) { 117 + if (startButton) { 118 + startButton.disabled = false; 119 + } 120 + 92 121 if (err instanceof Error) { 93 122 if (err.name === 'NotAllowedError') { 94 123 status.textContent = 'Registration was cancelled or timed out. Please try again.';