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 && void signInWithSudloh()}>
- ดำเนินการต่อด้วย Sudloh
- }
- {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
- }>
- ไปหน้าตั้งรหัสผ่าน Sudloh
-
- > : 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 && void connect()}>
- {busy ? "กำลังเชื่อมต่อ..." : "เชื่อมต่อ Sudloh"}
- }
- {(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() || "?"}
-
-
-
-
- }>จัดการบัญชีที่ Sudloh
- void refresh()}>
- {busy ? "กำลังอัปเดต..." : "อัปเดตข้อมูลจาก Sudloh"}
-
- {(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 &&
void start()}>ลองอีกครั้ง }
-
-
;
-}
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);