File
Blob: tests/worker/ban-defense.test.ts
| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { beforeAll, beforeEach, describe, expect, it } from "vitest"; |
| 3 | |
| 4 | import { |
| 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. |
| 28 | describe("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`. |
| 109 | describe("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 | }); |