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
67 changes: 65 additions & 2 deletions apps/api/src/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,47 @@
import { beforeAll, describe, expect, it } from "vitest";
import { getTestInstance } from "better-auth/test";
import { createPublicSessionPlugin } from "./auth.js";
import {
createPublicSessionListPlugin,
createPublicSessionPlugin,
} from "./auth.js";

function createTestAuth() {
return getTestInstance({
plugins: [createPublicSessionPlugin()],
plugins: [createPublicSessionPlugin(), createPublicSessionListPlugin()],
});
}

async function expectSafeSessionList(response: Response) {
expect(response.ok).toBe(true);
expect(response.headers.get("cache-control")).toBe("private, no-store");

const body = (await response.json()) as Record<string, unknown>[];

expect(body.length).toBeGreaterThan(0);
for (const session of body) {
expect(Object.keys(session).sort()).toEqual(
[
"id",
"userId",
"expiresAt",
"createdAt",
"updatedAt",
"ipAddress",
"userAgent",
].sort(),
);
expect(session.id).toEqual(expect.any(String));
expect(session.userId).toEqual(expect.any(String));
expect(session.expiresAt).toEqual(expect.any(String));
expect(session.createdAt).toEqual(expect.any(String));
expect(session.updatedAt).toEqual(expect.any(String));
expect(session).toHaveProperty("ipAddress");
expect(session).toHaveProperty("userAgent");
expect(session).not.toHaveProperty("token");
}
expect(JSON.stringify(body)).not.toContain(bearerToken);
}

type TestInstance = Awaited<ReturnType<typeof createTestAuth>>;

let instance: TestInstance;
Expand Down Expand Up @@ -73,3 +107,32 @@ describe("GET /api/auth/get-session", () => {
await expectSafeSession(response);
});
});

describe("GET /api/auth/list-sessions", () => {
it("未認証では 401 を返す", async () => {
const response = await instance.customFetchImpl(
"http://localhost:3000/api/auth/list-sessions",
);

expect(response.status).toBe(401);
});

it("Cookie 認証では非機密フィールドだけを返す", async () => {
const { headers } = await instance.signInWithTestUser();
const response = await instance.customFetchImpl(
"http://localhost:3000/api/auth/list-sessions",
{ headers },
);

await expectSafeSessionList(response);
});

it("Bearer 認証を維持しつつ非機密フィールドだけを返す", async () => {
const response = await instance.customFetchImpl(
"http://localhost:3000/api/auth/list-sessions",
{ headers: { Authorization: `Bearer ${bearerToken}` } },
);

await expectSafeSessionList(response);
});
});
58 changes: 56 additions & 2 deletions apps/api/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createAuthMiddleware } from "better-auth/api";
import { betterAuth } from "better-auth";
import { betterAuth, type BetterAuthPlugin } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { bearer, customSession } from "better-auth/plugins";
import { getDb } from "./db/index.js";
Expand Down Expand Up @@ -32,6 +32,56 @@ export function createPublicSessionPlugin() {
});
}

const publicSessionFields = [
"id",
"userId",
"expiresAt",
"createdAt",
"updatedAt",
"ipAddress",
"userAgent",
] as const;

async function getSessionList(returned: unknown): Promise<unknown[] | null> {
if (returned instanceof Response) {
if (!returned.ok) return null;

const body: unknown = await returned.clone().json();
return Array.isArray(body) ? body : null;
}

return Array.isArray(returned) ? returned : null;
}

export function createPublicSessionListPlugin() {
return {
id: "public-session-list",
hooks: {
after: [
{
matcher: (ctx) => ctx.path === "/list-sessions",
handler: createAuthMiddleware(async (ctx) => {
const sessions = await getSessionList(ctx.context.returned);
if (!sessions) return;

ctx.setHeader("Cache-Control", "private, no-store");

return ctx.json(
sessions.map((session) => {
const source = session as Record<string, unknown>;

return Object.fromEntries(
publicSessionFields.map((field) => [field, source[field]]),
);
}),
);
}),
},
],
},
} satisfies BetterAuthPlugin;
}

function createAuth() {
return betterAuth({
database: drizzleAdapter(getDb(), {
Expand All @@ -42,7 +92,11 @@ function createAuth() {
emailAndPassword: {
enabled: true,
},
plugins: [bearer(), createPublicSessionPlugin()],
plugins: [
bearer(),
createPublicSessionPlugin(),
createPublicSessionListPlugin(),
],
trustedOrigins: process.env.TRUSTED_ORIGINS
? process.env.TRUSTED_ORIGINS.split(",")
.map((s) => s.trim())
Expand Down
Loading