@edge-base/server 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin-build/.gitkeep +0 -0
- package/admin-build/_app/env.js +1 -0
- package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
- package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
- package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
- package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
- package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
- package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
- package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
- package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
- package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
- package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
- package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
- package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
- package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
- package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
- package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
- package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
- package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
- package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
- package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
- package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
- package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
- package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
- package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
- package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
- package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
- package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
- package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
- package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
- package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
- package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
- package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
- package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
- package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
- package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
- package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
- package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
- package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
- package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
- package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
- package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
- package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
- package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
- package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
- package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
- package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
- package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
- package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
- package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
- package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
- package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
- package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
- package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
- package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
- package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
- package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
- package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
- package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
- package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
- package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
- package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
- package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
- package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
- package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
- package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
- package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
- package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
- package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
- package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
- package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
- package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
- package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
- package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
- package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
- package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
- package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
- package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
- package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
- package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
- package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
- package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
- package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
- package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
- package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
- package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
- package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
- package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
- package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
- package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
- package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
- package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
- package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
- package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
- package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
- package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
- package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
- package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
- package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
- package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
- package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
- package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
- package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
- package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
- package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
- package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
- package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
- package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
- package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
- package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
- package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
- package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
- package/admin-build/_app/version.json +1 -0
- package/admin-build/favicon.svg +26 -0
- package/admin-build/index.html +45 -0
- package/openapi.json +19543 -0
- package/package.json +66 -0
- package/src/__tests__/admin-assets.test.ts +55 -0
- package/src/__tests__/admin-data-routes.test.ts +488 -0
- package/src/__tests__/admin-db-target.test.ts +103 -0
- package/src/__tests__/admin-routing.test.ts +31 -0
- package/src/__tests__/admin-user-management.test.ts +311 -0
- package/src/__tests__/analytics-query.test.ts +75 -0
- package/src/__tests__/auth-d1.test.ts +749 -0
- package/src/__tests__/auth-db-adapter.test.ts +73 -0
- package/src/__tests__/auth-jwt.test.ts +440 -0
- package/src/__tests__/auth-oauth.test.ts +389 -0
- package/src/__tests__/auth-password.test.ts +367 -0
- package/src/__tests__/auth-redirect.test.ts +87 -0
- package/src/__tests__/backup-restore.test.ts +711 -0
- package/src/__tests__/broadcast.test.ts +128 -0
- package/src/__tests__/cli.test.ts +178 -0
- package/src/__tests__/cloudflare-realtime.test.ts +113 -0
- package/src/__tests__/config.test.ts +469 -0
- package/src/__tests__/cors.test.ts +154 -0
- package/src/__tests__/cron.test.ts +302 -0
- package/src/__tests__/d1-handler.test.ts +402 -0
- package/src/__tests__/d1-sql.test.ts +120 -0
- package/src/__tests__/database-live-config.test.ts +42 -0
- package/src/__tests__/database-live-emitter.test.ts +56 -0
- package/src/__tests__/database-live-filters.test.ts +63 -0
- package/src/__tests__/database-live-route.test.ts +113 -0
- package/src/__tests__/db-sql.test.ts +163 -0
- package/src/__tests__/do-lifecycle.test.ts +263 -0
- package/src/__tests__/do-router.test.ts +729 -0
- package/src/__tests__/email-provider.test.ts +128 -0
- package/src/__tests__/email-templates.test.ts +528 -0
- package/src/__tests__/error-format.test.ts +250 -0
- package/src/__tests__/field-ops.test.ts +242 -0
- package/src/__tests__/functions-context.test.ts +334 -0
- package/src/__tests__/functions-d1-proxy.test.ts +229 -0
- package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
- package/src/__tests__/functions-route.test.ts +139 -0
- package/src/__tests__/internal-request.test.ts +77 -0
- package/src/__tests__/log-writer.test.ts +44 -0
- package/src/__tests__/logger.test.ts +58 -0
- package/src/__tests__/meta-admin-proxy.test.ts +48 -0
- package/src/__tests__/meta-export-coverage.test.ts +191 -0
- package/src/__tests__/meta-route-registration.test.ts +47 -0
- package/src/__tests__/namespace-dump.test.ts +28 -0
- package/src/__tests__/oauth-providers.test.ts +337 -0
- package/src/__tests__/openapi-coverage.test.ts +144 -0
- package/src/__tests__/pagination.test.ts +59 -0
- package/src/__tests__/password-policy.test.ts +191 -0
- package/src/__tests__/plugin-migrations.test.ts +379 -0
- package/src/__tests__/postgres-batch-compat.test.ts +133 -0
- package/src/__tests__/postgres-dialect.test.ts +328 -0
- package/src/__tests__/postgres-executor.test.ts +79 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
- package/src/__tests__/postgres-schema-init.test.ts +105 -0
- package/src/__tests__/postgres-table-utils.test.ts +107 -0
- package/src/__tests__/presence.test.ts +199 -0
- package/src/__tests__/provider.test.ts +550 -0
- package/src/__tests__/public-user-profile.test.ts +339 -0
- package/src/__tests__/push-handlers.test.ts +179 -0
- package/src/__tests__/push-provider.test.ts +80 -0
- package/src/__tests__/push-token.test.ts +418 -0
- package/src/__tests__/query.test.ts +771 -0
- package/src/__tests__/rate-limit.test.ts +260 -0
- package/src/__tests__/room-access-policy.test.ts +101 -0
- package/src/__tests__/room-handler-context.test.ts +130 -0
- package/src/__tests__/room-monitoring.test.ts +138 -0
- package/src/__tests__/room-runtime-routing.test.ts +222 -0
- package/src/__tests__/room.test.ts +254 -0
- package/src/__tests__/route-parser.test.ts +490 -0
- package/src/__tests__/rules.test.ts +234 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
- package/src/__tests__/scheduled.test.ts +80 -0
- package/src/__tests__/schema.test.ts +1273 -0
- package/src/__tests__/security-hardening.test.ts +312 -0
- package/src/__tests__/server.unit.test.ts +333 -0
- package/src/__tests__/service-key-db-proxy.test.ts +650 -0
- package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
- package/src/__tests__/service-key.test.ts +757 -0
- package/src/__tests__/smoke-skip-report.test.ts +72 -0
- package/src/__tests__/sms-provider.test.ts +39 -0
- package/src/__tests__/sql-route.test.ts +218 -0
- package/src/__tests__/storage-hook-context.test.ts +115 -0
- package/src/__tests__/totp.test.ts +200 -0
- package/src/__tests__/uuid.test.ts +144 -0
- package/src/__tests__/validation.test.ts +773 -0
- package/src/__tests__/websocket-pending.test.ts +163 -0
- package/src/_functions-registry.ts +51 -0
- package/src/bench-entry.ts +9 -0
- package/src/cloudflare-test.d.ts +1 -0
- package/src/durable-objects/auth-do.ts +49 -0
- package/src/durable-objects/database-do.ts +2240 -0
- package/src/durable-objects/database-live-do.ts +949 -0
- package/src/durable-objects/logs-do.ts +1200 -0
- package/src/durable-objects/room-runtime-base.ts +1604 -0
- package/src/durable-objects/rooms-do.ts +2191 -0
- package/src/generated-config.ts +6 -0
- package/src/index.ts +382 -0
- package/src/lib/admin-assets.ts +54 -0
- package/src/lib/admin-db-target.ts +301 -0
- package/src/lib/admin-routing.ts +35 -0
- package/src/lib/admin-user-management.ts +464 -0
- package/src/lib/analytics-adapter.ts +103 -0
- package/src/lib/analytics-query.ts +579 -0
- package/src/lib/auth-d1-service.ts +1193 -0
- package/src/lib/auth-d1.ts +1056 -0
- package/src/lib/auth-db-adapter.ts +289 -0
- package/src/lib/auth-redirect.ts +116 -0
- package/src/lib/cidr.ts +115 -0
- package/src/lib/client-ip.ts +51 -0
- package/src/lib/cloudflare-realtime.ts +251 -0
- package/src/lib/control-db.ts +36 -0
- package/src/lib/cron.ts +163 -0
- package/src/lib/d1-handler.ts +1425 -0
- package/src/lib/d1-schema-init.ts +255 -0
- package/src/lib/d1-sql.ts +33 -0
- package/src/lib/database-live-config.ts +24 -0
- package/src/lib/database-live-emitter.ts +111 -0
- package/src/lib/db-sql.ts +66 -0
- package/src/lib/do-retry.ts +36 -0
- package/src/lib/do-router.ts +270 -0
- package/src/lib/do-sql.ts +73 -0
- package/src/lib/email-provider.ts +379 -0
- package/src/lib/email-templates.ts +285 -0
- package/src/lib/email-translations.ts +422 -0
- package/src/lib/errors.ts +151 -0
- package/src/lib/functions.ts +2091 -0
- package/src/lib/hono.ts +56 -0
- package/src/lib/internal-request.ts +56 -0
- package/src/lib/jwt.ts +354 -0
- package/src/lib/log-writer.ts +272 -0
- package/src/lib/namespace-dump.ts +125 -0
- package/src/lib/oauth-providers.ts +1225 -0
- package/src/lib/op-parser.ts +99 -0
- package/src/lib/openapi.ts +146 -0
- package/src/lib/pagination.ts +19 -0
- package/src/lib/password-policy.ts +102 -0
- package/src/lib/password.ts +145 -0
- package/src/lib/plugin-migrations.ts +612 -0
- package/src/lib/postgres-executor.ts +203 -0
- package/src/lib/postgres-handler.ts +1102 -0
- package/src/lib/postgres-schema-init.ts +341 -0
- package/src/lib/postgres-table-utils.ts +87 -0
- package/src/lib/public-user-profile.ts +187 -0
- package/src/lib/push-provider.ts +409 -0
- package/src/lib/push-token.ts +294 -0
- package/src/lib/query-engine.ts +768 -0
- package/src/lib/room-monitoring.ts +97 -0
- package/src/lib/room-runtime.ts +14 -0
- package/src/lib/route-parser.ts +434 -0
- package/src/lib/schema.ts +538 -0
- package/src/lib/schemas.ts +152 -0
- package/src/lib/service-key.ts +419 -0
- package/src/lib/sms-provider.ts +230 -0
- package/src/lib/startup-config.ts +99 -0
- package/src/lib/totp.ts +242 -0
- package/src/lib/uuid.ts +87 -0
- package/src/lib/validation.ts +205 -0
- package/src/lib/version.ts +2 -0
- package/src/lib/websocket-pending.ts +40 -0
- package/src/middleware/auth.ts +169 -0
- package/src/middleware/captcha-verify.ts +217 -0
- package/src/middleware/cors.ts +159 -0
- package/src/middleware/error-handler.ts +54 -0
- package/src/middleware/internal-guard.ts +26 -0
- package/src/middleware/logger.ts +126 -0
- package/src/middleware/rate-limit.ts +283 -0
- package/src/middleware/rules.ts +475 -0
- package/src/routes/admin-auth.ts +447 -0
- package/src/routes/admin.ts +3501 -0
- package/src/routes/analytics-api.ts +290 -0
- package/src/routes/auth.ts +4222 -0
- package/src/routes/backup.ts +1466 -0
- package/src/routes/config.ts +53 -0
- package/src/routes/d1.ts +109 -0
- package/src/routes/database-live.ts +281 -0
- package/src/routes/functions.ts +155 -0
- package/src/routes/health.ts +32 -0
- package/src/routes/kv.ts +167 -0
- package/src/routes/oauth.ts +1055 -0
- package/src/routes/push.ts +1465 -0
- package/src/routes/room.ts +639 -0
- package/src/routes/schema-endpoint.ts +76 -0
- package/src/routes/sql.ts +176 -0
- package/src/routes/storage.ts +1674 -0
- package/src/routes/tables.ts +699 -0
- package/src/routes/users.ts +21 -0
- package/src/routes/vectorize.ts +372 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,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
|
+
}
|