Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/components/account-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export function AccountMenu() {
const [open, setOpen] = useState(false);
const session = authClient.useSession();
const orgs = authClient.useListOrganizations();
const active = authClient.useActiveOrganization();
// Not `useActiveOrganization()`: that endpoint returns every member and pending invitation to every member (COMP-012).
const activeId = session.data?.session.activeOrganizationId;
const active = (orgs.data ?? []).find((org) => org.id === activeId);

async function switchTo(organizationId: string) {
await authClient.organization.setActive({ organizationId });
Expand All @@ -35,7 +37,7 @@ export function AccountMenu() {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" className="min-w-0 gap-2 px-2">
<span className="hidden min-w-0 max-w-40 truncate text-sm sm:inline">{active.data?.name ?? ""}</span>
<span className="hidden min-w-0 max-w-40 truncate text-sm sm:inline">{active?.name ?? ""}</span>
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
{initials(user?.name, user?.email)}
</span>
Expand All @@ -50,7 +52,7 @@ export function AccountMenu() {
<div className="grid gap-2">
<p className="text-sm font-medium">Workspaces</p>
{(orgs.data ?? []).map((org) => (
<Button key={org.id} variant={org.id === active.data?.id ? "secondary" : "outline"} className="justify-start" onClick={() => switchTo(org.id)}>
<Button key={org.id} variant={org.id === activeId ? "secondary" : "outline"} className="justify-start" onClick={() => switchTo(org.id)}>
<Building2 aria-hidden />
{org.name}
</Button>
Expand Down
16 changes: 15 additions & 1 deletion src/server/auth/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { APIError, betterAuth } from "better-auth";
import { createAuthMiddleware } from "better-auth/api";
import { createAuthMiddleware, getSessionFromCtx } from "better-auth/api";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { after } from "next/server";
Expand All @@ -9,6 +9,7 @@ import { getDb, type Db } from "@/db";
import * as authSchema from "@/db/auth-schema";
import { users, workspaceMembers, workspaces } from "@/db/schema";
import { getAuthEnv } from "./env";
import { INVITATION_LISTING_PATHS, mayListInvitations } from "./invitation-access";
import { invitationText, INVITATION_SUBJECT } from "./invite-mail";
import { consumeOtpQuota, hashEmail } from "./otp-limit";
import { sendMail, type Mailer } from "./mail";
Expand Down Expand Up @@ -84,6 +85,19 @@ export function createAuth({
// gets ~60 guesses per address per day. Every refusal is logged (hashed address) so a campaign is visible. An anti-bot
// challenge on this endpoint is the real fix and is planned before launch.
before: createAuthMiddleware(async (ctx) => {
// Pending invitations carry the e-mail of people who have not joined: admins only (COMP-012). `get-full-organization`
// returns them too, so the account menu must not call it (it reads the organization list instead).
if ((INVITATION_LISTING_PATHS as readonly string[]).includes(ctx.path)) {
const session = await getSessionFromCtx(ctx);
if (!session) return; // the endpoint answers 401 itself
const allowed = await mayListInvitations(db, {
userId: session.user.id,
activeOrganizationId: (session.session as { activeOrganizationId?: string | null }).activeOrganizationId,
query: ctx.query,
});
if (!allowed) throw new APIError("FORBIDDEN", { message: "Apenas administradores veem os convites pendentes." });
return;
}
if (ctx.path !== "/email-otp/send-verification-otp") return;
const email = (ctx.body as { email?: unknown } | undefined)?.email;
if (typeof email !== "string") return;
Expand Down
97 changes: 97 additions & 0 deletions src/server/auth/invitation-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { beforeAll, describe, expect, it } from "vitest";
import type { Db } from "@/db";
import { createTestDb, type TestDb } from "@/test/db";
import { createAuth } from "./auth";
import { getAuthEnv } from "./env";

/** COMP-012: pending invitations (e-mails of people who have not joined) are visible to owners and admins only. */
describe("pending invitation visibility", () => {
let db: TestDb;
let auth: ReturnType<typeof createAuth>;
const mails: { text: string }[] = [];
let ownerH: Headers;
let adminH: Headers;
let memberH: Headers;
let orgId: string;

beforeAll(async () => {
db = await createTestDb();
auth = createAuth({
db: db as unknown as Db,
nextJsCookies: false,
env: getAuthEnv({ NODE_ENV: "test", BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-123", BETTER_AUTH_URL: "http://localhost:3000" }),
send: async (m) => void mails.push(m),
});
ownerH = await signIn("vis-owner@example.com");
const org = await auth.api.createOrganization({ headers: ownerH, body: { name: "Vis", slug: "vis" } });
orgId = org.id;
const join = async (email: string, role: "member" | "admin") => {
const inv = await auth.api.createInvitation({ headers: ownerH, body: { email, role, organizationId: orgId } });
const headers = await signIn(email);
await auth.api.acceptInvitation({ headers, body: { invitationId: inv.id } });
await auth.api.setActiveOrganization({ headers, body: { organizationId: orgId } });
return headers;
};
adminH = await join("vis-admin@example.com", "admin");
memberH = await join("vis-member@example.com", "member");
// a pending invitation nobody accepted
await auth.api.createInvitation({ headers: ownerH, body: { email: "pending-secret@example.com", role: "member", organizationId: orgId } });
});

async function signIn(email: string) {
mails.length = 0;
await auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } });
await new Promise((r) => setTimeout(r, 20));
const otp = /(\d{6})/.exec(mails[0].text)![1];
const res = await auth.api.signInEmailOTP({ body: { email, otp }, returnHeaders: true });
return new Headers({ cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") });
}

const get = (path: string, headers: Headers) => auth.handler(new Request(`http://localhost:3000/api/auth${path}`, { headers: { cookie: headers.get("cookie")! } }));

it("lets the owner and an admin list pending invitations", async () => {
for (const h of [ownerH, adminH]) {
const res = await get("/organization/list-invitations", h);
expect(res.status).toBe(200);
expect(await res.text()).toContain("pending-secret@example.com");
}
});

it("refuses list-invitations to a plain member, by session, id and over HTTP", async () => {
await expect(auth.api.listInvitations({ headers: memberH })).rejects.toThrow();
await expect(auth.api.listInvitations({ headers: memberH, query: { organizationId: orgId } })).rejects.toThrow();
const res = await get("/organization/list-invitations", memberH);
expect(res.status).toBe(403);
expect(await res.text()).not.toContain("pending-secret@example.com");
});

it("refuses get-full-organization to a plain member (it embeds the invitations), including by slug", async () => {
for (const path of ["/organization/get-full-organization", "/organization/get-full-organization?organizationSlug=vis", `/organization/get-full-organization?organizationId=${orgId}`]) {
const res = await get(path, memberH);
expect(res.status).toBe(403);
expect(await res.text()).not.toContain("pending-secret@example.com");
}
});

it("still serves get-full-organization to admins and the organization list to members", async () => {
const full = await get("/organization/get-full-organization", adminH);
expect(full.status).toBe(200);
expect(await full.text()).toContain("pending-secret@example.com");
const list = await get("/organization/list", memberH);
expect(list.status).toBe(200);
expect(await list.text()).toContain("Vis");
});

it("leaves the 401 to the endpoint when there is no session", async () => {
const res = await auth.handler(new Request("http://localhost:3000/api/auth/organization/list-invitations"));
expect(res.status).toBe(401);
});

it("refuses a member of another workspace who tries this one's id", async () => {
const outsider = await signIn("vis-outsider@example.com");
const own = await auth.api.createOrganization({ headers: outsider, body: { name: "Other", slug: "vis-other" } });
expect(own.id).not.toBe(orgId);
const res = await get(`/organization/list-invitations?organizationId=${orgId}`, outsider);
expect(res.status).toBe(403);
});
});
31 changes: 31 additions & 0 deletions src/server/auth/invitation-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { and, eq } from "drizzle-orm";
import type { Db } from "@/db";
import * as authSchema from "@/db/auth-schema";
import { strongestRole } from "./member-roles";

/** Endpoints that return the e-mail address of every pending invitation (COMP-012). */
export const INVITATION_LISTING_PATHS = ["/organization/list-invitations", "/organization/get-full-organization"] as const;

/**
* Pending invitations expose the e-mail of people who have not joined yet, so only owners and admins may read them.
* Resolves the organization the way Better Auth does (id, slug, then the session's active one); with none, the endpoint
* answers on its own. Returns false for a non-member too: the endpoint's own error is not worth leaking either way.
*/
export async function mayListInvitations(
db: Db,
{ userId, activeOrganizationId, query }: { userId: string; activeOrganizationId?: string | null; query?: { organizationId?: unknown; organizationSlug?: unknown } },
): Promise<boolean> {
let organizationId: string | null | undefined = typeof query?.organizationId === "string" ? query.organizationId : undefined;
if (!organizationId && typeof query?.organizationSlug === "string") {
const [org] = await db.select({ id: authSchema.organization.id }).from(authSchema.organization).where(eq(authSchema.organization.slug, query.organizationSlug));
organizationId = org?.id;
if (!organizationId) return true; // unknown slug: the endpoint itself answers "not found"
}
organizationId ??= activeOrganizationId;
if (!organizationId) return true;
const [row] = await db
.select({ role: authSchema.member.role })
.from(authSchema.member)
.where(and(eq(authSchema.member.userId, userId), eq(authSchema.member.organizationId, organizationId)));
return !!row && strongestRole(row.role) !== "member";
}
Loading