@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.
@@ -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
- let conditionalAbort: AbortController | null = null;
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 () => conditionalAbort?.abort();
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 { startAuthentication } = await import('@simplewebauthn/browser');
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
- conditionalAbort = new AbortController();
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
- const verifyRes = await fetch(`${apiBase}/passkey/login-finish`, {
108
- method: 'POST',
109
- headers: { 'Content-Type': 'application/json' },
110
- body: JSON.stringify(authResponse),
111
- });
112
- if (verifyRes.ok) await onSuccess?.();
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
- conditionalAbort?.abort();
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 { startAuthentication } = await import('@simplewebauthn/browser');
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 startAuthentication({ optionsJSON: opts });
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
- /* cancelled */
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.29",
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"