import { env } from "cloudflare:workers"; import { beforeAll, describe, expect, it } from "vitest"; import { DEFAULT_PASSWORD, DEFAULT_REDIRECT_URI, ISSUER, SELF, countOAuthTokensForClient, getLatestSessionIdForUser, getUserIdByEmail, promoteUserToAdmin, signInForCookie, signUpAdmin, signUpTestUser, testHeaders, type CreatedOAuthClient, type JsonErrorBody, type TestCredential, } from "./helpers"; describe("admin client registration", () => { const adminCred = { email: "admin-clients@example.com", password: DEFAULT_PASSWORD, name: "Admin Clients Tester", } satisfies TestCredential; const userCred = { email: "user-clients@example.com", password: DEFAULT_PASSWORD, name: "Regular User", } satisfies TestCredential; beforeAll(async () => { await signUpAdmin(adminCred); await signUpTestUser(userCred); }); it("rejects unauthenticated requests with 401", async () => { const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders(), body: JSON.stringify({ name: "anon", redirectUris: [DEFAULT_REDIRECT_URI] }), }); expect(res.status).toBe(401); }); it("rejects non-admin users with 403", async () => { const cookie = await signInForCookie(userCred.email, userCred.password, "10.30.0.1"); const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ name: "non-admin try", redirectUris: [DEFAULT_REDIRECT_URI] }), }); expect(res.status).toBe(403); // GET should also be gated. const listRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } }); expect(listRes.status).toBe(403); }); it("lets an admin create, list, rotate, and delete clients", async () => { const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1"); // 1. Create. const createRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ name: "fixture rp", redirectUris: [DEFAULT_REDIRECT_URI], skipConsent: true, }), }); expect(createRes.status).toBe(201); const created = (await createRes.json()) as CreatedOAuthClient; expect(created.client_id).toBeTruthy(); expect(created.client_secret).toBeTruthy(); // 2. List — the new client must show up. const listRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } }); expect(listRes.status).toBe(200); const list = (await listRes.json()) as Array<{ client_id: string }>; expect(list.some((c) => c.client_id === created.client_id)).toBe(true); // 3. Rotate the secret. The endpoint returns a fresh secret distinct // from the original; we don't verify cryptographic strength here, // just that rotation actually replaces the value. const rotateRes = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}/rotate`, { method: "POST", headers: testHeaders({ cookie }), }); expect(rotateRes.status).toBe(200); const rotated = (await rotateRes.json()) as { client_secret?: string }; expect(rotated.client_secret).toBeTruthy(); expect(rotated.client_secret).not.toBe(created.client_secret); // 4. Delete. const deleteRes = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "DELETE", headers: testHeaders({ cookie }), }); expect(deleteRes.status).toBe(204); // 5. After delete, the client is gone from the list. const listAfterRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } }); const listAfter = (await listAfterRes.json()) as Array<{ client_id: string }>; expect(listAfter.some((c) => c.client_id === created.client_id)).toBe(false); }, 30_000); it("rejects invalid create bodies with 400", async () => { const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1"); const missingName = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ redirectUris: [DEFAULT_REDIRECT_URI] }), }); expect(missingName.status).toBe(400); const missingRedirect = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ name: "no redirect" }), }); expect(missingRedirect.status).toBe(400); const emptyRedirect = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ name: "empty redirect", redirectUris: [] }), }); expect(emptyRedirect.status).toBe(400); }); it("rejects non-http(s) client_uri schemes with 400", async () => { const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1"); const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ name: "xss attempt", redirectUris: [DEFAULT_REDIRECT_URI], uri: "javascript:alert(1)", }), }); expect(res.status).toBe(400); const body = (await res.json()) as JsonErrorBody; expect(body.error).toBe("invalid_uri"); }); it("accepts localhost redirects and localhost subdomain client URIs with ports", async () => { const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.2"); const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ name: "localhost-subdomain-rp", redirectUris: ["http://localhost:5176/cb"], uri: "http://acme.localhost:5176", }), }); expect(res.status).toBe(201); const created = (await res.json()) as CreatedOAuthClient & { application_type: string; token_endpoint_auth_method: string; }; expect(created.client_id).toBeTruthy(); expect(created.application_type).toBe("native"); expect(created.token_endpoint_auth_method).toBe("client_secret_basic"); expect(created.client_secret).toBeTruthy(); }); it("rejects localhost subdomain redirects under the OAuth 1.7 redirect policy", async () => { const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.3"); const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ name: "invalid-loopback", redirectUris: ["http://acme.localhost:5176/cb"] }), }); expect(res.status).toBe(400); expect(((await res.json()) as JsonErrorBody).error).toBe("invalid_redirect_uri"); }); // The four raw OAuth client mutator routes are 404'd in // src/worker/index.ts so OAuth client management can only happen // through tessera's /api/admin/clients wrapper, which runs token // cleanup on rotation/delete and emits structured logs. // // Trailing-slash variants must also 404: Better Auth's router // normalizes them back to the same plugin endpoint, so an exact-match // stub would leak through. The middleware strips trailing slashes // before checking the blocked set. it("blocks raw POST /api/auth/oauth2/{create,update,delete,rotate-secret} with 404 for all variants", async () => { const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1"); const paths = [ "/api/auth/oauth2/create-client", "/api/auth/oauth2/update-client", "/api/auth/oauth2/delete-client", "/api/auth/oauth2/client/rotate-secret", ]; for (const path of paths) { for (const variant of [path, `${path}/`, `${path}//`]) { const res = await SELF.fetch(`${ISSUER}${variant}`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ client_id: "any", client_name: "any", redirect_uris: [DEFAULT_REDIRECT_URI], scope: "openid", token_endpoint_auth_method: "client_secret_basic", }), }); expect(res.status, `expected 404 for ${variant}`).toBe(404); } } }); // Rotation revokes tokens: an admin rotating a leaked client_secret // expects the kill-switch to extend to access/refresh tokens already // issued under the old secret. Mirrors handleBanUser's batch-delete // shape; see src/worker/api/admin/clients.ts handleRotateClientSecret. it("revokes existing access/refresh tokens for the rotated client only", async () => { const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1"); // Create the client we'll rotate. const targetRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ name: "rotate-target", redirectUris: [DEFAULT_REDIRECT_URI] }), }); expect(targetRes.status).toBe(201); const target = (await targetRes.json()) as CreatedOAuthClient; // Create a control client whose tokens MUST survive the rotation. const controlRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ name: "rotate-control", redirectUris: [DEFAULT_REDIRECT_URI] }), }); expect(controlRes.status).toBe(201); const control = (await controlRes.json()) as CreatedOAuthClient; // Look up admin's user id so the FK on userId is satisfied. const userId = await getUserIdByEmail(adminCred.email); // Seed access + refresh tokens for both clients so we can confirm // per-client deletion. Refresh tokens carry session_id (FK to // sessions); access tokens carry refresh_id pointing at the // refresh row. Inline minimal seed values that satisfy the schema. const sessionId = await getLatestSessionIdForUser(userId); const now = Date.now(); const expiresAt = now + 60_000; const scopes = JSON.stringify(["openid"]); const seed = async (clientId: string, tag: string) => { const refreshId = `rt-${tag}-${clientId}`; const accessId = `at-${tag}-${clientId}`; await env.DB.batch([ env.DB.prepare( `INSERT INTO oauth_refresh_tokens (id, token, client_id, session_id, user_id, expires_at, created_at, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ).bind(refreshId, `tok-${refreshId}`, clientId, sessionId, userId, expiresAt, now, scopes), env.DB.prepare( `INSERT INTO oauth_access_tokens (id, token, client_id, refresh_id, session_id, user_id, expires_at, created_at, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).bind(accessId, `tok-${accessId}`, clientId, refreshId, sessionId, userId, expiresAt, now, scopes), ]); }; await seed(target.client_id, "target"); await seed(control.client_id, "control"); // Sanity-check the seeds before rotation. expect(await countOAuthTokensForClient("oauth_access_tokens", target.client_id)).toBe(1); expect(await countOAuthTokensForClient("oauth_refresh_tokens", target.client_id)).toBe(1); expect(await countOAuthTokensForClient("oauth_access_tokens", control.client_id)).toBe(1); expect(await countOAuthTokensForClient("oauth_refresh_tokens", control.client_id)).toBe(1); const rotateRes = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(target.client_id)}/rotate`, { method: "POST", headers: testHeaders({ cookie }), }); expect(rotateRes.status).toBe(200); const rotated = (await rotateRes.json()) as { client_secret?: string }; expect(rotated.client_secret).toBeTruthy(); expect(await countOAuthTokensForClient("oauth_access_tokens", target.client_id)).toBe(0); expect(await countOAuthTokensForClient("oauth_refresh_tokens", target.client_id)).toBe(0); // Control client's tokens MUST be untouched. expect(await countOAuthTokensForClient("oauth_access_tokens", control.client_id)).toBe(1); expect(await countOAuthTokensForClient("oauth_refresh_tokens", control.client_id)).toBe(1); }, 30_000); // Multi-role admin: Better Auth's setRole accepts arrays and stores // them as `role = "admin,user"` (parseRoles in admin/routes.mjs). // tessera's role gating must accept membership, not scalar equality. it("accepts a multi-role admin for requireAdmin and clientPrivileges", async () => { const multiCred = { email: "multi-role-admin@example.com", password: DEFAULT_PASSWORD, name: "Multi Role", } satisfies TestCredential; await signUpTestUser(multiCred); // Stamp the comma-joined role directly; reaching this state via the // admin/set-role endpoint is what the upstream plugin does when // setRole receives an array. await promoteUserToAdmin(multiCred.email); await env.DB.prepare("UPDATE users SET role = ? WHERE email = ?").bind("admin,user", multiCred.email).run(); const cookie = await signInForCookie(multiCred.email, multiCred.password, "10.30.0.1"); // requireAdmin path. const listRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } }); expect(listRes.status).toBe(200); // clientPrivileges path via the tessera wrapper. The raw // /oauth2/create-client endpoint is 404'd; the wrapper calls // auth.api.createOAuthClient internally, which still routes through // the same clientPrivileges predicate the array-role normalization // protects. const createRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie }), body: JSON.stringify({ name: "multi-role-admin-client", redirectUris: [DEFAULT_REDIRECT_URI], }), }); expect(createRes.status).toBe(201); }, 30_000); // Multi-admin namespace: a client created by admin A must be visible // to admin B. With clientReference set to a constant, Better Auth's // per-row ownership check takes the referenceId branch instead of // falling back to userId equality. it("lets a second admin list and rotate a client created by the first admin", async () => { const cookieA = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1"); const createRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie: cookieA }), body: JSON.stringify({ name: "admin-A-client", redirectUris: [DEFAULT_REDIRECT_URI], }), }); expect(createRes.status).toBe(201); const created = (await createRes.json()) as CreatedOAuthClient; // Create a second admin and sign them in. const adminBCred = { email: "admin-clients-b@example.com", password: DEFAULT_PASSWORD, name: "Admin B", } satisfies TestCredential; await signUpAdmin(adminBCred); const cookieB = await signInForCookie(adminBCred.email, adminBCred.password, "10.30.0.1"); const listRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie: cookieB } }); expect(listRes.status).toBe(200); const list = (await listRes.json()) as Array<{ client_id: string }>; expect(list.some((c) => c.client_id === created.client_id)).toBe(true); const rotateRes = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}/rotate`, { method: "POST", headers: testHeaders({ cookie: cookieB }), }); expect(rotateRes.status).toBe(200); }, 30_000); describe("PATCH /api/admin/clients/:id", () => { interface ListedClient { client_id: string; client_name?: string | null; client_uri?: string | null; skip_consent?: boolean; redirect_uris?: string[] | null; } let adminCookie = ""; // Sign in once; per-test sign-ins would burn through RL_AUTH's // 10-per-minute budget for this IP across the suite. beforeAll(async () => { adminCookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.5"); }); const fetchClient = async (clientId: string): Promise => { const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie: adminCookie } }); const list = (await res.json()) as ListedClient[]; return list.find((c) => c.client_id === clientId); }; const createTargetClient = async (name: string): Promise => { const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ name, redirectUris: [DEFAULT_REDIRECT_URI] }), }); expect(res.status).toBe(201); return (await res.json()) as CreatedOAuthClient; }; it("rejects unauthenticated PATCH with 401", async () => { const res = await SELF.fetch(`${ISSUER}/api/admin/clients/anything`, { method: "PATCH", headers: testHeaders(), body: JSON.stringify({ name: "changed" }), }); expect(res.status).toBe(401); }); it("rejects non-admin PATCH with 403", async () => { const userCookie = await signInForCookie(userCred.email, userCred.password, "10.30.0.6"); const res = await SELF.fetch(`${ISSUER}/api/admin/clients/anything`, { method: "PATCH", headers: testHeaders({ cookie: userCookie }), body: JSON.stringify({ name: "changed" }), }); expect(res.status).toBe(403); }); it("updates client_name in isolation", async () => { const created = await createTargetClient("patch-name-target"); const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "PATCH", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ name: "patch-name-renamed" }), }); expect(patch.status).toBe(200); const after = await fetchClient(created.client_id); expect(after?.client_name).toBe("patch-name-renamed"); expect(after?.skip_consent).not.toBe(true); }); it("updates skipConsent in isolation", async () => { const created = await createTargetClient("patch-skip-target"); const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "PATCH", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ skipConsent: true }), }); expect(patch.status).toBe(200); const after = await fetchClient(created.client_id); expect(after?.skip_consent).toBe(true); expect(after?.client_name).toBe("patch-skip-target"); }); it("ignores arbitrary fields (only name, skipConsent, uri are honored)", async () => { const created = await createTargetClient("patch-strict"); const otherRedirect = "http://127.0.0.1:0/other"; const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "PATCH", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ name: "patch-strict-renamed", redirect_uris: [otherRedirect], redirectUris: [otherRedirect], metadata: { launcher: { lucide: "Hammer" } }, grant_types: ["client_credentials"], }), }); expect(patch.status).toBe(200); const after = await fetchClient(created.client_id); expect(after?.client_name).toBe("patch-strict-renamed"); // redirect_uris must not have been replaced. expect(after?.redirect_uris).toEqual([DEFAULT_REDIRECT_URI]); }); it("rejects empty PATCH body with 400", async () => { const created = await createTargetClient("patch-empty-target"); const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "PATCH", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({}), }); expect(patch.status).toBe(400); const body = (await patch.json()) as JsonErrorBody; expect(body.error).toBe("invalid_body"); }); it("rejects non-boolean skipConsent with 400", async () => { const created = await createTargetClient("patch-bad-skip"); const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "PATCH", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ skipConsent: "yes" }), }); expect(patch.status).toBe(400); const body = (await patch.json()) as JsonErrorBody; expect(body.error).toBe("invalid_skip_consent"); }); it("updates uri in isolation", async () => { const created = await createTargetClient("patch-uri-target"); const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "PATCH", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ uri: "https://anvil.limic.dev" }), }); expect(patch.status).toBe(200); const after = await fetchClient(created.client_id); expect(after?.client_uri).toBe("https://anvil.limic.dev"); expect(after?.client_name).toBe("patch-uri-target"); }); it("clears uri when sent as empty string", async () => { const created = await createTargetClient("patch-uri-clear"); const set = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "PATCH", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ uri: "https://anvil.limic.dev" }), }); expect(set.status).toBe(200); const cleared = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "PATCH", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ uri: " " }), }); expect(cleared.status).toBe(200); const after = await fetchClient(created.client_id); // Stored as empty string (Better Auth's PATCH schema rejects null); // consumers gate on truthiness, so absent and "" are equivalent. expect(after?.client_uri ?? "").toBe(""); }); it("rejects non-loopback http uri with 400", async () => { const created = await createTargetClient("patch-uri-http"); const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "PATCH", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ uri: "http://anvil.limic.dev" }), }); expect(patch.status).toBe(400); const body = (await patch.json()) as JsonErrorBody; expect(body.error).toBe("invalid_uri"); }); it("rejects javascript: uri with 400", async () => { const created = await createTargetClient("patch-uri-js"); const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "PATCH", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ uri: "javascript:alert(1)" }), }); expect(patch.status).toBe(400); const body = (await patch.json()) as JsonErrorBody; expect(body.error).toBe("invalid_uri"); }); it("rejects non-string uri with 400", async () => { const created = await createTargetClient("patch-uri-non-string"); const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "PATCH", headers: testHeaders({ cookie: adminCookie }), body: JSON.stringify({ uri: 42 }), }); expect(patch.status).toBe(400); const body = (await patch.json()) as JsonErrorBody; expect(body.error).toBe("invalid_uri"); }); }); });