@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,1225 @@
1
+ /**
2
+ * OAuth Provider Abstraction Layer
3
+ *
4
+ * Supports: Google, GitHub, Apple, Discord, Microsoft, Facebook, Kakao, Naver,
5
+ * X (Twitter), Reddit, Line, Slack, Spotify, Twitch
6
+ * Each provider implements a common interface for authorization URL generation,
7
+ * code exchange, and user info retrieval.
8
+ *
9
+ * M3: Google, GitHub, Apple, Discord
10
+ * M18: Microsoft, Facebook, Kakao, Naver, X, Line, Slack, Spotify, Twitch
11
+ */
12
+
13
+ // ─── Types ───
14
+
15
+ export interface OAuthUserInfo {
16
+ providerUserId: string;
17
+ email: string | null;
18
+ emailVerified: boolean;
19
+ displayName: string | null;
20
+ avatarUrl: string | null;
21
+ raw: Record<string, unknown>;
22
+ }
23
+
24
+ export interface OAuthTokens {
25
+ accessToken: string;
26
+ tokenType: string;
27
+ idToken?: string;
28
+ refreshToken?: string;
29
+ expiresIn?: number;
30
+ }
31
+
32
+ export interface OAuthProviderConfig {
33
+ clientId: string;
34
+ clientSecret: string;
35
+ }
36
+
37
+ export interface OAuthProvider {
38
+ name: string;
39
+ getAuthorizationUrl(
40
+ state: string,
41
+ redirectUri: string,
42
+ codeChallenge?: string,
43
+ ): string;
44
+ exchangeCode(
45
+ code: string,
46
+ redirectUri: string,
47
+ codeVerifier?: string,
48
+ ): Promise<OAuthTokens>;
49
+ getUserInfo(accessToken: string): Promise<OAuthUserInfo>;
50
+ }
51
+
52
+ // ─── PKCE Helper ───
53
+
54
+ export async function generatePKCE(): Promise<{ codeVerifier: string; codeChallenge: string }> {
55
+ const array = new Uint8Array(32);
56
+ crypto.getRandomValues(array);
57
+ const codeVerifier = base64urlEncode(array);
58
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
59
+ const codeChallenge = base64urlEncode(new Uint8Array(digest));
60
+ return { codeVerifier, codeChallenge };
61
+ }
62
+
63
+ function base64urlEncode(buffer: Uint8Array): string {
64
+ const str = btoa(String.fromCharCode(...buffer));
65
+ return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
66
+ }
67
+
68
+ // ─── Google OAuth ───
69
+
70
+ class GoogleOAuthProvider implements OAuthProvider {
71
+ readonly name = 'google';
72
+ constructor(private config: OAuthProviderConfig) {}
73
+
74
+ getAuthorizationUrl(state: string, redirectUri: string, codeChallenge?: string): string {
75
+ const params = new URLSearchParams({
76
+ client_id: this.config.clientId,
77
+ redirect_uri: redirectUri,
78
+ response_type: 'code',
79
+ scope: 'openid email profile',
80
+ state,
81
+ access_type: 'offline',
82
+ prompt: 'consent',
83
+ });
84
+ if (codeChallenge) {
85
+ params.set('code_challenge', codeChallenge);
86
+ params.set('code_challenge_method', 'S256');
87
+ }
88
+ return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
89
+ }
90
+
91
+ async exchangeCode(code: string, redirectUri: string, codeVerifier?: string): Promise<OAuthTokens> {
92
+ const body: Record<string, string> = {
93
+ client_id: this.config.clientId,
94
+ client_secret: this.config.clientSecret,
95
+ code,
96
+ redirect_uri: redirectUri,
97
+ grant_type: 'authorization_code',
98
+ };
99
+ if (codeVerifier) body.code_verifier = codeVerifier;
100
+
101
+ const resp = await fetch('https://oauth2.googleapis.com/token', {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
104
+ body: new URLSearchParams(body),
105
+ });
106
+ if (!resp.ok) throw new Error(`Google token exchange failed: ${resp.status}`);
107
+ const data = await resp.json() as Record<string, unknown>;
108
+ return {
109
+ accessToken: data.access_token as string,
110
+ tokenType: data.token_type as string,
111
+ idToken: data.id_token as string | undefined,
112
+ refreshToken: data.refresh_token as string | undefined,
113
+ expiresIn: data.expires_in as number | undefined,
114
+ };
115
+ }
116
+
117
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
118
+ const resp = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
119
+ headers: { Authorization: `Bearer ${accessToken}` },
120
+ });
121
+ if (!resp.ok) throw new Error(`Google userinfo failed: ${resp.status}`);
122
+ const data = await resp.json() as Record<string, unknown>;
123
+ return {
124
+ providerUserId: String(data.id),
125
+ email: (data.email as string) || null,
126
+ emailVerified: Boolean(data.verified_email), // Google uses verified_email
127
+ displayName: (data.name as string) || null,
128
+ avatarUrl: (data.picture as string) || null,
129
+ raw: data,
130
+ };
131
+ }
132
+ }
133
+
134
+ // ─── GitHub OAuth ───
135
+
136
+ class GitHubOAuthProvider implements OAuthProvider {
137
+ readonly name = 'github';
138
+ constructor(private config: OAuthProviderConfig) {}
139
+
140
+ getAuthorizationUrl(state: string, redirectUri: string): string {
141
+ const params = new URLSearchParams({
142
+ client_id: this.config.clientId,
143
+ redirect_uri: redirectUri,
144
+ scope: 'read:user user:email',
145
+ state,
146
+ });
147
+ return `https://github.com/login/oauth/authorize?${params}`;
148
+ }
149
+
150
+ async exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens> {
151
+ const resp = await fetch('https://github.com/login/oauth/access_token', {
152
+ method: 'POST',
153
+ headers: {
154
+ 'Content-Type': 'application/json',
155
+ Accept: 'application/json',
156
+ },
157
+ body: JSON.stringify({
158
+ client_id: this.config.clientId,
159
+ client_secret: this.config.clientSecret,
160
+ code,
161
+ redirect_uri: redirectUri,
162
+ }),
163
+ });
164
+ if (!resp.ok) throw new Error(`GitHub token exchange failed: ${resp.status}`);
165
+ const data = await resp.json() as Record<string, unknown>;
166
+ if (data.error) throw new Error(`GitHub OAuth error: ${data.error_description || data.error}`);
167
+ return {
168
+ accessToken: data.access_token as string,
169
+ tokenType: data.token_type as string,
170
+ };
171
+ }
172
+
173
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
174
+ const [userResp, emailsResp] = await Promise.all([
175
+ fetch('https://api.github.com/user', {
176
+ headers: { Authorization: `Bearer ${accessToken}`, 'User-Agent': 'EdgeBase' },
177
+ }),
178
+ fetch('https://api.github.com/user/emails', {
179
+ headers: { Authorization: `Bearer ${accessToken}`, 'User-Agent': 'EdgeBase' },
180
+ }),
181
+ ]);
182
+ if (!userResp.ok) throw new Error(`GitHub user API failed: ${userResp.status}`);
183
+ const userData = await userResp.json() as Record<string, unknown>;
184
+
185
+ let email: string | null = null;
186
+ let emailVerified = false;
187
+ if (emailsResp.ok) {
188
+ const emails = await emailsResp.json() as Array<{ email: string; primary: boolean; verified: boolean }>;
189
+ const primary = emails.find((e) => e.primary && e.verified);
190
+ if (primary) {
191
+ email = primary.email;
192
+ emailVerified = primary.verified;
193
+ }
194
+ }
195
+
196
+ return {
197
+ providerUserId: String(userData.id),
198
+ email,
199
+ emailVerified,
200
+ displayName: (userData.name as string) || (userData.login as string) || null,
201
+ avatarUrl: (userData.avatar_url as string) || null,
202
+ raw: userData,
203
+ };
204
+ }
205
+ }
206
+
207
+ // ─── Apple OAuth ───
208
+
209
+ class AppleOAuthProvider implements OAuthProvider {
210
+ readonly name = 'apple';
211
+ constructor(private config: OAuthProviderConfig) {}
212
+
213
+ getAuthorizationUrl(state: string, redirectUri: string): string {
214
+ const params = new URLSearchParams({
215
+ client_id: this.config.clientId,
216
+ redirect_uri: redirectUri,
217
+ response_type: 'code',
218
+ scope: 'name email',
219
+ state,
220
+ response_mode: 'form_post',
221
+ });
222
+ return `https://appleid.apple.com/auth/authorize?${params}`;
223
+ }
224
+
225
+ async exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens> {
226
+ const resp = await fetch('https://appleid.apple.com/auth/token', {
227
+ method: 'POST',
228
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
229
+ body: new URLSearchParams({
230
+ client_id: this.config.clientId,
231
+ client_secret: this.config.clientSecret,
232
+ code,
233
+ redirect_uri: redirectUri,
234
+ grant_type: 'authorization_code',
235
+ }),
236
+ });
237
+ if (!resp.ok) throw new Error(`Apple token exchange failed: ${resp.status}`);
238
+ const data = await resp.json() as Record<string, unknown>;
239
+ return {
240
+ accessToken: data.access_token as string,
241
+ tokenType: data.token_type as string,
242
+ idToken: data.id_token as string | undefined,
243
+ };
244
+ }
245
+
246
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
247
+ // Apple provides user info in the id_token, not a separate API
248
+ // The id_token is already returned from exchangeCode
249
+ // For Apple, we need the id_token from the token response
250
+ // This is handled in the callback route which passes idToken
251
+ void accessToken;
252
+ throw new Error('Apple getUserInfo should use id_token parsing instead');
253
+ }
254
+ }
255
+
256
+ /** Parse Apple id_token claims (JWT decode without verification — verification is via token exchange) */
257
+ export function parseAppleIdToken(idToken: string): OAuthUserInfo {
258
+ const parts = idToken.split('.');
259
+ if (parts.length !== 3) throw new Error('Invalid Apple id_token');
260
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
261
+ return {
262
+ providerUserId: payload.sub as string,
263
+ email: (payload.email as string) || null,
264
+ emailVerified: Boolean(payload.email_verified), // Apple always verified
265
+ displayName: null, // Apple sends name only on first sign-in via form_post body
266
+ avatarUrl: null,
267
+ raw: payload,
268
+ };
269
+ }
270
+
271
+ // ─── Discord OAuth ───
272
+
273
+ class DiscordOAuthProvider implements OAuthProvider {
274
+ readonly name = 'discord';
275
+ constructor(private config: OAuthProviderConfig) {}
276
+
277
+ getAuthorizationUrl(state: string, redirectUri: string): string {
278
+ const params = new URLSearchParams({
279
+ client_id: this.config.clientId,
280
+ redirect_uri: redirectUri,
281
+ response_type: 'code',
282
+ scope: 'identify email',
283
+ state,
284
+ });
285
+ return `https://discord.com/oauth2/authorize?${params}`;
286
+ }
287
+
288
+ async exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens> {
289
+ const resp = await fetch('https://discord.com/api/v10/oauth2/token', {
290
+ method: 'POST',
291
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
292
+ body: new URLSearchParams({
293
+ client_id: this.config.clientId,
294
+ client_secret: this.config.clientSecret,
295
+ code,
296
+ redirect_uri: redirectUri,
297
+ grant_type: 'authorization_code',
298
+ }),
299
+ });
300
+ if (!resp.ok) throw new Error(`Discord token exchange failed: ${resp.status}`);
301
+ const data = await resp.json() as Record<string, unknown>;
302
+ return {
303
+ accessToken: data.access_token as string,
304
+ tokenType: data.token_type as string,
305
+ };
306
+ }
307
+
308
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
309
+ const resp = await fetch('https://discord.com/api/v10/users/@me', {
310
+ headers: { Authorization: `Bearer ${accessToken}` },
311
+ });
312
+ if (!resp.ok) throw new Error(`Discord user API failed: ${resp.status}`);
313
+ const data = await resp.json() as Record<string, unknown>;
314
+
315
+ const avatarHash = data.avatar as string | null;
316
+ const avatarUrl = avatarHash
317
+ ? `https://cdn.discordapp.com/avatars/${data.id}/${avatarHash}.png`
318
+ : null;
319
+
320
+ return {
321
+ providerUserId: String(data.id),
322
+ email: (data.email as string) || null,
323
+ emailVerified: Boolean(data.verified), // Discord uses 'verified' field
324
+ displayName: (data.global_name as string) || (data.username as string) || null,
325
+ avatarUrl,
326
+ raw: data,
327
+ };
328
+ }
329
+ }
330
+
331
+ // ─── Microsoft (Azure AD) OAuth (M18) ───
332
+
333
+ class MicrosoftOAuthProvider implements OAuthProvider {
334
+ readonly name = 'microsoft';
335
+ constructor(private config: OAuthProviderConfig) {}
336
+
337
+ getAuthorizationUrl(state: string, redirectUri: string, codeChallenge?: string): string {
338
+ const params = new URLSearchParams({
339
+ client_id: this.config.clientId,
340
+ redirect_uri: redirectUri,
341
+ response_type: 'code',
342
+ scope: 'openid email profile',
343
+ state,
344
+ });
345
+ if (codeChallenge) {
346
+ params.set('code_challenge', codeChallenge);
347
+ params.set('code_challenge_method', 'S256');
348
+ }
349
+ return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${params}`;
350
+ }
351
+
352
+ async exchangeCode(code: string, redirectUri: string, codeVerifier?: string): Promise<OAuthTokens> {
353
+ const body: Record<string, string> = {
354
+ client_id: this.config.clientId,
355
+ client_secret: this.config.clientSecret,
356
+ code,
357
+ redirect_uri: redirectUri,
358
+ grant_type: 'authorization_code',
359
+ scope: 'openid email profile',
360
+ };
361
+ if (codeVerifier) body.code_verifier = codeVerifier;
362
+ const resp = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
363
+ method: 'POST',
364
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
365
+ body: new URLSearchParams(body),
366
+ });
367
+ if (!resp.ok) throw new Error(`Microsoft token exchange failed: ${resp.status}`);
368
+ const data = await resp.json() as Record<string, unknown>;
369
+ return {
370
+ accessToken: data.access_token as string,
371
+ tokenType: data.token_type as string,
372
+ idToken: data.id_token as string | undefined,
373
+ refreshToken: data.refresh_token as string | undefined,
374
+ expiresIn: data.expires_in as number | undefined,
375
+ };
376
+ }
377
+
378
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
379
+ const resp = await fetch('https://graph.microsoft.com/oidc/userinfo', {
380
+ headers: { Authorization: `Bearer ${accessToken}` },
381
+ });
382
+ if (!resp.ok) throw new Error(`Microsoft userinfo failed: ${resp.status}`);
383
+ const data = await resp.json() as Record<string, unknown>;
384
+ return {
385
+ providerUserId: String(data.sub),
386
+ email: (data.email as string) || null,
387
+ emailVerified: Boolean(data.email_verified), //: Microsoft provides email_verified
388
+ displayName: (data.name as string) || null,
389
+ avatarUrl: null, // Microsoft Graph userinfo doesn't return avatar
390
+ raw: data,
391
+ };
392
+ }
393
+ }
394
+
395
+ // ─── Facebook/Meta OAuth (M18) ───
396
+
397
+ class FacebookOAuthProvider implements OAuthProvider {
398
+ readonly name = 'facebook';
399
+ constructor(private config: OAuthProviderConfig) {}
400
+
401
+ getAuthorizationUrl(state: string, redirectUri: string): string {
402
+ const params = new URLSearchParams({
403
+ client_id: this.config.clientId,
404
+ redirect_uri: redirectUri,
405
+ response_type: 'code',
406
+ scope: 'email,public_profile',
407
+ state,
408
+ });
409
+ return `https://www.facebook.com/v19.0/dialog/oauth?${params}`;
410
+ }
411
+
412
+ async exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens> {
413
+ const params = new URLSearchParams({
414
+ client_id: this.config.clientId,
415
+ client_secret: this.config.clientSecret,
416
+ code,
417
+ redirect_uri: redirectUri,
418
+ });
419
+ const resp = await fetch(`https://graph.facebook.com/v19.0/oauth/access_token?${params}`);
420
+ if (!resp.ok) throw new Error(`Facebook token exchange failed: ${resp.status}`);
421
+ const data = await resp.json() as Record<string, unknown>;
422
+ return {
423
+ accessToken: data.access_token as string,
424
+ tokenType: data.token_type as string || 'bearer',
425
+ expiresIn: data.expires_in as number | undefined,
426
+ };
427
+ }
428
+
429
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
430
+ const resp = await fetch(
431
+ `https://graph.facebook.com/v19.0/me?fields=id,name,email,picture.type(large)&access_token=${accessToken}`,
432
+ );
433
+ if (!resp.ok) throw new Error(`Facebook user API failed: ${resp.status}`);
434
+ const data = await resp.json() as Record<string, unknown>;
435
+ const picture = data.picture as { data?: { url?: string } } | undefined;
436
+ return {
437
+ providerUserId: String(data.id),
438
+ email: (data.email as string) || null,
439
+ emailVerified: false, //: Facebook — no email_verified field, force false
440
+ displayName: (data.name as string) || null,
441
+ avatarUrl: picture?.data?.url || null,
442
+ raw: data,
443
+ };
444
+ }
445
+ }
446
+
447
+ // ─── Kakao OAuth (M18) ───
448
+
449
+ class KakaoOAuthProvider implements OAuthProvider {
450
+ readonly name = 'kakao';
451
+ constructor(private config: OAuthProviderConfig) {}
452
+
453
+ getAuthorizationUrl(state: string, redirectUri: string): string {
454
+ const params = new URLSearchParams({
455
+ client_id: this.config.clientId,
456
+ redirect_uri: redirectUri,
457
+ response_type: 'code',
458
+ state,
459
+ });
460
+ return `https://kauth.kakao.com/oauth/authorize?${params}`;
461
+ }
462
+
463
+ async exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens> {
464
+ const resp = await fetch('https://kauth.kakao.com/oauth/token', {
465
+ method: 'POST',
466
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
467
+ body: new URLSearchParams({
468
+ client_id: this.config.clientId,
469
+ client_secret: this.config.clientSecret,
470
+ code,
471
+ redirect_uri: redirectUri,
472
+ grant_type: 'authorization_code',
473
+ }),
474
+ });
475
+ if (!resp.ok) throw new Error(`Kakao token exchange failed: ${resp.status}`);
476
+ const data = await resp.json() as Record<string, unknown>;
477
+ return {
478
+ accessToken: data.access_token as string,
479
+ tokenType: data.token_type as string,
480
+ refreshToken: data.refresh_token as string | undefined,
481
+ expiresIn: data.expires_in as number | undefined,
482
+ };
483
+ }
484
+
485
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
486
+ const resp = await fetch('https://kapi.kakao.com/v2/user/me', {
487
+ headers: { Authorization: `Bearer ${accessToken}` },
488
+ });
489
+ if (!resp.ok) throw new Error(`Kakao user API failed: ${resp.status}`);
490
+ const data = await resp.json() as Record<string, unknown>;
491
+ const account = data.kakao_account as Record<string, unknown> | undefined;
492
+ const profile = account?.profile as Record<string, unknown> | undefined;
493
+ return {
494
+ providerUserId: String(data.id),
495
+ email: (account?.email as string) || null,
496
+ //: Kakao — conditional, use is_email_verified if available
497
+ emailVerified: Boolean(account?.is_email_verified),
498
+ displayName: (profile?.nickname as string) || null,
499
+ avatarUrl: (profile?.profile_image_url as string) || null,
500
+ raw: data,
501
+ };
502
+ }
503
+ }
504
+
505
+ // ─── Naver OAuth (M18) ───
506
+
507
+ class NaverOAuthProvider implements OAuthProvider {
508
+ readonly name = 'naver';
509
+ constructor(private config: OAuthProviderConfig) {}
510
+
511
+ getAuthorizationUrl(state: string, redirectUri: string): string {
512
+ const params = new URLSearchParams({
513
+ client_id: this.config.clientId,
514
+ redirect_uri: redirectUri,
515
+ response_type: 'code',
516
+ state,
517
+ });
518
+ return `https://nid.naver.com/oauth2.0/authorize?${params}`;
519
+ }
520
+
521
+ async exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens> {
522
+ void redirectUri; // Naver doesn't require redirect_uri in token exchange
523
+ const resp = await fetch('https://nid.naver.com/oauth2.0/token', {
524
+ method: 'POST',
525
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
526
+ body: new URLSearchParams({
527
+ client_id: this.config.clientId,
528
+ client_secret: this.config.clientSecret,
529
+ code,
530
+ grant_type: 'authorization_code',
531
+ }),
532
+ });
533
+ if (!resp.ok) throw new Error(`Naver token exchange failed: ${resp.status}`);
534
+ const data = await resp.json() as Record<string, unknown>;
535
+ return {
536
+ accessToken: data.access_token as string,
537
+ tokenType: data.token_type as string,
538
+ refreshToken: data.refresh_token as string | undefined,
539
+ expiresIn: typeof data.expires_in === 'string' ? parseInt(data.expires_in) : (data.expires_in as number | undefined),
540
+ };
541
+ }
542
+
543
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
544
+ const resp = await fetch('https://openapi.naver.com/v1/nid/me', {
545
+ headers: { Authorization: `Bearer ${accessToken}` },
546
+ });
547
+ if (!resp.ok) throw new Error(`Naver user API failed: ${resp.status}`);
548
+ const data = await resp.json() as Record<string, unknown>;
549
+ const response = data.response as Record<string, unknown> | undefined;
550
+ return {
551
+ providerUserId: String(response?.id),
552
+ email: (response?.email as string) || null,
553
+ emailVerified: false, //: Naver — no email_verified field, force false
554
+ displayName: (response?.name as string) || (response?.nickname as string) || null,
555
+ avatarUrl: (response?.profile_image as string) || null,
556
+ raw: data,
557
+ };
558
+ }
559
+ }
560
+
561
+ // ─── X (Twitter) OAuth 2.0 PKCE (M18) ───
562
+
563
+ class XOAuthProvider implements OAuthProvider {
564
+ readonly name = 'x';
565
+ constructor(private config: OAuthProviderConfig) {}
566
+
567
+ getAuthorizationUrl(state: string, redirectUri: string, codeChallenge?: string): string {
568
+ const params = new URLSearchParams({
569
+ client_id: this.config.clientId,
570
+ redirect_uri: redirectUri,
571
+ response_type: 'code',
572
+ scope: 'tweet.read users.read',
573
+ state,
574
+ code_challenge_method: 'S256',
575
+ });
576
+ // X (Twitter) OAuth 2.0 requires PKCE
577
+ if (codeChallenge) {
578
+ params.set('code_challenge', codeChallenge);
579
+ }
580
+ return `https://twitter.com/i/oauth2/authorize?${params}`;
581
+ }
582
+
583
+ async exchangeCode(code: string, redirectUri: string, codeVerifier?: string): Promise<OAuthTokens> {
584
+ const body: Record<string, string> = {
585
+ code,
586
+ redirect_uri: redirectUri,
587
+ grant_type: 'authorization_code',
588
+ client_id: this.config.clientId,
589
+ };
590
+ if (codeVerifier) body.code_verifier = codeVerifier;
591
+ // X uses Basic Auth for confidential clients
592
+ const credentials = btoa(`${this.config.clientId}:${this.config.clientSecret}`);
593
+ const resp = await fetch('https://api.x.com/2/oauth2/token', {
594
+ method: 'POST',
595
+ headers: {
596
+ 'Content-Type': 'application/x-www-form-urlencoded',
597
+ Authorization: `Basic ${credentials}`,
598
+ },
599
+ body: new URLSearchParams(body),
600
+ });
601
+ if (!resp.ok) throw new Error(`X token exchange failed: ${resp.status}`);
602
+ const data = await resp.json() as Record<string, unknown>;
603
+ return {
604
+ accessToken: data.access_token as string,
605
+ tokenType: data.token_type as string,
606
+ refreshToken: data.refresh_token as string | undefined,
607
+ expiresIn: data.expires_in as number | undefined,
608
+ };
609
+ }
610
+
611
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
612
+ const resp = await fetch('https://api.x.com/2/users/me?user.fields=profile_image_url,name,username', {
613
+ headers: { Authorization: `Bearer ${accessToken}` },
614
+ });
615
+ if (!resp.ok) throw new Error(`X user API failed: ${resp.status}`);
616
+ const json = await resp.json() as Record<string, unknown>;
617
+ const data = json.data as Record<string, unknown>;
618
+ return {
619
+ providerUserId: String(data.id),
620
+ email: null, //: X — email selectively provided, not requesting email scope
621
+ emailVerified: false, //: X — no email_verified, force false
622
+ displayName: (data.name as string) || (data.username as string) || null,
623
+ avatarUrl: (data.profile_image_url as string) || null,
624
+ raw: json,
625
+ };
626
+ }
627
+ }
628
+
629
+ // ─── Reddit OAuth 2.0 (M18) ───
630
+
631
+ class RedditOAuthProvider implements OAuthProvider {
632
+ readonly name = 'reddit';
633
+ constructor(private config: OAuthProviderConfig) {}
634
+
635
+ getAuthorizationUrl(state: string, redirectUri: string): string {
636
+ const params = new URLSearchParams({
637
+ client_id: this.config.clientId,
638
+ redirect_uri: redirectUri,
639
+ response_type: 'code',
640
+ duration: 'permanent',
641
+ scope: 'identity',
642
+ state,
643
+ });
644
+ return `https://www.reddit.com/api/v1/authorize?${params}`;
645
+ }
646
+
647
+ async exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens> {
648
+ const credentials = btoa(`${this.config.clientId}:${this.config.clientSecret}`);
649
+ const resp = await fetch('https://www.reddit.com/api/v1/access_token', {
650
+ method: 'POST',
651
+ headers: {
652
+ 'Content-Type': 'application/x-www-form-urlencoded',
653
+ Authorization: `Basic ${credentials}`,
654
+ 'User-Agent': 'EdgeBase OAuth/1.0',
655
+ },
656
+ body: new URLSearchParams({
657
+ code,
658
+ redirect_uri: redirectUri,
659
+ grant_type: 'authorization_code',
660
+ }),
661
+ });
662
+ if (!resp.ok) throw new Error(`Reddit token exchange failed: ${resp.status}`);
663
+ const data = await resp.json() as Record<string, unknown>;
664
+ return {
665
+ accessToken: data.access_token as string,
666
+ tokenType: data.token_type as string,
667
+ refreshToken: data.refresh_token as string | undefined,
668
+ expiresIn: data.expires_in as number | undefined,
669
+ };
670
+ }
671
+
672
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
673
+ const resp = await fetch('https://oauth.reddit.com/api/v1/me', {
674
+ headers: {
675
+ Authorization: `Bearer ${accessToken}`,
676
+ 'User-Agent': 'EdgeBase OAuth/1.0',
677
+ },
678
+ });
679
+ if (!resp.ok) throw new Error(`Reddit user API failed: ${resp.status}`);
680
+ const data = await resp.json() as Record<string, unknown>;
681
+ return {
682
+ providerUserId: String(data.id),
683
+ email: null, //: Reddit does not expose email through this OAuth scope set
684
+ emailVerified: false, //: Reddit does not expose email verification through this API
685
+ displayName: (data.name as string) || null,
686
+ avatarUrl: (data.snoovatar_img as string) || (data.icon_img as string) || null,
687
+ raw: data,
688
+ };
689
+ }
690
+ }
691
+
692
+ // ─── Line OAuth (M18) ───
693
+
694
+ class LineOAuthProvider implements OAuthProvider {
695
+ readonly name = 'line';
696
+ constructor(private config: OAuthProviderConfig) {}
697
+
698
+ getAuthorizationUrl(state: string, redirectUri: string): string {
699
+ const params = new URLSearchParams({
700
+ client_id: this.config.clientId,
701
+ redirect_uri: redirectUri,
702
+ response_type: 'code',
703
+ scope: 'profile openid email',
704
+ state,
705
+ });
706
+ return `https://access.line.me/oauth2/v2.1/authorize?${params}`;
707
+ }
708
+
709
+ async exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens> {
710
+ const resp = await fetch('https://api.line.me/oauth2/v2.1/token', {
711
+ method: 'POST',
712
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
713
+ body: new URLSearchParams({
714
+ client_id: this.config.clientId,
715
+ client_secret: this.config.clientSecret,
716
+ code,
717
+ redirect_uri: redirectUri,
718
+ grant_type: 'authorization_code',
719
+ }),
720
+ });
721
+ if (!resp.ok) throw new Error(`Line token exchange failed: ${resp.status}`);
722
+ const data = await resp.json() as Record<string, unknown>;
723
+ return {
724
+ accessToken: data.access_token as string,
725
+ tokenType: data.token_type as string,
726
+ idToken: data.id_token as string | undefined,
727
+ refreshToken: data.refresh_token as string | undefined,
728
+ expiresIn: data.expires_in as number | undefined,
729
+ };
730
+ }
731
+
732
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
733
+ const resp = await fetch('https://api.line.me/v2/profile', {
734
+ headers: { Authorization: `Bearer ${accessToken}` },
735
+ });
736
+ if (!resp.ok) throw new Error(`Line user API failed: ${resp.status}`);
737
+ const data = await resp.json() as Record<string, unknown>;
738
+ return {
739
+ providerUserId: String(data.userId),
740
+ email: null, // Line profile API doesn't return email; email comes from id_token
741
+ emailVerified: false, //: Line — no email_verified field, force false
742
+ displayName: (data.displayName as string) || null,
743
+ avatarUrl: (data.pictureUrl as string) || null,
744
+ raw: data,
745
+ };
746
+ }
747
+ }
748
+
749
+ // ─── Slack OAuth (OpenID Connect) (M18) ───
750
+
751
+ class SlackOAuthProvider implements OAuthProvider {
752
+ readonly name = 'slack';
753
+ constructor(private config: OAuthProviderConfig) {}
754
+
755
+ getAuthorizationUrl(state: string, redirectUri: string): string {
756
+ const params = new URLSearchParams({
757
+ client_id: this.config.clientId,
758
+ redirect_uri: redirectUri,
759
+ response_type: 'code',
760
+ scope: 'openid email profile',
761
+ state,
762
+ });
763
+ return `https://slack.com/openid/connect/authorize?${params}`;
764
+ }
765
+
766
+ async exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens> {
767
+ const resp = await fetch('https://slack.com/api/openid.connect.token', {
768
+ method: 'POST',
769
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
770
+ body: new URLSearchParams({
771
+ client_id: this.config.clientId,
772
+ client_secret: this.config.clientSecret,
773
+ code,
774
+ redirect_uri: redirectUri,
775
+ grant_type: 'authorization_code',
776
+ }),
777
+ });
778
+ if (!resp.ok) throw new Error(`Slack token exchange failed: ${resp.status}`);
779
+ const data = await resp.json() as Record<string, unknown>;
780
+ if (!data.ok) throw new Error(`Slack OAuth error: ${data.error}`);
781
+ return {
782
+ accessToken: data.access_token as string,
783
+ tokenType: data.token_type as string || 'bearer',
784
+ idToken: data.id_token as string | undefined,
785
+ };
786
+ }
787
+
788
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
789
+ const resp = await fetch('https://slack.com/api/openid.connect.userInfo', {
790
+ headers: { Authorization: `Bearer ${accessToken}` },
791
+ });
792
+ if (!resp.ok) throw new Error(`Slack userinfo failed: ${resp.status}`);
793
+ const data = await resp.json() as Record<string, unknown>;
794
+ return {
795
+ providerUserId: String(data.sub),
796
+ email: (data.email as string) || null,
797
+ emailVerified: Boolean(data.email_verified), //: Slack — always true
798
+ displayName: (data.name as string) || null,
799
+ avatarUrl: (data.picture as string) || null,
800
+ raw: data,
801
+ };
802
+ }
803
+ }
804
+
805
+ // ─── Spotify OAuth (M18) ───
806
+
807
+ class SpotifyOAuthProvider implements OAuthProvider {
808
+ readonly name = 'spotify';
809
+ constructor(private config: OAuthProviderConfig) {}
810
+
811
+ getAuthorizationUrl(state: string, redirectUri: string): string {
812
+ const params = new URLSearchParams({
813
+ client_id: this.config.clientId,
814
+ redirect_uri: redirectUri,
815
+ response_type: 'code',
816
+ scope: 'user-read-email user-read-private',
817
+ state,
818
+ });
819
+ return `https://accounts.spotify.com/authorize?${params}`;
820
+ }
821
+
822
+ async exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens> {
823
+ const credentials = btoa(`${this.config.clientId}:${this.config.clientSecret}`);
824
+ const resp = await fetch('https://accounts.spotify.com/api/token', {
825
+ method: 'POST',
826
+ headers: {
827
+ 'Content-Type': 'application/x-www-form-urlencoded',
828
+ Authorization: `Basic ${credentials}`,
829
+ },
830
+ body: new URLSearchParams({
831
+ code,
832
+ redirect_uri: redirectUri,
833
+ grant_type: 'authorization_code',
834
+ }),
835
+ });
836
+ if (!resp.ok) throw new Error(`Spotify token exchange failed: ${resp.status}`);
837
+ const data = await resp.json() as Record<string, unknown>;
838
+ return {
839
+ accessToken: data.access_token as string,
840
+ tokenType: data.token_type as string,
841
+ refreshToken: data.refresh_token as string | undefined,
842
+ expiresIn: data.expires_in as number | undefined,
843
+ };
844
+ }
845
+
846
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
847
+ const resp = await fetch('https://api.spotify.com/v1/me', {
848
+ headers: { Authorization: `Bearer ${accessToken}` },
849
+ });
850
+ if (!resp.ok) throw new Error(`Spotify user API failed: ${resp.status}`);
851
+ const data = await resp.json() as Record<string, unknown>;
852
+ const images = data.images as Array<{ url: string }> | undefined;
853
+ return {
854
+ providerUserId: String(data.id),
855
+ email: (data.email as string) || null,
856
+ emailVerified: false, //: Spotify — no email_verified, force false
857
+ displayName: (data.display_name as string) || null,
858
+ avatarUrl: images?.[0]?.url || null,
859
+ raw: data,
860
+ };
861
+ }
862
+ }
863
+
864
+ // ─── Twitch OAuth (M18) ───
865
+
866
+ class TwitchOAuthProvider implements OAuthProvider {
867
+ readonly name = 'twitch';
868
+ constructor(private config: OAuthProviderConfig) {}
869
+
870
+ getAuthorizationUrl(state: string, redirectUri: string): string {
871
+ const params = new URLSearchParams({
872
+ client_id: this.config.clientId,
873
+ redirect_uri: redirectUri,
874
+ response_type: 'code',
875
+ scope: 'user:read:email',
876
+ state,
877
+ });
878
+ return `https://id.twitch.tv/oauth2/authorize?${params}`;
879
+ }
880
+
881
+ async exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens> {
882
+ const resp = await fetch('https://id.twitch.tv/oauth2/token', {
883
+ method: 'POST',
884
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
885
+ body: new URLSearchParams({
886
+ client_id: this.config.clientId,
887
+ client_secret: this.config.clientSecret,
888
+ code,
889
+ redirect_uri: redirectUri,
890
+ grant_type: 'authorization_code',
891
+ }),
892
+ });
893
+ if (!resp.ok) throw new Error(`Twitch token exchange failed: ${resp.status}`);
894
+ const data = await resp.json() as Record<string, unknown>;
895
+ return {
896
+ accessToken: data.access_token as string,
897
+ tokenType: data.token_type as string,
898
+ refreshToken: data.refresh_token as string | undefined,
899
+ expiresIn: data.expires_in as number | undefined,
900
+ };
901
+ }
902
+
903
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
904
+ const resp = await fetch('https://api.twitch.tv/helix/users', {
905
+ headers: {
906
+ Authorization: `Bearer ${accessToken}`,
907
+ 'Client-Id': this.config.clientId,
908
+ },
909
+ });
910
+ if (!resp.ok) throw new Error(`Twitch user API failed: ${resp.status}`);
911
+ const json = await resp.json() as Record<string, unknown>;
912
+ const users = json.data as Array<Record<string, unknown>>;
913
+ const data = users?.[0];
914
+ if (!data) throw new Error('Twitch user not found');
915
+ return {
916
+ providerUserId: String(data.id),
917
+ email: (data.email as string) || null,
918
+ emailVerified: Boolean(data.email_verified), //: Twitch provides email_verified
919
+ displayName: (data.display_name as string) || (data.login as string) || null,
920
+ avatarUrl: (data.profile_image_url as string) || null,
921
+ raw: data,
922
+ };
923
+ }
924
+ }
925
+
926
+ // ─── OIDC Provider (Generic OpenID Connect, Phase 2 ⑦) ───
927
+
928
+ /**
929
+ * Extended config for OIDC providers (adds issuer + optional scopes).
930
+ */
931
+ export interface OIDCProviderConfig extends OAuthProviderConfig {
932
+ issuer: string;
933
+ scopes?: string[];
934
+ }
935
+
936
+ /**
937
+ * OpenID Connect discovery document (subset of fields we use).
938
+ */
939
+ interface OIDCDiscoveryDocument {
940
+ authorization_endpoint: string;
941
+ token_endpoint: string;
942
+ userinfo_endpoint?: string;
943
+ jwks_uri?: string;
944
+ issuer: string;
945
+ }
946
+
947
+ /**
948
+ * In-memory cache for OIDC discovery documents (per Worker lifetime).
949
+ * Avoids refetching on every request within the same Worker instance.
950
+ */
951
+ const oidcDiscoveryCache = new Map<string, { doc: OIDCDiscoveryDocument; expiresAt: number }>();
952
+
953
+ async function fetchOIDCDiscovery(issuer: string): Promise<OIDCDiscoveryDocument> {
954
+ const cacheKey = issuer;
955
+ const cached = oidcDiscoveryCache.get(cacheKey);
956
+ if (cached && cached.expiresAt > Date.now()) {
957
+ return cached.doc;
958
+ }
959
+
960
+ const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
961
+ const resp = await fetch(discoveryUrl, {
962
+ headers: { Accept: 'application/json' },
963
+ });
964
+
965
+ if (!resp.ok) {
966
+ throw new Error(`OIDC discovery failed for ${issuer}: ${resp.status} ${resp.statusText}`);
967
+ }
968
+
969
+ const doc = await resp.json() as OIDCDiscoveryDocument;
970
+ if (!doc.authorization_endpoint || !doc.token_endpoint) {
971
+ throw new Error(`OIDC discovery document missing required endpoints for ${issuer}`);
972
+ }
973
+
974
+ // Cache for 1 hour
975
+ oidcDiscoveryCache.set(cacheKey, { doc, expiresAt: Date.now() + 3600_000 });
976
+ return doc;
977
+ }
978
+
979
+ /**
980
+ * Parse OIDC ID token (JWT decode without cryptographic verification).
981
+ * Signature verification is delegated to the token endpoint trust (same pattern as Apple).
982
+ */
983
+ export function parseOIDCIdToken(idToken: string): OAuthUserInfo {
984
+ const parts = idToken.split('.');
985
+ if (parts.length !== 3) throw new Error('Invalid OIDC id_token');
986
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
987
+ return {
988
+ providerUserId: payload.sub as string,
989
+ email: (payload.email as string) || null,
990
+ emailVerified: Boolean(payload.email_verified),
991
+ displayName: (payload.name as string) || (payload.preferred_username as string) || null,
992
+ avatarUrl: (payload.picture as string) || null,
993
+ raw: payload,
994
+ };
995
+ }
996
+
997
+ class OIDCGenericProvider implements OAuthProvider {
998
+ readonly name: string;
999
+ private discovery: OIDCDiscoveryDocument | null = null;
1000
+
1001
+ constructor(
1002
+ providerName: string,
1003
+ private config: OIDCProviderConfig,
1004
+ ) {
1005
+ this.name = providerName;
1006
+ }
1007
+
1008
+ private async getDiscovery(): Promise<OIDCDiscoveryDocument> {
1009
+ if (!this.discovery) {
1010
+ this.discovery = await fetchOIDCDiscovery(this.config.issuer);
1011
+ }
1012
+ return this.discovery;
1013
+ }
1014
+
1015
+ getAuthorizationUrl(state: string, redirectUri: string, codeChallenge?: string): string {
1016
+ // For OIDC, we need the discovery document synchronously in getAuthorizationUrl.
1017
+ // We'll use a cached discovery doc if available, otherwise fall back to issuer + /authorize.
1018
+ const cached = oidcDiscoveryCache.get(this.config.issuer);
1019
+ const authEndpoint = cached?.doc?.authorization_endpoint
1020
+ || `${this.config.issuer.replace(/\/$/, '')}/authorize`;
1021
+
1022
+ const scopes = this.config.scopes || ['openid', 'email', 'profile'];
1023
+ const params = new URLSearchParams({
1024
+ client_id: this.config.clientId,
1025
+ redirect_uri: redirectUri,
1026
+ response_type: 'code',
1027
+ scope: scopes.join(' '),
1028
+ state,
1029
+ });
1030
+ if (codeChallenge) {
1031
+ params.set('code_challenge', codeChallenge);
1032
+ params.set('code_challenge_method', 'S256');
1033
+ }
1034
+ return `${authEndpoint}?${params}`;
1035
+ }
1036
+
1037
+ async exchangeCode(code: string, redirectUri: string, codeVerifier?: string): Promise<OAuthTokens> {
1038
+ const discovery = await this.getDiscovery();
1039
+
1040
+ const bodyParams: Record<string, string> = {
1041
+ client_id: this.config.clientId,
1042
+ client_secret: this.config.clientSecret,
1043
+ code,
1044
+ redirect_uri: redirectUri,
1045
+ grant_type: 'authorization_code',
1046
+ };
1047
+ if (codeVerifier) {
1048
+ bodyParams.code_verifier = codeVerifier;
1049
+ }
1050
+
1051
+ const resp = await fetch(discovery.token_endpoint, {
1052
+ method: 'POST',
1053
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1054
+ body: new URLSearchParams(bodyParams),
1055
+ });
1056
+
1057
+ if (!resp.ok) {
1058
+ const err = await resp.text().catch(() => '');
1059
+ throw new Error(`OIDC token exchange failed: ${resp.status} ${err}`);
1060
+ }
1061
+
1062
+ const data = await resp.json() as Record<string, unknown>;
1063
+ return {
1064
+ accessToken: data.access_token as string,
1065
+ tokenType: (data.token_type as string) || 'Bearer',
1066
+ idToken: data.id_token as string | undefined,
1067
+ refreshToken: data.refresh_token as string | undefined,
1068
+ expiresIn: data.expires_in as number | undefined,
1069
+ };
1070
+ }
1071
+
1072
+ async getUserInfo(accessToken: string): Promise<OAuthUserInfo> {
1073
+ const discovery = await this.getDiscovery();
1074
+
1075
+ if (!discovery.userinfo_endpoint) {
1076
+ throw new Error(`OIDC provider ${this.name} does not have a userinfo endpoint`);
1077
+ }
1078
+
1079
+ const resp = await fetch(discovery.userinfo_endpoint, {
1080
+ headers: { Authorization: `Bearer ${accessToken}` },
1081
+ });
1082
+
1083
+ if (!resp.ok) {
1084
+ throw new Error(`OIDC userinfo failed: ${resp.status}`);
1085
+ }
1086
+
1087
+ const data = await resp.json() as Record<string, unknown>;
1088
+ return {
1089
+ providerUserId: data.sub as string,
1090
+ email: (data.email as string) || null,
1091
+ emailVerified: Boolean(data.email_verified),
1092
+ displayName: (data.name as string) || (data.preferred_username as string) || null,
1093
+ avatarUrl: (data.picture as string) || null,
1094
+ raw: data,
1095
+ };
1096
+ }
1097
+ }
1098
+
1099
+ /**
1100
+ * Pre-fetch OIDC discovery document before calling getAuthorizationUrl().
1101
+ * This is needed because getAuthorizationUrl() is synchronous.
1102
+ */
1103
+ export async function prefetchOIDCDiscovery(issuer: string): Promise<void> {
1104
+ await fetchOIDCDiscovery(issuer);
1105
+ }
1106
+
1107
+ // ─── Provider Factory ───
1108
+
1109
+ const SUPPORTED_PROVIDERS = [
1110
+ 'google', 'github', 'apple', 'discord',
1111
+ // M18: Additional providers
1112
+ 'microsoft', 'facebook', 'kakao', 'naver', 'x', 'reddit', 'line', 'slack', 'spotify', 'twitch',
1113
+ ] as const;
1114
+ export type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number] | `oidc:${string}`;
1115
+
1116
+ /**
1117
+ * Check if a provider name is supported (built-in or OIDC federation).
1118
+ */
1119
+ export function isSupportedProvider(name: string): name is SupportedProvider {
1120
+ if ((SUPPORTED_PROVIDERS as readonly string[]).includes(name)) return true;
1121
+ // OIDC federation: oidc:{name}
1122
+ if (name.startsWith('oidc:') && name.length > 5) return true;
1123
+ return false;
1124
+ }
1125
+
1126
+ export function createOAuthProvider(name: SupportedProvider, config: OAuthProviderConfig): OAuthProvider {
1127
+ // OIDC federation: oidc:{name}
1128
+ if (name.startsWith('oidc:')) {
1129
+ const oidcConfig = config as OIDCProviderConfig;
1130
+ if (!oidcConfig.issuer) {
1131
+ throw new Error(`OIDC provider ${name} requires an 'issuer' in config.`);
1132
+ }
1133
+ return new OIDCGenericProvider(name, oidcConfig);
1134
+ }
1135
+
1136
+ switch (name) {
1137
+ case 'google': return new GoogleOAuthProvider(config);
1138
+ case 'github': return new GitHubOAuthProvider(config);
1139
+ case 'apple': return new AppleOAuthProvider(config);
1140
+ case 'discord': return new DiscordOAuthProvider(config);
1141
+ // M18
1142
+ case 'microsoft': return new MicrosoftOAuthProvider(config);
1143
+ case 'facebook': return new FacebookOAuthProvider(config);
1144
+ case 'kakao': return new KakaoOAuthProvider(config);
1145
+ case 'naver': return new NaverOAuthProvider(config);
1146
+ case 'x': return new XOAuthProvider(config);
1147
+ case 'reddit': return new RedditOAuthProvider(config);
1148
+ case 'line': return new LineOAuthProvider(config);
1149
+ case 'slack': return new SlackOAuthProvider(config);
1150
+ case 'spotify': return new SpotifyOAuthProvider(config);
1151
+ case 'twitch': return new TwitchOAuthProvider(config);
1152
+ default: throw new Error(`Unknown OAuth provider: ${name}`);
1153
+ }
1154
+ }
1155
+
1156
+ /**
1157
+ * Get OAuth provider config from a serialized config object.
1158
+ * Config format:
1159
+ * - Built-in: auth.oauth.{provider}.clientId, auth.oauth.{provider}.clientSecret
1160
+ * - OIDC: auth.oauth.oidc.{name}.clientId, auth.oauth.oidc.{name}.clientSecret, auth.oauth.oidc.{name}.issuer
1161
+ */
1162
+ function parseOAuthConfigInput(
1163
+ edgebaseConfig: Record<string, unknown> | string | undefined,
1164
+ ): Record<string, unknown> | null {
1165
+ if (!edgebaseConfig) return null;
1166
+ if (typeof edgebaseConfig === 'string') {
1167
+ try {
1168
+ const parsed = JSON.parse(edgebaseConfig);
1169
+ return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null;
1170
+ } catch {
1171
+ return null;
1172
+ }
1173
+ }
1174
+ return edgebaseConfig;
1175
+ }
1176
+
1177
+ export function getOAuthProviderConfig(
1178
+ edgebaseConfig: Record<string, unknown> | string | undefined,
1179
+ provider: SupportedProvider,
1180
+ ): OAuthProviderConfig | OIDCProviderConfig | null {
1181
+ const config = parseOAuthConfigInput(edgebaseConfig) as
1182
+ | {
1183
+ auth?: {
1184
+ oauth?: Record<string, unknown> & {
1185
+ oidc?: Record<string, { clientId?: string; clientSecret?: string; issuer?: string; scopes?: string[] }>;
1186
+ };
1187
+ };
1188
+ }
1189
+ | null;
1190
+ if (!config) return null;
1191
+
1192
+ // OIDC federation: oidc:{name}
1193
+ if (provider.startsWith('oidc:')) {
1194
+ const oidcName = provider.slice(5); // Remove 'oidc:' prefix
1195
+ const oidcConfig = config?.auth?.oauth?.oidc?.[oidcName];
1196
+ if (!oidcConfig?.clientId || !oidcConfig?.clientSecret || !oidcConfig?.issuer) return null;
1197
+ return {
1198
+ clientId: oidcConfig.clientId,
1199
+ clientSecret: oidcConfig.clientSecret,
1200
+ issuer: oidcConfig.issuer,
1201
+ scopes: oidcConfig.scopes,
1202
+ } as OIDCProviderConfig;
1203
+ }
1204
+
1205
+ const oauthConfig = config?.auth?.oauth?.[provider] as
1206
+ | { clientId?: string; clientSecret?: string }
1207
+ | undefined;
1208
+ if (!oauthConfig?.clientId || !oauthConfig?.clientSecret) return null;
1209
+ return { clientId: oauthConfig.clientId, clientSecret: oauthConfig.clientSecret };
1210
+ }
1211
+
1212
+ /**
1213
+ * Get list of allowed OAuth providers from config (includes OIDC federation providers).
1214
+ */
1215
+ export function getAllowedOAuthProviders(
1216
+ edgebaseConfig: Record<string, unknown> | string | undefined,
1217
+ ): SupportedProvider[] {
1218
+ const config = parseOAuthConfigInput(edgebaseConfig) as
1219
+ | { auth?: { allowedOAuthProviders?: string[] } }
1220
+ | null;
1221
+ if (!config) return [];
1222
+ const allowed = config?.auth?.allowedOAuthProviders;
1223
+ if (!Array.isArray(allowed)) return [];
1224
+ return allowed.filter((p: string) => isSupportedProvider(p)) as SupportedProvider[];
1225
+ }