Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions migrations/0021_sso_domain_verified.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- 0021: sso_provider.domain_verified(@better-auth/sso 的 domainVerification)。
--
-- 為什麼需要:OIDC callback 判斷「這個 provider 可不可以信任」的唯一條件是
-- provider.domainVerified === true 且登入者 email 的網域與 provider.domain 相符
-- (@better-auth/sso dist/index.mjs 的 isTrustedProvider)。不信任的 provider
-- 不能把 SSO 登入 link 到既有的同 email user —— 已經用 Google 登入過的員工改走
-- SSO 會直接被 ?error=UNKNOWN 踢回首頁(2026-08-28 上線當天發生)。
--
-- 我們不用 plugin 的 DNS TXT 驗證流程:HTTP 註冊端點整條封死(見 0020 的註解),
-- 註冊一律走 scripts/register-sso-provider.ts,該腳本從本 migration 起直接寫
-- domain_verified = true —— 管理者用腳本註冊即視為已驗證。
--
-- Forward-only, additive. Run AFTER 0020_sso_provider.sql.

ALTER TABLE sso_provider
ADD COLUMN domain_verified boolean NOT NULL DEFAULT false;

-- 既有的 provider 都是腳本註冊的(HTTP 路徑從未開放),一併視為已驗證。
UPDATE sso_provider SET domain_verified = true;
4 changes: 4 additions & 0 deletions scripts/register-sso-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ await ctx.adapter.create({
samlConfig: null,
userId,
organizationId,
// 管理者用腳本註冊即視為已驗證網域(我們不用 plugin 的 DNS TXT 流程)。
// 這是 OIDC callback 肯把 SSO 登入 link 到既有同 email user 的前提,
// 見 migrations/0021_sso_domain_verified.sql。
domainVerified: true,
},
});

Expand Down
14 changes: 10 additions & 4 deletions src/db/auth-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,12 @@ export const oauthConsent = pgTable("oauth_consent", {
// providerId / organizationId / domain。plugin 沒有為這個 model 宣告
// createdAt / updatedAt,所以這裡也沒有。
//
// `domainVerified` 只在 plugin 開了 `domainVerification` 時才會出現在 schema 裡,
// 本專案沒開,所以不放。adapter 是照 schema 的欄位逐一取值(@better-auth/core
// 的 adapter factory:`for (const field in fields)`),schema 裡沒有的輸入欄位
// 會被忽略而不是報錯,所以少這一欄不會炸。
// `domainVerified` 隨 plugin 的 `domainVerification` 選項存在(auth.ts 有開)。
// 它是 OIDC callback 信任判斷的唯一依據:provider.domainVerified === true 且
// email 網域與 provider.domain 相符,才允許把這次 SSO 登入 link 到既有的同
// email user(better-auth link-account 的 isTrustedProvider)。少了它,已用
// Google 登入過的人走 SSO 會被拒絕連結,整個 callback 以 ?error=UNKNOWN 收場
// —— 2026-08-28 上線當天就踩到。
//
// export 名稱必須正好是 `ssoProvider`:drizzle adapter 用 model 名稱去 schema
// 物件上取表。DB 表名照本專案慣例用 snake_case,見 migrations/0020_sso_provider.sql。
Expand All @@ -164,4 +166,8 @@ export const ssoProvider = pgTable("sso_provider", {
organizationId: text("organization_id"),
// home realm discovery 的鍵:登入時拿 email 的網域來比對這一欄。
domain: text("domain").notNull(),
// 我們沒有走 plugin 的 DNS TXT 驗證流程(HTTP 註冊整條封死了);這欄由
// scripts/register-sso-provider.ts 在註冊時直接寫 true —— 管理者用腳本註冊
// 就是驗證。migration: 0021_sso_domain_verified.sql。
domainVerified: boolean("domain_verified").notNull().default(false),
});
8 changes: 7 additions & 1 deletion src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,13 @@ export const auth = betterAuth({
//
// 刻意不設 organizationProvisioning:第一次 SSO 登入會自動建 user
// (implicit signup,這是我們要的),但要進哪個組織仍走既有的邀請流程。
sso({ providersLimit: 0 }),
// domainVerification 是 OIDC callback 的信任開關:開了之後 plugin 會讀
// sso_provider.domain_verified,「已驗證網域的 provider + email 網域相符」
// 才算 trusted provider,better-auth 才肯把 SSO 登入 link 到既有的同 email
// user(例如先用 Google 登入過的員工)。不開的話 link 一律被拒,走 SSO 的
// 老用戶會拿到 ?error=UNKNOWN(2026-08-28 實際發生過)。我們不用它附帶的
// DNS TXT 驗證流程 —— 註冊走腳本、domain_verified 由腳本直接寫 true。
sso({ providersLimit: 0, domainVerification: { enabled: true } }),
// OAuth 2.0 / OIDC provider for MCP clients. Adds /api/auth/mcp/* endpoints
// (authorize, token, register, get-session) + OAuth discovery. Unauthenticated
// authorize requests are sent to loginPage, which redirects back after sign-in.
Expand Down