@mrgnw/anahtar 0.0.28 → 0.0.29

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @mrgnw/anahtar
1
+ # anahtar: incredibly simple & secure auth with passkeys and email+otp fallback
2
2
 
3
3
  > **Pre-alpha.** API may change between releases.
4
4
 
@@ -13,6 +13,12 @@ Auth for SvelteKit. Email+OTP + optional passkeys.
13
13
  3. First login -> passkey registration prompt
14
14
  4. Future logins -> passkey autofill, OTP fallback
15
15
 
16
+ ## Roadmap: Proof-of-concept: everything can change
17
+ The goal is simplicity, we will likely stay with passkeys and email+OTP only.
18
+
19
+ - we may change the implemenation.
20
+ - we will likely split the UI elements from the API, making it easier to run headless, just use the UI, or use both.
21
+
16
22
  ## Quick start
17
23
 
18
24
  ```sh
@@ -210,6 +210,9 @@ function handlePasskeySkip() {
210
210
  bind:value={email}
211
211
  required
212
212
  autocomplete="username webauthn"
213
+ autocapitalize="none"
214
+ autocorrect="off"
215
+ spellcheck="false"
213
216
  placeholder={m.emailPlaceholder}
214
217
  class="anahtar-input"
215
218
  />
@@ -412,6 +412,9 @@ async function removePasskey(id: string) {
412
412
  placeholder={m.emailPlaceholder}
413
413
  class="anahtar-pill-email-input"
414
414
  autocomplete="username webauthn"
415
+ autocapitalize="none"
416
+ autocorrect="off"
417
+ spellcheck="false"
415
418
  disabled={loading}
416
419
  />
417
420
  <button type="submit" class="anahtar-pill-go" disabled={loading || !email.includes('@')}>
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
- await db.prepare(`INSERT INTO ${t.users} (id, email) VALUES (?, ?)`).bind(id, email).run();
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 = ?`).bind(email).run();
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();
@@ -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
- await pool.query(`INSERT INTO ${t.users} (id, email) VALUES ($1, $2)`, [id, email]);
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
- db.prepare(`INSERT INTO ${t.users} (id, email) VALUES (?, ?)`).run(id, email);
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 = ?`).run(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());
@@ -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/dist/passkey.js CHANGED
@@ -121,13 +121,25 @@ export async function generateAuthenticationChallenge(db, requestUrl, config) {
121
121
  }
122
122
  export async function verifyAuthenticationResponse(db, response, requestUrl, config) {
123
123
  const { rpID, origin } = getWebAuthnConfig(requestUrl, config);
124
+ const idHint = `${response.id.slice(0, 8)}…`;
124
125
  const passkey = await db.getPasskeyByCredentialId(response.id);
125
- if (!passkey)
126
+ if (!passkey) {
127
+ console.warn(`[anahtar login-finish] no stored passkey for credentialId=${idHint} (rpID=${rpID})`);
126
128
  return null;
127
- const challenge = JSON.parse(Buffer.from(response.response.clientDataJSON, "base64url").toString()).challenge;
129
+ }
130
+ let challenge;
131
+ try {
132
+ challenge = JSON.parse(Buffer.from(response.response.clientDataJSON, "base64url").toString()).challenge;
133
+ }
134
+ catch (e) {
135
+ console.warn(`[anahtar login-finish] clientDataJSON parse failed: ${e}`);
136
+ return null;
137
+ }
128
138
  const stored = await db.consumeChallenge(challenge);
129
- if (!stored)
139
+ if (!stored) {
140
+ console.warn(`[anahtar login-finish] challenge not found or expired (credentialId=${idHint})`);
130
141
  return null;
142
+ }
131
143
  try {
132
144
  const verification = await verifyAuthResponse({
133
145
  response,
@@ -144,14 +156,17 @@ export async function verifyAuthenticationResponse(db, response, requestUrl, con
144
156
  : undefined,
145
157
  },
146
158
  });
147
- if (!verification.verified)
159
+ if (!verification.verified) {
160
+ console.warn(`[anahtar login-finish] not verified (credentialId=${idHint}, expectedRPID=${rpID}, expectedOrigin=${origin})`);
148
161
  return null;
162
+ }
149
163
  await db.updatePasskeyCounter(passkey.id, verification.authenticationInfo.newCounter);
150
164
  return {
151
165
  user: { id: passkey.userId, email: passkey.email },
152
166
  };
153
167
  }
154
- catch {
168
+ catch (e) {
169
+ console.warn(`[anahtar login-finish] verification threw (credentialId=${idHint}, expectedRPID=${rpID}, expectedOrigin=${origin}): ${e}`);
155
170
  return null;
156
171
  }
157
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrgnw/anahtar",
3
- "version": "0.0.28",
3
+ "version": "0.0.29",
4
4
  "description": "Opinionated, reusable auth for SvelteKit. Email+OTP + passkeys.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,14 @@
12
12
  "registry": "https://registry.npmjs.org/",
13
13
  "access": "public"
14
14
  },
15
+ "scripts": {
16
+ "build": "svelte-package",
17
+ "check": "svelte-check --tsconfig ./tsconfig.json",
18
+ "test": "vitest run --config vitest.unit.ts && vitest run --config vitest.browser.ts",
19
+ "test:unit": "vitest run --config vitest.unit.ts",
20
+ "test:browser": "vitest run --config vitest.browser.ts",
21
+ "prepublishOnly": "pnpm build"
22
+ },
15
23
  "svelte": "./dist/index.js",
16
24
  "types": "./dist/index.d.ts",
17
25
  "exports": {
@@ -56,10 +64,16 @@
56
64
  }
57
65
  },
58
66
  "dependencies": {
67
+ "@mrgnw/anahtar": "0.0.29-diagnostic.0",
59
68
  "@oslojs/crypto": "^1.0.1",
60
69
  "@oslojs/encoding": "^1.1.0",
61
70
  "@simplewebauthn/server": "^13.2.2"
62
71
  },
72
+ "pnpm": {
73
+ "onlyBuiltDependencies": [
74
+ "better-sqlite3"
75
+ ]
76
+ },
63
77
  "devDependencies": {
64
78
  "@simplewebauthn/browser": "^13.2.2",
65
79
  "@sveltejs/kit": "^2.31.1",
@@ -75,12 +89,5 @@
75
89
  "svelte-check": "^4.3.1",
76
90
  "typescript": "^5.9.2",
77
91
  "vitest": "^3.2.1"
78
- },
79
- "scripts": {
80
- "build": "svelte-package",
81
- "check": "svelte-check --tsconfig ./tsconfig.json",
82
- "test": "vitest run --config vitest.unit.ts && vitest run --config vitest.browser.ts",
83
- "test:unit": "vitest run --config vitest.unit.ts",
84
- "test:browser": "vitest run --config vitest.browser.ts"
85
92
  }
86
- }
93
+ }