@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.
- package/CHANGELOG.md +14 -0
- package/package.json +38 -0
- package/src/env.ts +57 -0
- package/src/identity-link.ts +64 -0
- package/src/index.ts +46 -0
- package/src/log.ts +27 -0
- package/src/migrations/0001_users_sessions.sql +70 -0
- package/src/migrations/0002_passkeys.sql +17 -0
- package/src/migrations/0003_recovery_codes.sql +24 -0
- package/src/oauth-callback.ts +102 -0
- package/src/providers/apple.ts +12 -0
- package/src/providers/facebook.ts +12 -0
- package/src/providers/google.ts +117 -0
- package/src/recovery-codes.ts +148 -0
- package/src/routes/dev-bypass.ts +109 -0
- package/src/routes/oauth.ts +114 -0
- package/src/routes/passwordless.ts +419 -0
- package/src/session.ts +109 -0
- package/src/store/otp-store.ts +156 -0
- package/src/store/passkey-store.ts +117 -0
- package/src/store/session-store.ts +50 -0
- package/src/webauthn.ts +112 -0
- package/tsconfig.json +10 -0
|
@@ -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
|
+
}
|