Skip to content
File

Blob: tests/worker/connected-apps-block.test.ts

typescript141 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 countOAuthTokensForClient,
10 createOAuthClientAsAdmin,
11 getLatestSessionIdForUser,
12 getUserIdByEmail,
13 signInForCookie,
14 signUpAdmin,
15 signUpTestUser,
16 testHeaders,
17 type TestCredential,
18} from "./helpers";
19 
20const countConsents = async (clientId: string): Promise<number> => {
21 const row = await env.DB.prepare("SELECT COUNT(*) AS c FROM oauth_consents WHERE client_id = ?")
22 .bind(clientId)
23 .first<{ c: number }>();
24 return row?.c ?? 0;
25};
26 
27// User-facing revocation goes through tessera's
28// `DELETE /api/account/connected-apps/:id`, which deletes the consent
29// row plus matching access + refresh tokens in one D1 batch. The
30// plugin's raw `oauth2/{delete,update}-consent` endpoints touch only the
31// consent row, so a caller holding a session cookie could remove the UI
32// affordance for revocation while leaving previously-issued bearer
33// tokens valid until expiry. tessera blocks both raw paths.
34describe("Raw oauth2/delete-consent + oauth2/update-consent are blocked", () => {
35 const adminCred = {
36 email: "consent-block-admin@example.com",
37 password: DEFAULT_PASSWORD,
38 name: "Consent Block Admin",
39 } satisfies TestCredential;
40 const userCred = {
41 email: "consent-block-user@example.com",
42 password: DEFAULT_PASSWORD,
43 name: "Consent Block User",
44 } satisfies TestCredential;
45 let clientId: string;
46 let userId: string;
47 let consentId: string;
48 
49 beforeAll(async () => {
50 await signUpAdmin(adminCred);
51 await signUpTestUser(userCred);
52 
53 const adminCookie = await signInForCookie(adminCred.email, adminCred.password, "10.40.0.1");
54 const created = await createOAuthClientAsAdmin(adminCookie, {
55 name: "consent-block-target",
56 redirectUris: [DEFAULT_REDIRECT_URI],
57 });
58 clientId = created.client_id;
59 
60 userId = await getUserIdByEmail(userCred.email);
61 });
62 
63 beforeEach(async () => {
64 // Reset consent + tokens between tests so prior runs don't bleed
65 // through. consentId is stamped on each fresh insert to keep the
66 // raw endpoint targets unambiguous.
67 await env.DB.batch([
68 env.DB.prepare("DELETE FROM oauth_access_tokens WHERE client_id = ?").bind(clientId),
69 env.DB.prepare("DELETE FROM oauth_refresh_tokens WHERE client_id = ?").bind(clientId),
70 env.DB.prepare("DELETE FROM oauth_consents WHERE client_id = ?").bind(clientId),
71 ]);
72 
73 consentId = `consent-${crypto.randomUUID()}`;
74 const sessionId = await getLatestSessionIdForUser(userId);
75 
76 const now = Date.now();
77 const expiresAt = now + 60_000;
78 const scopes = JSON.stringify(["openid"]);
79 const refreshId = `rt-${consentId}`;
80 const accessId = `at-${consentId}`;
81 await env.DB.batch([
82 env.DB.prepare(
83 `INSERT INTO oauth_consents (id, client_id, user_id, scopes, created_at, updated_at)
84 VALUES (?, ?, ?, ?, ?, ?)`,
85 ).bind(consentId, clientId, userId, scopes, now, now),
86 env.DB.prepare(
87 `INSERT INTO oauth_refresh_tokens (id, token, client_id, session_id, user_id, expires_at, created_at, scopes)
88 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
89 ).bind(refreshId, `tok-${refreshId}`, clientId, sessionId, userId, expiresAt, now, scopes),
90 env.DB.prepare(
91 `INSERT INTO oauth_access_tokens (id, token, client_id, refresh_id, session_id, user_id, expires_at, created_at, scopes)
92 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
93 ).bind(accessId, `tok-${accessId}`, clientId, refreshId, sessionId, userId, expiresAt, now, scopes),
94 ]);
95 });
96 
97 it("blocks POST /api/auth/oauth2/delete-consent and leaves consent + tokens intact", async () => {
98 const cookie = await signInForCookie(userCred.email, userCred.password, "10.40.0.2");
99 const path = "/api/auth/oauth2/delete-consent";
100 for (const variant of [path, `${path}/`, `${path}//`]) {
101 const res = await SELF.fetch(`${ISSUER}${variant}`, {
102 method: "POST",
103 headers: testHeaders({ cookie }),
104 body: JSON.stringify({ id: consentId }),
105 });
106 expect(res.status, `expected 404 for ${variant}`).toBe(404);
107 }
108 expect(await countConsents(clientId)).toBe(1);
109 expect(await countOAuthTokensForClient("oauth_access_tokens", clientId)).toBe(1);
110 expect(await countOAuthTokensForClient("oauth_refresh_tokens", clientId)).toBe(1);
111 });
112 
113 it("blocks POST /api/auth/oauth2/update-consent and leaves consent + tokens intact", async () => {
114 const cookie = await signInForCookie(userCred.email, userCred.password, "10.40.0.3");
115 const path = "/api/auth/oauth2/update-consent";
116 for (const variant of [path, `${path}/`, `${path}//`]) {
117 const res = await SELF.fetch(`${ISSUER}${variant}`, {
118 method: "POST",
119 headers: testHeaders({ cookie }),
120 body: JSON.stringify({ id: consentId, update: { scopes: ["openid"] } }),
121 });
122 expect(res.status, `expected 404 for ${variant}`).toBe(404);
123 }
124 expect(await countConsents(clientId)).toBe(1);
125 expect(await countOAuthTokensForClient("oauth_access_tokens", clientId)).toBe(1);
126 expect(await countOAuthTokensForClient("oauth_refresh_tokens", clientId)).toBe(1);
127 });
128 
129 it("DELETE /api/account/connected-apps/:id still revokes via the supported path", async () => {
130 const cookie = await signInForCookie(userCred.email, userCred.password, "10.40.0.4");
131 const res = await SELF.fetch(`${ISSUER}/api/account/connected-apps/${encodeURIComponent(consentId)}`, {
132 method: "DELETE",
133 headers: testHeaders({ cookie }),
134 });
135 expect(res.status).toBe(204);
136 expect(await countConsents(clientId)).toBe(0);
137 expect(await countOAuthTokensForClient("oauth_access_tokens", clientId)).toBe(0);
138 expect(await countOAuthTokensForClient("oauth_refresh_tokens", clientId)).toBe(0);
139 });
140});