From 18d8c725aa8a980a8eb1ee177bce77ca458b6bd5 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Tue, 23 Jun 2026 10:46:23 -0700 Subject: [PATCH 1/2] fix(auth): Route org token requests to region_url Org auth tokens for multi-region SaaS orgs (e.g. us2, s4s2) embed a control-silo url (https://sentry.io) plus a region_url (https://.sentry.io) that serves the org's API requests. The CLI only parsed url and discarded region_url, so region-siloed requests such as snapshot uploads were sent to the control silo and rejected with 401 Invalid org token. Parse region_url from the token payload and prefer it as the base URL, falling back to url when absent. This routes all commands to the correct region automatically, with no need for a manual --url flag. Tokens where url equals region_url (self-hosted, classic SaaS) are unaffected. A token with an empty url but a present region_url now routes to region_url and overrides --url, where it previously let --url take precedence. --- src/config.rs | 6 +-- src/utils/auth_token/org_auth_token.rs | 17 +++++++ src/utils/auth_token/test.rs | 45 +++++++++++++++++++ .../org_tokens/region-url-routing.trycmd | 10 +++++ .../url-mismatch-empty-token.trycmd | 5 ++- 5 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 tests/integration/_cases/org_tokens/region-url-routing.trycmd diff --git a/src/config.rs b/src/config.rs index 30296962dd..52ecf847b1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -75,7 +75,7 @@ impl Config { let manually_configured_url = configured_url(&ini); let token_url = token_embedded_data .as_ref() - .map(|td| td.url.as_str()) + .map(|td| td.base_url()) .unwrap_or_default(); let url = if token_url.is_empty() { @@ -201,7 +201,7 @@ impl Config { Some(Auth::Token(ref val)) => { self.cached_token_data = val.payload().cloned(); - if let Some(token_url) = self.cached_token_data.as_ref().map(|td| td.url.as_str()) { + if let Some(token_url) = self.cached_token_data.as_ref().map(|td| td.base_url()) { self.cached_base_url = token_url.to_owned(); } @@ -232,7 +232,7 @@ impl Config { let token_url = self .cached_token_data .as_ref() - .map(|td| td.url.as_str()) + .map(|td| td.base_url()) .unwrap_or_default(); if !token_url.is_empty() && url != token_url { diff --git a/src/utils/auth_token/org_auth_token.rs b/src/utils/auth_token/org_auth_token.rs index c464a2d0cf..b2997109b4 100644 --- a/src/utils/auth_token/org_auth_token.rs +++ b/src/utils/auth_token/org_auth_token.rs @@ -19,6 +19,23 @@ pub struct AuthTokenPayload { // URL may be missing from some old auth tokens, see getsentry/sentry#57123 #[serde(deserialize_with = "url_deserializer")] pub url: String, + + // Region URL is the host that actually serves this org's API requests. It is + // absent from older tokens, in which case requests fall back to `url`. + #[serde(default, deserialize_with = "url_deserializer")] + pub region_url: String, +} + +impl AuthTokenPayload { + /// Returns the base URL that API requests for this org should target, + /// preferring the region URL and falling back to `url` when it is absent. + pub fn base_url(&self) -> &str { + if self.region_url.is_empty() { + &self.url + } else { + &self.region_url + } + } } /// Deserializes a URL from a string, returning an empty string if the URL is missing or null. diff --git a/src/utils/auth_token/test.rs b/src/utils/auth_token/test.rs index a25568b150..52e452fd59 100644 --- a/src/utils/auth_token/test.rs +++ b/src/utils/auth_token/test.rs @@ -21,12 +21,54 @@ fn test_valid_org_auth_token() { let payload = token.payload().unwrap(); assert_eq!(payload.org, "sentry"); assert_eq!(payload.url, "http://localhost:8000"); + assert_eq!(payload.region_url, "http://localhost:8000"); + assert_eq!(payload.base_url(), "http://localhost:8000"); assert_eq!(good_token, token.raw().expose_secret().clone()); assert!(token.format_recognized()); } +#[test] +fn test_valid_org_auth_token_region_url_differs() { + // Payload: {"url":"http://control.example","region_url":"http://region.example","org":"sentry"} + let good_token = String::from( + "sntrys_\ + eyJpYXQiOiAxNzA0MjA1ODAyLjE5OTc0MywgInVybCI6ICJodHRwOi8vY29udHJvbC5leGFtcGxlIiw\ + gInJlZ2lvbl91cmwiOiAiaHR0cDovL3JlZ2lvbi5leGFtcGxlIiwgIm9yZyI6ICJzZW50cnkifQ==_\ + lQ5ETt61cHhvJa35fxvxARsDXeVrd0pu4/smF4sRieA", + ); + + let token = AuthToken::from(good_token); + let payload = token.payload().unwrap(); + + assert_eq!(payload.org, "sentry"); + assert_eq!(payload.url, "http://control.example"); + assert_eq!(payload.region_url, "http://region.example"); + // base_url prefers region_url when present. + assert_eq!(payload.base_url(), "http://region.example"); +} + +#[test] +fn test_valid_org_auth_token_missing_region_url() { + // Legacy token with no region_url field at all; base_url falls back to url. + // Payload: {"url":"http://legacy.example","org":"sentry"} + let good_token = String::from( + "sntrys_\ + eyJpYXQiOiAxNzA0MjA1ODAyLjE5OTc0MywgInVybCI6ICJodHRwOi8vbGVnYWN5LmV4YW1wbGUiL\ + CAib3JnIjogInNlbnRyeSJ9_\ + lQ5ETt61cHhvJa35fxvxARsDXeVrd0pu4/smF4sRieA", + ); + + let token = AuthToken::from(good_token); + let payload = token.payload().unwrap(); + + assert_eq!(payload.org, "sentry"); + assert_eq!(payload.url, "http://legacy.example"); + assert!(payload.region_url.is_empty()); + assert_eq!(payload.base_url(), "http://legacy.example"); +} + #[test] fn test_valid_org_auth_token_missing_url() { let good_token = String::from( @@ -43,6 +85,9 @@ fn test_valid_org_auth_token_missing_url() { let payload = token.payload().unwrap(); assert_eq!(payload.org, "sentry"); assert!(payload.url.is_empty()); + // url is null but region_url is present, so base_url falls back to region_url. + assert_eq!(payload.region_url, "http://localhost:8000"); + assert_eq!(payload.base_url(), "http://localhost:8000"); assert_eq!(good_token, token.raw().expose_secret().clone()); diff --git a/tests/integration/_cases/org_tokens/region-url-routing.trycmd b/tests/integration/_cases/org_tokens/region-url-routing.trycmd new file mode 100644 index 0000000000..f209e9b2ce --- /dev/null +++ b/tests/integration/_cases/org_tokens/region-url-routing.trycmd @@ -0,0 +1,10 @@ +A region-scoped org token where url (control silo) differs from region_url (the org's +region host). The region_url governs routing: it is preferred over the token's url and +overrides --url. Payload: {"url":"http://control.example","region_url":"http://region.example","org":"sentry"} +``` +$ sentry-cli --auth-token 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 --url http://example.com info +? failed +[..]WARN[..] Using http://region.example (embedded in token) rather than manually-configured URL http://example.com. To use http://example.com, please provide an auth token for this URL. +... + +``` diff --git a/tests/integration/_cases/org_tokens/url-mismatch-empty-token.trycmd b/tests/integration/_cases/org_tokens/url-mismatch-empty-token.trycmd index db83846b6c..d95a53240a 100644 --- a/tests/integration/_cases/org_tokens/url-mismatch-empty-token.trycmd +++ b/tests/integration/_cases/org_tokens/url-mismatch-empty-token.trycmd @@ -1,8 +1,9 @@ -This auth token has an empty URL. We expect the --url argument to take precedence here +This token has an empty url but a non-empty region_url. The region_url now governs +routing, so it overrides the --url argument (the warning references the region_url). ``` $ sentry-cli --auth-token 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 --url https://sentry.example.com info ? failed -Sentry Server: https://sentry.example.com +[..]WARN[..] Using http://localhost:8000 (embedded in token) rather than manually-configured URL https://sentry.example.com. To use https://sentry.example.com, please provide an auth token for this URL. ... ``` From 07c70c929018cff5f0b3943f972a060cf94770e9 Mon Sep 17 00:00:00 2001 From: Nico Hinderling Date: Tue, 23 Jun 2026 10:49:02 -0700 Subject: [PATCH 2/2] docs(changelog): Add entry for org token region_url fix Adds the changelog entry required by Danger CI for #3342. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c4e2c3875..be1c1707a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - (snapshots) Compress preprod snapshot manifest with zstd ([#3336](https://github.com/getsentry/sentry-cli/pull/3336)) +### Fixes + +- (auth) Route org token requests to region_url ([#3342](https://github.com/getsentry/sentry-cli/pull/3342)) + ## 3.5.1 ### Internal Changes 🔧