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