feat(auth) : sync Sudloh profiles and validate sessions
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const session = vi.fn();
|
||||
const validate = 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,
|
||||
}));
|
||||
vi.mock("@/lib/auth/sudloh", () => ({ validateSudlohSession: validate }));
|
||||
|
||||
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" } });
|
||||
validate.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe("Better Auth Sudloh boundary", () => {
|
||||
it("returns no browser session after Sudloh revokes the bound token", async () => {
|
||||
const response = await GET(new Request("https://guide.sudloh.com/api/auth/get-session"));
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toBeNull();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies other account endpoints but permits the OIDC callback", async () => {
|
||||
const denied = await GET(new Request("https://guide.sudloh.com/api/auth/list-sessions"));
|
||||
expect(denied.status).toBe(401);
|
||||
const callback = await GET(new Request("https://guide.sudloh.com/api/auth/callback/sudloh?code=code"));
|
||||
expect(callback.status).toBe(200);
|
||||
});
|
||||
|
||||
it("denies Better Auth mutations with a revoked local session", 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" },
|
||||
body: JSON.stringify({ password: "example-password" }),
|
||||
}));
|
||||
expect(response.status).toBe(401);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,54 @@
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import { getAuth } from "@/lib/auth/server";
|
||||
import { getAuth, isSudlohOidcEnabled } from "@/lib/auth/server";
|
||||
import { validateSudlohSession } from "@/lib/auth/sudloh";
|
||||
import { getDb } from "@/db";
|
||||
import { accounts } from "@/db/schema";
|
||||
import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http";
|
||||
|
||||
const handlers = toNextJsHandler((request) => getAuth().handler(request));
|
||||
|
||||
export const GET = handlers.GET;
|
||||
async function hasActiveSudlohSession(request: Request): Promise<boolean | null> {
|
||||
if (!isSudlohOidcEnabled() || process.env.SUDLOH_OIDC_ONLY !== "true") return null;
|
||||
const session = await getAuth().api.getSession({ headers: request.headers });
|
||||
if (!session) return null;
|
||||
return validateSudlohSession(session.user.id, session.session.id);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const path = new URL(request.url).pathname;
|
||||
if (!path.endsWith("/callback/sudloh")) {
|
||||
try {
|
||||
const active = await hasActiveSudlohSession(request);
|
||||
if (active === false) {
|
||||
if (path.endsWith("/get-session"))
|
||||
return Response.json(null, { headers: { "Cache-Control": "no-store" } });
|
||||
throw new HttpError(401, "unauthorized");
|
||||
}
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
return handlers.GET(request);
|
||||
}
|
||||
|
||||
async function mutate(request: Request) {
|
||||
try {
|
||||
requireSameOrigin(request);
|
||||
const input = await readJson(request.clone());
|
||||
const path = new URL(request.url).pathname;
|
||||
if (!path.endsWith("/sign-in/social") && !path.endsWith("/sign-out") &&
|
||||
await hasActiveSudlohSession(request) === false) throw new HttpError(401, "unauthorized");
|
||||
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;
|
||||
|
||||
@@ -7,9 +7,13 @@ 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 }) }) }));
|
||||
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 }));
|
||||
|
||||
@@ -27,6 +31,7 @@ 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 }]);
|
||||
});
|
||||
|
||||
@@ -60,4 +65,10 @@ describe("profile update", () => {
|
||||
expect((await POST(profileRequest("New Name"))).status).toBe(401);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import sharp from "sharp";
|
||||
import { getDb } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { accounts, users } from "@/db/schema";
|
||||
import { requireCommissionUser } from "@/lib/commission/server";
|
||||
import { inspectImage } from "@/lib/media/inspect";
|
||||
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
||||
@@ -15,6 +15,9 @@ 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");
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getCustomerSession, isSudlohOidcEnabled } from "@/lib/auth/server";
|
||||
import { refreshLinkedSudlohProfile, validateSudlohSession } 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");
|
||||
if (isSudlohOidcEnabled() && process.env.SUDLOH_OIDC_ONLY === "true") {
|
||||
if (!await validateSudlohSession(session.user.id, session.session.id, true))
|
||||
throw new HttpError(401, "sudloh-session-expired");
|
||||
} else await refreshLinkedSudlohProfile(session.user.id);
|
||||
return Response.json({ ok: true }, { headers: { "Cache-Control": "no-store" } });
|
||||
} catch (cause) { return errorResponse(cause); }
|
||||
}
|
||||
@@ -9,9 +9,11 @@ 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 { safeAuthReturnPath } from "@/lib/auth/return-path";
|
||||
import { ACCOUNT_SETTINGS_URL } from "@/lib/auth/sudloh";
|
||||
|
||||
export const instant = false;
|
||||
|
||||
@@ -42,15 +44,13 @@ export default async function ProfilePage({ searchParams }: PageProps<"/profile"
|
||||
"เพิ่มรูปโปรไฟล์หรือแก้ชื่อที่แสดงก่อนเริ่มใช้งาน"}
|
||||
</p>}
|
||||
<div className="grid items-start gap-6 md:grid-cols-2">
|
||||
<ProfileForm {...user} nextPath={setup === "1" ? setupNextPath : undefined} />
|
||||
{oidcEnabled && <SudlohConnection linked={hasSudloh}
|
||||
{hasSudloh ? <SudlohProfile {...user} accountUrl={ACCOUNT_SETTINGS_URL} callbackError={sudloh === "error"} />
|
||||
: <ProfileForm {...user} nextPath={setup === "1" ? setupNextPath : undefined} />}
|
||||
{oidcEnabled && !hasSudloh && <SudlohConnection linked={hasSudloh}
|
||||
callbackError={sudloh === "error" && typeof error === "string" ? error : undefined} />}
|
||||
{(!hasSudloh || hasCredential) && <EmailSettings email={user.email} verified={user.emailVerified}
|
||||
{!hasSudloh && <EmailSettings email={user.email} verified={user.emailVerified}
|
||||
callbackCompleted={emailAction === "1" && !error} callbackError={typeof error === "string" && sudloh !== "error"} />}
|
||||
{(!hasSudloh || hasCredential) && <PasswordForm />}
|
||||
{hasSudloh && !hasCredential && <p className="text-sm text-muted-foreground">
|
||||
จัดการอีเมลและรหัสผ่านของคุณผ่าน Sudloh Account
|
||||
</p>}
|
||||
{!hasSudloh && hasCredential && <PasswordForm />}
|
||||
</div>
|
||||
</main>
|
||||
</div>;
|
||||
|
||||
Reference in New Issue
Block a user