tangled
alpha
login
or
join now
mary.my.id
/
danaus
4
fork
atom
work-in-progress atproto PDS
typescript
atproto
pds
atcute
4
fork
atom
overview
issues
pulls
pipelines
feat: WebAuthn passwordless support
mary.my.id
1 month ago
d68615ec
7fa59b39
verified
This commit was signed with the committer's
known signature
.
mary.my.id
SSH Key Fingerprint:
SHA256:ZlTP/auFSGpGnaoDg4mCTG1g9OZvXp62jWR4c6H4O3c=
+801
-263
19 changed files
expand all
collapse all
unified
split
packages
danaus
drizzle
accounts
20260117080253_flaky_dark_beast
migration.sql
snapshot.json
src
accounts
db
schema.ts
manager.ts
webauthn.ts
jsx.d.ts
web
controllers
account
security
overview.tsx
recovery.tsx
totp.tsx
webauthn
lib
forms.ts
webauthn.tsx
login
lib
forms.ts
login.tsx
verify.tsx
middlewares
session.ts
routes.ts
scripts
webauthn-authenticate.js
webauthn-passkey-login.js
webauthn-register.js
+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
0
0
0
0
0
0
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
0
0
0
0
0
0
0
0
0
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
0
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`);
0
···
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
0
0
0
0
0
0
0
0
0
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
0
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
{
0
0
0
0
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
{
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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": [
0
0
0
0
0
0
0
0
0
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
{
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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"
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
{
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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)],
0
0
0
0
0
0
0
0
0
0
0
0
0
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();
0
0
0
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();
0
0
0
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();
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
0
13
import type { WebauthnCredential } from './manager';
14
15
// #region constants
···
35
userName: string;
36
/** existing credentials to exclude */
37
excludeCredentials?: WebauthnCredential[];
0
0
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;
0
0
0
0
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[];
0
0
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',
0
0
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
};
0
0
0
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
);
0
31
const hasMfa = totpCredentials.length > 0 || securityKeys.length > 0;
32
33
return render(
···
46
account={account}
47
totpCredentials={totpCredentials}
48
securityKeys={securityKeys}
0
49
/>
50
51
{hasMfa && <RecoverySection />}
···
95
account,
96
totpCredentials,
97
securityKeys,
0
98
}: {
99
account: Account;
100
totpCredentials: TotpCredential[];
101
securityKeys: WebauthnCredential[];
0
102
}) => {
103
return (
104
<div class="flex flex-col gap-2">
···
191
</div>
192
))}
193
194
-
{/* Passkeys placeholder (future) */}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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">
0
0
0
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>
0
0
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>
0
0
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>
0
0
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>
0
0
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
0
0
0
0
0
0
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);
0
0
0
0
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);
0
0
0
0
54
token = state.token;
55
options = state.options;
56
}
57
58
const generalError = fields.issues()?.at(0);
59
0
0
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">
0
0
0
0
0
0
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>
0
0
0
0
112
113
-
<Dialog.Actions>
114
-
<Button type="button" href={routes.account.security.overview.href()}>
115
-
Cancel
116
-
</Button>
0
0
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>
0
0
0
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
+
/>
0
0
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"
0
0
0
0
0
0
0
0
115
/>
0
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>
0
0
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)
0
26
* @returns registration state with token and options
27
*/
28
export const initiateWebAuthnRegistration = async (
29
did: Did,
30
userName: string,
0
31
): Promise<WebAuthnRegistrationState> => {
32
const { accountManager, config } = getAppContext();
33
···
39
userId: did,
40
userName: userName,
41
excludeCredentials: existingCredentials,
0
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)),
0
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;
0
0
0
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
0
0
5
import { BaseLayout } from '#web/layouts/base.tsx';
0
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;
0
0
0
0
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())} />
0
0
0
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>
0
0
0
0
0
0
50
51
-
<Checkbox name="remember" value="true">
52
-
Remember this device
53
-
</Checkbox>
0
0
0
0
54
55
-
<Button type="submit" variant="primary">
56
-
Sign in
57
-
</Button>
58
-
</form>
59
-
</div>
60
-
</div>
61
-
</BaseLayout>,
62
-
);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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' })} />
0
0
0
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())} />
0
0
0
0
0
0
0
0
0
0
0
0
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
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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">
0
0
0
0
0
0
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">
0
0
0
0
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',
0
0
0
0
0
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) {
0
0
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
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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 {
0
0
0
0
0
0
0
0
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
0
0
0
0
0
26
connectedCallback() {
27
-
const options = this.dataset.options;
28
-
if (options) {
29
-
this.#handleRegistration(JSON.parse(options));
0
0
0
0
0
0
0
0
0
0
0
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 {
0
0
0
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';
0
0
0
70
return;
71
}
72
···
86
});
87
88
responseInput.value = serialized;
89
-
submitButton.disabled = false;
90
-
status.textContent = 'Security key registered! Click Save to continue.';
0
0
91
} catch (err) {
0
0
0
0
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
0
0
0
0
0
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;
0
0
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.';