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"); }); });