diff --git a/.env.example b/.env.example index 7ab85b4..c7a8b05 100644 --- a/.env.example +++ b/.env.example @@ -14,16 +14,6 @@ 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. A verified Sudloh administrator registers the -# exact HTTPS callback at https://account.sudloh.com/account → OIDC clients. -SUDLOH_OIDC_ISSUER=https://account.sudloh.com/api/auth -SUDLOH_OIDC_CLIENT_ID= -SUDLOH_OIDC_CLIENT_SECRET= -SUDLOH_OIDC_REDIRECT_URI= -# Set after linking existing Guide accounts to require Sudloh sign-in. -SUDLOH_OIDC_ONLY=false -SUDLOH_OIDC_PAUSED=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 e681fde..2f47cfc 100644 --- a/README.md +++ b/README.md @@ -14,14 +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:** 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. Linked profiles are managed at Sudloh Account; - Guide refreshes their profile from UserInfo on request. Guide sessions follow - Better Auth's rolling session lifetime rather than the roughly one-hour Sudloh - access token. A fresh Sudloh authorization is needed to refresh a profile after - that token expires. Sudloh Account sign-out or revocation does not end an - existing Guide session; users must also sign out of Guide. +- **Accounts:** Guide email/password sign-in, email verification, local profiles, + and rolling sessions. Existing users who signed up through Sudloh can set a + Guide password from the password-reset page. - **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, @@ -77,9 +72,8 @@ bun run db:migrate bun run dev ``` -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 +Visitors can sign in at `/auth/login` or 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. @@ -91,13 +85,6 @@ bun run worker:outbox bun run worker:discord ``` -For a temporary return to Guide email/password login, set -`SUDLOH_OIDC_ONLY=false` and `SUDLOH_OIDC_PAUSED=true` in the deployment -ConfigMap. The Sudloh client credentials can stay in the Secret. Existing Guide -sessions remain valid. Users who joined only through Sudloh can use Guide's -password-reset page to create a local password. Restore OIDC by setting -`SUDLOH_OIDC_PAUSED=false` and `SUDLOH_OIDC_ONLY=true`. - Email, commission payments, and push notifications need their corresponding service credentials. See [External prerequisites](#external-prerequisites) for the production configuration details. diff --git a/app/api/auth/[...all]/route.test.ts b/app/api/auth/[...all]/route.test.ts index 91a73c6..6a626b0 100644 --- a/app/api/auth/[...all]/route.test.ts +++ b/app/api/auth/[...all]/route.test.ts @@ -1,13 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const session = vi.fn(); const handler = vi.fn(async () => Response.json({ passed: true })); vi.mock("server-only", () => ({})); vi.mock("better-auth/next-js", () => ({ toNextJsHandler: () => ({ GET: handler }) })); vi.mock("@/lib/auth/server", () => ({ - getAuth: () => ({ api: { getSession: session }, handler }), - isSudlohOidcEnabled: () => true, + getAuth: () => ({ handler }), })); const { GET, POST } = await import("./route"); @@ -15,11 +13,9 @@ const { GET, POST } = await import("./route"); beforeEach(() => { vi.clearAllMocks(); process.env.BETTER_AUTH_URL = "https://guide.sudloh.com"; - process.env.SUDLOH_OIDC_ONLY = "true"; - session.mockResolvedValue({ user: { id: "user-1" }, session: { id: "session-1" } }); }); -describe("Better Auth Sudloh boundary", () => { +describe("Better Auth route", () => { it("lets Better Auth serve the local browser session", async () => { const response = await GET(new Request("https://guide.sudloh.com/api/auth/get-session")); expect(response.status).toBe(200); @@ -27,11 +23,6 @@ describe("Better Auth Sudloh boundary", () => { expect(handler).toHaveBeenCalledOnce(); }); - it("permits the OIDC callback", async () => { - const callback = await GET(new Request("https://guide.sudloh.com/api/auth/callback/sudloh?code=code")); - expect(callback.status).toBe(200); - }); - it("passes mutations to Better Auth for local session checks", async () => { const response = await POST(new Request("https://guide.sudloh.com/api/auth/admin/create-user", { method: "POST", headers: { Origin: "https://guide.sudloh.com", "Content-Type": "application/json" }, @@ -40,4 +31,13 @@ describe("Better Auth Sudloh boundary", () => { expect(response.status).toBe(200); expect(handler).toHaveBeenCalledOnce(); }); + + it("passes local profile changes to Better Auth", async () => { + const response = await POST(new Request("https://guide.sudloh.com/api/auth/change-email", { + method: "POST", headers: { Origin: "https://guide.sudloh.com", "Content-Type": "application/json" }, + body: JSON.stringify({ newEmail: "new@example.test" }), + })); + expect(response.status).toBe(200); + expect(handler).toHaveBeenCalledOnce(); + }); }); diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts index a439ebc..aa5a03a 100644 --- a/app/api/auth/[...all]/route.ts +++ b/app/api/auth/[...all]/route.ts @@ -1,9 +1,5 @@ import { toNextJsHandler } from "better-auth/next-js"; -import { and, eq } from "drizzle-orm"; - -import { getAuth, isSudlohOidcEnabled } from "@/lib/auth/server"; -import { getDb } from "@/db"; -import { accounts } from "@/db/schema"; +import { getAuth } from "@/lib/auth/server"; import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http"; const handlers = toNextJsHandler((request) => getAuth().handler(request)); @@ -17,17 +13,6 @@ async function mutate(request: Request) { requireSameOrigin(request); const input = await readJson(request.clone()); const path = new URL(request.url).pathname; - if (["/update-user", "/change-email"].some((endpoint) => path.endsWith(endpoint))) { - if (isSudlohOidcEnabled() && process.env.SUDLOH_OIDC_ONLY === "true") - throw new HttpError(403, "manage-profile-at-sudloh"); - const session = await getAuth().api.getSession({ headers: request.headers }); - if (session) { - const [linked] = await getDb().select({ id: accounts.id }).from(accounts).where(and( - eq(accounts.userId, session.user.id), eq(accounts.providerId, "sudloh"), - )).limit(1); - if (linked) throw new HttpError(403, "manage-profile-at-sudloh"); - } - } if (["/admin/create-user", "/admin/set-user-password"].some((endpoint) => path.endsWith(endpoint))) { const password = input && typeof input === "object" && "password" in input ? input.password : undefined; const newPassword = input && typeof input === "object" && "newPassword" in input ? input.newPassword : undefined; diff --git a/app/api/profile/route.test.ts b/app/api/profile/route.test.ts index 51e1515..460677c 100644 --- a/app/api/profile/route.test.ts +++ b/app/api/profile/route.test.ts @@ -7,12 +7,10 @@ const returning = vi.fn(); const where = vi.fn(() => ({ returning })); const set = vi.fn(() => ({ where })); const write = vi.fn(); -const linkedAccounts = vi.fn(); vi.mock("@/lib/commission/server", () => ({ requireCommissionUser })); vi.mock("@/db", () => ({ getDb: () => ({ update: () => ({ set }), - select: () => ({ from: () => ({ where: () => ({ limit: linkedAccounts }) }) }), }) })); vi.mock("@/lib/media/storage", () => ({ getMediaStorage: async () => ({ write }), publicMediaUrl: (key: string) => `https://cdn.test/${key}` })); vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: async () => undefined })); @@ -31,7 +29,6 @@ describe("profile update", () => { process.env.BETTER_AUTH_URL = "https://guide.sudloh.com"; vi.clearAllMocks(); requireCommissionUser.mockResolvedValue({ id: "user-1", image: null }); - linkedAccounts.mockResolvedValue([]); returning.mockResolvedValue([{ name: "New Name", image: null }]); }); @@ -66,9 +63,4 @@ describe("profile update", () => { expect(set).not.toHaveBeenCalled(); }); - it("sends Sudloh-linked users to Sudloh for profile changes", async () => { - linkedAccounts.mockResolvedValueOnce([{ id: "sudloh-account" }]); - expect((await POST(profileRequest("New Name"))).status).toBe(403); - expect(set).not.toHaveBeenCalled(); - }); }); diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index ae5abe4..180bce1 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -1,7 +1,7 @@ -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import sharp from "sharp"; import { getDb } from "@/db"; -import { accounts, users } from "@/db/schema"; +import { users } from "@/db/schema"; import { requireCommissionUser } from "@/lib/commission/server"; import { inspectImage } from "@/lib/media/inspect"; import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage"; @@ -15,9 +15,6 @@ export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireCommissionUser(); - const [sudloh] = await getDb().select({ id: accounts.id }).from(accounts) - .where(and(eq(accounts.userId, user.id), eq(accounts.providerId, "sudloh"))).limit(1); - if (sudloh) throw new HttpError(403, "manage-profile-at-sudloh"); await limitRequest("profile-update", user.id, 20); if (!request.headers.get("content-type")?.startsWith("multipart/form-data;")) throw new HttpError(415, "expected-multipart"); diff --git a/app/api/profile/sync/route.ts b/app/api/profile/sync/route.ts deleted file mode 100644 index 65275e8..0000000 --- a/app/api/profile/sync/route.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getCustomerSession } from "@/lib/auth/server"; -import { refreshLinkedSudlohProfile } from "@/lib/auth/sudloh"; -import { errorResponse, HttpError, requireSameOrigin } from "@/lib/security/http"; - -export async function POST(request: Request) { - try { - requireSameOrigin(request); - const session = await getCustomerSession(); - if (!session) throw new HttpError(401, "unauthorized"); - await refreshLinkedSudlohProfile(session.user.id); - return Response.json({ ok: true }, { headers: { "Cache-Control": "no-store" } }); - } catch (cause) { return errorResponse(cause); } -} diff --git a/app/auth/forgot-password/page.tsx b/app/auth/forgot-password/page.tsx index efb00e8..1287cd8 100644 --- a/app/auth/forgot-password/page.tsx +++ b/app/auth/forgot-password/page.tsx @@ -2,16 +2,13 @@ import { SiteHeader } from "@/components/public/site-header"; import { EmailActionForm } from "@/components/auth/email-action-form"; import { connection } from "next/server"; import { safeAuthReturnPath } from "@/lib/auth/return-path"; -import { isSudlohOidcEnabled } from "@/lib/auth/server"; export const instant = false; export default async function ForgotPasswordPage({ searchParams }: PageProps<"/auth/forgot-password">) { await connection(); const { next } = await searchParams; - const oidcEnabled = isSudlohOidcEnabled(); - const oidcOnly = oidcEnabled && process.env.SUDLOH_OIDC_ONLY === "true"; return
- +
; } diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index 6ef95bf..f138bb3 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -5,8 +5,7 @@ export const instant = false; export default async function LoginPage({ searchParams }: PageProps<"/auth/login">) { await connection(); - const { next, verified, error, sudloh } = await searchParams; - const oidcError = sudloh === "1" && typeof error === "string" ? error : undefined; + const { next, verified, error } = await searchParams; return ; + verificationError={typeof error === "string"} />; } diff --git a/app/auth/password-reset/page.tsx b/app/auth/password-reset/page.tsx index bf33e12..a78970c 100644 --- a/app/auth/password-reset/page.tsx +++ b/app/auth/password-reset/page.tsx @@ -2,17 +2,14 @@ import { connection } from "next/server"; import { SiteHeader } from "@/components/public/site-header"; import { EmailActionForm } from "@/components/auth/email-action-form"; import { safeAuthReturnPath } from "@/lib/auth/return-path"; -import { isSudlohOidcEnabled } from "@/lib/auth/server"; export const instant = false; export default async function ResetPasswordPage({ searchParams }: PageProps<"/auth/password-reset">) { await connection(); const { token, error, next } = await searchParams; - const oidcEnabled = isSudlohOidcEnabled(); - const oidcOnly = oidcEnabled && process.env.SUDLOH_OIDC_ONLY === "true"; return
+ nextPath={safeAuthReturnPath(next)} />
; } diff --git a/app/profile/page.tsx b/app/profile/page.tsx index e116192..dda998a 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -8,33 +8,28 @@ 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 { SudlohProfile } from "@/components/auth/sudloh-profile"; import { isAuthorizedAdmin } from "@/lib/auth/authorization"; -import { getCustomerSession, isSudlohOidcEnabled } from "@/lib/auth/server"; +import { getCustomerSession } from "@/lib/auth/server"; import { safeAuthReturnPath } from "@/lib/auth/return-path"; -import { ACCOUNT_SETTINGS_URL } from "@/lib/auth/sudloh"; export const instant = false; export default async function ProfilePage({ searchParams }: PageProps<"/profile">) { await connection(); - const { setup, next, upload, emailAction, error, sudloh } = await searchParams; + const { setup, next, upload, emailAction, error } = 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 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([]), + getDb().select({ providerId: accounts.providerId }) + .from(accounts).where(eq(accounts.userId, session.user.id)), ]); 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) ? : }
@@ -44,13 +39,10 @@ export default async function ProfilePage({ searchParams }: PageProps<"/profile" "เพิ่มรูปโปรไฟล์หรือแก้ชื่อที่แสดงก่อนเริ่มใช้งาน"}

}
- {hasSudloh ? - : } - {oidcEnabled && !hasSudloh && } - {!hasSudloh && } - {!hasSudloh && hasCredential && } + + + {hasCredential && }
; diff --git a/components/auth/account-form.tsx b/components/auth/account-form.tsx index deff4f6..e991526 100644 --- a/components/auth/account-form.tsx +++ b/components/auth/account-form.tsx @@ -10,7 +10,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth/client"; -import { sudlohStartErrorMessage } from "@/lib/auth/sudloh-redirect"; import { ProfileImageInput } from "./profile-image-input"; declare global { @@ -33,18 +32,12 @@ 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"; @@ -59,7 +52,7 @@ export function AccountForm({ const formRef = useRef(null); const container = useRef(null); const widget = useRef(null); - const captcha = !oidcOnly && process.env.NODE_ENV !== "development"; + const captcha = process.env.NODE_ENV !== "development"; useEffect(() => () => { if (profilePreview) URL.revokeObjectURL(profilePreview); }, [profilePreview]); @@ -179,44 +172,12 @@ 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) { - setError(sudlohStartErrorMessage(result.error.status)); - setBusy(false); - } - } catch { - setError("เริ่มเข้าสู่ระบบด้วย Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง"); - setBusy(false); - } - } - return ( {register ? "สมัครสมาชิก" : "เข้าสู่ระบบ"} - {oidcEnabled &&

- {oidcOnly ? "ใช้ Sudloh Account แล้วกลับมายัง Buzz Guide หลังเข้าสู่ระบบ" : "ใช้ Sudloh Account หรือบัญชี Buzz Guide ของคุณ"} -

}
- {oidcEnabled && } - {oidcError && - {oidcError === "account_not_linked" - ? oidcOnly ? "บัญชีนี้ยังไม่เชื่อมต่อกับ Buzz Guide กรุณาติดต่อผู้ดูแลเพื่อยืนยันตัวตน" - : "มีบัญชี Buzz Guide ที่ใช้อีเมลนี้แล้ว กรุณาเข้าสู่ระบบด้วยอีเมลและเชื่อมต่อ Sudloh จากหน้าโปรไฟล์" - : "เข้าสู่ระบบด้วย Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง"} - } - {!oidcOnly && <> {verified && ยืนยันอีเมลแล้ว กรุณาเข้าสู่ระบบ} {verificationError && ลิงก์ยืนยันไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่} {notice && {notice}} @@ -318,7 +279,6 @@ export function AccountForm({ {error} )} - }
); diff --git a/components/auth/account-page.tsx b/components/auth/account-page.tsx index ef1c3de..3d3a7ae 100644 --- a/components/auth/account-page.tsx +++ b/components/auth/account-page.tsx @@ -1,14 +1,12 @@ import { redirect } from "next/navigation"; import { SiteHeader } from "@/components/public/site-header"; import { isAuthorizedAdmin } from "@/lib/auth/authorization"; -import { getCustomerSession, isSudlohOidcEnabled } from "@/lib/auth/server"; +import { getCustomerSession } 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, oidcError }: { +export async function AccountPage({ mode, next, verified, verificationError }: { mode: "login" | "register"; next: unknown; verified?: boolean; verificationError?: boolean; - oidcError?: string; }) { const nextPath = safeAuthReturnPath(next); const session = await getCustomerSession(); @@ -17,15 +15,9 @@ export async function AccountPage({ mode, next, verified, verificationError, oid redirect(nextPath); } const siteKey = process.env.TURNSTILE_SITE_KEY ?? ""; - 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"); + if (process.env.NODE_ENV !== "development" && !siteKey) throw new Error("TURNSTILE_SITE_KEY is required"); return
+ verified={verified} verificationError={verificationError} />
; } diff --git a/components/auth/email-action-form.tsx b/components/auth/email-action-form.tsx index b0cb131..066a1ad 100644 --- a/components/auth/email-action-form.tsx +++ b/components/auth/email-action-form.tsx @@ -11,8 +11,8 @@ import { authClient } from "@/lib/auth/client"; type Mode = "forgot" | "reset"; -export function EmailActionForm({ mode, token = "", invalidToken = false, nextPath = "/", oidcEnabled = false, oidcOnly = false }: { - mode: Mode; token?: string; invalidToken?: boolean; nextPath?: string; oidcEnabled?: boolean; oidcOnly?: boolean; +export function EmailActionForm({ mode, token = "", invalidToken = false, nextPath = "/" }: { + mode: Mode; token?: string; invalidToken?: boolean; nextPath?: string; }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -53,16 +53,6 @@ export function EmailActionForm({ mode, token = "", invalidToken = false, nextPa return {title} - {oidcOnly ? <> -

Buzz Guide ใช้ Sudloh Account สำหรับเข้าสู่ระบบ กรุณาตั้งรหัสผ่านใหม่ที่ Sudloh Account

- - : null} - {oidcEnabled && !oidcOnly && mode === "forgot" ?

- หากเข้าสู่ระบบด้วย Sudloh Account ให้ ตั้งรหัสผ่านที่ Sudloh ส่วนแบบฟอร์มด้านล่างสำหรับบัญชี Buzz Guide ที่ใช้อีเมลและรหัสผ่าน -

: null} - {!oidcOnly && <> {sent && {mode === "reset" ? "ตั้งรหัสผ่านใหม่แล้ว กรุณาเข้าสู่ระบบ" : "หากอีเมลนี้มีบัญชีอยู่ โปรดตรวจสอบกล่องจดหมายและโฟลเดอร์สแปม"}} @@ -90,7 +80,6 @@ export function EmailActionForm({ mode, token = "", invalidToken = false, nextPa {mode === "reset" && !canReset && !sent && } - }
; diff --git a/components/auth/sign-out-button.tsx b/components/auth/sign-out-button.tsx index 381f687..f3924a9 100644 --- a/components/auth/sign-out-button.tsx +++ b/components/auth/sign-out-button.tsx @@ -4,7 +4,6 @@ import { useRouter } from "next/navigation"; import { LogOutIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { authClient } from "@/lib/auth/client"; -import { markSudlohSignInAttempt } from "@/lib/auth/sudloh-redirect"; import { unsubscribeCommissionPush } from "@/components/commission/push-client"; export function SignOutButton({ admin = false }: { admin?: boolean }) { @@ -15,7 +14,6 @@ export function SignOutButton({ admin = false }: { admin?: boolean }) { async function signOut() { await unsubscribeCommissionPush().catch(() => undefined); await authClient.signOut(); - markSudlohSignInAttempt(); router.push(admin ? "/auth/login?next=%2Fadmin" : "/commission"); router.refresh(); } diff --git a/components/auth/sudloh-connection.tsx b/components/auth/sudloh-connection.tsx deleted file mode 100644 index f081275..0000000 --- a/components/auth/sudloh-connection.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"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-profile.tsx b/components/auth/sudloh-profile.tsx deleted file mode 100644 index af74770..0000000 --- a/components/auth/sudloh-profile.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { authClient } from "@/lib/auth/client"; - -export function SudlohProfile({ name, email, image, accountUrl, callbackError }: { - name: string; email: string; image: string | null; accountUrl: string; callbackError: boolean; -}) { - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - - async function refresh() { - setBusy(true); - setError(""); - try { - const response = await fetch("/api/profile/sync", { method: "POST" }); - if (response.status === 401) { - const result = await authClient.signIn.social({ provider: "sudloh", callbackURL: "/profile", - errorCallbackURL: "/profile?sudloh=error" }); - if (result.error) throw new Error("เข้าสู่ระบบ Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง"); - return; - } - if (response.status === 409) throw new Error("อีเมลนี้ถูกใช้โดยบัญชี Buzz Guide อื่น กรุณาติดต่อผู้ดูแล"); - if (!response.ok) throw new Error("อัปเดตข้อมูลจาก Sudloh ไม่สำเร็จ กรุณาเข้าสู่ระบบใหม่แล้วลองอีกครั้ง"); - window.location.reload(); - } catch (cause) { - setError(cause instanceof Error ? cause.message : "อัปเดตข้อมูลจาก Sudloh ไม่สำเร็จ"); - setBusy(false); - } - } - - return - ข้อมูลบัญชี - จัดการชื่อ รูปโปรไฟล์ และอีเมลที่ Sudloh Account - - - - {image && } - {name.trim().charAt(0).toUpperCase() || "?"} - -

{name}

-

{email}

-
-
- - - - {(error || callbackError) && - {error || "เข้าสู่ระบบ Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง"} - } - -
; -} diff --git a/components/auth/sudloh-sign-in-redirect.tsx b/components/auth/sudloh-sign-in-redirect.tsx deleted file mode 100644 index 7a704d3..0000000 --- a/components/auth/sudloh-sign-in-redirect.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { authClient } from "@/lib/auth/client"; -import { claimAutomaticSudlohSignIn, markSudlohSignInAttempt, sudlohStartErrorMessage } from "@/lib/auth/sudloh-redirect"; - -export function SudlohSignInRedirect({ nextPath }: { nextPath: string }) { - const started = useRef(false); - const [message, setMessage] = useState("กำลังไปที่ Sudloh Account…"); - const [showRetry, setShowRetry] = useState(false); - - const start = useCallback(async () => { - markSudlohSignInAttempt(); - setShowRetry(false); - setMessage("กำลังไปที่ Sudloh Account…"); - try { - const result = await authClient.signIn.social({ - provider: "sudloh", - callbackURL: nextPath, - errorCallbackURL: `/auth/login?next=${encodeURIComponent(nextPath)}&sudloh=1`, - }); - if (result.error) { - setMessage(sudlohStartErrorMessage(result.error.status)); - setShowRetry(true); - } - } catch { - setMessage(sudlohStartErrorMessage()); - setShowRetry(true); - } - }, [nextPath]); - - useEffect(() => { - const timer = window.setTimeout(() => { - if (started.current) return; - started.current = true; - if (claimAutomaticSudlohSignIn()) void start(); - else { - setMessage("การเข้าสู่ระบบยังไม่เสร็จ กรุณาลองอีกครั้ง"); - setShowRetry(true); - } - }, 0); - return () => window.clearTimeout(timer); - }, [start]); - - return
-
-

{message}

-

เข้าสู่ระบบหรือสมัครสมาชิกที่ Sudloh Account แล้วระบบจะพาคุณกลับมายัง Buzz Guide

- {showRetry && } -
-
; -} diff --git a/k8s/base/configmap.yaml b/k8s/base/configmap.yaml index 503ad1b..3d7f7ed 100644 --- a/k8s/base/configmap.yaml +++ b/k8s/base/configmap.yaml @@ -7,10 +7,6 @@ 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: "false" - SUDLOH_OIDC_PAUSED: "true" # Traefik supplies X-Forwarded-For and does not trust incoming forwarded headers. # The network policy allows only Traefik to reach the app. TRUSTED_CLIENT_IP_HEADER: x-forwarded-for diff --git a/k8s/base/deployment.yaml b/k8s/base/deployment.yaml index 3b6b939..27fd48a 100644 --- a/k8s/base/deployment.yaml +++ b/k8s/base/deployment.yaml @@ -87,18 +87,6 @@ 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/oidc-flow.test.ts b/lib/auth/oidc-flow.test.ts deleted file mode 100644 index ff2e580..0000000 --- a/lib/auth/oidc-flow.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { betterAuth } from "better-auth"; -import { memoryAdapter } from "better-auth/adapters/memory"; -import { genericOAuth } from "better-auth/plugins"; - -const origin = "https://guide.test"; - -function createReplica(database: Record[]>) { - return betterAuth({ - baseURL: origin, - secret: "a-shared-test-secret-with-enough-entropy-123", - database: memoryAdapter(database), - rateLimit: { enabled: false }, - plugins: [genericOAuth({ config: [{ - providerId: "sudloh", - clientId: "test-client", - clientSecret: "test-secret", - authorizationUrl: "https://account.test/authorize", - tokenUrl: "https://account.test/token", - getToken: async () => ({ accessToken: "test-access-token", accessTokenExpiresAt: new Date(Date.now() + 3600_000) }), - getUserInfo: async () => ({ id: "test-subject", email: "user@test.invalid", - emailVerified: true, name: "Test User" }), - }] })], - }); -} - -describe("Sudloh OAuth callback", () => { - it("completes across replicas, creates the Guide session, and consumes state once", async () => { - const database = { user: [], session: [], account: [], verification: [] }; - const first = createReplica(database); - const second = createReplica(database); - const start = await first.handler(new Request(`${origin}/api/auth/sign-in/social`, { - method: "POST", headers: { Origin: origin, "Content-Type": "application/json" }, - body: JSON.stringify({ provider: "sudloh", callbackURL: "/profile" }), - })); - expect(start.status).toBe(200); - const authorization = new URL((await start.json()).url); - expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); - expect(authorization.searchParams.get("code_challenge")).toBeTruthy(); - const stateCookie = start.headers.get("set-cookie")?.split(";", 1)[0]; - expect(stateCookie).toBeTruthy(); - const callbackURL = new URL(`${origin}/api/auth/callback/sudloh`); - callbackURL.searchParams.set("code", "test-code"); - callbackURL.searchParams.set("state", authorization.searchParams.get("state")!); - const callback = await second.handler(new Request(callbackURL, { - headers: { Cookie: stateCookie! }, - })); - expect(callback.status).toBe(302); - expect(callback.headers.get("location")).toBe("/profile"); - const sessionCookie = callback.headers.getSetCookie() - .find((cookie) => cookie.includes("session_token="))?.split(";", 1)[0]; - const session = await second.api.getSession({ headers: new Headers({ Cookie: sessionCookie! }) }); - expect(session?.user.email).toBe("user@test.invalid"); - const replay = await first.handler(new Request(callbackURL, { - headers: { Cookie: stateCookie! }, - })); - expect(replay.headers.get("location")).toContain("state_mismatch"); - }); - - it("rejects a callback without state before exchanging a code", async () => { - const auth = createReplica({ user: [], session: [], account: [], verification: [] }); - const response = await auth.handler(new Request(`${origin}/api/auth/callback/sudloh?code=test-code`)); - expect(response.status).toBe(302); - expect(response.headers.get("location")).toContain("state_not_found"); - }); -}); diff --git a/lib/auth/server.test.ts b/lib/auth/server.test.ts index 4a33120..4dcb414 100644 --- a/lib/auth/server.test.ts +++ b/lib/auth/server.test.ts @@ -8,10 +8,9 @@ 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", "SUDLOH_OIDC_CLIENT_ID", "SUDLOH_OIDC_CLIENT_SECRET", "SUDLOH_OIDC_REDIRECT_URI", "SUDLOH_OIDC_ISSUER", "SUDLOH_OIDC_ONLY", "SUDLOH_OIDC_PAUSED", "TRUSTED_CLIENT_IP_HEADER"] as const; +const envNames = ["NODE_ENV", "DATABASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET", "BUZZ_DEMO_MODE", "RESEND_API_KEY", "TRUSTED_CLIENT_IP_HEADER"] as const; const testEnv = process.env as Record; const originalEnv = envNames.map((name) => testEnv[name]); @@ -23,12 +22,6 @@ 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; - delete testEnv.SUDLOH_OIDC_PAUSED; delete testEnv.TRUSTED_CLIENT_IP_HEADER; }); afterEach(() => { @@ -59,77 +52,28 @@ describe("actual administrator session boundary", () => { it("defaults accounts to user and requires six-character new passwords", async () => { (await import("./server")).getAuth(); const options = mocks.auth.mock.calls.at(-1)![0]; - expect(options.emailAndPassword).toMatchObject({ disableSignUp: false, minPasswordLength: 6 }); + expect(options.emailAndPassword).toMatchObject({ enabled: true, minPasswordLength: 6 }); expect(options.plugins).toContainEqual({ defaultRole: "user" }); 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, - overrideUserInfo: true, - scopes: ["openid", "profile", "email"], - redirectURI: "https://guide.example.test/api/auth/callback/sudloh", - }); - }); - it("uses local sign-in while Sudloh is paused, even with client credentials present", 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_PAUSED = "true"; - const { getAuth, isSudlohOidcEnabled } = await import("./server"); - expect(isSudlohOidcEnabled()).toBe(false); - getAuth(); - const options = mocks.auth.mock.calls.at(-1)![0]; - expect(options.emailAndPassword).toMatchObject({ enabled: true, disableSignUp: false }); - expect(options.plugins.some((plugin: { id?: string }) => plugin.id === "generic-oauth")).toBe(false); - }); - 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"; + it("always enables Guide credentials and registration", async () => { testEnv.TRUSTED_CLIENT_IP_HEADER = "x-forwarded-for"; (await import("./server")).getAuth(); const options = mocks.auth.mock.calls.at(-1)![0]; expect(options.advanced.ipAddress.ipAddressHeaders).toEqual(["x-forwarded-for"]); - expect(options.emailAndPassword).toMatchObject({ enabled: false, disableSignUp: true }); - expect(options.plugins.some((plugin: { id?: string }) => plugin.id === "email-otp")).toBe(false); + expect(options.emailAndPassword).toMatchObject({ enabled: true }); + expect(options.plugins.some((plugin: { id?: string }) => plugin.id === "email-otp")).toBe(true); + expect(options.plugins.some((plugin: { id?: string }) => plugin.id === "generic-oauth")).toBe(false); expect(options.databaseHooks).toBeUndefined(); }); - it("keeps the local Guide session after the Sudloh access token expires", 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"; + it("keeps the local Guide session", async () => { mocks.session.mockResolvedValue({ user: { id: "user-1", email: "a@test.invalid", emailVerified: true, role: "admin" }, session: { id: "guide-session" } }); const { getAdminSession, getCustomerSession } = await import("./server"); expect(await getCustomerSession()).toMatchObject({ user: { id: "user-1" } }); expect(await getAdminSession()).toMatchObject({ user: { id: "user-1" } }); }); - 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 69cb8c4..23fdfc3 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, genericOAuth } from "better-auth/plugins"; +import { admin, captcha, emailOTP } from "better-auth/plugins"; import { headers } from "next/headers"; import { cache } from "react"; @@ -32,26 +32,7 @@ function hasAuthConfiguration(): boolean { ); } -export function isSudlohOidcEnabled(): boolean { - if (process.env.SUDLOH_OIDC_PAUSED === "true") return false; - 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(), { @@ -71,8 +52,7 @@ function createAuth() { .filter(Boolean), secret: required("BETTER_AUTH_SECRET"), emailAndPassword: { - enabled: !oidcOnly, - disableSignUp: oidcOnly, + enabled: true, requireEmailVerification: true, minPasswordLength: 6, maxPasswordLength: 128, @@ -89,7 +69,6 @@ function createAuth() { }, }, }, - account: { accountLinking: { disableImplicitLinking: true } }, emailVerification: { sendOnSignUp: false, autoSignInAfterVerification: true, @@ -115,26 +94,13 @@ function createAuth() { }, }, plugins: [ - ...(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, - overrideUserInfo: true, - }] })] : []), - ...(!oidcOnly ? [emailOTP({ + 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/lib/auth/sudloh-redirect.test.ts b/lib/auth/sudloh-redirect.test.ts deleted file mode 100644 index 5f5f759..0000000 --- a/lib/auth/sudloh-redirect.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { claimAutomaticSudlohSignIn, markSudlohSignInAttempt, sudlohStartErrorMessage } from "./sudloh-redirect"; - -const values = new Map(); - -beforeEach(() => { - values.clear(); - vi.stubGlobal("sessionStorage", { - getItem: (key: string) => values.get(key) ?? null, - setItem: (key: string, value: string) => { values.set(key, value); }, - }); - vi.spyOn(Date, "now").mockReturnValue(1_000_000); -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe("Sudloh redirect guard", () => { - it("starts once and stops automatic redirects when login is revisited", () => { - expect(claimAutomaticSudlohSignIn()).toBe(true); - expect(claimAutomaticSudlohSignIn()).toBe(false); - vi.spyOn(Date, "now").mockReturnValue(1_000_000 + 5 * 60_000); - expect(claimAutomaticSudlohSignIn()).toBe(true); - }); - - it("keeps logout from immediately starting another sign-in", () => { - markSudlohSignInAttempt(); - expect(claimAutomaticSudlohSignIn()).toBe(false); - }); - - it("requires a manual start when browser storage is unavailable", () => { - vi.stubGlobal("sessionStorage", { getItem: () => { throw new Error("blocked"); } }); - expect(claimAutomaticSudlohSignIn()).toBe(false); - }); - - it("explains a rate limit without leaking the provider response", () => { - expect(sudlohStartErrorMessage(429)).toContain("รอสักครู่"); - expect(sudlohStartErrorMessage(503)).not.toContain("รอสักครู่"); - }); -}); diff --git a/lib/auth/sudloh-redirect.ts b/lib/auth/sudloh-redirect.ts deleted file mode 100644 index 3c16a47..0000000 --- a/lib/auth/sudloh-redirect.ts +++ /dev/null @@ -1,24 +0,0 @@ -const attemptKey = "sudloh-oidc-last-start"; -const attemptWindowMs = 5 * 60 * 1000; - -export function sudlohStartErrorMessage(status?: number): string { - return status === 429 - ? "ลองเข้าสู่ระบบบ่อยเกินไป กรุณารอสักครู่แล้วลองอีกครั้ง" - : "ไม่สามารถเริ่มเข้าสู่ระบบได้ กรุณาลองอีกครั้ง"; -} - -export function markSudlohSignInAttempt() { - try { sessionStorage.setItem(attemptKey, String(Date.now())); } - catch { /* A manual retry remains available when storage is blocked. */ } -} - -export function claimAutomaticSudlohSignIn(): boolean { - try { - const lastAttempt = Number(sessionStorage.getItem(attemptKey)); - if (lastAttempt > 0 && Date.now() - lastAttempt < attemptWindowMs) return false; - markSudlohSignInAttempt(); - return true; - } catch { - return false; - } -} diff --git a/lib/auth/sudloh.test.ts b/lib/auth/sudloh.test.ts deleted file mode 100644 index bcc3cef..0000000 --- a/lib/auth/sudloh.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const rows: unknown[][] = []; -const updateUser = vi.fn(async () => undefined); - -vi.mock("server-only", () => ({})); -vi.mock("@/db", () => ({ getDb: () => ({ - select: () => ({ from: () => ({ where: () => ({ limit: async () => rows.shift() ?? [] }) }) }), - update: () => ({ set: () => ({ where: updateUser }) }), -}) })); - -const { refreshLinkedSudlohProfile } = await import("./sudloh"); -const account = { accountId: "sub-1", accessToken: "access-1", - accessTokenExpiresAt: new Date(Date.now() + 60 * 60_000) }; - -beforeEach(() => { - rows.length = 0; - vi.clearAllMocks(); - process.env.SUDLOH_OIDC_ISSUER = "https://account.test/api/auth"; - process.env.SUDLOH_OIDC_CLIENT_ID = "client"; - process.env.SUDLOH_OIDC_CLIENT_SECRET = "secret"; -}); - -function provider(active: boolean, profileSub = "sub-1") { - return vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = String(input); - if (url.endsWith("openid-configuration")) return Response.json({ - issuer: "https://account.test/api/auth", - introspection_endpoint: "https://account.test/api/auth/oauth2/introspect", - userinfo_endpoint: "https://account.test/api/auth/oauth2/userinfo", - }); - if (url.endsWith("introspect")) return Response.json({ active, sub: "sub-1", - exp: Math.floor(Date.now() / 1000) + 3600 }); - if (url.endsWith("userinfo")) return Response.json({ sub: profileSub, name: "New Name", - email: "new@test.invalid", email_verified: true, picture: "https://account.test/avatar.png" }); - throw new Error(`unexpected URL: ${url}`); - }); -} - -describe("Sudloh profile refresh", () => { - it("requires a new Sudloh sign-in after the access token expires", async () => { - rows.push([{ ...account, accessTokenExpiresAt: new Date(Date.now() - 1000) }]); - await expect(refreshLinkedSudlohProfile("user-1")) - .rejects.toMatchObject({ status: 401, message: "sudloh-sign-in-required" }); - }); - - it("requires a new Sudloh sign-in when the token is revoked", async () => { - const fetchMock = provider(false); - rows.push([account]); - await expect(refreshLinkedSudlohProfile("user-1")) - .rejects.toMatchObject({ status: 401, message: "sudloh-sign-in-required" }); - fetchMock.mockRestore(); - }); - - it("rejects UserInfo for another subject", async () => { - const fetchMock = provider(true, "someone-else"); - rows.push([account]); - await expect(refreshLinkedSudlohProfile("user-1")) - .rejects.toMatchObject({ status: 503 }); - expect(updateUser).not.toHaveBeenCalled(); - fetchMock.mockRestore(); - }); - - it("reports a verified Sudloh email that conflicts with another Guide account", async () => { - const fetchMock = provider(true); - rows.push([account], [{ name: "Old Name", email: "old@test.invalid", emailVerified: true, image: null }]); - updateUser.mockRejectedValueOnce({ cause: { code: "23505" } }); - await expect(refreshLinkedSudlohProfile("user-1")) - .rejects.toMatchObject({ status: 409, message: "sudloh-email-conflict" }); - fetchMock.mockRestore(); - }); - - it("updates a linked profile while its token is active", async () => { - const fetchMock = provider(true); - rows.push([account], [{ name: "Old Name", email: "old@test.invalid", emailVerified: true, image: null }]); - await refreshLinkedSudlohProfile("user-1"); - expect(updateUser).toHaveBeenCalledOnce(); - fetchMock.mockRestore(); - }); -}); diff --git a/lib/auth/sudloh.ts b/lib/auth/sudloh.ts deleted file mode 100644 index 12ad366..0000000 --- a/lib/auth/sudloh.ts +++ /dev/null @@ -1,110 +0,0 @@ -import "server-only"; - -import { and, eq } from "drizzle-orm"; -import { getDb } from "@/db"; -import { accounts, users } from "@/db/schema"; -import { HttpError } from "@/lib/security/http"; - -const ACCOUNT_SETTINGS_URL = "https://account.sudloh.com/account"; - -export { ACCOUNT_SETTINGS_URL }; - -type Endpoints = { issuer: string; userinfo_endpoint: string; introspection_endpoint: string }; -type BoundToken = { token: string; accountId: string }; - -let discovery: { endpoints: Endpoints; until: number } | undefined; - -async function endpoints(): Promise { - if (discovery && discovery.until > Date.now()) return discovery.endpoints; - const issuer = (process.env.SUDLOH_OIDC_ISSUER || "https://account.sudloh.com/api/auth").replace(/\/$/, ""); - const response = await fetch(`${issuer}/.well-known/openid-configuration`, { - cache: "no-store", signal: AbortSignal.timeout(5000), - }); - if (!response.ok) throw new HttpError(503, "sudloh-unavailable"); - const data: unknown = await response.json(); - if (!data || typeof data !== "object" || !("issuer" in data) || data.issuer !== issuer || - !("userinfo_endpoint" in data) || typeof data.userinfo_endpoint !== "string" || - !("introspection_endpoint" in data) || typeof data.introspection_endpoint !== "string") { - throw new HttpError(503, "sudloh-discovery-invalid"); - } - const result = data as Endpoints; - for (const url of [result.userinfo_endpoint, result.introspection_endpoint]) { - if (new URL(url).origin !== new URL(issuer).origin) throw new HttpError(503, "sudloh-discovery-invalid"); - } - discovery = { endpoints: result, until: Date.now() + 10 * 60_000 }; - return result; -} - -async function userInfo(account: BoundToken) { - const response = await fetch((await endpoints()).userinfo_endpoint, { - headers: { Authorization: `Bearer ${account.token}` }, - cache: "no-store", signal: AbortSignal.timeout(5000), - }); - if (!response.ok) throw new HttpError(503, "sudloh-unavailable"); - const data: unknown = await response.json(); - if (!data || typeof data !== "object" || - !("sub" in data) || data.sub !== account.accountId || - !("name" in data) || typeof data.name !== "string" || - !("email" in data) || typeof data.email !== "string" || - !("email_verified" in data) || data.email_verified !== true || - !("picture" in data) || typeof data.picture !== "string") { - throw new HttpError(503, "sudloh-profile-invalid"); - } - return data as { sub: string; name: string; email: string; email_verified: true; picture: string }; -} - -async function introspect(account: BoundToken): Promise { - const clientId = process.env.SUDLOH_OIDC_CLIENT_ID; - const clientSecret = process.env.SUDLOH_OIDC_CLIENT_SECRET; - if (!clientId || !clientSecret) throw new HttpError(503, "sudloh-not-configured"); - const response = await fetch((await endpoints()).introspection_endpoint, { - method: "POST", - headers: { - Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ token: account.token, token_type_hint: "access_token" }), - cache: "no-store", signal: AbortSignal.timeout(5000), - }); - if (!response.ok) throw new HttpError(503, "sudloh-unavailable"); - const data: unknown = await response.json(); - if (!data || typeof data !== "object" || !("active" in data) || typeof data.active !== "boolean") { - throw new HttpError(503, "sudloh-introspection-invalid"); - } - if (!data.active) return false; - if (!("sub" in data) || data.sub !== account.accountId || - !("exp" in data) || typeof data.exp !== "number" || data.exp <= Date.now() / 1000) return false; - return true; -} - -async function updateProfile(userId: string, token: BoundToken): Promise { - const profile = await userInfo(token); - const [current] = await getDb().select({ name: users.name, email: users.email, - emailVerified: users.emailVerified, image: users.image }).from(users).where(eq(users.id, userId)).limit(1); - if (!current) return false; - const email = profile.email.toLowerCase(); - if (current.name !== profile.name || current.email !== email || - !current.emailVerified || current.image !== profile.picture) { - try { - await getDb().update(users).set({ name: profile.name, email, - emailVerified: true, image: profile.picture }).where(eq(users.id, userId)); - } catch (cause) { - const dbError = cause && typeof cause === "object" && "cause" in cause ? cause.cause : cause; - if (dbError && typeof dbError === "object" && "code" in dbError && dbError.code === "23505") - throw new HttpError(409, "sudloh-email-conflict"); - throw cause; - } - } - return true; -} - -export async function refreshLinkedSudlohProfile(userId: string): Promise { - const [account] = await getDb().select().from(accounts).where(and( - eq(accounts.userId, userId), eq(accounts.providerId, "sudloh"), - )).limit(1); - if (!account?.accessToken || !account.accessTokenExpiresAt || - account.accessTokenExpiresAt.getTime() <= Date.now()) throw new HttpError(401, "sudloh-sign-in-required"); - const token: BoundToken = { token: account.accessToken, accountId: account.accountId }; - if (!await introspect(token)) throw new HttpError(401, "sudloh-sign-in-required"); - if (!await updateProfile(userId, token)) throw new HttpError(401, "unauthorized"); -} diff --git a/tests/security-boundaries.test.ts b/tests/security-boundaries.test.ts index 0f80eaa..cbe1106 100644 --- a/tests/security-boundaries.test.ts +++ b/tests/security-boundaries.test.ts @@ -49,8 +49,7 @@ describe("administrative security boundaries", () => { new URL("../components/auth/account-form.tsx", import.meta.url), "utf8", ); - expect(source).toContain('const captcha = !oidcOnly && process.env.NODE_ENV !== "development";'); - expect(source).toContain('{!oidcOnly && <>'); + expect(source).toContain('const captcha = process.env.NODE_ENV !== "development";'); const loginGuard = "if (busy || (!register && captcha && !token)) return;"; const registrationGuard = "if (!form?.reportValidity() || (captcha && !token)) return;"; expect(source).toContain(loginGuard);