@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.
Files changed (309) hide show
  1. package/admin-build/.gitkeep +0 -0
  2. package/admin-build/_app/env.js +1 -0
  3. package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
  4. package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
  5. package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
  6. package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
  7. package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
  8. package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
  9. package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
  10. package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
  11. package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
  12. package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
  13. package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
  14. package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
  15. package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
  16. package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
  17. package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
  18. package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
  19. package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
  20. package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
  21. package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
  22. package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
  23. package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
  24. package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
  25. package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
  26. package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
  27. package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
  28. package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
  29. package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
  30. package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
  31. package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
  32. package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
  33. package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
  34. package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
  35. package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
  36. package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
  37. package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
  38. package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
  39. package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
  40. package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
  41. package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
  42. package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
  43. package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
  44. package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
  45. package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
  46. package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
  47. package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
  48. package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
  49. package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
  50. package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
  51. package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
  52. package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
  53. package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
  54. package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
  55. package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
  56. package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
  57. package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
  58. package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
  59. package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
  60. package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
  61. package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
  62. package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
  63. package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
  64. package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
  65. package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
  66. package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
  67. package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
  68. package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
  69. package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
  70. package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
  71. package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
  72. package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
  73. package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
  74. package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
  75. package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
  76. package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
  77. package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
  78. package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
  79. package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
  80. package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
  81. package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
  82. package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
  83. package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
  84. package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
  85. package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
  86. package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
  87. package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
  88. package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
  89. package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
  90. package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
  91. package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
  92. package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
  93. package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
  94. package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
  95. package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
  96. package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
  97. package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
  98. package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
  99. package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
  100. package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
  101. package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
  102. package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
  103. package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
  104. package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
  105. package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
  106. package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
  107. package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
  108. package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
  109. package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
  110. package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
  111. package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
  112. package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
  113. package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
  114. package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
  115. package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
  116. package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
  117. package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
  118. package/admin-build/_app/version.json +1 -0
  119. package/admin-build/favicon.svg +26 -0
  120. package/admin-build/index.html +45 -0
  121. package/openapi.json +19543 -0
  122. package/package.json +66 -0
  123. package/src/__tests__/admin-assets.test.ts +55 -0
  124. package/src/__tests__/admin-data-routes.test.ts +488 -0
  125. package/src/__tests__/admin-db-target.test.ts +103 -0
  126. package/src/__tests__/admin-routing.test.ts +31 -0
  127. package/src/__tests__/admin-user-management.test.ts +311 -0
  128. package/src/__tests__/analytics-query.test.ts +75 -0
  129. package/src/__tests__/auth-d1.test.ts +749 -0
  130. package/src/__tests__/auth-db-adapter.test.ts +73 -0
  131. package/src/__tests__/auth-jwt.test.ts +440 -0
  132. package/src/__tests__/auth-oauth.test.ts +389 -0
  133. package/src/__tests__/auth-password.test.ts +367 -0
  134. package/src/__tests__/auth-redirect.test.ts +87 -0
  135. package/src/__tests__/backup-restore.test.ts +711 -0
  136. package/src/__tests__/broadcast.test.ts +128 -0
  137. package/src/__tests__/cli.test.ts +178 -0
  138. package/src/__tests__/cloudflare-realtime.test.ts +113 -0
  139. package/src/__tests__/config.test.ts +469 -0
  140. package/src/__tests__/cors.test.ts +154 -0
  141. package/src/__tests__/cron.test.ts +302 -0
  142. package/src/__tests__/d1-handler.test.ts +402 -0
  143. package/src/__tests__/d1-sql.test.ts +120 -0
  144. package/src/__tests__/database-live-config.test.ts +42 -0
  145. package/src/__tests__/database-live-emitter.test.ts +56 -0
  146. package/src/__tests__/database-live-filters.test.ts +63 -0
  147. package/src/__tests__/database-live-route.test.ts +113 -0
  148. package/src/__tests__/db-sql.test.ts +163 -0
  149. package/src/__tests__/do-lifecycle.test.ts +263 -0
  150. package/src/__tests__/do-router.test.ts +729 -0
  151. package/src/__tests__/email-provider.test.ts +128 -0
  152. package/src/__tests__/email-templates.test.ts +528 -0
  153. package/src/__tests__/error-format.test.ts +250 -0
  154. package/src/__tests__/field-ops.test.ts +242 -0
  155. package/src/__tests__/functions-context.test.ts +334 -0
  156. package/src/__tests__/functions-d1-proxy.test.ts +229 -0
  157. package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
  158. package/src/__tests__/functions-route.test.ts +139 -0
  159. package/src/__tests__/internal-request.test.ts +77 -0
  160. package/src/__tests__/log-writer.test.ts +44 -0
  161. package/src/__tests__/logger.test.ts +58 -0
  162. package/src/__tests__/meta-admin-proxy.test.ts +48 -0
  163. package/src/__tests__/meta-export-coverage.test.ts +191 -0
  164. package/src/__tests__/meta-route-registration.test.ts +47 -0
  165. package/src/__tests__/namespace-dump.test.ts +28 -0
  166. package/src/__tests__/oauth-providers.test.ts +337 -0
  167. package/src/__tests__/openapi-coverage.test.ts +144 -0
  168. package/src/__tests__/pagination.test.ts +59 -0
  169. package/src/__tests__/password-policy.test.ts +191 -0
  170. package/src/__tests__/plugin-migrations.test.ts +379 -0
  171. package/src/__tests__/postgres-batch-compat.test.ts +133 -0
  172. package/src/__tests__/postgres-dialect.test.ts +328 -0
  173. package/src/__tests__/postgres-executor.test.ts +79 -0
  174. package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
  175. package/src/__tests__/postgres-schema-init.test.ts +105 -0
  176. package/src/__tests__/postgres-table-utils.test.ts +107 -0
  177. package/src/__tests__/presence.test.ts +199 -0
  178. package/src/__tests__/provider.test.ts +550 -0
  179. package/src/__tests__/public-user-profile.test.ts +339 -0
  180. package/src/__tests__/push-handlers.test.ts +179 -0
  181. package/src/__tests__/push-provider.test.ts +80 -0
  182. package/src/__tests__/push-token.test.ts +418 -0
  183. package/src/__tests__/query.test.ts +771 -0
  184. package/src/__tests__/rate-limit.test.ts +260 -0
  185. package/src/__tests__/room-access-policy.test.ts +101 -0
  186. package/src/__tests__/room-handler-context.test.ts +130 -0
  187. package/src/__tests__/room-monitoring.test.ts +138 -0
  188. package/src/__tests__/room-runtime-routing.test.ts +222 -0
  189. package/src/__tests__/room.test.ts +254 -0
  190. package/src/__tests__/route-parser.test.ts +490 -0
  191. package/src/__tests__/rules.test.ts +234 -0
  192. package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
  193. package/src/__tests__/scheduled.test.ts +80 -0
  194. package/src/__tests__/schema.test.ts +1273 -0
  195. package/src/__tests__/security-hardening.test.ts +312 -0
  196. package/src/__tests__/server.unit.test.ts +333 -0
  197. package/src/__tests__/service-key-db-proxy.test.ts +650 -0
  198. package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
  199. package/src/__tests__/service-key.test.ts +757 -0
  200. package/src/__tests__/smoke-skip-report.test.ts +72 -0
  201. package/src/__tests__/sms-provider.test.ts +39 -0
  202. package/src/__tests__/sql-route.test.ts +218 -0
  203. package/src/__tests__/storage-hook-context.test.ts +115 -0
  204. package/src/__tests__/totp.test.ts +200 -0
  205. package/src/__tests__/uuid.test.ts +144 -0
  206. package/src/__tests__/validation.test.ts +773 -0
  207. package/src/__tests__/websocket-pending.test.ts +163 -0
  208. package/src/_functions-registry.ts +51 -0
  209. package/src/bench-entry.ts +9 -0
  210. package/src/cloudflare-test.d.ts +1 -0
  211. package/src/durable-objects/auth-do.ts +49 -0
  212. package/src/durable-objects/database-do.ts +2240 -0
  213. package/src/durable-objects/database-live-do.ts +949 -0
  214. package/src/durable-objects/logs-do.ts +1200 -0
  215. package/src/durable-objects/room-runtime-base.ts +1604 -0
  216. package/src/durable-objects/rooms-do.ts +2191 -0
  217. package/src/generated-config.ts +6 -0
  218. package/src/index.ts +382 -0
  219. package/src/lib/admin-assets.ts +54 -0
  220. package/src/lib/admin-db-target.ts +301 -0
  221. package/src/lib/admin-routing.ts +35 -0
  222. package/src/lib/admin-user-management.ts +464 -0
  223. package/src/lib/analytics-adapter.ts +103 -0
  224. package/src/lib/analytics-query.ts +579 -0
  225. package/src/lib/auth-d1-service.ts +1193 -0
  226. package/src/lib/auth-d1.ts +1056 -0
  227. package/src/lib/auth-db-adapter.ts +289 -0
  228. package/src/lib/auth-redirect.ts +116 -0
  229. package/src/lib/cidr.ts +115 -0
  230. package/src/lib/client-ip.ts +51 -0
  231. package/src/lib/cloudflare-realtime.ts +251 -0
  232. package/src/lib/control-db.ts +36 -0
  233. package/src/lib/cron.ts +163 -0
  234. package/src/lib/d1-handler.ts +1425 -0
  235. package/src/lib/d1-schema-init.ts +255 -0
  236. package/src/lib/d1-sql.ts +33 -0
  237. package/src/lib/database-live-config.ts +24 -0
  238. package/src/lib/database-live-emitter.ts +111 -0
  239. package/src/lib/db-sql.ts +66 -0
  240. package/src/lib/do-retry.ts +36 -0
  241. package/src/lib/do-router.ts +270 -0
  242. package/src/lib/do-sql.ts +73 -0
  243. package/src/lib/email-provider.ts +379 -0
  244. package/src/lib/email-templates.ts +285 -0
  245. package/src/lib/email-translations.ts +422 -0
  246. package/src/lib/errors.ts +151 -0
  247. package/src/lib/functions.ts +2091 -0
  248. package/src/lib/hono.ts +56 -0
  249. package/src/lib/internal-request.ts +56 -0
  250. package/src/lib/jwt.ts +354 -0
  251. package/src/lib/log-writer.ts +272 -0
  252. package/src/lib/namespace-dump.ts +125 -0
  253. package/src/lib/oauth-providers.ts +1225 -0
  254. package/src/lib/op-parser.ts +99 -0
  255. package/src/lib/openapi.ts +146 -0
  256. package/src/lib/pagination.ts +19 -0
  257. package/src/lib/password-policy.ts +102 -0
  258. package/src/lib/password.ts +145 -0
  259. package/src/lib/plugin-migrations.ts +612 -0
  260. package/src/lib/postgres-executor.ts +203 -0
  261. package/src/lib/postgres-handler.ts +1102 -0
  262. package/src/lib/postgres-schema-init.ts +341 -0
  263. package/src/lib/postgres-table-utils.ts +87 -0
  264. package/src/lib/public-user-profile.ts +187 -0
  265. package/src/lib/push-provider.ts +409 -0
  266. package/src/lib/push-token.ts +294 -0
  267. package/src/lib/query-engine.ts +768 -0
  268. package/src/lib/room-monitoring.ts +97 -0
  269. package/src/lib/room-runtime.ts +14 -0
  270. package/src/lib/route-parser.ts +434 -0
  271. package/src/lib/schema.ts +538 -0
  272. package/src/lib/schemas.ts +152 -0
  273. package/src/lib/service-key.ts +419 -0
  274. package/src/lib/sms-provider.ts +230 -0
  275. package/src/lib/startup-config.ts +99 -0
  276. package/src/lib/totp.ts +242 -0
  277. package/src/lib/uuid.ts +87 -0
  278. package/src/lib/validation.ts +205 -0
  279. package/src/lib/version.ts +2 -0
  280. package/src/lib/websocket-pending.ts +40 -0
  281. package/src/middleware/auth.ts +169 -0
  282. package/src/middleware/captcha-verify.ts +217 -0
  283. package/src/middleware/cors.ts +159 -0
  284. package/src/middleware/error-handler.ts +54 -0
  285. package/src/middleware/internal-guard.ts +26 -0
  286. package/src/middleware/logger.ts +126 -0
  287. package/src/middleware/rate-limit.ts +283 -0
  288. package/src/middleware/rules.ts +475 -0
  289. package/src/routes/admin-auth.ts +447 -0
  290. package/src/routes/admin.ts +3501 -0
  291. package/src/routes/analytics-api.ts +290 -0
  292. package/src/routes/auth.ts +4222 -0
  293. package/src/routes/backup.ts +1466 -0
  294. package/src/routes/config.ts +53 -0
  295. package/src/routes/d1.ts +109 -0
  296. package/src/routes/database-live.ts +281 -0
  297. package/src/routes/functions.ts +155 -0
  298. package/src/routes/health.ts +32 -0
  299. package/src/routes/kv.ts +167 -0
  300. package/src/routes/oauth.ts +1055 -0
  301. package/src/routes/push.ts +1465 -0
  302. package/src/routes/room.ts +639 -0
  303. package/src/routes/schema-endpoint.ts +76 -0
  304. package/src/routes/sql.ts +176 -0
  305. package/src/routes/storage.ts +1674 -0
  306. package/src/routes/tables.ts +699 -0
  307. package/src/routes/users.ts +21 -0
  308. package/src/routes/vectorize.ts +372 -0
  309. package/src/types.ts +99 -0
@@ -0,0 +1,1055 @@
1
+ /**
2
+ * OAuth routes — Worker-level OAuth2 flow
3
+ *
4
+ * Mounted at /api/auth/oauth — resolved paths:
5
+ * GET /api/auth/oauth/:provider → Redirect to provider authorization URL
6
+ * GET /api/auth/oauth/:provider/callback → Handle OAuth callback, create/link user
7
+ * POST /api/auth/oauth/link/:provider → Start authenticated account linking redirect
8
+ * GET /api/auth/oauth/link/:provider/callback → Handle link OAuth callback
9
+ */
10
+ import { OpenAPIHono, createRoute, z, type HonoEnv } from '../lib/hono.js';
11
+ import type { Env } from '../types.js';
12
+ import { EdgeBaseError, getAuthAccess } from '@edge-base/shared';
13
+ import type { AuthAccess } from '@edge-base/shared';
14
+ import { parseConfig } from '../lib/do-router.js';
15
+ import {
16
+ appendRedirectParams,
17
+ parseClientRedirectInput,
18
+ parseClientRedirectUrl,
19
+ } from '../lib/auth-redirect.js';
20
+ import { zodDefaultHook, jsonResponseSchema, errorResponseSchema } from '../lib/schemas.js';
21
+ import {
22
+ isSupportedProvider,
23
+ createOAuthProvider,
24
+ getOAuthProviderConfig,
25
+ getAllowedOAuthProviders,
26
+ generatePKCE,
27
+ parseAppleIdToken,
28
+ parseOIDCIdToken,
29
+ prefetchOIDCDiscovery,
30
+ type OAuthUserInfo,
31
+ type OIDCProviderConfig,
32
+ type SupportedProvider,
33
+ } from '../lib/oauth-providers.js';
34
+ import {
35
+ ensureAuthSchema,
36
+ lookupOAuth,
37
+ registerOAuthPending,
38
+ confirmOAuth,
39
+ deleteOAuth,
40
+ lookupEmail,
41
+ registerEmailPending,
42
+ confirmEmail,
43
+ deleteEmail,
44
+ deleteEmailPending,
45
+ deleteAnon,
46
+ upsertUserPublic,
47
+ } from '../lib/auth-d1.js';
48
+ import type { UserPublicData } from '../lib/auth-d1.js';
49
+ import { captchaMiddleware } from '../middleware/captcha-verify.js';
50
+ import * as authService from '../lib/auth-d1-service.js';
51
+ import { signAccessToken, signRefreshToken, parseDuration } from '../lib/jwt.js';
52
+ import { generateId } from '../lib/uuid.js';
53
+ import { resolveAuthDb, type AuthDb } from '../lib/auth-db-adapter.js';
54
+ import { getTrustedClientIp } from '../lib/client-ip.js';
55
+
56
+ /** Resolve AuthDb from Hono context. Defaults to D1 (AUTH_DB binding). */
57
+ function getAuthDb(c: { env: unknown }): AuthDb {
58
+ return resolveAuthDb(c.env as Record<string, unknown>);
59
+ }
60
+
61
+ /** Resolve AuthDb from env directly (for helper functions). */
62
+ function getAuthDbFromEnv(env: unknown): AuthDb {
63
+ return resolveAuthDb(env as Record<string, unknown>);
64
+ }
65
+
66
+ type OAuthRuntimeConfig = Record<string, unknown> & {
67
+ baseUrl?: string;
68
+ captcha?: boolean;
69
+ auth?: {
70
+ session?: {
71
+ accessTokenTTL?: string;
72
+ refreshTokenTTL?: string;
73
+ };
74
+ };
75
+ };
76
+
77
+ function getOAuthRuntimeConfig(env: Env): OAuthRuntimeConfig {
78
+ return parseConfig(env) as unknown as OAuthRuntimeConfig;
79
+ }
80
+
81
+ export const oauthRoute = new OpenAPIHono<HonoEnv>({ defaultHook: zodDefaultHook });
82
+
83
+ // Error handler for OAuth sub-app
84
+ oauthRoute.onError((err, c) => {
85
+ if (err instanceof EdgeBaseError) {
86
+ return c.json(err.toJSON(), err.code as 400);
87
+ }
88
+ console.error('OAuth unhandled error:', err);
89
+ return c.json({ code: 500, message: 'OAuth error.' }, 500);
90
+ });
91
+
92
+ // ─── Helpers ───
93
+
94
+ function getBaseUrl(c: { env: Env; req: { url: string } }): string {
95
+ try {
96
+ const baseUrl = getOAuthRuntimeConfig(c.env).baseUrl;
97
+ if (typeof baseUrl === 'string' && baseUrl.length > 0) {
98
+ return baseUrl.replace(/\/$/, '');
99
+ }
100
+ } catch {
101
+ // Fall back to request-derived origin below.
102
+ }
103
+
104
+ try {
105
+ const requestUrl = new URL(c.req.url);
106
+ return requestUrl.origin.replace(/\/$/, '');
107
+ } catch {
108
+ return '';
109
+ }
110
+ }
111
+
112
+ function generateState(): string {
113
+ const bytes = new Uint8Array(32);
114
+ crypto.getRandomValues(bytes);
115
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
116
+ }
117
+
118
+ function getClientIP(env: Env, request: Request): string {
119
+ return getTrustedClientIp(env, request) ?? '0.0.0.0';
120
+ }
121
+
122
+ type AuthAccessAction = Extract<keyof AuthAccess, string>;
123
+
124
+ async function ensureAuthActionAllowed(
125
+ c: { env: Env; req: { raw: Request }; get(name: string): unknown },
126
+ action: AuthAccessAction,
127
+ input: Record<string, unknown> | null,
128
+ ): Promise<void> {
129
+ const config = parseConfig(c.env);
130
+ const rule = getAuthAccess(config.auth)?.[action];
131
+ if (!rule) return;
132
+
133
+ const auth = (c.get('auth') as {
134
+ id: string;
135
+ role?: string;
136
+ email?: string | null;
137
+ isAnonymous?: boolean;
138
+ custom?: Record<string, unknown> | null;
139
+ meta?: Record<string, unknown>;
140
+ } | null | undefined) ?? null;
141
+
142
+ const allowed = await Promise.resolve(rule(input, {
143
+ request: c.req.raw,
144
+ auth: auth ? {
145
+ id: auth.id,
146
+ role: auth.role,
147
+ email: auth.email ?? undefined,
148
+ isAnonymous: auth.isAnonymous,
149
+ custom: auth.custom ?? undefined,
150
+ meta: auth.meta,
151
+ } : null,
152
+ ip: getClientIP(c.env, c.req.raw),
153
+ }));
154
+
155
+ if (!allowed) {
156
+ throw new EdgeBaseError(403, `Auth action '${action}' is not allowed.`, undefined, 'action-not-allowed');
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Create a session and generate JWT tokens for an OAuth user.
162
+ * Shared by all OAuth flows (sign-in, auto-link, create, link).
163
+ */
164
+ async function createOAuthSessionAndTokens(
165
+ env: Env,
166
+ user: Record<string, unknown>,
167
+ ): Promise<{ accessToken: string; refreshToken: string }> {
168
+ const userId = user.id as string;
169
+ const secret = env.JWT_USER_SECRET;
170
+ if (!secret) throw new EdgeBaseError(500, 'JWT_USER_SECRET is not configured.', undefined, 'internal-error');
171
+
172
+ const config = getOAuthRuntimeConfig(env);
173
+ const accessTTL = config.auth?.session?.accessTokenTTL ?? '15m';
174
+ const refreshTTL = config.auth?.session?.refreshTokenTTL ?? '28d';
175
+
176
+ const dbClaims = user.customClaims
177
+ ? (typeof user.customClaims === 'string' ? JSON.parse(user.customClaims as string) : user.customClaims)
178
+ : undefined;
179
+
180
+ const accessToken = await signAccessToken(
181
+ {
182
+ sub: userId,
183
+ email: user.email as string | null,
184
+ displayName: (user.displayName as string | null) ?? undefined,
185
+ role: user.role as string,
186
+ isAnonymous: (typeof user.isAnonymous === 'number') ? user.isAnonymous === 1 : !!user.isAnonymous,
187
+ custom: dbClaims,
188
+ },
189
+ secret,
190
+ accessTTL,
191
+ );
192
+
193
+ const sessionId = generateId();
194
+ const refreshToken = await signRefreshToken(
195
+ { sub: userId, type: 'refresh', jti: sessionId },
196
+ secret,
197
+ refreshTTL,
198
+ );
199
+
200
+ const now = new Date().toISOString();
201
+ const refreshTTLSeconds = parseDuration(refreshTTL);
202
+ const expiresAt = new Date(Date.now() + refreshTTLSeconds * 1000).toISOString();
203
+
204
+ await authService.createSession(getAuthDbFromEnv(env), {
205
+ id: sessionId,
206
+ userId,
207
+ refreshToken,
208
+ expiresAt,
209
+ metadata: JSON.stringify({ ip: '0.0.0.0', userAgent: 'OAuth', lastActiveAt: now }),
210
+ });
211
+
212
+ return { accessToken, refreshToken };
213
+ }
214
+
215
+ // ─── D1 Schema Middleware ───
216
+
217
+ oauthRoute.use('*', async (c, next) => {
218
+ await ensureAuthSchema(getAuthDb(c));
219
+ await next();
220
+ });
221
+
222
+ // ─── Captcha for OAuth start ───
223
+ // captcha_token is passed as query parameter for GET requests
224
+ oauthRoute.use('/:provider', captchaMiddleware('oauth'));
225
+
226
+ // ─── GET /api/auth/oauth/:provider — Redirect to OAuth provider ───
227
+
228
+ const oauthRedirect = createRoute({
229
+ operationId: 'oauthRedirect',
230
+ method: 'get',
231
+ path: '/{provider}',
232
+ tags: ['client'],
233
+ summary: 'Start OAuth redirect',
234
+ request: { params: z.object({ provider: z.string() }) },
235
+ responses: {
236
+ 302: { description: 'Redirect to OAuth provider' },
237
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
238
+ },
239
+ });
240
+
241
+ oauthRoute.openapi(oauthRedirect, async (c) => {
242
+ const providerName = c.req.param('provider')!;
243
+ const appRedirectUrl = parseClientRedirectUrl(
244
+ c.env,
245
+ c.req.query('redirect_url') ?? c.req.query('redirectUrl'),
246
+ );
247
+
248
+ if (!isSupportedProvider(providerName)) {
249
+ throw new EdgeBaseError(400, `Unsupported OAuth provider: ${providerName}`, undefined, 'validation-failed');
250
+ }
251
+ await ensureAuthActionAllowed(c, 'oauthRedirect', { provider: providerName });
252
+
253
+ // Check if provider is allowed
254
+ const configObj = getOAuthRuntimeConfig(c.env);
255
+ const allowed = getAllowedOAuthProviders(configObj);
256
+ if (allowed.length > 0 && !allowed.includes(providerName)) {
257
+ throw new EdgeBaseError(400, `OAuth provider ${providerName} is not enabled.`, undefined, 'feature-not-enabled');
258
+ }
259
+
260
+ const providerConfig = getOAuthProviderConfig(configObj, providerName);
261
+ if (!providerConfig) {
262
+ throw new EdgeBaseError(500, `OAuth provider ${providerName} is not configured.`, undefined, 'internal-error');
263
+ }
264
+
265
+ // Pre-fetch OIDC discovery document (must happen before getAuthorizationUrl)
266
+ if (providerName.startsWith('oidc:') && (providerConfig as OIDCProviderConfig).issuer) {
267
+ await prefetchOIDCDiscovery((providerConfig as OIDCProviderConfig).issuer);
268
+ }
269
+
270
+ const provider = createOAuthProvider(providerName, providerConfig);
271
+ const state = generateState();
272
+ const redirectUri = `${getBaseUrl(c)}/api/auth/oauth/${encodeURIComponent(providerName)}/callback`;
273
+
274
+ // PKCE for providers that require or strongly prefer it.
275
+ let codeChallenge: string | undefined;
276
+ let codeVerifier: string | undefined;
277
+ if (providerName === 'google' || providerName === 'x' || providerName.startsWith('oidc:')) {
278
+ const pkce = await generatePKCE();
279
+ codeChallenge = pkce.codeChallenge;
280
+ codeVerifier = pkce.codeVerifier;
281
+ }
282
+
283
+ // Determine if captcha was verified for this request
284
+ let captchaPassed = false;
285
+ try {
286
+ if (getOAuthRuntimeConfig(c.env).captcha) {
287
+ captchaPassed = true;
288
+ }
289
+ } catch { /* ignore */ }
290
+
291
+ // Store state in KV
292
+ await c.env.KV.put(
293
+ `oauth:state:${state}`,
294
+ JSON.stringify({
295
+ provider: providerName,
296
+ redirectUri,
297
+ codeVerifier: codeVerifier || null,
298
+ appRedirectUrl,
299
+ ...(captchaPassed ? { captcha_passed: true } : {}),
300
+ }),
301
+ { expirationTtl: 300 },
302
+ );
303
+
304
+ const authUrl = provider.getAuthorizationUrl(state, redirectUri, codeChallenge);
305
+ return c.redirect(authUrl);
306
+ });
307
+
308
+ // ─── GET /api/auth/oauth/:provider/callback — Handle OAuth callback ───
309
+
310
+ const oauthCallback = createRoute({
311
+ operationId: 'oauthCallback',
312
+ method: 'get',
313
+ path: '/{provider}/callback',
314
+ tags: ['client'],
315
+ summary: 'OAuth callback',
316
+ request: { params: z.object({ provider: z.string() }) },
317
+ responses: {
318
+ 200: { description: 'Auth tokens', content: { 'application/json': { schema: jsonResponseSchema } } },
319
+ 302: { description: 'Redirect with tokens' },
320
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
321
+ },
322
+ });
323
+
324
+ oauthRoute.openapi(oauthCallback, async (c) => {
325
+ const providerName = c.req.param('provider')!;
326
+ const code = c.req.query('code');
327
+ const state = c.req.query('state');
328
+ const error = c.req.query('error');
329
+
330
+ if (error) {
331
+ if (state) {
332
+ const stateData = await c.env.KV.get(`oauth:state:${state}`);
333
+ if (stateData) {
334
+ try {
335
+ const stored = JSON.parse(stateData) as {
336
+ provider: string;
337
+ appRedirectUrl?: string | null;
338
+ };
339
+ if (stored.provider === providerName && stored.appRedirectUrl) {
340
+ await c.env.KV.delete(`oauth:state:${state}`);
341
+ return c.redirect(appendRedirectParams(stored.appRedirectUrl, {
342
+ error,
343
+ error_description: c.req.query('error_description') || error,
344
+ }));
345
+ }
346
+ } catch {
347
+ // Fall through to JSON error response.
348
+ }
349
+ }
350
+ }
351
+ throw new EdgeBaseError(400, `OAuth error: ${c.req.query('error_description') || error}`, undefined, 'validation-failed');
352
+ }
353
+ if (!code || !state) {
354
+ throw new EdgeBaseError(400, 'Missing code or state parameter.', undefined, 'validation-failed');
355
+ }
356
+ if (!isSupportedProvider(providerName)) {
357
+ throw new EdgeBaseError(400, `Unsupported OAuth provider: ${providerName}`, undefined, 'validation-failed');
358
+ }
359
+
360
+ // Verify state from KV
361
+ const stateData = await c.env.KV.get(`oauth:state:${state}`);
362
+ if (!stateData) {
363
+ throw new EdgeBaseError(400, 'Invalid or expired OAuth state.', undefined, 'invalid-token');
364
+ }
365
+
366
+ const { provider: storedProvider, redirectUri, codeVerifier, captcha_passed, appRedirectUrl } = JSON.parse(stateData) as {
367
+ provider: string;
368
+ redirectUri: string;
369
+ codeVerifier: string | null;
370
+ captcha_passed?: boolean;
371
+ appRedirectUrl?: string | null;
372
+ };
373
+ if (storedProvider !== providerName) {
374
+ throw new EdgeBaseError(400, 'OAuth state provider mismatch.', undefined, 'validation-failed');
375
+ }
376
+ await ensureAuthActionAllowed(c, 'oauthCallback', { provider: providerName, state });
377
+ // Delete state immediately after policy check (single-use)
378
+ await c.env.KV.delete(`oauth:state:${state}`);
379
+
380
+ // Verify captcha was passed during OAuth initiation
381
+ try {
382
+ if (getOAuthRuntimeConfig(c.env).captcha && !captcha_passed) {
383
+ if (!c.req.header('X-EdgeBase-Service-Key')) {
384
+ throw new EdgeBaseError(403, 'Captcha verification required for OAuth.', undefined, 'forbidden');
385
+ }
386
+ }
387
+ } catch (e) {
388
+ if (e instanceof EdgeBaseError) throw e;
389
+ }
390
+
391
+ const configObj = getOAuthRuntimeConfig(c.env);
392
+ const providerConfig = getOAuthProviderConfig(configObj, providerName);
393
+ if (!providerConfig) {
394
+ throw new EdgeBaseError(500, `OAuth provider ${providerName} is not configured.`, undefined, 'internal-error');
395
+ }
396
+
397
+ const provider = createOAuthProvider(providerName, providerConfig);
398
+
399
+ // Exchange code for tokens
400
+ const tokens = await provider.exchangeCode(code, redirectUri, codeVerifier || undefined);
401
+
402
+ // Get user info
403
+ let userInfo: OAuthUserInfo;
404
+ if (providerName === 'apple' && tokens.idToken) {
405
+ userInfo = parseAppleIdToken(tokens.idToken);
406
+ } else if (providerName.startsWith('oidc:') && tokens.idToken) {
407
+ // OIDC: prefer id_token claims, fall back to userinfo endpoint
408
+ userInfo = parseOIDCIdToken(tokens.idToken);
409
+ } else {
410
+ userInfo = await provider.getUserInfo(tokens.accessToken);
411
+ }
412
+
413
+ // Normalize email
414
+ if (userInfo.email) {
415
+ userInfo = { ...userInfo, email: userInfo.email.trim().toLowerCase() };
416
+ }
417
+
418
+ // Process OAuth callback — this is the core logic
419
+ const result = await processOAuthCallback(c.env, providerName, userInfo);
420
+ if (appRedirectUrl) {
421
+ return c.redirect(appendRedirectParams(appRedirectUrl, {
422
+ access_token: result.accessToken,
423
+ refresh_token: result.refreshToken,
424
+ }));
425
+ }
426
+ return c.json(result, result.created ? 201 : 200);
427
+ });
428
+
429
+ // ─── POST /api/auth/oauth/link/:provider — Start anonymous→OAuth linking ───
430
+
431
+ const oauthLinkStart = createRoute({
432
+ operationId: 'oauthLinkStart',
433
+ method: 'post',
434
+ path: '/link/{provider}',
435
+ tags: ['client'],
436
+ summary: 'Start OAuth account linking',
437
+ request: { params: z.object({ provider: z.string() }) },
438
+ responses: {
439
+ 200: { description: 'Redirect URL', content: { 'application/json': { schema: jsonResponseSchema } } },
440
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
441
+ 401: { description: 'Unauthorized', content: { 'application/json': { schema: errorResponseSchema } } },
442
+ },
443
+ });
444
+
445
+ oauthRoute.openapi(oauthLinkStart, async (c) => {
446
+ const providerName = c.req.param('provider')!;
447
+ const body = await c.req.json<{ redirectUrl?: string; state?: string }>().catch(() => null);
448
+ const redirect = parseClientRedirectInput(c.env, body);
449
+ const appRedirectUrl = redirect.redirectUrl;
450
+
451
+ if (!isSupportedProvider(providerName)) {
452
+ throw new EdgeBaseError(400, `Unsupported OAuth provider: ${providerName}`, undefined, 'validation-failed');
453
+ }
454
+
455
+ // Verify JWT — user must be authenticated.
456
+ const auth = c.get('auth') as { id: string; isAnonymous: boolean } | null;
457
+ if (!auth) {
458
+ throw new EdgeBaseError(401, 'Authentication required.', undefined, 'unauthenticated');
459
+ }
460
+
461
+ const userId = auth.id;
462
+ await ensureAuthActionAllowed(c, 'oauthLinkStart', { provider: providerName, userId });
463
+
464
+ const currentUser = await authService.getUserById(getAuthDb(c), userId);
465
+ if (!currentUser) {
466
+ throw new EdgeBaseError(404, 'User not found.', undefined, 'user-not-found');
467
+ }
468
+ if (Number(currentUser.disabled) === 1) {
469
+ throw new EdgeBaseError(403, 'This account has been disabled.', undefined, 'account-disabled');
470
+ }
471
+
472
+ // Check if provider is allowed
473
+ const configObj2 = getOAuthRuntimeConfig(c.env);
474
+ const allowed2 = getAllowedOAuthProviders(configObj2);
475
+ if (allowed2.length > 0 && !allowed2.includes(providerName)) {
476
+ throw new EdgeBaseError(400, `OAuth provider ${providerName} is not enabled.`, undefined, 'feature-not-enabled');
477
+ }
478
+
479
+ const providerConfig2 = getOAuthProviderConfig(configObj2, providerName);
480
+ if (!providerConfig2) {
481
+ throw new EdgeBaseError(500, `OAuth provider ${providerName} is not configured.`, undefined, 'internal-error');
482
+ }
483
+
484
+ const provider = createOAuthProvider(providerName, providerConfig2);
485
+ const state = generateState();
486
+ const redirectUri = `${getBaseUrl(c)}/api/auth/oauth/link/${providerName}/callback`;
487
+ const linkMode = auth.isAnonymous ? 'anonymous-upgrade' : 'attach-oauth';
488
+
489
+ // PKCE for Google and OIDC providers
490
+ let codeChallenge: string | undefined;
491
+ let codeVerifier: string | undefined;
492
+ if (providerName === 'google' || providerName.startsWith('oidc:')) {
493
+ const pkce = await generatePKCE();
494
+ codeChallenge = pkce.codeChallenge;
495
+ codeVerifier = pkce.codeVerifier;
496
+ }
497
+
498
+ // Store state in KV with link metadata (shardId kept as 0 for legacy compatibility)
499
+ await c.env.KV.put(
500
+ `oauth:link-state:${state}`,
501
+ JSON.stringify({
502
+ provider: providerName,
503
+ redirectUri,
504
+ codeVerifier: codeVerifier || null,
505
+ appRedirectUrl,
506
+ linkUserId: userId,
507
+ linkMode,
508
+ appState: redirect.state,
509
+ }),
510
+ { expirationTtl: 300 },
511
+ );
512
+
513
+ const authUrl = provider.getAuthorizationUrl(state, redirectUri, codeChallenge);
514
+ return c.json({ redirectUrl: authUrl });
515
+ });
516
+
517
+ // ─── GET /api/auth/oauth/link/:provider/callback — Handle link OAuth callback ───
518
+
519
+ const oauthLinkCallback = createRoute({
520
+ operationId: 'oauthLinkCallback',
521
+ method: 'get',
522
+ path: '/link/{provider}/callback',
523
+ tags: ['client'],
524
+ summary: 'OAuth link callback',
525
+ request: { params: z.object({ provider: z.string() }) },
526
+ responses: {
527
+ 200: { description: 'Link result', content: { 'application/json': { schema: jsonResponseSchema } } },
528
+ 302: { description: 'Redirect after linking' },
529
+ 400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
530
+ },
531
+ });
532
+
533
+ oauthRoute.openapi(oauthLinkCallback, async (c) => {
534
+ const providerName = c.req.param('provider')!;
535
+ const code = c.req.query('code');
536
+ const state = c.req.query('state');
537
+ const error = c.req.query('error');
538
+
539
+ if (error) {
540
+ if (state) {
541
+ const stateData = await c.env.KV.get(`oauth:link-state:${state}`);
542
+ if (stateData) {
543
+ try {
544
+ const stored = JSON.parse(stateData) as {
545
+ provider: string;
546
+ appState?: string | null;
547
+ appRedirectUrl?: string | null;
548
+ };
549
+ if (stored.provider === providerName && stored.appRedirectUrl) {
550
+ await c.env.KV.delete(`oauth:link-state:${state}`);
551
+ return c.redirect(appendRedirectParams(stored.appRedirectUrl, {
552
+ error,
553
+ error_description: c.req.query('error_description') || error,
554
+ state: stored.appState ?? undefined,
555
+ }));
556
+ }
557
+ } catch {
558
+ // Fall through to JSON error response.
559
+ }
560
+ }
561
+ }
562
+ throw new EdgeBaseError(400, `OAuth error: ${c.req.query('error_description') || error}`, undefined, 'validation-failed');
563
+ }
564
+ if (!code || !state) {
565
+ throw new EdgeBaseError(400, 'Missing code or state parameter.', undefined, 'validation-failed');
566
+ }
567
+ if (!isSupportedProvider(providerName)) {
568
+ throw new EdgeBaseError(400, `Unsupported OAuth provider: ${providerName}`, undefined, 'validation-failed');
569
+ }
570
+
571
+ // Verify link state from KV (different prefix from regular OAuth)
572
+ const stateData = await c.env.KV.get(`oauth:link-state:${state}`);
573
+ if (!stateData) {
574
+ throw new EdgeBaseError(400, 'Invalid or expired OAuth link state.', undefined, 'invalid-token');
575
+ }
576
+
577
+ const { provider: storedProvider, redirectUri, codeVerifier, linkUserId, appRedirectUrl, linkMode, appState } = JSON.parse(stateData) as {
578
+ provider: string;
579
+ redirectUri: string;
580
+ codeVerifier: string | null;
581
+ linkUserId: string;
582
+ linkMode?: 'anonymous-upgrade' | 'attach-oauth';
583
+ appState?: string | null;
584
+ appRedirectUrl?: string | null;
585
+ };
586
+ if (storedProvider !== providerName) {
587
+ throw new EdgeBaseError(400, 'OAuth state provider mismatch.', undefined, 'validation-failed');
588
+ }
589
+ await ensureAuthActionAllowed(c, 'oauthLinkCallback', {
590
+ provider: providerName,
591
+ state,
592
+ linkUserId,
593
+ });
594
+ await c.env.KV.delete(`oauth:link-state:${state}`);
595
+
596
+ const configObj = getOAuthRuntimeConfig(c.env);
597
+ const providerConfig = getOAuthProviderConfig(configObj, providerName);
598
+ if (!providerConfig) {
599
+ throw new EdgeBaseError(500, `OAuth provider ${providerName} is not configured.`, undefined, 'internal-error');
600
+ }
601
+
602
+ const provider = createOAuthProvider(providerName, providerConfig);
603
+
604
+ // Exchange code for tokens
605
+ const tokens = await provider.exchangeCode(code, redirectUri, codeVerifier || undefined);
606
+
607
+ // Get user info
608
+ let userInfo: OAuthUserInfo;
609
+ if (providerName === 'apple' && tokens.idToken) {
610
+ userInfo = parseAppleIdToken(tokens.idToken);
611
+ } else {
612
+ userInfo = await provider.getUserInfo(tokens.accessToken);
613
+ }
614
+
615
+ // Normalize email
616
+ if (userInfo.email) {
617
+ userInfo = { ...userInfo, email: userInfo.email.trim().toLowerCase() };
618
+ }
619
+
620
+ // Process link OAuth callback
621
+ const result = linkMode === 'attach-oauth'
622
+ ? await processAttachOAuthCallback(c.env, providerName, userInfo, linkUserId)
623
+ : await processLinkOAuthCallback(c.env, providerName, userInfo, linkUserId);
624
+ if (appRedirectUrl) {
625
+ return c.redirect(appendRedirectParams(appRedirectUrl, {
626
+ access_token: result.accessToken,
627
+ refresh_token: result.refreshToken,
628
+ state: appState ?? undefined,
629
+ }));
630
+ }
631
+ return c.json(result);
632
+ });
633
+
634
+ // ─── Core OAuth callback processing (D1-based,) ───
635
+
636
+ interface OAuthResult {
637
+ user: Record<string, unknown>;
638
+ accessToken: string;
639
+ refreshToken: string;
640
+ created: boolean;
641
+ }
642
+
643
+ async function processOAuthCallback(
644
+ env: Env,
645
+ providerName: SupportedProvider,
646
+ userInfo: OAuthUserInfo,
647
+ ): Promise<OAuthResult> {
648
+ const db = getAuthDbFromEnv(env);
649
+ // Step 1: Check _oauth_index in D1 for existing OAuth account
650
+ const oauthRecord = await lookupOAuth(db, providerName, userInfo.providerUserId);
651
+
652
+ // Case A: Existing OAuth account → just sign in
653
+ if (oauthRecord) {
654
+ const { userId } = oauthRecord;
655
+ const user = await authService.getUserById(db, userId);
656
+ if (!user) throw new EdgeBaseError(500, 'User not found for OAuth account.', undefined, 'internal-error');
657
+ const { accessToken, refreshToken } = await createOAuthSessionAndTokens(env, user);
658
+ return { user: authService.sanitizeUser(user), accessToken, refreshToken, created: false };
659
+ }
660
+
661
+ // Step 2: Check _email_index in D1 for auto-linking
662
+ if (userInfo.email) {
663
+ const emailRecord = await lookupEmail(db, userInfo.email);
664
+
665
+ if (emailRecord) {
666
+ // Auto-link: email_verified check
667
+ if (userInfo.emailVerified) {
668
+ return autoLinkOAuth(env, providerName, userInfo, emailRecord);
669
+ }
670
+ // email_verified = false → create new account (email 미제공 정책 동일 흐름)
671
+ userInfo = { ...userInfo, email: null };
672
+ } else {
673
+ const existingUser = await db.first<{ id: string }>(
674
+ `SELECT id FROM _users WHERE lower(email) = lower(?)`,
675
+ [userInfo.email],
676
+ );
677
+ if (existingUser) {
678
+ if (userInfo.emailVerified) {
679
+ const existingUserId = String(existingUser.id);
680
+ try {
681
+ await registerEmailPending(db, userInfo.email, existingUserId);
682
+ await confirmEmail(db, userInfo.email, existingUserId);
683
+ } catch (err) {
684
+ if ((err as Error).message !== 'EMAIL_ALREADY_REGISTERED') {
685
+ throw err;
686
+ }
687
+ }
688
+ return autoLinkOAuth(env, providerName, userInfo, { userId: existingUserId, shardId: 0 });
689
+ }
690
+ userInfo = { ...userInfo, email: null };
691
+ }
692
+ }
693
+ }
694
+
695
+ // Step 3: Create new user via OAuth
696
+ return createOAuthUser(env, providerName, userInfo);
697
+ }
698
+
699
+ /**
700
+ * Process link/oauth callback — anonymous → OAuth
701
+ *
702
+ * Does NOT apply auto-connect policy.
703
+ * If email exists in _email_index as confirmed → 409 Conflict.
704
+ */
705
+ async function processLinkOAuthCallback(
706
+ env: Env,
707
+ providerName: SupportedProvider,
708
+ userInfo: OAuthUserInfo,
709
+ linkUserId: string,
710
+ ): Promise<OAuthResult> {
711
+ // Check if OAuth account already exists in D1
712
+ const oauthRecord = await lookupOAuth(getAuthDbFromEnv(env), providerName, userInfo.providerUserId);
713
+ if (oauthRecord) {
714
+ throw new EdgeBaseError(409, 'This OAuth account is already linked to another user.', undefined, 'already-exists');
715
+ }
716
+
717
+ // Check email conflict in D1
718
+ if (userInfo.email) {
719
+ const emailRecord = await lookupEmail(getAuthDbFromEnv(env), userInfo.email);
720
+ if (emailRecord) {
721
+ throw new EdgeBaseError(409, 'Email is already registered to another account.', undefined, 'email-already-exists');
722
+ }
723
+ }
724
+
725
+ // D1: register in _oauth_index as pending
726
+ try {
727
+ await registerOAuthPending(getAuthDbFromEnv(env), providerName, userInfo.providerUserId, linkUserId);
728
+ } catch (err) {
729
+ if ((err as Error).message === 'OAUTH_ALREADY_LINKED') {
730
+ throw new EdgeBaseError(409, 'This OAuth account is already linked.', undefined, 'already-exists');
731
+ }
732
+ throw err;
733
+ }
734
+
735
+ // If email available + verified, also register in _email_index
736
+ if (userInfo.email && userInfo.emailVerified) {
737
+ try {
738
+ await registerEmailPending(getAuthDbFromEnv(env), userInfo.email, linkUserId);
739
+ } catch {
740
+ // If email registration fails, clean up OAuth and re-throw
741
+ await deleteOAuth(getAuthDbFromEnv(env), providerName, userInfo.providerUserId).catch(() => {});
742
+ throw new EdgeBaseError(409, 'Email is already registered.', undefined, 'email-already-exists');
743
+ }
744
+ }
745
+
746
+ // Link OAuth directly in D1 instead of shard
747
+ try {
748
+ // Update user: set email/displayName/avatarUrl, clear isAnonymous
749
+ const updates: Record<string, unknown> = { isAnonymous: 0 };
750
+ if (userInfo.email) updates.email = userInfo.email;
751
+ if (userInfo.displayName) updates.displayName = userInfo.displayName;
752
+ if (userInfo.avatarUrl) updates.avatarUrl = userInfo.avatarUrl;
753
+ if (userInfo.emailVerified) updates.verified = 1;
754
+ await authService.updateUser(getAuthDbFromEnv(env), linkUserId, updates);
755
+
756
+ // Create OAuth account
757
+ const oauthId = generateId();
758
+ await authService.createOAuthAccount(getAuthDbFromEnv(env), {
759
+ id: oauthId,
760
+ userId: linkUserId,
761
+ provider: providerName,
762
+ providerUserId: userInfo.providerUserId,
763
+ });
764
+ } catch (err) {
765
+ // Compensating transactions — D1 cleanup
766
+ await deleteOAuth(getAuthDbFromEnv(env), providerName, userInfo.providerUserId).catch(() => {});
767
+ if (userInfo.email && userInfo.emailVerified) {
768
+ await deleteEmail(getAuthDbFromEnv(env), userInfo.email).catch(() => {});
769
+ }
770
+ throw new EdgeBaseError(500, `Link failed: ${(err as Error).message}`, undefined, 'internal-error');
771
+ }
772
+
773
+ // Confirm in D1
774
+ await confirmOAuth(getAuthDbFromEnv(env), providerName, userInfo.providerUserId);
775
+ if (userInfo.email && userInfo.emailVerified) {
776
+ await confirmEmail(getAuthDbFromEnv(env), userInfo.email, linkUserId);
777
+ }
778
+
779
+ // Best-effort: delete from _anon_index in D1
780
+ await deleteAnon(getAuthDbFromEnv(env), linkUserId).catch(() => {});
781
+
782
+ // Get updated user and create session
783
+ const user = await authService.getUserById(getAuthDbFromEnv(env), linkUserId);
784
+ if (!user) throw new EdgeBaseError(500, 'User not found after link.', undefined, 'internal-error');
785
+ const { accessToken, refreshToken } = await createOAuthSessionAndTokens(env, user);
786
+
787
+ // Sync _users_public
788
+ try {
789
+ await upsertUserPublic(getAuthDbFromEnv(env), linkUserId, authService.buildPublicUserData(user) as unknown as UserPublicData);
790
+ } catch { /* best-effort */ }
791
+
792
+ return { user: authService.sanitizeUser(user), accessToken, refreshToken, created: false };
793
+ }
794
+
795
+ /**
796
+ * Process link/oauth callback — authenticated user attaches an additional OAuth identity.
797
+ */
798
+ async function processAttachOAuthCallback(
799
+ env: Env,
800
+ providerName: SupportedProvider,
801
+ userInfo: OAuthUserInfo,
802
+ linkUserId: string,
803
+ ): Promise<OAuthResult> {
804
+ const db = getAuthDbFromEnv(env);
805
+
806
+ const oauthRecord = await lookupOAuth(db, providerName, userInfo.providerUserId);
807
+ if (oauthRecord) {
808
+ if (oauthRecord.userId === linkUserId) {
809
+ throw new EdgeBaseError(409, 'This OAuth account is already linked to your user.', undefined, 'already-exists');
810
+ }
811
+ throw new EdgeBaseError(409, 'This OAuth account is already linked to another user.', undefined, 'already-exists');
812
+ }
813
+
814
+ const currentUser = await authService.getUserById(db, linkUserId);
815
+ if (!currentUser) throw new EdgeBaseError(404, 'User not found.');
816
+ if (Number(currentUser.disabled) === 1) throw new EdgeBaseError(403, 'This account has been disabled.');
817
+
818
+ let pendingEmail: string | null = null;
819
+ const updates: Record<string, unknown> = {};
820
+ const currentEmail = typeof currentUser.email === 'string' ? currentUser.email : null;
821
+
822
+ if (!currentUser.displayName && userInfo.displayName) {
823
+ updates.displayName = userInfo.displayName;
824
+ }
825
+ if (!currentUser.avatarUrl && userInfo.avatarUrl) {
826
+ updates.avatarUrl = userInfo.avatarUrl;
827
+ }
828
+
829
+ if (userInfo.email && userInfo.emailVerified) {
830
+ if (!currentEmail) {
831
+ const emailRecord = await lookupEmail(db, userInfo.email);
832
+ if (emailRecord && emailRecord.userId !== linkUserId) {
833
+ throw new EdgeBaseError(409, 'Email is already registered to another account.', undefined, 'email-already-exists');
834
+ }
835
+ if (!emailRecord) {
836
+ pendingEmail = userInfo.email;
837
+ await registerEmailPending(db, pendingEmail, linkUserId);
838
+ }
839
+ updates.email = userInfo.email;
840
+ updates.verified = 1;
841
+ } else if (currentEmail === userInfo.email && !currentUser.verified) {
842
+ updates.verified = 1;
843
+ }
844
+ }
845
+
846
+ try {
847
+ await registerOAuthPending(db, providerName, userInfo.providerUserId, linkUserId);
848
+ if (Object.keys(updates).length > 0) {
849
+ await authService.updateUser(db, linkUserId, updates);
850
+ }
851
+ await authService.createOAuthAccount(db, {
852
+ id: generateId(),
853
+ userId: linkUserId,
854
+ provider: providerName,
855
+ providerUserId: userInfo.providerUserId,
856
+ });
857
+ } catch (err) {
858
+ await deleteOAuth(db, providerName, userInfo.providerUserId).catch(() => {});
859
+ if (pendingEmail) {
860
+ await deleteEmailPending(db, pendingEmail).catch(() => {});
861
+ }
862
+ if (err instanceof EdgeBaseError) throw err;
863
+ throw new EdgeBaseError(500, `Link failed: ${(err as Error).message}`, undefined, 'internal-error');
864
+ }
865
+
866
+ await confirmOAuth(db, providerName, userInfo.providerUserId);
867
+ if (pendingEmail) {
868
+ await confirmEmail(db, pendingEmail, linkUserId);
869
+ }
870
+
871
+ const user = await authService.getUserById(db, linkUserId);
872
+ if (!user) throw new EdgeBaseError(500, 'User not found after link.', undefined, 'internal-error');
873
+ const { accessToken, refreshToken } = await createOAuthSessionAndTokens(env, user);
874
+
875
+ try {
876
+ await upsertUserPublic(db, linkUserId, authService.buildPublicUserData(user) as unknown as UserPublicData);
877
+ } catch { /* best-effort */ }
878
+
879
+ return { user: authService.sanitizeUser(user), accessToken, refreshToken, created: false };
880
+ }
881
+
882
+ /**
883
+ * Auto-link: add OAuth to existing email-verified user
884
+ */
885
+ async function autoLinkOAuth(
886
+ env: Env,
887
+ providerName: SupportedProvider,
888
+ userInfo: OAuthUserInfo,
889
+ emailRecord: { userId: string; shardId: number },
890
+ ): Promise<OAuthResult> {
891
+ const { userId } = emailRecord;
892
+ const db = getAuthDbFromEnv(env);
893
+
894
+ // D1: register in _oauth_index
895
+ try {
896
+ await registerOAuthPending(db, providerName, userInfo.providerUserId, userId);
897
+ } catch (err) {
898
+ if ((err as Error).message === 'OAUTH_ALREADY_LINKED') {
899
+ throw new EdgeBaseError(409, 'This OAuth account is already linked.', undefined, 'already-exists');
900
+ }
901
+ throw err;
902
+ }
903
+
904
+ const currentUser = await authService.getUserById(db, userId);
905
+ if (!currentUser) throw new EdgeBaseError(404, 'User not found.');
906
+ if (Number(currentUser.disabled) === 1) throw new EdgeBaseError(403, 'This account has been disabled.');
907
+
908
+ const updates: Record<string, unknown> = {};
909
+ if (!currentUser.displayName && userInfo.displayName) {
910
+ updates.displayName = userInfo.displayName;
911
+ }
912
+ if (!currentUser.avatarUrl && userInfo.avatarUrl) {
913
+ updates.avatarUrl = userInfo.avatarUrl;
914
+ }
915
+ if (!currentUser.email && userInfo.email) {
916
+ updates.email = userInfo.email;
917
+ }
918
+ if (userInfo.emailVerified && !currentUser.verified) {
919
+ updates.verified = 1;
920
+ }
921
+
922
+ try {
923
+ if (Object.keys(updates).length > 0) {
924
+ await authService.updateUser(db, userId, updates);
925
+ }
926
+ const oauthId = generateId();
927
+ await authService.createOAuthAccount(db, {
928
+ id: oauthId,
929
+ userId,
930
+ provider: providerName,
931
+ providerUserId: userInfo.providerUserId,
932
+ });
933
+ await confirmOAuth(db, providerName, userInfo.providerUserId);
934
+ } catch (err) {
935
+ await deleteOAuth(db, providerName, userInfo.providerUserId).catch(() => {});
936
+ if (err instanceof EdgeBaseError) throw err;
937
+ throw new EdgeBaseError(500, `OAuth auto-link failed: ${(err as Error).message}`, undefined, 'internal-error');
938
+ }
939
+
940
+ // Get user and create session
941
+ const user = await authService.getUserById(db, userId);
942
+ if (!user) throw new EdgeBaseError(500, 'User not found.', undefined, 'internal-error');
943
+ const { accessToken, refreshToken } = await createOAuthSessionAndTokens(env, user);
944
+
945
+ return { user: authService.sanitizeUser(user), accessToken, refreshToken, created: false };
946
+ }
947
+
948
+ /**
949
+ * Create new OAuth user
950
+ */
951
+ async function createOAuthUser(
952
+ env: Env,
953
+ providerName: SupportedProvider,
954
+ userInfo: OAuthUserInfo,
955
+ ): Promise<OAuthResult> {
956
+ const userId = crypto.randomUUID();
957
+ const db = getAuthDbFromEnv(env);
958
+ const reservedEmail = userInfo.email && userInfo.emailVerified ? userInfo.email : null;
959
+ let userCreated = false;
960
+ let user: Record<string, unknown> | null = null;
961
+
962
+ // D1: register in _oauth_index as pending
963
+ try {
964
+ await registerOAuthPending(db, providerName, userInfo.providerUserId, userId);
965
+ } catch (err) {
966
+ if ((err as Error).message === 'OAUTH_ALREADY_LINKED') {
967
+ throw new EdgeBaseError(409, 'This OAuth account is already linked.', undefined, 'already-exists');
968
+ }
969
+ throw err;
970
+ }
971
+
972
+ // If email is available + verified, also register in _email_index
973
+ if (reservedEmail) {
974
+ try {
975
+ await registerEmailPending(db, reservedEmail, userId);
976
+ } catch {
977
+ await deleteOAuth(db, providerName, userInfo.providerUserId).catch(() => {});
978
+ throw new EdgeBaseError(409, 'Email is already registered.', undefined, 'email-already-exists');
979
+ }
980
+ }
981
+
982
+ try {
983
+ // Create user directly in D1
984
+ user = await authService.createUser(db, {
985
+ userId,
986
+ email: userInfo.email ?? null,
987
+ passwordHash: '', // no password for OAuth users
988
+ displayName: userInfo.displayName,
989
+ avatarUrl: userInfo.avatarUrl,
990
+ verified: !!userInfo.emailVerified,
991
+ role: 'user',
992
+ });
993
+ userCreated = true;
994
+
995
+ // Create OAuth account in D1
996
+ const oauthId = generateId();
997
+ await authService.createOAuthAccount(db, {
998
+ id: oauthId,
999
+ userId,
1000
+ provider: providerName,
1001
+ providerUserId: userInfo.providerUserId,
1002
+ });
1003
+
1004
+ // Confirm in D1
1005
+ await confirmOAuth(db, providerName, userInfo.providerUserId);
1006
+ if (reservedEmail) {
1007
+ await confirmEmail(db, reservedEmail, userId);
1008
+ }
1009
+
1010
+ // Create session
1011
+ const { accessToken, refreshToken } = await createOAuthSessionAndTokens(env, user);
1012
+
1013
+ // Sync to _users_public
1014
+ try {
1015
+ await upsertUserPublic(db, userId, authService.buildPublicUserData(user) as unknown as UserPublicData);
1016
+ } catch { /* best-effort */ }
1017
+
1018
+ return { user: authService.sanitizeUser(user), accessToken, refreshToken, created: true };
1019
+ } catch (err) {
1020
+ const message = err instanceof Error ? err.message : String(err);
1021
+ if (!userCreated && reservedEmail && userInfo.emailVerified && /_users\.email|idx_users_email/i.test(message)) {
1022
+ const existingUser = await db.first<{ id: string }>(
1023
+ `SELECT id FROM _users WHERE lower(email) = lower(?)`,
1024
+ [reservedEmail],
1025
+ );
1026
+ await deleteOAuth(db, providerName, userInfo.providerUserId).catch(() => {});
1027
+ await deleteEmailPending(db, reservedEmail).catch(() => {});
1028
+ if (existingUser) {
1029
+ try {
1030
+ await registerEmailPending(db, reservedEmail, existingUser.id);
1031
+ await confirmEmail(db, reservedEmail, existingUser.id);
1032
+ } catch (healingErr) {
1033
+ if ((healingErr as Error).message !== 'EMAIL_ALREADY_REGISTERED') {
1034
+ throw healingErr;
1035
+ }
1036
+ }
1037
+ return autoLinkOAuth(env, providerName, userInfo, {
1038
+ userId: existingUser.id,
1039
+ shardId: 0,
1040
+ });
1041
+ }
1042
+ }
1043
+
1044
+ await deleteOAuth(db, providerName, userInfo.providerUserId).catch(() => {});
1045
+ if (reservedEmail) {
1046
+ await deleteEmail(db, reservedEmail).catch(() => {});
1047
+ }
1048
+ if (userCreated) {
1049
+ await authService.deleteUserCascade(db, userId).catch(() => {});
1050
+ await db.run(`DELETE FROM _users_public WHERE id = ?`, [userId]).catch(() => {});
1051
+ }
1052
+ if (err instanceof EdgeBaseError) throw err;
1053
+ throw new EdgeBaseError(500, `OAuth user creation failed: ${(err as Error).message}`, undefined, 'internal-error');
1054
+ }
1055
+ }