Skip to content

Commit 12a424b

Browse files
committed
feat(oauth): request explicit scopes instead of * on sign-in
1 parent aebc65a commit 12a424b

2 files changed

Lines changed: 253 additions & 9 deletions

File tree

packages/shared/src/oauth.test.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,53 @@ import { describe, expect, it } from "vitest";
22
import { OAUTH_SCOPE_VERSION, OAUTH_SCOPES } from "./oauth";
33

44
describe("OAUTH_SCOPES guard", () => {
5-
it("snapshot breaks when scopes change — bump OAUTH_SCOPE_VERSION if this fails", () => {
5+
// Fingerprint instead of snapshotting the whole list: any add, removal, or reorder of
6+
// OAUTH_SCOPES changes the count or fingerprint and fails this test. When it fails, bump
7+
// OAUTH_SCOPE_VERSION (and update the expected values) so existing installs are forced to
8+
// re-authorize with the new set.
9+
it("fails when OAUTH_SCOPES changes — bump OAUTH_SCOPE_VERSION", () => {
10+
const fingerprint = OAUTH_SCOPES.reduce((hash, scope) => {
11+
for (let i = 0; i < scope.length; i++) {
12+
hash = (Math.imul(31, hash) + scope.charCodeAt(i)) | 0;
13+
}
14+
return hash;
15+
}, 0);
16+
617
expect({
718
scopeVersion: OAUTH_SCOPE_VERSION,
8-
scopes: OAUTH_SCOPES,
19+
scopeCount: OAUTH_SCOPES.length,
20+
fingerprint,
921
}).toMatchInlineSnapshot(`
1022
{
11-
"scopeVersion": 5,
12-
"scopes": [
13-
"*",
14-
],
23+
"fingerprint": -1043925073,
24+
"scopeCount": 198,
25+
"scopeVersion": 6,
1526
}
1627
`);
1728
});
29+
30+
// Structural guards that catch drift cheaply, independent of the exact list.
31+
// OAUTH_SCOPES must mirror OAUTH_SCOPES_SUPPORTED in the API's generated
32+
// services/mcp/src/lib/oauth-scopes.generated.ts (plus llm_gateway:read); this
33+
// repo can't assert cross-repo equality, so these check the shape invariants.
34+
it("contains no duplicate scopes", () => {
35+
expect(OAUTH_SCOPES.length).toBe(new Set(OAUTH_SCOPES).size);
36+
});
37+
38+
it("includes the privileged llm_gateway:read scope exactly once, kept last", () => {
39+
expect(
40+
OAUTH_SCOPES.filter((scope) => scope === "llm_gateway:read"),
41+
).toHaveLength(1);
42+
expect(OAUTH_SCOPES.at(-1)).toBe("llm_gateway:read");
43+
});
44+
45+
it("only contains well-formed scope strings", () => {
46+
const openidConnectScopes = new Set(["openid", "profile", "email"]);
47+
const malformed = OAUTH_SCOPES.filter(
48+
(scope) =>
49+
!openidConnectScopes.has(scope) &&
50+
!/^[a-z_]+:(read|write)$/.test(scope),
51+
);
52+
expect(malformed).toEqual([]);
53+
});
1854
});

packages/shared/src/oauth.ts

Lines changed: 211 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,218 @@ export const POSTHOG_US_CLIENT_ID = "HCWoE0aRFMYxIxFNTTwkOORn5LBjOt2GVDzwSw5W";
44
export const POSTHOG_EU_CLIENT_ID = "AIvijgMS0dxKEmr5z6odvRd8Pkh5vts3nPTzgzU9";
55
export const POSTHOG_DEV_CLIENT_ID = "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ";
66

7-
// Bump OAUTH_SCOPE_VERSION below whenever OAUTH_SCOPES changes to force re-authentication
8-
export const OAUTH_SCOPES = ["*"];
7+
// Mirrors the scopes PostHog advertises as grantable: OAUTH_SCOPES_SUPPORTED in the API's
8+
// services/mcp/src/lib/oauth-scopes.generated.ts, published as scopes_supported at
9+
// /.well-known/oauth-authorization-server, plus the one privileged scope (llm_gateway:read)
10+
// the LLM gateway requires, which the advertised set excludes. Requesting this explicit set
11+
// instead of "*" keeps the token least-privilege; the privileged extra is granted via this
12+
// app's seeded scope ceiling. Keep in sync with that generated list; bump OAUTH_SCOPE_VERSION
13+
// below whenever the set changes.
14+
export const OAUTH_SCOPES = [
15+
"access_control:read",
16+
"access_control:write",
17+
"account:read",
18+
"account:write",
19+
"action:read",
20+
"action:write",
21+
"activity_log:read",
22+
"activity_log:write",
23+
"agent_approvals:read",
24+
"agent_approvals:write",
25+
"agents:read",
26+
"agents:write",
27+
"alert:read",
28+
"alert:write",
29+
"annotation:read",
30+
"annotation:write",
31+
"approvals:read",
32+
"approvals:write",
33+
"batch_export:read",
34+
"batch_export:write",
35+
"batch_import:read",
36+
"batch_import:write",
37+
"business_knowledge:read",
38+
"business_knowledge:write",
39+
"cohort:read",
40+
"cohort:write",
41+
"comment:read",
42+
"comment:write",
43+
"conversation:read",
44+
"conversation:write",
45+
"customer_analytics:read",
46+
"customer_analytics:write",
47+
"customer_journey:read",
48+
"customer_journey:write",
49+
"customer_profile_config:read",
50+
"customer_profile_config:write",
51+
"dashboard:read",
52+
"dashboard:write",
53+
"dashboard_template:read",
54+
"dashboard_template:write",
55+
"data_catalog:read",
56+
"data_catalog:write",
57+
"data_catalog_approval:read",
58+
"data_catalog_approval:write",
59+
"dataset:read",
60+
"dataset:write",
61+
"early_access_feature:read",
62+
"early_access_feature:write",
63+
"element:read",
64+
"element:write",
65+
"email",
66+
"endpoint:read",
67+
"endpoint:write",
68+
"engineering_analytics:read",
69+
"engineering_analytics:write",
70+
"error_tracking:read",
71+
"error_tracking:write",
72+
"evaluation:read",
73+
"evaluation:write",
74+
"event_definition:read",
75+
"event_definition:write",
76+
"event_filter:read",
77+
"event_filter:write",
78+
"experiment:read",
79+
"experiment:write",
80+
"experiment_holdout:read",
81+
"experiment_holdout:write",
82+
"experiment_saved_metric:read",
83+
"experiment_saved_metric:write",
84+
"export:read",
85+
"export:write",
86+
"external_data_schema:read",
87+
"external_data_schema:write",
88+
"external_data_source:read",
89+
"external_data_source:write",
90+
"feature_flag:read",
91+
"feature_flag:write",
92+
"field_note:read",
93+
"field_note:write",
94+
"file_system:read",
95+
"file_system:write",
96+
"file_system_shortcut:read",
97+
"file_system_shortcut:write",
98+
"group:read",
99+
"group:write",
100+
"health_issue:read",
101+
"health_issue:write",
102+
"heatmap:read",
103+
"heatmap:write",
104+
"hog_flow:read",
105+
"hog_flow:write",
106+
"hog_function:read",
107+
"hog_function:write",
108+
"ingestion_warning:read",
109+
"ingestion_warning:write",
110+
"insight:read",
111+
"insight:write",
112+
"insight_variable:read",
113+
"insight_variable:write",
114+
"integration:read",
115+
"integration:write",
116+
"legal_document:read",
117+
"legal_document:write",
118+
"link:read",
119+
"link:write",
120+
"live_debugger:read",
121+
"live_debugger:write",
122+
"llm_analytics:read",
123+
"llm_analytics:write",
124+
"llm_prompt:read",
125+
"llm_prompt:write",
126+
"llm_provider_key:read",
127+
"llm_provider_key:write",
128+
"llm_skill:read",
129+
"llm_skill:write",
130+
"logs:read",
131+
"logs:write",
132+
"loop:read",
133+
"loop:write",
134+
"marketing_analytics:read",
135+
"marketing_analytics:write",
136+
"mcp_analytics:read",
137+
"mcp_analytics:write",
138+
"metrics:read",
139+
"metrics:write",
140+
"notebook:read",
141+
"notebook:write",
142+
"openid",
143+
"organization:read",
144+
"organization:write",
145+
"organization_integration:read",
146+
"organization_integration:write",
147+
"organization_member:read",
148+
"organization_member:write",
149+
"person:read",
150+
"person:write",
151+
"plugin:read",
152+
"plugin:write",
153+
"product_enablement:read",
154+
"product_enablement:write",
155+
"product_tour:read",
156+
"product_tour:write",
157+
"profile",
158+
"project:read",
159+
"project:write",
160+
"property_definition:read",
161+
"property_definition:write",
162+
"query:read",
163+
"query:write",
164+
"replay_scanner:read",
165+
"replay_scanner:write",
166+
"revenue_analytics:read",
167+
"revenue_analytics:write",
168+
"session_recording:read",
169+
"session_recording:write",
170+
"session_recording_playlist:read",
171+
"session_recording_playlist:write",
172+
"sharing_configuration:read",
173+
"sharing_configuration:write",
174+
"signal_scout:read",
175+
"signal_scout:write",
176+
"streamlit_app:read",
177+
"streamlit_app:write",
178+
"subscription:read",
179+
"subscription:write",
180+
"survey:read",
181+
"survey:write",
182+
"tagger:read",
183+
"tagger:write",
184+
"task:read",
185+
"task:write",
186+
"ticket:read",
187+
"ticket:write",
188+
"tracing:read",
189+
"tracing:write",
190+
"uploaded_media:read",
191+
"uploaded_media:write",
192+
"usage_metric:read",
193+
"usage_metric:write",
194+
"user:read",
195+
"user:write",
196+
"user_interview:read",
197+
"user_interview:write",
198+
"vision_action:read",
199+
"vision_action:write",
200+
"visual_review:read",
201+
"visual_review:write",
202+
"warehouse_objects:read",
203+
"warehouse_objects:write",
204+
"warehouse_table:read",
205+
"warehouse_table:write",
206+
"warehouse_view:read",
207+
"warehouse_view:write",
208+
"web_analytics:read",
209+
"web_analytics:write",
210+
"webhook:read",
211+
"webhook:write",
212+
// Privileged: the embedded agent's model calls go through PostHog's LLM gateway
213+
// (gateway.{region}.posthog.com), which requires this scope. Not in the advertised set
214+
// above; granted via this app's seeded ceiling. Without it the gateway 403s every call.
215+
"llm_gateway:read",
216+
];
9217

10-
export const OAUTH_SCOPE_VERSION = 5;
218+
export const OAUTH_SCOPE_VERSION = 6;
11219

12220
// Token refresh settings
13221
export const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000; // 30 minutes before expiry

0 commit comments

Comments
 (0)