File
Blob: tests/worker/admin-clients.test.ts
| 1 | import { env } from "cloudflare:workers"; |
| 2 | import { beforeAll, describe, expect, it } from "vitest"; |
| 3 | |
| 4 | import { |
| 5 | DEFAULT_PASSWORD, |
| 6 | DEFAULT_REDIRECT_URI, |
| 7 | ISSUER, |
| 8 | SELF, |
| 9 | countOAuthTokensForClient, |
| 10 | getLatestSessionIdForUser, |
| 11 | getUserIdByEmail, |
| 12 | promoteUserToAdmin, |
| 13 | signInForCookie, |
| 14 | signUpAdmin, |
| 15 | signUpTestUser, |
| 16 | testHeaders, |
| 17 | type CreatedOAuthClient, |
| 18 | type JsonErrorBody, |
| 19 | type TestCredential, |
| 20 | } from "./helpers"; |
| 21 | |
| 22 | describe("admin client registration", () => { |
| 23 | const adminCred = { |
| 24 | email: "admin-clients@example.com", |
| 25 | password: DEFAULT_PASSWORD, |
| 26 | name: "Admin Clients Tester", |
| 27 | } satisfies TestCredential; |
| 28 | const userCred = { |
| 29 | email: "user-clients@example.com", |
| 30 | password: DEFAULT_PASSWORD, |
| 31 | name: "Regular User", |
| 32 | } satisfies TestCredential; |
| 33 | |
| 34 | beforeAll(async () => { |
| 35 | await signUpAdmin(adminCred); |
| 36 | await signUpTestUser(userCred); |
| 37 | }); |
| 38 | |
| 39 | it("rejects unauthenticated requests with 401", async () => { |
| 40 | const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 41 | method: "POST", |
| 42 | headers: testHeaders(), |
| 43 | body: JSON.stringify({ name: "anon", redirectUris: [DEFAULT_REDIRECT_URI] }), |
| 44 | }); |
| 45 | expect(res.status).toBe(401); |
| 46 | }); |
| 47 | |
| 48 | it("rejects non-admin users with 403", async () => { |
| 49 | const cookie = await signInForCookie(userCred.email, userCred.password, "10.30.0.1"); |
| 50 | const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 51 | method: "POST", |
| 52 | headers: testHeaders({ cookie }), |
| 53 | body: JSON.stringify({ name: "non-admin try", redirectUris: [DEFAULT_REDIRECT_URI] }), |
| 54 | }); |
| 55 | expect(res.status).toBe(403); |
| 56 | |
| 57 | // GET should also be gated. |
| 58 | const listRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } }); |
| 59 | expect(listRes.status).toBe(403); |
| 60 | }); |
| 61 | |
| 62 | it("lets an admin create, list, rotate, and delete clients", async () => { |
| 63 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1"); |
| 64 | |
| 65 | // 1. Create. |
| 66 | const createRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 67 | method: "POST", |
| 68 | headers: testHeaders({ cookie }), |
| 69 | body: JSON.stringify({ |
| 70 | name: "fixture rp", |
| 71 | redirectUris: [DEFAULT_REDIRECT_URI], |
| 72 | skipConsent: true, |
| 73 | }), |
| 74 | }); |
| 75 | expect(createRes.status).toBe(201); |
| 76 | const created = (await createRes.json()) as CreatedOAuthClient; |
| 77 | expect(created.client_id).toBeTruthy(); |
| 78 | expect(created.client_secret).toBeTruthy(); |
| 79 | |
| 80 | // 2. List — the new client must show up. |
| 81 | const listRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } }); |
| 82 | expect(listRes.status).toBe(200); |
| 83 | const list = (await listRes.json()) as Array<{ client_id: string }>; |
| 84 | expect(list.some((c) => c.client_id === created.client_id)).toBe(true); |
| 85 | |
| 86 | // 3. Rotate the secret. The endpoint returns a fresh secret distinct |
| 87 | // from the original; we don't verify cryptographic strength here, |
| 88 | // just that rotation actually replaces the value. |
| 89 | const rotateRes = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}/rotate`, { |
| 90 | method: "POST", |
| 91 | headers: testHeaders({ cookie }), |
| 92 | }); |
| 93 | expect(rotateRes.status).toBe(200); |
| 94 | const rotated = (await rotateRes.json()) as { client_secret?: string }; |
| 95 | expect(rotated.client_secret).toBeTruthy(); |
| 96 | expect(rotated.client_secret).not.toBe(created.client_secret); |
| 97 | |
| 98 | // 4. Delete. |
| 99 | const deleteRes = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 100 | method: "DELETE", |
| 101 | headers: testHeaders({ cookie }), |
| 102 | }); |
| 103 | expect(deleteRes.status).toBe(204); |
| 104 | |
| 105 | // 5. After delete, the client is gone from the list. |
| 106 | const listAfterRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } }); |
| 107 | const listAfter = (await listAfterRes.json()) as Array<{ client_id: string }>; |
| 108 | expect(listAfter.some((c) => c.client_id === created.client_id)).toBe(false); |
| 109 | }, 30_000); |
| 110 | |
| 111 | it("rejects invalid create bodies with 400", async () => { |
| 112 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1"); |
| 113 | |
| 114 | const missingName = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 115 | method: "POST", |
| 116 | headers: testHeaders({ cookie }), |
| 117 | body: JSON.stringify({ redirectUris: [DEFAULT_REDIRECT_URI] }), |
| 118 | }); |
| 119 | expect(missingName.status).toBe(400); |
| 120 | |
| 121 | const missingRedirect = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 122 | method: "POST", |
| 123 | headers: testHeaders({ cookie }), |
| 124 | body: JSON.stringify({ name: "no redirect" }), |
| 125 | }); |
| 126 | expect(missingRedirect.status).toBe(400); |
| 127 | |
| 128 | const emptyRedirect = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 129 | method: "POST", |
| 130 | headers: testHeaders({ cookie }), |
| 131 | body: JSON.stringify({ name: "empty redirect", redirectUris: [] }), |
| 132 | }); |
| 133 | expect(emptyRedirect.status).toBe(400); |
| 134 | }); |
| 135 | |
| 136 | it("rejects non-http(s) client_uri schemes with 400", async () => { |
| 137 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1"); |
| 138 | const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 139 | method: "POST", |
| 140 | headers: testHeaders({ cookie }), |
| 141 | body: JSON.stringify({ |
| 142 | name: "xss attempt", |
| 143 | redirectUris: [DEFAULT_REDIRECT_URI], |
| 144 | uri: "javascript:alert(1)", |
| 145 | }), |
| 146 | }); |
| 147 | expect(res.status).toBe(400); |
| 148 | const body = (await res.json()) as JsonErrorBody; |
| 149 | expect(body.error).toBe("invalid_uri"); |
| 150 | }); |
| 151 | |
| 152 | it("accepts localhost redirects and localhost subdomain client URIs with ports", async () => { |
| 153 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.2"); |
| 154 | const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 155 | method: "POST", |
| 156 | headers: testHeaders({ cookie }), |
| 157 | body: JSON.stringify({ |
| 158 | name: "localhost-subdomain-rp", |
| 159 | redirectUris: ["http://localhost:5176/cb"], |
| 160 | uri: "http://acme.localhost:5176", |
| 161 | }), |
| 162 | }); |
| 163 | expect(res.status).toBe(201); |
| 164 | const created = (await res.json()) as CreatedOAuthClient & { |
| 165 | application_type: string; |
| 166 | token_endpoint_auth_method: string; |
| 167 | }; |
| 168 | expect(created.client_id).toBeTruthy(); |
| 169 | expect(created.application_type).toBe("native"); |
| 170 | expect(created.token_endpoint_auth_method).toBe("client_secret_basic"); |
| 171 | expect(created.client_secret).toBeTruthy(); |
| 172 | }); |
| 173 | |
| 174 | it("rejects localhost subdomain redirects under the OAuth 1.7 redirect policy", async () => { |
| 175 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.3"); |
| 176 | const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 177 | method: "POST", |
| 178 | headers: testHeaders({ cookie }), |
| 179 | body: JSON.stringify({ name: "invalid-loopback", redirectUris: ["http://acme.localhost:5176/cb"] }), |
| 180 | }); |
| 181 | expect(res.status).toBe(400); |
| 182 | expect(((await res.json()) as JsonErrorBody).error).toBe("invalid_redirect_uri"); |
| 183 | }); |
| 184 | |
| 185 | // The four raw OAuth client mutator routes are 404'd in |
| 186 | // src/worker/index.ts so OAuth client management can only happen |
| 187 | // through tessera's /api/admin/clients wrapper, which runs token |
| 188 | // cleanup on rotation/delete and emits structured logs. |
| 189 | // |
| 190 | // Trailing-slash variants must also 404: Better Auth's router |
| 191 | // normalizes them back to the same plugin endpoint, so an exact-match |
| 192 | // stub would leak through. The middleware strips trailing slashes |
| 193 | // before checking the blocked set. |
| 194 | it("blocks raw POST /api/auth/oauth2/{create,update,delete,rotate-secret} with 404 for all variants", async () => { |
| 195 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1"); |
| 196 | const paths = [ |
| 197 | "/api/auth/oauth2/create-client", |
| 198 | "/api/auth/oauth2/update-client", |
| 199 | "/api/auth/oauth2/delete-client", |
| 200 | "/api/auth/oauth2/client/rotate-secret", |
| 201 | ]; |
| 202 | for (const path of paths) { |
| 203 | for (const variant of [path, `${path}/`, `${path}//`]) { |
| 204 | const res = await SELF.fetch(`${ISSUER}${variant}`, { |
| 205 | method: "POST", |
| 206 | headers: testHeaders({ cookie }), |
| 207 | body: JSON.stringify({ |
| 208 | client_id: "any", |
| 209 | client_name: "any", |
| 210 | redirect_uris: [DEFAULT_REDIRECT_URI], |
| 211 | scope: "openid", |
| 212 | token_endpoint_auth_method: "client_secret_basic", |
| 213 | }), |
| 214 | }); |
| 215 | expect(res.status, `expected 404 for ${variant}`).toBe(404); |
| 216 | } |
| 217 | } |
| 218 | }); |
| 219 | |
| 220 | // Rotation revokes tokens: an admin rotating a leaked client_secret |
| 221 | // expects the kill-switch to extend to access/refresh tokens already |
| 222 | // issued under the old secret. Mirrors handleBanUser's batch-delete |
| 223 | // shape; see src/worker/api/admin/clients.ts handleRotateClientSecret. |
| 224 | it("revokes existing access/refresh tokens for the rotated client only", async () => { |
| 225 | const cookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1"); |
| 226 | |
| 227 | // Create the client we'll rotate. |
| 228 | const targetRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 229 | method: "POST", |
| 230 | headers: testHeaders({ cookie }), |
| 231 | body: JSON.stringify({ name: "rotate-target", redirectUris: [DEFAULT_REDIRECT_URI] }), |
| 232 | }); |
| 233 | expect(targetRes.status).toBe(201); |
| 234 | const target = (await targetRes.json()) as CreatedOAuthClient; |
| 235 | |
| 236 | // Create a control client whose tokens MUST survive the rotation. |
| 237 | const controlRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 238 | method: "POST", |
| 239 | headers: testHeaders({ cookie }), |
| 240 | body: JSON.stringify({ name: "rotate-control", redirectUris: [DEFAULT_REDIRECT_URI] }), |
| 241 | }); |
| 242 | expect(controlRes.status).toBe(201); |
| 243 | const control = (await controlRes.json()) as CreatedOAuthClient; |
| 244 | |
| 245 | // Look up admin's user id so the FK on userId is satisfied. |
| 246 | const userId = await getUserIdByEmail(adminCred.email); |
| 247 | |
| 248 | // Seed access + refresh tokens for both clients so we can confirm |
| 249 | // per-client deletion. Refresh tokens carry session_id (FK to |
| 250 | // sessions); access tokens carry refresh_id pointing at the |
| 251 | // refresh row. Inline minimal seed values that satisfy the schema. |
| 252 | const sessionId = await getLatestSessionIdForUser(userId); |
| 253 | |
| 254 | const now = Date.now(); |
| 255 | const expiresAt = now + 60_000; |
| 256 | const scopes = JSON.stringify(["openid"]); |
| 257 | const seed = async (clientId: string, tag: string) => { |
| 258 | const refreshId = `rt-${tag}-${clientId}`; |
| 259 | const accessId = `at-${tag}-${clientId}`; |
| 260 | await env.DB.batch([ |
| 261 | env.DB.prepare( |
| 262 | `INSERT INTO oauth_refresh_tokens (id, token, client_id, session_id, user_id, expires_at, created_at, scopes) |
| 263 | VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, |
| 264 | ).bind(refreshId, `tok-${refreshId}`, clientId, sessionId, userId, expiresAt, now, scopes), |
| 265 | env.DB.prepare( |
| 266 | `INSERT INTO oauth_access_tokens (id, token, client_id, refresh_id, session_id, user_id, expires_at, created_at, scopes) |
| 267 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
| 268 | ).bind(accessId, `tok-${accessId}`, clientId, refreshId, sessionId, userId, expiresAt, now, scopes), |
| 269 | ]); |
| 270 | }; |
| 271 | await seed(target.client_id, "target"); |
| 272 | await seed(control.client_id, "control"); |
| 273 | |
| 274 | // Sanity-check the seeds before rotation. |
| 275 | expect(await countOAuthTokensForClient("oauth_access_tokens", target.client_id)).toBe(1); |
| 276 | expect(await countOAuthTokensForClient("oauth_refresh_tokens", target.client_id)).toBe(1); |
| 277 | expect(await countOAuthTokensForClient("oauth_access_tokens", control.client_id)).toBe(1); |
| 278 | expect(await countOAuthTokensForClient("oauth_refresh_tokens", control.client_id)).toBe(1); |
| 279 | |
| 280 | const rotateRes = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(target.client_id)}/rotate`, { |
| 281 | method: "POST", |
| 282 | headers: testHeaders({ cookie }), |
| 283 | }); |
| 284 | expect(rotateRes.status).toBe(200); |
| 285 | const rotated = (await rotateRes.json()) as { client_secret?: string }; |
| 286 | expect(rotated.client_secret).toBeTruthy(); |
| 287 | |
| 288 | expect(await countOAuthTokensForClient("oauth_access_tokens", target.client_id)).toBe(0); |
| 289 | expect(await countOAuthTokensForClient("oauth_refresh_tokens", target.client_id)).toBe(0); |
| 290 | // Control client's tokens MUST be untouched. |
| 291 | expect(await countOAuthTokensForClient("oauth_access_tokens", control.client_id)).toBe(1); |
| 292 | expect(await countOAuthTokensForClient("oauth_refresh_tokens", control.client_id)).toBe(1); |
| 293 | }, 30_000); |
| 294 | |
| 295 | // Multi-role admin: Better Auth's setRole accepts arrays and stores |
| 296 | // them as `role = "admin,user"` (parseRoles in admin/routes.mjs). |
| 297 | // tessera's role gating must accept membership, not scalar equality. |
| 298 | it("accepts a multi-role admin for requireAdmin and clientPrivileges", async () => { |
| 299 | const multiCred = { |
| 300 | email: "multi-role-admin@example.com", |
| 301 | password: DEFAULT_PASSWORD, |
| 302 | name: "Multi Role", |
| 303 | } satisfies TestCredential; |
| 304 | await signUpTestUser(multiCred); |
| 305 | // Stamp the comma-joined role directly; reaching this state via the |
| 306 | // admin/set-role endpoint is what the upstream plugin does when |
| 307 | // setRole receives an array. |
| 308 | await promoteUserToAdmin(multiCred.email); |
| 309 | await env.DB.prepare("UPDATE users SET role = ? WHERE email = ?").bind("admin,user", multiCred.email).run(); |
| 310 | |
| 311 | const cookie = await signInForCookie(multiCred.email, multiCred.password, "10.30.0.1"); |
| 312 | |
| 313 | // requireAdmin path. |
| 314 | const listRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie } }); |
| 315 | expect(listRes.status).toBe(200); |
| 316 | |
| 317 | // clientPrivileges path via the tessera wrapper. The raw |
| 318 | // /oauth2/create-client endpoint is 404'd; the wrapper calls |
| 319 | // auth.api.createOAuthClient internally, which still routes through |
| 320 | // the same clientPrivileges predicate the array-role normalization |
| 321 | // protects. |
| 322 | const createRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 323 | method: "POST", |
| 324 | headers: testHeaders({ cookie }), |
| 325 | body: JSON.stringify({ |
| 326 | name: "multi-role-admin-client", |
| 327 | redirectUris: [DEFAULT_REDIRECT_URI], |
| 328 | }), |
| 329 | }); |
| 330 | expect(createRes.status).toBe(201); |
| 331 | }, 30_000); |
| 332 | |
| 333 | // Multi-admin namespace: a client created by admin A must be visible |
| 334 | // to admin B. With clientReference set to a constant, Better Auth's |
| 335 | // per-row ownership check takes the referenceId branch instead of |
| 336 | // falling back to userId equality. |
| 337 | it("lets a second admin list and rotate a client created by the first admin", async () => { |
| 338 | const cookieA = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.1"); |
| 339 | const createRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 340 | method: "POST", |
| 341 | headers: testHeaders({ cookie: cookieA }), |
| 342 | body: JSON.stringify({ |
| 343 | name: "admin-A-client", |
| 344 | redirectUris: [DEFAULT_REDIRECT_URI], |
| 345 | }), |
| 346 | }); |
| 347 | expect(createRes.status).toBe(201); |
| 348 | const created = (await createRes.json()) as CreatedOAuthClient; |
| 349 | |
| 350 | // Create a second admin and sign them in. |
| 351 | const adminBCred = { |
| 352 | email: "admin-clients-b@example.com", |
| 353 | password: DEFAULT_PASSWORD, |
| 354 | name: "Admin B", |
| 355 | } satisfies TestCredential; |
| 356 | await signUpAdmin(adminBCred); |
| 357 | const cookieB = await signInForCookie(adminBCred.email, adminBCred.password, "10.30.0.1"); |
| 358 | |
| 359 | const listRes = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie: cookieB } }); |
| 360 | expect(listRes.status).toBe(200); |
| 361 | const list = (await listRes.json()) as Array<{ client_id: string }>; |
| 362 | expect(list.some((c) => c.client_id === created.client_id)).toBe(true); |
| 363 | |
| 364 | const rotateRes = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}/rotate`, { |
| 365 | method: "POST", |
| 366 | headers: testHeaders({ cookie: cookieB }), |
| 367 | }); |
| 368 | expect(rotateRes.status).toBe(200); |
| 369 | }, 30_000); |
| 370 | |
| 371 | describe("PATCH /api/admin/clients/:id", () => { |
| 372 | interface ListedClient { |
| 373 | client_id: string; |
| 374 | client_name?: string | null; |
| 375 | client_uri?: string | null; |
| 376 | skip_consent?: boolean; |
| 377 | redirect_uris?: string[] | null; |
| 378 | } |
| 379 | |
| 380 | let adminCookie = ""; |
| 381 | |
| 382 | // Sign in once; per-test sign-ins would burn through RL_AUTH's |
| 383 | // 10-per-minute budget for this IP across the suite. |
| 384 | beforeAll(async () => { |
| 385 | adminCookie = await signInForCookie(adminCred.email, adminCred.password, "10.30.0.5"); |
| 386 | }); |
| 387 | |
| 388 | const fetchClient = async (clientId: string): Promise<ListedClient | undefined> => { |
| 389 | const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { headers: { cookie: adminCookie } }); |
| 390 | const list = (await res.json()) as ListedClient[]; |
| 391 | return list.find((c) => c.client_id === clientId); |
| 392 | }; |
| 393 | |
| 394 | const createTargetClient = async (name: string): Promise<CreatedOAuthClient> => { |
| 395 | const res = await SELF.fetch(`${ISSUER}/api/admin/clients`, { |
| 396 | method: "POST", |
| 397 | headers: testHeaders({ cookie: adminCookie }), |
| 398 | body: JSON.stringify({ name, redirectUris: [DEFAULT_REDIRECT_URI] }), |
| 399 | }); |
| 400 | expect(res.status).toBe(201); |
| 401 | return (await res.json()) as CreatedOAuthClient; |
| 402 | }; |
| 403 | |
| 404 | it("rejects unauthenticated PATCH with 401", async () => { |
| 405 | const res = await SELF.fetch(`${ISSUER}/api/admin/clients/anything`, { |
| 406 | method: "PATCH", |
| 407 | headers: testHeaders(), |
| 408 | body: JSON.stringify({ name: "changed" }), |
| 409 | }); |
| 410 | expect(res.status).toBe(401); |
| 411 | }); |
| 412 | |
| 413 | it("rejects non-admin PATCH with 403", async () => { |
| 414 | const userCookie = await signInForCookie(userCred.email, userCred.password, "10.30.0.6"); |
| 415 | const res = await SELF.fetch(`${ISSUER}/api/admin/clients/anything`, { |
| 416 | method: "PATCH", |
| 417 | headers: testHeaders({ cookie: userCookie }), |
| 418 | body: JSON.stringify({ name: "changed" }), |
| 419 | }); |
| 420 | expect(res.status).toBe(403); |
| 421 | }); |
| 422 | |
| 423 | it("updates client_name in isolation", async () => { |
| 424 | const created = await createTargetClient("patch-name-target"); |
| 425 | const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 426 | method: "PATCH", |
| 427 | headers: testHeaders({ cookie: adminCookie }), |
| 428 | body: JSON.stringify({ name: "patch-name-renamed" }), |
| 429 | }); |
| 430 | expect(patch.status).toBe(200); |
| 431 | const after = await fetchClient(created.client_id); |
| 432 | expect(after?.client_name).toBe("patch-name-renamed"); |
| 433 | expect(after?.skip_consent).not.toBe(true); |
| 434 | }); |
| 435 | |
| 436 | it("updates skipConsent in isolation", async () => { |
| 437 | const created = await createTargetClient("patch-skip-target"); |
| 438 | const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 439 | method: "PATCH", |
| 440 | headers: testHeaders({ cookie: adminCookie }), |
| 441 | body: JSON.stringify({ skipConsent: true }), |
| 442 | }); |
| 443 | expect(patch.status).toBe(200); |
| 444 | const after = await fetchClient(created.client_id); |
| 445 | expect(after?.skip_consent).toBe(true); |
| 446 | expect(after?.client_name).toBe("patch-skip-target"); |
| 447 | }); |
| 448 | |
| 449 | it("ignores arbitrary fields (only name, skipConsent, uri are honored)", async () => { |
| 450 | const created = await createTargetClient("patch-strict"); |
| 451 | const otherRedirect = "http://127.0.0.1:0/other"; |
| 452 | const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 453 | method: "PATCH", |
| 454 | headers: testHeaders({ cookie: adminCookie }), |
| 455 | body: JSON.stringify({ |
| 456 | name: "patch-strict-renamed", |
| 457 | redirect_uris: [otherRedirect], |
| 458 | redirectUris: [otherRedirect], |
| 459 | metadata: { launcher: { lucide: "Hammer" } }, |
| 460 | grant_types: ["client_credentials"], |
| 461 | }), |
| 462 | }); |
| 463 | expect(patch.status).toBe(200); |
| 464 | const after = await fetchClient(created.client_id); |
| 465 | expect(after?.client_name).toBe("patch-strict-renamed"); |
| 466 | // redirect_uris must not have been replaced. |
| 467 | expect(after?.redirect_uris).toEqual([DEFAULT_REDIRECT_URI]); |
| 468 | }); |
| 469 | |
| 470 | it("rejects empty PATCH body with 400", async () => { |
| 471 | const created = await createTargetClient("patch-empty-target"); |
| 472 | const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 473 | method: "PATCH", |
| 474 | headers: testHeaders({ cookie: adminCookie }), |
| 475 | body: JSON.stringify({}), |
| 476 | }); |
| 477 | expect(patch.status).toBe(400); |
| 478 | const body = (await patch.json()) as JsonErrorBody; |
| 479 | expect(body.error).toBe("invalid_body"); |
| 480 | }); |
| 481 | |
| 482 | it("rejects non-boolean skipConsent with 400", async () => { |
| 483 | const created = await createTargetClient("patch-bad-skip"); |
| 484 | const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 485 | method: "PATCH", |
| 486 | headers: testHeaders({ cookie: adminCookie }), |
| 487 | body: JSON.stringify({ skipConsent: "yes" }), |
| 488 | }); |
| 489 | expect(patch.status).toBe(400); |
| 490 | const body = (await patch.json()) as JsonErrorBody; |
| 491 | expect(body.error).toBe("invalid_skip_consent"); |
| 492 | }); |
| 493 | |
| 494 | it("updates uri in isolation", async () => { |
| 495 | const created = await createTargetClient("patch-uri-target"); |
| 496 | const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 497 | method: "PATCH", |
| 498 | headers: testHeaders({ cookie: adminCookie }), |
| 499 | body: JSON.stringify({ uri: "https://anvil.limic.dev" }), |
| 500 | }); |
| 501 | expect(patch.status).toBe(200); |
| 502 | const after = await fetchClient(created.client_id); |
| 503 | expect(after?.client_uri).toBe("https://anvil.limic.dev"); |
| 504 | expect(after?.client_name).toBe("patch-uri-target"); |
| 505 | }); |
| 506 | |
| 507 | it("clears uri when sent as empty string", async () => { |
| 508 | const created = await createTargetClient("patch-uri-clear"); |
| 509 | const set = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 510 | method: "PATCH", |
| 511 | headers: testHeaders({ cookie: adminCookie }), |
| 512 | body: JSON.stringify({ uri: "https://anvil.limic.dev" }), |
| 513 | }); |
| 514 | expect(set.status).toBe(200); |
| 515 | const cleared = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 516 | method: "PATCH", |
| 517 | headers: testHeaders({ cookie: adminCookie }), |
| 518 | body: JSON.stringify({ uri: " " }), |
| 519 | }); |
| 520 | expect(cleared.status).toBe(200); |
| 521 | const after = await fetchClient(created.client_id); |
| 522 | // Stored as empty string (Better Auth's PATCH schema rejects null); |
| 523 | // consumers gate on truthiness, so absent and "" are equivalent. |
| 524 | expect(after?.client_uri ?? "").toBe(""); |
| 525 | }); |
| 526 | |
| 527 | it("rejects non-loopback http uri with 400", async () => { |
| 528 | const created = await createTargetClient("patch-uri-http"); |
| 529 | const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 530 | method: "PATCH", |
| 531 | headers: testHeaders({ cookie: adminCookie }), |
| 532 | body: JSON.stringify({ uri: "http://anvil.limic.dev" }), |
| 533 | }); |
| 534 | expect(patch.status).toBe(400); |
| 535 | const body = (await patch.json()) as JsonErrorBody; |
| 536 | expect(body.error).toBe("invalid_uri"); |
| 537 | }); |
| 538 | |
| 539 | it("rejects javascript: uri with 400", async () => { |
| 540 | const created = await createTargetClient("patch-uri-js"); |
| 541 | const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 542 | method: "PATCH", |
| 543 | headers: testHeaders({ cookie: adminCookie }), |
| 544 | body: JSON.stringify({ uri: "javascript:alert(1)" }), |
| 545 | }); |
| 546 | expect(patch.status).toBe(400); |
| 547 | const body = (await patch.json()) as JsonErrorBody; |
| 548 | expect(body.error).toBe("invalid_uri"); |
| 549 | }); |
| 550 | |
| 551 | it("rejects non-string uri with 400", async () => { |
| 552 | const created = await createTargetClient("patch-uri-non-string"); |
| 553 | const patch = await SELF.fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 554 | method: "PATCH", |
| 555 | headers: testHeaders({ cookie: adminCookie }), |
| 556 | body: JSON.stringify({ uri: 42 }), |
| 557 | }); |
| 558 | expect(patch.status).toBe(400); |
| 559 | const body = (await patch.json()) as JsonErrorBody; |
| 560 | expect(body.error).toBe("invalid_uri"); |
| 561 | }); |
| 562 | }); |
| 563 | }); |