fix(auth) : bind Sudloh sessions and stop OAuth redirect loops
This commit is contained in:
+1
-1
@@ -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=
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 <div className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<p>{failed ? "ไม่สามารถเริ่มเข้าสู่ระบบได้" : "กำลังไปที่ Sudloh Account…"}</p>
|
||||
<p>{message}</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">เข้าสู่ระบบหรือสมัครสมาชิกที่ Sudloh Account แล้วระบบจะพาคุณกลับมายัง Buzz Guide</p>
|
||||
{failed && <Button onClick={() => void start().catch(() => setFailed(true))}>ลองอีกครั้ง</Button>}
|
||||
{showRetry && <Button onClick={() => void start()}>ลองอีกครั้ง</Button>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, Record<string, unknown>[]>, 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: "[email protected]",
|
||||
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("[email protected]");
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | undefined>;
|
||||
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";
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isSudlohCallback(context: { path: string; params?: { id?: string } } | null): boolean {
|
||||
return context?.path === "/callback/:id" && context.params?.id === "sudloh";
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { claimAutomaticSudlohSignIn, markSudlohSignInAttempt, sudlohStartErrorMessage } from "./sudloh-redirect";
|
||||
|
||||
const values = new Map<string, string>();
|
||||
|
||||
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("รอสักครู่");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user