import { env } from "cloudflare:workers"; import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { DEFAULT_PASSWORD, DEFAULT_REDIRECT_URI, ISSUER, SELF, authorizeWithPkce, createOAuthClientAsAdmin, exchangeAuthorizationCode, getUserIdByEmail, signInForCookie, signUpAdmin, signUpTestUser, type JsonErrorBody, type OAuthTokenResponse, type TestCredential, } from "./helpers"; // Defense-in-depth on top of the live ban path. `handleBanUser` // flips `banned` and drops sessions atomically, so a banned user // normally has no live cookie. The loadSession predicate covers the // case where `banned` is flipped outside that path: a manual D1 fix, // admin tooling that misses the wrapper, or Better Auth's plugin // /admin/ban-user route. Tests bypass `handleBanUser` to keep the // session row alive. describe("Banned-user defense-in-depth on loadSession", () => { const cred = { email: "ban-defense-tester@example.com", password: DEFAULT_PASSWORD, name: "Ban Defense Tester", } satisfies TestCredential; let cookie: string; beforeAll(async () => { await signUpTestUser(cred); }); beforeEach(async () => { // Reset to a clean unbanned state and obtain a fresh session every // test so previous mutations do not bleed across cases. await env.DB.prepare("UPDATE users SET banned = 0, ban_expires = NULL WHERE email = ?").bind(cred.email).run(); cookie = await signInForCookie(cred.email, cred.password, "10.75.0.1"); }); it("rejects requireUser routes with 403 when banned outside handleBanUser", async () => { await env.DB.prepare("UPDATE users SET banned = 1 WHERE email = ?").bind(cred.email).run(); const res = await SELF.fetch(`${ISSUER}/api/account/connected-apps`, { headers: { cookie }, }); expect(res.status).toBe(403); const body = (await res.json()) as JsonErrorBody; expect(body.error).toBe("BANNED_USER"); }); it("rejects requireAdmin routes with 403 when banned outside handleBanUser", async () => { await env.DB.prepare("UPDATE users SET role = ?, banned = 1 WHERE email = ?").bind("admin", cred.email).run(); const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } }); expect(res.status).toBe(403); const body = (await res.json()) as JsonErrorBody; expect(body.error).toBe("BANNED_USER"); }); it("allows requests when banned has expired (banExpires in the past)", async () => { const pastMs = Date.now() - 1_000; await env.DB.prepare("UPDATE users SET banned = 1, ban_expires = ? WHERE email = ?").bind(pastMs, cred.email).run(); const res = await SELF.fetch(`${ISSUER}/api/account/connected-apps`, { headers: { cookie }, }); expect(res.status).toBe(200); }); it("rejects when banned with a future banExpires (active temporary ban)", async () => { const futureMs = Date.now() + 60_000; await env.DB.prepare("UPDATE users SET banned = 1, ban_expires = ? WHERE email = ?") .bind(futureMs, cred.email) .run(); const res = await SELF.fetch(`${ISSUER}/api/account/connected-apps`, { headers: { cookie }, }); expect(res.status).toBe(403); const body = (await res.json()) as JsonErrorBody; expect(body.error).toBe("BANNED_USER"); }); it("rejects raw /api/auth/get-session when banned outside handleBanUser", async () => { await env.DB.prepare("UPDATE users SET banned = 1 WHERE email = ?").bind(cred.email).run(); const res = await SELF.fetch(`${ISSUER}/api/auth/get-session`, { headers: { cookie } }); expect(res.status).not.toBe(200); const body = (await res.json()) as { code?: string }; expect(body.code).toBe("BANNED_USER"); }); }); // Bearer-token surfaces (/oauth2/token, /oauth2/userinfo) bypass // `loadSession` because they authenticate via authorization codes and // access tokens, not session cookies. Better Auth's admin plugin only // checks bans in `databaseHooks.session.create.before`, so a user whose // `banned` flag is flipped after the auth code or access token was // issued retains usable credentials. tessera closes that gap via the // `customTokenResponseFields` (mint-time) and `customAccessTokenClaims` // (validation-time) hooks in `worker/auth/index.ts`. describe("Banned-user defense-in-depth on OAuth flow surfaces", () => { const adminCred = { email: "ban-oauth-admin@example.com", password: DEFAULT_PASSWORD, name: "Ban OAuth Admin", } satisfies TestCredential; const userCred = { email: "ban-oauth-user@example.com", password: DEFAULT_PASSWORD, name: "Ban OAuth User", } satisfies TestCredential; let clientId: string; let clientSecret: string; beforeAll(async () => { await signUpAdmin(adminCred); await signUpTestUser(userCred); const adminCookie = await signInForCookie(adminCred.email, adminCred.password, "10.76.0.1"); const created = await createOAuthClientAsAdmin(adminCookie, { name: "ban-oauth-target", redirectUris: [DEFAULT_REDIRECT_URI], skipConsent: true, }); clientId = created.client_id; clientSecret = created.client_secret; }); beforeEach(async () => { // Always start each test from an unbanned state. Tests flip the // ban flag mid-flow and assert the kill-switch fires. await env.DB.prepare("UPDATE users SET banned = 0, ban_expires = NULL WHERE email = ?").bind(userCred.email).run(); }); // PKCE round-trip plus authorization-code redirect helper. Kept // inline so each test can choose where in the flow to flip the ban // flag. const runAuthorize = async ( cookieValue: string, ): Promise<{ code: string | null; status: number; verifier: string }> => { const result = await authorizeWithPkce({ clientId, cookie: cookieValue, redirectUri: DEFAULT_REDIRECT_URI, state: "ban-defense-state", }); let code: string | null = null; if (result.location) { try { const parsed = new URL(result.location, ISSUER); if (parsed.host === "127.0.0.1:0") { code = parsed.searchParams.get("code"); } } catch { // Non-URL location (e.g. relative redirect to /auth-error). Leave code null. } } return { code, status: result.status, verifier: result.verifier }; }; it("/api/auth/oauth2/authorize does not mint a code for a banned user", async () => { const cookieValue = await signInForCookie(userCred.email, userCred.password, "10.76.0.2"); await env.DB.prepare("UPDATE users SET banned = 1 WHERE email = ?").bind(userCred.email).run(); const authorizeRes = await runAuthorize(cookieValue); expect(authorizeRes.status).toBe(403); const { code } = authorizeRes; expect(code).toBeNull(); }); it("/oauth2/token rejects an authorization code minted before the ban", async () => { const cookieValue = await signInForCookie(userCred.email, userCred.password, "10.76.0.3"); // Mint an authorization code while still unbanned, then flip the ban. const authorizeRes = await runAuthorize(cookieValue); expect(authorizeRes.status).toBe(302); const { code, verifier } = authorizeRes; expect(code).toBeTruthy(); await env.DB.prepare("UPDATE users SET banned = 1 WHERE email = ?").bind(userCred.email).run(); const tokenRes = await exchangeAuthorizationCode({ code: code!, verifier, clientId, clientSecret, redirectUri: DEFAULT_REDIRECT_URI, }); expect(tokenRes.status).not.toBe(200); // No oauth_access_tokens row should have been written for this user. const userId = await getUserIdByEmail(userCred.email); const tokenCount = await env.DB.prepare("SELECT COUNT(*) AS c FROM oauth_access_tokens WHERE user_id = ?") .bind(userId) .first<{ c: number }>(); expect(tokenCount?.c ?? 0).toBe(0); }); it("/oauth2/userinfo rejects a bearer token after the user is banned", async () => { const cookieValue = await signInForCookie(userCred.email, userCred.password, "10.76.0.4"); // Complete the full /authorize → /token flow before banning. const authorizeRes = await runAuthorize(cookieValue); expect(authorizeRes.status).toBe(302); const { code, verifier } = authorizeRes; expect(code).toBeTruthy(); const tokenRes = await exchangeAuthorizationCode({ code: code!, verifier, clientId, clientSecret, redirectUri: DEFAULT_REDIRECT_URI, }); expect(tokenRes.status).toBe(200); const tokens = (await tokenRes.json()) as OAuthTokenResponse; expect(tokens.access_token).toBeTruthy(); // Ban the user, then attempt /userinfo with the still-unexpired bearer. await env.DB.prepare("UPDATE users SET banned = 1 WHERE email = ?").bind(userCred.email).run(); const userinfoRes = await SELF.fetch(`${ISSUER}/api/auth/oauth2/userinfo`, { headers: { authorization: `Bearer ${tokens.access_token}` }, }); expect(userinfoRes.status).not.toBe(200); }); });