Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions packages/shared/src/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
232 changes: 226 additions & 6 deletions packages/shared/src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading