Skip to content
File

Blob: tests/worker/ban-defense.test.ts

typescript233 lines
1import { env } from "cloudflare:workers";
2import { beforeAll, beforeEach, describe, expect, it } from "vitest";
3 
4import {
5 DEFAULT_PASSWORD,
6 DEFAULT_REDIRECT_URI,
7 ISSUER,
8 SELF,
9 authorizeWithPkce,
10 createOAuthClientAsAdmin,
11 exchangeAuthorizationCode,
12 getUserIdByEmail,
13 signInForCookie,
14 signUpAdmin,
15 signUpTestUser,
16 type JsonErrorBody,
17 type OAuthTokenResponse,
18 type TestCredential,
19} from "./helpers";
20 
21// Defense-in-depth on top of the live ban path. `handleBanUser`
22// flips `banned` and drops sessions atomically, so a banned user
23// normally has no live cookie. The loadSession predicate covers the
24// case where `banned` is flipped outside that path: a manual D1 fix,
25// admin tooling that misses the wrapper, or Better Auth's plugin
26// /admin/ban-user route. Tests bypass `handleBanUser` to keep the
27// session row alive.
28describe("Banned-user defense-in-depth on loadSession", () => {
29 const cred = {
30 email: "ban-defense-tester@example.com",
31 password: DEFAULT_PASSWORD,
32 name: "Ban Defense Tester",
33 } satisfies TestCredential;
34 let cookie: string;
35 
36 beforeAll(async () => {
37 await signUpTestUser(cred);
38 });
39 
40 beforeEach(async () => {
41 // Reset to a clean unbanned state and obtain a fresh session every
42 // test so previous mutations do not bleed across cases.
43 await env.DB.prepare("UPDATE users SET banned = 0, ban_expires = NULL WHERE email = ?").bind(cred.email).run();
44 cookie = await signInForCookie(cred.email, cred.password, "10.75.0.1");
45 });
46 
47 it("rejects requireUser routes with 403 when banned outside handleBanUser", async () => {
48 await env.DB.prepare("UPDATE users SET banned = 1 WHERE email = ?").bind(cred.email).run();
49 
50 const res = await SELF.fetch(`${ISSUER}/api/account/connected-apps`, {
51 headers: { cookie },
52 });
53 expect(res.status).toBe(403);
54 const body = (await res.json()) as JsonErrorBody;
55 expect(body.error).toBe("BANNED_USER");
56 });
57 
58 it("rejects requireAdmin routes with 403 when banned outside handleBanUser", async () => {
59 await env.DB.prepare("UPDATE users SET role = ?, banned = 1 WHERE email = ?").bind("admin", cred.email).run();
60 
61 const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } });
62 expect(res.status).toBe(403);
63 const body = (await res.json()) as JsonErrorBody;
64 expect(body.error).toBe("BANNED_USER");
65 });
66 
67 it("allows requests when banned has expired (banExpires in the past)", async () => {
68 const pastMs = Date.now() - 1_000;
69 await env.DB.prepare("UPDATE users SET banned = 1, ban_expires = ? WHERE email = ?").bind(pastMs, cred.email).run();
70 
71 const res = await SELF.fetch(`${ISSUER}/api/account/connected-apps`, {
72 headers: { cookie },
73 });
74 expect(res.status).toBe(200);
75 });
76 
77 it("rejects when banned with a future banExpires (active temporary ban)", async () => {
78 const futureMs = Date.now() + 60_000;
79 await env.DB.prepare("UPDATE users SET banned = 1, ban_expires = ? WHERE email = ?")
80 .bind(futureMs, cred.email)
81 .run();
82 
83 const res = await SELF.fetch(`${ISSUER}/api/account/connected-apps`, {
84 headers: { cookie },
85 });
86 expect(res.status).toBe(403);
87 const body = (await res.json()) as JsonErrorBody;
88 expect(body.error).toBe("BANNED_USER");
89 });
90 
91 it("rejects raw /api/auth/get-session when banned outside handleBanUser", async () => {
92 await env.DB.prepare("UPDATE users SET banned = 1 WHERE email = ?").bind(cred.email).run();
93 
94 const res = await SELF.fetch(`${ISSUER}/api/auth/get-session`, { headers: { cookie } });
95 expect(res.status).not.toBe(200);
96 const body = (await res.json()) as { code?: string };
97 expect(body.code).toBe("BANNED_USER");
98 });
99});
100 
101// Bearer-token surfaces (/oauth2/token, /oauth2/userinfo) bypass
102// `loadSession` because they authenticate via authorization codes and
103// access tokens, not session cookies. Better Auth's admin plugin only
104// checks bans in `databaseHooks.session.create.before`, so a user whose
105// `banned` flag is flipped after the auth code or access token was
106// issued retains usable credentials. tessera closes that gap via the
107// `customTokenResponseFields` (mint-time) and `customAccessTokenClaims`
108// (validation-time) hooks in `worker/auth/index.ts`.
109describe("Banned-user defense-in-depth on OAuth flow surfaces", () => {
110 const adminCred = {
111 email: "ban-oauth-admin@example.com",
112 password: DEFAULT_PASSWORD,
113 name: "Ban OAuth Admin",
114 } satisfies TestCredential;
115 const userCred = {
116 email: "ban-oauth-user@example.com",
117 password: DEFAULT_PASSWORD,
118 name: "Ban OAuth User",
119 } satisfies TestCredential;
120 let clientId: string;
121 let clientSecret: string;
122 
123 beforeAll(async () => {
124 await signUpAdmin(adminCred);
125 await signUpTestUser(userCred);
126 
127 const adminCookie = await signInForCookie(adminCred.email, adminCred.password, "10.76.0.1");
128 const created = await createOAuthClientAsAdmin(adminCookie, {
129 name: "ban-oauth-target",
130 redirectUris: [DEFAULT_REDIRECT_URI],
131 skipConsent: true,
132 });
133 clientId = created.client_id;
134 clientSecret = created.client_secret;
135 });
136 
137 beforeEach(async () => {
138 // Always start each test from an unbanned state. Tests flip the
139 // ban flag mid-flow and assert the kill-switch fires.
140 await env.DB.prepare("UPDATE users SET banned = 0, ban_expires = NULL WHERE email = ?").bind(userCred.email).run();
141 });
142 
143 // PKCE round-trip plus authorization-code redirect helper. Kept
144 // inline so each test can choose where in the flow to flip the ban
145 // flag.
146 const runAuthorize = async (
147 cookieValue: string,
148 ): Promise<{ code: string | null; status: number; verifier: string }> => {
149 const result = await authorizeWithPkce({
150 clientId,
151 cookie: cookieValue,
152 redirectUri: DEFAULT_REDIRECT_URI,
153 state: "ban-defense-state",
154 });
155 let code: string | null = null;
156 if (result.location) {
157 try {
158 const parsed = new URL(result.location, ISSUER);
159 if (parsed.host === "127.0.0.1:0") {
160 code = parsed.searchParams.get("code");
161 }
162 } catch {
163 // Non-URL location (e.g. relative redirect to /auth-error). Leave code null.
164 }
165 }
166 return { code, status: result.status, verifier: result.verifier };
167 };
168 
169 it("/api/auth/oauth2/authorize does not mint a code for a banned user", async () => {
170 const cookieValue = await signInForCookie(userCred.email, userCred.password, "10.76.0.2");
171 await env.DB.prepare("UPDATE users SET banned = 1 WHERE email = ?").bind(userCred.email).run();
172 
173 const authorizeRes = await runAuthorize(cookieValue);
174 expect(authorizeRes.status).toBe(403);
175 const { code } = authorizeRes;
176 expect(code).toBeNull();
177 });
178 
179 it("/oauth2/token rejects an authorization code minted before the ban", async () => {
180 const cookieValue = await signInForCookie(userCred.email, userCred.password, "10.76.0.3");
181 
182 // Mint an authorization code while still unbanned, then flip the ban.
183 const authorizeRes = await runAuthorize(cookieValue);
184 expect(authorizeRes.status).toBe(302);
185 const { code, verifier } = authorizeRes;
186 expect(code).toBeTruthy();
187 await env.DB.prepare("UPDATE users SET banned = 1 WHERE email = ?").bind(userCred.email).run();
188 
189 const tokenRes = await exchangeAuthorizationCode({
190 code: code!,
191 verifier,
192 clientId,
193 clientSecret,
194 redirectUri: DEFAULT_REDIRECT_URI,
195 });
196 expect(tokenRes.status).not.toBe(200);
197 
198 // No oauth_access_tokens row should have been written for this user.
199 const userId = await getUserIdByEmail(userCred.email);
200 const tokenCount = await env.DB.prepare("SELECT COUNT(*) AS c FROM oauth_access_tokens WHERE user_id = ?")
201 .bind(userId)
202 .first<{ c: number }>();
203 expect(tokenCount?.c ?? 0).toBe(0);
204 });
205 
206 it("/oauth2/userinfo rejects a bearer token after the user is banned", async () => {
207 const cookieValue = await signInForCookie(userCred.email, userCred.password, "10.76.0.4");
208 
209 // Complete the full /authorize → /token flow before banning.
210 const authorizeRes = await runAuthorize(cookieValue);
211 expect(authorizeRes.status).toBe(302);
212 const { code, verifier } = authorizeRes;
213 expect(code).toBeTruthy();
214 const tokenRes = await exchangeAuthorizationCode({
215 code: code!,
216 verifier,
217 clientId,
218 clientSecret,
219 redirectUri: DEFAULT_REDIRECT_URI,
220 });
221 expect(tokenRes.status).toBe(200);
222 const tokens = (await tokenRes.json()) as OAuthTokenResponse;
223 expect(tokens.access_token).toBeTruthy();
224 
225 // Ban the user, then attempt /userinfo with the still-unexpired bearer.
226 await env.DB.prepare("UPDATE users SET banned = 1 WHERE email = ?").bind(userCred.email).run();
227 const userinfoRes = await SELF.fetch(`${ISSUER}/api/auth/oauth2/userinfo`, {
228 headers: { authorization: `Bearer ${tokens.access_token}` },
229 });
230 expect(userinfoRes.status).not.toBe(200);
231 });
232});