Files
gitea-mirror/tests/e2e/06-api-keys.spec.ts
T
ARUNAVO RAYandGitHub 83dd74dd52 feat(auth): API keys for programmatic access (#380)
* feat(auth): API keys for programmatic access

Adds the @better-auth/api-key plugin so scripts and CI pipelines can call
the existing endpoints with an x-api-key header instead of a session
cookie. Keys are owned by a user, hashed at rest, prefixed gm_, never
expire unless an expiry is chosen, and are not rate limited. A small
guard plugin refuses key management calls that arrive with a key, so a
leaked key cannot mint or revoke keys.

New API Keys section on the Authentication tab with create, show once,
copy and revoke. Migration 0017 adds the api_keys table with the
validator fixture. docs/API.md documents the header and the calls
automation needs. An e2e spec covers create, use, refuse and revoke over
HTTP. bun.nix regenerated for the new package.

Closes #314

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX

* fix(e2e): send Origin on cookie-authenticated key management calls

Better Auth rejects a cookie-authenticated POST without an Origin header (403 MISSING_OR_NULL_ORIGIN). Browsers always send one, the Playwright request context does not, so the spec sets it on the create and delete calls. Also asserts the guard's 403 code and documents the Origin requirement for scripts that manage keys with a session.

Claude-Session: https://claude.ai/code/session_01Tp9pmi65a8k5jLMQFLf4JX
2026-09-02 14:57:58 +05:30

163 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 06 – API keys.
*
* Creates a personal API key through the Better Auth endpoint, calls the
* app with the key in the x-api-key header instead of a session cookie,
* then revokes it and checks the key stops working.
*/
import { test, expect } from "@playwright/test";
import { APP_URL, APP_USER_EMAIL, getAppSessionCookies } from "./helpers";
const KEY_HEADER = "x-api-key";
/**
* Better Auth's CSRF check rejects a cookie-authenticated POST that has no
* Origin header (403 MISSING_OR_NULL_ORIGIN). Browsers always send one; the
* Playwright request context does not, so the key management calls set it
* by hand. App routes and key-authenticated calls do not need it.
*/
const sessionPostHeaders = (cookies: string) => ({
Cookie: cookies,
Origin: APP_URL,
"Content-Type": "application/json",
});
test.describe("E2E: API keys", () => {
let cookies = "";
let keyId = "";
let secret = "";
test("Step 1: create a key with the session", async ({ request }) => {
cookies = await getAppSessionCookies(request);
expect(cookies).toBeTruthy();
const resp = await request.post(`${APP_URL}/api/auth/api-key/create`, {
headers: sessionPostHeaders(cookies),
data: { name: "e2e-key" },
failOnStatusCode: false,
});
expect(resp.status(), await resp.text()).toBe(200);
const body = await resp.json();
keyId = body.id;
secret = body.key;
expect(keyId).toBeTruthy();
expect(secret.startsWith("gm_")).toBeTruthy();
expect(secret.length).toBeGreaterThan(64);
expect(body.expiresAt).toBeNull();
expect(body.rateLimitEnabled).toBe(false);
console.log(`[ApiKeys] Created key ${body.start}… (${keyId})`);
});
test("Step 2: the key resolves to the user's session", async ({ request }) => {
const resp = await request.get(`${APP_URL}/api/auth/get-session`, {
headers: { [KEY_HEADER]: secret },
failOnStatusCode: false,
});
expect(resp.status(), await resp.text()).toBe(200);
const session = await resp.json();
expect(session?.user?.email).toBe(APP_USER_EMAIL);
});
test("Step 3: app routes accept the key without a cookie", async ({ request }) => {
const resp = await request.get(`${APP_URL}/api/github/repositories`, {
headers: { [KEY_HEADER]: secret },
failOnStatusCode: false,
});
// 200 once a configuration exists (spec 02 saves one); never a 401.
expect(resp.status()).not.toBe(401);
expect(resp.status()).toBeLessThan(500);
if (resp.status() === 200) {
const body = await resp.json();
expect(body.success).toBe(true);
expect(Array.isArray(body.repositories)).toBeTruthy();
console.log(`[ApiKeys] Listed ${body.repositories.length} repositories with the key`);
}
});
test("Step 4: missing, malformed and unknown keys are rejected", async ({ request }) => {
const noKey = await request.get(`${APP_URL}/api/github/repositories`, {
failOnStatusCode: false,
});
expect(noKey.status()).toBe(401);
const shortKey = await request.get(`${APP_URL}/api/github/repositories`, {
headers: { [KEY_HEADER]: "gm_not-a-real-key" },
failOnStatusCode: false,
});
expect(shortKey.status()).toBe(401);
const unknownKey = await request.get(`${APP_URL}/api/github/repositories`, {
headers: { [KEY_HEADER]: `gm_${"a".repeat(64)}` },
failOnStatusCode: false,
});
expect(unknownKey.status()).toBe(401);
});
test("Step 4b: a key cannot create, list or revoke keys", async ({ request }) => {
// The api-key-guard plugin (src/lib/auth-api-key-guard.ts) answers 403
// with code API_KEY_CANNOT_MANAGE_KEYS before the plugin's own session
// check runs, so these are 403 rather than 401.
const create = await request.post(`${APP_URL}/api/auth/api-key/create`, {
headers: { [KEY_HEADER]: secret, "Content-Type": "application/json" },
data: { name: "minted-by-a-key" },
failOnStatusCode: false,
});
expect(create.status(), await create.text()).toBe(403);
expect((await create.json()).code).toBe("API_KEY_CANNOT_MANAGE_KEYS");
const list = await request.get(`${APP_URL}/api/auth/api-key/list`, {
headers: { [KEY_HEADER]: secret },
failOnStatusCode: false,
});
expect(list.status()).toBe(403);
const del = await request.post(`${APP_URL}/api/auth/api-key/delete`, {
headers: { [KEY_HEADER]: secret, "Content-Type": "application/json" },
data: { keyId },
failOnStatusCode: false,
});
expect(del.status()).toBe(403);
});
test("Step 5: the list shows the key start and usage, never the secret", async ({ request }) => {
const resp = await request.get(`${APP_URL}/api/auth/api-key/list`, {
headers: { Cookie: cookies },
failOnStatusCode: false,
});
expect(resp.status(), await resp.text()).toBe(200);
const body = (await resp.json()) as { apiKeys: Array<Record<string, unknown>>; total: number };
expect(Array.isArray(body.apiKeys)).toBeTruthy();
expect(body.total).toBeGreaterThanOrEqual(1);
const row = body.apiKeys.find((candidate) => candidate.id === keyId);
expect(row).toBeTruthy();
expect(row!.key).toBeUndefined();
expect(typeof row!.start).toBe("string");
expect(secret.startsWith(row!.start as string)).toBeTruthy();
expect(row!.lastRequest).toBeTruthy();
expect(row!.name).toBe("e2e-key");
});
test("Step 6: revoking the key stops it working", async ({ request }) => {
const del = await request.post(`${APP_URL}/api/auth/api-key/delete`, {
headers: sessionPostHeaders(cookies),
data: { keyId },
failOnStatusCode: false,
});
expect(del.status(), await del.text()).toBe(200);
const resp = await request.get(`${APP_URL}/api/github/repositories`, {
headers: { [KEY_HEADER]: secret },
failOnStatusCode: false,
});
expect(resp.status()).toBe(401);
const session = await request.get(`${APP_URL}/api/auth/get-session`, {
headers: { [KEY_HEADER]: secret },
failOnStatusCode: false,
});
expect(session.status()).not.toBe(200);
console.log("[ApiKeys] Revoked key is rejected");
});
});