@mrgnw/anahtar 0.0.29-diagnostic.0 → 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/AuthFlow.svelte +3 -0
- package/dist/components/AuthPill.svelte +55 -21
- package/dist/db/d1.js +10 -8
- package/dist/db/postgres.js +8 -6
- package/dist/db/sqlite.js +10 -8
- package/dist/email.d.ts +1 -0
- package/dist/email.js +6 -0
- package/package.json +5 -1
|
@@ -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
|
}
|
|
@@ -412,13 +436,18 @@ async function removePasskey(id: string) {
|
|
|
412
436
|
placeholder={m.emailPlaceholder}
|
|
413
437
|
class="anahtar-pill-email-input"
|
|
414
438
|
autocomplete="username webauthn"
|
|
439
|
+
autocapitalize="none"
|
|
440
|
+
autocorrect="off"
|
|
441
|
+
spellcheck="false"
|
|
415
442
|
disabled={loading}
|
|
416
443
|
/>
|
|
417
|
-
<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}>
|
|
418
445
|
{#if loading}
|
|
419
|
-
|
|
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>
|
|
420
449
|
{:else}
|
|
421
|
-
<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">
|
|
422
451
|
<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
|
|
423
452
|
</svg>
|
|
424
453
|
{/if}
|
|
@@ -585,6 +614,11 @@ async function removePasskey(id: string) {
|
|
|
585
614
|
.anahtar-pill-go:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
586
615
|
.anahtar-pill-go:hover:not(:disabled) { opacity: 0.85; }
|
|
587
616
|
|
|
617
|
+
@keyframes anahtar-spin {
|
|
618
|
+
to { transform: rotate(360deg); }
|
|
619
|
+
}
|
|
620
|
+
.anahtar-spinner { animation: anahtar-spin 0.8s linear infinite; }
|
|
621
|
+
|
|
588
622
|
/* OTP */
|
|
589
623
|
.anahtar-pill-otp-label {
|
|
590
624
|
font-size: 0.8125rem;
|
package/dist/db/d1.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeEmail } from '../email.js';
|
|
1
2
|
export function d1Adapter(db, options = {}) {
|
|
2
3
|
const p = options.tablePrefix ?? 'auth_';
|
|
3
4
|
const t = {
|
|
@@ -45,8 +46,8 @@ export function d1Adapter(db, options = {}) {
|
|
|
45
46
|
},
|
|
46
47
|
async getUserByEmail(email) {
|
|
47
48
|
const row = await db
|
|
48
|
-
.prepare(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email =
|
|
49
|
-
.bind(email)
|
|
49
|
+
.prepare(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email = ? COLLATE NOCASE`)
|
|
50
|
+
.bind(normalizeEmail(email))
|
|
50
51
|
.first();
|
|
51
52
|
if (!row)
|
|
52
53
|
return null;
|
|
@@ -59,10 +60,11 @@ export function d1Adapter(db, options = {}) {
|
|
|
59
60
|
},
|
|
60
61
|
async createUser(email) {
|
|
61
62
|
const id = crypto.randomUUID();
|
|
62
|
-
|
|
63
|
+
const normalized = normalizeEmail(email);
|
|
64
|
+
await db.prepare(`INSERT INTO ${t.users} (id, email) VALUES (?, ?)`).bind(id, normalized).run();
|
|
63
65
|
return {
|
|
64
66
|
id,
|
|
65
|
-
email,
|
|
67
|
+
email: normalized,
|
|
66
68
|
skipPasskeyPrompt: false,
|
|
67
69
|
createdAt: Math.floor(Date.now() / 1000)
|
|
68
70
|
};
|
|
@@ -102,13 +104,13 @@ export function d1Adapter(db, options = {}) {
|
|
|
102
104
|
async storeOTP(email, id, code, expiresAt) {
|
|
103
105
|
await db
|
|
104
106
|
.prepare(`INSERT INTO ${t.otpCodes} (id, email, code, expires_at) VALUES (?, ?, ?, ?)`)
|
|
105
|
-
.bind(id, email, code, expiresAt)
|
|
107
|
+
.bind(id, normalizeEmail(email), code, expiresAt)
|
|
106
108
|
.run();
|
|
107
109
|
},
|
|
108
110
|
async getLatestOTP(email) {
|
|
109
111
|
const row = await db
|
|
110
|
-
.prepare(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = ? ORDER BY created_at DESC LIMIT 1`)
|
|
111
|
-
.bind(email)
|
|
112
|
+
.prepare(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = ? COLLATE NOCASE ORDER BY created_at DESC LIMIT 1`)
|
|
113
|
+
.bind(normalizeEmail(email))
|
|
112
114
|
.first();
|
|
113
115
|
if (!row)
|
|
114
116
|
return null;
|
|
@@ -127,7 +129,7 @@ export function d1Adapter(db, options = {}) {
|
|
|
127
129
|
await db.prepare(`DELETE FROM ${t.otpCodes} WHERE id = ?`).bind(id).run();
|
|
128
130
|
},
|
|
129
131
|
async deleteOTPsForEmail(email) {
|
|
130
|
-
await db.prepare(`DELETE FROM ${t.otpCodes} WHERE email =
|
|
132
|
+
await db.prepare(`DELETE FROM ${t.otpCodes} WHERE email = ? COLLATE NOCASE`).bind(normalizeEmail(email)).run();
|
|
131
133
|
},
|
|
132
134
|
async storeChallenge(challenge, userId, expiresAt) {
|
|
133
135
|
await db.prepare(`DELETE FROM ${t.challenges} WHERE expires_at < ?`).bind(Date.now()).run();
|
package/dist/db/postgres.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { normalizeEmail } from '../email.js';
|
|
2
3
|
export function postgresAdapter(pool, options = {}) {
|
|
3
4
|
const p = options.tablePrefix ?? 'auth_';
|
|
4
5
|
const t = {
|
|
@@ -58,7 +59,7 @@ export function postgresAdapter(pool, options = {}) {
|
|
|
58
59
|
`);
|
|
59
60
|
},
|
|
60
61
|
async getUserByEmail(email) {
|
|
61
|
-
const row = await queryOne(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email = $1`, [email]);
|
|
62
|
+
const row = await queryOne(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE LOWER(email) = $1`, [normalizeEmail(email)]);
|
|
62
63
|
if (!row)
|
|
63
64
|
return null;
|
|
64
65
|
return {
|
|
@@ -70,10 +71,11 @@ export function postgresAdapter(pool, options = {}) {
|
|
|
70
71
|
},
|
|
71
72
|
async createUser(email) {
|
|
72
73
|
const id = randomUUID();
|
|
73
|
-
|
|
74
|
+
const normalized = normalizeEmail(email);
|
|
75
|
+
await pool.query(`INSERT INTO ${t.users} (id, email) VALUES ($1, $2)`, [id, normalized]);
|
|
74
76
|
return {
|
|
75
77
|
id,
|
|
76
|
-
email,
|
|
78
|
+
email: normalized,
|
|
77
79
|
skipPasskeyPrompt: false,
|
|
78
80
|
createdAt: Date.now()
|
|
79
81
|
};
|
|
@@ -108,13 +110,13 @@ export function postgresAdapter(pool, options = {}) {
|
|
|
108
110
|
async storeOTP(email, id, code, expiresAt) {
|
|
109
111
|
await pool.query(`INSERT INTO ${t.otpCodes} (id, email, code, expires_at) VALUES ($1, $2, $3, $4)`, [
|
|
110
112
|
id,
|
|
111
|
-
email,
|
|
113
|
+
normalizeEmail(email),
|
|
112
114
|
code,
|
|
113
115
|
expiresAt
|
|
114
116
|
]);
|
|
115
117
|
},
|
|
116
118
|
async getLatestOTP(email) {
|
|
117
|
-
const row = await queryOne(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = $1 ORDER BY created_at DESC LIMIT 1`, [email]);
|
|
119
|
+
const row = await queryOne(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE LOWER(email) = $1 ORDER BY created_at DESC LIMIT 1`, [normalizeEmail(email)]);
|
|
118
120
|
if (!row)
|
|
119
121
|
return null;
|
|
120
122
|
return {
|
|
@@ -132,7 +134,7 @@ export function postgresAdapter(pool, options = {}) {
|
|
|
132
134
|
await pool.query(`DELETE FROM ${t.otpCodes} WHERE id = $1`, [id]);
|
|
133
135
|
},
|
|
134
136
|
async deleteOTPsForEmail(email) {
|
|
135
|
-
await pool.query(`DELETE FROM ${t.otpCodes} WHERE email = $1`, [email]);
|
|
137
|
+
await pool.query(`DELETE FROM ${t.otpCodes} WHERE LOWER(email) = $1`, [normalizeEmail(email)]);
|
|
136
138
|
},
|
|
137
139
|
async storeChallenge(challenge, userId, expiresAt) {
|
|
138
140
|
await pool.query(`DELETE FROM ${t.challenges} WHERE expires_at < $1`, [Date.now()]);
|
package/dist/db/sqlite.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { normalizeEmail } from '../email.js';
|
|
2
3
|
export function sqliteAdapter(db, options = {}) {
|
|
3
4
|
const p = options.tablePrefix ?? 'auth_';
|
|
4
5
|
const t = {
|
|
@@ -53,8 +54,8 @@ export function sqliteAdapter(db, options = {}) {
|
|
|
53
54
|
},
|
|
54
55
|
getUserByEmail(email) {
|
|
55
56
|
const row = db
|
|
56
|
-
.prepare(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email =
|
|
57
|
-
.get(email);
|
|
57
|
+
.prepare(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email = ? COLLATE NOCASE`)
|
|
58
|
+
.get(normalizeEmail(email));
|
|
58
59
|
if (!row)
|
|
59
60
|
return null;
|
|
60
61
|
return {
|
|
@@ -66,10 +67,11 @@ export function sqliteAdapter(db, options = {}) {
|
|
|
66
67
|
},
|
|
67
68
|
createUser(email) {
|
|
68
69
|
const id = randomUUID();
|
|
69
|
-
|
|
70
|
+
const normalized = normalizeEmail(email);
|
|
71
|
+
db.prepare(`INSERT INTO ${t.users} (id, email) VALUES (?, ?)`).run(id, normalized);
|
|
70
72
|
return {
|
|
71
73
|
id,
|
|
72
|
-
email,
|
|
74
|
+
email: normalized,
|
|
73
75
|
skipPasskeyPrompt: false,
|
|
74
76
|
createdAt: Math.floor(Date.now() / 1000)
|
|
75
77
|
};
|
|
@@ -100,12 +102,12 @@ export function sqliteAdapter(db, options = {}) {
|
|
|
100
102
|
db.prepare(`DELETE FROM ${t.sessions} WHERE id = ?`).run(tokenHash);
|
|
101
103
|
},
|
|
102
104
|
storeOTP(email, id, code, expiresAt) {
|
|
103
|
-
db.prepare(`INSERT INTO ${t.otpCodes} (id, email, code, expires_at) VALUES (?, ?, ?, ?)`).run(id, email, code, expiresAt);
|
|
105
|
+
db.prepare(`INSERT INTO ${t.otpCodes} (id, email, code, expires_at) VALUES (?, ?, ?, ?)`).run(id, normalizeEmail(email), code, expiresAt);
|
|
104
106
|
},
|
|
105
107
|
getLatestOTP(email) {
|
|
106
108
|
const row = db
|
|
107
|
-
.prepare(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = ? ORDER BY created_at DESC LIMIT 1`)
|
|
108
|
-
.get(email);
|
|
109
|
+
.prepare(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = ? COLLATE NOCASE ORDER BY created_at DESC LIMIT 1`)
|
|
110
|
+
.get(normalizeEmail(email));
|
|
109
111
|
if (!row)
|
|
110
112
|
return null;
|
|
111
113
|
return {
|
|
@@ -123,7 +125,7 @@ export function sqliteAdapter(db, options = {}) {
|
|
|
123
125
|
db.prepare(`DELETE FROM ${t.otpCodes} WHERE id = ?`).run(id);
|
|
124
126
|
},
|
|
125
127
|
deleteOTPsForEmail(email) {
|
|
126
|
-
db.prepare(`DELETE FROM ${t.otpCodes} WHERE email =
|
|
128
|
+
db.prepare(`DELETE FROM ${t.otpCodes} WHERE email = ? COLLATE NOCASE`).run(normalizeEmail(email));
|
|
127
129
|
},
|
|
128
130
|
storeChallenge(challenge, userId, expiresAt) {
|
|
129
131
|
db.prepare(`DELETE FROM ${t.challenges} WHERE expires_at < ?`).run(Date.now());
|
package/dist/email.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function normalizeEmail(email: string): string;
|
package/dist/email.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Email is the user's identity key. Normalize it to one canonical form so the
|
|
2
|
+
// same address always maps to a single account regardless of how it was typed
|
|
3
|
+
// (mobile keyboards auto-capitalize, users add stray whitespace, etc.).
|
|
4
|
+
export function normalizeEmail(email) {
|
|
5
|
+
return email.trim().toLowerCase();
|
|
6
|
+
}
|
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",
|