diff --git a/.env.example b/.env.example
index a26a293..d0c791c 100644
--- a/.env.example
+++ b/.env.example
@@ -81,5 +81,5 @@ DEPLOYMENT_WEBHOOK_SECRET=replace-with-a-strong-random-secret
# Readiness dependency timeout.
HEALTHCHECK_TIMEOUT_MS=2500
-# Set only when Traefik overwrites this header and direct pod ingress is denied.
+# Set only when the trusted ingress sanitizes this header and direct pod ingress is denied.
TRUSTED_CLIENT_IP_HEADER=
diff --git a/components/auth/account-form.tsx b/components/auth/account-form.tsx
index 5746690..deff4f6 100644
--- a/components/auth/account-form.tsx
+++ b/components/auth/account-form.tsx
@@ -10,6 +10,7 @@ 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 {
@@ -187,7 +188,10 @@ export function AccountForm({
const result = await authClient.signIn.social({
provider: "sudloh", callbackURL: nextPath, errorCallbackURL,
});
- if (result.error) throw new Error("เริ่มเข้าสู่ระบบด้วย Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง");
+ if (result.error) {
+ setError(sudlohStartErrorMessage(result.error.status));
+ setBusy(false);
+ }
} catch {
setError("เริ่มเข้าสู่ระบบด้วย Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง");
setBusy(false);
diff --git a/components/auth/account-menu.tsx b/components/auth/account-menu.tsx
index 0ca195c..7c02474 100644
--- a/components/auth/account-menu.tsx
+++ b/components/auth/account-menu.tsx
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { authClient } from "@/lib/auth/client";
+import { markSudlohSignInAttempt } from "@/lib/auth/sudloh-redirect";
import { unsubscribeCommissionPush } from "@/components/commission/push-client";
export function AccountMenu({ admin = false }: { admin?: boolean }) {
@@ -17,6 +18,7 @@ export function AccountMenu({ 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-sign-in-redirect.tsx b/components/auth/sudloh-sign-in-redirect.tsx
index ae2c10c..7a704d3 100644
--- a/components/auth/sudloh-sign-in-redirect.tsx
+++ b/components/auth/sudloh-sign-in-redirect.tsx
@@ -3,32 +3,51 @@
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 [failed, setFailed] = useState(false);
+ const [message, setMessage] = useState("กำลังไปที่ Sudloh Account…");
+ const [showRetry, setShowRetry] = useState(false);
const start = useCallback(async () => {
- setFailed(false);
- const result = await authClient.signIn.social({
- provider: "sudloh",
- callbackURL: nextPath,
- errorCallbackURL: `/auth/login?next=${encodeURIComponent(nextPath)}&sudloh=1`,
- });
- if (result.error) setFailed(true);
+ 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(() => {
- if (started.current) return;
- started.current = true;
- void start().catch(() => setFailed(true));
+ 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
-
{failed ? "ไม่สามารถเริ่มเข้าสู่ระบบได้" : "กำลังไปที่ Sudloh Account…"}
+
{message}
เข้าสู่ระบบหรือสมัครสมาชิกที่ Sudloh Account แล้วระบบจะพาคุณกลับมายัง Buzz Guide
- {failed &&
}
+ {showRetry &&
}
;
}
diff --git a/k8s/base/configmap.yaml b/k8s/base/configmap.yaml
index cd03bf0..facd8b5 100644
--- a/k8s/base/configmap.yaml
+++ b/k8s/base/configmap.yaml
@@ -10,8 +10,9 @@ data:
SUDLOH_OIDC_ISSUER: https://account.sudloh.com/api/auth
SUDLOH_OIDC_REDIRECT_URI: https://guide.sudloh.com/api/auth/callback/sudloh
SUDLOH_OIDC_ONLY: "true"
- # Traefik must overwrite this header; do not expose the app directly.
- TRUSTED_CLIENT_IP_HEADER: x-real-ip
+ # 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
DATABASE_POOL_SIZE: "10"
HEALTHCHECK_TIMEOUT_MS: "2500"
NEXT_DEPLOYMENT_ID: replace-me
diff --git a/lib/auth/oidc-flow.test.ts b/lib/auth/oidc-flow.test.ts
new file mode 100644
index 0000000..8c2bc31
--- /dev/null
+++ b/lib/auth/oidc-flow.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it, vi } from "vitest";
+import { betterAuth } from "better-auth";
+import { memoryAdapter } from "better-auth/adapters/memory";
+import { genericOAuth } from "better-auth/plugins";
+import { isSudlohCallback } from "./sudloh-callback";
+
+const origin = "https://guide.test";
+
+function createReplica(database: Record[]>, onSession: (sessionId: string) => void) {
+ return betterAuth({
+ baseURL: origin,
+ secret: "a-shared-test-secret-with-enough-entropy-123",
+ database: memoryAdapter(database),
+ rateLimit: { enabled: false },
+ databaseHooks: { session: { create: { after: async (
+ session: { id: string }, context: { path: string; params?: { id?: string } } | null,
+ ) => {
+ if (isSudlohCallback(context)) onSession(session.id);
+ } } } },
+ 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, binds the Guide session, and consumes state once", async () => {
+ const database = { user: [], session: [], account: [], verification: [] };
+ const bind = vi.fn();
+ const first = createReplica(database, bind);
+ const second = createReplica(database, bind);
+ 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");
+ expect(bind).toHaveBeenCalledOnce();
+ 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");
+ expect(bind).toHaveBeenCalledOnce();
+ });
+
+ it("rejects a callback without state before exchanging a code", async () => {
+ const bind = vi.fn();
+ const auth = createReplica({ user: [], session: [], account: [], verification: [] }, bind);
+ 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");
+ expect(bind).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/auth/server.test.ts b/lib/auth/server.test.ts
index 7c6fdb4..361face 100644
--- a/lib/auth/server.test.ts
+++ b/lib/auth/server.test.ts
@@ -13,7 +13,7 @@ vi.mock("better-auth/plugins", () => ({ admin: (options: unknown) => options, ca
emailOTP: (options: unknown) => ({ id: "email-otp", options }) }));
vi.mock("./sudloh", () => ({ bindSudlohSession: sudloh.bind, validateSudlohSession: sudloh.validate }));
-const envNames = ["NODE_ENV", "DATABASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET", "BUZZ_DEMO_MODE", "RESEND_API_KEY", "SUDLOH_OIDC_CLIENT_ID", "SUDLOH_OIDC_CLIENT_SECRET", "SUDLOH_OIDC_REDIRECT_URI", "SUDLOH_OIDC_ISSUER", "SUDLOH_OIDC_ONLY"] as const;
+const 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", "TRUSTED_CLIENT_IP_HEADER"] as const;
const testEnv = process.env as Record;
const originalEnv = envNames.map((name) => testEnv[name]);
@@ -32,6 +32,7 @@ beforeEach(() => {
delete testEnv.SUDLOH_OIDC_REDIRECT_URI;
delete testEnv.SUDLOH_OIDC_ISSUER;
delete testEnv.SUDLOH_OIDC_ONLY;
+ delete testEnv.TRUSTED_CLIENT_IP_HEADER;
});
afterEach(() => {
envNames.forEach((name, index) => {
@@ -91,13 +92,19 @@ describe("actual administrator session boundary", () => {
testEnv.SUDLOH_OIDC_CLIENT_SECRET = "secret";
testEnv.SUDLOH_OIDC_REDIRECT_URI = "https://guide.example.test/api/auth/callback/sudloh";
testEnv.SUDLOH_OIDC_ONLY = "true";
+ 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);
await options.databaseHooks.session.create.after({ id: "guide-session", userId: "user-1" },
- { path: "/callback/sudloh" });
+ { path: "/callback/:id", params: { id: "sudloh" } });
expect(sudloh.bind).toHaveBeenCalledWith("user-1", "guide-session");
+ sudloh.bind.mockClear();
+ await options.databaseHooks.session.create.after({ id: "other-session", userId: "user-1" },
+ { path: "/callback/:id", params: { id: "other" } });
+ expect(sudloh.bind).not.toHaveBeenCalled();
});
it("denies a revoked Sudloh session", async () => {
testEnv.SUDLOH_OIDC_CLIENT_ID = "client";
diff --git a/lib/auth/server.ts b/lib/auth/server.ts
index 885b3e9..12ba05f 100644
--- a/lib/auth/server.ts
+++ b/lib/auth/server.ts
@@ -20,6 +20,7 @@ import { HttpError } from "@/lib/security/http";
import { consumeRateLimit } from "@/lib/security/rate-limit";
import { sendAuthEmail } from "./email";
import { bindSudlohSession, validateSudlohSession } from "./sudloh";
+import { isSudlohCallback } from "./sudloh-callback";
function required(name: string): string {
const value = process.env[name];
@@ -65,9 +66,11 @@ function createAuth() {
transaction: true,
}),
...(oidcOnly ? { databaseHooks: { session: { create: { after: async (
- session: { id: string; userId: string }, context: { path: string } | null,
+ session: { id: string; userId: string },
+ context: { path: string; params?: { id?: string } } | null,
) => {
- if (context?.path.endsWith("/callback/sudloh")) await bindSudlohSession(session.userId, session.id);
+ if (isSudlohCallback(context))
+ await bindSudlohSession(session.userId, session.id);
} } } } } : {}),
baseURL: required("BETTER_AUTH_URL"),
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
diff --git a/lib/auth/sudloh-callback.ts b/lib/auth/sudloh-callback.ts
new file mode 100644
index 0000000..a977ed5
--- /dev/null
+++ b/lib/auth/sudloh-callback.ts
@@ -0,0 +1,3 @@
+export function isSudlohCallback(context: { path: string; params?: { id?: string } } | null): boolean {
+ return context?.path === "/callback/:id" && context.params?.id === "sudloh";
+}
diff --git a/lib/auth/sudloh-redirect.test.ts b/lib/auth/sudloh-redirect.test.ts
new file mode 100644
index 0000000..5f5f759
--- /dev/null
+++ b/lib/auth/sudloh-redirect.test.ts
@@ -0,0 +1,42 @@
+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
new file mode 100644
index 0000000..3c16a47
--- /dev/null
+++ b/lib/auth/sudloh-redirect.ts
@@ -0,0 +1,24 @@
+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
index ffd9e66..d059186 100644
--- a/lib/auth/sudloh.test.ts
+++ b/lib/auth/sudloh.test.ts
@@ -48,6 +48,12 @@ function provider(active: boolean, profileSub = "sub-1") {
}
describe("Sudloh session validation", () => {
+ it("rejects a new Guide session when the callback did not bind it", async () => {
+ rows.push([account]);
+ expect(await validateSudlohSession("user-1", "session-1")).toBe(false);
+ expect(removeSession).toHaveBeenCalledOnce();
+ });
+
it("binds the callback token to its Guide session and caches a verified check", async () => {
rows.push([account]);
await bindSudlohSession("user-1", "session-1");
diff --git a/lib/security/http.test.ts b/lib/security/http.test.ts
index 9e5acb9..2fa3a64 100644
--- a/lib/security/http.test.ts
+++ b/lib/security/http.test.ts
@@ -69,5 +69,8 @@ describe("request boundaries", () => {
expect(trustedClientAddress(new Headers({ "x-real-ip": "::ffff:192.0.2.1" }))).toBe("192.0.2.1");
expect(trustedClientAddress(new Headers({ "x-real-ip": "2001:db8:1:2::1234" })))
.toBe(trustedClientAddress(new Headers({ "x-real-ip": "2001:0db8:0001:0002:0:0:0:1235" })));
+ process.env.TRUSTED_CLIENT_IP_HEADER = "x-forwarded-for";
+ expect(trustedClientAddress(new Headers({ "x-forwarded-for": "192.0.2.1" }))).toBe("192.0.2.1");
+ expect(trustedClientAddress(new Headers({ "x-forwarded-for": "192.0.2.1, 198.51.100.2" }))).toBe("unknown");
});
});