diff --git a/packages/shared/src/oauth.test.ts b/packages/shared/src/oauth.test.ts index 5e73af4447..f230c1d601 100644 --- a/packages/shared/src/oauth.test.ts +++ b/packages/shared/src/oauth.test.ts @@ -20,14 +20,35 @@ describe("OAUTH_SCOPES guard", () => { fingerprint, }).toMatchInlineSnapshot(` { - "fingerprint": 42, - "scopeCount": 1, - "scopeVersion": 5, + "fingerprint": -950115820, + "scopeCount": 204, + "scopeVersion": 6, } `); }); - 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/packages/shared/src/oauth.ts b/packages/shared/src/oauth.ts index bda398cb77..37a1c0c753 100644 --- a/packages/shared/src/oauth.ts +++ b/packages/shared/src/oauth.ts @@ -4,13 +4,233 @@ 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", + "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", + // 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", +]; -export const OAUTH_SCOPE_VERSION = 5; +export const OAUTH_SCOPE_VERSION = 6; // Token refresh settings export const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000; // 30 minutes before expiry