From 6dfa9bbf89b32abd64fed3c1450022bef427f63a Mon Sep 17 00:00:00 2001 From: gunshiz Date: Mon, 5 Oct 2026 16:55:38 +0700 Subject: [PATCH] Route Buzz Guide login through Sudloh Account --- .env.example | 9 ++++ README.md | 9 ++-- app/auth/login/page.tsx | 5 +- app/profile/page.tsx | 32 ++++++++---- components/auth/account-form.tsx | 35 ++++++++++++- components/auth/account-page.tsx | 16 ++++-- components/auth/sudloh-connection.tsx | 54 +++++++++++++++++++++ components/auth/sudloh-sign-in-redirect.tsx | 33 +++++++++++++ k8s/base/configmap.yaml | 3 ++ k8s/base/deployment.yaml | 12 +++++ lib/auth/server.test.ts | 47 +++++++++++++++++- lib/auth/server.ts | 41 ++++++++++++++-- tests/security-boundaries.test.ts | 3 +- 13 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 components/auth/sudloh-connection.tsx create mode 100644 components/auth/sudloh-sign-in-redirect.tsx diff --git a/.env.example b/.env.example index f605291..277435f 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,15 @@ BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes # Separate trusted browser origins with commas for local development or proxies. BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 +# Optional Sudloh Account sign-in. Register the exact HTTPS callback before enabling. +# Leave client ID and secret unset until the account service operator assigns them. +SUDLOH_OIDC_ISSUER=https://account.sudloh.com/api/auth +SUDLOH_OIDC_CLIENT_ID= +SUDLOH_OIDC_CLIENT_SECRET= +SUDLOH_OIDC_REDIRECT_URI= +# Set only after migration and conflict review to route all new sign-ins through Sudloh. +SUDLOH_OIDC_ONLY=false + # Resend sending key; verify sudloh.com before sending from no-reply@sudloh.com. RESEND_API_KEY=replace-with-resend-sending-key diff --git a/README.md b/README.md index 726810e..d485ec1 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ endorsed by HoYoverse. The repository and deployment resources retain the - **Public guides:** a searchable character directory and responsive guide pages. - **Visual editing:** structured editors for overviews, weapons, artifacts, constellations, teams, and custom sections, with autosave and conflict recovery. -- **Accounts:** email/password authentication through Better Auth, registration - email OTP, profile email changes, and administrator-managed accounts. +- **Accounts:** Sudloh Account OIDC sign-in with local Buzz sessions, IDs, and + roles. Legacy email/password and registration OTP remain available only before + `SUDLOH_OIDC_ONLY=true` cutover. - **Media:** S3-compatible uploads and publication-aware delivery for staged files. - **Catalog updates:** Discord-triggered synchronization with Lunaris. - **Commissions:** PromptPay checkout, Slip2Go verification, ticket attachments, @@ -71,7 +72,9 @@ bun run db:migrate bun run dev ``` -Visitors can register at `/auth/register`. For background processing, run the +With the Sudloh client configured, `/auth/login` starts the Account sign-in +redirect automatically. Before OIDC cutover, visitors can register at +`/auth/register`. For background processing, run the outbox worker in a separate terminal. Run the Discord worker when using catalog updates; its configuration is described below. diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index f138bb3..6ef95bf 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -5,7 +5,8 @@ export const instant = false; export default async function LoginPage({ searchParams }: PageProps<"/auth/login">) { await connection(); - const { next, verified, error } = await searchParams; + const { next, verified, error, sudloh } = await searchParams; + const oidcError = sudloh === "1" && typeof error === "string" ? error : undefined; return ; + verificationError={typeof error === "string" && !oidcError} oidcError={oidcError} />; } diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 80e2f47..42d8183 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -2,29 +2,38 @@ import { eq } from "drizzle-orm"; import { redirect } from "next/navigation"; import { connection } from "next/server"; import { getDb } from "@/db"; -import { users } from "@/db/schema"; +import { accounts, users } from "@/db/schema"; import { SiteHeader } from "@/components/public/site-header"; import { AdminHeader } from "@/components/admin/admin-header"; import { ProfileForm } from "@/components/auth/profile-form"; import { PasswordForm } from "@/components/auth/password-form"; import { EmailSettings } from "@/components/auth/email-settings"; +import { SudlohConnection } from "@/components/auth/sudloh-connection"; import { isAuthorizedAdmin } from "@/lib/auth/authorization"; -import { getCustomerSession } from "@/lib/auth/server"; +import { getCustomerSession, isSudlohOidcEnabled } from "@/lib/auth/server"; import { safeAuthReturnPath } from "@/lib/auth/return-path"; export const instant = false; export default async function ProfilePage({ searchParams }: PageProps<"/profile">) { await connection(); - const { setup, next, upload, emailAction, error } = await searchParams; + const { setup, next, upload, emailAction, error, sudloh } = await searchParams; const nextPath = safeAuthReturnPath(next); const setupNextPath = nextPath === "/profile" || nextPath.startsWith("/profile?") ? "/" : nextPath; const session = await getCustomerSession(); if (!session) redirect("/auth/login?next=%2Fprofile"); - const [user] = await getDb().select({ name: users.name, email: users.email, - emailVerified: users.emailVerified, image: users.image }) - .from(users).where(eq(users.id, session.user.id)).limit(1); + const oidcEnabled = isSudlohOidcEnabled(); + const [userRows, accountRows] = await Promise.all([ + getDb().select({ name: users.name, email: users.email, + emailVerified: users.emailVerified, image: users.image }) + .from(users).where(eq(users.id, session.user.id)).limit(1), + oidcEnabled ? getDb().select({ providerId: accounts.providerId }) + .from(accounts).where(eq(accounts.userId, session.user.id)) : Promise.resolve([]), + ]); + const [user] = userRows; if (!user) redirect("/auth/login?next=%2Fprofile"); + const hasSudloh = accountRows.some((account) => account.providerId === "sudloh"); + const hasCredential = accountRows.some((account) => account.providerId === "credential"); return
{isAuthorizedAdmin(session.user) ? : }

โปรไฟล์ของฉัน

@@ -34,9 +43,14 @@ export default async function ProfilePage({ searchParams }: PageProps<"/profile"

}
- - + {oidcEnabled && } + {(!hasSudloh || hasCredential) && } + {(!hasSudloh || hasCredential) && } + {hasSudloh && !hasCredential &&

+ จัดการอีเมลและรหัสผ่านของคุณผ่าน Sudloh Account +

}
; diff --git a/components/auth/account-form.tsx b/components/auth/account-form.tsx index edd8e7a..1d2a6ff 100644 --- a/components/auth/account-form.tsx +++ b/components/auth/account-form.tsx @@ -32,12 +32,18 @@ export function AccountForm({ nextPath, verified, verificationError, + oidcEnabled, + oidcOnly, + oidcError, }: { mode: "login" | "register"; siteKey: string; nextPath: string; verified?: boolean; verificationError?: boolean; + oidcEnabled?: boolean; + oidcOnly?: boolean; + oidcError?: string; }) { const router = useRouter(); const register = mode === "register"; @@ -52,7 +58,7 @@ export function AccountForm({ const formRef = useRef(null); const container = useRef(null); const widget = useRef(null); - const captcha = process.env.NODE_ENV !== "development"; + const captcha = !oidcOnly && process.env.NODE_ENV !== "development"; useEffect(() => () => { if (profilePreview) URL.revokeObjectURL(profilePreview); }, [profilePreview]); @@ -172,12 +178,38 @@ export function AccountForm({ } finally { setBusy(false); } } + async function signInWithSudloh() { + if (busy) return; + setBusy(true); + setError(""); + try { + const errorCallbackURL = `/auth/login?next=${encodeURIComponent(nextPath)}&sudloh=1`; + const result = await authClient.signIn.social({ + provider: "sudloh", callbackURL: nextPath, errorCallbackURL, + }); + if (result.error) throw new Error("เริ่มเข้าสู่ระบบด้วย Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง"); + } catch { + setError("เริ่มเข้าสู่ระบบด้วย Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง"); + setBusy(false); + } + } + return ( {register ? "สมัครสมาชิก" : "เข้าสู่ระบบ"} + {oidcEnabled && } + {oidcError && + {oidcError === "account_not_linked" + ? oidcOnly ? "บัญชีนี้ยังไม่เชื่อมต่อกับ Buzz Guide กรุณาติดต่อผู้ดูแลเพื่อยืนยันตัวตน" + : "มีบัญชี Buzz Guide ที่ใช้อีเมลนี้แล้ว กรุณาเข้าสู่ระบบด้วยอีเมลและเชื่อมต่อ Sudloh จากหน้าโปรไฟล์" + : "เข้าสู่ระบบด้วย Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง"} + } + {!oidcOnly && <> {verified && ยืนยันอีเมลแล้ว กรุณาเข้าสู่ระบบ} {verificationError && ลิงก์ยืนยันไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่} {notice && {notice}} @@ -279,6 +311,7 @@ export function AccountForm({ {error} )} + } ); diff --git a/components/auth/account-page.tsx b/components/auth/account-page.tsx index 3d3a7ae..ef1c3de 100644 --- a/components/auth/account-page.tsx +++ b/components/auth/account-page.tsx @@ -1,12 +1,14 @@ import { redirect } from "next/navigation"; import { SiteHeader } from "@/components/public/site-header"; import { isAuthorizedAdmin } from "@/lib/auth/authorization"; -import { getCustomerSession } from "@/lib/auth/server"; +import { getCustomerSession, isSudlohOidcEnabled } from "@/lib/auth/server"; import { safeAuthReturnPath } from "@/lib/auth/return-path"; import { AccountForm } from "./account-form"; +import { SudlohSignInRedirect } from "./sudloh-sign-in-redirect"; -export async function AccountPage({ mode, next, verified, verificationError }: { +export async function AccountPage({ mode, next, verified, verificationError, oidcError }: { mode: "login" | "register"; next: unknown; verified?: boolean; verificationError?: boolean; + oidcError?: string; }) { const nextPath = safeAuthReturnPath(next); const session = await getCustomerSession(); @@ -15,9 +17,15 @@ export async function AccountPage({ mode, next, verified, verificationError }: { redirect(nextPath); } const siteKey = process.env.TURNSTILE_SITE_KEY ?? ""; - if (process.env.NODE_ENV !== "development" && !siteKey) throw new Error("TURNSTILE_SITE_KEY is required"); + const oidcEnabled = isSudlohOidcEnabled(); + const oidcOnly = oidcEnabled && process.env.SUDLOH_OIDC_ONLY === "true"; + if (mode === "login" && oidcEnabled && !oidcError) { + return
; + } + if (!oidcOnly && process.env.NODE_ENV !== "development" && !siteKey) throw new Error("TURNSTILE_SITE_KEY is required"); return
+ oidcEnabled={oidcEnabled} oidcOnly={oidcOnly} + verified={verified} verificationError={verificationError} oidcError={oidcError} />
; } diff --git a/components/auth/sudloh-connection.tsx b/components/auth/sudloh-connection.tsx new file mode 100644 index 0000000..f081275 --- /dev/null +++ b/components/auth/sudloh-connection.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { authClient } from "@/lib/auth/client"; + +export function SudlohConnection({ linked, callbackError }: { + linked: boolean; + callbackError?: string; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + async function connect() { + if (busy) return; + setBusy(true); + setError(""); + try { + const result = await authClient.linkSocial({ + provider: "sudloh", + callbackURL: "/profile?sudloh=linked", + errorCallbackURL: "/profile?sudloh=error", + }); + if (result.error) throw new Error(); + } catch { + setError("เชื่อมต่อ Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง"); + setBusy(false); + } + } + + const callbackMessage = callbackError === "email_does_not_match" + ? "อีเมล Sudloh ต้องตรงกับอีเมล Buzz Guide ก่อนเชื่อมต่อบัญชี" + : callbackError === "account_already_linked_to_different_user" + ? "บัญชี Sudloh นี้เชื่อมต่อกับบัญชี Buzz Guide อื่นแล้ว" + : callbackError ? "เชื่อมต่อ Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง" : ""; + + return + Sudloh Account + +

+ {linked ? "เชื่อมต่อแล้ว คุณสามารถใช้ Sudloh เพื่อเข้าสู่ระบบ Buzz Guide" + : "เชื่อมต่อบัญชี Sudloh เพื่อใช้เข้าสู่ระบบ Buzz Guide ในครั้งถัดไป"} +

+ {!linked && } + {(callbackMessage || error) && + {callbackMessage || error} + } +
+
; +} diff --git a/components/auth/sudloh-sign-in-redirect.tsx b/components/auth/sudloh-sign-in-redirect.tsx new file mode 100644 index 0000000..3bd36c6 --- /dev/null +++ b/components/auth/sudloh-sign-in-redirect.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth/client"; + +export function SudlohSignInRedirect({ nextPath }: { nextPath: string }) { + const started = useRef(false); + const [failed, setFailed] = useState(false); + + const start = useCallback(async () => { + setFailed(false); + const result = await authClient.signIn.social({ + provider: "sudloh", + callbackURL: nextPath, + errorCallbackURL: `/auth/login?next=${encodeURIComponent(nextPath)}&sudloh=1`, + }); + if (result.error) setFailed(true); + }, [nextPath]); + + useEffect(() => { + if (started.current) return; + started.current = true; + void start().catch(() => setFailed(true)); + }, [start]); + + return
+
+

{failed ? "ไม่สามารถเริ่มเข้าสู่ระบบได้" : "กำลังไปที่ Sudloh Account…"}

+ {failed && } +
+
; +} diff --git a/k8s/base/configmap.yaml b/k8s/base/configmap.yaml index 30e3754..cd03bf0 100644 --- a/k8s/base/configmap.yaml +++ b/k8s/base/configmap.yaml @@ -7,6 +7,9 @@ metadata: data: BASE_URL: https://guide.sudloh.com BETTER_AUTH_URL: https://guide.sudloh.com + SUDLOH_OIDC_ISSUER: https://account.sudloh.com/api/auth + SUDLOH_OIDC_REDIRECT_URI: https://guide.sudloh.com/api/auth/callback/sudloh + SUDLOH_OIDC_ONLY: "true" # Traefik must overwrite this header; do not expose the app directly. TRUSTED_CLIENT_IP_HEADER: x-real-ip DATABASE_POOL_SIZE: "10" diff --git a/k8s/base/deployment.yaml b/k8s/base/deployment.yaml index 27fd48a..3b6b939 100644 --- a/k8s/base/deployment.yaml +++ b/k8s/base/deployment.yaml @@ -87,6 +87,18 @@ spec: secretKeyRef: name: buzz-sheet-env key: BETTER_AUTH_SECRET + - name: SUDLOH_OIDC_CLIENT_ID + valueFrom: + secretKeyRef: + name: buzz-sheet-env + key: SUDLOH_OIDC_CLIENT_ID + optional: true + - name: SUDLOH_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: buzz-sheet-env + key: SUDLOH_OIDC_CLIENT_SECRET + optional: true - name: RESEND_API_KEY valueFrom: secretKeyRef: diff --git a/lib/auth/server.test.ts b/lib/auth/server.test.ts index 17d9721..7eb275b 100644 --- a/lib/auth/server.test.ts +++ b/lib/auth/server.test.ts @@ -8,9 +8,10 @@ vi.mock("better-auth", () => ({ betterAuth: mocks.auth })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: () => ({}) })); vi.mock("better-auth/next-js", () => ({ nextCookies: () => ({}) })); vi.mock("better-auth/plugins", () => ({ admin: (options: unknown) => options, captcha: () => ({}), + genericOAuth: (options: unknown) => ({ id: "generic-oauth", options }), emailOTP: (options: unknown) => ({ id: "email-otp", options }) })); -const envNames = ["NODE_ENV", "DATABASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET", "BUZZ_DEMO_MODE", "RESEND_API_KEY"] as const; +const envNames = ["NODE_ENV", "DATABASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET", "BUZZ_DEMO_MODE", "RESEND_API_KEY", "SUDLOH_OIDC_CLIENT_ID", "SUDLOH_OIDC_CLIENT_SECRET", "SUDLOH_OIDC_REDIRECT_URI", "SUDLOH_OIDC_ISSUER", "SUDLOH_OIDC_ONLY"] as const; const testEnv = process.env as Record; const originalEnv = envNames.map((name) => testEnv[name]); @@ -22,6 +23,11 @@ beforeEach(() => { testEnv.BETTER_AUTH_SECRET = "a-test-secret-with-more-than-32-characters"; mocks.auth.mockReturnValue({ api: { getSession: mocks.session } }); mocks.session.mockResolvedValue(null); + delete testEnv.SUDLOH_OIDC_CLIENT_ID; + delete testEnv.SUDLOH_OIDC_CLIENT_SECRET; + delete testEnv.SUDLOH_OIDC_REDIRECT_URI; + delete testEnv.SUDLOH_OIDC_ISSUER; + delete testEnv.SUDLOH_OIDC_ONLY; }); afterEach(() => { envNames.forEach((name, index) => { @@ -56,6 +62,45 @@ describe("actual administrator session boundary", () => { expect(options.rateLimit.customStorage.consume).toBeTypeOf("function"); expect(options.databaseHooks).toBeUndefined(); }); + it("adds verified Sudloh sign-in while retaining email sign-in", async () => { + testEnv.SUDLOH_OIDC_CLIENT_ID = "client"; + testEnv.SUDLOH_OIDC_CLIENT_SECRET = "secret"; + testEnv.SUDLOH_OIDC_REDIRECT_URI = "https://guide.example.test/api/auth/callback/sudloh"; + const { getAuth, isSudlohOidcEnabled } = await import("./server"); + expect(isSudlohOidcEnabled()).toBe(true); + getAuth(); + const options = mocks.auth.mock.calls.at(-1)![0]; + expect(options.emailAndPassword).toMatchObject({ enabled: true, disableSignUp: false }); + expect(options.account.accountLinking.disableImplicitLinking).toBe(true); + expect(options.account.accountLinking.enabled).not.toBe(false); + expect(options.plugins.some((plugin: { id?: string }) => plugin.id === "email-otp")).toBe(true); + expect(options.plugins.find((plugin: { id?: string }) => plugin.id === "generic-oauth").options.config[0]).toMatchObject({ + providerId: "sudloh", authentication: "basic", requireIdTokenVerification: true, + requireEmailVerification: true, disableProviderLogout: true, + scopes: ["openid", "profile", "email"], + redirectURI: "https://guide.example.test/api/auth/callback/sudloh", + }); + }); + it("disables local sign-in after the OIDC cutover flag is set", async () => { + testEnv.SUDLOH_OIDC_CLIENT_ID = "client"; + testEnv.SUDLOH_OIDC_CLIENT_SECRET = "secret"; + testEnv.SUDLOH_OIDC_REDIRECT_URI = "https://guide.example.test/api/auth/callback/sudloh"; + testEnv.SUDLOH_OIDC_ONLY = "true"; + (await import("./server")).getAuth(); + const options = mocks.auth.mock.calls.at(-1)![0]; + expect(options.emailAndPassword).toMatchObject({ enabled: false, disableSignUp: true }); + expect(options.plugins.some((plugin: { id?: string }) => plugin.id === "email-otp")).toBe(false); + }); + it("rejects incomplete or mismatched Sudloh client configuration", async () => { + const { getAuth, isSudlohOidcEnabled } = await import("./server"); + testEnv.SUDLOH_OIDC_REDIRECT_URI = "https://guide.example.test/api/auth/callback/sudloh"; + expect(isSudlohOidcEnabled()).toBe(false); + testEnv.SUDLOH_OIDC_CLIENT_ID = "client"; + expect(isSudlohOidcEnabled).toThrow("client ID, client secret, and exact redirect URI"); + testEnv.SUDLOH_OIDC_CLIENT_SECRET = "secret"; + testEnv.SUDLOH_OIDC_REDIRECT_URI = "https://wrong.example.test/api/auth/callback/sudloh"; + expect(getAuth).toThrow("SUDLOH_OIDC_REDIRECT_URI must be"); + }); it("requires verification and sends auth links from the configured sender", async () => { testEnv.RESEND_API_KEY = "test-key"; const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); diff --git a/lib/auth/server.ts b/lib/auth/server.ts index a2db7a7..3aa4032 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -3,7 +3,7 @@ import "server-only"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { nextCookies } from "better-auth/next-js"; -import { admin, captcha, emailOTP } from "better-auth/plugins"; +import { admin, captcha, emailOTP, genericOAuth } from "better-auth/plugins"; import { headers } from "next/headers"; import { cache } from "react"; @@ -32,7 +32,25 @@ function hasAuthConfiguration(): boolean { ); } +export function isSudlohOidcEnabled(): boolean { + const clientId = process.env.SUDLOH_OIDC_CLIENT_ID; + const clientSecret = process.env.SUDLOH_OIDC_CLIENT_SECRET; + if (!clientId && !clientSecret) return false; + if (!clientId || !clientSecret || !process.env.SUDLOH_OIDC_REDIRECT_URI) { + throw new Error("Sudloh OIDC requires a client ID, client secret, and exact redirect URI."); + } + return true; +} + function createAuth() { + const oidcEnabled = isSudlohOidcEnabled(); + const oidcOnly = oidcEnabled && process.env.SUDLOH_OIDC_ONLY === "true"; + if (oidcEnabled) { + const callback = `${required("BETTER_AUTH_URL").replace(/\/$/, "")}/api/auth/callback/sudloh`; + if (process.env.SUDLOH_OIDC_REDIRECT_URI !== callback) { + throw new Error(`SUDLOH_OIDC_REDIRECT_URI must be ${callback}`); + } + } return betterAuth({ appName: "Buzz Guide", database: drizzleAdapter(getDb(), { @@ -52,8 +70,8 @@ function createAuth() { .filter(Boolean), secret: required("BETTER_AUTH_SECRET"), emailAndPassword: { - enabled: true, - disableSignUp: false, + enabled: !oidcOnly, + disableSignUp: oidcOnly, requireEmailVerification: true, minPasswordLength: 6, maxPasswordLength: 128, @@ -70,6 +88,7 @@ function createAuth() { }, }, }, + account: { accountLinking: { disableImplicitLinking: true } }, emailVerification: { sendOnSignUp: false, autoSignInAfterVerification: true, @@ -95,13 +114,25 @@ function createAuth() { }, }, plugins: [ - emailOTP({ + ...(oidcEnabled ? [genericOAuth({ config: [{ + providerId: "sudloh", + clientId: process.env.SUDLOH_OIDC_CLIENT_ID!, + clientSecret: process.env.SUDLOH_OIDC_CLIENT_SECRET!, + redirectURI: process.env.SUDLOH_OIDC_REDIRECT_URI!, + discoveryUrl: `${(process.env.SUDLOH_OIDC_ISSUER || "https://account.sudloh.com/api/auth").replace(/\/$/, "")}/.well-known/openid-configuration`, + scopes: ["openid", "profile", "email"], + authentication: "basic", + requireIdTokenVerification: true, + requireEmailVerification: true, + disableProviderLogout: true, + }] })] : []), + ...(!oidcOnly ? [emailOTP({ sendVerificationOnSignUp: true, storeOTP: "hashed", sendVerificationOTP: async ({ email, otp }) => { await sendAuthEmail(email, "รหัสยืนยัน Buzz Guide", `รหัสยืนยันอีเมลของคุณคือ ${otp}\n\nรหัสนี้หมดอายุใน 5 นาที`); }, - }), + })] : []), ...(process.env.NODE_ENV === "development" ? [] : [ captcha({ provider: "cloudflare-turnstile", diff --git a/tests/security-boundaries.test.ts b/tests/security-boundaries.test.ts index cbe1106..0f80eaa 100644 --- a/tests/security-boundaries.test.ts +++ b/tests/security-boundaries.test.ts @@ -49,7 +49,8 @@ describe("administrative security boundaries", () => { new URL("../components/auth/account-form.tsx", import.meta.url), "utf8", ); - expect(source).toContain('const captcha = process.env.NODE_ENV !== "development";'); + expect(source).toContain('const captcha = !oidcOnly && process.env.NODE_ENV !== "development";'); + expect(source).toContain('{!oidcOnly && <>'); const loginGuard = "if (busy || (!register && captcha && !token)) return;"; const registrationGuard = "if (!form?.reportValidity() || (captcha && !token)) return;"; expect(source).toContain(loginGuard);