@mrgnw/anahtar 0.0.29 → 0.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/AuthPill.svelte +52 -21
- package/package.json +5 -2
|
@@ -60,10 +60,25 @@ let isTouch = $state(false);
|
|
|
60
60
|
let hoveredKey = $state<string | null>(null);
|
|
61
61
|
let passkeyRefresh = $state(0);
|
|
62
62
|
let passkeyOnboarding = $state(false);
|
|
63
|
-
|
|
63
|
+
|
|
64
|
+
const PASSKEY_TIMEOUT_MS = 8000;
|
|
65
|
+
// Captured from the lazy @simplewebauthn/browser import so onMount cleanup (sync)
|
|
66
|
+
// and handleEmailSubmit can cancel an in-flight ceremony without re-importing.
|
|
67
|
+
let webauthnAbort: { cancelCeremony: () => void } | null = null;
|
|
64
68
|
|
|
65
69
|
const isAuthenticated = $derived(!!user);
|
|
66
70
|
|
|
71
|
+
// Best-effort bound on a promise so a WebAuthn ceremony that never settles
|
|
72
|
+
// (e.g. rpID mismatch on preview domains) can't wedge the sign-in flow.
|
|
73
|
+
function raceTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
74
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
75
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
76
|
+
timer = setTimeout(() => reject(new Error('timeout')), ms);
|
|
77
|
+
});
|
|
78
|
+
// Promise.race consumes the loser's later settlement, so no unhandled rejection.
|
|
79
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
80
|
+
}
|
|
81
|
+
|
|
67
82
|
function shortDate(ts?: number): string {
|
|
68
83
|
if (!ts) return '';
|
|
69
84
|
const d = new Date(ts);
|
|
@@ -77,7 +92,7 @@ const passkeyPromise = $derived(
|
|
|
77
92
|
onMount(() => {
|
|
78
93
|
isTouch = matchMedia('(pointer: coarse)').matches;
|
|
79
94
|
if (!isAuthenticated) tryConditionalWebAuthn();
|
|
80
|
-
return () =>
|
|
95
|
+
return () => webauthnAbort?.cancelCeremony();
|
|
81
96
|
});
|
|
82
97
|
|
|
83
98
|
async function handleSignOut() {
|
|
@@ -94,40 +109,45 @@ async function handleSignOut() {
|
|
|
94
109
|
|
|
95
110
|
async function tryConditionalWebAuthn() {
|
|
96
111
|
try {
|
|
97
|
-
const
|
|
112
|
+
const mod = await import('@simplewebauthn/browser');
|
|
113
|
+
webauthnAbort = mod.WebAuthnAbortService;
|
|
98
114
|
const res = await fetch(`${apiBase}/passkey/login-start`);
|
|
99
115
|
if (!res.ok) return;
|
|
100
116
|
const options = (await res.json()) as any;
|
|
101
|
-
|
|
102
|
-
const authResponse = await startAuthentication({
|
|
117
|
+
const authResponse = await mod.startAuthentication({
|
|
103
118
|
optionsJSON: options,
|
|
104
119
|
useBrowserAutofill: true,
|
|
105
120
|
});
|
|
121
|
+
// Only reached when the user actually picks a passkey via autofill.
|
|
122
|
+
// Scope `loading` to the verify step so the abort path never touches it
|
|
123
|
+
// (an aborted ceremony throws straight to the catch below).
|
|
106
124
|
loading = true;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
125
|
+
try {
|
|
126
|
+
const verifyRes = await fetch(`${apiBase}/passkey/login-finish`, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/json' },
|
|
129
|
+
body: JSON.stringify(authResponse),
|
|
130
|
+
});
|
|
131
|
+
if (verifyRes.ok) await onSuccess?.();
|
|
132
|
+
} finally {
|
|
133
|
+
loading = false;
|
|
134
|
+
}
|
|
113
135
|
} catch {
|
|
114
136
|
/* not available or cancelled */
|
|
115
|
-
} finally {
|
|
116
|
-
loading = false;
|
|
117
137
|
}
|
|
118
138
|
}
|
|
119
139
|
|
|
120
140
|
async function handleEmailSubmit(e: SubmitEvent) {
|
|
121
141
|
e.preventDefault();
|
|
122
142
|
if (!email.includes('@')) return;
|
|
123
|
-
|
|
124
|
-
conditionalAbort = null;
|
|
143
|
+
webauthnAbort?.cancelCeremony();
|
|
125
144
|
loading = true;
|
|
126
145
|
error = '';
|
|
127
146
|
try {
|
|
128
147
|
// Passkey-first: check if user has passkeys before falling back to OTP
|
|
129
148
|
try {
|
|
130
|
-
const
|
|
149
|
+
const mod = await import('@simplewebauthn/browser');
|
|
150
|
+
webauthnAbort = mod.WebAuthnAbortService;
|
|
131
151
|
const checkRes = await fetch(`${apiBase}/passkey/check-email`, {
|
|
132
152
|
method: 'POST',
|
|
133
153
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -137,7 +157,10 @@ async function handleEmailSubmit(e: SubmitEvent) {
|
|
|
137
157
|
const opts = (await checkRes.json()) as any;
|
|
138
158
|
if ((opts?.allowCredentials?.length ?? 0) > 0) {
|
|
139
159
|
try {
|
|
140
|
-
const authResp = await
|
|
160
|
+
const authResp = await raceTimeout(
|
|
161
|
+
mod.startAuthentication({ optionsJSON: opts }),
|
|
162
|
+
PASSKEY_TIMEOUT_MS,
|
|
163
|
+
);
|
|
141
164
|
const vRes = await fetch(`${apiBase}/passkey/login-finish`, {
|
|
142
165
|
method: 'POST',
|
|
143
166
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -148,7 +171,8 @@ async function handleEmailSubmit(e: SubmitEvent) {
|
|
|
148
171
|
return;
|
|
149
172
|
}
|
|
150
173
|
} catch {
|
|
151
|
-
|
|
174
|
+
mod.WebAuthnAbortService.cancelCeremony();
|
|
175
|
+
/* stalled, cancelled, or failed — fall through to OTP */
|
|
152
176
|
}
|
|
153
177
|
}
|
|
154
178
|
}
|
|
@@ -417,11 +441,13 @@ async function removePasskey(id: string) {
|
|
|
417
441
|
spellcheck="false"
|
|
418
442
|
disabled={loading}
|
|
419
443
|
/>
|
|
420
|
-
<button type="submit" class="anahtar-pill-go" disabled={loading || !email.includes('@')}>
|
|
444
|
+
<button type="submit" class="anahtar-pill-go" disabled={loading || !email.includes('@')} aria-label={m.continue}>
|
|
421
445
|
{#if loading}
|
|
422
|
-
|
|
446
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="anahtar-spinner" aria-hidden="true">
|
|
447
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
|
448
|
+
</svg>
|
|
423
449
|
{:else}
|
|
424
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
450
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
425
451
|
<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
|
|
426
452
|
</svg>
|
|
427
453
|
{/if}
|
|
@@ -588,6 +614,11 @@ async function removePasskey(id: string) {
|
|
|
588
614
|
.anahtar-pill-go:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
589
615
|
.anahtar-pill-go:hover:not(:disabled) { opacity: 0.85; }
|
|
590
616
|
|
|
617
|
+
@keyframes anahtar-spin {
|
|
618
|
+
to { transform: rotate(360deg); }
|
|
619
|
+
}
|
|
620
|
+
.anahtar-spinner { animation: anahtar-spin 0.8s linear infinite; }
|
|
621
|
+
|
|
591
622
|
/* OTP */
|
|
592
623
|
.anahtar-pill-otp-label {
|
|
593
624
|
font-size: 0.8125rem;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrgnw/anahtar",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.30",
|
|
4
4
|
"description": "Opinionated, reusable auth for SvelteKit. Email+OTP + passkeys.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,10 @@
|
|
|
40
40
|
"types": "./dist/db/d1.d.ts",
|
|
41
41
|
"default": "./dist/db/d1.js"
|
|
42
42
|
},
|
|
43
|
+
"./device": {
|
|
44
|
+
"types": "./dist/device.d.ts",
|
|
45
|
+
"default": "./dist/device.js"
|
|
46
|
+
},
|
|
43
47
|
"./components": {
|
|
44
48
|
"types": "./dist/components/index.d.ts",
|
|
45
49
|
"svelte": "./dist/components/index.js",
|
|
@@ -64,7 +68,6 @@
|
|
|
64
68
|
}
|
|
65
69
|
},
|
|
66
70
|
"dependencies": {
|
|
67
|
-
"@mrgnw/anahtar": "0.0.29-diagnostic.0",
|
|
68
71
|
"@oslojs/crypto": "^1.0.1",
|
|
69
72
|
"@oslojs/encoding": "^1.1.0",
|
|
70
73
|
"@simplewebauthn/server": "^13.2.2"
|