@pya-platform/auth 0.1.0

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.
@@ -0,0 +1,419 @@
1
+ import {
2
+ OtpVerifyBodySchema,
3
+ PasskeyAuthVerifyBodySchema,
4
+ PasskeyRegisterVerifyBodySchema,
5
+ type ProviderClaims,
6
+ type Role,
7
+ RoleSchema,
8
+ StartBodySchema,
9
+ } from '@pya-platform/shared'
10
+ import { ForbiddenError, UnauthorizedError, ValidationError } from '@pya-platform/shared'
11
+ import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/server'
12
+ import { Hono } from 'hono'
13
+ import { deleteCookie, setCookie } from 'hono/cookie'
14
+ import * as v from 'valibot'
15
+ import { provisionOrLink } from '../identity-link.ts'
16
+ import { logAuth } from '../log.ts'
17
+ import {
18
+ countUnusedCodes,
19
+ invalidateAllPasskeysForUser,
20
+ redeemRecoveryCode,
21
+ regenerateRecoveryCodes,
22
+ } from '../recovery-codes.ts'
23
+ import { issueSession, requireAuth, revokeSession } from '../session.ts'
24
+ import { generateCode, maskEmail, sendOtpEmail, storeOtp, verifyOtp } from '../store/otp-store.ts'
25
+ import {
26
+ countPasskeysByUser,
27
+ deletePasskey,
28
+ findPasskeyByCredentialId,
29
+ findPasskeysByUser,
30
+ insertPasskey,
31
+ } from '../store/passkey-store.ts'
32
+ import {
33
+ genAuthOptions,
34
+ genRegOptions,
35
+ uint8ToBase64url,
36
+ verifyAuth,
37
+ verifyReg,
38
+ } from '../webauthn.ts'
39
+
40
+ const REDIRECT_ALLOWLIST: ReadonlyArray<RegExp> = [
41
+ /^\/$/,
42
+ /^\/stores\/[a-z0-9-]+$/,
43
+ /^\/cart$/,
44
+ /^\/checkout$/,
45
+ /^\/orders\/[a-z0-9-]+$/,
46
+ /^\/profile$/,
47
+ ]
48
+ const safeRedirect = (input: string | undefined): string => {
49
+ if (input === undefined) return '/'
50
+ return REDIRECT_ALLOWLIST.some((re) => re.test(input)) ? input : '/'
51
+ }
52
+
53
+ const sha256Hex = async (input: string): Promise<string> => {
54
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
55
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
56
+ }
57
+
58
+ const parseOrThrow = <T>(schema: v.GenericSchema<unknown, T>, raw: unknown): T => {
59
+ const parsed = v.safeParse(schema, raw)
60
+ if (!parsed.success) {
61
+ throw new ValidationError({
62
+ issues: parsed.issues.map((i) => ({
63
+ path: i.path?.map((p) => String(p.key)).join('.') ?? 'body',
64
+ message: i.message,
65
+ })),
66
+ })
67
+ }
68
+ return parsed.output as T
69
+ }
70
+
71
+ const findUserByEmail = async (
72
+ db: D1Database,
73
+ email: string,
74
+ ): Promise<{ id: string; email: string } | undefined> => {
75
+ const r = await db
76
+ .prepare("SELECT id, email FROM users WHERE email = ? AND status != 'deleted'")
77
+ .bind(email)
78
+ .first<{ id: string; email: string }>()
79
+ return r === null ? undefined : r
80
+ }
81
+
82
+ /** Load distinct roles + owned/staffed store IDs for the session payload.
83
+ * Always includes 'customer' so anyone can place orders. Unknown role rows
84
+ * (schema drift) are dropped via Valibot parse. */
85
+ const loadSessionRoles = async (
86
+ db: D1Database,
87
+ userId: string,
88
+ ): Promise<{ roles: Role[]; storeIds: string[] }> => {
89
+ const { results } = await db
90
+ .prepare('SELECT role, store_id FROM roles WHERE user_id = ?')
91
+ .bind(userId)
92
+ .all<{ role: string; store_id: string | null }>()
93
+ const roleSet = new Set<Role>(['customer'])
94
+ const storeIds: string[] = []
95
+ for (const r of results) {
96
+ const parsed = v.safeParse(RoleSchema, r.role)
97
+ if (!parsed.success) continue
98
+ roleSet.add(parsed.output)
99
+ if (
100
+ r.store_id !== null &&
101
+ (parsed.output === 'store_owner' || parsed.output === 'store_staff')
102
+ ) {
103
+ storeIds.push(r.store_id)
104
+ }
105
+ }
106
+ return { roles: [...roleSet], storeIds }
107
+ }
108
+
109
+ const app = new Hono<{ Bindings: Env }>()
110
+
111
+ // ───── /start ─────
112
+ // Two branches:
113
+ // has passkey → return passkey auth options (no OTP)
114
+ // else → send OTP (code + magic link in email)
115
+ // `force:'otp'` skips the passkey check and always sends OTP.
116
+ app.post('/start', async (c) => {
117
+ const body = parseOrThrow(StartBodySchema, await c.req.json())
118
+ const email = body.email.toLowerCase()
119
+ const redirectAfter = safeRedirect(body.redirect)
120
+
121
+ if (body.force !== 'otp') {
122
+ const user = await findUserByEmail(c.env.DB, email)
123
+ if (user !== undefined) {
124
+ const passkeys = await findPasskeysByUser(c.env.DB, user.id)
125
+ if (passkeys.length > 0) {
126
+ const options = await genAuthOptions(c.env, passkeys)
127
+ await c.env.OAUTH_STATE.put(
128
+ `webauthn:auth:${await sha256Hex(email)}`,
129
+ JSON.stringify({ challenge: options.challenge, redirectAfter, ts: Date.now() }),
130
+ { expirationTtl: 300 },
131
+ )
132
+ return c.json({ method: 'passkey' as const, options })
133
+ }
134
+ }
135
+ }
136
+
137
+ const code = generateCode()
138
+ await storeOtp(c.env.OAUTH_STATE, c.env, email, code, redirectAfter)
139
+ await sendOtpEmail(c.env, email, code)
140
+ return c.json({ method: 'otp' as const, sentTo: maskEmail(email) })
141
+ })
142
+
143
+ // ───── /otp/verify ─────
144
+ app.post('/otp/verify', async (c) => {
145
+ const body = parseOrThrow(OtpVerifyBodySchema, await c.req.json())
146
+ const email = body.email.toLowerCase()
147
+
148
+ const result = await verifyOtp(c.env.OAUTH_STATE, c.env, email, body.code)
149
+ if (result.status === 'expired') throw new UnauthorizedError({ reason: 'code_expired' })
150
+ if (result.status === 'locked') throw new UnauthorizedError({ reason: 'too_many_attempts' })
151
+ if (result.status === 'invalid') {
152
+ throw new UnauthorizedError({ reason: `invalid_code:${result.remaining}_remaining` })
153
+ }
154
+
155
+ const claims: ProviderClaims = {
156
+ provider: 'email',
157
+ subject: email,
158
+ email,
159
+ emailVerified: true,
160
+ }
161
+ const link = await provisionOrLink(c.env.DB, claims, 'login', undefined)
162
+ const passkeyCount = await countPasskeysByUser(c.env.DB, link.userId)
163
+
164
+ const ip = c.req.header('CF-Connecting-IP') ?? ''
165
+ logAuth({
166
+ event: 'auth.login.email',
167
+ ts: Math.floor(Date.now() / 1000),
168
+ userId: link.userId,
169
+ provider: 'email',
170
+ ipHash: await sha256Hex(ip + (c.env.SESSION_PEPPER ?? '')),
171
+ outcome: link.created ? 'created' : link.linked ? 'linked' : 'reused',
172
+ })
173
+
174
+ const { roles, storeIds } = await loadSessionRoles(c.env.DB, link.userId)
175
+ const session = await issueSession(c, setCookie, false, {
176
+ userId: link.userId,
177
+ roles,
178
+ storeIds,
179
+ })
180
+
181
+ return c.json({
182
+ ok: true,
183
+ sid: session.sid,
184
+ csrf: session.csrf,
185
+ hasPasskey: passkeyCount > 0,
186
+ redirect: result.record.redirectAfter,
187
+ })
188
+ })
189
+
190
+ // ───── /passkey/auth/verify ─────
191
+ app.post('/passkey/auth/verify', async (c) => {
192
+ const body = parseOrThrow(PasskeyAuthVerifyBodySchema, await c.req.json())
193
+ const email = body.email.toLowerCase()
194
+
195
+ const stateKey = `webauthn:auth:${await sha256Hex(email)}`
196
+ const state = await c.env.OAUTH_STATE.get<{ challenge: string; redirectAfter: string }>(
197
+ stateKey,
198
+ {
199
+ type: 'json',
200
+ },
201
+ )
202
+ if (state === null) throw new UnauthorizedError({ reason: 'challenge_expired' })
203
+ await c.env.OAUTH_STATE.delete(stateKey)
204
+
205
+ const user = await findUserByEmail(c.env.DB, email)
206
+ if (user === undefined) throw new UnauthorizedError({ reason: 'no_user' })
207
+
208
+ const assertion = body.assertion as unknown as AuthenticationResponseJSON
209
+ const passkey = await findPasskeyByCredentialId(c.env.DB, assertion.id)
210
+ if (passkey === undefined || passkey.userId !== user.id) {
211
+ throw new UnauthorizedError({ reason: 'unknown_credential' })
212
+ }
213
+
214
+ const verified = await verifyAuth(c.env, assertion, state.challenge, passkey)
215
+ if (!verified.verified) throw new UnauthorizedError({ reason: 'assertion_invalid' })
216
+
217
+ await c.env.DB.prepare(
218
+ 'UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE credential_id = ?',
219
+ )
220
+ .bind(
221
+ verified.authenticationInfo.newCounter,
222
+ Math.floor(Date.now() / 1000),
223
+ passkey.credentialId,
224
+ )
225
+ .run()
226
+
227
+ const ip = c.req.header('CF-Connecting-IP') ?? ''
228
+ logAuth({
229
+ event: 'auth.login.passkey',
230
+ ts: Math.floor(Date.now() / 1000),
231
+ userId: user.id,
232
+ provider: 'email',
233
+ ipHash: await sha256Hex(ip + (c.env.SESSION_PEPPER ?? '')),
234
+ outcome: 'reused',
235
+ })
236
+
237
+ const { roles, storeIds } = await loadSessionRoles(c.env.DB, user.id)
238
+ const session = await issueSession(c, setCookie, false, {
239
+ userId: user.id,
240
+ roles,
241
+ storeIds,
242
+ })
243
+
244
+ return c.json({
245
+ ok: true,
246
+ sid: session.sid,
247
+ csrf: session.csrf,
248
+ redirect: state.redirectAfter,
249
+ })
250
+ })
251
+
252
+ // ───── /passkey/register/options ───── (requires session)
253
+ app.post('/passkey/register/options', requireAuth, async (c) => {
254
+ const session = c.get('session')
255
+ const user = await c.env.DB.prepare('SELECT email FROM users WHERE id = ?')
256
+ .bind(session.userId)
257
+ .first<{ email: string }>()
258
+ if (user === null) throw new ForbiddenError({ required: 'user' })
259
+
260
+ const existing = await findPasskeysByUser(c.env.DB, session.userId)
261
+ const options = await genRegOptions(c.env, session.userId, user.email, existing)
262
+ const challengeId = crypto.randomUUID()
263
+ await c.env.OAUTH_STATE.put(
264
+ `webauthn:reg:${challengeId}`,
265
+ JSON.stringify({ userId: session.userId, challenge: options.challenge, ts: Date.now() }),
266
+ { expirationTtl: 300 },
267
+ )
268
+ return c.json({ challengeId, options })
269
+ })
270
+
271
+ // ───── /passkey/register/verify ───── (requires session)
272
+ app.post('/passkey/register/verify', requireAuth, async (c) => {
273
+ const body = parseOrThrow(PasskeyRegisterVerifyBodySchema, await c.req.json())
274
+ const session = c.get('session')
275
+
276
+ const stateKey = `webauthn:reg:${body.challengeId}`
277
+ const state = await c.env.OAUTH_STATE.get<{ userId: string; challenge: string }>(stateKey, {
278
+ type: 'json',
279
+ })
280
+ if (state === null || state.userId !== session.userId) {
281
+ throw new UnauthorizedError({ reason: 'challenge_expired' })
282
+ }
283
+ await c.env.OAUTH_STATE.delete(stateKey)
284
+
285
+ const attestation = body.attestation as unknown as RegistrationResponseJSON
286
+ const verified = await verifyReg(c.env, attestation, state.challenge)
287
+ if (!verified.verified || verified.registrationInfo === undefined) {
288
+ throw new UnauthorizedError({ reason: 'attestation_invalid' })
289
+ }
290
+
291
+ const info = verified.registrationInfo
292
+ await insertPasskey(c.env.DB, {
293
+ credentialId: info.credential.id,
294
+ userId: session.userId,
295
+ publicKey: uint8ToBase64url(info.credential.publicKey),
296
+ signCount: info.credential.counter,
297
+ transports: info.credential.transports ?? [],
298
+ label: body.label,
299
+ backupEligible: info.credentialBackedUp,
300
+ backupState: info.credentialBackedUp,
301
+ })
302
+
303
+ logAuth({
304
+ event: 'auth.passkey.registered',
305
+ ts: Math.floor(Date.now() / 1000),
306
+ userId: session.userId,
307
+ provider: 'email',
308
+ outcome: 'created',
309
+ })
310
+
311
+ return c.json({ ok: true })
312
+ })
313
+
314
+ // ───── /passkeys (list, delete) — requires session ─────
315
+ app.get('/passkeys', requireAuth, async (c) => {
316
+ const session = c.get('session')
317
+ const rows = await findPasskeysByUser(c.env.DB, session.userId)
318
+ return c.json({
319
+ items: rows.map((p) => ({
320
+ credentialId: p.credentialId,
321
+ label: p.label,
322
+ createdAt: p.createdAt,
323
+ lastUsedAt: p.lastUsedAt,
324
+ })),
325
+ })
326
+ })
327
+
328
+ app.delete('/passkeys/:id', requireAuth, async (c) => {
329
+ const session = c.get('session')
330
+ await deletePasskey(c.env.DB, c.req.param('id'), session.userId)
331
+ return c.json({ ok: true })
332
+ })
333
+
334
+ // ───── /logout ─────
335
+ // ───── recovery codes ─────
336
+
337
+ /** Authenticated. Generates 8 fresh codes; replaces any prior set
338
+ * (the plaintext is shown ONCE and the user is responsible for saving). */
339
+ app.post('/recovery/generate', requireAuth, async (c) => {
340
+ const session = c.get('session')
341
+ const set = await regenerateRecoveryCodes(c.env.DB, session.userId)
342
+ logAuth({
343
+ event: 'auth.recovery.generated',
344
+ ts: Math.floor(Date.now() / 1000),
345
+ userId: session.userId,
346
+ provider: 'recovery',
347
+ ipHash: await sha256Hex(
348
+ (c.req.header('CF-Connecting-IP') ?? '') + (c.env.SESSION_PEPPER ?? ''),
349
+ ),
350
+ outcome: 'reused',
351
+ })
352
+ return c.json({ data: set })
353
+ })
354
+
355
+ /** Authenticated. Reports how many unused codes are left so the UI can warn
356
+ * when the bag is empty / down to 1-2. */
357
+ app.get('/recovery/status', requireAuth, async (c) => {
358
+ const session = c.get('session')
359
+ const unused = await countUnusedCodes(c.env.DB, session.userId)
360
+ return c.json({ data: { unused, total: 8 } }, 200, { 'Cache-Control': 'private, no-store' })
361
+ })
362
+
363
+ /** PUBLIC — last-resort login when both passkey and email OTP are unreachable.
364
+ * Marks the code used, wipes ALL passkeys (force re-enroll because a leaked
365
+ * code means the account is compromised), and mints a session. */
366
+ const RecoveryRedeemBody = v.object({
367
+ email: v.pipe(v.string(), v.email()),
368
+ code: v.pipe(v.string(), v.minLength(8), v.maxLength(32)),
369
+ })
370
+ app.post('/recovery/redeem', async (c) => {
371
+ const body = parseOrThrow(RecoveryRedeemBody, await c.req.json())
372
+ const email = body.email.toLowerCase()
373
+
374
+ // biome-ignore lint/style/useNamingConvention: D1 raw columns
375
+ type UserRow = { id: string }
376
+ const userRow = await c.env.DB.prepare(
377
+ "SELECT id FROM users WHERE email = ? AND status != 'deleted'",
378
+ )
379
+ .bind(email)
380
+ .first<UserRow>()
381
+ if (userRow === null) {
382
+ // Don't disclose existence; same error path as a wrong code.
383
+ throw new UnauthorizedError({ reason: 'invalid_recovery' })
384
+ }
385
+ const result = await redeemRecoveryCode(c.env.DB, userRow.id, body.code)
386
+ if (!result.ok) throw new UnauthorizedError({ reason: 'invalid_recovery' })
387
+
388
+ // Security: wipe every passkey. The legitimate user re-enrolls.
389
+ await invalidateAllPasskeysForUser(c.env.DB, userRow.id)
390
+
391
+ logAuth({
392
+ event: 'auth.login.recovery',
393
+ ts: Math.floor(Date.now() / 1000),
394
+ userId: userRow.id,
395
+ provider: 'recovery',
396
+ ipHash: await sha256Hex(
397
+ (c.req.header('CF-Connecting-IP') ?? '') + (c.env.SESSION_PEPPER ?? ''),
398
+ ),
399
+ outcome: 'reused',
400
+ })
401
+
402
+ const { roles, storeIds } = await loadSessionRoles(c.env.DB, userRow.id)
403
+ const session = await issueSession(c, setCookie, false, {
404
+ userId: userRow.id,
405
+ roles,
406
+ storeIds,
407
+ })
408
+ return c.json({ ok: true, sid: session.sid, csrf: session.csrf, passkeysWiped: true })
409
+ })
410
+
411
+ app.post('/logout', requireAuth, async (c) => {
412
+ const sid = c.get('sid')
413
+ // Best-effort: revoke for both customer and admin cookie names.
414
+ await revokeSession({ env: c.env }, deleteCookie, sid, false)
415
+ await revokeSession({ env: c.env }, deleteCookie, sid, true)
416
+ return c.json({ ok: true })
417
+ })
418
+
419
+ export { app as passwordlessRoutes }
package/src/session.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { UnauthorizedError } from '@pya-platform/shared'
2
+ import type { SessionRecord } from '@pya-platform/shared'
3
+ import { type deleteCookie, getCookie, type setCookie } from 'hono/cookie'
4
+ import { createMiddleware } from 'hono/factory'
5
+ import {
6
+ deleteSession,
7
+ newSessionId,
8
+ readSession,
9
+ touchSession,
10
+ writeSession,
11
+ } from './store/session-store.ts'
12
+
13
+ const COOKIE_NAME = 'pya_sid'
14
+ const COOKIE_NAME_ADMIN = 'pya_sid_admin'
15
+ const CSRF_COOKIE = 'pya_csrf'
16
+ const SESSION_LIFETIME_SEC = 60 * 60 * 24 * 30
17
+
18
+ interface SessionVariables {
19
+ readonly session: SessionRecord
20
+ readonly sid: string
21
+ }
22
+
23
+ /** Verify the session cookie, attach `session` + `sid` to context. */
24
+ export const requireAuth = createMiddleware<{
25
+ Bindings: Env
26
+ Variables: SessionVariables
27
+ }>(async (c, next) => {
28
+ const isAdmin = c.req.url.startsWith(`${c.env.ADMIN_ORIGIN}/admin`)
29
+ // Accept session either as cookie (same-site) or as Authorization: Bearer
30
+ // (cross-origin preview where third-party cookies are blocked).
31
+ const cookieSid = getCookie(c, isAdmin ? COOKIE_NAME_ADMIN : COOKIE_NAME)
32
+ const auth = c.req.header('Authorization')
33
+ const bearerSid = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length).trim() : undefined
34
+ const sid = cookieSid ?? bearerSid
35
+ if (sid === undefined) {
36
+ throw new UnauthorizedError({ reason: 'missing session token' })
37
+ }
38
+ const record = await readSession(c.env.SESSIONS, sid)
39
+ if (record === undefined) {
40
+ throw new UnauthorizedError({ reason: 'session not found or expired' })
41
+ }
42
+ // Sliding write-behind every 60s.
43
+ await touchSession(c.env.SESSIONS, sid, record)
44
+ c.set('session', record)
45
+ c.set('sid', sid)
46
+ await next()
47
+ })
48
+
49
+ /** SameSite policy: in production, site and api share a parent domain → Lax/Strict OK.
50
+ * In preview/staging, site (pages.dev) and api (workers.dev) are cross-site → require None. */
51
+ const customerSameSite = (env: Env): 'Lax' | 'None' =>
52
+ env.ENVIRONMENT === 'production' ? 'Lax' : 'None'
53
+
54
+ export interface IssuedSession {
55
+ readonly sid: string
56
+ readonly csrf: string
57
+ }
58
+
59
+ export const issueSession = async (
60
+ c: { env: Env; req: { header: (k: string) => string | undefined } },
61
+ setCookieFn: typeof setCookie,
62
+ isAdmin: boolean,
63
+ baseRecord: Omit<SessionRecord, 'iat' | 'lastSeen' | 'ipHash' | 'uaHash'>,
64
+ ): Promise<IssuedSession> => {
65
+ const now = Math.floor(Date.now() / 1000)
66
+ const ip = c.req.header('CF-Connecting-IP') ?? ''
67
+ const ua = c.req.header('User-Agent') ?? ''
68
+ const record: SessionRecord = {
69
+ ...baseRecord,
70
+ iat: now,
71
+ lastSeen: now,
72
+ ipHash: await sha256(ip + (c.env.SESSION_PEPPER ?? '')),
73
+ uaHash: await sha256(ua),
74
+ }
75
+ const sid = newSessionId()
76
+ await writeSession(c.env.SESSIONS, sid, record)
77
+ const sameSite = isAdmin ? 'Strict' : customerSameSite(c.env)
78
+ setCookieFn(c as never, isAdmin ? COOKIE_NAME_ADMIN : COOKIE_NAME, sid, {
79
+ path: '/',
80
+ httpOnly: true,
81
+ secure: true,
82
+ sameSite,
83
+ maxAge: SESSION_LIFETIME_SEC,
84
+ })
85
+ const csrf = newSessionId()
86
+ setCookieFn(c as never, CSRF_COOKIE, csrf, {
87
+ path: '/',
88
+ secure: true,
89
+ sameSite: customerSameSite(c.env),
90
+ maxAge: SESSION_LIFETIME_SEC,
91
+ })
92
+ return { sid, csrf }
93
+ }
94
+
95
+ export const revokeSession = async (
96
+ c: { env: Env },
97
+ setCookieFn: typeof deleteCookie,
98
+ sid: string,
99
+ isAdmin: boolean,
100
+ ): Promise<void> => {
101
+ await deleteSession(c.env.SESSIONS, sid)
102
+ setCookieFn(c as never, isAdmin ? COOKIE_NAME_ADMIN : COOKIE_NAME, { path: '/' })
103
+ setCookieFn(c as never, CSRF_COOKIE, { path: '/' })
104
+ }
105
+
106
+ const sha256 = async (s: string): Promise<string> => {
107
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s))
108
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
109
+ }
@@ -0,0 +1,156 @@
1
+ import { UpstreamError } from '@pya-platform/shared'
2
+
3
+ const TTL_SEC = 10 * 60
4
+ const MAX_ATTEMPTS = 5
5
+
6
+ export interface OtpRecord {
7
+ readonly codeHash: string
8
+ readonly email: string
9
+ readonly attempts: number
10
+ readonly redirectAfter: string
11
+ readonly ts: number
12
+ }
13
+
14
+ export type VerifyResult =
15
+ | { readonly status: 'ok'; readonly record: OtpRecord }
16
+ | { readonly status: 'invalid'; readonly remaining: number }
17
+ | { readonly status: 'expired' }
18
+ | { readonly status: 'locked' }
19
+
20
+ export const generateCode = (): string => {
21
+ const buf = new Uint32Array(1)
22
+ crypto.getRandomValues(buf)
23
+ const n = (buf[0] ?? 0) % 1_000_000
24
+ return n.toString().padStart(6, '0')
25
+ }
26
+
27
+ const sha256Hex = async (input: string): Promise<string> => {
28
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
29
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
30
+ }
31
+
32
+ const keyFor = async (email: string): Promise<string> =>
33
+ `otp:${await sha256Hex(email.toLowerCase())}`
34
+ const hashCode = (code: string, email: string, pepper: string): Promise<string> =>
35
+ sha256Hex(`${code}|${email.toLowerCase()}|${pepper}`)
36
+
37
+ export const storeOtp = async (
38
+ kv: KVNamespace,
39
+ env: { readonly SESSION_PEPPER?: string },
40
+ email: string,
41
+ code: string,
42
+ redirectAfter: string,
43
+ ): Promise<void> => {
44
+ const record: OtpRecord = {
45
+ codeHash: await hashCode(code, email, env.SESSION_PEPPER ?? ''),
46
+ email: email.toLowerCase(),
47
+ attempts: 0,
48
+ redirectAfter,
49
+ ts: Math.floor(Date.now() / 1000),
50
+ }
51
+ await kv.put(await keyFor(email), JSON.stringify(record), { expirationTtl: TTL_SEC })
52
+ }
53
+
54
+ export const verifyOtp = async (
55
+ kv: KVNamespace,
56
+ env: { readonly SESSION_PEPPER?: string },
57
+ email: string,
58
+ code: string,
59
+ ): Promise<VerifyResult> => {
60
+ const key = await keyFor(email)
61
+ const raw = await kv.get<OtpRecord>(key, { type: 'json' })
62
+ if (raw === null) return { status: 'expired' }
63
+ if (raw.attempts >= MAX_ATTEMPTS) {
64
+ await kv.delete(key)
65
+ return { status: 'locked' }
66
+ }
67
+
68
+ const incoming = await hashCode(code, email, env.SESSION_PEPPER ?? '')
69
+ if (incoming !== raw.codeHash) {
70
+ const next: OtpRecord = { ...raw, attempts: raw.attempts + 1 }
71
+ if (next.attempts >= MAX_ATTEMPTS) {
72
+ await kv.delete(key)
73
+ return { status: 'locked' }
74
+ }
75
+ const remainingTtl = Math.max(60, TTL_SEC - (Math.floor(Date.now() / 1000) - raw.ts))
76
+ await kv.put(key, JSON.stringify(next), { expirationTtl: remainingTtl })
77
+ return { status: 'invalid', remaining: MAX_ATTEMPTS - next.attempts }
78
+ }
79
+
80
+ await kv.delete(key)
81
+ return { status: 'ok', record: raw }
82
+ }
83
+
84
+ const renderHtml = (code: string, magicLink: string): string => `<!DOCTYPE html>
85
+ <html><body style="margin:0;padding:0;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f6f6f6">
86
+ <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" width="100%" style="max-width:600px;margin:32px auto;background:#fff;border-radius:12px;overflow:hidden">
87
+ <tr><td style="padding:32px 32px 8px;color:#111">
88
+ <h1 style="margin:0 0 8px;font-size:22px">PyaEats</h1>
89
+ <p style="margin:0;color:#444;font-size:15px">Tu código de acceso (válido 10 minutos):</p>
90
+ </td></tr>
91
+ <tr><td style="padding:16px 32px">
92
+ <div style="font-size:38px;font-weight:800;letter-spacing:8px;color:#111;background:#f8f8f8;border:1px solid #eee;border-radius:8px;padding:18px;text-align:center;font-variant-numeric:tabular-nums;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace">${code}</div>
93
+ </td></tr>
94
+ <tr><td style="padding:16px 32px 4px;text-align:center">
95
+ <a href="${magicLink}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;font-weight:600;font-size:15px;padding:12px 22px;border-radius:8px">Acceder ahora →</a>
96
+ </td></tr>
97
+ <tr><td style="padding:8px 32px 24px;color:#666;font-size:13px;line-height:1.5;text-align:center">
98
+ Pegá el código en el sitio o tocá el botón. Si no fuiste vos, podés ignorar este email — nadie puede acceder sin él.
99
+ </td></tr>
100
+ </table>
101
+ </body></html>`
102
+
103
+ const renderText = (code: string, magicLink: string): string =>
104
+ `PyaEats — tu código de acceso (válido 10 min)\n\nCódigo: ${code}\n\nO accedé directamente: ${magicLink}\n\nSi no fuiste vos, ignorá este email.`
105
+
106
+ export const sendOtpEmail = async (env: Env, email: string, code: string): Promise<void> => {
107
+ // Prefer the verified domain whenever EMAIL_DOMAIN is set (regardless of
108
+ // ENVIRONMENT) — that's the marker that domain verify in Resend completed.
109
+ // Fall back to Resend's sandbox sender (onboarding@resend.dev) only when no
110
+ // domain is verified yet (sends restricted to the account-verified address).
111
+ const sender =
112
+ env.EMAIL_DOMAIN !== undefined && env.EMAIL_DOMAIN !== ''
113
+ ? `PyaEats <noreply@${env.EMAIL_DOMAIN}>`
114
+ : 'PyaEats Dev <onboarding@resend.dev>'
115
+
116
+ // Site origin without trailing slash. Fragment-encoded so mail scanners that
117
+ // pre-fetch the URL don't consume the one-shot code (fragments aren't sent).
118
+ const siteOrigin = (env.SITE_ORIGIN ?? '').replace(/\/$/, '')
119
+ const magicLink = `${siteOrigin}/login#email=${encodeURIComponent(email)}&code=${code}`
120
+
121
+ const res = await fetch('https://api.resend.com/emails', {
122
+ method: 'POST',
123
+ headers: {
124
+ Authorization: `Bearer ${env.RESEND_API_KEY ?? ''}`,
125
+ 'Content-Type': 'application/json',
126
+ },
127
+ body: JSON.stringify({
128
+ from: sender,
129
+ to: email,
130
+ subject: `${code} — tu código de PyaEats`,
131
+ html: renderHtml(code, magicLink),
132
+ text: renderText(code, magicLink),
133
+ }),
134
+ })
135
+ if (!res.ok) {
136
+ const body = await res.text().catch(() => '')
137
+ // Surface Resend's reason in worker logs (captured by `wrangler tail`).
138
+ console.error(
139
+ JSON.stringify({
140
+ stream: 'audit',
141
+ event: 'auth.email.send_failed',
142
+ provider: 'resend',
143
+ status: res.status,
144
+ body: body.slice(0, 500),
145
+ }),
146
+ )
147
+ throw new UpstreamError({ provider: 'resend', status: res.status })
148
+ }
149
+ }
150
+
151
+ export const maskEmail = (email: string): string => {
152
+ const [local, domain] = email.split('@')
153
+ if (local === undefined || domain === undefined) return email
154
+ const head = local.slice(0, Math.min(2, local.length))
155
+ return `${head}${local.length > 2 ? '***' : ''}@${domain}`
156
+ }