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
7678async 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 - z 0 - 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
138154async 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
160177async 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
178200async 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 - z 0 - 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
348384async 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
370408async function verifyToken ( request , env ) {
371- const token = ( request . headers . get ( 'Authorization' ) || '' ) . replace ( / ^ B e a r e r \s + / i, '' ) ;
409+ const auth = request . headers . get ( 'Authorization' ) || '' ;
410+ const token = auth . replace ( / ^ B e a r e r \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
498540function getRpId ( env ) {
499541 return env . FRONTEND_RP_ID || 'vibecoded-stocard.pages.dev' ;
500542}
0 commit comments