From 3e1ef67f0b188899b6f36215160cab69a05e1087 Mon Sep 17 00:00:00 2001 From: Matt Brooker Date: Wed, 29 Jul 2026 17:49:57 -0400 Subject: [PATCH 1/2] feat(oauth): re-land explicit scopes now that Code ceilings are seeded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request the live scopes_supported set plus llm_gateway:read instead of "*", and bump OAUTH_SCOPE_VERSION 5 → 6 so installs re-authorize. The Jul 22 revert (#3668) was required because #3411 shipped the explicit list before US/EU OAuth app ceilings were seeded. Both Code apps are now seeded to [@default, llm_gateway:read] and *→ceiling narrowing is live, so the client change is unblocked. Regenerated from us.posthog.com scopes_supported (203 scopes, includes loop:* and post-#3411 additions); drop agents:* which left the advertised set. Comment documents the seed-before-release guardrail so it doesn't get bundled again without the ceiling. --- .../desktop/packages/shared/src/oauth.test.ts | 31 ++- products/desktop/packages/shared/src/oauth.ts | 241 +++++++++++++++++- 2 files changed, 261 insertions(+), 11 deletions(-) diff --git a/products/desktop/packages/shared/src/oauth.test.ts b/products/desktop/packages/shared/src/oauth.test.ts index 08ec07c368e3..a3d3de2721e8 100644 --- a/products/desktop/packages/shared/src/oauth.test.ts +++ b/products/desktop/packages/shared/src/oauth.test.ts @@ -20,14 +20,35 @@ describe("OAUTH_SCOPES guard", () => { fingerprint, }).toMatchInlineSnapshot(` { - "fingerprint": 42, - "scopeCount": 1, - "scopeVersion": 6, + "fingerprint": -1740465438, + "scopeCount": 212, + "scopeVersion": 7, } `); }); - it("requests the grandfathered wildcard grant", () => { - expect(OAUTH_SCOPES).toEqual(["*"]); + // Structural guards that catch drift cheaply, independent of the exact list. + // OAUTH_SCOPES must mirror OAUTH_SCOPES_SUPPORTED in the API's generated + // services/mcp/src/lib/oauth-scopes.generated.ts (plus llm_gateway:read); this + // repo can't assert cross-repo equality, so these check the shape invariants. + it("contains no duplicate scopes", () => { + expect(OAUTH_SCOPES.length).toBe(new Set(OAUTH_SCOPES).size); + }); + + it("includes the privileged llm_gateway:read scope exactly once, kept last", () => { + expect( + OAUTH_SCOPES.filter((scope) => scope === "llm_gateway:read"), + ).toHaveLength(1); + expect(OAUTH_SCOPES.at(-1)).toBe("llm_gateway:read"); + }); + + it("only contains well-formed scope strings", () => { + const openidConnectScopes = new Set(["openid", "profile", "email"]); + const malformed = OAUTH_SCOPES.filter( + (scope) => + !openidConnectScopes.has(scope) && + !/^[a-z_]+:(read|write)$/.test(scope), + ); + expect(malformed).toEqual([]); }); }); diff --git a/products/desktop/packages/shared/src/oauth.ts b/products/desktop/packages/shared/src/oauth.ts index 55d6562c6aac..78785aa4afea 100644 --- a/products/desktop/packages/shared/src/oauth.ts +++ b/products/desktop/packages/shared/src/oauth.ts @@ -4,11 +4,239 @@ export const POSTHOG_US_CLIENT_ID = "HCWoE0aRFMYxIxFNTTwkOORn5LBjOt2GVDzwSw5W"; export const POSTHOG_EU_CLIENT_ID = "AIvijgMS0dxKEmr5z6odvRd8Pkh5vts3nPTzgzU9"; export const POSTHOG_DEV_CLIENT_ID = "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ"; -// Wildcard, not the explicit scope list: the prod OAuth apps have no seeded scope ceiling, -// so /oauth/authorize rejects the privileged llm_gateway:read with invalid_scope while "*" -// is grandfathered. Re-land the explicit list only after the US and EU app ceilings are -// seeded with ["@default", "llm_gateway:read"]. Bump OAUTH_SCOPE_VERSION on any change. -export const OAUTH_SCOPES = ["*"]; +// Explicit scopes instead of "*". Mirrors scopes_supported at +// /.well-known/oauth-authorization-server (OAUTH_SCOPES_SUPPORTED in the API's +// services/mcp/src/lib/oauth-scopes.generated.ts), plus privileged llm_gateway:read +// which the advertised set excludes. The LLM gateway requires that scope; it is +// granted only because this app's OAuth ceiling is seeded to +// ["@default", "llm_gateway:read"] in US + EU. +// +// Deploy-order guardrail: NEVER ship a non-"*" OAUTH_SCOPES set (or bump +// OAUTH_SCOPE_VERSION off a build that still requests "*") until that ceiling is +// seeded in both regions. A non-empty ceiling rejects scope=* at /authorize, and +// an empty/unseeded ceiling rejects privileged llm_gateway:read. #3411 shipped +// the explicit list bundled with loops without seeding; prod login broke and was +// reverted in #3668. Keep this comment when regenerating the list. +// +// Regenerate from the live advertised list + llm_gateway:read last; bump +// OAUTH_SCOPE_VERSION whenever the set changes so installs re-authorize. +export const OAUTH_SCOPES = [ + "openid", + "profile", + "email", + "action:read", + "action:write", + "access_control:read", + "access_control:write", + "account:read", + "account:write", + "activity_log:read", + "activity_log:write", + "alert:read", + "alert:write", + "annotation:read", + "annotation:write", + "approvals:read", + "approvals:write", + "batch_export:read", + "batch_export:write", + "batch_import:read", + "batch_import:write", + "business_knowledge:read", + "business_knowledge:write", + "canvas:read", + "canvas:write", + "cohort:read", + "cohort:write", + "comment:read", + "comment:write", + "conversation:read", + "conversation:write", + "customer_analytics:read", + "customer_analytics:write", + "customer_journey:read", + "customer_journey:write", + "customer_profile_config:read", + "customer_profile_config:write", + "data_catalog:read", + "data_catalog:write", + "data_catalog_approval:read", + "data_catalog_approval:write", + "dashboard:read", + "dashboard:write", + "event_filter:read", + "event_filter:write", + "dashboard_template:read", + "dashboard_template:write", + "dataset:read", + "dataset:write", + "early_access_feature:read", + "early_access_feature:write", + "endpoint:read", + "endpoint:write", + "engineering_analytics:read", + "engineering_analytics:write", + "error_tracking:read", + "error_tracking:write", + "evaluation:read", + "evaluation:write", + "element:read", + "element:write", + "event_definition:read", + "event_definition:write", + "experiment:read", + "experiment:write", + "experiment_holdout:read", + "experiment_holdout:write", + "experiment_saved_metric:read", + "experiment_saved_metric:write", + "export:read", + "export:write", + "external_data_schema:read", + "external_data_schema:write", + "external_data_source:read", + "external_data_source:write", + "feature_flag:read", + "feature_flag:write", + "file_system:read", + "file_system:write", + "file_system_shortcut:read", + "file_system_shortcut:write", + "group:read", + "group:write", + "health_issue:read", + "health_issue:write", + "heatmap:read", + "heatmap:write", + "hog_flow:read", + "hog_flow:write", + "hog_function:read", + "hog_function:write", + "ingestion_warning:read", + "ingestion_warning:write", + "insight:read", + "insight:write", + "insight_variable:read", + "insight_variable:write", + "integration:read", + "integration:write", + "legal_document:read", + "legal_document:write", + "link:read", + "link:write", + "live_debugger:read", + "live_debugger:write", + "llm_analytics:read", + "llm_analytics:write", + "ai_observability_clusters:read", + "ai_observability_clusters:write", + "llm_playground:read", + "llm_playground:write", + "llm_prompt:read", + "llm_prompt:write", + "llm_provider_key:read", + "llm_provider_key:write", + "llm_skill:read", + "llm_skill:write", + "logs:read", + "logs:write", + "loop:read", + "loop:write", + "marketing_analytics:read", + "marketing_analytics:write", + "mcp_analytics:read", + "mcp_analytics:write", + "metrics:read", + "metrics:write", + "notebook:read", + "notebook:write", + "organization:read", + "organization:write", + "organization_integration:read", + "organization_integration:write", + "organization_member:read", + "organization_member:write", + "person:read", + "person:write", + "plugin:read", + "plugin:write", + "product_enablement:read", + "product_enablement:write", + "product_tour:read", + "product_tour:write", + "project:read", + "project:write", + "property_definition:read", + "property_definition:write", + "query:read", + "query:write", + "replay_scanner:read", + "replay_scanner:write", + "review_hog:read", + "review_hog:write", + "revenue_analytics:read", + "revenue_analytics:write", + "session_recording:read", + "session_recording:write", + "session_recording_playlist:read", + "session_recording_playlist:write", + "sharing_configuration:read", + "sharing_configuration:write", + "signal_scout:read", + "signal_scout:write", + "stamphog:read", + "stamphog:write", + "streamlit_app:read", + "streamlit_app:write", + "subscription:read", + "subscription:write", + "survey:read", + "survey:write", + "tagger:read", + "tagger:write", + "ticket:read", + "ticket:write", + "task:read", + "task:write", + "toolbar:read", + "toolbar:write", + "tracing:read", + "tracing:write", + "field_note:read", + "field_note:write", + "uploaded_media:read", + "uploaded_media:write", + "usage_metric:read", + "usage_metric:write", + "user:read", + "user:write", + "user_interview:read", + "user_interview:write", + "vision_action:read", + "vision_action:write", + "visual_review:read", + "visual_review:write", + "warehouse_objects:read", + "warehouse_objects:write", + "warehouse_table:read", + "warehouse_table:write", + "warehouse_view:read", + "warehouse_view:write", + "web_analytics:read", + "web_analytics:write", + "webhook:read", + "webhook:write", + "batch_import_support:read", + "batch_import_support:write", + "query_performance:read", + "query_performance:write", + "wizard_session:read", + "wizard_session:write", + // Privileged: embedded agent model calls go through PostHog's LLM gateway + // (gateway.{region}.posthog.com), which requires this scope. Not in the + // advertised set above; granted via this app's seeded ceiling. + "llm_gateway:read", +]; // v6: the server grew the `canvas` scope (PostHog/posthog#73874). On apps with a seeded // scope ceiling, /oauth/authorize narrows a `*` request to the ceiling ENUMERATED AT GRANT @@ -16,7 +244,8 @@ export const OAUTH_SCOPES = ["*"]; // /canvases/ forever. Bumping forces one re-auth, whose fresh grant includes it. The same // applies to every future server-side scope addition the app relies on, even when // OAUTH_SCOPES itself is unchanged. -export const OAUTH_SCOPE_VERSION = 6; +// v7: "*" replaced with the explicit list above. +export const OAUTH_SCOPE_VERSION = 7; // Token refresh settings export const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000; // 30 minutes before expiry From a432d2899cfd90fa3cbb18f7d68a26c6b7d86c04 Mon Sep 17 00:00:00 2001 From: Matt Brooker Date: Tue, 4 Aug 2026 08:40:58 -0400 Subject: [PATCH 2/2] fix(desktop): drop staff-only hidden scopes from OAUTH_SCOPES batch_import_support, query_performance, and wizard_session are OAUTH_SCOPES_HIDDEN server-side: subtracted out of UNPRIVILEGED_SCOPES, so the app's "@default" ceiling can never cover them. /authorize treats an explicit scope list as all-or-nothing, so requesting them would fail every sign-in with invalid_scope. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CAPcQaAZNzcWNhCh1nyBPi --- products/desktop/packages/shared/src/oauth.test.ts | 4 ++-- products/desktop/packages/shared/src/oauth.ts | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/products/desktop/packages/shared/src/oauth.test.ts b/products/desktop/packages/shared/src/oauth.test.ts index a3d3de2721e8..b52f0d146fef 100644 --- a/products/desktop/packages/shared/src/oauth.test.ts +++ b/products/desktop/packages/shared/src/oauth.test.ts @@ -20,8 +20,8 @@ describe("OAUTH_SCOPES guard", () => { fingerprint, }).toMatchInlineSnapshot(` { - "fingerprint": -1740465438, - "scopeCount": 212, + "fingerprint": -237404099, + "scopeCount": 206, "scopeVersion": 7, } `); diff --git a/products/desktop/packages/shared/src/oauth.ts b/products/desktop/packages/shared/src/oauth.ts index 78785aa4afea..5dbcc1ea968d 100644 --- a/products/desktop/packages/shared/src/oauth.ts +++ b/products/desktop/packages/shared/src/oauth.ts @@ -11,6 +11,11 @@ export const POSTHOG_DEV_CLIENT_ID = "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ"; // granted only because this app's OAuth ceiling is seeded to // ["@default", "llm_gateway:read"] in US + EU. // +// That generated file also exports OAUTH_SCOPES_HIDDEN (batch_import_support, +// query_performance, wizard_session). Never copy those in: they are staff-only +// and subtracted out of UNPRIVILEGED_SCOPES, so "@default" can never cover them +// and /authorize rejects the whole request with invalid_scope. +// // Deploy-order guardrail: NEVER ship a non-"*" OAUTH_SCOPES set (or bump // OAUTH_SCOPE_VERSION off a build that still requests "*") until that ceiling is // seeded in both regions. A non-empty ceiling rejects scope=* at /authorize, and @@ -226,12 +231,6 @@ export const OAUTH_SCOPES = [ "web_analytics:write", "webhook:read", "webhook:write", - "batch_import_support:read", - "batch_import_support:write", - "query_performance:read", - "query_performance:write", - "wizard_session:read", - "wizard_session:write", // Privileged: embedded agent model calls go through PostHog's LLM gateway // (gateway.{region}.posthog.com), which requires this scope. Not in the // advertised set above; granted via this app's seeded ceiling.