@edge-base/server 0.1.1
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/admin-build/.gitkeep +0 -0
- package/admin-build/_app/env.js +1 -0
- package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
- package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
- package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
- package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
- package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
- package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
- package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
- package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
- package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
- package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
- package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
- package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
- package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
- package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
- package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
- package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
- package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
- package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
- package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
- package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
- package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
- package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
- package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
- package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
- package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
- package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
- package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
- package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
- package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
- package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
- package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
- package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
- package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
- package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
- package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
- package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
- package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
- package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
- package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
- package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
- package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
- package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
- package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
- package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
- package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
- package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
- package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
- package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
- package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
- package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
- package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
- package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
- package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
- package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
- package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
- package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
- package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
- package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
- package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
- package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
- package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
- package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
- package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
- package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
- package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
- package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
- package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
- package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
- package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
- package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
- package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
- package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
- package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
- package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
- package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
- package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
- package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
- package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
- package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
- package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
- package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
- package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
- package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
- package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
- package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
- package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
- package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
- package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
- package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
- package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
- package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
- package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
- package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
- package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
- package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
- package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
- package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
- package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
- package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
- package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
- package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
- package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
- package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
- package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
- package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
- package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
- package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
- package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
- package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
- package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
- package/admin-build/_app/version.json +1 -0
- package/admin-build/favicon.svg +26 -0
- package/admin-build/index.html +45 -0
- package/openapi.json +19543 -0
- package/package.json +66 -0
- package/src/__tests__/admin-assets.test.ts +55 -0
- package/src/__tests__/admin-data-routes.test.ts +488 -0
- package/src/__tests__/admin-db-target.test.ts +103 -0
- package/src/__tests__/admin-routing.test.ts +31 -0
- package/src/__tests__/admin-user-management.test.ts +311 -0
- package/src/__tests__/analytics-query.test.ts +75 -0
- package/src/__tests__/auth-d1.test.ts +749 -0
- package/src/__tests__/auth-db-adapter.test.ts +73 -0
- package/src/__tests__/auth-jwt.test.ts +440 -0
- package/src/__tests__/auth-oauth.test.ts +389 -0
- package/src/__tests__/auth-password.test.ts +367 -0
- package/src/__tests__/auth-redirect.test.ts +87 -0
- package/src/__tests__/backup-restore.test.ts +711 -0
- package/src/__tests__/broadcast.test.ts +128 -0
- package/src/__tests__/cli.test.ts +178 -0
- package/src/__tests__/cloudflare-realtime.test.ts +113 -0
- package/src/__tests__/config.test.ts +469 -0
- package/src/__tests__/cors.test.ts +154 -0
- package/src/__tests__/cron.test.ts +302 -0
- package/src/__tests__/d1-handler.test.ts +402 -0
- package/src/__tests__/d1-sql.test.ts +120 -0
- package/src/__tests__/database-live-config.test.ts +42 -0
- package/src/__tests__/database-live-emitter.test.ts +56 -0
- package/src/__tests__/database-live-filters.test.ts +63 -0
- package/src/__tests__/database-live-route.test.ts +113 -0
- package/src/__tests__/db-sql.test.ts +163 -0
- package/src/__tests__/do-lifecycle.test.ts +263 -0
- package/src/__tests__/do-router.test.ts +729 -0
- package/src/__tests__/email-provider.test.ts +128 -0
- package/src/__tests__/email-templates.test.ts +528 -0
- package/src/__tests__/error-format.test.ts +250 -0
- package/src/__tests__/field-ops.test.ts +242 -0
- package/src/__tests__/functions-context.test.ts +334 -0
- package/src/__tests__/functions-d1-proxy.test.ts +229 -0
- package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
- package/src/__tests__/functions-route.test.ts +139 -0
- package/src/__tests__/internal-request.test.ts +77 -0
- package/src/__tests__/log-writer.test.ts +44 -0
- package/src/__tests__/logger.test.ts +58 -0
- package/src/__tests__/meta-admin-proxy.test.ts +48 -0
- package/src/__tests__/meta-export-coverage.test.ts +191 -0
- package/src/__tests__/meta-route-registration.test.ts +47 -0
- package/src/__tests__/namespace-dump.test.ts +28 -0
- package/src/__tests__/oauth-providers.test.ts +337 -0
- package/src/__tests__/openapi-coverage.test.ts +144 -0
- package/src/__tests__/pagination.test.ts +59 -0
- package/src/__tests__/password-policy.test.ts +191 -0
- package/src/__tests__/plugin-migrations.test.ts +379 -0
- package/src/__tests__/postgres-batch-compat.test.ts +133 -0
- package/src/__tests__/postgres-dialect.test.ts +328 -0
- package/src/__tests__/postgres-executor.test.ts +79 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
- package/src/__tests__/postgres-schema-init.test.ts +105 -0
- package/src/__tests__/postgres-table-utils.test.ts +107 -0
- package/src/__tests__/presence.test.ts +199 -0
- package/src/__tests__/provider.test.ts +550 -0
- package/src/__tests__/public-user-profile.test.ts +339 -0
- package/src/__tests__/push-handlers.test.ts +179 -0
- package/src/__tests__/push-provider.test.ts +80 -0
- package/src/__tests__/push-token.test.ts +418 -0
- package/src/__tests__/query.test.ts +771 -0
- package/src/__tests__/rate-limit.test.ts +260 -0
- package/src/__tests__/room-access-policy.test.ts +101 -0
- package/src/__tests__/room-handler-context.test.ts +130 -0
- package/src/__tests__/room-monitoring.test.ts +138 -0
- package/src/__tests__/room-runtime-routing.test.ts +222 -0
- package/src/__tests__/room.test.ts +254 -0
- package/src/__tests__/route-parser.test.ts +490 -0
- package/src/__tests__/rules.test.ts +234 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
- package/src/__tests__/scheduled.test.ts +80 -0
- package/src/__tests__/schema.test.ts +1273 -0
- package/src/__tests__/security-hardening.test.ts +312 -0
- package/src/__tests__/server.unit.test.ts +333 -0
- package/src/__tests__/service-key-db-proxy.test.ts +650 -0
- package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
- package/src/__tests__/service-key.test.ts +757 -0
- package/src/__tests__/smoke-skip-report.test.ts +72 -0
- package/src/__tests__/sms-provider.test.ts +39 -0
- package/src/__tests__/sql-route.test.ts +218 -0
- package/src/__tests__/storage-hook-context.test.ts +115 -0
- package/src/__tests__/totp.test.ts +200 -0
- package/src/__tests__/uuid.test.ts +144 -0
- package/src/__tests__/validation.test.ts +773 -0
- package/src/__tests__/websocket-pending.test.ts +163 -0
- package/src/_functions-registry.ts +51 -0
- package/src/bench-entry.ts +9 -0
- package/src/cloudflare-test.d.ts +1 -0
- package/src/durable-objects/auth-do.ts +49 -0
- package/src/durable-objects/database-do.ts +2240 -0
- package/src/durable-objects/database-live-do.ts +949 -0
- package/src/durable-objects/logs-do.ts +1200 -0
- package/src/durable-objects/room-runtime-base.ts +1604 -0
- package/src/durable-objects/rooms-do.ts +2191 -0
- package/src/generated-config.ts +6 -0
- package/src/index.ts +382 -0
- package/src/lib/admin-assets.ts +54 -0
- package/src/lib/admin-db-target.ts +301 -0
- package/src/lib/admin-routing.ts +35 -0
- package/src/lib/admin-user-management.ts +464 -0
- package/src/lib/analytics-adapter.ts +103 -0
- package/src/lib/analytics-query.ts +579 -0
- package/src/lib/auth-d1-service.ts +1193 -0
- package/src/lib/auth-d1.ts +1056 -0
- package/src/lib/auth-db-adapter.ts +289 -0
- package/src/lib/auth-redirect.ts +116 -0
- package/src/lib/cidr.ts +115 -0
- package/src/lib/client-ip.ts +51 -0
- package/src/lib/cloudflare-realtime.ts +251 -0
- package/src/lib/control-db.ts +36 -0
- package/src/lib/cron.ts +163 -0
- package/src/lib/d1-handler.ts +1425 -0
- package/src/lib/d1-schema-init.ts +255 -0
- package/src/lib/d1-sql.ts +33 -0
- package/src/lib/database-live-config.ts +24 -0
- package/src/lib/database-live-emitter.ts +111 -0
- package/src/lib/db-sql.ts +66 -0
- package/src/lib/do-retry.ts +36 -0
- package/src/lib/do-router.ts +270 -0
- package/src/lib/do-sql.ts +73 -0
- package/src/lib/email-provider.ts +379 -0
- package/src/lib/email-templates.ts +285 -0
- package/src/lib/email-translations.ts +422 -0
- package/src/lib/errors.ts +151 -0
- package/src/lib/functions.ts +2091 -0
- package/src/lib/hono.ts +56 -0
- package/src/lib/internal-request.ts +56 -0
- package/src/lib/jwt.ts +354 -0
- package/src/lib/log-writer.ts +272 -0
- package/src/lib/namespace-dump.ts +125 -0
- package/src/lib/oauth-providers.ts +1225 -0
- package/src/lib/op-parser.ts +99 -0
- package/src/lib/openapi.ts +146 -0
- package/src/lib/pagination.ts +19 -0
- package/src/lib/password-policy.ts +102 -0
- package/src/lib/password.ts +145 -0
- package/src/lib/plugin-migrations.ts +612 -0
- package/src/lib/postgres-executor.ts +203 -0
- package/src/lib/postgres-handler.ts +1102 -0
- package/src/lib/postgres-schema-init.ts +341 -0
- package/src/lib/postgres-table-utils.ts +87 -0
- package/src/lib/public-user-profile.ts +187 -0
- package/src/lib/push-provider.ts +409 -0
- package/src/lib/push-token.ts +294 -0
- package/src/lib/query-engine.ts +768 -0
- package/src/lib/room-monitoring.ts +97 -0
- package/src/lib/room-runtime.ts +14 -0
- package/src/lib/route-parser.ts +434 -0
- package/src/lib/schema.ts +538 -0
- package/src/lib/schemas.ts +152 -0
- package/src/lib/service-key.ts +419 -0
- package/src/lib/sms-provider.ts +230 -0
- package/src/lib/startup-config.ts +99 -0
- package/src/lib/totp.ts +242 -0
- package/src/lib/uuid.ts +87 -0
- package/src/lib/validation.ts +205 -0
- package/src/lib/version.ts +2 -0
- package/src/lib/websocket-pending.ts +40 -0
- package/src/middleware/auth.ts +169 -0
- package/src/middleware/captcha-verify.ts +217 -0
- package/src/middleware/cors.ts +159 -0
- package/src/middleware/error-handler.ts +54 -0
- package/src/middleware/internal-guard.ts +26 -0
- package/src/middleware/logger.ts +126 -0
- package/src/middleware/rate-limit.ts +283 -0
- package/src/middleware/rules.ts +475 -0
- package/src/routes/admin-auth.ts +447 -0
- package/src/routes/admin.ts +3501 -0
- package/src/routes/analytics-api.ts +290 -0
- package/src/routes/auth.ts +4222 -0
- package/src/routes/backup.ts +1466 -0
- package/src/routes/config.ts +53 -0
- package/src/routes/d1.ts +109 -0
- package/src/routes/database-live.ts +281 -0
- package/src/routes/functions.ts +155 -0
- package/src/routes/health.ts +32 -0
- package/src/routes/kv.ts +167 -0
- package/src/routes/oauth.ts +1055 -0
- package/src/routes/push.ts +1465 -0
- package/src/routes/room.ts +639 -0
- package/src/routes/schema-endpoint.ts +76 -0
- package/src/routes/sql.ts +176 -0
- package/src/routes/storage.ts +1674 -0
- package/src/routes/tables.ts +699 -0
- package/src/routes/users.ts +21 -0
- package/src/routes/vectorize.ts +372 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,4222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth routes — D1-first (Phase 3: Auth DO eliminated)
|
|
3
|
+
*
|
|
4
|
+
* All auth operations go directly to D1 via auth-d1-service.
|
|
5
|
+
* No more DO shard routing — single D1 database for all users.
|
|
6
|
+
*/
|
|
7
|
+
import { OpenAPIHono, createRoute, z, type HonoEnv } from '../lib/hono.js';
|
|
8
|
+
import type { Env } from '../types.js';
|
|
9
|
+
import { EdgeBaseError, getAuthAccess, getAuthHandlers } from '@edge-base/shared';
|
|
10
|
+
import type {
|
|
11
|
+
AuthAccess,
|
|
12
|
+
AuthTrigger, EmailConfig, EmailTemplateOverrides, EmailSubjectOverrides,
|
|
13
|
+
EmailOtpConfig, MagicLinkConfig, MailType, MailHookCtx, MfaConfig,
|
|
14
|
+
PasskeysConfig, PasswordPolicyConfig, SmsConfig, SmsHookCtx, SmsType,
|
|
15
|
+
} from '@edge-base/shared';
|
|
16
|
+
import type { AuthContext } from '../middleware/auth.js';
|
|
17
|
+
import {
|
|
18
|
+
validateKey,
|
|
19
|
+
buildConstraintCtx,
|
|
20
|
+
resolveRootServiceKey,
|
|
21
|
+
resolveServiceKeyCandidate,
|
|
22
|
+
timingSafeEqual,
|
|
23
|
+
} from '../lib/service-key.js';
|
|
24
|
+
import { counter, getLimit } from '../middleware/rate-limit.js';
|
|
25
|
+
import { parseConfig } from '../lib/do-router.js';
|
|
26
|
+
import {
|
|
27
|
+
buildEmailActionUrl,
|
|
28
|
+
parseClientRedirectInput,
|
|
29
|
+
} from '../lib/auth-redirect.js';
|
|
30
|
+
import {
|
|
31
|
+
signAccessToken, signRefreshToken, verifyRefreshTokenWithFallback,
|
|
32
|
+
parseDuration, decodeTokenUnsafe, TokenExpiredError,
|
|
33
|
+
} from '../lib/jwt.js';
|
|
34
|
+
import { generateId } from '../lib/uuid.js';
|
|
35
|
+
import { captchaMiddleware } from '../middleware/captcha-verify.js';
|
|
36
|
+
import { hashPassword, verifyPassword, needsRehash } from '../lib/password.js';
|
|
37
|
+
import { validatePassword } from '../lib/password-policy.js';
|
|
38
|
+
import { createEmailProvider } from '../lib/email-provider.js';
|
|
39
|
+
import type { EmailProvider } from '../lib/email-provider.js';
|
|
40
|
+
import { createSmsProvider } from '../lib/sms-provider.js';
|
|
41
|
+
import { getTrustedClientIp } from '../lib/client-ip.js';
|
|
42
|
+
import type { SmsProvider } from '../lib/sms-provider.js';
|
|
43
|
+
import { renderVerifyEmail, renderPasswordReset, renderMagicLink, renderEmailOtp, renderEmailChange } from '../lib/email-templates.js';
|
|
44
|
+
import { getDefaultSubject } from '../lib/email-translations.js';
|
|
45
|
+
import {
|
|
46
|
+
generateTOTPSecret, generateTOTPUri, verifyTOTP,
|
|
47
|
+
generateRecoveryCodes, encryptSecret, decryptSecret,
|
|
48
|
+
} from '../lib/totp.js';
|
|
49
|
+
import {
|
|
50
|
+
getFunctionsByTrigger,
|
|
51
|
+
buildFunctionKvProxy,
|
|
52
|
+
buildFunctionD1Proxy,
|
|
53
|
+
buildFunctionVectorizeProxy,
|
|
54
|
+
buildFunctionPushProxy,
|
|
55
|
+
buildAdminAuthContext,
|
|
56
|
+
buildAdminDbProxy,
|
|
57
|
+
getWorkerUrl,
|
|
58
|
+
} from '../lib/functions.js';
|
|
59
|
+
import * as authService from '../lib/auth-d1-service.js';
|
|
60
|
+
import {
|
|
61
|
+
ensureAuthSchema,
|
|
62
|
+
lookupEmail,
|
|
63
|
+
registerEmailPending,
|
|
64
|
+
confirmEmail,
|
|
65
|
+
deleteEmail,
|
|
66
|
+
deleteEmailPending,
|
|
67
|
+
registerAnonPending,
|
|
68
|
+
confirmAnon,
|
|
69
|
+
deleteAnon,
|
|
70
|
+
deleteOAuth,
|
|
71
|
+
lookupPhone,
|
|
72
|
+
registerPhonePending,
|
|
73
|
+
confirmPhone,
|
|
74
|
+
registerPasskey,
|
|
75
|
+
deletePasskey,
|
|
76
|
+
} from '../lib/auth-d1.js';
|
|
77
|
+
import { zodDefaultHook, jsonResponseSchema, errorResponseSchema } from '../lib/schemas.js';
|
|
78
|
+
import { resolveAuthDb, type AuthDb } from '../lib/auth-db-adapter.js';
|
|
79
|
+
import { queuePublicUserProjectionSync, syncPublicUserProjection } from '../lib/public-user-profile.js';
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
/** Resolve AuthDb from Hono context or raw env. Defaults to D1 (AUTH_DB binding). */
|
|
83
|
+
function getAuthDb(c: { env: Env }): AuthDb {
|
|
84
|
+
return resolveAuthDb(c.env as unknown as Record<string, unknown>);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Resolve AuthDb from raw env object. */
|
|
88
|
+
function envAuthDb(env: Env): AuthDb {
|
|
89
|
+
return resolveAuthDb(env as unknown as Record<string, unknown>);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const authRoute = new OpenAPIHono<HonoEnv>({ defaultHook: zodDefaultHook });
|
|
93
|
+
|
|
94
|
+
// Ensure errors propagate to parent app's error handler
|
|
95
|
+
authRoute.onError((err, c) => {
|
|
96
|
+
if (err instanceof EdgeBaseError) {
|
|
97
|
+
return c.json(err.toJSON(), err.code as 400);
|
|
98
|
+
}
|
|
99
|
+
// Duck-type fallback
|
|
100
|
+
const e = err as unknown as Record<string, unknown>;
|
|
101
|
+
if (typeof e.code === 'number' && e.code >= 400 && e.code < 600 && typeof e.message === 'string') {
|
|
102
|
+
const body: Record<string, unknown> = { code: e.code, message: e.message };
|
|
103
|
+
if (typeof e.slug === 'string') body.slug = e.slug;
|
|
104
|
+
return c.json(body, e.code as 400);
|
|
105
|
+
}
|
|
106
|
+
throw err; // Re-throw for parent error handler
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ─── Helpers ───
|
|
110
|
+
|
|
111
|
+
function requireAuth(auth: AuthContext | null): string {
|
|
112
|
+
if (!auth) {
|
|
113
|
+
throw new EdgeBaseError(401, 'Authentication required.', undefined, 'unauthenticated');
|
|
114
|
+
}
|
|
115
|
+
return auth.id;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type AuthAccessAction = Extract<keyof AuthAccess, string>;
|
|
119
|
+
|
|
120
|
+
async function ensureAuthActionAllowed(
|
|
121
|
+
c: { env: Env; req: { raw: Request }; get(name: string): unknown },
|
|
122
|
+
action: AuthAccessAction,
|
|
123
|
+
input: Record<string, unknown> | null,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
const config = parseConfig(c.env);
|
|
126
|
+
const rule = getAuthAccess(config.auth)?.[action];
|
|
127
|
+
if (!rule) return;
|
|
128
|
+
|
|
129
|
+
const auth = (c.get('auth') as AuthContext | null | undefined) ?? null;
|
|
130
|
+
const allowed = await Promise.resolve(rule(input, {
|
|
131
|
+
request: c.req.raw,
|
|
132
|
+
auth: auth ? {
|
|
133
|
+
id: auth.id,
|
|
134
|
+
role: auth.role,
|
|
135
|
+
email: auth.email ?? undefined,
|
|
136
|
+
isAnonymous: auth.isAnonymous,
|
|
137
|
+
custom: auth.custom ?? undefined,
|
|
138
|
+
meta: auth.meta,
|
|
139
|
+
} : null,
|
|
140
|
+
ip: getClientIP(c.env, c.req.raw),
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
if (!allowed) {
|
|
144
|
+
throw new EdgeBaseError(403, `Auth action '${action}' is not allowed.`, undefined, 'action-not-allowed');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getUserSecret(env: Env): string {
|
|
149
|
+
if (!env.JWT_USER_SECRET) throw new EdgeBaseError(500, 'JWT_USER_SECRET is not configured. Set it in your environment variables to enable authentication.', undefined, 'internal-error');
|
|
150
|
+
return env.JWT_USER_SECRET;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getAccessTokenTTL(env: Env): string {
|
|
154
|
+
const config = parseConfig(env);
|
|
155
|
+
return config?.auth?.session?.accessTokenTTL ?? '15m';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getRefreshTokenTTL(env: Env): string {
|
|
159
|
+
const config = parseConfig(env);
|
|
160
|
+
return config?.auth?.session?.refreshTokenTTL ?? '28d';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getMaxActiveSessions(env: Env): number {
|
|
164
|
+
const config = parseConfig(env);
|
|
165
|
+
return config?.auth?.session?.maxActiveSessions ?? 0; // 0 = unlimited
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isEmailProviderName(value: unknown): value is EmailConfig['provider'] {
|
|
169
|
+
return value === 'resend' || value === 'sendgrid' || value === 'mailgun' || value === 'ses';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function getEmailConfig(env: Env): EmailConfig | undefined {
|
|
173
|
+
const config = parseConfig(env);
|
|
174
|
+
const configured = (config as Record<string, unknown> | null)?.email as EmailConfig | undefined;
|
|
175
|
+
const runtimeEnv = env as unknown as Record<string, unknown>;
|
|
176
|
+
|
|
177
|
+
const provider = configured?.provider
|
|
178
|
+
?? (isEmailProviderName(runtimeEnv.EDGEBASE_EMAIL_PROVIDER) ? runtimeEnv.EDGEBASE_EMAIL_PROVIDER : undefined);
|
|
179
|
+
const apiKey = configured?.apiKey
|
|
180
|
+
?? (typeof runtimeEnv.EDGEBASE_EMAIL_API_KEY === 'string' ? runtimeEnv.EDGEBASE_EMAIL_API_KEY : undefined);
|
|
181
|
+
const from = configured?.from
|
|
182
|
+
?? (typeof runtimeEnv.EDGEBASE_EMAIL_FROM === 'string' ? runtimeEnv.EDGEBASE_EMAIL_FROM : undefined);
|
|
183
|
+
|
|
184
|
+
if (!provider || !apiKey || !from) {
|
|
185
|
+
return configured;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
provider,
|
|
190
|
+
apiKey,
|
|
191
|
+
from,
|
|
192
|
+
domain: configured?.domain
|
|
193
|
+
?? (typeof runtimeEnv.EDGEBASE_EMAIL_MAILGUN_DOMAIN === 'string' ? runtimeEnv.EDGEBASE_EMAIL_MAILGUN_DOMAIN : undefined),
|
|
194
|
+
region: configured?.region
|
|
195
|
+
?? (typeof runtimeEnv.EDGEBASE_EMAIL_SES_REGION === 'string' ? runtimeEnv.EDGEBASE_EMAIL_SES_REGION : undefined),
|
|
196
|
+
appName: configured?.appName ?? 'EdgeBase Local Auth Harness',
|
|
197
|
+
defaultLocale: configured?.defaultLocale,
|
|
198
|
+
verifyUrl: configured?.verifyUrl
|
|
199
|
+
?? (typeof runtimeEnv.EDGEBASE_APP_WEB_VERIFY_EMAIL_URL === 'string' ? runtimeEnv.EDGEBASE_APP_WEB_VERIFY_EMAIL_URL : undefined),
|
|
200
|
+
resetUrl: configured?.resetUrl
|
|
201
|
+
?? (typeof runtimeEnv.EDGEBASE_APP_WEB_RESET_PASSWORD_URL === 'string' ? runtimeEnv.EDGEBASE_APP_WEB_RESET_PASSWORD_URL : undefined),
|
|
202
|
+
magicLinkUrl: configured?.magicLinkUrl
|
|
203
|
+
?? (typeof runtimeEnv.EDGEBASE_APP_WEB_MAGIC_LINK_URL === 'string' ? runtimeEnv.EDGEBASE_APP_WEB_MAGIC_LINK_URL : undefined),
|
|
204
|
+
emailChangeUrl: configured?.emailChangeUrl
|
|
205
|
+
?? (typeof runtimeEnv.EDGEBASE_APP_WEB_CHANGE_EMAIL_URL === 'string' ? runtimeEnv.EDGEBASE_APP_WEB_CHANGE_EMAIL_URL : undefined),
|
|
206
|
+
templates: configured?.templates,
|
|
207
|
+
subjects: configured?.subjects,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getAppName(env: Env): string {
|
|
212
|
+
return getEmailConfig(env)?.appName ?? 'EdgeBase';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Parse Accept-Language header to extract primary language code.
|
|
217
|
+
* "ko-KR,ko;q=0.9,en-US;q=0.8" → "ko"
|
|
218
|
+
*/
|
|
219
|
+
function parseAcceptLanguage(header: string | undefined): string | undefined {
|
|
220
|
+
if (!header) return undefined;
|
|
221
|
+
const first = header.split(',')[0]?.trim().split(';')[0]?.trim();
|
|
222
|
+
return first?.split('-')[0] || undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Resolve locale for email sending.
|
|
227
|
+
* Priority: user's stored locale > Accept-Language header > app default locale > 'en'
|
|
228
|
+
*/
|
|
229
|
+
function resolveEmailLocale(env: Env, userLocale?: string | null, acceptLang?: string): string {
|
|
230
|
+
if (userLocale && userLocale !== 'en') return userLocale;
|
|
231
|
+
if (userLocale === 'en') return 'en';
|
|
232
|
+
if (acceptLang) return acceptLang;
|
|
233
|
+
return getEmailConfig(env)?.defaultLocale ?? 'en';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function getEmailTemplates(env: Env): EmailTemplateOverrides | undefined {
|
|
237
|
+
return getEmailConfig(env)?.templates;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Resolve LocalizedString to a plain string.
|
|
242
|
+
* LocalizedString = string | Record<string, string>.
|
|
243
|
+
* When locale is provided, tries: exact locale → base language → 'en' → first available.
|
|
244
|
+
*/
|
|
245
|
+
function resolveLocalizedString(val: undefined, locale?: string): undefined;
|
|
246
|
+
function resolveLocalizedString(val: string | Record<string, string>, locale?: string): string;
|
|
247
|
+
function resolveLocalizedString(val: string | Record<string, string> | undefined, locale?: string): string | undefined;
|
|
248
|
+
function resolveLocalizedString(val: string | Record<string, string> | undefined, locale?: string): string | undefined {
|
|
249
|
+
if (val === undefined) return undefined;
|
|
250
|
+
if (typeof val === 'string') return val;
|
|
251
|
+
if (locale) {
|
|
252
|
+
const base = locale.split('-')[0];
|
|
253
|
+
const resolved = val[locale] ?? val[base] ?? val.en ?? Object.values(val)[0];
|
|
254
|
+
return resolved || undefined;
|
|
255
|
+
}
|
|
256
|
+
return val.en || Object.values(val)[0] || undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function getEmailSubjects(env: Env): EmailSubjectOverrides | undefined {
|
|
260
|
+
return getEmailConfig(env)?.subjects;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function resolveSubject(env: Env, type: keyof EmailSubjectOverrides, defaultSubject: string, locale?: string): string {
|
|
264
|
+
const custom = getEmailSubjects(env)?.[type];
|
|
265
|
+
if (!custom) return defaultSubject;
|
|
266
|
+
// LocalizedString can be string or Record<string, string>
|
|
267
|
+
let subjectStr: string;
|
|
268
|
+
if (typeof custom === 'string') {
|
|
269
|
+
subjectStr = custom;
|
|
270
|
+
} else if (locale) {
|
|
271
|
+
const base = locale.split('-')[0];
|
|
272
|
+
subjectStr = custom[locale] ?? custom[base] ?? custom.en ?? Object.values(custom)[0] ?? defaultSubject;
|
|
273
|
+
} else {
|
|
274
|
+
subjectStr = custom.en || Object.values(custom)[0] || defaultSubject;
|
|
275
|
+
}
|
|
276
|
+
return subjectStr.replace(/\{\{appName\}\}/g, getAppName(env));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function getMagicLinkConfig(env: Env): MagicLinkConfig | undefined {
|
|
280
|
+
const config = parseConfig(env);
|
|
281
|
+
return config?.auth?.magicLink;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getEmailOtpConfig(env: Env): EmailOtpConfig | undefined {
|
|
285
|
+
const config = parseConfig(env);
|
|
286
|
+
return config?.auth?.emailOtp;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function getSmsConfig(env: Env): SmsConfig | undefined {
|
|
290
|
+
const config = parseConfig(env);
|
|
291
|
+
return (config as Record<string, unknown> | null)?.sms as SmsConfig | undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function getMfaConfig(env: Env): MfaConfig | undefined {
|
|
295
|
+
const config = parseConfig(env);
|
|
296
|
+
return config?.auth?.mfa;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function getPasswordPolicyConfig(env: Env): PasswordPolicyConfig | undefined {
|
|
300
|
+
const config = parseConfig(env);
|
|
301
|
+
return config?.auth?.passwordPolicy;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function getPasskeysConfig(env: Env): PasskeysConfig | undefined {
|
|
305
|
+
const config = parseConfig(env);
|
|
306
|
+
return config?.auth?.passkeys;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
type OAuthIdentityRecord = {
|
|
310
|
+
id: string;
|
|
311
|
+
provider: string;
|
|
312
|
+
providerUserId: string;
|
|
313
|
+
createdAt: string;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
async function getIdentityState(env: Env, db: AuthDb, userId: string): Promise<{
|
|
317
|
+
user: Record<string, unknown>;
|
|
318
|
+
oauthAccounts: OAuthIdentityRecord[];
|
|
319
|
+
passkeyCount: number;
|
|
320
|
+
summary: {
|
|
321
|
+
total: number;
|
|
322
|
+
hasPassword: boolean;
|
|
323
|
+
hasMagicLink: boolean;
|
|
324
|
+
hasEmailOtp: boolean;
|
|
325
|
+
hasPhone: boolean;
|
|
326
|
+
passkeyCount: number;
|
|
327
|
+
oauthCount: number;
|
|
328
|
+
};
|
|
329
|
+
}> {
|
|
330
|
+
const user = await authService.getUserById(db, userId);
|
|
331
|
+
if (!user) {
|
|
332
|
+
throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const oauthAccounts = (await authService.listOAuthAccounts(db, userId)).map((row) => ({
|
|
336
|
+
id: String(row.id),
|
|
337
|
+
provider: String(row.provider),
|
|
338
|
+
providerUserId: String(row.providerUserId),
|
|
339
|
+
createdAt: String(row.createdAt),
|
|
340
|
+
}));
|
|
341
|
+
|
|
342
|
+
const passkeyCount = getPasskeysConfig(env)?.enabled
|
|
343
|
+
? (await authService.listWebAuthnCredentials(db, userId)).length
|
|
344
|
+
: 0;
|
|
345
|
+
|
|
346
|
+
const hasPassword =
|
|
347
|
+
parseConfig(env)?.auth?.emailAuth !== false
|
|
348
|
+
&& typeof user.passwordHash === 'string'
|
|
349
|
+
&& user.passwordHash.length > 0;
|
|
350
|
+
const hasMagicLink =
|
|
351
|
+
typeof user.email === 'string'
|
|
352
|
+
&& user.email.length > 0
|
|
353
|
+
&& !!getMagicLinkConfig(env)?.enabled;
|
|
354
|
+
const hasEmailOtp =
|
|
355
|
+
typeof user.email === 'string'
|
|
356
|
+
&& user.email.length > 0
|
|
357
|
+
&& !!getEmailOtpConfig(env)?.enabled;
|
|
358
|
+
const hasPhone =
|
|
359
|
+
!!parseConfig(env)?.auth?.phoneAuth
|
|
360
|
+
&& typeof user.phone === 'string'
|
|
361
|
+
&& user.phone.length > 0
|
|
362
|
+
&& Number(user.phoneVerified) === 1;
|
|
363
|
+
|
|
364
|
+
const total =
|
|
365
|
+
Number(hasPassword)
|
|
366
|
+
+ Number(hasMagicLink)
|
|
367
|
+
+ Number(hasEmailOtp)
|
|
368
|
+
+ Number(hasPhone)
|
|
369
|
+
+ passkeyCount
|
|
370
|
+
+ oauthAccounts.length;
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
user,
|
|
374
|
+
oauthAccounts,
|
|
375
|
+
passkeyCount,
|
|
376
|
+
summary: {
|
|
377
|
+
total,
|
|
378
|
+
hasPassword,
|
|
379
|
+
hasMagicLink,
|
|
380
|
+
hasEmailOtp,
|
|
381
|
+
hasPhone,
|
|
382
|
+
passkeyCount,
|
|
383
|
+
oauthCount: oauthAccounts.length,
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function generateOTP(): string {
|
|
389
|
+
const buf = new Uint32Array(1);
|
|
390
|
+
crypto.getRandomValues(buf);
|
|
391
|
+
return String(buf[0] % 1000000).padStart(6, '0');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function parseTTLtoMs(ttl: string): number {
|
|
395
|
+
const match = ttl.match(/^(\d+)(s|m|h|d)$/);
|
|
396
|
+
if (!match) return 15 * 60 * 1000; // default 15m
|
|
397
|
+
const value = parseInt(match[1], 10);
|
|
398
|
+
switch (match[2]) {
|
|
399
|
+
case 's': return value * 1000;
|
|
400
|
+
case 'm': return value * 60 * 1000;
|
|
401
|
+
case 'h': return value * 60 * 60 * 1000;
|
|
402
|
+
case 'd': return value * 24 * 60 * 60 * 1000;
|
|
403
|
+
default: return 15 * 60 * 1000;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function hashRecoveryCode(code: string): Promise<string> {
|
|
408
|
+
const encoded = new TextEncoder().encode(code);
|
|
409
|
+
const hash = await crypto.subtle.digest('SHA-256', encoded);
|
|
410
|
+
const bytes = new Uint8Array(hash);
|
|
411
|
+
let hex = '';
|
|
412
|
+
for (const b of bytes) {
|
|
413
|
+
hex += b.toString(16).padStart(2, '0');
|
|
414
|
+
}
|
|
415
|
+
return hex;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function verifyRecoveryCode(code: string, storedHash: string): Promise<boolean> {
|
|
419
|
+
const hash = await hashRecoveryCode(code);
|
|
420
|
+
if (hash.length !== storedHash.length) return false;
|
|
421
|
+
let result = 0;
|
|
422
|
+
for (let i = 0; i < hash.length; i++) {
|
|
423
|
+
result |= hash.charCodeAt(i) ^ storedHash.charCodeAt(i);
|
|
424
|
+
}
|
|
425
|
+
return result === 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Generate access token from user record, merging DB customClaims and hook overrides.
|
|
430
|
+
*/
|
|
431
|
+
async function generateAccessToken(
|
|
432
|
+
env: Env,
|
|
433
|
+
user: Record<string, unknown>,
|
|
434
|
+
hookClaimsOverride?: Record<string, unknown>,
|
|
435
|
+
): Promise<string> {
|
|
436
|
+
const dbClaims = user.customClaims
|
|
437
|
+
? (typeof user.customClaims === 'string' ? JSON.parse(user.customClaims as string) : user.customClaims)
|
|
438
|
+
: undefined;
|
|
439
|
+
|
|
440
|
+
let finalClaims = dbClaims;
|
|
441
|
+
if (hookClaimsOverride) {
|
|
442
|
+
finalClaims = { ...(dbClaims || {}), ...hookClaimsOverride };
|
|
443
|
+
const SYSTEM_CLAIMS = ['sub', 'iss', 'exp', 'iat', 'isAnonymous', 'displayName'];
|
|
444
|
+
for (const key of SYSTEM_CLAIMS) {
|
|
445
|
+
if (key in finalClaims) delete finalClaims[key];
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return signAccessToken(
|
|
450
|
+
{
|
|
451
|
+
sub: user.id as string,
|
|
452
|
+
email: user.email as string | null,
|
|
453
|
+
displayName: (user.displayName as string | null) ?? undefined,
|
|
454
|
+
role: user.role as string,
|
|
455
|
+
isAnonymous: (typeof user.isAnonymous === 'number') ? user.isAnonymous === 1 : !!user.isAnonymous,
|
|
456
|
+
custom: finalClaims,
|
|
457
|
+
},
|
|
458
|
+
getUserSecret(env),
|
|
459
|
+
getAccessTokenTTL(env),
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Create a session with eviction and token generation — D1-based.
|
|
465
|
+
*/
|
|
466
|
+
async function createSessionAndTokens(
|
|
467
|
+
env: Env,
|
|
468
|
+
userId: string,
|
|
469
|
+
ip: string,
|
|
470
|
+
userAgent: string,
|
|
471
|
+
): Promise<{ accessToken: string; refreshToken: string; sessionId: string }> {
|
|
472
|
+
const db = envAuthDb(env);
|
|
473
|
+
const user = await authService.getUserById(db, userId);
|
|
474
|
+
if (!user) throw new EdgeBaseError(500, 'Internal error: user record was not found immediately after creation.', undefined, 'internal-error');
|
|
475
|
+
|
|
476
|
+
// Session limit eviction
|
|
477
|
+
const maxSessions = getMaxActiveSessions(env);
|
|
478
|
+
if (maxSessions > 0) {
|
|
479
|
+
await authService.evictOldestSessions(db, userId, maxSessions);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const accessToken = await generateAccessToken(env, user);
|
|
483
|
+
const sessionId = generateId();
|
|
484
|
+
const refreshToken = await signRefreshToken(
|
|
485
|
+
{ sub: userId, type: 'refresh', jti: sessionId },
|
|
486
|
+
getUserSecret(env),
|
|
487
|
+
getRefreshTokenTTL(env),
|
|
488
|
+
);
|
|
489
|
+
const now = new Date().toISOString();
|
|
490
|
+
const refreshTTLSeconds = parseDuration(getRefreshTokenTTL(env));
|
|
491
|
+
const expiresAt = new Date(Date.now() + refreshTTLSeconds * 1000).toISOString();
|
|
492
|
+
|
|
493
|
+
const metadata = JSON.stringify({ ip, userAgent, lastActiveAt: now });
|
|
494
|
+
|
|
495
|
+
await authService.createSession(db, {
|
|
496
|
+
id: sessionId,
|
|
497
|
+
userId,
|
|
498
|
+
refreshToken,
|
|
499
|
+
expiresAt,
|
|
500
|
+
metadata,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
return { accessToken, refreshToken, sessionId };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Rotate refresh token — D1-based.
|
|
508
|
+
*/
|
|
509
|
+
async function rotateRefreshTokenFlow(
|
|
510
|
+
env: Env,
|
|
511
|
+
ctx: ExecutionContext,
|
|
512
|
+
session: Record<string, unknown>,
|
|
513
|
+
userId: string,
|
|
514
|
+
workerUrl?: string,
|
|
515
|
+
): Promise<{ user: Record<string, unknown>; accessToken: string; refreshToken: string }> {
|
|
516
|
+
const db = envAuthDb(env);
|
|
517
|
+
const user = await authService.getUserById(db, userId);
|
|
518
|
+
if (!user) throw new EdgeBaseError(401, 'User not found.', undefined, 'invalid-credentials');
|
|
519
|
+
|
|
520
|
+
if (user.disabled === 1) {
|
|
521
|
+
throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const newRefreshToken = await signRefreshToken(
|
|
525
|
+
{ sub: userId, type: 'refresh', jti: generateId() },
|
|
526
|
+
getUserSecret(env),
|
|
527
|
+
getRefreshTokenTTL(env),
|
|
528
|
+
);
|
|
529
|
+
const refreshTTLSeconds = parseDuration(getRefreshTokenTTL(env));
|
|
530
|
+
const expiresAt = new Date(Date.now() + refreshTTLSeconds * 1000).toISOString();
|
|
531
|
+
|
|
532
|
+
await authService.rotateRefreshToken(
|
|
533
|
+
db,
|
|
534
|
+
session.id as string,
|
|
535
|
+
newRefreshToken,
|
|
536
|
+
session.refreshToken as string,
|
|
537
|
+
expiresAt,
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
// onTokenRefresh hook — blocking, returns custom claims
|
|
541
|
+
let hookClaims: Record<string, unknown> | undefined;
|
|
542
|
+
try {
|
|
543
|
+
const result = await executeAuthHook(env, ctx, 'onTokenRefresh', authService.sanitizeUser(user), { blocking: true, workerUrl });
|
|
544
|
+
if (result && typeof result === 'object') {
|
|
545
|
+
hookClaims = result;
|
|
546
|
+
}
|
|
547
|
+
} catch {
|
|
548
|
+
console.error('[EdgeBase] onTokenRefresh hook failed, proceeding without hook claims');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const accessToken = await generateAccessToken(env, user, hookClaims);
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
user: authService.sanitizeUser(user),
|
|
555
|
+
accessToken,
|
|
556
|
+
refreshToken: newRefreshToken,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Sync user data to _users_public and KV cache.
|
|
562
|
+
*/
|
|
563
|
+
function syncUserPublic(
|
|
564
|
+
env: Env,
|
|
565
|
+
ctx: ExecutionContext,
|
|
566
|
+
userId: string,
|
|
567
|
+
userData: Record<string, unknown>,
|
|
568
|
+
isSync: boolean = false,
|
|
569
|
+
): Promise<void> | void {
|
|
570
|
+
const authDb = envAuthDb(env);
|
|
571
|
+
if (isSync) {
|
|
572
|
+
return syncPublicUserProjection(authDb, userId, userData, {
|
|
573
|
+
executionCtx: ctx,
|
|
574
|
+
kv: env.KV,
|
|
575
|
+
awaitCacheWrites: true,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
queuePublicUserProjectionSync(authDb, userId, userData, {
|
|
579
|
+
executionCtx: ctx,
|
|
580
|
+
kv: env.KV,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Execute auth hooks for a given event — D1-based (no re-entrancy concern).
|
|
586
|
+
*/
|
|
587
|
+
export async function executeAuthHook(
|
|
588
|
+
env: Env,
|
|
589
|
+
ctx: ExecutionContext,
|
|
590
|
+
event: AuthTrigger['event'],
|
|
591
|
+
userData: Record<string, unknown>,
|
|
592
|
+
options: { blocking?: boolean; ip?: string; userAgent?: string; workerUrl?: string } = {},
|
|
593
|
+
): Promise<Record<string, unknown> | void> {
|
|
594
|
+
const functions = getFunctionsByTrigger('auth', { type: 'auth', event } as AuthTrigger);
|
|
595
|
+
if (functions.length === 0) return;
|
|
596
|
+
|
|
597
|
+
const HOOK_TIMEOUT_MS = 5000;
|
|
598
|
+
const config = parseConfig(env);
|
|
599
|
+
const serviceKey = resolveRootServiceKey(config, env);
|
|
600
|
+
const adminDb = buildAdminDbProxy({
|
|
601
|
+
databaseNamespace: env.DATABASE,
|
|
602
|
+
config,
|
|
603
|
+
workerUrl: options.workerUrl,
|
|
604
|
+
serviceKey,
|
|
605
|
+
env,
|
|
606
|
+
executionCtx: ctx,
|
|
607
|
+
});
|
|
608
|
+
const authAdminBase = buildAdminAuthContext({
|
|
609
|
+
d1Database: env.AUTH_DB,
|
|
610
|
+
serviceKey,
|
|
611
|
+
workerUrl: options.workerUrl,
|
|
612
|
+
kvNamespace: env.KV,
|
|
613
|
+
});
|
|
614
|
+
const mergedBlockingResult: Record<string, unknown> = {};
|
|
615
|
+
|
|
616
|
+
for (const { name, definition } of functions) {
|
|
617
|
+
try {
|
|
618
|
+
const authAdmin = {
|
|
619
|
+
...authAdminBase,
|
|
620
|
+
async createUser(_data: {
|
|
621
|
+
email: string;
|
|
622
|
+
password: string;
|
|
623
|
+
displayName?: string;
|
|
624
|
+
role?: string;
|
|
625
|
+
}) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
'admin.auth.createUser() is not available inside auth hooks. ' +
|
|
628
|
+
'Use the Admin API or SDK for user creation.',
|
|
629
|
+
);
|
|
630
|
+
},
|
|
631
|
+
async deleteUser(_userId: string) {
|
|
632
|
+
throw new Error(
|
|
633
|
+
'admin.auth.deleteUser() is not available inside auth hooks. ' +
|
|
634
|
+
'Use the Admin API or SDK for user deletion.',
|
|
635
|
+
);
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const hookCtx: Record<string, unknown> = {
|
|
640
|
+
request: new Request('http://internal/auth-hook'),
|
|
641
|
+
auth: null,
|
|
642
|
+
admin: {
|
|
643
|
+
db: adminDb,
|
|
644
|
+
table: (name: string) => adminDb('shared').table(name),
|
|
645
|
+
auth: authAdmin,
|
|
646
|
+
async sql(namespace: string, id: string | undefined, query: string, params?: unknown[]) {
|
|
647
|
+
if (options.workerUrl && serviceKey) {
|
|
648
|
+
const res = await fetch(`${options.workerUrl}/api/sql`, {
|
|
649
|
+
method: 'POST',
|
|
650
|
+
headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': serviceKey },
|
|
651
|
+
body: JSON.stringify({ namespace, id, sql: query, params: params ?? [] }),
|
|
652
|
+
});
|
|
653
|
+
if (!res.ok) throw new Error(`admin.sql() failed: ${res.status}`);
|
|
654
|
+
return res.json();
|
|
655
|
+
}
|
|
656
|
+
throw new Error('admin.sql() requires workerUrl in auth hook context.');
|
|
657
|
+
},
|
|
658
|
+
async broadcast(channel: string, event: string, payload?: Record<string, unknown>) {
|
|
659
|
+
if (options.workerUrl && serviceKey) {
|
|
660
|
+
await fetch(`${options.workerUrl}/api/db/broadcast`, {
|
|
661
|
+
method: 'POST',
|
|
662
|
+
headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': serviceKey },
|
|
663
|
+
body: JSON.stringify({ channel, event, payload: payload ?? {} }),
|
|
664
|
+
});
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
throw new Error('admin.broadcast() requires workerUrl in auth hook context.');
|
|
668
|
+
},
|
|
669
|
+
functions: {
|
|
670
|
+
async call(name: string, data?: unknown) {
|
|
671
|
+
if (options.workerUrl && serviceKey) {
|
|
672
|
+
const safeName = name.split('/').map(encodeURIComponent).join('/');
|
|
673
|
+
const res = await fetch(`${options.workerUrl}/api/functions/${safeName}`, {
|
|
674
|
+
method: 'POST',
|
|
675
|
+
headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': serviceKey },
|
|
676
|
+
body: JSON.stringify(data ?? {}),
|
|
677
|
+
});
|
|
678
|
+
if (!res.ok) throw new Error(`admin.functions.call('${name}') failed: ${res.status}`);
|
|
679
|
+
return res.json();
|
|
680
|
+
}
|
|
681
|
+
throw new Error('admin.functions.call() requires workerUrl in auth hook context.');
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
kv: (namespace: string) => buildFunctionKvProxy(namespace, config, env, options.workerUrl, serviceKey),
|
|
685
|
+
d1: (database: string) => buildFunctionD1Proxy(database, config, env, options.workerUrl, serviceKey),
|
|
686
|
+
vector: (index: string) => buildFunctionVectorizeProxy(index, config, env, options.workerUrl, serviceKey),
|
|
687
|
+
push: buildFunctionPushProxy(options.workerUrl, serviceKey),
|
|
688
|
+
},
|
|
689
|
+
data: { after: userData },
|
|
690
|
+
...(options.ip ? { ip: options.ip } : {}),
|
|
691
|
+
...(options.userAgent ? { userAgent: options.userAgent } : {}),
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
if (options.blocking) {
|
|
695
|
+
const result = await Promise.race([
|
|
696
|
+
definition.handler(hookCtx),
|
|
697
|
+
new Promise((_, reject) =>
|
|
698
|
+
setTimeout(() => reject(new Error(`Auth hook '${name}' timed out (5s)`)), HOOK_TIMEOUT_MS),
|
|
699
|
+
),
|
|
700
|
+
]);
|
|
701
|
+
if (result && typeof result === 'object') {
|
|
702
|
+
Object.assign(mergedBlockingResult, result as Record<string, unknown>);
|
|
703
|
+
}
|
|
704
|
+
} else {
|
|
705
|
+
ctx.waitUntil(
|
|
706
|
+
definition.handler(hookCtx).catch((err: unknown) => {
|
|
707
|
+
console.error(`[EdgeBase] Auth hook '${name}' (${event}) failed:`, err);
|
|
708
|
+
}),
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
} catch (err) {
|
|
712
|
+
if (options.blocking) {
|
|
713
|
+
console.error(`[EdgeBase] Blocking auth hook '${name}' (${event}) failed:`, err);
|
|
714
|
+
throw new EdgeBaseError(403, `Auth hook '${name}' rejected the operation.`, undefined, 'hook-rejected');
|
|
715
|
+
}
|
|
716
|
+
console.error(`[EdgeBase] Auth hook '${name}' (${event}) error:`, err);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (options.blocking && Object.keys(mergedBlockingResult).length > 0) {
|
|
721
|
+
return mergedBlockingResult;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Send email with optional auth.handlers.email.onSend interception.
|
|
727
|
+
* The optional `locale` parameter is passed through to the onSend hook.
|
|
728
|
+
*/
|
|
729
|
+
async function sendMailWithHook(
|
|
730
|
+
env: Env,
|
|
731
|
+
ctx: ExecutionContext,
|
|
732
|
+
provider: EmailProvider,
|
|
733
|
+
type: MailType,
|
|
734
|
+
to: string,
|
|
735
|
+
subject: string,
|
|
736
|
+
html: string,
|
|
737
|
+
locale?: string,
|
|
738
|
+
): Promise<{ success: boolean; messageId?: string }> {
|
|
739
|
+
const config = parseConfig(env);
|
|
740
|
+
const onSend = getAuthHandlers(config)?.email?.onSend;
|
|
741
|
+
|
|
742
|
+
let finalSubject = subject;
|
|
743
|
+
let finalHtml = html;
|
|
744
|
+
|
|
745
|
+
if (onSend) {
|
|
746
|
+
const MAIL_HOOK_TIMEOUT = 5000;
|
|
747
|
+
const mailCtx: MailHookCtx = {
|
|
748
|
+
waitUntil: (p: Promise<unknown>) => ctx.waitUntil(p),
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
try {
|
|
752
|
+
const result = await Promise.race([
|
|
753
|
+
Promise.resolve(onSend(type, to, finalSubject, finalHtml, mailCtx, locale)),
|
|
754
|
+
new Promise<never>((_, reject) =>
|
|
755
|
+
setTimeout(() => reject(new Error('Mail hook timed out')), MAIL_HOOK_TIMEOUT),
|
|
756
|
+
),
|
|
757
|
+
]);
|
|
758
|
+
|
|
759
|
+
if (result) {
|
|
760
|
+
if (result.subject) finalSubject = result.subject;
|
|
761
|
+
if (result.html) finalHtml = result.html;
|
|
762
|
+
}
|
|
763
|
+
} catch (err) {
|
|
764
|
+
console.error('[EdgeBase] auth.handlers.email.onSend rejected or timed out:', err);
|
|
765
|
+
throw new EdgeBaseError(403, 'Mail hook rejected the email.', undefined, 'hook-rejected');
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return provider.send({ to, subject: finalSubject, html: finalHtml });
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function sendSmsWithHook(
|
|
773
|
+
env: Env,
|
|
774
|
+
ctx: ExecutionContext,
|
|
775
|
+
provider: SmsProvider,
|
|
776
|
+
type: SmsType,
|
|
777
|
+
to: string,
|
|
778
|
+
body: string,
|
|
779
|
+
): Promise<void> {
|
|
780
|
+
const onSend = getAuthHandlers(parseConfig(env))?.sms?.onSend;
|
|
781
|
+
let finalBody = body;
|
|
782
|
+
|
|
783
|
+
if (onSend) {
|
|
784
|
+
const SMS_HOOK_TIMEOUT = 5000;
|
|
785
|
+
const smsCtx: SmsHookCtx = {
|
|
786
|
+
waitUntil: (p: Promise<unknown>) => ctx.waitUntil(p),
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
const result = await Promise.race([
|
|
791
|
+
Promise.resolve(onSend(type, to, finalBody, smsCtx)),
|
|
792
|
+
new Promise<never>((_, reject) =>
|
|
793
|
+
setTimeout(() => reject(new Error('SMS hook timed out')), SMS_HOOK_TIMEOUT),
|
|
794
|
+
),
|
|
795
|
+
]);
|
|
796
|
+
|
|
797
|
+
if (result?.body) {
|
|
798
|
+
finalBody = result.body;
|
|
799
|
+
}
|
|
800
|
+
} catch (err) {
|
|
801
|
+
console.error('[EdgeBase] auth.handlers.sms.onSend rejected or timed out:', err);
|
|
802
|
+
throw new EdgeBaseError(403, 'SMS hook rejected the SMS.', undefined, 'hook-rejected');
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
await provider.send({ to, body: finalBody });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Extract client IP from request headers.
|
|
811
|
+
*
|
|
812
|
+
* Priority: CF-Connecting-IP (Cloudflare, tamper-proof) →
|
|
813
|
+
* X-Forwarded-For (only when trustSelfHostedProxy=true).
|
|
814
|
+
*
|
|
815
|
+
* Security: X-Forwarded-For is client-spoofable when EdgeBase is exposed without
|
|
816
|
+
* a reverse proxy. Self-hosted deployments MUST place EdgeBase behind Nginx/Caddy
|
|
817
|
+
* that overwrites X-Forwarded-For with $remote_addr. See docs/self-hosting.md.
|
|
818
|
+
*/
|
|
819
|
+
function getClientIP(env: Env, request: Request): string {
|
|
820
|
+
return getTrustedClientIp(env, request) ?? '0.0.0.0';
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function getAnonymousAuthEnabled(env: Env): boolean {
|
|
824
|
+
try {
|
|
825
|
+
const config = parseConfig(env);
|
|
826
|
+
return !!config?.auth?.anonymousAuth;
|
|
827
|
+
} catch {
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ─── D1 Schema Middleware ───
|
|
833
|
+
|
|
834
|
+
authRoute.use('*', async (c, next) => {
|
|
835
|
+
await ensureAuthSchema(getAuthDb(c));
|
|
836
|
+
await next();
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
// ─── Auth Rate Limiting Middleware ───
|
|
840
|
+
// 2-layer: software counter (config-driven) + Binding ceiling
|
|
841
|
+
// Service Key는 auth 그룹 바이패스
|
|
842
|
+
|
|
843
|
+
authRoute.use('*', async (c, next) => {
|
|
844
|
+
// Service Key bypasses auth rate limit
|
|
845
|
+
const providedServiceKey = resolveServiceKeyCandidate(
|
|
846
|
+
c.req,
|
|
847
|
+
c.get('serviceKeyToken') as string | null | undefined,
|
|
848
|
+
);
|
|
849
|
+
if (providedServiceKey) {
|
|
850
|
+
const config = c.env ? parseConfig(c.env) : {};
|
|
851
|
+
const { result: skResult } = validateKey(
|
|
852
|
+
providedServiceKey,
|
|
853
|
+
'auth:*:*:bypass',
|
|
854
|
+
config,
|
|
855
|
+
c.env as never,
|
|
856
|
+
undefined,
|
|
857
|
+
buildConstraintCtx((c.env ?? {}) as { ENVIRONMENT?: string }, c.req),
|
|
858
|
+
);
|
|
859
|
+
if (skResult === 'valid') {
|
|
860
|
+
await next();
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
// 'invalid' key provided — still fall through to normal rate limiting
|
|
864
|
+
// (don't throw here; auth routes return their own errors)
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const ip = getClientIP(c.env, c.req.raw);
|
|
868
|
+
const config = c.env ? parseConfig(c.env) : undefined;
|
|
869
|
+
|
|
870
|
+
// Layer 1: Software counter (config-driven)
|
|
871
|
+
const { requests, windowSec } = getLimit(config, 'auth');
|
|
872
|
+
const counterKey = `auth:${ip}`;
|
|
873
|
+
if (!counter.check(counterKey, requests, windowSec)) {
|
|
874
|
+
throw new EdgeBaseError(429, 'Too many requests. Try again later.', undefined, 'rate-limited');
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Layer 2: Binding ceiling
|
|
878
|
+
const authLimiter = c.env?.AUTH_RATE_LIMITER;
|
|
879
|
+
if (authLimiter) {
|
|
880
|
+
const { success } = await authLimiter.limit({ key: ip });
|
|
881
|
+
if (!success) {
|
|
882
|
+
throw new EdgeBaseError(429, 'Too many requests. Try again later.', undefined, 'rate-limited');
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
await next();
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// ─── Captcha Middleware ───
|
|
889
|
+
// Applied per-route after rate limiting. Service Key requests bypass.
|
|
890
|
+
authRoute.use('/signup', captchaMiddleware('signup'));
|
|
891
|
+
authRoute.use('/signin', captchaMiddleware('signin'));
|
|
892
|
+
authRoute.use('/signin/anonymous', captchaMiddleware('anonymous'));
|
|
893
|
+
authRoute.use('/signin/magic-link', captchaMiddleware('magic-link'));
|
|
894
|
+
authRoute.use('/signin/phone', captchaMiddleware('phone'));
|
|
895
|
+
authRoute.use('/request-password-reset', captchaMiddleware('password-reset'));
|
|
896
|
+
|
|
897
|
+
// ─── Signup (D1 Control Plane) ───
|
|
898
|
+
|
|
899
|
+
const signup = createRoute({
|
|
900
|
+
operationId: 'authSignup',
|
|
901
|
+
method: 'post',
|
|
902
|
+
path: '/signup',
|
|
903
|
+
tags: ['client'],
|
|
904
|
+
summary: 'Sign up with email and password',
|
|
905
|
+
request: {
|
|
906
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
907
|
+
email: z.string(),
|
|
908
|
+
password: z.string(),
|
|
909
|
+
}).passthrough() } }, required: true },
|
|
910
|
+
},
|
|
911
|
+
responses: {
|
|
912
|
+
201: { description: 'User created', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
913
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
914
|
+
409: { description: 'Email already registered', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
915
|
+
429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
916
|
+
},
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
authRoute.openapi(signup, async (c) => {
|
|
920
|
+
const ip = getClientIP(c.env, c.req.raw);
|
|
921
|
+
const config = c.env ? parseConfig(c.env) : undefined;
|
|
922
|
+
|
|
923
|
+
// Layer 1: Software counter (config-driven)
|
|
924
|
+
const { requests, windowSec } = getLimit(config, 'authSignup');
|
|
925
|
+
const counterKey = `authSignup:${ip}`;
|
|
926
|
+
if (!counter.check(counterKey, requests, windowSec)) {
|
|
927
|
+
throw new EdgeBaseError(429, 'Too many signup attempts. Please try again later.', undefined, 'rate-limited');
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Layer 2: Binding ceiling
|
|
931
|
+
const signupLimiter = c.env?.AUTH_SIGNUP_RATE_LIMITER;
|
|
932
|
+
if (signupLimiter) {
|
|
933
|
+
const { success } = await signupLimiter.limit({ key: ip });
|
|
934
|
+
if (!success) {
|
|
935
|
+
throw new EdgeBaseError(429, 'Too many signup attempts. Please try again later.', undefined, 'rate-limited');
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const body = await c.req.json<{ email: string; password: string; data?: Record<string, unknown> }>();
|
|
940
|
+
if (!body.email || !body.password) {
|
|
941
|
+
throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'invalid-input');
|
|
942
|
+
}
|
|
943
|
+
body.email = body.email.trim().toLowerCase(); //
|
|
944
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
|
|
945
|
+
throw new EdgeBaseError(400, 'Invalid email format. Please provide a valid email address.', undefined, 'invalid-email');
|
|
946
|
+
}
|
|
947
|
+
if (body.password.length < 8) {
|
|
948
|
+
throw new EdgeBaseError(400, 'Password must be at least 8 characters.', undefined, 'password-too-short');
|
|
949
|
+
}
|
|
950
|
+
if (body.password.length > 256) {
|
|
951
|
+
throw new EdgeBaseError(400, 'Password must not exceed 256 characters.', undefined, 'password-too-long');
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
await ensureAuthActionAllowed(c, 'signUp', body as unknown as Record<string, unknown>);
|
|
955
|
+
|
|
956
|
+
// Validate optional fields from body.data
|
|
957
|
+
const displayName = body.data?.displayName ?? null;
|
|
958
|
+
const avatarUrl = body.data?.avatarUrl ?? null;
|
|
959
|
+
if (displayName !== null && typeof displayName === 'string' && displayName.length > 200) {
|
|
960
|
+
throw new EdgeBaseError(400, 'Display name must not exceed 200 characters.', undefined, 'display-name-too-long');
|
|
961
|
+
}
|
|
962
|
+
if (avatarUrl !== null && typeof avatarUrl === 'string' && avatarUrl.length > 2048) {
|
|
963
|
+
throw new EdgeBaseError(400, 'Avatar URL must not exceed 2048 characters.', undefined, 'invalid-input');
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Validate locale if provided
|
|
967
|
+
const rawLocale = (body as Record<string, unknown>).locale as string | undefined;
|
|
968
|
+
if (rawLocale && !/^[a-z]{2}(-[A-Z]{2})?$/.test(rawLocale)) {
|
|
969
|
+
throw new EdgeBaseError(400, 'Invalid locale format. Expected format: "en" or "en-US".', undefined, 'invalid-locale');
|
|
970
|
+
}
|
|
971
|
+
const locale = rawLocale ?? parseAcceptLanguage(c.req.header('accept-language')) ?? 'en';
|
|
972
|
+
|
|
973
|
+
const userId = generateId();
|
|
974
|
+
const db = getAuthDb(c);
|
|
975
|
+
|
|
976
|
+
// Register pending in D1 email index
|
|
977
|
+
try {
|
|
978
|
+
await registerEmailPending(db, body.email, userId);
|
|
979
|
+
} catch (err) {
|
|
980
|
+
if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
|
|
981
|
+
throw new EdgeBaseError(409, 'Email already registered.', undefined, 'email-already-exists');
|
|
982
|
+
}
|
|
983
|
+
throw new EdgeBaseError(500, 'Signup failed. Please try again.', undefined, 'internal-error');
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Create user directly in D1
|
|
987
|
+
try {
|
|
988
|
+
// Password policy validation
|
|
989
|
+
const policyResult = await validatePassword(body.password, getPasswordPolicyConfig(c.env));
|
|
990
|
+
if (!policyResult.valid) {
|
|
991
|
+
await deleteEmailPending(db, body.email).catch(() => {});
|
|
992
|
+
throw new EdgeBaseError(400, policyResult.errors[0], { password: { code: 'password_policy', message: policyResult.errors.join('; ') } }, 'password-policy');
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const passwordHash = await hashPassword(body.password);
|
|
996
|
+
|
|
997
|
+
await authService.createUser(db, {
|
|
998
|
+
userId,
|
|
999
|
+
email: body.email,
|
|
1000
|
+
passwordHash,
|
|
1001
|
+
displayName: displayName as string | null,
|
|
1002
|
+
avatarUrl: avatarUrl as string | null,
|
|
1003
|
+
emailVisibility: 'private',
|
|
1004
|
+
role: 'user',
|
|
1005
|
+
verified: false,
|
|
1006
|
+
locale,
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// beforeSignUp hook — blocking, can cancel signup
|
|
1010
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
|
|
1011
|
+
id: userId,
|
|
1012
|
+
email: body.email,
|
|
1013
|
+
displayName,
|
|
1014
|
+
avatarUrl,
|
|
1015
|
+
}, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
1016
|
+
|
|
1017
|
+
// Create session + tokens
|
|
1018
|
+
const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
|
|
1019
|
+
|
|
1020
|
+
// Confirm email in D1 index
|
|
1021
|
+
await confirmEmail(db, body.email, userId);
|
|
1022
|
+
|
|
1023
|
+
// Sync to _users_public
|
|
1024
|
+
const user = await authService.getUserById(db, userId);
|
|
1025
|
+
if (user) {
|
|
1026
|
+
syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(user));
|
|
1027
|
+
|
|
1028
|
+
// afterSignUp hook — non-blocking
|
|
1029
|
+
c.executionCtx.waitUntil(
|
|
1030
|
+
executeAuthHook(c.env, c.executionCtx, 'afterSignUp', authService.sanitizeUser(user), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
return c.json({
|
|
1034
|
+
user: authService.sanitizeUser(user),
|
|
1035
|
+
accessToken: session.accessToken,
|
|
1036
|
+
refreshToken: session.refreshToken,
|
|
1037
|
+
}, 201);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return c.json({ accessToken: session.accessToken, refreshToken: session.refreshToken }, 201);
|
|
1041
|
+
} catch (err) {
|
|
1042
|
+
if (err instanceof EdgeBaseError) throw err;
|
|
1043
|
+
// Compensating transaction
|
|
1044
|
+
await deleteEmailPending(db, body.email).catch(() => {});
|
|
1045
|
+
throw new EdgeBaseError(500, 'Signup failed. Please try again.', undefined, 'internal-error');
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
// ─── Signin (D1 Control Plane) ───
|
|
1050
|
+
|
|
1051
|
+
const signin = createRoute({
|
|
1052
|
+
operationId: 'authSignin',
|
|
1053
|
+
method: 'post',
|
|
1054
|
+
path: '/signin',
|
|
1055
|
+
tags: ['client'],
|
|
1056
|
+
summary: 'Sign in with email and password',
|
|
1057
|
+
request: {
|
|
1058
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
1059
|
+
email: z.string(),
|
|
1060
|
+
password: z.string(),
|
|
1061
|
+
}).passthrough() } }, required: true },
|
|
1062
|
+
},
|
|
1063
|
+
responses: {
|
|
1064
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1065
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1066
|
+
401: { description: 'Invalid credentials', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1067
|
+
429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1068
|
+
},
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
authRoute.openapi(signin, async (c) => {
|
|
1072
|
+
const body = await c.req.json<{ email: string; password: string }>();
|
|
1073
|
+
if (!body.email || !body.password) {
|
|
1074
|
+
throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'invalid-input');
|
|
1075
|
+
}
|
|
1076
|
+
body.email = body.email.trim().toLowerCase();
|
|
1077
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
|
|
1078
|
+
throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
|
|
1079
|
+
}
|
|
1080
|
+
if (body.password.length > 256) {
|
|
1081
|
+
throw new EdgeBaseError(400, 'Password must not exceed 256 characters.', undefined, 'password-too-long');
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
await ensureAuthActionAllowed(c, 'signIn', body as unknown as Record<string, unknown>);
|
|
1085
|
+
|
|
1086
|
+
// Layer 1: Software counter (config-driven, email당)
|
|
1087
|
+
const config = c.env ? parseConfig(c.env) : undefined;
|
|
1088
|
+
const signinLimit = getLimit(config, 'authSignin');
|
|
1089
|
+
const signinKey = `authSignin:${body.email}`;
|
|
1090
|
+
if (!counter.check(signinKey, signinLimit.requests, signinLimit.windowSec)) {
|
|
1091
|
+
throw new EdgeBaseError(429, 'Too many login attempts. Try again later.', undefined, 'rate-limited');
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Layer 2: Binding ceiling
|
|
1095
|
+
const signinLimiter = c.env?.AUTH_SIGNIN_RATE_LIMITER;
|
|
1096
|
+
if (signinLimiter) {
|
|
1097
|
+
const { success } = await signinLimiter.limit({ key: body.email });
|
|
1098
|
+
if (!success) {
|
|
1099
|
+
throw new EdgeBaseError(429, 'Too many login attempts. Try again later.', undefined, 'rate-limited');
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Look up email → userId in D1
|
|
1104
|
+
const record = await lookupEmail(getAuthDb(c), body.email);
|
|
1105
|
+
if (!record) {
|
|
1106
|
+
throw new EdgeBaseError(401, 'Invalid credentials.', undefined, 'invalid-credentials');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const { userId } = record;
|
|
1110
|
+
const ip = getClientIP(c.env, c.req.raw);
|
|
1111
|
+
const db = getAuthDb(c);
|
|
1112
|
+
|
|
1113
|
+
// Verify password directly in D1
|
|
1114
|
+
const user = await authService.getUserById(db, userId);
|
|
1115
|
+
if (!user) {
|
|
1116
|
+
throw new EdgeBaseError(401, 'Invalid credentials.', undefined, 'invalid-credentials');
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// OAuth-only user check
|
|
1120
|
+
if (!user.passwordHash) {
|
|
1121
|
+
throw new EdgeBaseError(403, 'This account uses OAuth sign-in. Password login is not available.', undefined, 'oauth-only');
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const valid = await verifyPassword(body.password, user.passwordHash as string);
|
|
1125
|
+
if (!valid) {
|
|
1126
|
+
throw new EdgeBaseError(401, 'Invalid credentials.', undefined, 'invalid-credentials');
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Lazy re-hash: if password uses non-native format (e.g. imported bcrypt), upgrade to PBKDF2
|
|
1130
|
+
if (needsRehash(user.passwordHash as string)) {
|
|
1131
|
+
const newHash = await hashPassword(body.password);
|
|
1132
|
+
await authService.updateUser(db, userId, { passwordHash: newHash });
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Disabled user check
|
|
1136
|
+
if (user.disabled === 1) {
|
|
1137
|
+
throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// beforeSignIn hook — blocking, can reject signin
|
|
1141
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforeSignIn', authService.sanitizeUser(user), { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
1142
|
+
|
|
1143
|
+
// MFA Check
|
|
1144
|
+
const mfaConfig = getMfaConfig(c.env);
|
|
1145
|
+
if (mfaConfig?.totp) {
|
|
1146
|
+
const factors = await authService.listVerifiedMfaFactors(db, userId);
|
|
1147
|
+
if (factors.length > 0) {
|
|
1148
|
+
const mfaTicket = crypto.randomUUID();
|
|
1149
|
+
await c.env.KV.put(
|
|
1150
|
+
`mfa-ticket:${mfaTicket}`,
|
|
1151
|
+
JSON.stringify({ userId }),
|
|
1152
|
+
{ expirationTtl: 300 },
|
|
1153
|
+
);
|
|
1154
|
+
|
|
1155
|
+
return c.json({
|
|
1156
|
+
mfaRequired: true,
|
|
1157
|
+
mfaTicket,
|
|
1158
|
+
factors: factors.map((f: Record<string, unknown>) => ({ id: f.id, type: f.type })),
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Lazy cleanup of expired sessions
|
|
1164
|
+
await authService.cleanExpiredSessionsForUser(db, userId);
|
|
1165
|
+
|
|
1166
|
+
const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
|
|
1167
|
+
|
|
1168
|
+
// afterSignIn hook — non-blocking
|
|
1169
|
+
c.executionCtx.waitUntil(
|
|
1170
|
+
executeAuthHook(c.env, c.executionCtx, 'afterSignIn', authService.sanitizeUser(user), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
1171
|
+
);
|
|
1172
|
+
|
|
1173
|
+
return c.json({
|
|
1174
|
+
user: authService.sanitizeUser(user),
|
|
1175
|
+
accessToken: session.accessToken,
|
|
1176
|
+
refreshToken: session.refreshToken,
|
|
1177
|
+
});
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
// ─── Anonymous Signin (D1 Control Plane) ───
|
|
1181
|
+
|
|
1182
|
+
const signinAnonymous = createRoute({
|
|
1183
|
+
operationId: 'authSigninAnonymous',
|
|
1184
|
+
method: 'post',
|
|
1185
|
+
path: '/signin/anonymous',
|
|
1186
|
+
tags: ['client'],
|
|
1187
|
+
summary: 'Sign in anonymously',
|
|
1188
|
+
request: {
|
|
1189
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
1190
|
+
captchaToken: z.string().optional(),
|
|
1191
|
+
}).passthrough() } }, required: false },
|
|
1192
|
+
},
|
|
1193
|
+
responses: {
|
|
1194
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1195
|
+
404: { description: 'Anonymous auth not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1196
|
+
},
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
authRoute.openapi(signinAnonymous, async (c) => {
|
|
1200
|
+
if (!getAnonymousAuthEnabled(c.env)) {
|
|
1201
|
+
throw new EdgeBaseError(404, 'Anonymous authentication is not enabled.', undefined, 'feature-not-enabled');
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const rawBody = await c.req.json<Record<string, unknown>>().catch(() => ({}));
|
|
1205
|
+
await ensureAuthActionAllowed(c, 'signInAnonymous', rawBody);
|
|
1206
|
+
|
|
1207
|
+
const ip = getClientIP(c.env, c.req.raw);
|
|
1208
|
+
|
|
1209
|
+
const userId = generateId();
|
|
1210
|
+
const db = getAuthDb(c);
|
|
1211
|
+
|
|
1212
|
+
// Register in D1 _anon_index
|
|
1213
|
+
await registerAnonPending(db, userId);
|
|
1214
|
+
|
|
1215
|
+
try {
|
|
1216
|
+
// Create anonymous user directly in D1
|
|
1217
|
+
await authService.createAnonymousUser(db, userId);
|
|
1218
|
+
|
|
1219
|
+
const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
|
|
1220
|
+
|
|
1221
|
+
// Confirm in D1
|
|
1222
|
+
await confirmAnon(db, userId);
|
|
1223
|
+
|
|
1224
|
+
const user = await authService.getUserById(db, userId);
|
|
1225
|
+
if (user) {
|
|
1226
|
+
syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(user));
|
|
1227
|
+
|
|
1228
|
+
return c.json({
|
|
1229
|
+
user: authService.sanitizeUser(user),
|
|
1230
|
+
accessToken: session.accessToken,
|
|
1231
|
+
refreshToken: session.refreshToken,
|
|
1232
|
+
}, 201);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
return c.json({ accessToken: session.accessToken, refreshToken: session.refreshToken }, 201);
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
if (err instanceof EdgeBaseError) throw err;
|
|
1238
|
+
await deleteAnon(db, userId).catch(() => {});
|
|
1239
|
+
throw new EdgeBaseError(500, 'Anonymous signin failed.', undefined, 'internal-error');
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
// ─── Magic Link (D1 Control Plane) ───
|
|
1244
|
+
|
|
1245
|
+
const signinMagicLink = createRoute({
|
|
1246
|
+
operationId: 'authSigninMagicLink',
|
|
1247
|
+
method: 'post',
|
|
1248
|
+
path: '/signin/magic-link',
|
|
1249
|
+
tags: ['client'],
|
|
1250
|
+
summary: 'Send magic link to email',
|
|
1251
|
+
request: {
|
|
1252
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
1253
|
+
email: z.string(),
|
|
1254
|
+
}).passthrough() } }, required: true },
|
|
1255
|
+
},
|
|
1256
|
+
responses: {
|
|
1257
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1258
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1259
|
+
404: { description: 'Magic link not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1260
|
+
},
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
authRoute.openapi(signinMagicLink, async (c) => {
|
|
1264
|
+
const body = await c.req.json<{
|
|
1265
|
+
email: string;
|
|
1266
|
+
redirectUrl?: string;
|
|
1267
|
+
state?: string;
|
|
1268
|
+
}>();
|
|
1269
|
+
if (!body.email) {
|
|
1270
|
+
throw new EdgeBaseError(400, 'Email is required.', undefined, 'invalid-input');
|
|
1271
|
+
}
|
|
1272
|
+
body.email = body.email.trim().toLowerCase();
|
|
1273
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
|
|
1274
|
+
throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
|
|
1275
|
+
}
|
|
1276
|
+
const redirect = parseClientRedirectInput(c.env, body);
|
|
1277
|
+
|
|
1278
|
+
await ensureAuthActionAllowed(c, 'signInMagicLink', body as unknown as Record<string, unknown>);
|
|
1279
|
+
|
|
1280
|
+
const config = c.env ? parseConfig(c.env) : undefined;
|
|
1281
|
+
if (!config?.auth?.magicLink?.enabled) {
|
|
1282
|
+
throw new EdgeBaseError(404, 'Magic link authentication is not enabled.', undefined, 'feature-not-enabled');
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const autoCreate = config.auth.magicLink.autoCreate !== false; // default true
|
|
1286
|
+
|
|
1287
|
+
// Look up email in D1
|
|
1288
|
+
const record = await lookupEmail(getAuthDb(c), body.email);
|
|
1289
|
+
|
|
1290
|
+
const db = getAuthDb(c);
|
|
1291
|
+
let debugToken: string | undefined;
|
|
1292
|
+
let debugActionUrl: string | undefined;
|
|
1293
|
+
|
|
1294
|
+
if (record) {
|
|
1295
|
+
// Existing user — send magic link directly via D1
|
|
1296
|
+
const { userId } = record;
|
|
1297
|
+
const user = await authService.getUserById(db, userId);
|
|
1298
|
+
if (!user) return c.json({ ok: true }); // Don't reveal details
|
|
1299
|
+
if (!user.email) return c.json({ ok: true });
|
|
1300
|
+
|
|
1301
|
+
// Delete old magic-link tokens
|
|
1302
|
+
await authService.deleteEmailTokensByUserAndType(db, userId, 'magic-link');
|
|
1303
|
+
|
|
1304
|
+
const magicLinkConfig = getMagicLinkConfig(c.env);
|
|
1305
|
+
const tokenTTL = magicLinkConfig?.tokenTTL ?? '15m';
|
|
1306
|
+
const ttlMs = parseTTLtoMs(tokenTTL);
|
|
1307
|
+
|
|
1308
|
+
const token = crypto.randomUUID();
|
|
1309
|
+
const now = new Date();
|
|
1310
|
+
const expiresAt = new Date(now.getTime() + ttlMs);
|
|
1311
|
+
await authService.createEmailToken(db, {
|
|
1312
|
+
token,
|
|
1313
|
+
userId,
|
|
1314
|
+
type: 'magic-link',
|
|
1315
|
+
expiresAt: expiresAt.toISOString(),
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
const provider = createEmailProvider(getEmailConfig(c.env), c.env);
|
|
1319
|
+
const emailCfg = getEmailConfig(c.env);
|
|
1320
|
+
const fallbackMagicLinkUrl = emailCfg?.magicLinkUrl
|
|
1321
|
+
? emailCfg.magicLinkUrl.replace('{token}', token)
|
|
1322
|
+
: `#magic-link?token=${token}`;
|
|
1323
|
+
const magicLinkUrl = buildEmailActionUrl({
|
|
1324
|
+
redirectUrl: redirect.redirectUrl,
|
|
1325
|
+
fallbackUrl: fallbackMagicLinkUrl,
|
|
1326
|
+
token,
|
|
1327
|
+
type: 'magic-link',
|
|
1328
|
+
state: redirect.state,
|
|
1329
|
+
});
|
|
1330
|
+
if (!provider) {
|
|
1331
|
+
const release = config?.release ?? false;
|
|
1332
|
+
if (!release) {
|
|
1333
|
+
console.warn('[MagicLink] Email provider not configured. Token:', token);
|
|
1334
|
+
debugToken = token;
|
|
1335
|
+
debugActionUrl = magicLinkUrl;
|
|
1336
|
+
}
|
|
1337
|
+
} else {
|
|
1338
|
+
const locale = resolveEmailLocale(c.env, user.locale as string | null, parseAcceptLanguage(c.req.header('accept-language')));
|
|
1339
|
+
const html = renderMagicLink({
|
|
1340
|
+
appName: getAppName(c.env),
|
|
1341
|
+
magicLinkUrl,
|
|
1342
|
+
expiresInMinutes: Math.round(ttlMs / 60000),
|
|
1343
|
+
}, resolveLocalizedString(getEmailTemplates(c.env)?.magicLink, locale), locale);
|
|
1344
|
+
|
|
1345
|
+
const defaultSubject = getDefaultSubject(locale, 'magicLink').replace(/\{\{appName\}\}/g, getAppName(c.env));
|
|
1346
|
+
await sendMailWithHook(
|
|
1347
|
+
c.env, c.executionCtx, provider, 'magicLink', user.email as string,
|
|
1348
|
+
resolveSubject(c.env, 'magicLink', defaultSubject, locale), html, locale,
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
} else if (autoCreate) {
|
|
1352
|
+
// Auto-create user + send magic link
|
|
1353
|
+
const userId = generateId();
|
|
1354
|
+
|
|
1355
|
+
try {
|
|
1356
|
+
await registerEmailPending(db, body.email, userId);
|
|
1357
|
+
} catch (err) {
|
|
1358
|
+
if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
|
|
1359
|
+
return c.json({ ok: true });
|
|
1360
|
+
}
|
|
1361
|
+
throw new EdgeBaseError(500, 'Magic link request failed.', undefined, 'internal-error');
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
try {
|
|
1365
|
+
// Create user with no password, verified = 1
|
|
1366
|
+
const reqLocale = parseAcceptLanguage(c.req.header('accept-language'));
|
|
1367
|
+
await authService.createUser(db, {
|
|
1368
|
+
userId,
|
|
1369
|
+
email: body.email,
|
|
1370
|
+
passwordHash: '',
|
|
1371
|
+
emailVisibility: 'private',
|
|
1372
|
+
role: 'user',
|
|
1373
|
+
verified: true,
|
|
1374
|
+
locale: reqLocale ?? 'en',
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
// beforeSignUp hook
|
|
1378
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforeSignUp', {
|
|
1379
|
+
id: userId, email: body.email, displayName: null, avatarUrl: null,
|
|
1380
|
+
}, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
1381
|
+
|
|
1382
|
+
// Sync to _users_public
|
|
1383
|
+
const user = await authService.getUserById(db, userId);
|
|
1384
|
+
if (user) {
|
|
1385
|
+
syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(user));
|
|
1386
|
+
c.executionCtx.waitUntil(
|
|
1387
|
+
executeAuthHook(c.env, c.executionCtx, 'afterSignUp', authService.sanitizeUser(user), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
await confirmEmail(db, body.email, userId);
|
|
1392
|
+
|
|
1393
|
+
// Send magic link token
|
|
1394
|
+
const magicLinkConfig = getMagicLinkConfig(c.env);
|
|
1395
|
+
const tokenTTL = magicLinkConfig?.tokenTTL ?? '15m';
|
|
1396
|
+
const ttlMs = parseTTLtoMs(tokenTTL);
|
|
1397
|
+
|
|
1398
|
+
const token = crypto.randomUUID();
|
|
1399
|
+
const tokenNow = new Date();
|
|
1400
|
+
const expiresAt = new Date(tokenNow.getTime() + ttlMs);
|
|
1401
|
+
await authService.createEmailToken(db, {
|
|
1402
|
+
token,
|
|
1403
|
+
userId,
|
|
1404
|
+
type: 'magic-link',
|
|
1405
|
+
expiresAt: expiresAt.toISOString(),
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
const provider = createEmailProvider(getEmailConfig(c.env), c.env);
|
|
1409
|
+
const emailCfg = getEmailConfig(c.env);
|
|
1410
|
+
const fallbackMagicLinkUrl = emailCfg?.magicLinkUrl
|
|
1411
|
+
? emailCfg.magicLinkUrl.replace('{token}', token)
|
|
1412
|
+
: `#magic-link?token=${token}`;
|
|
1413
|
+
const magicLinkUrl = buildEmailActionUrl({
|
|
1414
|
+
redirectUrl: redirect.redirectUrl,
|
|
1415
|
+
fallbackUrl: fallbackMagicLinkUrl,
|
|
1416
|
+
token,
|
|
1417
|
+
type: 'magic-link',
|
|
1418
|
+
state: redirect.state,
|
|
1419
|
+
});
|
|
1420
|
+
if (provider) {
|
|
1421
|
+
const locale = resolveEmailLocale(c.env, reqLocale);
|
|
1422
|
+
const html = renderMagicLink({
|
|
1423
|
+
appName: getAppName(c.env),
|
|
1424
|
+
magicLinkUrl,
|
|
1425
|
+
expiresInMinutes: Math.round(ttlMs / 60000),
|
|
1426
|
+
}, resolveLocalizedString(getEmailTemplates(c.env)?.magicLink, locale), locale);
|
|
1427
|
+
|
|
1428
|
+
const defaultSubject = getDefaultSubject(locale, 'magicLink').replace(/\{\{appName\}\}/g, getAppName(c.env));
|
|
1429
|
+
await sendMailWithHook(
|
|
1430
|
+
c.env, c.executionCtx, provider, 'magicLink', body.email,
|
|
1431
|
+
resolveSubject(c.env, 'magicLink', defaultSubject, locale), html, locale,
|
|
1432
|
+
).catch(() => {});
|
|
1433
|
+
} else {
|
|
1434
|
+
const release = config?.release ?? false;
|
|
1435
|
+
if (!release) {
|
|
1436
|
+
debugToken = token;
|
|
1437
|
+
debugActionUrl = magicLinkUrl;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
if (err instanceof EdgeBaseError) throw err;
|
|
1442
|
+
await deleteEmailPending(db, body.email).catch(() => {});
|
|
1443
|
+
return c.json({ ok: true });
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
// else: !autoCreate && !record → return ok (don't reveal email existence)
|
|
1447
|
+
|
|
1448
|
+
return c.json(debugToken
|
|
1449
|
+
? { ok: true, token: debugToken, actionUrl: debugActionUrl }
|
|
1450
|
+
: { ok: true });
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
const verifyMagicLink = createRoute({
|
|
1454
|
+
operationId: 'authVerifyMagicLink',
|
|
1455
|
+
method: 'post',
|
|
1456
|
+
path: '/verify-magic-link',
|
|
1457
|
+
tags: ['client'],
|
|
1458
|
+
summary: 'Verify magic link token',
|
|
1459
|
+
request: {
|
|
1460
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
1461
|
+
token: z.string(),
|
|
1462
|
+
}).passthrough() } }, required: true },
|
|
1463
|
+
},
|
|
1464
|
+
responses: {
|
|
1465
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1466
|
+
400: { description: 'Invalid or expired token', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1467
|
+
},
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
authRoute.openapi(verifyMagicLink, async (c) => {
|
|
1471
|
+
const body = await c.req.json<{ token: string }>();
|
|
1472
|
+
if (!body.token) throw new EdgeBaseError(400, 'Magic link token is required.', undefined, 'invalid-input');
|
|
1473
|
+
|
|
1474
|
+
await ensureAuthActionAllowed(c, 'verifyMagicLink', body as unknown as Record<string, unknown>);
|
|
1475
|
+
|
|
1476
|
+
const db = getAuthDb(c);
|
|
1477
|
+
const ip = getClientIP(c.env, c.req.raw);
|
|
1478
|
+
|
|
1479
|
+
// Look up token directly in D1
|
|
1480
|
+
const tokenRow = await authService.getEmailToken(db, body.token);
|
|
1481
|
+
if (!tokenRow || tokenRow.type !== 'magic-link') {
|
|
1482
|
+
throw new EdgeBaseError(400, 'Invalid or expired magic link token.', undefined, 'invalid-token');
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (new Date(tokenRow.expiresAt as string) < new Date()) {
|
|
1486
|
+
await authService.deleteEmailToken(db, body.token);
|
|
1487
|
+
throw new EdgeBaseError(400, 'Magic link has expired. Please request a new one.', undefined, 'token-expired');
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const userId = tokenRow.userId as string;
|
|
1491
|
+
const user = await authService.getUserById(db, userId);
|
|
1492
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
1493
|
+
|
|
1494
|
+
// Disabled user check
|
|
1495
|
+
if (user.disabled === 1) {
|
|
1496
|
+
throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// Mark email as verified if not already
|
|
1500
|
+
if (!user.verified) {
|
|
1501
|
+
await authService.updateUser(db, userId, { verified: true });
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// beforeSignIn hook
|
|
1505
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforeSignIn', authService.sanitizeUser(user), { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
1506
|
+
|
|
1507
|
+
// Delete the token (single-use)
|
|
1508
|
+
await authService.deleteEmailToken(db, body.token);
|
|
1509
|
+
|
|
1510
|
+
// Lazy cleanup of expired sessions
|
|
1511
|
+
await authService.cleanExpiredSessionsForUser(db, userId);
|
|
1512
|
+
|
|
1513
|
+
// Create session
|
|
1514
|
+
const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
|
|
1515
|
+
|
|
1516
|
+
// Re-read user (verified flag may have been updated)
|
|
1517
|
+
const updatedUser = await authService.getUserById(db, userId) || user;
|
|
1518
|
+
|
|
1519
|
+
// afterSignIn hook — non-blocking
|
|
1520
|
+
c.executionCtx.waitUntil(
|
|
1521
|
+
executeAuthHook(c.env, c.executionCtx, 'afterSignIn', authService.sanitizeUser(updatedUser), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
1522
|
+
);
|
|
1523
|
+
|
|
1524
|
+
return c.json({
|
|
1525
|
+
user: authService.sanitizeUser(updatedUser),
|
|
1526
|
+
accessToken: session.accessToken,
|
|
1527
|
+
refreshToken: session.refreshToken,
|
|
1528
|
+
});
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
// ─── Phone/SMS OTP Routes ───
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* E.164 phone number normalization.
|
|
1535
|
+
* Strips whitespace, dashes, parentheses. Must start with '+'.
|
|
1536
|
+
*/
|
|
1537
|
+
function normalizePhone(phone: string): string {
|
|
1538
|
+
const cleaned = phone.replace(/[\s\-()]/g, '');
|
|
1539
|
+
if (!/^\+[1-9]\d{6,14}$/.test(cleaned)) {
|
|
1540
|
+
throw new EdgeBaseError(400, 'Invalid phone number. Must be in E.164 format (e.g. +15551234567).', undefined, 'invalid-phone');
|
|
1541
|
+
}
|
|
1542
|
+
return cleaned;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function getPhoneAuthEnabled(env: Env): boolean {
|
|
1546
|
+
try {
|
|
1547
|
+
const config = parseConfig(env);
|
|
1548
|
+
return !!config?.auth?.phoneAuth;
|
|
1549
|
+
} catch {
|
|
1550
|
+
return false;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// POST /signin/phone — send OTP SMS
|
|
1555
|
+
const signinPhone = createRoute({
|
|
1556
|
+
operationId: 'authSigninPhone',
|
|
1557
|
+
method: 'post',
|
|
1558
|
+
path: '/signin/phone',
|
|
1559
|
+
tags: ['client'],
|
|
1560
|
+
summary: 'Send OTP SMS to phone number',
|
|
1561
|
+
request: {
|
|
1562
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
1563
|
+
phone: z.string(),
|
|
1564
|
+
}).passthrough() } }, required: true },
|
|
1565
|
+
},
|
|
1566
|
+
responses: {
|
|
1567
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1568
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1569
|
+
404: { description: 'Phone auth not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1570
|
+
429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1571
|
+
},
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
authRoute.openapi(signinPhone, async (c) => {
|
|
1575
|
+
if (!getPhoneAuthEnabled(c.env)) {
|
|
1576
|
+
throw new EdgeBaseError(404, 'Phone authentication is not enabled.', undefined, 'feature-not-enabled');
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
const body = await c.req.json<{ phone: string }>();
|
|
1580
|
+
if (!body.phone) throw new EdgeBaseError(400, 'Phone number is required.', undefined, 'invalid-input');
|
|
1581
|
+
const phone = normalizePhone(body.phone);
|
|
1582
|
+
|
|
1583
|
+
await ensureAuthActionAllowed(c, 'signInPhone', { phone });
|
|
1584
|
+
|
|
1585
|
+
// Rate limit per phone: max 5 OTPs per hour
|
|
1586
|
+
const phoneRateKey = `phone-rate:${phone}`;
|
|
1587
|
+
if (!counter.check(phoneRateKey, 5, 3600)) {
|
|
1588
|
+
throw new EdgeBaseError(429, 'Too many OTP requests for this phone number. Try again later.', undefined, 'rate-limited');
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// Look up phone in D1
|
|
1592
|
+
const record = await lookupPhone(getAuthDb(c), phone);
|
|
1593
|
+
|
|
1594
|
+
let devCode: string | undefined;
|
|
1595
|
+
|
|
1596
|
+
const db = getAuthDb(c);
|
|
1597
|
+
|
|
1598
|
+
if (record) {
|
|
1599
|
+
// Existing user — send OTP directly
|
|
1600
|
+
const { userId } = record;
|
|
1601
|
+
const user = await authService.getUserById(db, userId);
|
|
1602
|
+
if (!user) return c.json({ ok: true });
|
|
1603
|
+
|
|
1604
|
+
const code = generateOTP();
|
|
1605
|
+
|
|
1606
|
+
// Store OTP in KV with 5 min TTL
|
|
1607
|
+
await c.env.KV.put(
|
|
1608
|
+
`phone-otp:${phone}`,
|
|
1609
|
+
JSON.stringify({ code, userId, attempts: 0 }),
|
|
1610
|
+
{ expirationTtl: 300 },
|
|
1611
|
+
);
|
|
1612
|
+
|
|
1613
|
+
// Send SMS
|
|
1614
|
+
const smsProvider = createSmsProvider(getSmsConfig(c.env), c.env);
|
|
1615
|
+
if (smsProvider) {
|
|
1616
|
+
const appName = getAppName(c.env);
|
|
1617
|
+
await sendSmsWithHook(
|
|
1618
|
+
c.env,
|
|
1619
|
+
c.executionCtx,
|
|
1620
|
+
smsProvider,
|
|
1621
|
+
'phoneOtp',
|
|
1622
|
+
phone,
|
|
1623
|
+
`Your ${appName} verification code is: ${code}. Valid for 5 minutes.`,
|
|
1624
|
+
);
|
|
1625
|
+
} else {
|
|
1626
|
+
const release = parseConfig(c.env)?.release ?? false;
|
|
1627
|
+
if (!release) {
|
|
1628
|
+
console.warn('[Phone] SMS provider not configured. OTP:', code);
|
|
1629
|
+
devCode = code;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
} else {
|
|
1633
|
+
// New user — auto-create
|
|
1634
|
+
const userId = generateId();
|
|
1635
|
+
|
|
1636
|
+
try {
|
|
1637
|
+
await registerPhonePending(db, phone, userId);
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
if ((err as Error).message === 'PHONE_ALREADY_REGISTERED') {
|
|
1640
|
+
return c.json({ ok: true });
|
|
1641
|
+
}
|
|
1642
|
+
throw new EdgeBaseError(500, 'Phone OTP request failed.', undefined, 'internal-error');
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
try {
|
|
1646
|
+
// Create user with phone in D1
|
|
1647
|
+
await authService.createUser(db, {
|
|
1648
|
+
userId,
|
|
1649
|
+
email: null,
|
|
1650
|
+
passwordHash: '',
|
|
1651
|
+
role: 'user',
|
|
1652
|
+
verified: true,
|
|
1653
|
+
});
|
|
1654
|
+
await authService.updateUser(db, userId, { phone, phoneVerified: false });
|
|
1655
|
+
|
|
1656
|
+
const code = generateOTP();
|
|
1657
|
+
|
|
1658
|
+
// Store OTP in KV
|
|
1659
|
+
await c.env.KV.put(
|
|
1660
|
+
`phone-otp:${phone}`,
|
|
1661
|
+
JSON.stringify({ code, userId, attempts: 0 }),
|
|
1662
|
+
{ expirationTtl: 300 },
|
|
1663
|
+
);
|
|
1664
|
+
|
|
1665
|
+
// Send SMS
|
|
1666
|
+
const smsProvider = createSmsProvider(getSmsConfig(c.env), c.env);
|
|
1667
|
+
if (smsProvider) {
|
|
1668
|
+
const appName = getAppName(c.env);
|
|
1669
|
+
await sendSmsWithHook(
|
|
1670
|
+
c.env,
|
|
1671
|
+
c.executionCtx,
|
|
1672
|
+
smsProvider,
|
|
1673
|
+
'phoneOtp',
|
|
1674
|
+
phone,
|
|
1675
|
+
`Your ${appName} verification code is: ${code}. Valid for 5 minutes.`,
|
|
1676
|
+
);
|
|
1677
|
+
} else {
|
|
1678
|
+
const release = parseConfig(c.env)?.release ?? false;
|
|
1679
|
+
if (!release) {
|
|
1680
|
+
console.warn('[Phone] SMS provider not configured. OTP:', code);
|
|
1681
|
+
devCode = code;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
await confirmPhone(db, phone, userId);
|
|
1686
|
+
} catch (err) {
|
|
1687
|
+
if (err instanceof EdgeBaseError) throw err;
|
|
1688
|
+
return c.json({ ok: true });
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// Return OTP code only in dev mode (SMS provider not configured) for testing
|
|
1693
|
+
const release = parseConfig(c.env)?.release ?? false;
|
|
1694
|
+
return c.json(devCode && !release ? { ok: true, code: devCode } : { ok: true });
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
// POST /verify-phone — verify OTP → create session
|
|
1698
|
+
const verifyPhone = createRoute({
|
|
1699
|
+
operationId: 'authVerifyPhone',
|
|
1700
|
+
method: 'post',
|
|
1701
|
+
path: '/verify-phone',
|
|
1702
|
+
tags: ['client'],
|
|
1703
|
+
summary: 'Verify phone OTP and create session',
|
|
1704
|
+
request: {
|
|
1705
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
1706
|
+
phone: z.string(),
|
|
1707
|
+
code: z.string(),
|
|
1708
|
+
}).passthrough() } }, required: true },
|
|
1709
|
+
},
|
|
1710
|
+
responses: {
|
|
1711
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1712
|
+
400: { description: 'Invalid or expired OTP', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1713
|
+
401: { description: 'Invalid OTP code', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1714
|
+
404: { description: 'Phone auth not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1715
|
+
429: { description: 'Too many attempts', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1716
|
+
},
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
authRoute.openapi(verifyPhone, async (c) => {
|
|
1720
|
+
if (!getPhoneAuthEnabled(c.env)) {
|
|
1721
|
+
throw new EdgeBaseError(404, 'Phone authentication is not enabled.', undefined, 'feature-not-enabled');
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
const body = await c.req.json<{ phone: string; code: string }>();
|
|
1725
|
+
if (!body.phone || !body.code) {
|
|
1726
|
+
throw new EdgeBaseError(400, 'Phone number and OTP code are required.', undefined, 'invalid-input');
|
|
1727
|
+
}
|
|
1728
|
+
const phone = normalizePhone(body.phone);
|
|
1729
|
+
|
|
1730
|
+
await ensureAuthActionAllowed(c, 'verifyPhoneOtp', {
|
|
1731
|
+
phone,
|
|
1732
|
+
code: body.code,
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
// Look up phone → userId via KV OTP data
|
|
1736
|
+
const otpData = await c.env.KV.get(`phone-otp:${phone}`, 'json') as {
|
|
1737
|
+
code: string; userId: string; attempts: number;
|
|
1738
|
+
} | null;
|
|
1739
|
+
|
|
1740
|
+
if (!otpData) {
|
|
1741
|
+
throw new EdgeBaseError(400, 'Invalid or expired OTP. Please request a new code.', undefined, 'invalid-token');
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// Check attempts (max 5)
|
|
1745
|
+
if (otpData.attempts >= 5) {
|
|
1746
|
+
await c.env.KV.delete(`phone-otp:${phone}`).catch(() => {});
|
|
1747
|
+
throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Verify code (timing-safe comparison)
|
|
1751
|
+
if (!timingSafeEqual(otpData.code, body.code)) {
|
|
1752
|
+
await c.env.KV.put(
|
|
1753
|
+
`phone-otp:${phone}`,
|
|
1754
|
+
JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
|
|
1755
|
+
{ expirationTtl: 300 },
|
|
1756
|
+
).catch(() => {});
|
|
1757
|
+
throw new EdgeBaseError(401, 'Invalid OTP code.', undefined, 'invalid-otp');
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// OTP valid — delete it (single-use)
|
|
1761
|
+
await c.env.KV.delete(`phone-otp:${phone}`).catch(() => {});
|
|
1762
|
+
|
|
1763
|
+
const { userId } = otpData;
|
|
1764
|
+
const ip = getClientIP(c.env, c.req.raw);
|
|
1765
|
+
const db = getAuthDb(c);
|
|
1766
|
+
|
|
1767
|
+
const user = await authService.getUserById(db, userId);
|
|
1768
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
1769
|
+
|
|
1770
|
+
// Disabled user check
|
|
1771
|
+
if (user.disabled === 1) {
|
|
1772
|
+
throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// Mark phone as verified if not already
|
|
1776
|
+
if (user.phoneVerified !== 1) {
|
|
1777
|
+
await authService.updateUser(db, userId, { phoneVerified: true });
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// beforeSignIn hook
|
|
1781
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforeSignIn', authService.sanitizeUser(user), { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
1782
|
+
|
|
1783
|
+
// MFA Check
|
|
1784
|
+
const mfaConfig = getMfaConfig(c.env);
|
|
1785
|
+
if (mfaConfig?.totp) {
|
|
1786
|
+
const factors = await authService.listVerifiedMfaFactors(db, userId);
|
|
1787
|
+
if (factors.length > 0) {
|
|
1788
|
+
const mfaTicket = crypto.randomUUID();
|
|
1789
|
+
await c.env.KV.put(
|
|
1790
|
+
`mfa-ticket:${mfaTicket}`,
|
|
1791
|
+
JSON.stringify({ userId }),
|
|
1792
|
+
{ expirationTtl: 300 },
|
|
1793
|
+
);
|
|
1794
|
+
return c.json({
|
|
1795
|
+
mfaRequired: true,
|
|
1796
|
+
mfaTicket,
|
|
1797
|
+
factors: factors.map((f: Record<string, unknown>) => ({ id: f.id, type: f.type })),
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// Create session
|
|
1803
|
+
const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
|
|
1804
|
+
|
|
1805
|
+
// Re-fetch user after phoneVerified update
|
|
1806
|
+
const updatedUser = await authService.getUserById(db, userId) || user;
|
|
1807
|
+
|
|
1808
|
+
// afterSignIn hook — non-blocking
|
|
1809
|
+
c.executionCtx.waitUntil(
|
|
1810
|
+
executeAuthHook(c.env, c.executionCtx, 'afterSignIn', authService.sanitizeUser(updatedUser), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
1811
|
+
);
|
|
1812
|
+
|
|
1813
|
+
return c.json({
|
|
1814
|
+
user: authService.sanitizeUser(updatedUser),
|
|
1815
|
+
accessToken: session.accessToken,
|
|
1816
|
+
refreshToken: session.refreshToken,
|
|
1817
|
+
});
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1820
|
+
// POST /link/phone — link phone to existing account (authenticated)
|
|
1821
|
+
const linkPhone = createRoute({
|
|
1822
|
+
operationId: 'authLinkPhone',
|
|
1823
|
+
method: 'post',
|
|
1824
|
+
path: '/link/phone',
|
|
1825
|
+
tags: ['client'],
|
|
1826
|
+
summary: 'Link phone number to existing account',
|
|
1827
|
+
request: {
|
|
1828
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
1829
|
+
phone: z.string(),
|
|
1830
|
+
}).passthrough() } }, required: true },
|
|
1831
|
+
},
|
|
1832
|
+
responses: {
|
|
1833
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1834
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1835
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1836
|
+
404: { description: 'Phone auth not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1837
|
+
409: { description: 'Phone already registered', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1838
|
+
429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1839
|
+
},
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
authRoute.openapi(linkPhone, async (c) => {
|
|
1843
|
+
if (!getPhoneAuthEnabled(c.env)) {
|
|
1844
|
+
throw new EdgeBaseError(404, 'Phone authentication is not enabled.', undefined, 'feature-not-enabled');
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
const userId = requireAuth(c.get('auth'));
|
|
1848
|
+
const body = await c.req.json<{ phone: string }>();
|
|
1849
|
+
if (!body.phone) throw new EdgeBaseError(400, 'Phone number is required.', undefined, 'invalid-input');
|
|
1850
|
+
const phone = normalizePhone(body.phone);
|
|
1851
|
+
await ensureAuthActionAllowed(c, 'linkPhone', { phone, userId });
|
|
1852
|
+
const db = getAuthDb(c);
|
|
1853
|
+
|
|
1854
|
+
// Rate limit per phone
|
|
1855
|
+
const phoneRateKey = `phone-rate:${phone}`;
|
|
1856
|
+
if (!counter.check(phoneRateKey, 5, 3600)) {
|
|
1857
|
+
throw new EdgeBaseError(429, 'Too many OTP requests. Try again later.', undefined, 'rate-limited');
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// Check if phone is already registered
|
|
1861
|
+
const existing = await lookupPhone(db, phone);
|
|
1862
|
+
if (existing) {
|
|
1863
|
+
throw new EdgeBaseError(409, 'Phone number is already registered to another account.', undefined, 'phone-already-exists');
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// Check if user already has a phone
|
|
1867
|
+
const user = await authService.getUserById(db, userId);
|
|
1868
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
1869
|
+
if (user.phone) {
|
|
1870
|
+
throw new EdgeBaseError(409, 'User already has a phone number linked.', undefined, 'already-exists');
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
const code = generateOTP();
|
|
1874
|
+
|
|
1875
|
+
// Store link OTP in KV (separate key pattern)
|
|
1876
|
+
await c.env.KV.put(
|
|
1877
|
+
`phone-link-otp:${phone}`,
|
|
1878
|
+
JSON.stringify({ code, userId, attempts: 0 }),
|
|
1879
|
+
{ expirationTtl: 300 },
|
|
1880
|
+
);
|
|
1881
|
+
|
|
1882
|
+
// Send SMS
|
|
1883
|
+
const smsProvider = createSmsProvider(getSmsConfig(c.env), c.env);
|
|
1884
|
+
if (smsProvider) {
|
|
1885
|
+
const appName = getAppName(c.env);
|
|
1886
|
+
await sendSmsWithHook(
|
|
1887
|
+
c.env,
|
|
1888
|
+
c.executionCtx,
|
|
1889
|
+
smsProvider,
|
|
1890
|
+
'phoneLink',
|
|
1891
|
+
phone,
|
|
1892
|
+
`Your ${appName} phone linking code is: ${code}. Valid for 5 minutes.`,
|
|
1893
|
+
);
|
|
1894
|
+
return c.json({ ok: true });
|
|
1895
|
+
} else {
|
|
1896
|
+
const release = parseConfig(c.env)?.release ?? false;
|
|
1897
|
+
if (!release) {
|
|
1898
|
+
console.warn('[Phone] SMS provider not configured. Link OTP:', code);
|
|
1899
|
+
return c.json({ ok: true, code });
|
|
1900
|
+
}
|
|
1901
|
+
return c.json({ ok: true });
|
|
1902
|
+
}
|
|
1903
|
+
});
|
|
1904
|
+
|
|
1905
|
+
// POST /verify-link-phone — verify OTP and link phone to account (authenticated)
|
|
1906
|
+
const verifyLinkPhone = createRoute({
|
|
1907
|
+
operationId: 'authVerifyLinkPhone',
|
|
1908
|
+
method: 'post',
|
|
1909
|
+
path: '/verify-link-phone',
|
|
1910
|
+
tags: ['client'],
|
|
1911
|
+
summary: 'Verify OTP and link phone to account',
|
|
1912
|
+
request: {
|
|
1913
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
1914
|
+
phone: z.string(),
|
|
1915
|
+
code: z.string(),
|
|
1916
|
+
}).passthrough() } }, required: true },
|
|
1917
|
+
},
|
|
1918
|
+
responses: {
|
|
1919
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1920
|
+
400: { description: 'Invalid or expired OTP', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1921
|
+
401: { description: 'Invalid OTP code', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1922
|
+
403: { description: 'OTP not issued for this user', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1923
|
+
404: { description: 'Phone auth not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1924
|
+
409: { description: 'Phone already registered', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1925
|
+
429: { description: 'Too many attempts', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1926
|
+
},
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
authRoute.openapi(verifyLinkPhone, async (c) => {
|
|
1930
|
+
if (!getPhoneAuthEnabled(c.env)) {
|
|
1931
|
+
throw new EdgeBaseError(404, 'Phone authentication is not enabled.', undefined, 'feature-not-enabled');
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
const userId = requireAuth(c.get('auth'));
|
|
1935
|
+
const body = await c.req.json<{ phone: string; code: string }>();
|
|
1936
|
+
if (!body.phone || !body.code) {
|
|
1937
|
+
throw new EdgeBaseError(400, 'Phone number and OTP code are required.', undefined, 'invalid-input');
|
|
1938
|
+
}
|
|
1939
|
+
const phone = normalizePhone(body.phone);
|
|
1940
|
+
await ensureAuthActionAllowed(c, 'verifyLinkPhone', { phone, code: body.code, userId });
|
|
1941
|
+
const db = getAuthDb(c);
|
|
1942
|
+
|
|
1943
|
+
// Verify OTP from KV
|
|
1944
|
+
const otpData = await c.env.KV.get(`phone-link-otp:${phone}`, 'json') as {
|
|
1945
|
+
code: string; userId: string; attempts: number;
|
|
1946
|
+
} | null;
|
|
1947
|
+
|
|
1948
|
+
if (!otpData) {
|
|
1949
|
+
throw new EdgeBaseError(400, 'Invalid or expired OTP. Please request a new code.', undefined, 'invalid-token');
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
if (otpData.userId !== userId) {
|
|
1953
|
+
throw new EdgeBaseError(403, 'OTP was not issued for this user.', undefined, 'action-not-allowed');
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
if (otpData.attempts >= 5) {
|
|
1957
|
+
await c.env.KV.delete(`phone-link-otp:${phone}`).catch(() => {});
|
|
1958
|
+
throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
if (!timingSafeEqual(otpData.code, body.code)) {
|
|
1962
|
+
await c.env.KV.put(
|
|
1963
|
+
`phone-link-otp:${phone}`,
|
|
1964
|
+
JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
|
|
1965
|
+
{ expirationTtl: 300 },
|
|
1966
|
+
).catch(() => {});
|
|
1967
|
+
throw new EdgeBaseError(401, 'Invalid OTP code.', undefined, 'invalid-otp');
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// OTP valid — delete it
|
|
1971
|
+
await c.env.KV.delete(`phone-link-otp:${phone}`).catch(() => {});
|
|
1972
|
+
|
|
1973
|
+
// Register phone in D1
|
|
1974
|
+
try {
|
|
1975
|
+
await registerPhonePending(db, phone, userId);
|
|
1976
|
+
} catch (err) {
|
|
1977
|
+
if ((err as Error).message === 'PHONE_ALREADY_REGISTERED') {
|
|
1978
|
+
throw new EdgeBaseError(409, 'Phone number is already registered.', undefined, 'phone-already-exists');
|
|
1979
|
+
}
|
|
1980
|
+
throw new EdgeBaseError(500, 'Phone linking failed.', undefined, 'internal-error');
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// Update user record directly in D1
|
|
1984
|
+
await authService.updateUser(db, userId, { phone, phoneVerified: true, isAnonymous: false });
|
|
1985
|
+
|
|
1986
|
+
// Confirm in D1
|
|
1987
|
+
await confirmPhone(db, phone, userId);
|
|
1988
|
+
|
|
1989
|
+
// Delete anon index if exists (upgrade path)
|
|
1990
|
+
await deleteAnon(db, userId).catch(() => {});
|
|
1991
|
+
|
|
1992
|
+
return c.json({ ok: true });
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
// ─── Email OTP Routes ───
|
|
1996
|
+
|
|
1997
|
+
function getEmailOtpEnabled(env: Env): boolean {
|
|
1998
|
+
try {
|
|
1999
|
+
const config = parseConfig(env);
|
|
2000
|
+
return !!config?.auth?.emailOtp?.enabled;
|
|
2001
|
+
} catch {
|
|
2002
|
+
return false;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// POST /signin/email-otp — send OTP code to email
|
|
2007
|
+
const signinEmailOtp = createRoute({
|
|
2008
|
+
operationId: 'authSigninEmailOtp',
|
|
2009
|
+
method: 'post',
|
|
2010
|
+
path: '/signin/email-otp',
|
|
2011
|
+
tags: ['client'],
|
|
2012
|
+
summary: 'Send OTP code to email',
|
|
2013
|
+
request: {
|
|
2014
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
2015
|
+
email: z.string(),
|
|
2016
|
+
}).passthrough() } }, required: true },
|
|
2017
|
+
},
|
|
2018
|
+
responses: {
|
|
2019
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2020
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2021
|
+
404: { description: 'Email OTP not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2022
|
+
429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2023
|
+
},
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
authRoute.openapi(signinEmailOtp, async (c) => {
|
|
2027
|
+
if (!getEmailOtpEnabled(c.env)) {
|
|
2028
|
+
throw new EdgeBaseError(404, 'Email OTP authentication is not enabled.', undefined, 'feature-not-enabled');
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
const body = await c.req.json<{ email: string }>();
|
|
2032
|
+
if (!body.email) throw new EdgeBaseError(400, 'Email is required.', undefined, 'invalid-input');
|
|
2033
|
+
const email = body.email.trim().toLowerCase();
|
|
2034
|
+
|
|
2035
|
+
// Basic email validation
|
|
2036
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
2037
|
+
throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
await ensureAuthActionAllowed(c, 'signInEmailOtp', { email });
|
|
2041
|
+
|
|
2042
|
+
// Rate limit per email: max 5 OTPs per hour
|
|
2043
|
+
const emailRateKey = `email-otp-rate:${email}`;
|
|
2044
|
+
if (!counter.check(emailRateKey, 5, 3600)) {
|
|
2045
|
+
throw new EdgeBaseError(429, 'Too many OTP requests for this email. Try again later.', undefined, 'rate-limited');
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
// Look up email in D1
|
|
2049
|
+
const record = await lookupEmail(getAuthDb(c), email);
|
|
2050
|
+
|
|
2051
|
+
let devCode: string | undefined;
|
|
2052
|
+
|
|
2053
|
+
const db = getAuthDb(c);
|
|
2054
|
+
|
|
2055
|
+
if (record) {
|
|
2056
|
+
// Existing user — send OTP directly
|
|
2057
|
+
const { userId } = record;
|
|
2058
|
+
const user = await authService.getUserById(db, userId);
|
|
2059
|
+
if (!user) return c.json({ ok: true });
|
|
2060
|
+
|
|
2061
|
+
const code = generateOTP();
|
|
2062
|
+
|
|
2063
|
+
// Store OTP in KV with 5 min TTL
|
|
2064
|
+
await c.env.KV.put(
|
|
2065
|
+
`email-otp:${email}`,
|
|
2066
|
+
JSON.stringify({ code, userId, attempts: 0 }),
|
|
2067
|
+
{ expirationTtl: 300 },
|
|
2068
|
+
);
|
|
2069
|
+
|
|
2070
|
+
// Send email
|
|
2071
|
+
const emailProvider = createEmailProvider(getEmailConfig(c.env), c.env);
|
|
2072
|
+
if (emailProvider) {
|
|
2073
|
+
const appName = getAppName(c.env);
|
|
2074
|
+
const locale = resolveEmailLocale(c.env, user.locale as string | null, parseAcceptLanguage(c.req.header('accept-language')));
|
|
2075
|
+
const html = renderEmailOtp({ appName, code, expiresInMinutes: 5 }, resolveLocalizedString(getEmailTemplates(c.env)?.emailOtp, locale), locale);
|
|
2076
|
+
const defaultSubject = getDefaultSubject(locale, 'emailOtp').replace(/\{\{appName\}\}/g, appName);
|
|
2077
|
+
await sendMailWithHook(
|
|
2078
|
+
c.env, c.executionCtx, emailProvider, 'emailOtp', email,
|
|
2079
|
+
resolveSubject(c.env, 'emailOtp', defaultSubject, locale), html, locale,
|
|
2080
|
+
);
|
|
2081
|
+
} else {
|
|
2082
|
+
const release = parseConfig(c.env)?.release ?? false;
|
|
2083
|
+
if (!release) {
|
|
2084
|
+
console.warn('[EmailOTP] Email provider not configured. OTP:', code);
|
|
2085
|
+
devCode = code;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
} else {
|
|
2089
|
+
// New user — auto-create if enabled
|
|
2090
|
+
const config = parseConfig(c.env);
|
|
2091
|
+
const autoCreate = config?.auth?.emailOtp?.autoCreate !== false;
|
|
2092
|
+
if (!autoCreate) {
|
|
2093
|
+
return c.json({ ok: true });
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
const userId = generateId();
|
|
2097
|
+
|
|
2098
|
+
try {
|
|
2099
|
+
await registerEmailPending(db, email, userId);
|
|
2100
|
+
} catch (err) {
|
|
2101
|
+
if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
|
|
2102
|
+
return c.json({ ok: true });
|
|
2103
|
+
}
|
|
2104
|
+
throw new EdgeBaseError(500, 'Email OTP request failed.', undefined, 'internal-error');
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
try {
|
|
2108
|
+
// Create user with email, verified = 1
|
|
2109
|
+
const otpReqLocale = parseAcceptLanguage(c.req.header('accept-language'));
|
|
2110
|
+
await authService.createUser(db, {
|
|
2111
|
+
userId,
|
|
2112
|
+
email,
|
|
2113
|
+
passwordHash: '',
|
|
2114
|
+
role: 'user',
|
|
2115
|
+
verified: true,
|
|
2116
|
+
locale: otpReqLocale ?? 'en',
|
|
2117
|
+
});
|
|
2118
|
+
|
|
2119
|
+
const code = generateOTP();
|
|
2120
|
+
|
|
2121
|
+
// Store OTP in KV
|
|
2122
|
+
await c.env.KV.put(
|
|
2123
|
+
`email-otp:${email}`,
|
|
2124
|
+
JSON.stringify({ code, userId, attempts: 0 }),
|
|
2125
|
+
{ expirationTtl: 300 },
|
|
2126
|
+
);
|
|
2127
|
+
|
|
2128
|
+
// Send email
|
|
2129
|
+
const emailProvider = createEmailProvider(getEmailConfig(c.env), c.env);
|
|
2130
|
+
if (emailProvider) {
|
|
2131
|
+
const appName = getAppName(c.env);
|
|
2132
|
+
const locale = resolveEmailLocale(c.env, otpReqLocale);
|
|
2133
|
+
const html = renderEmailOtp({ appName, code, expiresInMinutes: 5 }, resolveLocalizedString(getEmailTemplates(c.env)?.emailOtp, locale), locale);
|
|
2134
|
+
const defaultSubject = getDefaultSubject(locale, 'emailOtp').replace(/\{\{appName\}\}/g, appName);
|
|
2135
|
+
await sendMailWithHook(
|
|
2136
|
+
c.env, c.executionCtx, emailProvider, 'emailOtp', email,
|
|
2137
|
+
resolveSubject(c.env, 'emailOtp', defaultSubject, locale), html, locale,
|
|
2138
|
+
);
|
|
2139
|
+
} else {
|
|
2140
|
+
const release = parseConfig(c.env)?.release ?? false;
|
|
2141
|
+
if (!release) {
|
|
2142
|
+
console.warn('[EmailOTP] Email provider not configured. OTP:', code);
|
|
2143
|
+
devCode = code;
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
await confirmEmail(db, email, userId);
|
|
2148
|
+
} catch (err) {
|
|
2149
|
+
if (err instanceof EdgeBaseError) throw err;
|
|
2150
|
+
return c.json({ ok: true });
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// Return OTP code only in dev mode (email provider not configured) for testing
|
|
2155
|
+
const release = parseConfig(c.env)?.release ?? false;
|
|
2156
|
+
return c.json(devCode && !release ? { ok: true, code: devCode } : { ok: true });
|
|
2157
|
+
});
|
|
2158
|
+
|
|
2159
|
+
// POST /verify-email-otp — verify OTP → create session
|
|
2160
|
+
const verifyEmailOtp = createRoute({
|
|
2161
|
+
operationId: 'authVerifyEmailOtp',
|
|
2162
|
+
method: 'post',
|
|
2163
|
+
path: '/verify-email-otp',
|
|
2164
|
+
tags: ['client'],
|
|
2165
|
+
summary: 'Verify email OTP and create session',
|
|
2166
|
+
request: {
|
|
2167
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
2168
|
+
email: z.string(),
|
|
2169
|
+
code: z.string(),
|
|
2170
|
+
}).passthrough() } }, required: true },
|
|
2171
|
+
},
|
|
2172
|
+
responses: {
|
|
2173
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2174
|
+
400: { description: 'Invalid or expired OTP', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2175
|
+
401: { description: 'Invalid OTP code', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2176
|
+
404: { description: 'Email OTP not enabled', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2177
|
+
429: { description: 'Too many attempts', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2178
|
+
},
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2181
|
+
authRoute.openapi(verifyEmailOtp, async (c) => {
|
|
2182
|
+
if (!getEmailOtpEnabled(c.env)) {
|
|
2183
|
+
throw new EdgeBaseError(404, 'Email OTP authentication is not enabled.', undefined, 'feature-not-enabled');
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
const body = await c.req.json<{ email: string; code: string }>();
|
|
2187
|
+
if (!body.email || !body.code) {
|
|
2188
|
+
throw new EdgeBaseError(400, 'Email and OTP code are required.', undefined, 'invalid-input');
|
|
2189
|
+
}
|
|
2190
|
+
const email = body.email.trim().toLowerCase();
|
|
2191
|
+
|
|
2192
|
+
await ensureAuthActionAllowed(c, 'verifyEmailOtp', {
|
|
2193
|
+
email,
|
|
2194
|
+
code: body.code,
|
|
2195
|
+
});
|
|
2196
|
+
|
|
2197
|
+
// Look up OTP data from KV
|
|
2198
|
+
const otpData = await c.env.KV.get(`email-otp:${email}`, 'json') as {
|
|
2199
|
+
code: string; userId: string; attempts: number;
|
|
2200
|
+
} | null;
|
|
2201
|
+
|
|
2202
|
+
if (!otpData) {
|
|
2203
|
+
throw new EdgeBaseError(400, 'Invalid or expired OTP. Please request a new code.', undefined, 'invalid-token');
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
if (otpData.attempts >= 5) {
|
|
2207
|
+
await c.env.KV.delete(`email-otp:${email}`).catch(() => {});
|
|
2208
|
+
throw new EdgeBaseError(429, 'Too many failed OTP attempts. Please request a new code.', undefined, 'rate-limited');
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
if (!timingSafeEqual(otpData.code, body.code)) {
|
|
2212
|
+
await c.env.KV.put(
|
|
2213
|
+
`email-otp:${email}`,
|
|
2214
|
+
JSON.stringify({ ...otpData, attempts: otpData.attempts + 1 }),
|
|
2215
|
+
{ expirationTtl: 300 },
|
|
2216
|
+
).catch(() => {});
|
|
2217
|
+
throw new EdgeBaseError(401, 'Invalid OTP code.', undefined, 'invalid-otp');
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// OTP valid — delete it (single-use)
|
|
2221
|
+
await c.env.KV.delete(`email-otp:${email}`).catch(() => {});
|
|
2222
|
+
|
|
2223
|
+
const { userId } = otpData;
|
|
2224
|
+
const ip = getClientIP(c.env, c.req.raw);
|
|
2225
|
+
const db = getAuthDb(c);
|
|
2226
|
+
|
|
2227
|
+
const user = await authService.getUserById(db, userId);
|
|
2228
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
2229
|
+
|
|
2230
|
+
// Disabled user check
|
|
2231
|
+
if (user.disabled === 1) {
|
|
2232
|
+
throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// beforeSignIn hook
|
|
2236
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforeSignIn', authService.sanitizeUser(user), { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
2237
|
+
|
|
2238
|
+
// MFA Check
|
|
2239
|
+
const mfaConfig = getMfaConfig(c.env);
|
|
2240
|
+
if (mfaConfig?.totp) {
|
|
2241
|
+
const factors = await authService.listVerifiedMfaFactors(db, userId);
|
|
2242
|
+
if (factors.length > 0) {
|
|
2243
|
+
const mfaTicket = crypto.randomUUID();
|
|
2244
|
+
await c.env.KV.put(
|
|
2245
|
+
`mfa-ticket:${mfaTicket}`,
|
|
2246
|
+
JSON.stringify({ userId }),
|
|
2247
|
+
{ expirationTtl: 300 },
|
|
2248
|
+
);
|
|
2249
|
+
return c.json({
|
|
2250
|
+
mfaRequired: true,
|
|
2251
|
+
mfaTicket,
|
|
2252
|
+
factors: factors.map((f: Record<string, unknown>) => ({ id: f.id, type: f.type })),
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
// Create session
|
|
2258
|
+
const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
|
|
2259
|
+
|
|
2260
|
+
// afterSignIn hook — non-blocking
|
|
2261
|
+
c.executionCtx.waitUntil(
|
|
2262
|
+
executeAuthHook(c.env, c.executionCtx, 'afterSignIn', authService.sanitizeUser(user), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
2263
|
+
);
|
|
2264
|
+
|
|
2265
|
+
return c.json({
|
|
2266
|
+
user: authService.sanitizeUser(user),
|
|
2267
|
+
accessToken: session.accessToken,
|
|
2268
|
+
refreshToken: session.refreshToken,
|
|
2269
|
+
});
|
|
2270
|
+
});
|
|
2271
|
+
|
|
2272
|
+
// ─── MFA/TOTP Routes ───
|
|
2273
|
+
|
|
2274
|
+
// POST /mfa/totp/enroll — enroll new TOTP factor (authenticated)
|
|
2275
|
+
const mfaTotpEnroll = createRoute({
|
|
2276
|
+
operationId: 'authMfaTotpEnroll',
|
|
2277
|
+
method: 'post',
|
|
2278
|
+
path: '/mfa/totp/enroll',
|
|
2279
|
+
tags: ['client'],
|
|
2280
|
+
summary: 'Enroll new TOTP factor',
|
|
2281
|
+
responses: {
|
|
2282
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2283
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2284
|
+
},
|
|
2285
|
+
});
|
|
2286
|
+
|
|
2287
|
+
authRoute.openapi(mfaTotpEnroll, async (c) => {
|
|
2288
|
+
const userId = requireAuth(c.get('auth'));
|
|
2289
|
+
await ensureAuthActionAllowed(c, 'mfaTotpEnroll', { userId });
|
|
2290
|
+
const db = getAuthDb(c);
|
|
2291
|
+
|
|
2292
|
+
const mfaCfg = getMfaConfig(c.env);
|
|
2293
|
+
if (!mfaCfg?.totp) throw new EdgeBaseError(404, 'TOTP MFA is not enabled.', undefined, 'feature-not-enabled');
|
|
2294
|
+
|
|
2295
|
+
// Check if user already has a verified TOTP factor
|
|
2296
|
+
const existing = await authService.getMfaFactorByUser(db, userId, 'totp');
|
|
2297
|
+
if (existing && existing.verified) {
|
|
2298
|
+
throw new EdgeBaseError(409, 'TOTP factor already enrolled. Disable it first to re-enroll.', undefined, 'mfa-already-enrolled');
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// Delete any unverified (pending) factors
|
|
2302
|
+
await authService.deleteUnverifiedMfaFactors(db, userId, 'totp');
|
|
2303
|
+
|
|
2304
|
+
// Generate TOTP secret
|
|
2305
|
+
const secret = generateTOTPSecret();
|
|
2306
|
+
const encryptedSecret = await encryptSecret(secret, getUserSecret(c.env));
|
|
2307
|
+
|
|
2308
|
+
// Get user email for QR code URI
|
|
2309
|
+
const user = await authService.getUserById(db, userId);
|
|
2310
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
2311
|
+
|
|
2312
|
+
const appName = getAppName(c.env);
|
|
2313
|
+
const qrCodeUri = generateTOTPUri(secret, (user.email as string) || userId, appName);
|
|
2314
|
+
|
|
2315
|
+
// Create factor (unverified)
|
|
2316
|
+
const factorId = generateId();
|
|
2317
|
+
await authService.createMfaFactor(db, {
|
|
2318
|
+
id: factorId,
|
|
2319
|
+
userId,
|
|
2320
|
+
type: 'totp',
|
|
2321
|
+
secret: encryptedSecret,
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
// Generate recovery codes
|
|
2325
|
+
const recoveryCodes = generateRecoveryCodes(8);
|
|
2326
|
+
const hashedCodes: { id: string; codeHash: string }[] = [];
|
|
2327
|
+
for (const code of recoveryCodes) {
|
|
2328
|
+
hashedCodes.push({ id: generateId(), codeHash: await hashRecoveryCode(code) });
|
|
2329
|
+
}
|
|
2330
|
+
await authService.createRecoveryCodes(db, userId, hashedCodes);
|
|
2331
|
+
|
|
2332
|
+
return c.json({
|
|
2333
|
+
factorId,
|
|
2334
|
+
secret,
|
|
2335
|
+
qrCodeUri,
|
|
2336
|
+
recoveryCodes,
|
|
2337
|
+
});
|
|
2338
|
+
});
|
|
2339
|
+
|
|
2340
|
+
// POST /mfa/totp/verify — confirm TOTP enrollment (authenticated)
|
|
2341
|
+
const mfaTotpVerify = createRoute({
|
|
2342
|
+
operationId: 'authMfaTotpVerify',
|
|
2343
|
+
method: 'post',
|
|
2344
|
+
path: '/mfa/totp/verify',
|
|
2345
|
+
tags: ['client'],
|
|
2346
|
+
summary: 'Confirm TOTP enrollment with code',
|
|
2347
|
+
request: {
|
|
2348
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
2349
|
+
factorId: z.string(),
|
|
2350
|
+
code: z.string(),
|
|
2351
|
+
}).passthrough() } }, required: true },
|
|
2352
|
+
},
|
|
2353
|
+
responses: {
|
|
2354
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2355
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2356
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2357
|
+
},
|
|
2358
|
+
});
|
|
2359
|
+
|
|
2360
|
+
authRoute.openapi(mfaTotpVerify, async (c) => {
|
|
2361
|
+
const userId = requireAuth(c.get('auth'));
|
|
2362
|
+
const body = await c.req.json<{ factorId: string; code: string }>();
|
|
2363
|
+
if (!body.factorId || !body.code) {
|
|
2364
|
+
throw new EdgeBaseError(400, 'factorId and code are required.', undefined, 'invalid-input');
|
|
2365
|
+
}
|
|
2366
|
+
await ensureAuthActionAllowed(c, 'mfaTotpVerify', {
|
|
2367
|
+
factorId: body.factorId,
|
|
2368
|
+
code: body.code,
|
|
2369
|
+
userId,
|
|
2370
|
+
});
|
|
2371
|
+
const db = getAuthDb(c);
|
|
2372
|
+
|
|
2373
|
+
const factor = await authService.getMfaFactorForUser(db, body.factorId, userId);
|
|
2374
|
+
if (!factor) throw new EdgeBaseError(404, 'TOTP factor not found.', undefined, 'not-found');
|
|
2375
|
+
if (factor.verified) throw new EdgeBaseError(400, 'TOTP factor is already verified.', undefined, 'mfa-already-enrolled');
|
|
2376
|
+
|
|
2377
|
+
// Decrypt and verify TOTP code
|
|
2378
|
+
const secret = await decryptSecret(factor.secret as string, getUserSecret(c.env));
|
|
2379
|
+
const valid = await verifyTOTP(secret, body.code);
|
|
2380
|
+
if (!valid) throw new EdgeBaseError(400, 'Invalid TOTP code. Please try again.', undefined, 'invalid-totp');
|
|
2381
|
+
|
|
2382
|
+
// Mark factor as verified
|
|
2383
|
+
await authService.verifyMfaFactor(db, body.factorId);
|
|
2384
|
+
|
|
2385
|
+
return c.json({ ok: true });
|
|
2386
|
+
});
|
|
2387
|
+
|
|
2388
|
+
// POST /mfa/verify — verify TOTP code during signin (mfaTicket-based)
|
|
2389
|
+
const mfaVerify = createRoute({
|
|
2390
|
+
operationId: 'authMfaVerify',
|
|
2391
|
+
method: 'post',
|
|
2392
|
+
path: '/mfa/verify',
|
|
2393
|
+
tags: ['client'],
|
|
2394
|
+
summary: 'Verify MFA code during signin',
|
|
2395
|
+
request: {
|
|
2396
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
2397
|
+
mfaTicket: z.string(),
|
|
2398
|
+
code: z.string(),
|
|
2399
|
+
}).passthrough() } }, required: true },
|
|
2400
|
+
},
|
|
2401
|
+
responses: {
|
|
2402
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2403
|
+
400: { description: 'Invalid or expired MFA ticket', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2404
|
+
401: { description: 'MFA verification failed', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2405
|
+
},
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
authRoute.openapi(mfaVerify, async (c) => {
|
|
2409
|
+
const body = await c.req.json<{ mfaTicket: string; code: string }>();
|
|
2410
|
+
if (!body.mfaTicket || !body.code) {
|
|
2411
|
+
throw new EdgeBaseError(400, 'mfaTicket and code are required.', undefined, 'invalid-input');
|
|
2412
|
+
}
|
|
2413
|
+
await ensureAuthActionAllowed(c, 'mfaVerify', {
|
|
2414
|
+
mfaTicket: body.mfaTicket,
|
|
2415
|
+
code: body.code,
|
|
2416
|
+
});
|
|
2417
|
+
|
|
2418
|
+
// Look up mfaTicket from KV
|
|
2419
|
+
const ticketData = await c.env.KV.get(`mfa-ticket:${body.mfaTicket}`, 'json') as {
|
|
2420
|
+
userId: string;
|
|
2421
|
+
} | null;
|
|
2422
|
+
|
|
2423
|
+
if (!ticketData) {
|
|
2424
|
+
throw new EdgeBaseError(400, 'Invalid or expired MFA ticket.', undefined, 'invalid-token');
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
const { userId } = ticketData;
|
|
2428
|
+
const ip = getClientIP(c.env, c.req.raw);
|
|
2429
|
+
const db = getAuthDb(c);
|
|
2430
|
+
|
|
2431
|
+
// Get verified TOTP factor
|
|
2432
|
+
const factor = await authService.getMfaFactorByUser(db, userId, 'totp');
|
|
2433
|
+
if (!factor || !factor.verified) throw new EdgeBaseError(400, 'No verified TOTP factor found.', undefined, 'invalid-input');
|
|
2434
|
+
|
|
2435
|
+
// Disabled check
|
|
2436
|
+
const user = await authService.getUserById(db, userId);
|
|
2437
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
2438
|
+
if (user.disabled === 1) {
|
|
2439
|
+
throw new EdgeBaseError(403, 'Account is disabled.', undefined, 'account-disabled');
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// Decrypt and verify
|
|
2443
|
+
const secret = await decryptSecret(factor.secret as string, getUserSecret(c.env));
|
|
2444
|
+
const valid = await verifyTOTP(secret, body.code);
|
|
2445
|
+
if (!valid) throw new EdgeBaseError(401, 'Invalid TOTP code.', undefined, 'invalid-totp');
|
|
2446
|
+
|
|
2447
|
+
// Delete mfaTicket (single-use)
|
|
2448
|
+
await c.env.KV.delete(`mfa-ticket:${body.mfaTicket}`).catch(() => {});
|
|
2449
|
+
|
|
2450
|
+
// MFA passed — create session
|
|
2451
|
+
await authService.cleanExpiredSessionsForUser(db, userId);
|
|
2452
|
+
const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
|
|
2453
|
+
|
|
2454
|
+
// afterSignIn hook — non-blocking
|
|
2455
|
+
c.executionCtx.waitUntil(
|
|
2456
|
+
executeAuthHook(c.env, c.executionCtx, 'afterSignIn', authService.sanitizeUser(user), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
2457
|
+
);
|
|
2458
|
+
|
|
2459
|
+
return c.json({
|
|
2460
|
+
user: authService.sanitizeUser(user),
|
|
2461
|
+
accessToken: session.accessToken,
|
|
2462
|
+
refreshToken: session.refreshToken,
|
|
2463
|
+
});
|
|
2464
|
+
});
|
|
2465
|
+
|
|
2466
|
+
// POST /mfa/recovery — use recovery code during signin (mfaTicket-based)
|
|
2467
|
+
const mfaRecovery = createRoute({
|
|
2468
|
+
operationId: 'authMfaRecovery',
|
|
2469
|
+
method: 'post',
|
|
2470
|
+
path: '/mfa/recovery',
|
|
2471
|
+
tags: ['client'],
|
|
2472
|
+
summary: 'Use recovery code during MFA signin',
|
|
2473
|
+
request: {
|
|
2474
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
2475
|
+
mfaTicket: z.string(),
|
|
2476
|
+
recoveryCode: z.string(),
|
|
2477
|
+
}).passthrough() } }, required: true },
|
|
2478
|
+
},
|
|
2479
|
+
responses: {
|
|
2480
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2481
|
+
400: { description: 'Invalid or expired MFA ticket', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2482
|
+
401: { description: 'Recovery code verification failed', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2483
|
+
},
|
|
2484
|
+
});
|
|
2485
|
+
|
|
2486
|
+
authRoute.openapi(mfaRecovery, async (c) => {
|
|
2487
|
+
const body = await c.req.json<{ mfaTicket: string; recoveryCode: string }>();
|
|
2488
|
+
if (!body.mfaTicket || !body.recoveryCode) {
|
|
2489
|
+
throw new EdgeBaseError(400, 'mfaTicket and recoveryCode are required.', undefined, 'invalid-input');
|
|
2490
|
+
}
|
|
2491
|
+
await ensureAuthActionAllowed(c, 'mfaRecovery', {
|
|
2492
|
+
mfaTicket: body.mfaTicket,
|
|
2493
|
+
recoveryCode: body.recoveryCode,
|
|
2494
|
+
});
|
|
2495
|
+
|
|
2496
|
+
// Look up mfaTicket from KV
|
|
2497
|
+
const ticketData = await c.env.KV.get(`mfa-ticket:${body.mfaTicket}`, 'json') as {
|
|
2498
|
+
userId: string;
|
|
2499
|
+
} | null;
|
|
2500
|
+
|
|
2501
|
+
if (!ticketData) {
|
|
2502
|
+
throw new EdgeBaseError(400, 'Invalid or expired MFA ticket.', undefined, 'invalid-token');
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
const { userId } = ticketData;
|
|
2506
|
+
const ip = getClientIP(c.env, c.req.raw);
|
|
2507
|
+
const db = getAuthDb(c);
|
|
2508
|
+
|
|
2509
|
+
// Disabled check
|
|
2510
|
+
const user = await authService.getUserById(db, userId);
|
|
2511
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
2512
|
+
if (user.disabled === 1) {
|
|
2513
|
+
throw new EdgeBaseError(403, 'Account is disabled.', undefined, 'account-disabled');
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
// Find unused recovery codes for this user
|
|
2517
|
+
const codes = await authService.listRecoveryCodes(db, userId);
|
|
2518
|
+
if (codes.length === 0) {
|
|
2519
|
+
throw new EdgeBaseError(400, 'No recovery codes available.', undefined, 'invalid-input');
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
// Check each code (hash comparison)
|
|
2523
|
+
let matchedCodeId: string | null = null;
|
|
2524
|
+
for (const codeRow of codes) {
|
|
2525
|
+
const valid = await verifyRecoveryCode(body.recoveryCode, codeRow.codeHash as string);
|
|
2526
|
+
if (valid) {
|
|
2527
|
+
matchedCodeId = codeRow.id as string;
|
|
2528
|
+
break;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
if (!matchedCodeId) {
|
|
2533
|
+
throw new EdgeBaseError(401, 'Invalid recovery code.', undefined, 'invalid-recovery-code');
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
// Mark recovery code as used (single-use)
|
|
2537
|
+
await authService.useRecoveryCode(db, matchedCodeId);
|
|
2538
|
+
|
|
2539
|
+
// Delete mfaTicket (single-use)
|
|
2540
|
+
await c.env.KV.delete(`mfa-ticket:${body.mfaTicket}`).catch(() => {});
|
|
2541
|
+
|
|
2542
|
+
// MFA passed — create session
|
|
2543
|
+
await authService.cleanExpiredSessionsForUser(db, userId);
|
|
2544
|
+
const session = await createSessionAndTokens(c.env, userId, ip, c.req.header('user-agent') || '');
|
|
2545
|
+
|
|
2546
|
+
// afterSignIn hook — non-blocking
|
|
2547
|
+
c.executionCtx.waitUntil(
|
|
2548
|
+
executeAuthHook(c.env, c.executionCtx, 'afterSignIn', authService.sanitizeUser(user), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
2549
|
+
);
|
|
2550
|
+
|
|
2551
|
+
return c.json({
|
|
2552
|
+
user: authService.sanitizeUser(user),
|
|
2553
|
+
accessToken: session.accessToken,
|
|
2554
|
+
refreshToken: session.refreshToken,
|
|
2555
|
+
});
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
// DELETE /mfa/totp — disable TOTP (authenticated)
|
|
2559
|
+
const mfaTotpDelete = createRoute({
|
|
2560
|
+
operationId: 'authMfaTotpDelete',
|
|
2561
|
+
method: 'delete',
|
|
2562
|
+
path: '/mfa/totp',
|
|
2563
|
+
tags: ['client'],
|
|
2564
|
+
summary: 'Disable TOTP factor',
|
|
2565
|
+
request: {
|
|
2566
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
2567
|
+
password: z.string().optional(),
|
|
2568
|
+
code: z.string().optional(),
|
|
2569
|
+
}).passthrough() } }, required: false },
|
|
2570
|
+
},
|
|
2571
|
+
responses: {
|
|
2572
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2573
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2574
|
+
},
|
|
2575
|
+
});
|
|
2576
|
+
|
|
2577
|
+
authRoute.openapi(mfaTotpDelete, async (c) => {
|
|
2578
|
+
const userId = requireAuth(c.get('auth'));
|
|
2579
|
+
const bodyText = await c.req.text();
|
|
2580
|
+
let body: { password?: string; code?: string } = {};
|
|
2581
|
+
try { body = JSON.parse(bodyText); } catch { /* empty body OK */ }
|
|
2582
|
+
await ensureAuthActionAllowed(c, 'mfaTotpDelete', {
|
|
2583
|
+
userId,
|
|
2584
|
+
passwordProvided: !!body.password,
|
|
2585
|
+
codeProvided: !!body.code,
|
|
2586
|
+
});
|
|
2587
|
+
const db = getAuthDb(c);
|
|
2588
|
+
|
|
2589
|
+
// Verify identity: require either password or TOTP code
|
|
2590
|
+
const user = await authService.getUserById(db, userId);
|
|
2591
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
2592
|
+
|
|
2593
|
+
if (body.password) {
|
|
2594
|
+
if (!user.passwordHash) throw new EdgeBaseError(400, 'This account has no password set.', undefined, 'invalid-input');
|
|
2595
|
+
const valid = await verifyPassword(body.password, user.passwordHash as string);
|
|
2596
|
+
if (!valid) throw new EdgeBaseError(401, 'Invalid password.', undefined, 'invalid-password');
|
|
2597
|
+
} else if (body.code) {
|
|
2598
|
+
const factor = await authService.getMfaFactorByUser(db, userId, 'totp');
|
|
2599
|
+
if (!factor || !factor.verified) throw new EdgeBaseError(400, 'No TOTP factor found.', undefined, 'invalid-input');
|
|
2600
|
+
const secret = await decryptSecret(factor.secret as string, getUserSecret(c.env));
|
|
2601
|
+
const valid = await verifyTOTP(secret, body.code);
|
|
2602
|
+
if (!valid) throw new EdgeBaseError(401, 'Invalid TOTP code.', undefined, 'invalid-totp');
|
|
2603
|
+
} else {
|
|
2604
|
+
throw new EdgeBaseError(400, 'Either password or TOTP code is required to disable MFA.', undefined, 'invalid-input');
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// Delete all MFA factors and recovery codes
|
|
2608
|
+
await authService.disableMfa(db, userId);
|
|
2609
|
+
|
|
2610
|
+
return c.json({ ok: true });
|
|
2611
|
+
});
|
|
2612
|
+
|
|
2613
|
+
// GET /mfa/factors — list user's MFA factors (authenticated)
|
|
2614
|
+
const mfaFactors = createRoute({
|
|
2615
|
+
operationId: 'authMfaFactors',
|
|
2616
|
+
method: 'get',
|
|
2617
|
+
path: '/mfa/factors',
|
|
2618
|
+
tags: ['client'],
|
|
2619
|
+
summary: 'List MFA factors for authenticated user',
|
|
2620
|
+
responses: {
|
|
2621
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2622
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2623
|
+
},
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2626
|
+
authRoute.openapi(mfaFactors, async (c) => {
|
|
2627
|
+
const userId = requireAuth(c.get('auth'));
|
|
2628
|
+
await ensureAuthActionAllowed(c, 'mfaFactors', { userId });
|
|
2629
|
+
const db = getAuthDb(c);
|
|
2630
|
+
|
|
2631
|
+
const factors = await authService.listMfaFactors(db, userId);
|
|
2632
|
+
|
|
2633
|
+
return c.json({
|
|
2634
|
+
factors: factors.map((f) => ({
|
|
2635
|
+
id: f.id,
|
|
2636
|
+
type: f.type,
|
|
2637
|
+
verified: f.verified,
|
|
2638
|
+
createdAt: f.createdAt,
|
|
2639
|
+
})),
|
|
2640
|
+
});
|
|
2641
|
+
});
|
|
2642
|
+
|
|
2643
|
+
// ─── Shard-routed routes (Refresh Token in body) ───
|
|
2644
|
+
|
|
2645
|
+
const refresh = createRoute({
|
|
2646
|
+
operationId: 'authRefresh',
|
|
2647
|
+
method: 'post',
|
|
2648
|
+
path: '/refresh',
|
|
2649
|
+
tags: ['client'],
|
|
2650
|
+
summary: 'Refresh access token',
|
|
2651
|
+
request: {
|
|
2652
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
2653
|
+
refreshToken: z.string(),
|
|
2654
|
+
}).passthrough() } }, required: true },
|
|
2655
|
+
},
|
|
2656
|
+
responses: {
|
|
2657
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2658
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2659
|
+
401: { description: 'Invalid refresh token', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2660
|
+
},
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
authRoute.openapi(refresh, async (c) => {
|
|
2664
|
+
const bodyText = await c.req.text();
|
|
2665
|
+
let body: { refreshToken?: string };
|
|
2666
|
+
try { body = JSON.parse(bodyText); } catch { throw new EdgeBaseError(400, 'Invalid JSON.', undefined, 'invalid-json'); }
|
|
2667
|
+
if (!body.refreshToken) throw new EdgeBaseError(400, 'Refresh token is required.', undefined, 'invalid-input');
|
|
2668
|
+
|
|
2669
|
+
await ensureAuthActionAllowed(c, 'refresh', {
|
|
2670
|
+
refreshToken: body.refreshToken,
|
|
2671
|
+
});
|
|
2672
|
+
|
|
2673
|
+
const db = getAuthDb(c);
|
|
2674
|
+
const GRACE_PERIOD_SECONDS = 30;
|
|
2675
|
+
|
|
2676
|
+
// Verify the refresh token signature
|
|
2677
|
+
let tokenPayload;
|
|
2678
|
+
try {
|
|
2679
|
+
tokenPayload = await verifyRefreshTokenWithFallback(
|
|
2680
|
+
body.refreshToken,
|
|
2681
|
+
getUserSecret(c.env),
|
|
2682
|
+
c.env.JWT_USER_SECRET_OLD,
|
|
2683
|
+
c.env.JWT_USER_SECRET_OLD_AT,
|
|
2684
|
+
);
|
|
2685
|
+
} catch (err) {
|
|
2686
|
+
if (err instanceof TokenExpiredError) {
|
|
2687
|
+
throw new EdgeBaseError(401, 'Refresh token expired.', undefined, 'refresh-token-expired');
|
|
2688
|
+
}
|
|
2689
|
+
throw new EdgeBaseError(401, 'Invalid refresh token.', undefined, 'invalid-refresh-token');
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
const userId = tokenPayload.sub;
|
|
2693
|
+
|
|
2694
|
+
// Use getSessionByRefreshToken which checks both current and previous tokens
|
|
2695
|
+
const result = await authService.getSessionByRefreshToken(db, body.refreshToken, userId);
|
|
2696
|
+
|
|
2697
|
+
if (!result) {
|
|
2698
|
+
throw new EdgeBaseError(401, 'Invalid refresh token.', undefined, 'invalid-refresh-token');
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
const { session, matchType } = result;
|
|
2702
|
+
|
|
2703
|
+
if (matchType === 'current') {
|
|
2704
|
+
// Normal rotation
|
|
2705
|
+
return c.json(await rotateRefreshTokenFlow(c.env, c.executionCtx, session, userId, getWorkerUrl(c.req.url, c.env)));
|
|
2706
|
+
}
|
|
2707
|
+
|
|
2708
|
+
// matchType === 'previous' — Grace Period check
|
|
2709
|
+
const rotatedAt = session.rotatedAt as string;
|
|
2710
|
+
const rotatedTime = new Date(rotatedAt).getTime();
|
|
2711
|
+
const gracePeriodMs = GRACE_PERIOD_SECONDS * 1000;
|
|
2712
|
+
|
|
2713
|
+
if (Date.now() - rotatedTime <= gracePeriodMs) {
|
|
2714
|
+
// Within grace period — return current tokens without re-rotation
|
|
2715
|
+
const user = await authService.getUserById(db, userId);
|
|
2716
|
+
if (!user) throw new EdgeBaseError(401, 'User not found.', undefined, 'invalid-credentials');
|
|
2717
|
+
return c.json({
|
|
2718
|
+
user: authService.sanitizeUser(user),
|
|
2719
|
+
accessToken: await generateAccessToken(c.env, user),
|
|
2720
|
+
refreshToken: session.refreshToken as string,
|
|
2721
|
+
});
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
// Beyond grace period — token theft suspected! Revoke session
|
|
2725
|
+
await authService.deleteSession(db, session.id as string);
|
|
2726
|
+
throw new EdgeBaseError(401, 'Refresh token reuse detected. Session revoked.', undefined, 'refresh-token-reused');
|
|
2727
|
+
});
|
|
2728
|
+
|
|
2729
|
+
const signout = createRoute({
|
|
2730
|
+
operationId: 'authSignout',
|
|
2731
|
+
method: 'post',
|
|
2732
|
+
path: '/signout',
|
|
2733
|
+
tags: ['client'],
|
|
2734
|
+
summary: 'Sign out and revoke refresh token',
|
|
2735
|
+
request: {
|
|
2736
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
2737
|
+
refreshToken: z.string(),
|
|
2738
|
+
}).passthrough() } }, required: true },
|
|
2739
|
+
},
|
|
2740
|
+
responses: {
|
|
2741
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2742
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2743
|
+
401: { description: 'Invalid refresh token', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2744
|
+
},
|
|
2745
|
+
});
|
|
2746
|
+
|
|
2747
|
+
authRoute.openapi(signout, async (c) => {
|
|
2748
|
+
const bodyText = await c.req.text();
|
|
2749
|
+
let body: { refreshToken?: string };
|
|
2750
|
+
try { body = JSON.parse(bodyText); } catch { throw new EdgeBaseError(400, 'Invalid JSON.', undefined, 'invalid-json'); }
|
|
2751
|
+
if (!body.refreshToken) throw new EdgeBaseError(400, 'Refresh token is required.', undefined, 'invalid-input');
|
|
2752
|
+
|
|
2753
|
+
await ensureAuthActionAllowed(c, 'signOut', {
|
|
2754
|
+
refreshToken: body.refreshToken,
|
|
2755
|
+
});
|
|
2756
|
+
|
|
2757
|
+
const payload = decodeTokenUnsafe(body.refreshToken);
|
|
2758
|
+
if (!payload?.sub) throw new EdgeBaseError(401, 'Invalid refresh token.', undefined, 'invalid-refresh-token');
|
|
2759
|
+
|
|
2760
|
+
const db = getAuthDb(c);
|
|
2761
|
+
const userId = payload.sub as string;
|
|
2762
|
+
|
|
2763
|
+
// beforeSignOut hook — blocking
|
|
2764
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforeSignOut', { userId }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
2765
|
+
|
|
2766
|
+
// Delete session by refreshToken
|
|
2767
|
+
await authService.deleteSessionByRefreshToken(db, body.refreshToken);
|
|
2768
|
+
|
|
2769
|
+
// afterSignOut hook — non-blocking
|
|
2770
|
+
c.executionCtx.waitUntil(
|
|
2771
|
+
executeAuthHook(c.env, c.executionCtx, 'afterSignOut', { userId }, { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
2772
|
+
);
|
|
2773
|
+
|
|
2774
|
+
return c.json({ ok: true });
|
|
2775
|
+
});
|
|
2776
|
+
|
|
2777
|
+
// ─── Shard-routed routes (Access Token in header) ───
|
|
2778
|
+
|
|
2779
|
+
// POST /change-password — verify current password, set new one
|
|
2780
|
+
const changePassword = createRoute({
|
|
2781
|
+
operationId: 'authChangePassword',
|
|
2782
|
+
method: 'post',
|
|
2783
|
+
path: '/change-password',
|
|
2784
|
+
tags: ['client'],
|
|
2785
|
+
summary: 'Change password for authenticated user',
|
|
2786
|
+
request: {
|
|
2787
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
2788
|
+
currentPassword: z.string(),
|
|
2789
|
+
newPassword: z.string(),
|
|
2790
|
+
}).passthrough() } }, required: true },
|
|
2791
|
+
},
|
|
2792
|
+
responses: {
|
|
2793
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2794
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2795
|
+
401: { description: 'Authentication required or invalid password', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2796
|
+
},
|
|
2797
|
+
});
|
|
2798
|
+
|
|
2799
|
+
authRoute.openapi(changePassword, async (c) => {
|
|
2800
|
+
const userId = requireAuth(c.get('auth'));
|
|
2801
|
+
const bodyText = await c.req.text();
|
|
2802
|
+
let body: { currentPassword: string; newPassword: string };
|
|
2803
|
+
try { body = JSON.parse(bodyText); } catch { throw new EdgeBaseError(400, 'Invalid JSON.', undefined, 'invalid-json'); }
|
|
2804
|
+
if (!body.currentPassword || !body.newPassword) {
|
|
2805
|
+
throw new EdgeBaseError(400, 'currentPassword and newPassword are required.', undefined, 'invalid-input');
|
|
2806
|
+
}
|
|
2807
|
+
if (body.newPassword.length < 8) {
|
|
2808
|
+
throw new EdgeBaseError(400, 'Password must be at least 8 characters.', undefined, 'password-too-short');
|
|
2809
|
+
}
|
|
2810
|
+
await ensureAuthActionAllowed(c, 'changePassword', {
|
|
2811
|
+
userId,
|
|
2812
|
+
newPasswordLength: body.newPassword.length,
|
|
2813
|
+
});
|
|
2814
|
+
|
|
2815
|
+
const db = getAuthDb(c);
|
|
2816
|
+
|
|
2817
|
+
// Password policy validation
|
|
2818
|
+
const policyResult = await validatePassword(body.newPassword, getPasswordPolicyConfig(c.env));
|
|
2819
|
+
if (!policyResult.valid) {
|
|
2820
|
+
throw new EdgeBaseError(400, policyResult.errors[0], { password: { code: 'password_policy', message: policyResult.errors.join('; ') } }, 'password-policy');
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
const user = await authService.getUserById(db, userId);
|
|
2824
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
2825
|
+
if (Number(user.disabled) === 1) throw new EdgeBaseError(403, 'Account is disabled.', undefined, 'account-disabled');
|
|
2826
|
+
if (!user.passwordHash) {
|
|
2827
|
+
throw new EdgeBaseError(403, 'This account uses OAuth sign-in. Password login is not available.', undefined, 'oauth-only');
|
|
2828
|
+
}
|
|
2829
|
+
if (user.isAnonymous === 1) {
|
|
2830
|
+
throw new EdgeBaseError(403, 'Anonymous accounts cannot change password.', undefined, 'anonymous-not-allowed');
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
const valid = await verifyPassword(body.currentPassword, user.passwordHash as string);
|
|
2834
|
+
if (!valid) {
|
|
2835
|
+
throw new EdgeBaseError(401, 'Current password is incorrect.', undefined, 'invalid-password');
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
// beforePasswordReset hook
|
|
2839
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforePasswordReset', { userId }, { blocking: true, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
2840
|
+
|
|
2841
|
+
// Update password
|
|
2842
|
+
const newHash = await hashPassword(body.newPassword);
|
|
2843
|
+
await authService.updateUser(db, userId, { passwordHash: newHash });
|
|
2844
|
+
|
|
2845
|
+
// afterPasswordReset hook — non-blocking
|
|
2846
|
+
const changedUser = await authService.getUserById(db, userId);
|
|
2847
|
+
if (changedUser) {
|
|
2848
|
+
c.executionCtx.waitUntil(
|
|
2849
|
+
executeAuthHook(c.env, c.executionCtx, 'afterPasswordReset', authService.sanitizeUser(changedUser), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
2850
|
+
);
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
// Revoke all sessions + create new session
|
|
2854
|
+
await authService.deleteAllUserSessions(db, userId);
|
|
2855
|
+
const ip = getClientIP(c.env, c.req.raw);
|
|
2856
|
+
const userAgent = c.req.header('user-agent') || 'change-password';
|
|
2857
|
+
const session = await createSessionAndTokens(c.env, userId, ip, userAgent);
|
|
2858
|
+
|
|
2859
|
+
// Re-read user
|
|
2860
|
+
const updatedUser = await authService.getUserById(db, userId);
|
|
2861
|
+
if (updatedUser) {
|
|
2862
|
+
syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(updatedUser));
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
return c.json({
|
|
2866
|
+
user: updatedUser ? authService.sanitizeUser(updatedUser) : null,
|
|
2867
|
+
accessToken: session.accessToken,
|
|
2868
|
+
refreshToken: session.refreshToken,
|
|
2869
|
+
});
|
|
2870
|
+
});
|
|
2871
|
+
|
|
2872
|
+
// ─── Email Change (authenticated) ───
|
|
2873
|
+
|
|
2874
|
+
// POST /change-email — request email change (password re-confirm + verification email)
|
|
2875
|
+
const changeEmail = createRoute({
|
|
2876
|
+
operationId: 'authChangeEmail',
|
|
2877
|
+
method: 'post',
|
|
2878
|
+
path: '/change-email',
|
|
2879
|
+
tags: ['client'],
|
|
2880
|
+
summary: 'Request email change with password confirmation',
|
|
2881
|
+
request: {
|
|
2882
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
2883
|
+
newEmail: z.string(),
|
|
2884
|
+
password: z.string(),
|
|
2885
|
+
}).passthrough() } }, required: true },
|
|
2886
|
+
},
|
|
2887
|
+
responses: {
|
|
2888
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
2889
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2890
|
+
401: { description: 'Password verification failed', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2891
|
+
409: { description: 'Email already registered', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2892
|
+
429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
2893
|
+
},
|
|
2894
|
+
});
|
|
2895
|
+
|
|
2896
|
+
authRoute.openapi(changeEmail, async (c) => {
|
|
2897
|
+
const userId = requireAuth(c.get('auth'));
|
|
2898
|
+
const body = await c.req.json<{
|
|
2899
|
+
newEmail: string;
|
|
2900
|
+
password: string;
|
|
2901
|
+
redirectUrl?: string;
|
|
2902
|
+
state?: string;
|
|
2903
|
+
}>();
|
|
2904
|
+
|
|
2905
|
+
if (!body.newEmail || !body.password) {
|
|
2906
|
+
throw new EdgeBaseError(400, 'newEmail and password are required.', undefined, 'invalid-input');
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
const newEmail = body.newEmail.trim().toLowerCase();
|
|
2910
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
|
2911
|
+
throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
|
|
2912
|
+
}
|
|
2913
|
+
const redirect = parseClientRedirectInput(c.env, body);
|
|
2914
|
+
await ensureAuthActionAllowed(c, 'changeEmail', { userId, newEmail });
|
|
2915
|
+
|
|
2916
|
+
// Rate limit per user
|
|
2917
|
+
const rateKey = `email-change-rate:${userId}`;
|
|
2918
|
+
if (!counter.check(rateKey, 3, 3600)) {
|
|
2919
|
+
throw new EdgeBaseError(429, 'Too many email change requests. Try again later.', undefined, 'rate-limited');
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
const db = getAuthDb(c);
|
|
2923
|
+
|
|
2924
|
+
// 1. Check new email is not already registered
|
|
2925
|
+
const existing = await lookupEmail(db, newEmail);
|
|
2926
|
+
if (existing) {
|
|
2927
|
+
throw new EdgeBaseError(409, 'Email is already registered.', undefined, 'email-already-exists');
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
// 2. Verify password directly in D1
|
|
2931
|
+
const user = await authService.getUserById(db, userId);
|
|
2932
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
2933
|
+
if (!user.passwordHash) {
|
|
2934
|
+
throw new EdgeBaseError(403, 'This account uses OAuth sign-in. Password-based email change is not available.', undefined, 'oauth-only');
|
|
2935
|
+
}
|
|
2936
|
+
if (user.disabled === 1) {
|
|
2937
|
+
throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
const valid = await verifyPassword(body.password, user.passwordHash as string);
|
|
2941
|
+
if (!valid) {
|
|
2942
|
+
throw new EdgeBaseError(401, 'Password verification failed.', undefined, 'invalid-password');
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
const oldEmail = user.email as string;
|
|
2946
|
+
|
|
2947
|
+
// 3. Generate verification token and store in KV
|
|
2948
|
+
const token = crypto.randomUUID();
|
|
2949
|
+
await c.env.KV.put(
|
|
2950
|
+
`email-change:${token}`,
|
|
2951
|
+
JSON.stringify({ userId, newEmail, oldEmail }),
|
|
2952
|
+
{ expirationTtl: 86400 },
|
|
2953
|
+
);
|
|
2954
|
+
|
|
2955
|
+
// 4. Send verification email to the NEW email address
|
|
2956
|
+
const provider = createEmailProvider(getEmailConfig(c.env), c.env);
|
|
2957
|
+
if (provider) {
|
|
2958
|
+
const appName = getAppName(c.env);
|
|
2959
|
+
const emailCfg = getEmailConfig(c.env);
|
|
2960
|
+
const fallbackVerifyUrl = emailCfg?.emailChangeUrl
|
|
2961
|
+
? emailCfg.emailChangeUrl.replace('{token}', token)
|
|
2962
|
+
: `#verify-email-change?token=${token}`;
|
|
2963
|
+
const verifyUrl = buildEmailActionUrl({
|
|
2964
|
+
redirectUrl: redirect.redirectUrl,
|
|
2965
|
+
fallbackUrl: fallbackVerifyUrl,
|
|
2966
|
+
token,
|
|
2967
|
+
type: 'email-change',
|
|
2968
|
+
state: redirect.state,
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
const locale = resolveEmailLocale(c.env, user.locale as string | null, parseAcceptLanguage(c.req.header('accept-language')));
|
|
2972
|
+
const html = renderEmailChange({
|
|
2973
|
+
appName,
|
|
2974
|
+
verifyUrl,
|
|
2975
|
+
token,
|
|
2976
|
+
newEmail,
|
|
2977
|
+
expiresInHours: 24,
|
|
2978
|
+
}, resolveLocalizedString(getEmailTemplates(c.env)?.emailChange, locale), locale);
|
|
2979
|
+
|
|
2980
|
+
const defaultSubject = getDefaultSubject(locale, 'emailChange').replace(/\{\{appName\}\}/g, appName);
|
|
2981
|
+
await sendMailWithHook(
|
|
2982
|
+
c.env, c.executionCtx, provider, 'emailChange', newEmail,
|
|
2983
|
+
resolveSubject(c.env, 'emailChange', defaultSubject, locale), html, locale,
|
|
2984
|
+
).catch((err) => {
|
|
2985
|
+
console.error('[Email Change] Failed to send verification email:', err);
|
|
2986
|
+
});
|
|
2987
|
+
} else {
|
|
2988
|
+
const release = parseConfig(c.env)?.release ?? false;
|
|
2989
|
+
if (!release) {
|
|
2990
|
+
console.warn('[Email Change] Email provider not configured. Token:', token);
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
const release = parseConfig(c.env)?.release ?? false;
|
|
2995
|
+
if (!release) {
|
|
2996
|
+
const emailCfg = getEmailConfig(c.env);
|
|
2997
|
+
const fallbackVerifyUrl = emailCfg?.emailChangeUrl
|
|
2998
|
+
? emailCfg.emailChangeUrl.replace('{token}', token)
|
|
2999
|
+
: `#verify-email-change?token=${token}`;
|
|
3000
|
+
const actionUrl = buildEmailActionUrl({
|
|
3001
|
+
redirectUrl: redirect.redirectUrl,
|
|
3002
|
+
fallbackUrl: fallbackVerifyUrl,
|
|
3003
|
+
token,
|
|
3004
|
+
type: 'email-change',
|
|
3005
|
+
state: redirect.state,
|
|
3006
|
+
});
|
|
3007
|
+
return c.json({ ok: true, token, actionUrl });
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
return c.json({ ok: true });
|
|
3011
|
+
});
|
|
3012
|
+
|
|
3013
|
+
// POST /verify-email-change — verify token + swap email
|
|
3014
|
+
const verifyEmailChange = createRoute({
|
|
3015
|
+
operationId: 'authVerifyEmailChange',
|
|
3016
|
+
method: 'post',
|
|
3017
|
+
path: '/verify-email-change',
|
|
3018
|
+
tags: ['client'],
|
|
3019
|
+
summary: 'Verify email change token',
|
|
3020
|
+
request: {
|
|
3021
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
3022
|
+
token: z.string(),
|
|
3023
|
+
}).passthrough() } }, required: true },
|
|
3024
|
+
},
|
|
3025
|
+
responses: {
|
|
3026
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3027
|
+
400: { description: 'Invalid or expired token', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3028
|
+
409: { description: 'Email already registered', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3029
|
+
},
|
|
3030
|
+
});
|
|
3031
|
+
|
|
3032
|
+
authRoute.openapi(verifyEmailChange, async (c) => {
|
|
3033
|
+
const body = await c.req.json<{ token: string }>();
|
|
3034
|
+
if (!body.token) throw new EdgeBaseError(400, 'Verification token is required.', undefined, 'invalid-input');
|
|
3035
|
+
|
|
3036
|
+
await ensureAuthActionAllowed(c, 'verifyEmailChange', body as unknown as Record<string, unknown>);
|
|
3037
|
+
|
|
3038
|
+
const db = getAuthDb(c);
|
|
3039
|
+
|
|
3040
|
+
// 1. Read from KV
|
|
3041
|
+
const data = await c.env.KV.get(`email-change:${body.token}`, 'json') as {
|
|
3042
|
+
userId: string; newEmail: string; oldEmail: string;
|
|
3043
|
+
} | null;
|
|
3044
|
+
|
|
3045
|
+
if (!data) {
|
|
3046
|
+
throw new EdgeBaseError(400, 'Invalid or expired email change token.', undefined, 'invalid-token');
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
const { userId, newEmail, oldEmail } = data;
|
|
3050
|
+
|
|
3051
|
+
// 2. Delete KV token (single-use)
|
|
3052
|
+
await c.env.KV.delete(`email-change:${body.token}`).catch(() => {});
|
|
3053
|
+
|
|
3054
|
+
// 3. Check new email is still not registered (race condition check)
|
|
3055
|
+
const existing = await lookupEmail(db, newEmail);
|
|
3056
|
+
if (existing) {
|
|
3057
|
+
throw new EdgeBaseError(409, 'Email is already registered.', undefined, 'email-already-exists');
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
// 4. Register new email as pending in D1
|
|
3061
|
+
try {
|
|
3062
|
+
await registerEmailPending(db, newEmail, userId);
|
|
3063
|
+
} catch (err) {
|
|
3064
|
+
if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
|
|
3065
|
+
throw new EdgeBaseError(409, 'Email is already registered.', undefined, 'email-already-exists');
|
|
3066
|
+
}
|
|
3067
|
+
throw new EdgeBaseError(500, 'Email change failed.', undefined, 'internal-error');
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
// 5. Update user email directly in D1
|
|
3071
|
+
try {
|
|
3072
|
+
await authService.updateUser(db, userId, { email: newEmail });
|
|
3073
|
+
} catch {
|
|
3074
|
+
await deleteEmailPending(db, newEmail).catch(() => {});
|
|
3075
|
+
throw new EdgeBaseError(500, 'Email change failed.', undefined, 'internal-error');
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
// 6. Confirm new email + delete old email in D1
|
|
3079
|
+
await confirmEmail(db, newEmail, userId);
|
|
3080
|
+
if (oldEmail) {
|
|
3081
|
+
await deleteEmail(db, oldEmail).catch(() => {});
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
// Sync _users_public
|
|
3085
|
+
const user = await authService.getUserById(db, userId);
|
|
3086
|
+
if (user) {
|
|
3087
|
+
syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(user));
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
return c.json({ ok: true, user: user ? authService.sanitizeUser(user) : { id: userId, email: newEmail } });
|
|
3091
|
+
});
|
|
3092
|
+
|
|
3093
|
+
// ─── Passkeys/WebAuthn ────────────────────────────────────────────────────────
|
|
3094
|
+
|
|
3095
|
+
// POST /passkeys/register-options — authenticated, generate registration options
|
|
3096
|
+
const passkeysRegisterOptions = createRoute({
|
|
3097
|
+
operationId: 'authPasskeysRegisterOptions',
|
|
3098
|
+
method: 'post',
|
|
3099
|
+
path: '/passkeys/register-options',
|
|
3100
|
+
tags: ['client'],
|
|
3101
|
+
summary: 'Generate passkey registration options',
|
|
3102
|
+
responses: {
|
|
3103
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3104
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3105
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3106
|
+
},
|
|
3107
|
+
});
|
|
3108
|
+
|
|
3109
|
+
authRoute.openapi(passkeysRegisterOptions, async (c) => {
|
|
3110
|
+
const userId = requireAuth(c.get('auth'));
|
|
3111
|
+
await ensureAuthActionAllowed(c, 'passkeysRegisterOptions', { userId });
|
|
3112
|
+
const db = getAuthDb(c);
|
|
3113
|
+
const passkeysConfig = getPasskeysConfig(c.env);
|
|
3114
|
+
if (!passkeysConfig?.enabled) throw new EdgeBaseError(400, 'Passkeys are not enabled.', undefined, 'feature-not-enabled');
|
|
3115
|
+
|
|
3116
|
+
const { generateRegistrationOptions } = await import('@simplewebauthn/server');
|
|
3117
|
+
|
|
3118
|
+
const user = await authService.getUserById(db, userId);
|
|
3119
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
3120
|
+
if (Number(user.disabled) === 1) throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
|
|
3121
|
+
|
|
3122
|
+
// Gather existing credentials for exclusion
|
|
3123
|
+
const existingCreds = await authService.listWebAuthnCredentials(db, userId);
|
|
3124
|
+
|
|
3125
|
+
const options = await generateRegistrationOptions({
|
|
3126
|
+
rpName: passkeysConfig.rpName,
|
|
3127
|
+
rpID: passkeysConfig.rpID,
|
|
3128
|
+
userName: (user.email as string) || userId,
|
|
3129
|
+
userDisplayName: (user.displayName as string) || (user.email as string) || '',
|
|
3130
|
+
excludeCredentials: existingCreds.map((cred) => ({
|
|
3131
|
+
id: cred.credentialId,
|
|
3132
|
+
transports: cred.transports ? JSON.parse(cred.transports) : undefined,
|
|
3133
|
+
})),
|
|
3134
|
+
authenticatorSelection: {
|
|
3135
|
+
residentKey: 'preferred',
|
|
3136
|
+
userVerification: 'preferred',
|
|
3137
|
+
},
|
|
3138
|
+
attestationType: 'none',
|
|
3139
|
+
});
|
|
3140
|
+
|
|
3141
|
+
// Store challenge in KV (TTL 5 min)
|
|
3142
|
+
await c.env.KV.put(
|
|
3143
|
+
`webauthn-challenge:${userId}`,
|
|
3144
|
+
options.challenge,
|
|
3145
|
+
{ expirationTtl: 300 },
|
|
3146
|
+
);
|
|
3147
|
+
|
|
3148
|
+
return c.json({ options });
|
|
3149
|
+
});
|
|
3150
|
+
|
|
3151
|
+
// POST /passkeys/register — authenticated, verify registration and store credential
|
|
3152
|
+
const passkeysRegister = createRoute({
|
|
3153
|
+
operationId: 'authPasskeysRegister',
|
|
3154
|
+
method: 'post',
|
|
3155
|
+
path: '/passkeys/register',
|
|
3156
|
+
tags: ['client'],
|
|
3157
|
+
summary: 'Verify and store passkey registration',
|
|
3158
|
+
request: {
|
|
3159
|
+
body: { content: { 'application/json': { schema: z.object({}).passthrough() } }, required: true },
|
|
3160
|
+
},
|
|
3161
|
+
responses: {
|
|
3162
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3163
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3164
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3165
|
+
},
|
|
3166
|
+
});
|
|
3167
|
+
|
|
3168
|
+
authRoute.openapi(passkeysRegister, async (c) => {
|
|
3169
|
+
const userId = requireAuth(c.get('auth'));
|
|
3170
|
+
const db = getAuthDb(c);
|
|
3171
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3172
|
+
const body = await c.req.json<{ response: any }>();
|
|
3173
|
+
if (!body.response) throw new EdgeBaseError(400, 'Registration response is required.', undefined, 'invalid-input');
|
|
3174
|
+
await ensureAuthActionAllowed(c, 'passkeysRegister', { userId });
|
|
3175
|
+
|
|
3176
|
+
const passkeysConfig = getPasskeysConfig(c.env);
|
|
3177
|
+
if (!passkeysConfig?.enabled) throw new EdgeBaseError(400, 'Passkeys are not enabled.', undefined, 'feature-not-enabled');
|
|
3178
|
+
|
|
3179
|
+
const { verifyRegistrationResponse } = await import('@simplewebauthn/server');
|
|
3180
|
+
|
|
3181
|
+
const user = await authService.getUserById(db, userId);
|
|
3182
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
3183
|
+
if (Number(user.disabled) === 1) throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
|
|
3184
|
+
|
|
3185
|
+
// Retrieve challenge from KV
|
|
3186
|
+
const expectedChallenge = await c.env.KV.get(`webauthn-challenge:${userId}`);
|
|
3187
|
+
if (!expectedChallenge) throw new EdgeBaseError(400, 'Challenge expired or not found. Please request new registration options.', undefined, 'challenge-expired');
|
|
3188
|
+
|
|
3189
|
+
// Clean up challenge (single-use)
|
|
3190
|
+
await c.env.KV.delete(`webauthn-challenge:${userId}`);
|
|
3191
|
+
|
|
3192
|
+
const expectedOrigin = Array.isArray(passkeysConfig.origin) ? passkeysConfig.origin : [passkeysConfig.origin];
|
|
3193
|
+
|
|
3194
|
+
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
|
3195
|
+
try {
|
|
3196
|
+
verification = await verifyRegistrationResponse({
|
|
3197
|
+
response: body.response,
|
|
3198
|
+
expectedChallenge,
|
|
3199
|
+
expectedOrigin,
|
|
3200
|
+
expectedRPID: passkeysConfig.rpID,
|
|
3201
|
+
// Registration/auth options use userVerification: 'preferred', so server verification
|
|
3202
|
+
// must not silently upgrade that requirement to "required".
|
|
3203
|
+
requireUserVerification: false,
|
|
3204
|
+
});
|
|
3205
|
+
} catch (error) {
|
|
3206
|
+
throw new EdgeBaseError(
|
|
3207
|
+
400,
|
|
3208
|
+
error instanceof Error ? error.message : 'Passkey registration verification failed.',
|
|
3209
|
+
undefined,
|
|
3210
|
+
'invalid-input',
|
|
3211
|
+
);
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
3215
|
+
throw new EdgeBaseError(400, 'Registration verification failed.', undefined, 'invalid-input');
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo;
|
|
3219
|
+
const transports = body.response.response?.transports || [];
|
|
3220
|
+
|
|
3221
|
+
// Convert credentialPublicKey Uint8Array to base64 for TEXT storage
|
|
3222
|
+
const pubKeyBase64 = btoa(String.fromCharCode(...credentialPublicKey));
|
|
3223
|
+
|
|
3224
|
+
const credId = generateId();
|
|
3225
|
+
|
|
3226
|
+
try {
|
|
3227
|
+
await authService.createWebAuthnCredential(db, {
|
|
3228
|
+
id: credId,
|
|
3229
|
+
userId,
|
|
3230
|
+
credentialId: credentialID,
|
|
3231
|
+
credentialPublicKey: pubKeyBase64,
|
|
3232
|
+
counter,
|
|
3233
|
+
transports: JSON.stringify(transports),
|
|
3234
|
+
});
|
|
3235
|
+
|
|
3236
|
+
// Register in D1 passkey index
|
|
3237
|
+
await registerPasskey(db, credentialID, userId);
|
|
3238
|
+
} catch (error) {
|
|
3239
|
+
console.error('[Passkeys] Failed to persist registered credential:', error);
|
|
3240
|
+
throw new EdgeBaseError(
|
|
3241
|
+
500,
|
|
3242
|
+
error instanceof Error
|
|
3243
|
+
? `Passkey registration persistence failed: ${error.message}`
|
|
3244
|
+
: 'Passkey registration persistence failed.',
|
|
3245
|
+
undefined,
|
|
3246
|
+
'internal-error',
|
|
3247
|
+
);
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
return c.json({ ok: true, credentialId: credentialID });
|
|
3251
|
+
});
|
|
3252
|
+
|
|
3253
|
+
// POST /passkeys/auth-options — public, generate authentication options
|
|
3254
|
+
const passkeysAuthOptions = createRoute({
|
|
3255
|
+
operationId: 'authPasskeysAuthOptions',
|
|
3256
|
+
method: 'post',
|
|
3257
|
+
path: '/passkeys/auth-options',
|
|
3258
|
+
tags: ['client'],
|
|
3259
|
+
summary: 'Generate passkey authentication options',
|
|
3260
|
+
request: {
|
|
3261
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
3262
|
+
email: z.string().optional(),
|
|
3263
|
+
}).passthrough() } }, required: false },
|
|
3264
|
+
},
|
|
3265
|
+
responses: {
|
|
3266
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3267
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3268
|
+
404: { description: 'User not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3269
|
+
},
|
|
3270
|
+
});
|
|
3271
|
+
|
|
3272
|
+
authRoute.openapi(passkeysAuthOptions, async (c) => {
|
|
3273
|
+
const db = getAuthDb(c);
|
|
3274
|
+
const passkeysConfig = getPasskeysConfig(c.env);
|
|
3275
|
+
if (!passkeysConfig?.enabled) throw new EdgeBaseError(400, 'Passkeys are not enabled.', undefined, 'feature-not-enabled');
|
|
3276
|
+
|
|
3277
|
+
const { generateAuthenticationOptions } = await import('@simplewebauthn/server');
|
|
3278
|
+
const body: { email?: string } = await c.req.json<{ email?: string }>().catch(() => ({}));
|
|
3279
|
+
const email = typeof body.email === 'string' ? body.email.toLowerCase().trim() : undefined;
|
|
3280
|
+
await ensureAuthActionAllowed(c, 'passkeysAuthOptions', email ? { email } : null);
|
|
3281
|
+
let userId: string | undefined;
|
|
3282
|
+
|
|
3283
|
+
// If email is provided, look up the user to get their specific credentials
|
|
3284
|
+
if (email) {
|
|
3285
|
+
const emailLookup = await lookupEmail(db, email);
|
|
3286
|
+
if (!emailLookup) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
3287
|
+
userId = emailLookup.userId;
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
// Transport strings from DB are valid AuthenticatorTransportFuture values
|
|
3291
|
+
type TransportFuture = 'ble' | 'cable' | 'hybrid' | 'internal' | 'nfc' | 'smart-card' | 'usb';
|
|
3292
|
+
let allowCredentials: { id: string; transports?: TransportFuture[] }[] | undefined;
|
|
3293
|
+
|
|
3294
|
+
// If userId is provided, limit to that user's credentials
|
|
3295
|
+
if (userId) {
|
|
3296
|
+
const creds = await authService.listWebAuthnCredentials(db, userId);
|
|
3297
|
+
if (creds.length === 0) throw new EdgeBaseError(400, 'No passkeys registered for this user.', undefined, 'not-found');
|
|
3298
|
+
allowCredentials = creds.map((cred) => ({
|
|
3299
|
+
id: cred.credentialId,
|
|
3300
|
+
transports: cred.transports ? JSON.parse(cred.transports) as TransportFuture[] : undefined,
|
|
3301
|
+
}));
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
const options = await generateAuthenticationOptions({
|
|
3305
|
+
rpID: passkeysConfig.rpID,
|
|
3306
|
+
allowCredentials,
|
|
3307
|
+
userVerification: 'preferred',
|
|
3308
|
+
});
|
|
3309
|
+
|
|
3310
|
+
// Store challenge in KV (keyed by challenge itself for discoverable flow)
|
|
3311
|
+
await c.env.KV.put(
|
|
3312
|
+
`webauthn-auth-challenge:${options.challenge}`,
|
|
3313
|
+
JSON.stringify({ userId: userId || null }),
|
|
3314
|
+
{ expirationTtl: 300 },
|
|
3315
|
+
);
|
|
3316
|
+
|
|
3317
|
+
return c.json({ options });
|
|
3318
|
+
});
|
|
3319
|
+
|
|
3320
|
+
// POST /passkeys/authenticate — public, verify assertion and create session
|
|
3321
|
+
const passkeysAuthenticate = createRoute({
|
|
3322
|
+
operationId: 'authPasskeysAuthenticate',
|
|
3323
|
+
method: 'post',
|
|
3324
|
+
path: '/passkeys/authenticate',
|
|
3325
|
+
tags: ['client'],
|
|
3326
|
+
summary: 'Authenticate with passkey',
|
|
3327
|
+
request: {
|
|
3328
|
+
body: { content: { 'application/json': { schema: z.object({}).passthrough() } }, required: true },
|
|
3329
|
+
},
|
|
3330
|
+
responses: {
|
|
3331
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3332
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3333
|
+
401: { description: 'Authentication failed', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3334
|
+
},
|
|
3335
|
+
});
|
|
3336
|
+
|
|
3337
|
+
authRoute.openapi(passkeysAuthenticate, async (c) => {
|
|
3338
|
+
const db = getAuthDb(c);
|
|
3339
|
+
const passkeysConfig = getPasskeysConfig(c.env);
|
|
3340
|
+
if (!passkeysConfig?.enabled) throw new EdgeBaseError(400, 'Passkeys are not enabled.', undefined, 'feature-not-enabled');
|
|
3341
|
+
|
|
3342
|
+
const { verifyAuthenticationResponse } = await import('@simplewebauthn/server');
|
|
3343
|
+
|
|
3344
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3345
|
+
const body = await c.req.json<{ response: any }>();
|
|
3346
|
+
if (!body.response) throw new EdgeBaseError(400, 'Authentication response is required.', undefined, 'invalid-input');
|
|
3347
|
+
|
|
3348
|
+
const credentialId = body.response.id as string;
|
|
3349
|
+
if (!credentialId) throw new EdgeBaseError(400, 'Credential ID is required in the response.', undefined, 'invalid-input');
|
|
3350
|
+
await ensureAuthActionAllowed(c, 'passkeysAuthenticate', { credentialId });
|
|
3351
|
+
|
|
3352
|
+
const ip = getClientIP(c.env, c.req.raw);
|
|
3353
|
+
const userAgent = c.req.header('User-Agent') || 'passkey';
|
|
3354
|
+
|
|
3355
|
+
// Find credential in D1 (check existence before challenge validation)
|
|
3356
|
+
const credRow = await authService.getWebAuthnCredential(db, credentialId);
|
|
3357
|
+
if (!credRow) throw new EdgeBaseError(400, 'Unknown credential.', undefined, 'invalid-input');
|
|
3358
|
+
|
|
3359
|
+
// Extract challenge from clientDataJSON
|
|
3360
|
+
const clientDataJSON = body.response.response?.clientDataJSON as string;
|
|
3361
|
+
if (!clientDataJSON) throw new EdgeBaseError(400, 'clientDataJSON is required in the response.', undefined, 'invalid-input');
|
|
3362
|
+
|
|
3363
|
+
let parsedChallenge: string;
|
|
3364
|
+
try {
|
|
3365
|
+
const decoded = atob(clientDataJSON.replace(/-/g, '+').replace(/_/g, '/'));
|
|
3366
|
+
const parsed = JSON.parse(decoded);
|
|
3367
|
+
parsedChallenge = parsed.challenge;
|
|
3368
|
+
} catch {
|
|
3369
|
+
throw new EdgeBaseError(400, 'Invalid clientDataJSON.', undefined, 'invalid-input');
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
// Retrieve challenge data from KV
|
|
3373
|
+
const challengeData = await c.env.KV.get(`webauthn-auth-challenge:${parsedChallenge}`);
|
|
3374
|
+
if (!challengeData) throw new EdgeBaseError(400, 'Challenge expired or not found.', undefined, 'challenge-expired');
|
|
3375
|
+
|
|
3376
|
+
// Clean up challenge (single-use)
|
|
3377
|
+
await c.env.KV.delete(`webauthn-auth-challenge:${parsedChallenge}`);
|
|
3378
|
+
|
|
3379
|
+
const userId = credRow.userId as string;
|
|
3380
|
+
const user = await authService.getUserById(db, userId);
|
|
3381
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
3382
|
+
if (Number(user.disabled) === 1) throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
|
|
3383
|
+
|
|
3384
|
+
// Reconstruct Uint8Array from base64 string
|
|
3385
|
+
const pubKeyBinary = Uint8Array.from(atob(credRow.credentialPublicKey as string), (ch) => ch.charCodeAt(0));
|
|
3386
|
+
|
|
3387
|
+
const expectedOrigin = Array.isArray(passkeysConfig.origin) ? passkeysConfig.origin : [passkeysConfig.origin];
|
|
3388
|
+
|
|
3389
|
+
let verification: Awaited<ReturnType<typeof verifyAuthenticationResponse>>;
|
|
3390
|
+
try {
|
|
3391
|
+
verification = await verifyAuthenticationResponse({
|
|
3392
|
+
response: body.response,
|
|
3393
|
+
expectedChallenge: parsedChallenge,
|
|
3394
|
+
expectedOrigin,
|
|
3395
|
+
expectedRPID: passkeysConfig.rpID,
|
|
3396
|
+
authenticator: {
|
|
3397
|
+
credentialID: credRow.credentialId as string,
|
|
3398
|
+
credentialPublicKey: pubKeyBinary,
|
|
3399
|
+
counter: credRow.counter as number,
|
|
3400
|
+
},
|
|
3401
|
+
requireUserVerification: false,
|
|
3402
|
+
});
|
|
3403
|
+
} catch (error) {
|
|
3404
|
+
throw new EdgeBaseError(
|
|
3405
|
+
401,
|
|
3406
|
+
error instanceof Error ? error.message : 'Passkey authentication verification failed.',
|
|
3407
|
+
undefined,
|
|
3408
|
+
'invalid-credentials',
|
|
3409
|
+
);
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
if (!verification.verified) {
|
|
3413
|
+
throw new EdgeBaseError(401, 'Authentication verification failed.', undefined, 'invalid-credentials');
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
// Update counter
|
|
3417
|
+
await authService.updateWebAuthnCounter(db, credentialId, verification.authenticationInfo.newCounter);
|
|
3418
|
+
|
|
3419
|
+
// Run beforeSignIn hook
|
|
3420
|
+
const sanitizedUser = authService.sanitizeUser(user);
|
|
3421
|
+
const hookResult = await executeAuthHook(c.env, c.executionCtx, 'beforeSignIn', sanitizedUser, { ip, userAgent, workerUrl: getWorkerUrl(c.req.url, c.env) });
|
|
3422
|
+
if (hookResult?.blocked) {
|
|
3423
|
+
throw new EdgeBaseError(403, 'Sign-in blocked by hook.', undefined, 'hook-rejected');
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
// MFA Check
|
|
3427
|
+
const mfaConfig = getMfaConfig(c.env);
|
|
3428
|
+
if (mfaConfig?.totp) {
|
|
3429
|
+
const factors = await authService.listVerifiedMfaFactors(db, userId);
|
|
3430
|
+
if (factors.length > 0) {
|
|
3431
|
+
const mfaTicket = crypto.randomUUID();
|
|
3432
|
+
await c.env.KV.put(
|
|
3433
|
+
`mfa-ticket:${mfaTicket}`,
|
|
3434
|
+
JSON.stringify({ userId }),
|
|
3435
|
+
{ expirationTtl: 300 },
|
|
3436
|
+
);
|
|
3437
|
+
return c.json({
|
|
3438
|
+
mfaRequired: true,
|
|
3439
|
+
mfaTicket,
|
|
3440
|
+
factors: factors.map((f) => ({ id: f.id, type: f.type })),
|
|
3441
|
+
});
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
// Create session
|
|
3446
|
+
const session = await createSessionAndTokens(c.env, userId, ip, userAgent);
|
|
3447
|
+
|
|
3448
|
+
// Run afterSignIn hook (non-blocking)
|
|
3449
|
+
c.executionCtx.waitUntil(
|
|
3450
|
+
executeAuthHook(c.env, c.executionCtx, 'afterSignIn', sanitizedUser, { ip, userAgent, workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
3451
|
+
);
|
|
3452
|
+
|
|
3453
|
+
return c.json({
|
|
3454
|
+
user: sanitizedUser,
|
|
3455
|
+
accessToken: session.accessToken,
|
|
3456
|
+
refreshToken: session.refreshToken,
|
|
3457
|
+
});
|
|
3458
|
+
});
|
|
3459
|
+
|
|
3460
|
+
// GET /passkeys — list passkeys for authenticated user
|
|
3461
|
+
const passkeysList = createRoute({
|
|
3462
|
+
operationId: 'authPasskeysList',
|
|
3463
|
+
method: 'get',
|
|
3464
|
+
path: '/passkeys',
|
|
3465
|
+
tags: ['client'],
|
|
3466
|
+
summary: 'List passkeys for authenticated user',
|
|
3467
|
+
responses: {
|
|
3468
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3469
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3470
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3471
|
+
},
|
|
3472
|
+
});
|
|
3473
|
+
|
|
3474
|
+
authRoute.openapi(passkeysList, async (c) => {
|
|
3475
|
+
const userId = requireAuth(c.get('auth'));
|
|
3476
|
+
await ensureAuthActionAllowed(c, 'passkeysList', { userId });
|
|
3477
|
+
const db = getAuthDb(c);
|
|
3478
|
+
|
|
3479
|
+
const user = await authService.getUserById(db, userId);
|
|
3480
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
3481
|
+
|
|
3482
|
+
const creds = await authService.listWebAuthnCredentials(db, userId);
|
|
3483
|
+
|
|
3484
|
+
return c.json({
|
|
3485
|
+
passkeys: creds.map((cred) => ({
|
|
3486
|
+
id: cred.id,
|
|
3487
|
+
credentialId: cred.credentialId,
|
|
3488
|
+
transports: cred.transports ? JSON.parse(cred.transports) : [],
|
|
3489
|
+
createdAt: cred.createdAt,
|
|
3490
|
+
})),
|
|
3491
|
+
});
|
|
3492
|
+
});
|
|
3493
|
+
|
|
3494
|
+
// DELETE /passkeys/:credentialId — delete a passkey for authenticated user
|
|
3495
|
+
const passkeysDelete = createRoute({
|
|
3496
|
+
operationId: 'authPasskeysDelete',
|
|
3497
|
+
method: 'delete',
|
|
3498
|
+
path: '/passkeys/{credentialId}',
|
|
3499
|
+
tags: ['client'],
|
|
3500
|
+
summary: 'Delete a passkey',
|
|
3501
|
+
request: {
|
|
3502
|
+
params: z.object({ credentialId: z.string() }),
|
|
3503
|
+
},
|
|
3504
|
+
responses: {
|
|
3505
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3506
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3507
|
+
404: { description: 'Passkey not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3508
|
+
},
|
|
3509
|
+
});
|
|
3510
|
+
|
|
3511
|
+
authRoute.openapi(passkeysDelete, async (c) => {
|
|
3512
|
+
const userId = requireAuth(c.get('auth'));
|
|
3513
|
+
const db = getAuthDb(c);
|
|
3514
|
+
const credentialId = decodeURIComponent(c.req.param('credentialId')!);
|
|
3515
|
+
await ensureAuthActionAllowed(c, 'passkeysDelete', { userId, credentialId });
|
|
3516
|
+
|
|
3517
|
+
// Verify credential belongs to user
|
|
3518
|
+
const cred = await authService.getWebAuthnCredential(db, credentialId);
|
|
3519
|
+
if (!cred || cred.userId !== userId) throw new EdgeBaseError(404, 'Passkey not found.', undefined, 'not-found');
|
|
3520
|
+
|
|
3521
|
+
// Delete from _webauthn_credentials table
|
|
3522
|
+
await authService.deleteWebAuthnCredential(db, credentialId, userId);
|
|
3523
|
+
|
|
3524
|
+
// Also remove from D1 passkey index
|
|
3525
|
+
await deletePasskey(db, credentialId).catch(() => {});
|
|
3526
|
+
|
|
3527
|
+
return c.json({ ok: true });
|
|
3528
|
+
});
|
|
3529
|
+
|
|
3530
|
+
// ─── GET /me — Current authenticated user info ──────────────
|
|
3531
|
+
|
|
3532
|
+
const getMe = createRoute({
|
|
3533
|
+
operationId: 'authGetMe',
|
|
3534
|
+
method: 'get',
|
|
3535
|
+
path: '/me',
|
|
3536
|
+
tags: ['client'],
|
|
3537
|
+
summary: 'Get current authenticated user info',
|
|
3538
|
+
responses: {
|
|
3539
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3540
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3541
|
+
},
|
|
3542
|
+
});
|
|
3543
|
+
|
|
3544
|
+
authRoute.openapi(getMe, async (c) => {
|
|
3545
|
+
const auth = c.get('auth');
|
|
3546
|
+
if (!auth) {
|
|
3547
|
+
return c.json({ code: 401, message: 'Authentication required.' }, 401);
|
|
3548
|
+
}
|
|
3549
|
+
const userId = requireAuth(auth);
|
|
3550
|
+
await ensureAuthActionAllowed(c, 'getMe', { userId });
|
|
3551
|
+
const db = getAuthDb(c);
|
|
3552
|
+
|
|
3553
|
+
const user = await authService.getUserById(db, userId);
|
|
3554
|
+
if (!user) return c.json({ code: 404, message: 'User not found' }, 404);
|
|
3555
|
+
|
|
3556
|
+
return c.json({ user: authService.sanitizeUser(user, { includeAppMetadata: true }) });
|
|
3557
|
+
});
|
|
3558
|
+
|
|
3559
|
+
const updateProfile = createRoute({
|
|
3560
|
+
operationId: 'authUpdateProfile',
|
|
3561
|
+
method: 'patch',
|
|
3562
|
+
path: '/profile',
|
|
3563
|
+
tags: ['client'],
|
|
3564
|
+
summary: 'Update user profile',
|
|
3565
|
+
request: {
|
|
3566
|
+
body: { content: { 'application/json': { schema: z.object({}).passthrough() } }, required: true },
|
|
3567
|
+
},
|
|
3568
|
+
responses: {
|
|
3569
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3570
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3571
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3572
|
+
},
|
|
3573
|
+
});
|
|
3574
|
+
|
|
3575
|
+
authRoute.openapi(updateProfile, async (c) => {
|
|
3576
|
+
const userId = requireAuth(c.get('auth'));
|
|
3577
|
+
const db = getAuthDb(c);
|
|
3578
|
+
|
|
3579
|
+
const body = c.req.valid('json') as {
|
|
3580
|
+
displayName?: string;
|
|
3581
|
+
avatarUrl?: string;
|
|
3582
|
+
emailVisibility?: 'public' | 'private';
|
|
3583
|
+
metadata?: Record<string, unknown>;
|
|
3584
|
+
locale?: string | null;
|
|
3585
|
+
};
|
|
3586
|
+
await ensureAuthActionAllowed(c, 'updateProfile', {
|
|
3587
|
+
userId,
|
|
3588
|
+
...(body.displayName !== undefined ? { displayName: body.displayName } : {}),
|
|
3589
|
+
...(body.avatarUrl !== undefined ? { avatarUrl: body.avatarUrl } : {}),
|
|
3590
|
+
...(body.emailVisibility !== undefined ? { emailVisibility: body.emailVisibility } : {}),
|
|
3591
|
+
...(body.locale !== undefined ? { locale: body.locale } : {}),
|
|
3592
|
+
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
|
|
3593
|
+
});
|
|
3594
|
+
|
|
3595
|
+
const updates: Record<string, unknown> = {};
|
|
3596
|
+
|
|
3597
|
+
if (body.displayName !== undefined) {
|
|
3598
|
+
if (typeof body.displayName === 'string' && body.displayName.length > 200) {
|
|
3599
|
+
throw new EdgeBaseError(400, 'Display name must not exceed 200 characters.', undefined, 'display-name-too-long');
|
|
3600
|
+
}
|
|
3601
|
+
updates.displayName = body.displayName;
|
|
3602
|
+
}
|
|
3603
|
+
if (body.avatarUrl !== undefined) {
|
|
3604
|
+
if (typeof body.avatarUrl === 'string' && body.avatarUrl.length > 2048) {
|
|
3605
|
+
throw new EdgeBaseError(400, 'Avatar URL must not exceed 2048 characters.', undefined, 'invalid-input');
|
|
3606
|
+
}
|
|
3607
|
+
updates.avatarUrl = body.avatarUrl;
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
// User-writable metadata (16KB limit)
|
|
3611
|
+
if (body.metadata !== undefined) {
|
|
3612
|
+
const metadataStr = JSON.stringify(body.metadata);
|
|
3613
|
+
if (metadataStr.length > 16384) {
|
|
3614
|
+
throw new EdgeBaseError(400, 'metadata exceeds 16KB limit.', undefined, 'invalid-input');
|
|
3615
|
+
}
|
|
3616
|
+
updates.metadata = metadataStr;
|
|
3617
|
+
}
|
|
3618
|
+
|
|
3619
|
+
// User locale preference for i18n emails
|
|
3620
|
+
if (body.locale !== undefined) {
|
|
3621
|
+
const localeVal = body.locale;
|
|
3622
|
+
if (localeVal !== null && !/^[a-z]{2}(-[A-Z]{2})?$/.test(localeVal as string)) {
|
|
3623
|
+
throw new EdgeBaseError(400, 'Invalid locale format. Use ISO 639-1 (e.g. "en", "ko", "ja-JP").', undefined, 'invalid-locale');
|
|
3624
|
+
}
|
|
3625
|
+
updates.locale = localeVal ?? 'en';
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
// emailVisibility change handling
|
|
3629
|
+
let isPrivacyDowngrade = false;
|
|
3630
|
+
let previousVisibility: string | null = null;
|
|
3631
|
+
|
|
3632
|
+
if (body.emailVisibility !== undefined) {
|
|
3633
|
+
if (!['public', 'private'].includes(body.emailVisibility)) {
|
|
3634
|
+
throw new EdgeBaseError(400, 'emailVisibility must be "public" or "private".', undefined, 'invalid-input');
|
|
3635
|
+
}
|
|
3636
|
+
isPrivacyDowngrade = body.emailVisibility === 'private';
|
|
3637
|
+
if (isPrivacyDowngrade) {
|
|
3638
|
+
const current = await authService.getUserById(db, userId);
|
|
3639
|
+
previousVisibility = (current?.emailVisibility as string) ?? 'private';
|
|
3640
|
+
}
|
|
3641
|
+
updates.emailVisibility = body.emailVisibility;
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
if (Object.keys(updates).length === 0) {
|
|
3645
|
+
throw new EdgeBaseError(400, 'No valid fields to update. Allowed fields: displayName, avatarUrl, emailVisibility, metadata.', undefined, 'no-fields-to-update');
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
await authService.updateUser(db, userId, updates);
|
|
3649
|
+
|
|
3650
|
+
const user = await authService.getUserById(db, userId);
|
|
3651
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
3652
|
+
|
|
3653
|
+
// Sync _users_public
|
|
3654
|
+
if (isPrivacyDowngrade && previousVisibility === 'public') {
|
|
3655
|
+
// Synchronous processing for privacy downgrade
|
|
3656
|
+
try {
|
|
3657
|
+
await syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(user), true);
|
|
3658
|
+
} catch {
|
|
3659
|
+
// Compensating transaction: restore previous emailVisibility
|
|
3660
|
+
await authService.updateUser(db, userId, { emailVisibility: previousVisibility });
|
|
3661
|
+
throw new EdgeBaseError(500, 'Privacy setting change failed. Please try again.', undefined, 'internal-error');
|
|
3662
|
+
}
|
|
3663
|
+
} else {
|
|
3664
|
+
syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(user));
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
// displayName is included in JWT, so issue fresh tokens when it changes
|
|
3668
|
+
if (body.displayName !== undefined) {
|
|
3669
|
+
const accessToken = await generateAccessToken(c.env, user);
|
|
3670
|
+
return c.json({ user: authService.sanitizeUser(user), accessToken });
|
|
3671
|
+
}
|
|
3672
|
+
|
|
3673
|
+
return c.json({ user: authService.sanitizeUser(user) });
|
|
3674
|
+
});
|
|
3675
|
+
|
|
3676
|
+
const getSessions = createRoute({
|
|
3677
|
+
operationId: 'authGetSessions',
|
|
3678
|
+
method: 'get',
|
|
3679
|
+
path: '/sessions',
|
|
3680
|
+
tags: ['client'],
|
|
3681
|
+
summary: 'List active sessions',
|
|
3682
|
+
responses: {
|
|
3683
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3684
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3685
|
+
},
|
|
3686
|
+
});
|
|
3687
|
+
|
|
3688
|
+
authRoute.openapi(getSessions, async (c) => {
|
|
3689
|
+
const userId = requireAuth(c.get('auth'));
|
|
3690
|
+
await ensureAuthActionAllowed(c, 'getSessions', { userId });
|
|
3691
|
+
const db = getAuthDb(c);
|
|
3692
|
+
|
|
3693
|
+
const sessions = await authService.listUserSessions(db, userId);
|
|
3694
|
+
|
|
3695
|
+
return c.json({ sessions });
|
|
3696
|
+
});
|
|
3697
|
+
|
|
3698
|
+
const deleteSession = createRoute({
|
|
3699
|
+
operationId: 'authDeleteSession',
|
|
3700
|
+
method: 'delete',
|
|
3701
|
+
path: '/sessions/{id}',
|
|
3702
|
+
tags: ['client'],
|
|
3703
|
+
summary: 'Delete a session',
|
|
3704
|
+
request: {
|
|
3705
|
+
params: z.object({ id: z.string() }),
|
|
3706
|
+
},
|
|
3707
|
+
responses: {
|
|
3708
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3709
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3710
|
+
404: { description: 'Session not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3711
|
+
},
|
|
3712
|
+
});
|
|
3713
|
+
|
|
3714
|
+
authRoute.openapi(deleteSession, async (c) => {
|
|
3715
|
+
const userId = requireAuth(c.get('auth'));
|
|
3716
|
+
const db = getAuthDb(c);
|
|
3717
|
+
const sessionId = c.req.param('id')!;
|
|
3718
|
+
await ensureAuthActionAllowed(c, 'deleteSession', { userId, sessionId });
|
|
3719
|
+
|
|
3720
|
+
await authService.deleteSessionForUser(db, sessionId, userId);
|
|
3721
|
+
|
|
3722
|
+
return c.json({ ok: true });
|
|
3723
|
+
});
|
|
3724
|
+
|
|
3725
|
+
const getIdentities = createRoute({
|
|
3726
|
+
operationId: 'authGetIdentities',
|
|
3727
|
+
method: 'get',
|
|
3728
|
+
path: '/identities',
|
|
3729
|
+
tags: ['client'],
|
|
3730
|
+
summary: 'List linked sign-in identities for the current user',
|
|
3731
|
+
responses: {
|
|
3732
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3733
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3734
|
+
},
|
|
3735
|
+
});
|
|
3736
|
+
|
|
3737
|
+
authRoute.openapi(getIdentities, async (c) => {
|
|
3738
|
+
const userId = requireAuth(c.get('auth'));
|
|
3739
|
+
await ensureAuthActionAllowed(c, 'getIdentities', { userId });
|
|
3740
|
+
const db = getAuthDb(c);
|
|
3741
|
+
|
|
3742
|
+
const { user, oauthAccounts, summary } = await getIdentityState(c.env, db, userId);
|
|
3743
|
+
|
|
3744
|
+
return c.json({
|
|
3745
|
+
identities: oauthAccounts.map((account) => ({
|
|
3746
|
+
id: account.id,
|
|
3747
|
+
kind: 'oauth',
|
|
3748
|
+
provider: account.provider,
|
|
3749
|
+
providerUserId: account.providerUserId,
|
|
3750
|
+
createdAt: account.createdAt,
|
|
3751
|
+
canUnlink: summary.total > 1,
|
|
3752
|
+
})),
|
|
3753
|
+
methods: {
|
|
3754
|
+
...summary,
|
|
3755
|
+
email: typeof user.email === 'string' ? user.email : null,
|
|
3756
|
+
phone: typeof user.phone === 'string' ? user.phone : null,
|
|
3757
|
+
},
|
|
3758
|
+
});
|
|
3759
|
+
});
|
|
3760
|
+
|
|
3761
|
+
const deleteIdentity = createRoute({
|
|
3762
|
+
operationId: 'authDeleteIdentity',
|
|
3763
|
+
method: 'delete',
|
|
3764
|
+
path: '/identities/{identityId}',
|
|
3765
|
+
tags: ['client'],
|
|
3766
|
+
summary: 'Unlink a linked sign-in identity',
|
|
3767
|
+
request: {
|
|
3768
|
+
params: z.object({ identityId: z.string() }),
|
|
3769
|
+
},
|
|
3770
|
+
responses: {
|
|
3771
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3772
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3773
|
+
404: { description: 'Identity not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3774
|
+
},
|
|
3775
|
+
});
|
|
3776
|
+
|
|
3777
|
+
authRoute.openapi(deleteIdentity, async (c) => {
|
|
3778
|
+
const userId = requireAuth(c.get('auth'));
|
|
3779
|
+
const db = getAuthDb(c);
|
|
3780
|
+
const identityId = c.req.param('identityId')!;
|
|
3781
|
+
await ensureAuthActionAllowed(c, 'deleteIdentity', { userId, identityId });
|
|
3782
|
+
|
|
3783
|
+
const { oauthAccounts, summary } = await getIdentityState(c.env, db, userId);
|
|
3784
|
+
const identity = oauthAccounts.find((account) => account.id === identityId);
|
|
3785
|
+
if (!identity) {
|
|
3786
|
+
throw new EdgeBaseError(404, 'Identity not found.', undefined, 'not-found');
|
|
3787
|
+
}
|
|
3788
|
+
if (summary.total <= 1) {
|
|
3789
|
+
throw new EdgeBaseError(400, 'Cannot unlink the last sign-in method.', undefined, 'invalid-input');
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
await authService.deleteOAuthAccount(db, identity.id);
|
|
3793
|
+
await deleteOAuth(db, identity.provider, identity.providerUserId).catch(() => {});
|
|
3794
|
+
|
|
3795
|
+
const next = await getIdentityState(c.env, db, userId);
|
|
3796
|
+
return c.json({
|
|
3797
|
+
ok: true,
|
|
3798
|
+
identities: next.oauthAccounts.map((account) => ({
|
|
3799
|
+
id: account.id,
|
|
3800
|
+
kind: 'oauth',
|
|
3801
|
+
provider: account.provider,
|
|
3802
|
+
providerUserId: account.providerUserId,
|
|
3803
|
+
createdAt: account.createdAt,
|
|
3804
|
+
canUnlink: next.summary.total > 1,
|
|
3805
|
+
})),
|
|
3806
|
+
methods: next.summary,
|
|
3807
|
+
});
|
|
3808
|
+
});
|
|
3809
|
+
|
|
3810
|
+
// ─── Anonymous → Email/Password linking ───
|
|
3811
|
+
|
|
3812
|
+
const linkEmail = createRoute({
|
|
3813
|
+
operationId: 'authLinkEmail',
|
|
3814
|
+
method: 'post',
|
|
3815
|
+
path: '/link/email',
|
|
3816
|
+
tags: ['client'],
|
|
3817
|
+
summary: 'Link email and password to existing account',
|
|
3818
|
+
request: {
|
|
3819
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
3820
|
+
email: z.string(),
|
|
3821
|
+
password: z.string(),
|
|
3822
|
+
}).passthrough() } }, required: true },
|
|
3823
|
+
},
|
|
3824
|
+
responses: {
|
|
3825
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3826
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3827
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3828
|
+
409: { description: 'Email already registered', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3829
|
+
},
|
|
3830
|
+
});
|
|
3831
|
+
|
|
3832
|
+
authRoute.openapi(linkEmail, async (c) => {
|
|
3833
|
+
const userId = requireAuth(c.get('auth'));
|
|
3834
|
+
const db = getAuthDb(c);
|
|
3835
|
+
const body = await c.req.json<{ email: string; password: string }>();
|
|
3836
|
+
|
|
3837
|
+
if (!body.email || !body.password) {
|
|
3838
|
+
throw new EdgeBaseError(400, 'Email and password are required.', undefined, 'invalid-input');
|
|
3839
|
+
}
|
|
3840
|
+
body.email = body.email.trim().toLowerCase();
|
|
3841
|
+
if (body.password.length < 8) {
|
|
3842
|
+
throw new EdgeBaseError(400, 'Password must be at least 8 characters.', undefined, 'password-too-short');
|
|
3843
|
+
}
|
|
3844
|
+
await ensureAuthActionAllowed(c, 'linkEmail', { userId, email: body.email });
|
|
3845
|
+
|
|
3846
|
+
// Verify user exists and is anonymous
|
|
3847
|
+
const user = await authService.getUserById(db, userId);
|
|
3848
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
3849
|
+
if (!user.isAnonymous) throw new EdgeBaseError(400, 'User is not anonymous.', undefined, 'invalid-input');
|
|
3850
|
+
if (Number(user.disabled) === 1) throw new EdgeBaseError(403, 'Account is disabled.', undefined, 'account-disabled');
|
|
3851
|
+
|
|
3852
|
+
// Check email uniqueness in D1
|
|
3853
|
+
const existing = await lookupEmail(db, body.email);
|
|
3854
|
+
if (existing) {
|
|
3855
|
+
throw new EdgeBaseError(409, 'Email is already registered.', undefined, 'email-already-exists');
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
// Register email as pending in D1
|
|
3859
|
+
try {
|
|
3860
|
+
await registerEmailPending(db, body.email, userId);
|
|
3861
|
+
} catch (err) {
|
|
3862
|
+
if ((err as Error).message === 'EMAIL_ALREADY_REGISTERED') {
|
|
3863
|
+
throw new EdgeBaseError(409, 'Email is already registered.', undefined, 'email-already-exists');
|
|
3864
|
+
}
|
|
3865
|
+
throw err;
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3868
|
+
// Update user in D1
|
|
3869
|
+
const passwordHash = await hashPassword(body.password);
|
|
3870
|
+
try {
|
|
3871
|
+
await authService.updateUser(db, userId, {
|
|
3872
|
+
email: body.email,
|
|
3873
|
+
passwordHash,
|
|
3874
|
+
isAnonymous: 0,
|
|
3875
|
+
});
|
|
3876
|
+
} catch (err) {
|
|
3877
|
+
await deleteEmailPending(db, body.email).catch(() => {});
|
|
3878
|
+
throw new EdgeBaseError(500, `Link failed: ${(err as Error).message}`, undefined, 'internal-error');
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3881
|
+
// Confirm email in D1
|
|
3882
|
+
await confirmEmail(db, body.email, userId);
|
|
3883
|
+
|
|
3884
|
+
// Best-effort: delete from _anon_index
|
|
3885
|
+
await deleteAnon(db, userId).catch(() => {});
|
|
3886
|
+
|
|
3887
|
+
// Sync _users_public
|
|
3888
|
+
const updatedUser = await authService.getUserById(db, userId);
|
|
3889
|
+
if (updatedUser) {
|
|
3890
|
+
syncUserPublic(c.env, c.executionCtx, userId, authService.buildPublicUserData(updatedUser));
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
// Generate new tokens (isAnonymous = false now)
|
|
3894
|
+
const session = await createSessionAndTokens(c.env, userId, '0.0.0.0', 'link');
|
|
3895
|
+
|
|
3896
|
+
return c.json({
|
|
3897
|
+
user: authService.sanitizeUser(updatedUser || user),
|
|
3898
|
+
accessToken: session.accessToken,
|
|
3899
|
+
refreshToken: session.refreshToken,
|
|
3900
|
+
});
|
|
3901
|
+
});
|
|
3902
|
+
|
|
3903
|
+
// ─── Email Verification & Password Reset (M14,) ───
|
|
3904
|
+
|
|
3905
|
+
const requestEmailVerification = createRoute({
|
|
3906
|
+
operationId: 'authRequestEmailVerification',
|
|
3907
|
+
method: 'post',
|
|
3908
|
+
path: '/request-email-verification',
|
|
3909
|
+
tags: ['client'],
|
|
3910
|
+
summary: 'Send a verification email to the current authenticated user',
|
|
3911
|
+
request: {
|
|
3912
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
3913
|
+
redirectUrl: z.string().optional(),
|
|
3914
|
+
state: z.string().optional(),
|
|
3915
|
+
}).passthrough() } }, required: false },
|
|
3916
|
+
},
|
|
3917
|
+
responses: {
|
|
3918
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
3919
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3920
|
+
401: { description: 'Authentication required', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3921
|
+
403: { description: 'Verification email is not available', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3922
|
+
429: { description: 'Too many requests', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
3923
|
+
},
|
|
3924
|
+
});
|
|
3925
|
+
|
|
3926
|
+
authRoute.openapi(requestEmailVerification, async (c) => {
|
|
3927
|
+
const userId = requireAuth(c.get('auth'));
|
|
3928
|
+
const body = await c.req.json<{
|
|
3929
|
+
redirectUrl?: string;
|
|
3930
|
+
state?: string;
|
|
3931
|
+
}>().catch(() => ({}));
|
|
3932
|
+
const redirect = parseClientRedirectInput(c.env, body);
|
|
3933
|
+
await ensureAuthActionAllowed(c, 'verifyEmail', { userId });
|
|
3934
|
+
|
|
3935
|
+
const db = getAuthDb(c);
|
|
3936
|
+
const user = await authService.getUserById(db, userId);
|
|
3937
|
+
if (!user) throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
|
|
3938
|
+
if (!user.email) throw new EdgeBaseError(400, 'Current user has no email address.', undefined, 'invalid-input');
|
|
3939
|
+
if (Number(user.isAnonymous) === 1) throw new EdgeBaseError(403, 'Anonymous users cannot request verification email.', undefined, 'anonymous-not-allowed');
|
|
3940
|
+
if (Number(user.disabled) === 1) throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
|
|
3941
|
+
|
|
3942
|
+
const rateKey = `verify-email-rate:${userId}`;
|
|
3943
|
+
if (!counter.check(rateKey, 3, 3600)) {
|
|
3944
|
+
throw new EdgeBaseError(429, 'Too many verification email requests. Try again later.', undefined, 'rate-limited');
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3947
|
+
await authService.deleteEmailTokensByUserAndType(db, userId, 'verify');
|
|
3948
|
+
|
|
3949
|
+
const token = crypto.randomUUID();
|
|
3950
|
+
const expiresInHours = 24;
|
|
3951
|
+
const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000).toISOString();
|
|
3952
|
+
await authService.createEmailToken(db, {
|
|
3953
|
+
token,
|
|
3954
|
+
userId,
|
|
3955
|
+
type: 'verify',
|
|
3956
|
+
expiresAt,
|
|
3957
|
+
});
|
|
3958
|
+
|
|
3959
|
+
const emailConfig = getEmailConfig(c.env);
|
|
3960
|
+
const fallbackVerifyUrl = emailConfig?.verifyUrl
|
|
3961
|
+
? emailConfig.verifyUrl.replace('{token}', token)
|
|
3962
|
+
: `#verify-email?token=${token}`;
|
|
3963
|
+
const verifyUrl = buildEmailActionUrl({
|
|
3964
|
+
redirectUrl: redirect.redirectUrl,
|
|
3965
|
+
fallbackUrl: fallbackVerifyUrl,
|
|
3966
|
+
token,
|
|
3967
|
+
type: 'verify',
|
|
3968
|
+
state: redirect.state,
|
|
3969
|
+
});
|
|
3970
|
+
|
|
3971
|
+
const provider = createEmailProvider(getEmailConfig(c.env), c.env);
|
|
3972
|
+
if (!provider) {
|
|
3973
|
+
const release = parseConfig(c.env)?.release ?? false;
|
|
3974
|
+
if (!release) {
|
|
3975
|
+
console.warn('[VerifyEmail] Email provider not configured. Verification email not sent. Token:', token);
|
|
3976
|
+
return c.json({ ok: true, message: 'Email provider not configured.', token, actionUrl: verifyUrl });
|
|
3977
|
+
}
|
|
3978
|
+
return c.json({ ok: true, message: 'Email provider not configured.' });
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3981
|
+
const locale = resolveEmailLocale(c.env, user.locale as string | null, parseAcceptLanguage(c.req.header('accept-language')));
|
|
3982
|
+
const html = renderVerifyEmail({
|
|
3983
|
+
appName: getAppName(c.env),
|
|
3984
|
+
verifyUrl,
|
|
3985
|
+
token,
|
|
3986
|
+
expiresInHours,
|
|
3987
|
+
}, resolveLocalizedString(getEmailTemplates(c.env)?.verification, locale), locale);
|
|
3988
|
+
|
|
3989
|
+
const defaultSubject = getDefaultSubject(locale, 'verification').replace(/\{\{appName\}\}/g, getAppName(c.env));
|
|
3990
|
+
const result = await sendMailWithHook(
|
|
3991
|
+
c.env, c.executionCtx, provider, 'verification', user.email as string,
|
|
3992
|
+
resolveSubject(c.env, 'verification', defaultSubject, locale), html, locale,
|
|
3993
|
+
);
|
|
3994
|
+
|
|
3995
|
+
return c.json({ ok: result.success, messageId: result.messageId });
|
|
3996
|
+
});
|
|
3997
|
+
|
|
3998
|
+
// POST /verify-email — KV token→shardId lookup → direct Shard call
|
|
3999
|
+
const verifyEmail = createRoute({
|
|
4000
|
+
operationId: 'authVerifyEmail',
|
|
4001
|
+
method: 'post',
|
|
4002
|
+
path: '/verify-email',
|
|
4003
|
+
tags: ['client'],
|
|
4004
|
+
summary: 'Verify email address with token',
|
|
4005
|
+
request: {
|
|
4006
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
4007
|
+
token: z.string(),
|
|
4008
|
+
}).passthrough() } }, required: true },
|
|
4009
|
+
},
|
|
4010
|
+
responses: {
|
|
4011
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
4012
|
+
400: { description: 'Invalid or expired token', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
4013
|
+
},
|
|
4014
|
+
});
|
|
4015
|
+
|
|
4016
|
+
authRoute.openapi(verifyEmail, async (c) => {
|
|
4017
|
+
const body = await c.req.json<{ token: string }>();
|
|
4018
|
+
if (!body.token) throw new EdgeBaseError(400, 'Verification token is required.', undefined, 'invalid-input');
|
|
4019
|
+
|
|
4020
|
+
await ensureAuthActionAllowed(c, 'verifyEmail', body as unknown as Record<string, unknown>);
|
|
4021
|
+
|
|
4022
|
+
const db = getAuthDb(c);
|
|
4023
|
+
|
|
4024
|
+
// Look up token directly in D1
|
|
4025
|
+
const row = await authService.getEmailTokenByType(db, body.token, 'verify');
|
|
4026
|
+
if (!row) throw new EdgeBaseError(400, 'Invalid or expired verification token.', undefined, 'invalid-token');
|
|
4027
|
+
|
|
4028
|
+
if (new Date(row.expiresAt as string) < new Date()) {
|
|
4029
|
+
await authService.deleteEmailToken(db, body.token);
|
|
4030
|
+
throw new EdgeBaseError(400, 'Verification token has expired. Please request a new one.', undefined, 'token-expired');
|
|
4031
|
+
}
|
|
4032
|
+
|
|
4033
|
+
const userId = row.userId as string;
|
|
4034
|
+
|
|
4035
|
+
await authService.updateUser(db, userId, { verified: 1 });
|
|
4036
|
+
await authService.deleteEmailTokensByUserAndType(db, userId, 'verify');
|
|
4037
|
+
|
|
4038
|
+
// onEmailVerified hook -- non-blocking
|
|
4039
|
+
const verifiedUser = await authService.getUserById(db, userId);
|
|
4040
|
+
if (verifiedUser) {
|
|
4041
|
+
c.executionCtx.waitUntil(
|
|
4042
|
+
executeAuthHook(c.env, c.executionCtx, 'onEmailVerified', authService.sanitizeUser(verifiedUser), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
4043
|
+
);
|
|
4044
|
+
}
|
|
4045
|
+
|
|
4046
|
+
return c.json({ ok: true, message: 'Email verified' });
|
|
4047
|
+
});
|
|
4048
|
+
|
|
4049
|
+
// POST /request-password-reset — D1 email lookup → Shard
|
|
4050
|
+
const requestPasswordReset = createRoute({
|
|
4051
|
+
operationId: 'authRequestPasswordReset',
|
|
4052
|
+
method: 'post',
|
|
4053
|
+
path: '/request-password-reset',
|
|
4054
|
+
tags: ['client'],
|
|
4055
|
+
summary: 'Request password reset email',
|
|
4056
|
+
request: {
|
|
4057
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
4058
|
+
email: z.string(),
|
|
4059
|
+
}).passthrough() } }, required: true },
|
|
4060
|
+
},
|
|
4061
|
+
responses: {
|
|
4062
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
4063
|
+
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
4064
|
+
},
|
|
4065
|
+
});
|
|
4066
|
+
|
|
4067
|
+
authRoute.openapi(requestPasswordReset, async (c) => {
|
|
4068
|
+
const body = await c.req.json<{
|
|
4069
|
+
email: string;
|
|
4070
|
+
redirectUrl?: string;
|
|
4071
|
+
state?: string;
|
|
4072
|
+
}>();
|
|
4073
|
+
if (!body.email) throw new EdgeBaseError(400, 'Email is required.', undefined, 'invalid-input');
|
|
4074
|
+
body.email = body.email.trim().toLowerCase();
|
|
4075
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
|
|
4076
|
+
throw new EdgeBaseError(400, 'Invalid email format.', undefined, 'invalid-email');
|
|
4077
|
+
}
|
|
4078
|
+
const redirect = parseClientRedirectInput(c.env, body);
|
|
4079
|
+
|
|
4080
|
+
await ensureAuthActionAllowed(c, 'requestPasswordReset', body as unknown as Record<string, unknown>);
|
|
4081
|
+
|
|
4082
|
+
const db = getAuthDb(c);
|
|
4083
|
+
|
|
4084
|
+
// Look up email in D1
|
|
4085
|
+
const record = await lookupEmail(db, body.email);
|
|
4086
|
+
|
|
4087
|
+
if (!record) {
|
|
4088
|
+
// Don't reveal whether email exists -- return ok
|
|
4089
|
+
return c.json({ ok: true, message: 'If the email exists, a reset link has been sent.' });
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
const { userId } = record;
|
|
4093
|
+
const user = await authService.getUserById(db, userId);
|
|
4094
|
+
if (!user || !user.email) {
|
|
4095
|
+
return c.json({ ok: true, message: 'If the email exists, a reset link has been sent.' });
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
// Delete old reset tokens for this user
|
|
4099
|
+
await authService.deleteEmailTokensByUserAndType(db, userId, 'password-reset');
|
|
4100
|
+
|
|
4101
|
+
const token = crypto.randomUUID();
|
|
4102
|
+
const now = new Date();
|
|
4103
|
+
const expiresAt = new Date(now.getTime() + 60 * 60 * 1000); // 1h
|
|
4104
|
+
|
|
4105
|
+
await authService.createEmailToken(db, {
|
|
4106
|
+
token,
|
|
4107
|
+
userId,
|
|
4108
|
+
type: 'password-reset',
|
|
4109
|
+
expiresAt: expiresAt.toISOString(),
|
|
4110
|
+
});
|
|
4111
|
+
|
|
4112
|
+
const emailConfig = getEmailConfig(c.env);
|
|
4113
|
+
const fallbackResetUrl = emailConfig?.resetUrl
|
|
4114
|
+
? emailConfig.resetUrl.replace('{token}', token)
|
|
4115
|
+
: `#reset-password?token=${token}`;
|
|
4116
|
+
const resetUrl = buildEmailActionUrl({
|
|
4117
|
+
redirectUrl: redirect.redirectUrl,
|
|
4118
|
+
fallbackUrl: fallbackResetUrl,
|
|
4119
|
+
token,
|
|
4120
|
+
type: 'password-reset',
|
|
4121
|
+
state: redirect.state,
|
|
4122
|
+
});
|
|
4123
|
+
|
|
4124
|
+
const provider = createEmailProvider(getEmailConfig(c.env), c.env);
|
|
4125
|
+
if (!provider) {
|
|
4126
|
+
const release = parseConfig(c.env)?.release ?? false;
|
|
4127
|
+
if (!release) {
|
|
4128
|
+
console.warn('[Auth] Email provider not configured. Reset email not sent. Token:', token);
|
|
4129
|
+
return c.json({ ok: true, message: 'Email provider not configured.', token, actionUrl: resetUrl });
|
|
4130
|
+
}
|
|
4131
|
+
return c.json({ ok: true, message: 'Email provider not configured.' });
|
|
4132
|
+
}
|
|
4133
|
+
|
|
4134
|
+
const locale = resolveEmailLocale(c.env, user.locale as string | null, parseAcceptLanguage(c.req.header('accept-language')));
|
|
4135
|
+
const html = renderPasswordReset({
|
|
4136
|
+
appName: getAppName(c.env),
|
|
4137
|
+
resetUrl,
|
|
4138
|
+
token,
|
|
4139
|
+
expiresInMinutes: 60,
|
|
4140
|
+
}, resolveLocalizedString(getEmailTemplates(c.env)?.passwordReset, locale), locale);
|
|
4141
|
+
|
|
4142
|
+
const defaultSubject = getDefaultSubject(locale, 'passwordReset').replace(/\{\{appName\}\}/g, getAppName(c.env));
|
|
4143
|
+
const result = await sendMailWithHook(
|
|
4144
|
+
c.env, c.executionCtx, provider, 'passwordReset', user.email as string,
|
|
4145
|
+
resolveSubject(c.env, 'passwordReset', defaultSubject, locale), html, locale,
|
|
4146
|
+
);
|
|
4147
|
+
|
|
4148
|
+
return c.json({ ok: result.success, messageId: result.messageId });
|
|
4149
|
+
});
|
|
4150
|
+
|
|
4151
|
+
// POST /reset-password — KV token→shardId lookup → direct Shard call
|
|
4152
|
+
const resetPassword = createRoute({
|
|
4153
|
+
operationId: 'authResetPassword',
|
|
4154
|
+
method: 'post',
|
|
4155
|
+
path: '/reset-password',
|
|
4156
|
+
tags: ['client'],
|
|
4157
|
+
summary: 'Reset password with token',
|
|
4158
|
+
request: {
|
|
4159
|
+
body: { content: { 'application/json': { schema: z.object({
|
|
4160
|
+
token: z.string(),
|
|
4161
|
+
newPassword: z.string(),
|
|
4162
|
+
}).passthrough() } }, required: true },
|
|
4163
|
+
},
|
|
4164
|
+
responses: {
|
|
4165
|
+
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
4166
|
+
400: { description: 'Invalid or expired token', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
4167
|
+
403: { description: 'Blocked by hook', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
4168
|
+
},
|
|
4169
|
+
});
|
|
4170
|
+
|
|
4171
|
+
authRoute.openapi(resetPassword, async (c) => {
|
|
4172
|
+
const body = await c.req.json<{ token: string; newPassword: string }>();
|
|
4173
|
+
if (!body.token) throw new EdgeBaseError(400, 'Password reset token is required.', undefined, 'invalid-input');
|
|
4174
|
+
if (!body.newPassword) throw new EdgeBaseError(400, 'New password is required.', undefined, 'invalid-input');
|
|
4175
|
+
|
|
4176
|
+
await ensureAuthActionAllowed(c, 'resetPassword', {
|
|
4177
|
+
token: body.token,
|
|
4178
|
+
newPassword: body.newPassword,
|
|
4179
|
+
});
|
|
4180
|
+
|
|
4181
|
+
const db = getAuthDb(c);
|
|
4182
|
+
|
|
4183
|
+
// Password policy validation
|
|
4184
|
+
const policyResult = await validatePassword(body.newPassword, getPasswordPolicyConfig(c.env));
|
|
4185
|
+
if (!policyResult.valid) {
|
|
4186
|
+
throw new EdgeBaseError(400, policyResult.errors[0], { password: { code: 'password_policy', message: policyResult.errors.join('; ') } }, 'password-policy');
|
|
4187
|
+
}
|
|
4188
|
+
|
|
4189
|
+
// Look up token in D1
|
|
4190
|
+
const row = await authService.getEmailTokenByType(db, body.token, 'password-reset');
|
|
4191
|
+
if (!row) throw new EdgeBaseError(400, 'Invalid or expired password reset token.', undefined, 'invalid-token');
|
|
4192
|
+
|
|
4193
|
+
if (new Date(row.expiresAt as string) < new Date()) {
|
|
4194
|
+
await authService.deleteEmailToken(db, body.token);
|
|
4195
|
+
throw new EdgeBaseError(400, 'Password reset token has expired. Please request a new one.', undefined, 'token-expired');
|
|
4196
|
+
}
|
|
4197
|
+
|
|
4198
|
+
const userId = row.userId as string;
|
|
4199
|
+
|
|
4200
|
+
await executeAuthHook(c.env, c.executionCtx, 'beforePasswordReset', { userId }, {
|
|
4201
|
+
blocking: true,
|
|
4202
|
+
workerUrl: getWorkerUrl(c.req.url, c.env),
|
|
4203
|
+
});
|
|
4204
|
+
|
|
4205
|
+
const newHash = await hashPassword(body.newPassword);
|
|
4206
|
+
await authService.updateUser(db, userId, { passwordHash: newHash });
|
|
4207
|
+
|
|
4208
|
+
// afterPasswordReset hook -- non-blocking
|
|
4209
|
+
const resetUser = await authService.getUserById(db, userId);
|
|
4210
|
+
if (resetUser) {
|
|
4211
|
+
c.executionCtx.waitUntil(
|
|
4212
|
+
executeAuthHook(c.env, c.executionCtx, 'afterPasswordReset', authService.sanitizeUser(resetUser), { workerUrl: getWorkerUrl(c.req.url, c.env) }).catch(() => {}),
|
|
4213
|
+
);
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4216
|
+
// Revoke all sessions (force re-login)
|
|
4217
|
+
await authService.deleteAllUserSessions(db, userId);
|
|
4218
|
+
// Delete all reset tokens
|
|
4219
|
+
await authService.deleteEmailTokensByUserAndType(db, userId, 'password-reset');
|
|
4220
|
+
|
|
4221
|
+
return c.json({ ok: true, message: 'Password reset. All sessions revoked.' });
|
|
4222
|
+
});
|