Skip to content

Commit 975db67

Browse files
committed
fix usernames
1 parent c59c54f commit 975db67

2 files changed

Lines changed: 103 additions & 61 deletions

File tree

public/index.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -243,10 +243,10 @@ <h2>Welcome back</h2>
243243
<div class="auth-card" id="auth-register-panel" style="display:none">
244244
<div>
245245
<h2>Create account</h2>
246-
<p>Register a passkey — or skip it and use magic link to sign in from any device.</p>
246+
<p>Enter your email to register a passkey. Your email ties your passkey and magic link to the same account.</p>
247247
</div>
248248
<div class="auth-error" id="register-error"></div>
249-
<input class="auth-input" type="text" id="reg-username" placeholder="Choose a username" autocomplete="username" maxlength="32" />
249+
<input class="auth-input" type="email" id="reg-email" placeholder="your@email.com" autocomplete="email" onkeydown="if(event.key==='Enter') registerPasskey()" />
250250
<button class="passkey-btn" onclick="registerPasskey()" id="register-btn">
251251
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
252252
Register Passkey
@@ -520,11 +520,11 @@ <h3>About</h3>
520520

521521
// ── REGISTER ──────────────────────────────────────────────────
522522
async function registerPasskey() {
523-
const username = document.getElementById('reg-username').value.trim();
524-
if (!username || username.length < 2) { showAuthError('register','Username must be at least 2 characters'); return; }
523+
const email = document.getElementById('reg-email').value.trim();
524+
if (!email || !email.includes('@')) { showAuthError('register', 'Please enter a valid email address'); return; }
525525
setAuthLoading('register', true);
526526
try {
527-
const { options, error } = await api('/auth/register/begin','POST',{ username });
527+
const { options, error } = await api('/auth/register/begin', 'POST', { email });
528528
if (error) { showAuthError('register', error); return; }
529529

530530
const cred = await navigator.credentials.create({ publicKey: {

worker/index.js

Lines changed: 98 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* KV namespace bindings (wrangler.toml):
77
* CARDEX_KV
88
*
9-
* Secrets (wrangler secret put <name>):
9+
* Secrets (wrangler secret put <NAME>):
1010
* JWT_SECRET — long random string for signing JWTs
1111
* BREVO_API_KEY — from app.brevo.com → SMTP & API → API Keys
1212
*
@@ -71,6 +71,8 @@ export default {
7171

7272
// ════════════════════════════════════════════════════════════════════════════
7373
// MAGIC LINK — SEND
74+
// POST /auth/magic/send { email }
75+
// Creates/finds user by email, stores a 15-min token, sends link via Brevo.
7476
// ════════════════════════════════════════════════════════════════════════════
7577

7678
async function magicSend(request, env) {
@@ -79,46 +81,58 @@ async function magicSend(request, env) {
7981

8082
const normalEmail = email.toLowerCase().trim();
8183

84+
// Find or create user
8285
let userId = await env.CARDEX_KV.get(`email:${normalEmail}`);
8386
if (!userId) {
87+
// New user — auto-create account from email
8488
userId = crypto.randomUUID();
8589
const username = normalEmail.split('@')[0].replace(/[^a-z0-9_]/gi, '').slice(0, 20) || 'user';
8690
await env.CARDEX_KV.put(`user:${userId}`, JSON.stringify({
87-
id: userId, username, email: normalEmail, createdAt: new Date().toISOString(),
91+
id: userId,
92+
username,
93+
email: normalEmail,
94+
createdAt: new Date().toISOString(),
8895
}));
8996
await env.CARDEX_KV.put(`email:${normalEmail}`, userId);
9097
}
9198

99+
// Generate token
92100
const token = generateToken();
93-
const expires = Date.now() + 15 * 60 * 1000;
101+
const expires = Date.now() + 15 * 60 * 1000; // 15 minutes
94102

95103
await env.CARDEX_KV.put(
96104
`magiclink:${token}`,
97105
JSON.stringify({ userId, email: normalEmail, expires }),
98-
{ expirationTtl: 900 }
106+
{ expirationTtl: 900 } // 15 min in seconds
99107
);
100108

109+
// Build magic link — points back to the frontend with ?magic=<token>
101110
const frontendOrigin = env.FRONTEND_ORIGIN || 'https://vibecoded-stocard.pages.dev';
102111
const magicUrl = `${frontendOrigin}/?magic=${token}`;
103112

113+
// Send via Brevo
104114
const emailResult = await sendBrevoEmail({
105-
apiKey: env.BREVO_API_KEY,
106-
to: normalEmail,
107-
fromEmail: env.EMAIL_FROM || 'noreply@cardex.app',
108-
fromName: env.EMAIL_FROM_NAME || 'Cardex',
109-
subject: 'Your Cardex sign-in link',
115+
apiKey: env.BREVO_API_KEY,
116+
to: normalEmail,
117+
fromEmail: env.EMAIL_FROM || 'noreply@cardex.app',
118+
fromName: env.EMAIL_FROM_NAME || 'Cardex',
119+
subject: 'Your Cardex sign-in link',
110120
html: `
111121
<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:32px 24px;background:#f9f9fb;border-radius:12px">
112-
<h1 style="font-size:24px;font-weight:700;margin:0 0 8px;color:#0a0a0f">Sign in to Cardex</h1>
122+
<h1 style="font-size:24px;font-weight:700;margin:0 0 8px;color:#0a0a0f">
123+
Sign in to Cardex
124+
</h1>
113125
<p style="color:#555;margin:0 0 28px;line-height:1.6">
114126
Click the button below to sign in. This link expires in <strong>15 minutes</strong> and can only be used once.
115127
</p>
116-
<a href="${magicUrl}" style="display:inline-block;padding:14px 28px;background:linear-gradient(135deg,#7c6dfa,#fa6d9a);color:white;text-decoration:none;border-radius:10px;font-weight:600;font-size:16px">
128+
<a href="${magicUrl}"
129+
style="display:inline-block;padding:14px 28px;background:linear-gradient(135deg,#7c6dfa,#fa6d9a);color:white;text-decoration:none;border-radius:10px;font-weight:600;font-size:16px">
117130
Sign in to Cardex
118131
</a>
119132
<p style="color:#999;font-size:12px;margin:28px 0 0;line-height:1.6">
120-
If you didn't request this, ignore this email.<br/>
121-
Or copy this link: <a href="${magicUrl}" style="color:#7c6dfa;word-break:break-all">${magicUrl}</a>
133+
If you didn't request this, you can safely ignore this email.<br/>
134+
Or copy this link manually:<br/>
135+
<a href="${magicUrl}" style="color:#7c6dfa;word-break:break-all">${magicUrl}</a>
122136
</p>
123137
</div>`,
124138
});
@@ -128,11 +142,13 @@ async function magicSend(request, env) {
128142
return json({ error: 'Failed to send email. Check BREVO_API_KEY.' }, 502, env);
129143
}
130144

131-
return json({ ok: true }, 200, env);
145+
return json({ ok: true, message: 'Magic link sent — check your email.' }, 200, env);
132146
}
133147

134148
// ════════════════════════════════════════════════════════════════════════════
135149
// MAGIC LINK — VERIFY
150+
// POST /auth/magic/verify { token }
151+
// Validates token, deletes it, issues JWT.
136152
// ════════════════════════════════════════════════════════════════════════════
137153

138154
async function magicVerify(request, env) {
@@ -142,6 +158,7 @@ async function magicVerify(request, env) {
142158
const data = await env.CARDEX_KV.get(`magiclink:${token}`, 'json');
143159
if (!data) return json({ error: 'Link expired or already used' }, 401, env);
144160

161+
// One-time use — delete immediately
145162
await env.CARDEX_KV.delete(`magiclink:${token}`);
146163

147164
if (Date.now() > data.expires) return json({ error: 'Link expired' }, 401, env);
@@ -154,47 +171,64 @@ async function magicVerify(request, env) {
154171
}
155172

156173
// ════════════════════════════════════════════════════════════════════════════
157-
// BREVO
174+
// BREVO EMAIL HELPER
158175
// ════════════════════════════════════════════════════════════════════════════
159176

160177
async function sendBrevoEmail({ apiKey, to, fromEmail, fromName, subject, html }) {
161178
const res = await fetch('https://api.brevo.com/v3/smtp/email', {
162179
method: 'POST',
163-
headers: { 'api-key': apiKey, 'Content-Type': 'application/json', 'Accept': 'application/json' },
180+
headers: {
181+
'api-key': apiKey,
182+
'Content-Type': 'application/json',
183+
'Accept': 'application/json',
184+
},
164185
body: JSON.stringify({
165-
sender: { email: fromEmail, name: fromName },
166-
to: [{ email: to }],
186+
sender: { email: fromEmail, name: fromName },
187+
to: [{ email: to }],
167188
subject,
168189
htmlContent: html,
169190
}),
170191
});
171-
return { ok: res.ok, status: res.status, body: await res.text() };
192+
const body = await res.text();
193+
return { ok: res.ok, status: res.status, body };
172194
}
173195

174196
// ════════════════════════════════════════════════════════════════════════════
175197
// PASSKEY — REGISTER BEGIN
176198
// ════════════════════════════════════════════════════════════════════════════
177199

178200
async function registerBegin(request, env) {
179-
const { username } = await request.json();
180-
if (!username || username.length < 2) return json({ error: 'Username too short' }, 400, env);
201+
const { email } = await request.json();
202+
if (!email || !email.includes('@')) return json({ error: 'Invalid email' }, 400, env);
181203

182-
const existing = await env.CARDEX_KV.get(`username:${username.toLowerCase()}`);
183-
if (existing) return json({ error: 'Username already taken' }, 409, env);
204+
const normalEmail = email.toLowerCase().trim();
205+
206+
// Find existing account by email, or create a new one — same as magic link
207+
let userId = await env.CARDEX_KV.get(`email:${normalEmail}`);
208+
if (!userId) {
209+
userId = crypto.randomUUID();
210+
const username = normalEmail.split('@')[0].replace(/[^a-z0-9_]/gi, '').slice(0, 20) || 'user';
211+
await env.CARDEX_KV.put(`user:${userId}`, JSON.stringify({
212+
id: userId, username, email: normalEmail, createdAt: new Date().toISOString(),
213+
}));
214+
await env.CARDEX_KV.put(`email:${normalEmail}`, userId);
215+
}
184216

185-
const userId = crypto.randomUUID();
186217
const challenge = generateChallenge();
187218

188219
await env.CARDEX_KV.put(
189220
`challenge:${challenge}`,
190-
JSON.stringify({ userId, username, type: 'register' }),
221+
JSON.stringify({ userId, email: normalEmail, type: 'register' }),
191222
{ expirationTtl: 300 }
192223
);
193224

225+
const user = await env.CARDEX_KV.get(`user:${userId}`, 'json');
226+
const displayName = user?.username || normalEmail.split('@')[0];
227+
194228
return json({ options: {
195229
challenge,
196230
rp: { name: 'Cardex Loyalty Wallet', id: getRpId(env) },
197-
user: { id: userId, name: username, displayName: username },
231+
user: { id: userId, name: normalEmail, displayName },
198232
pubKeyCredParams: [
199233
{ alg: -7, type: 'public-key' },
200234
{ alg: -257, type: 'public-key' },
@@ -221,14 +255,14 @@ async function registerFinish(request, env) {
221255
if (!challengeData || challengeData.type !== 'register')
222256
return json({ error: 'Invalid or expired challenge' }, 400, env);
223257

224-
const { userId, username } = challengeData;
258+
const { userId } = challengeData;
225259

226260
const clientDataJSON = base64urlDecode(credential.response.clientDataJSON);
227261
const clientData = JSON.parse(new TextDecoder().decode(clientDataJSON));
228262

229-
if (clientData.type !== 'webauthn.create') return json({ error: 'Wrong ceremony type' }, 400, env);
230-
if (clientData.challenge !== challengeToken) return json({ error: 'Challenge mismatch' }, 400, env);
231-
if (!verifyOrigin(clientData.origin, env)) return json({ error: 'Origin mismatch' }, 400, env);
263+
if (clientData.type !== 'webauthn.create') return json({ error: 'Wrong ceremony type' }, 400, env);
264+
if (clientData.challenge !== challengeToken) return json({ error: 'Challenge mismatch' }, 400, env);
265+
if (!verifyOrigin(clientData.origin, env)) return json({ error: 'Origin mismatch' }, 400, env);
232266

233267
const authData = parseAttestationObject(base64urlDecode(credential.response.attestationObject));
234268
const credId = bufferToBase64url(authData.credentialId);
@@ -240,11 +274,10 @@ async function registerFinish(request, env) {
240274
transports: credential.response.transports || [],
241275
}));
242276

243-
await env.CARDEX_KV.put(`user:${userId}`, JSON.stringify({ id: userId, username, createdAt: new Date().toISOString() }));
244-
await env.CARDEX_KV.put(`username:${username.toLowerCase()}`, userId);
245-
277+
// User record already exists (created in registerBegin) — just fetch for the response
278+
const user = await env.CARDEX_KV.get(`user:${userId}`, 'json');
246279
const token = await issueToken(userId, env);
247-
return json({ token, userId, username }, 200, env);
280+
return json({ token, userId, username: user?.username }, 200, env);
248281
}
249282

250283
// ════════════════════════════════════════════════════════════════════════════
@@ -259,13 +292,15 @@ async function loginBegin(request, env) {
259292
{ expirationTtl: 300 }
260293
);
261294

262-
return json({ options: {
263-
challenge,
264-
rpId: getRpId(env),
265-
timeout: 60000,
266-
userVerification: 'required',
267-
allowCredentials: [],
268-
}}, 200, env);
295+
return json({
296+
options: {
297+
challenge,
298+
rpId: getRpId(env),
299+
timeout: 60000,
300+
userVerification: 'required',
301+
allowCredentials: [],
302+
}
303+
}, 200, env);
269304
}
270305

271306
// ════════════════════════════════════════════════════════════════════════════
@@ -289,17 +324,18 @@ async function loginFinish(request, env) {
289324
if (clientData.challenge !== challengeToken) return json({ error: 'Challenge mismatch' }, 400, env);
290325
if (!verifyOrigin(clientData.origin, env)) return json({ error: 'Origin mismatch' }, 400, env);
291326

292-
const authDataBuf = base64urlDecode(credential.response.authenticatorData);
293-
const authData = parseAuthenticatorData(authDataBuf);
327+
const authDataBuf = base64urlDecode(credential.response.authenticatorData);
328+
const authData = parseAuthenticatorData(authDataBuf);
294329

295330
if (authData.counter > 0 && authData.counter <= credEntry.counter)
296331
return json({ error: 'Counter replay detected' }, 400, env);
297332

298-
const valid = await verifyCoseSignature(
299-
base64urlDecode(credEntry.publicKeyCose),
300-
concat(authDataBuf, await sha256(clientDataJSON)),
301-
base64urlDecode(credential.response.signature)
302-
);
333+
const publicKeyCose = base64urlDecode(credEntry.publicKeyCose);
334+
const signatureBuf = base64urlDecode(credential.response.signature);
335+
const clientDataHash = await sha256(clientDataJSON);
336+
const signedData = concat(authDataBuf, clientDataHash);
337+
338+
const valid = await verifyCoseSignature(publicKeyCose, signedData, signatureBuf);
303339
if (!valid) return json({ error: 'Signature verification failed' }, 401, env);
304340

305341
await env.CARDEX_KV.put(`cred:${credential.id}`, JSON.stringify({ ...credEntry, counter: authData.counter }));
@@ -346,11 +382,13 @@ async function setCards(request, env) {
346382
// ════════════════════════════════════════════════════════════════════════════
347383

348384
async function getJwtKey(env) {
385+
const secret = env.JWT_SECRET || 'changeme-set-JWT_SECRET-in-wrangler';
349386
return crypto.subtle.importKey(
350387
'raw',
351-
new TextEncoder().encode(env.JWT_SECRET || 'changeme-set-JWT_SECRET-in-wrangler'),
388+
new TextEncoder().encode(secret),
352389
{ name: 'HMAC', hash: 'SHA-256' },
353-
false, ['sign', 'verify']
390+
false,
391+
['sign', 'verify']
354392
);
355393
}
356394

@@ -368,13 +406,16 @@ async function issueToken(userId, env) {
368406
}
369407

370408
async function verifyToken(request, env) {
371-
const token = (request.headers.get('Authorization') || '').replace(/^Bearer\s+/i, '');
409+
const auth = request.headers.get('Authorization') || '';
410+
const token = auth.replace(/^Bearer\s+/i, '');
372411
if (!token) return { error: 'Missing token' };
373412
try {
374413
const [hB64, pB64, sB64] = token.split('.');
375414
const key = await getJwtKey(env);
376415
const valid = await crypto.subtle.verify(
377-
'HMAC', key, base64urlDecode(sB64), new TextEncoder().encode(`${hB64}.${pB64}`)
416+
'HMAC', key,
417+
base64urlDecode(sB64),
418+
new TextEncoder().encode(`${hB64}.${pB64}`)
378419
);
379420
if (!valid) return { error: 'Invalid token' };
380421
const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(pB64)));
@@ -422,7 +463,7 @@ function parseAttestationObject(buf) {
422463
const majorType = byte >> 5;
423464
const addInfo = byte & 0x1f;
424465
let len = addInfo;
425-
if (addInfo === 24) { len = dv.getUint8(offset++); }
466+
if (addInfo === 24) { len = dv.getUint8(offset++); }
426467
else if (addInfo === 25) { len = dv.getUint16(offset); offset += 2; }
427468
else if (addInfo === 26) { len = dv.getUint32(offset); offset += 4; }
428469

@@ -443,8 +484,8 @@ function parseAuthenticatorData(buf, includeCredential = false) {
443484
const AT = (buf[32] & 0x40) !== 0;
444485
let credentialId = null, credentialPublicKey = null;
445486
if (includeCredential && AT) {
446-
let off = 37 + 16;
447-
const credIdLen = (buf[off] << 8) | buf[off + 1]; off += 2;
487+
let off = 37 + 16; // skip rpIdHash(32)+flags(1)+counter(4)+aaguid(16)
488+
const credIdLen = (buf[off] << 8) | buf[off + 1]; off += 2;
448489
credentialId = buf.slice(off, off + credIdLen); off += credIdLen;
449490
credentialPublicKey = buf.slice(off);
450491
}
@@ -460,7 +501,7 @@ async function verifyCoseSignature(coseKey, data, signature) {
460501
const majorType = byte >> 5;
461502
const addInfo = byte & 0x1f;
462503
let len = addInfo;
463-
if (addInfo === 24) { len = dv.getUint8(offset++); }
504+
if (addInfo === 24) { len = dv.getUint8(offset++); }
464505
else if (addInfo === 25) { len = dv.getUint16(offset); offset += 2; }
465506
if (majorType === 2) { const b = new Uint8Array(coseKey.buffer, coseKey.byteOffset + offset, len); offset += len; return b; }
466507
if (majorType === 0) return len;
@@ -495,6 +536,7 @@ async function verifyCoseSignature(coseKey, data, signature) {
495536
// MISC HELPERS
496537
// ════════════════════════════════════════════════════════════════════════════
497538

539+
// rpId and origin now come from env vars — no more deriving from request URL
498540
function getRpId(env) {
499541
return env.FRONTEND_RP_ID || 'vibecoded-stocard.pages.dev';
500542
}

0 commit comments

Comments
 (0)