From f171efe9607595a454643155a02aa682ee4bc521 Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 15:05:50 +0300 Subject: [PATCH 01/16] feat: add shared oauth auth core --- Cargo.lock | 4 + Cargo.toml | 2 + crates/locality-auth-core/Cargo.toml | 13 ++ crates/locality-auth-core/src/lib.rs | 7 + crates/locality-auth-core/src/oauth.rs | 291 +++++++++++++++++++++++++ 5 files changed, 317 insertions(+) create mode 100644 crates/locality-auth-core/Cargo.toml create mode 100644 crates/locality-auth-core/src/lib.rs create mode 100644 crates/locality-auth-core/src/oauth.rs diff --git a/Cargo.lock b/Cargo.lock index 74b0ec81..4695f740 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2298,6 +2298,10 @@ dependencies = [ "zstd", ] +[[package]] +name = "locality-auth-core" +version = "0.1.0" + [[package]] name = "locality-cloud-files" version = "0.3.7" diff --git a/Cargo.toml b/Cargo.toml index ee382f21..2ef6570c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/locality-protocol", "crates/locality-platform", "crates/locality-store", + "crates/locality-auth-core", "crates/locality-notion", "crates/locality-google-docs", "crates/locality-google-calendar", @@ -34,6 +35,7 @@ locality-engine = { path = "crates/locality-engine" } locality-protocol = { path = "crates/locality-protocol" } locality-platform = { path = "crates/locality-platform" } locality-store = { path = "crates/locality-store" } +locality-auth-core = { path = "crates/locality-auth-core" } locality-notion = { path = "crates/locality-notion" } locality-google-docs = { path = "crates/locality-google-docs" } locality-google-calendar = { path = "crates/locality-google-calendar" } diff --git a/crates/locality-auth-core/Cargo.toml b/crates/locality-auth-core/Cargo.toml new file mode 100644 index 00000000..9ac94e84 --- /dev/null +++ b/crates/locality-auth-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "locality-auth-core" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +name = "locality_auth_core" +path = "src/lib.rs" + +[dependencies] diff --git a/crates/locality-auth-core/src/lib.rs b/crates/locality-auth-core/src/lib.rs new file mode 100644 index 00000000..23df1132 --- /dev/null +++ b/crates/locality-auth-core/src/lib.rs @@ -0,0 +1,7 @@ +//! Shared OAuth connector auth contracts for Locality runtimes. +//! +//! This crate owns stable connector IDs, OAuth callback paths, and scope +//! profiles. It does not own token storage, tenant authorization, broker route +//! handling, or hosted source finalization. + +pub mod oauth; diff --git a/crates/locality-auth-core/src/oauth.rs b/crates/locality-auth-core/src/oauth.rs new file mode 100644 index 00000000..c3020bb9 --- /dev/null +++ b/crates/locality-auth-core/src/oauth.rs @@ -0,0 +1,291 @@ +//! Shared OAuth connector profiles. + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum OAuthConnector { + Notion, + GoogleDocs, + GoogleCalendar, + Gmail, + Slack, +} + +impl OAuthConnector { + pub const fn all() -> &'static [Self] { + &[ + Self::Notion, + Self::GoogleDocs, + Self::GoogleCalendar, + Self::Gmail, + Self::Slack, + ] + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Notion => "notion", + Self::GoogleDocs => "google-docs", + Self::GoogleCalendar => "google-calendar", + Self::Gmail => "gmail", + Self::Slack => "slack", + } + } + + pub const fn broker_callback_path(self) -> &'static str { + match self { + Self::Notion => "/v1/oauth/notion/callback", + Self::GoogleDocs => "/v1/oauth/google-docs/callback", + Self::GoogleCalendar => "/v1/oauth/google-calendar/callback", + Self::Gmail => "/v1/oauth/gmail/callback", + Self::Slack => "/v1/oauth/slack/callback", + } + } + + pub const fn default_local_callback_uri(self) -> &'static str { + match self { + Self::Notion => "http://localhost:8757/oauth/notion/callback", + Self::GoogleDocs => "http://localhost:8757/oauth/google-docs/callback", + Self::GoogleCalendar => "http://localhost:8757/oauth/google-calendar/callback", + Self::Gmail => "http://localhost:8757/oauth/gmail/callback", + Self::Slack => "http://localhost:8757/oauth/slack/callback", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum OAuthHostMode { + LocalBrokered, + HostedAdmin, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OAuthProfile { + pub connector: OAuthConnector, + pub host: OAuthHostMode, + pub scopes: &'static [&'static str], + pub required_scopes: &'static [&'static str], + pub client_completion_redirect_uri: &'static str, + pub broker_callback_path: &'static str, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OAuthProfileError { + BrokerBaseUrlMustBeHttps, + BrokerBaseUrlMustNotContainQueryOrFragment, + BrokerBaseUrlMustNotBeEmpty, +} + +pub const GOOGLE_IDENTITY_SCOPES: &[&str] = &["openid", "email", "profile"]; + +pub const NOTION_LOCAL_BROKER_SCOPES: &[&str] = &[]; +pub const NOTION_HOSTED_ADMIN_SCOPES: &[&str] = &[]; + +pub const GOOGLE_DOCS_LOCAL_BROKER_SCOPES: &[&str] = &[ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/drive.metadata", +]; +pub const GOOGLE_DOCS_HOSTED_ADMIN_SCOPES: &[&str] = GOOGLE_DOCS_LOCAL_BROKER_SCOPES; + +pub const GOOGLE_CALENDAR_LOCAL_BROKER_SCOPES: &[&str] = &[ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/calendar.events", +]; +pub const GOOGLE_CALENDAR_HOSTED_ADMIN_SCOPES: &[&str] = GOOGLE_CALENDAR_LOCAL_BROKER_SCOPES; +pub const GOOGLE_CALENDAR_REQUIRED_API_SCOPES: &[&str] = + &["https://www.googleapis.com/auth/calendar.events"]; + +pub const GMAIL_LOCAL_BROKER_SCOPES: &[&str] = &[ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", +]; +pub const GMAIL_HOSTED_ADMIN_SCOPES: &[&str] = GMAIL_LOCAL_BROKER_SCOPES; +pub const GMAIL_REQUIRED_API_SCOPES: &[&str] = &[ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", +]; +pub const GMAIL_FULL_MAILBOX_SCOPE: &str = "https://mail.google.com/"; + +pub const SLACK_AUTO_JOIN_PUBLIC_CHANNELS_SCOPE: &str = "channels:join"; +pub const SLACK_LOCAL_BROKER_SCOPES: &[&str] = &[ + "channels:read", + "channels:history", + "groups:read", + "groups:history", + "im:read", + "im:history", + "mpim:read", + "mpim:history", + "users:read", + "team:read", + "files:read", + SLACK_AUTO_JOIN_PUBLIC_CHANNELS_SCOPE, +]; +pub const SLACK_HOSTED_ADMIN_SCOPES: &[&str] = &[ + "channels:history", + "channels:read", + "files:read", + "groups:history", + "groups:read", + "users:read", +]; + +pub const fn oauth_profile(connector: OAuthConnector, host: OAuthHostMode) -> Option { + let scopes = match (connector, host) { + (OAuthConnector::Notion, OAuthHostMode::LocalBrokered) => NOTION_LOCAL_BROKER_SCOPES, + (OAuthConnector::Notion, OAuthHostMode::HostedAdmin) => NOTION_HOSTED_ADMIN_SCOPES, + (OAuthConnector::GoogleDocs, OAuthHostMode::LocalBrokered) => { + GOOGLE_DOCS_LOCAL_BROKER_SCOPES + } + (OAuthConnector::GoogleDocs, OAuthHostMode::HostedAdmin) => GOOGLE_DOCS_HOSTED_ADMIN_SCOPES, + (OAuthConnector::GoogleCalendar, OAuthHostMode::LocalBrokered) => { + GOOGLE_CALENDAR_LOCAL_BROKER_SCOPES + } + (OAuthConnector::GoogleCalendar, OAuthHostMode::HostedAdmin) => { + GOOGLE_CALENDAR_HOSTED_ADMIN_SCOPES + } + (OAuthConnector::Gmail, OAuthHostMode::LocalBrokered) => GMAIL_LOCAL_BROKER_SCOPES, + (OAuthConnector::Gmail, OAuthHostMode::HostedAdmin) => GMAIL_HOSTED_ADMIN_SCOPES, + (OAuthConnector::Slack, OAuthHostMode::LocalBrokered) => SLACK_LOCAL_BROKER_SCOPES, + (OAuthConnector::Slack, OAuthHostMode::HostedAdmin) => SLACK_HOSTED_ADMIN_SCOPES, + }; + let required_scopes = match (connector, host) { + (OAuthConnector::GoogleCalendar, _) => GOOGLE_CALENDAR_REQUIRED_API_SCOPES, + (OAuthConnector::Gmail, _) => GMAIL_REQUIRED_API_SCOPES, + (OAuthConnector::Slack, _) => scopes, + _ => scopes, + }; + Some(OAuthProfile { + connector, + host, + scopes, + required_scopes, + client_completion_redirect_uri: connector.default_local_callback_uri(), + broker_callback_path: connector.broker_callback_path(), + }) +} + +pub fn broker_callback_uri( + public_base_url: &str, + connector: OAuthConnector, +) -> Result { + let trimmed = public_base_url.trim(); + if trimmed.is_empty() { + return Err(OAuthProfileError::BrokerBaseUrlMustNotBeEmpty); + } + if !trimmed.starts_with("https://") { + return Err(OAuthProfileError::BrokerBaseUrlMustBeHttps); + } + if trimmed.contains('?') || trimmed.contains('#') { + return Err(OAuthProfileError::BrokerBaseUrlMustNotContainQueryOrFragment); + } + Ok(format!( + "{}{}", + trimmed.trim_end_matches('/'), + connector.broker_callback_path() + )) +} + +pub fn scope_csv(scopes: &[&str]) -> String { + scopes.join(",") +} + +pub fn granted_scopes_match_exact(granted: &[String], expected: &[&str]) -> bool { + if granted.len() != expected.len() { + return false; + } + expected + .iter() + .all(|expected_scope| granted.iter().any(|scope| scope == expected_scope)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_broker_profiles_cover_all_public_oauth_connectors() { + let connectors = OAuthConnector::all(); + assert_eq!( + connectors, + &[ + OAuthConnector::Notion, + OAuthConnector::GoogleDocs, + OAuthConnector::GoogleCalendar, + OAuthConnector::Gmail, + OAuthConnector::Slack, + ] + ); + + for connector in connectors { + let profile = oauth_profile(*connector, OAuthHostMode::LocalBrokered) + .expect("local broker profile"); + assert_eq!(profile.connector, *connector); + assert_eq!(profile.host, OAuthHostMode::LocalBrokered); + assert!( + profile + .client_completion_redirect_uri + .starts_with("http://localhost:8757/") + ); + assert!(profile.broker_callback_path.starts_with("/v1/oauth/")); + assert!(profile.broker_callback_path.ends_with("/callback")); + } + } + + #[test] + fn hosted_slack_profile_is_reduced_from_local_slack_profile() { + let hosted = oauth_profile(OAuthConnector::Slack, OAuthHostMode::HostedAdmin) + .expect("hosted Slack profile"); + assert_eq!( + hosted.scopes, + &[ + "channels:history", + "channels:read", + "files:read", + "groups:history", + "groups:read", + "users:read", + ] + ); + assert!(!hosted.scopes.contains(&"im:read")); + assert!(!hosted.scopes.contains(&"mpim:read")); + assert!(!hosted.scopes.contains(&"team:read")); + assert!(!hosted.scopes.contains(&"channels:join")); + } + + #[test] + fn broker_callback_uri_requires_https_base_url() { + assert_eq!( + broker_callback_uri("https://oauth.locality.test/", OAuthConnector::Gmail).unwrap(), + "https://oauth.locality.test/v1/oauth/gmail/callback" + ); + assert_eq!( + broker_callback_uri("http://oauth.locality.test", OAuthConnector::Gmail), + Err(OAuthProfileError::BrokerBaseUrlMustBeHttps) + ); + assert_eq!( + broker_callback_uri( + "https://oauth.locality.test/path?query=1", + OAuthConnector::Gmail + ), + Err(OAuthProfileError::BrokerBaseUrlMustNotContainQueryOrFragment) + ); + } + + #[test] + fn scope_csv_uses_provider_expected_order() { + let profile = oauth_profile(OAuthConnector::Slack, OAuthHostMode::HostedAdmin) + .expect("hosted Slack profile"); + assert_eq!( + scope_csv(profile.scopes), + "channels:history,channels:read,files:read,groups:history,groups:read,users:read" + ); + } +} From c192bf303ea91c02a80ec9775f707a5e7b5375a6 Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 15:17:03 +0300 Subject: [PATCH 02/16] fix: harden oauth profile helpers --- crates/locality-auth-core/src/oauth.rs | 102 +++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/crates/locality-auth-core/src/oauth.rs b/crates/locality-auth-core/src/oauth.rs index c3020bb9..85bc4745 100644 --- a/crates/locality-auth-core/src/oauth.rs +++ b/crates/locality-auth-core/src/oauth.rs @@ -186,6 +186,9 @@ pub fn broker_callback_uri( if trimmed.contains('?') || trimmed.contains('#') { return Err(OAuthProfileError::BrokerBaseUrlMustNotContainQueryOrFragment); } + if https_base_url_host(trimmed).is_none() { + return Err(OAuthProfileError::BrokerBaseUrlMustNotBeEmpty); + } Ok(format!( "{}{}", trimmed.trim_end_matches('/'), @@ -193,6 +196,24 @@ pub fn broker_callback_uri( )) } +fn https_base_url_host(public_base_url: &str) -> Option<&str> { + let after_scheme = public_base_url.strip_prefix("https://")?; + let authority = after_scheme.split('/').next().unwrap_or_default(); + if authority.is_empty() || authority.chars().any(char::is_whitespace) { + return None; + } + + let authority = authority.rsplit('@').next().unwrap_or(authority); + let host = if let Some(bracketed_host) = authority.strip_prefix('[') { + let closing_bracket = bracketed_host.find(']')?; + &bracketed_host[..closing_bracket] + } else { + authority.split(':').next().unwrap_or(authority) + }; + + if host.is_empty() { None } else { Some(host) } +} + pub fn scope_csv(scopes: &[&str]) -> String { scopes.join(",") } @@ -201,11 +222,28 @@ pub fn granted_scopes_match_exact(granted: &[String], expected: &[&str]) -> bool if granted.len() != expected.len() { return false; } + if string_slice_has_duplicates(granted) || str_slice_has_duplicates(expected) { + return false; + } expected .iter() .all(|expected_scope| granted.iter().any(|scope| scope == expected_scope)) } +fn string_slice_has_duplicates(values: &[String]) -> bool { + values + .iter() + .enumerate() + .any(|(index, value)| values[index + 1..].iter().any(|other| other == value)) +} + +fn str_slice_has_duplicates(values: &[&str]) -> bool { + values + .iter() + .enumerate() + .any(|(index, value)| values[index + 1..].iter().any(|other| other == value)) +} + #[cfg(test)] mod tests { use super::*; @@ -277,6 +315,30 @@ mod tests { ), Err(OAuthProfileError::BrokerBaseUrlMustNotContainQueryOrFragment) ); + assert_eq!( + broker_callback_uri( + "https://oauth.locality.test/path#fragment", + OAuthConnector::Gmail + ), + Err(OAuthProfileError::BrokerBaseUrlMustNotContainQueryOrFragment) + ); + } + + #[test] + fn broker_callback_uri_rejects_empty_or_whitespace_hosts() { + for base_url in [ + "https://", + "https:///foo", + "https://:443", + "https:// /foo", + "https://\t/foo", + ] { + assert_eq!( + broker_callback_uri(base_url, OAuthConnector::Gmail), + Err(OAuthProfileError::BrokerBaseUrlMustNotBeEmpty), + "{base_url} must be rejected as missing a usable host" + ); + } } #[test] @@ -288,4 +350,44 @@ mod tests { "channels:history,channels:read,files:read,groups:history,groups:read,users:read" ); } + + #[test] + fn granted_scopes_match_exact_accepts_different_order() { + let granted = scope_strings(&["email", "profile", "openid"]); + assert!(granted_scopes_match_exact( + &granted, + &["openid", "email", "profile"] + )); + } + + #[test] + fn granted_scopes_match_exact_rejects_missing_scope() { + let granted = scope_strings(&["openid", "email"]); + assert!(!granted_scopes_match_exact( + &granted, + &["openid", "email", "profile"] + )); + } + + #[test] + fn granted_scopes_match_exact_rejects_extra_scope() { + let granted = scope_strings(&["openid", "email", "profile", "calendar"]); + assert!(!granted_scopes_match_exact( + &granted, + &["openid", "email", "profile"] + )); + } + + #[test] + fn granted_scopes_match_exact_rejects_duplicate_scope() { + let granted = scope_strings(&["openid", "email", "email"]); + assert!(!granted_scopes_match_exact( + &granted, + &["openid", "email", "email"] + )); + } + + fn scope_strings(scopes: &[&str]) -> Vec { + scopes.iter().map(|scope| (*scope).to_string()).collect() + } } From ef4bea3721a8e2936d5c877efbe39119904e8c18 Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 15:24:42 +0300 Subject: [PATCH 03/16] refactor: use shared oauth profiles --- Cargo.lock | 5 ++++ crates/locality-gmail/Cargo.toml | 1 + crates/locality-gmail/src/oauth.rs | 24 +++++++--------- crates/locality-google-calendar/Cargo.toml | 1 + crates/locality-google-calendar/src/oauth.rs | 17 +++++------ crates/locality-google-docs/Cargo.toml | 1 + crates/locality-google-docs/src/oauth.rs | 14 +++------ crates/locality-notion/Cargo.toml | 1 + crates/locality-notion/src/oauth.rs | 4 +++ crates/locality-slack/Cargo.toml | 1 + crates/locality-slack/src/connector.rs | 3 +- crates/locality-slack/src/oauth.rs | 30 ++++++++------------ 12 files changed, 49 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4695f740..95267fee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2398,6 +2398,7 @@ name = "locality-gmail" version = "0.3.7" dependencies = [ "base64 0.22.1", + "locality-auth-core", "locality-connector", "locality-core", "reqwest", @@ -2412,6 +2413,7 @@ name = "locality-google-calendar" version = "0.3.7" dependencies = [ "chrono", + "locality-auth-core", "locality-connector", "locality-core", "reqwest", @@ -2425,6 +2427,7 @@ dependencies = [ name = "locality-google-docs" version = "0.3.7" dependencies = [ + "locality-auth-core", "locality-connector", "locality-core", "reqwest", @@ -2465,6 +2468,7 @@ name = "locality-notion" version = "0.3.7" dependencies = [ "base64 0.22.1", + "locality-auth-core", "locality-connector", "locality-core", "mime_guess", @@ -2504,6 +2508,7 @@ name = "locality-slack" version = "0.3.7" dependencies = [ "chrono", + "locality-auth-core", "locality-connector", "locality-core", "locality-protocol", diff --git a/crates/locality-gmail/Cargo.toml b/crates/locality-gmail/Cargo.toml index 8ddebe99..9e5ea3dd 100644 --- a/crates/locality-gmail/Cargo.toml +++ b/crates/locality-gmail/Cargo.toml @@ -12,6 +12,7 @@ path = "src/lib.rs" [dependencies] base64 = "0.22" +locality-auth-core.workspace = true locality-core.workspace = true locality-connector.workspace = true reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "query", "rustls-no-provider"] } diff --git a/crates/locality-gmail/src/oauth.rs b/crates/locality-gmail/src/oauth.rs index 5359f7d4..f21351ab 100644 --- a/crates/locality-gmail/src/oauth.rs +++ b/crates/locality-gmail/src/oauth.rs @@ -2,6 +2,10 @@ use std::collections::BTreeSet; use std::fmt; use std::sync::OnceLock; +use locality_auth_core::oauth::{ + GMAIL_FULL_MAILBOX_SCOPE as AUTH_CORE_GMAIL_FULL_MAILBOX_SCOPE, GMAIL_LOCAL_BROKER_SCOPES, + GMAIL_REQUIRED_API_SCOPES, OAuthConnector, +}; use locality_connector::ConnectorCapabilities; use locality_connector::oauth_broker::{ OAuthBrokerCodeExchange, OAuthBrokerRefresh, OAuthBrokerStart, OAuthBrokerStartResponse, @@ -12,21 +16,13 @@ use reqwest::blocking::Client; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -pub const GMAIL_CONNECTOR_ID: &str = "gmail"; +pub const GMAIL_CONNECTOR_ID: &str = OAuthConnector::Gmail.as_str(); pub const DEFAULT_GMAIL_OAUTH_BROKER_URL: &str = "https://afs-oauth-broker.saurabh-b07.workers.dev"; -pub const DEFAULT_GMAIL_OAUTH_REDIRECT_URI: &str = "http://localhost:8757/oauth/gmail/callback"; -pub const GMAIL_OAUTH_SCOPES: &[&str] = &[ - "openid", - "email", - "profile", - "https://www.googleapis.com/auth/gmail.readonly", - "https://www.googleapis.com/auth/gmail.compose", -]; -const REQUIRED_GMAIL_API_SCOPES: &[&str] = &[ - "https://www.googleapis.com/auth/gmail.readonly", - "https://www.googleapis.com/auth/gmail.compose", -]; -pub const GMAIL_FULL_MAILBOX_SCOPE: &str = "https://mail.google.com/"; +pub const DEFAULT_GMAIL_OAUTH_REDIRECT_URI: &str = + OAuthConnector::Gmail.default_local_callback_uri(); +pub const GMAIL_OAUTH_SCOPES: &[&str] = GMAIL_LOCAL_BROKER_SCOPES; +const REQUIRED_GMAIL_API_SCOPES: &[&str] = GMAIL_REQUIRED_API_SCOPES; +pub const GMAIL_FULL_MAILBOX_SCOPE: &str = AUTH_CORE_GMAIL_FULL_MAILBOX_SCOPE; static REQWEST_CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); diff --git a/crates/locality-google-calendar/Cargo.toml b/crates/locality-google-calendar/Cargo.toml index 566d35e2..c12acfbe 100644 --- a/crates/locality-google-calendar/Cargo.toml +++ b/crates/locality-google-calendar/Cargo.toml @@ -12,6 +12,7 @@ path = "src/lib.rs" [dependencies] chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +locality-auth-core.workspace = true locality-core.workspace = true locality-connector.workspace = true reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "query", "rustls-no-provider"] } diff --git a/crates/locality-google-calendar/src/oauth.rs b/crates/locality-google-calendar/src/oauth.rs index 496d55c0..9dce76c3 100644 --- a/crates/locality-google-calendar/src/oauth.rs +++ b/crates/locality-google-calendar/src/oauth.rs @@ -2,6 +2,9 @@ use std::collections::BTreeSet; use std::fmt; use std::sync::OnceLock; +use locality_auth_core::oauth::{ + GOOGLE_CALENDAR_LOCAL_BROKER_SCOPES, GOOGLE_CALENDAR_REQUIRED_API_SCOPES, OAuthConnector, +}; use locality_connector::ConnectorCapabilities; use locality_connector::oauth_broker::{ OAuthBrokerCodeExchange, OAuthBrokerRefresh, OAuthBrokerStart, OAuthBrokerStartResponse, @@ -12,19 +15,13 @@ use reqwest::blocking::Client; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -pub const GOOGLE_CALENDAR_CONNECTOR_ID: &str = "google-calendar"; +pub const GOOGLE_CALENDAR_CONNECTOR_ID: &str = OAuthConnector::GoogleCalendar.as_str(); pub const DEFAULT_GOOGLE_CALENDAR_OAUTH_BROKER_URL: &str = "https://afs-oauth-broker.saurabh-b07.workers.dev"; pub const DEFAULT_GOOGLE_CALENDAR_OAUTH_REDIRECT_URI: &str = - "http://localhost:8757/oauth/google-calendar/callback"; -pub const GOOGLE_CALENDAR_OAUTH_SCOPES: &[&str] = &[ - "openid", - "email", - "profile", - "https://www.googleapis.com/auth/calendar.events", -]; -const REQUIRED_GOOGLE_CALENDAR_API_SCOPES: &[&str] = - &["https://www.googleapis.com/auth/calendar.events"]; + OAuthConnector::GoogleCalendar.default_local_callback_uri(); +pub const GOOGLE_CALENDAR_OAUTH_SCOPES: &[&str] = GOOGLE_CALENDAR_LOCAL_BROKER_SCOPES; +const REQUIRED_GOOGLE_CALENDAR_API_SCOPES: &[&str] = GOOGLE_CALENDAR_REQUIRED_API_SCOPES; static REQWEST_CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); diff --git a/crates/locality-google-docs/Cargo.toml b/crates/locality-google-docs/Cargo.toml index 88f2994b..2607a414 100644 --- a/crates/locality-google-docs/Cargo.toml +++ b/crates/locality-google-docs/Cargo.toml @@ -11,6 +11,7 @@ name = "locality_google_docs" path = "src/lib.rs" [dependencies] +locality-auth-core.workspace = true locality-core.workspace = true locality-connector.workspace = true reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "query", "rustls-no-provider"] } diff --git a/crates/locality-google-docs/src/oauth.rs b/crates/locality-google-docs/src/oauth.rs index fa4bd624..08a704dd 100644 --- a/crates/locality-google-docs/src/oauth.rs +++ b/crates/locality-google-docs/src/oauth.rs @@ -1,6 +1,7 @@ use std::fmt; use std::sync::OnceLock; +use locality_auth_core::oauth::{GOOGLE_DOCS_LOCAL_BROKER_SCOPES, OAuthConnector}; use locality_connector::ConnectorCapabilities; use locality_connector::oauth_broker::{ OAuthBrokerCodeExchange, OAuthBrokerRefresh, OAuthBrokerStart, OAuthBrokerStartResponse, @@ -11,21 +12,14 @@ use reqwest::blocking::Client; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -pub const GOOGLE_DOCS_CONNECTOR_ID: &str = "google-docs"; +pub const GOOGLE_DOCS_CONNECTOR_ID: &str = OAuthConnector::GoogleDocs.as_str(); // Cloudflare worker name is still `afs-oauth-broker`; the workers.dev hostname // predates the Locality product rename until auth.locality.dev is deployed. pub const DEFAULT_GOOGLE_DOCS_OAUTH_BROKER_URL: &str = "https://afs-oauth-broker.saurabh-b07.workers.dev"; pub const DEFAULT_GOOGLE_DOCS_OAUTH_REDIRECT_URI: &str = - "http://localhost:8757/oauth/google-docs/callback"; -pub const GOOGLE_DOCS_OAUTH_SCOPES: &[&str] = &[ - "openid", - "email", - "profile", - "https://www.googleapis.com/auth/documents", - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/drive.metadata", -]; + OAuthConnector::GoogleDocs.default_local_callback_uri(); +pub const GOOGLE_DOCS_OAUTH_SCOPES: &[&str] = GOOGLE_DOCS_LOCAL_BROKER_SCOPES; static REQWEST_CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); diff --git a/crates/locality-notion/Cargo.toml b/crates/locality-notion/Cargo.toml index 2e7951c9..b2b817a1 100644 --- a/crates/locality-notion/Cargo.toml +++ b/crates/locality-notion/Cargo.toml @@ -12,6 +12,7 @@ path = "src/lib.rs" [dependencies] base64 = "0.22" +locality-auth-core.workspace = true locality-core.workspace = true locality-connector.workspace = true mime_guess = "2.0" diff --git a/crates/locality-notion/src/oauth.rs b/crates/locality-notion/src/oauth.rs index d20284a4..7324ff84 100644 --- a/crates/locality-notion/src/oauth.rs +++ b/crates/locality-notion/src/oauth.rs @@ -7,6 +7,7 @@ use std::fmt; use std::time::Duration; +use locality_auth_core::oauth::OAuthConnector; use locality_core::{LocalityError, LocalityResult}; use reqwest::{Url, blocking::Client}; use serde::de::DeserializeOwned; @@ -20,6 +21,9 @@ pub const DEFAULT_NOTION_OAUTH_AUTHORIZE_URL: &str = "https://api.notion.com/v1/ // predates the Locality product rename until auth.locality.dev is deployed. pub const DEFAULT_LOCALITY_NOTION_OAUTH_BROKER_URL: &str = "https://afs-oauth-broker.saurabh-b07.workers.dev"; +pub const NOTION_CONNECTOR_ID: &str = OAuthConnector::Notion.as_str(); +pub const DEFAULT_NOTION_OAUTH_REDIRECT_URI: &str = + OAuthConnector::Notion.default_local_callback_uri(); const REDACTED: &str = ""; const NOTION_OAUTH_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/crates/locality-slack/Cargo.toml b/crates/locality-slack/Cargo.toml index 872bf7ad..68c43489 100644 --- a/crates/locality-slack/Cargo.toml +++ b/crates/locality-slack/Cargo.toml @@ -12,6 +12,7 @@ path = "src/lib.rs" [dependencies] chrono = { version = "0.4", default-features = false, features = ["std"] } +locality-auth-core.workspace = true locality-core.workspace = true locality-connector.workspace = true locality-protocol.workspace = true diff --git a/crates/locality-slack/src/connector.rs b/crates/locality-slack/src/connector.rs index 45983b9d..2588fc69 100644 --- a/crates/locality-slack/src/connector.rs +++ b/crates/locality-slack/src/connector.rs @@ -3,6 +3,7 @@ use std::fmt; use std::path::Path; use std::sync::Arc; +use locality_auth_core::oauth::OAuthConnector; use locality_connector::{ ApplyPlanRequest, ApplyPlanResult, ApplyUndoRequest, ApplyUndoResult, ChildContainer, Connector, ConnectorCapabilities, ConnectorExecutionPolicy, ConnectorKind, EnumerateRequest, @@ -25,7 +26,7 @@ use crate::render::{ }; use crate::settings::{SlackConversationType, SlackMountSettings}; -pub const SLACK_CONNECTOR_ID: &str = "slack"; +pub const SLACK_CONNECTOR_ID: &str = OAuthConnector::Slack.as_str(); const CONVERSATIONS_PAGE_SIZE: u32 = 200; const USERS_PAGE_SIZE: u32 = 200; diff --git a/crates/locality-slack/src/oauth.rs b/crates/locality-slack/src/oauth.rs index a02c8413..818d099e 100644 --- a/crates/locality-slack/src/oauth.rs +++ b/crates/locality-slack/src/oauth.rs @@ -2,6 +2,11 @@ use std::collections::BTreeSet; use std::fmt; use std::sync::OnceLock; +use locality_auth_core::oauth::{ + OAuthConnector, + SLACK_AUTO_JOIN_PUBLIC_CHANNELS_SCOPE as AUTH_CORE_SLACK_AUTO_JOIN_PUBLIC_CHANNELS_SCOPE, + SLACK_LOCAL_BROKER_SCOPES, +}; use locality_connector::ConnectorCapabilities; use locality_connector::oauth_broker::{ OAuthBrokerCodeExchange, OAuthBrokerRefresh, OAuthBrokerStart, OAuthBrokerStartResponse, @@ -15,24 +20,13 @@ use serde::{Deserialize, Serialize}; use crate::connector::SLACK_CONNECTOR_ID; pub const DEFAULT_SLACK_OAUTH_BROKER_URL: &str = "https://afs-oauth-broker.saurabh-b07.workers.dev"; -pub const DEFAULT_SLACK_OAUTH_REDIRECT_URI: &str = "http://localhost:8757/oauth/slack/callback"; - -pub const SLACK_AUTO_JOIN_PUBLIC_CHANNELS_SCOPE: &str = "channels:join"; - -pub const SLACK_OAUTH_SCOPES: &[&str] = &[ - "channels:read", - "channels:history", - "groups:read", - "groups:history", - "im:read", - "im:history", - "mpim:read", - "mpim:history", - "users:read", - "team:read", - "files:read", - SLACK_AUTO_JOIN_PUBLIC_CHANNELS_SCOPE, -]; +pub const DEFAULT_SLACK_OAUTH_REDIRECT_URI: &str = + OAuthConnector::Slack.default_local_callback_uri(); + +pub const SLACK_AUTO_JOIN_PUBLIC_CHANNELS_SCOPE: &str = + AUTH_CORE_SLACK_AUTO_JOIN_PUBLIC_CHANNELS_SCOPE; + +pub const SLACK_OAUTH_SCOPES: &[&str] = SLACK_LOCAL_BROKER_SCOPES; static REQWEST_CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); From 4cfdad643e28466225f5f0a3c3614090fdc9c5ac Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 15:36:39 +0300 Subject: [PATCH 04/16] fix: reuse notion oauth connector id --- crates/locality-gmail/src/oauth.rs | 29 ++++++++++++++++-------- crates/locality-google-docs/src/oauth.rs | 26 ++++++++++++++------- crates/locality-notion/src/lib.rs | 18 ++++++++++++++- crates/locality-notion/src/oauth.rs | 12 +++++++++- 4 files changed, 66 insertions(+), 19 deletions(-) diff --git a/crates/locality-gmail/src/oauth.rs b/crates/locality-gmail/src/oauth.rs index f21351ab..827f9b12 100644 --- a/crates/locality-gmail/src/oauth.rs +++ b/crates/locality-gmail/src/oauth.rs @@ -268,8 +268,9 @@ mod tests { use locality_connector::oauth_broker::OAuthBrokerToken; use super::{ - GMAIL_CONNECTOR_ID, GMAIL_FULL_MAILBOX_SCOPE, GMAIL_OAUTH_SCOPES, GmailOAuthScopeError, - StoredGmailCredential, gmail_capabilities_json, validate_gmail_oauth_scopes, + DEFAULT_GMAIL_OAUTH_REDIRECT_URI, GMAIL_CONNECTOR_ID, GMAIL_FULL_MAILBOX_SCOPE, + GMAIL_OAUTH_SCOPES, GmailOAuthScopeError, StoredGmailCredential, gmail_capabilities_json, + validate_gmail_oauth_scopes, }; fn gmail_scopes() -> Vec { @@ -280,13 +281,23 @@ mod tests { } #[test] - fn oauth_scopes_cover_read_and_compose_without_full_mailbox_scope() { - assert!(GMAIL_OAUTH_SCOPES.contains(&"openid")); - assert!(GMAIL_OAUTH_SCOPES.contains(&"email")); - assert!(GMAIL_OAUTH_SCOPES.contains(&"profile")); - assert!(GMAIL_OAUTH_SCOPES.contains(&"https://www.googleapis.com/auth/gmail.readonly")); - assert!(GMAIL_OAUTH_SCOPES.contains(&"https://www.googleapis.com/auth/gmail.compose")); - assert!(!GMAIL_OAUTH_SCOPES.contains(&"https://mail.google.com/")); + fn oauth_constants_match_gmail_broker_contract() { + assert_eq!(GMAIL_CONNECTOR_ID, "gmail"); + assert_eq!( + DEFAULT_GMAIL_OAUTH_REDIRECT_URI, + "http://localhost:8757/oauth/gmail/callback" + ); + assert_eq!( + GMAIL_OAUTH_SCOPES, + &[ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", + ] + ); + assert_eq!(GMAIL_FULL_MAILBOX_SCOPE, "https://mail.google.com/"); } #[test] diff --git a/crates/locality-google-docs/src/oauth.rs b/crates/locality-google-docs/src/oauth.rs index 08a704dd..4c6013b6 100644 --- a/crates/locality-google-docs/src/oauth.rs +++ b/crates/locality-google-docs/src/oauth.rs @@ -219,18 +219,28 @@ mod tests { use locality_connector::oauth_broker::OAuthBrokerToken; use super::{ - GOOGLE_DOCS_CONNECTOR_ID, GOOGLE_DOCS_OAUTH_SCOPES, StoredGoogleDocsCredential, - google_docs_capabilities_json, + DEFAULT_GOOGLE_DOCS_OAUTH_REDIRECT_URI, GOOGLE_DOCS_CONNECTOR_ID, GOOGLE_DOCS_OAUTH_SCOPES, + StoredGoogleDocsCredential, google_docs_capabilities_json, }; #[test] - fn oauth_scopes_include_google_docs_and_workspace_metadata_access() { - assert!(GOOGLE_DOCS_OAUTH_SCOPES.contains(&"https://www.googleapis.com/auth/documents")); - assert!(!GOOGLE_DOCS_OAUTH_SCOPES.contains(&"https://www.googleapis.com/auth/drive")); - assert!( - GOOGLE_DOCS_OAUTH_SCOPES.contains(&"https://www.googleapis.com/auth/drive.metadata") + fn oauth_constants_match_google_docs_broker_contract() { + assert_eq!(GOOGLE_DOCS_CONNECTOR_ID, "google-docs"); + assert_eq!( + DEFAULT_GOOGLE_DOCS_OAUTH_REDIRECT_URI, + "http://localhost:8757/oauth/google-docs/callback" + ); + assert_eq!( + GOOGLE_DOCS_OAUTH_SCOPES, + &[ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/drive.metadata", + ] ); - assert!(GOOGLE_DOCS_OAUTH_SCOPES.contains(&"https://www.googleapis.com/auth/drive.file")); } #[test] diff --git a/crates/locality-notion/src/lib.rs b/crates/locality-notion/src/lib.rs index 622e15d7..e3c84b03 100644 --- a/crates/locality-notion/src/lib.rs +++ b/crates/locality-notion/src/lib.rs @@ -42,6 +42,7 @@ use crate::media::{ MediaDownloadReport, MediaFetchReport, PortableMediaCaptureFetcher, PortableMediaCapturePolicy, default_portable_media_fetcher, download_media_assets, fetch_media_asset_report_with_fetcher, }; +use crate::oauth::NOTION_CONNECTOR_ID; use crate::projection::{ enumerate_explicit_root_trees, enumerate_shared_pages, list_container_children, observe_entity, resolve_notion_object_path_entries, resolve_page_path_entries, @@ -314,7 +315,7 @@ impl Connector for NotionConnector { } fn kind(&self) -> ConnectorKind { - ConnectorKind("notion") + ConnectorKind(NOTION_CONNECTOR_ID) } fn capabilities(&self) -> ConnectorCapabilities { @@ -452,3 +453,18 @@ impl Connector for NotionConnector { apply_undo(self.api.as_ref(), request) } } + +#[cfg(test)] +mod tests { + use locality_connector::Connector; + + use super::{NotionConfig, NotionConnector}; + use crate::oauth::NOTION_CONNECTOR_ID; + + #[test] + fn notion_connector_kind_matches_oauth_connector_id() { + let connector = NotionConnector::new(NotionConfig::default()); + + assert_eq!(connector.kind().0, NOTION_CONNECTOR_ID); + } +} diff --git a/crates/locality-notion/src/oauth.rs b/crates/locality-notion/src/oauth.rs index 7324ff84..af4e10d3 100644 --- a/crates/locality-notion/src/oauth.rs +++ b/crates/locality-notion/src/oauth.rs @@ -461,12 +461,22 @@ mod tests { use reqwest::Url; use super::{ - DEFAULT_NOTION_OAUTH_AUTHORIZE_URL, HttpNotionOAuthBrokerClient, HttpNotionOAuthClient, + DEFAULT_NOTION_OAUTH_AUTHORIZE_URL, DEFAULT_NOTION_OAUTH_REDIRECT_URI, + HttpNotionOAuthBrokerClient, HttpNotionOAuthClient, NOTION_CONNECTOR_ID, NotionOAuthBrokerCodeExchange, NotionOAuthBrokerRefresh, NotionOAuthBrokerStartResponse, NotionOAuthCodeExchange, NotionOAuthRefresh, NotionOAuthToken, StoredNotionCredential, normalize_notion_authorization_url, }; + #[test] + fn oauth_constants_match_notion_broker_contract() { + assert_eq!(NOTION_CONNECTOR_ID, "notion"); + assert_eq!( + DEFAULT_NOTION_OAUTH_REDIRECT_URI, + "http://localhost:8757/oauth/notion/callback" + ); + } + #[test] fn broker_start_response_normalizes_missing_response_type() { let start = NotionOAuthBrokerStartResponse { From c5a3f534716a204e2341b015af5c6d05552d1c9a Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 15:45:18 +0300 Subject: [PATCH 05/16] fix: route provider oauth callbacks through broker --- apps/oauth-service/src/app.ts | 281 +++++++++++------- apps/oauth-service/src/security/redirects.ts | 34 ++- apps/oauth-service/src/security/session.ts | 53 +++- apps/oauth-service/src/types.ts | 1 + apps/oauth-service/test/app.test.ts | 73 ++++- .../test/google-calendar.test.ts | 11 +- 6 files changed, 326 insertions(+), 127 deletions(-) diff --git a/apps/oauth-service/src/app.ts b/apps/oauth-service/src/app.ts index 4e3b02a1..060ba4f9 100644 --- a/apps/oauth-service/src/app.ts +++ b/apps/oauth-service/src/app.ts @@ -18,13 +18,14 @@ import { exchangeNotionCode, notionAuthorizeUrl, refreshNotionToken, type Notion import { exchangeSlackCode, refreshSlackToken, slackAuthorizeUrl, type SlackTokenResponse } from "./oauth/slack"; import { randomBase64Url, decryptJsonHandle, encryptJsonHandle } from "./security/crypto"; import { + providerCallbackUri, validateGmailRedirectUri, validateGoogleCalendarRedirectUri, validateGoogleDocsRedirectUri, validateNotionRedirectUri, validateSlackRedirectUri } from "./security/redirects"; -import { nowSeconds, signSession, verifySession } from "./security/session"; +import { nowSeconds, signSession, verifySession, type OAuthSessionPayloadV2 } from "./security/session"; import type { ApiErrorBody, BrokerEnv, ConnectorId } from "./types"; const SESSION_TTL_SECONDS = 10 * 60; @@ -91,33 +92,46 @@ app.get("/.well-known/loc-auth-broker", (c) => }) ); +app.get("/v1/oauth/:connector/callback", async (c) => { + const connector = connectorFromParam(c.req.param("connector")); + const state = requireString(c.req.query("state"), "state"); + const payload = await verifySession( + state, + requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") + ); + if (payload.v !== 2 || payload.connector !== connector) { + throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); + } + if (payload.provider_redirect_uri !== providerCallbackUri(c.env, connector)) { + throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker callback URI"); + } + + const requestUrl = new URL(c.req.url); + if (!requestUrl.searchParams.get("code") && !requestUrl.searchParams.get("error")) { + throw badRequest("invalid_oauth_callback", "OAuth callback must include code or error"); + } + + c.header("Cache-Control", "no-store"); + c.header("Referrer-Policy", "no-referrer"); + return c.redirect(callbackRedirectTarget(payload, state, requestUrl), 302); +}); + app.post("/v1/oauth/notion/start", async (c) => { const body = await optionalJson(c.req.raw); - const redirectUri = validateNotionRedirectUri( + const clientRedirectUri = validateNotionRedirectUri( c.env, body.redirect_uri ?? "http://localhost:8757/oauth/notion/callback" ); - const now = nowSeconds(); - const state = randomBase64Url(); - const session = await signSession( - { - v: 1, - connector: "notion", - state, - redirect_uri: redirectUri, - iat: now, - exp: now + SESSION_TTL_SECONDS, - nonce: randomBase64Url() - }, - requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") - ); + const providerRedirectUri = providerCallbackUri(c.env, "notion"); + const session = await currentSession(c.env, "notion", clientRedirectUri, providerRedirectUri); return c.json({ connector: "notion", client_id: c.env.LOCALITY_NOTION_CLIENT_ID, - authorization_url: notionAuthorizeUrl(c.env, redirectUri, state), - redirect_uri: redirectUri, + authorization_url: notionAuthorizeUrl(c.env, providerRedirectUri, session), + redirect_uri: clientRedirectUri, + provider_redirect_uri: providerRedirectUri, session, - state, + state: session, expires_in: SESSION_TTL_SECONDS }); }); @@ -127,15 +141,22 @@ app.post("/v1/oauth/notion/exchange", async (c) => { const session = requireString(body.session, "session"); const state = requireString(body.state, "state"); const code = requireString(body.code, "code"); - const redirectUri = validateNotionRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); + const clientRedirectUri = validateNotionRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); const payload = await verifySession( session, requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") ); - if (payload.connector !== "notion" || payload.state !== state || payload.redirect_uri !== redirectUri) { + const providerRedirectUri = providerCallbackUri(c.env, "notion"); + if ( + payload.v !== 2 || + payload.connector !== "notion" || + state !== session || + payload.client_redirect_uri !== clientRedirectUri || + payload.provider_redirect_uri !== providerRedirectUri + ) { throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); } - const token = await exchangeNotionCode(c.env, code, redirectUri); + const token = await exchangeNotionCode(c.env, code, payload.provider_redirect_uri); return c.json(await shapeNotionTokenResponse(c.env, token)); }); @@ -148,31 +169,20 @@ app.post("/v1/oauth/notion/refresh", async (c) => { app.post("/v1/oauth/google-docs/start", async (c) => { const body = await optionalJson(c.req.raw); - const redirectUri = validateGoogleDocsRedirectUri( + const clientRedirectUri = validateGoogleDocsRedirectUri( c.env, body.redirect_uri ?? "http://localhost:8757/oauth/google-docs/callback" ); - const now = nowSeconds(); - const state = randomBase64Url(); - const session = await signSession( - { - v: 1, - connector: "google-docs", - state, - redirect_uri: redirectUri, - iat: now, - exp: now + SESSION_TTL_SECONDS, - nonce: randomBase64Url() - }, - requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") - ); + const providerRedirectUri = providerCallbackUri(c.env, "google-docs"); + const session = await currentSession(c.env, "google-docs", clientRedirectUri, providerRedirectUri); return c.json({ connector: "google-docs", client_id: googleClientId(c.env), - authorization_url: googleDocsAuthorizeUrl(c.env, redirectUri, state), - redirect_uri: redirectUri, + authorization_url: googleDocsAuthorizeUrl(c.env, providerRedirectUri, session), + redirect_uri: clientRedirectUri, + provider_redirect_uri: providerRedirectUri, session, - state, + state: session, expires_in: SESSION_TTL_SECONDS }); }); @@ -182,15 +192,22 @@ app.post("/v1/oauth/google-docs/exchange", async (c) => { const session = requireString(body.session, "session"); const state = requireString(body.state, "state"); const code = requireString(body.code, "code"); - const redirectUri = validateGoogleDocsRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); + const clientRedirectUri = validateGoogleDocsRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); const payload = await verifySession( session, requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") ); - if (payload.connector !== "google-docs" || payload.state !== state || payload.redirect_uri !== redirectUri) { + const providerRedirectUri = providerCallbackUri(c.env, "google-docs"); + if ( + payload.v !== 2 || + payload.connector !== "google-docs" || + state !== session || + payload.client_redirect_uri !== clientRedirectUri || + payload.provider_redirect_uri !== providerRedirectUri + ) { throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); } - const token = await exchangeGoogleDocsCode(c.env, code, redirectUri); + const token = await exchangeGoogleDocsCode(c.env, code, payload.provider_redirect_uri); return c.json(await shapeGoogleDocsTokenResponse(c.env, token)); }); @@ -203,31 +220,20 @@ app.post("/v1/oauth/google-docs/refresh", async (c) => { app.post("/v1/oauth/google-calendar/start", async (c) => { const body = await optionalJson(c.req.raw); - const redirectUri = validateGoogleCalendarRedirectUri( + const clientRedirectUri = validateGoogleCalendarRedirectUri( c.env, body.redirect_uri ?? "http://localhost:8757/oauth/google-calendar/callback" ); - const now = nowSeconds(); - const state = randomBase64Url(); - const session = await signSession( - { - v: 1, - connector: "google-calendar", - state, - redirect_uri: redirectUri, - iat: now, - exp: now + SESSION_TTL_SECONDS, - nonce: randomBase64Url() - }, - requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") - ); + const providerRedirectUri = providerCallbackUri(c.env, "google-calendar"); + const session = await currentSession(c.env, "google-calendar", clientRedirectUri, providerRedirectUri); return c.json({ connector: "google-calendar", client_id: googleClientId(c.env), - authorization_url: googleCalendarAuthorizeUrl(c.env, redirectUri, state), - redirect_uri: redirectUri, + authorization_url: googleCalendarAuthorizeUrl(c.env, providerRedirectUri, session), + redirect_uri: clientRedirectUri, + provider_redirect_uri: providerRedirectUri, session, - state, + state: session, expires_in: SESSION_TTL_SECONDS }); }); @@ -237,15 +243,22 @@ app.post("/v1/oauth/google-calendar/exchange", async (c) => { const session = requireString(body.session, "session"); const state = requireString(body.state, "state"); const code = requireString(body.code, "code"); - const redirectUri = validateGoogleCalendarRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); + const clientRedirectUri = validateGoogleCalendarRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); const payload = await verifySession( session, requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") ); - if (payload.connector !== "google-calendar" || payload.state !== state || payload.redirect_uri !== redirectUri) { + const providerRedirectUri = providerCallbackUri(c.env, "google-calendar"); + if ( + payload.v !== 2 || + payload.connector !== "google-calendar" || + state !== session || + payload.client_redirect_uri !== clientRedirectUri || + payload.provider_redirect_uri !== providerRedirectUri + ) { throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); } - const token = await exchangeGoogleCalendarCode(c.env, code, redirectUri); + const token = await exchangeGoogleCalendarCode(c.env, code, payload.provider_redirect_uri); return c.json(await shapeGoogleCalendarTokenResponse(c.env, token)); }); @@ -258,31 +271,20 @@ app.post("/v1/oauth/google-calendar/refresh", async (c) => { app.post("/v1/oauth/gmail/start", async (c) => { const body = await optionalJson(c.req.raw); - const redirectUri = validateGmailRedirectUri( + const clientRedirectUri = validateGmailRedirectUri( c.env, body.redirect_uri ?? "http://localhost:8757/oauth/gmail/callback" ); - const now = nowSeconds(); - const state = randomBase64Url(); - const session = await signSession( - { - v: 1, - connector: "gmail", - state, - redirect_uri: redirectUri, - iat: now, - exp: now + SESSION_TTL_SECONDS, - nonce: randomBase64Url() - }, - requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") - ); + const providerRedirectUri = providerCallbackUri(c.env, "gmail"); + const session = await currentSession(c.env, "gmail", clientRedirectUri, providerRedirectUri); return c.json({ connector: "gmail", client_id: googleClientId(c.env), - authorization_url: gmailAuthorizeUrl(c.env, redirectUri, state), - redirect_uri: redirectUri, + authorization_url: gmailAuthorizeUrl(c.env, providerRedirectUri, session), + redirect_uri: clientRedirectUri, + provider_redirect_uri: providerRedirectUri, session, - state, + state: session, expires_in: SESSION_TTL_SECONDS }); }); @@ -292,15 +294,22 @@ app.post("/v1/oauth/gmail/exchange", async (c) => { const session = requireString(body.session, "session"); const state = requireString(body.state, "state"); const code = requireString(body.code, "code"); - const redirectUri = validateGmailRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); + const clientRedirectUri = validateGmailRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); const payload = await verifySession( session, requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") ); - if (payload.connector !== "gmail" || payload.state !== state || payload.redirect_uri !== redirectUri) { + const providerRedirectUri = providerCallbackUri(c.env, "gmail"); + if ( + payload.v !== 2 || + payload.connector !== "gmail" || + state !== session || + payload.client_redirect_uri !== clientRedirectUri || + payload.provider_redirect_uri !== providerRedirectUri + ) { throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); } - const token = await exchangeGmailCode(c.env, code, redirectUri); + const token = await exchangeGmailCode(c.env, code, payload.provider_redirect_uri); return c.json(await shapeGmailTokenResponse(c.env, token)); }); @@ -313,31 +322,20 @@ app.post("/v1/oauth/gmail/refresh", async (c) => { app.post("/v1/oauth/slack/start", async (c) => { const body = await optionalJson(c.req.raw); - const redirectUri = validateSlackRedirectUri( + const clientRedirectUri = validateSlackRedirectUri( c.env, body.redirect_uri ?? "http://localhost:8757/oauth/slack/callback" ); - const now = nowSeconds(); - const state = randomBase64Url(); - const session = await signSession( - { - v: 1, - connector: "slack", - state, - redirect_uri: redirectUri, - iat: now, - exp: now + SESSION_TTL_SECONDS, - nonce: randomBase64Url() - }, - requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") - ); + const providerRedirectUri = providerCallbackUri(c.env, "slack"); + const session = await currentSession(c.env, "slack", clientRedirectUri, providerRedirectUri); return c.json({ connector: "slack", client_id: c.env.LOCALITY_SLACK_CLIENT_ID, - authorization_url: slackAuthorizeUrl(c.env, redirectUri, state), - redirect_uri: redirectUri, + authorization_url: slackAuthorizeUrl(c.env, providerRedirectUri, session), + redirect_uri: clientRedirectUri, + provider_redirect_uri: providerRedirectUri, session, - state, + state: session, expires_in: SESSION_TTL_SECONDS }); }); @@ -347,15 +345,22 @@ app.post("/v1/oauth/slack/exchange", async (c) => { const session = requireString(body.session, "session"); const state = requireString(body.state, "state"); const code = requireString(body.code, "code"); - const redirectUri = validateSlackRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); + const clientRedirectUri = validateSlackRedirectUri(c.env, requireString(body.redirect_uri, "redirect_uri")); const payload = await verifySession( session, requireOperationalSecret(c.env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") ); - if (payload.connector !== "slack" || payload.state !== state || payload.redirect_uri !== redirectUri) { + const providerRedirectUri = providerCallbackUri(c.env, "slack"); + if ( + payload.v !== 2 || + payload.connector !== "slack" || + state !== session || + payload.client_redirect_uri !== clientRedirectUri || + payload.provider_redirect_uri !== providerRedirectUri + ) { throw badRequest("oauth_session_mismatch", "OAuth callback did not match the broker session"); } - const token = await exchangeSlackCode(c.env, code, redirectUri); + const token = await exchangeSlackCode(c.env, code, payload.provider_redirect_uri); return c.json(await shapeSlackTokenResponse(c.env, token)); }); @@ -454,6 +459,70 @@ async function shapeSlackTokenResponse(env: BrokerEnv, token: SlackTokenResponse }; } +function connectorFromParam(value: string): ConnectorId { + if ( + value === "notion" || + value === "google-docs" || + value === "google-calendar" || + value === "gmail" || + value === "slack" + ) { + return value; + } + throw badRequest("unknown_connector", "OAuth connector is not supported"); +} + +async function currentSession( + env: BrokerEnv, + connector: ConnectorId, + clientRedirectUri: string, + providerRedirectUri: string +): Promise { + const now = nowSeconds(); + return signSession( + { + v: 2, + connector, + state_nonce: randomBase64Url(), + client_redirect_uri: clientRedirectUri, + provider_redirect_uri: providerRedirectUri, + iat: now, + exp: now + SESSION_TTL_SECONDS, + nonce: randomBase64Url() + }, + requireOperationalSecret(env.LOCALITY_BROKER_SESSION_SECRET, "LOCALITY_BROKER_SESSION_SECRET") + ); +} + +function callbackRedirectTarget(payload: OAuthSessionPayloadV2, state: string, requestUrl: URL): string { + const target = new URL(payload.client_redirect_uri); + const code = requestUrl.searchParams.get("code"); + if (code) { + target.searchParams.set("code", boundedCallbackValue(code, "code")); + } + const error = requestUrl.searchParams.get("error"); + if (error) { + target.searchParams.set("error", boundedCallbackValue(error, "error")); + } + const errorDescription = requestUrl.searchParams.get("error_description"); + if (errorDescription) { + target.searchParams.set("error_description", boundedCallbackValue(errorDescription, "error_description")); + } + const errorUri = requestUrl.searchParams.get("error_uri"); + if (errorUri) { + target.searchParams.set("error_uri", boundedCallbackValue(errorUri, "error_uri")); + } + target.searchParams.set("state", state); + return target.toString(); +} + +function boundedCallbackValue(value: string, field: string): string { + if (value.length > 4096) { + throw badRequest("invalid_oauth_callback", `${field} is too large`); + } + return value; +} + async function shapeRefreshToken(env: BrokerEnv, connector: ConnectorId, refreshToken: string | undefined) { if (!refreshToken) { return {}; diff --git a/apps/oauth-service/src/security/redirects.ts b/apps/oauth-service/src/security/redirects.ts index 8426c063..30e84a4c 100644 --- a/apps/oauth-service/src/security/redirects.ts +++ b/apps/oauth-service/src/security/redirects.ts @@ -1,5 +1,5 @@ -import { badRequest } from "../http/errors"; -import type { BrokerEnv } from "../types"; +import { badRequest, configError } from "../http/errors"; +import type { BrokerEnv, ConnectorId } from "../types"; const DEFAULT_NOTION_REDIRECT_URIS = [ "http://localhost:8757/oauth/notion/callback", @@ -66,6 +66,36 @@ export function validateSlackRedirectUri(env: BrokerEnv, redirectUri: string): s return validateLoopbackRedirectUri("Slack", allowedSlackRedirectUris(env), redirectUri); } +export function providerCallbackUri(env: BrokerEnv, connector: ConnectorId): string { + const base = env.LOCALITY_BROKER_PUBLIC_BASE_URL; + if (!base || base.trim() === "") { + throw configError("LOCALITY_BROKER_PUBLIC_BASE_URL must be configured"); + } + + let parsed: URL; + try { + parsed = new URL(base); + } catch { + throw configError("LOCALITY_BROKER_PUBLIC_BASE_URL must be a valid HTTPS URL"); + } + + if (parsed.protocol !== "https:") { + throw configError("LOCALITY_BROKER_PUBLIC_BASE_URL must use HTTPS"); + } + if (!parsed.hostname) { + throw configError("LOCALITY_BROKER_PUBLIC_BASE_URL must include a hostname"); + } + if (parsed.username || parsed.password) { + throw configError("LOCALITY_BROKER_PUBLIC_BASE_URL must not include credentials"); + } + if (parsed.search || parsed.hash) { + throw configError("LOCALITY_BROKER_PUBLIC_BASE_URL must not include query or fragment"); + } + + parsed.pathname = `${parsed.pathname.replace(/\/+$/, "")}/v1/oauth/${connector}/callback`; + return parsed.toString(); +} + function validateLoopbackRedirectUri(connectorName: string, allowed: string[], redirectUri: string): string { let parsed: URL; try { diff --git a/apps/oauth-service/src/security/session.ts b/apps/oauth-service/src/security/session.ts index 07275f32..76feb3bc 100644 --- a/apps/oauth-service/src/security/session.ts +++ b/apps/oauth-service/src/security/session.ts @@ -2,7 +2,7 @@ import { badRequest, unauthorized } from "../http/errors"; import type { ConnectorId } from "../types"; import { constantTimeEqual, hmacSha256Base64Url, parseUtf8Base64Url, utf8Base64Url } from "./crypto"; -export interface OAuthSessionPayload { +export interface OAuthSessionPayloadV1 { v: 1; connector: ConnectorId; state: string; @@ -12,6 +12,19 @@ export interface OAuthSessionPayload { nonce: string; } +export interface OAuthSessionPayloadV2 { + v: 2; + connector: ConnectorId; + state_nonce: string; + client_redirect_uri: string; + provider_redirect_uri: string; + iat: number; + exp: number; + nonce: string; +} + +export type OAuthSessionPayload = OAuthSessionPayloadV1 | OAuthSessionPayloadV2; + export async function signSession(payload: OAuthSessionPayload, secret: string): Promise { const body = utf8Base64Url(JSON.stringify(payload)); const signature = await hmacSha256Base64Url(secret, body); @@ -51,17 +64,35 @@ function isOAuthSessionPayload(value: unknown): value is OAuthSessionPayload { return false; } const payload = value as Partial; - return ( - payload.v === 1 && - (payload.connector === "notion" || - payload.connector === "google-docs" || - payload.connector === "google-calendar" || - payload.connector === "gmail" || - payload.connector === "slack") && - typeof payload.state === "string" && - typeof payload.redirect_uri === "string" && + const base = + isConnector(payload.connector) && typeof payload.iat === "number" && typeof payload.exp === "number" && - typeof payload.nonce === "string" + typeof payload.nonce === "string"; + if (!base) { + return false; + } + if (payload.v === 1) { + const legacy = payload as Partial; + return typeof legacy.state === "string" && typeof legacy.redirect_uri === "string"; + } + if (payload.v === 2) { + const current = payload as Partial; + return ( + typeof current.state_nonce === "string" && + typeof current.client_redirect_uri === "string" && + typeof current.provider_redirect_uri === "string" + ); + } + return false; +} + +function isConnector(value: unknown): value is ConnectorId { + return ( + value === "notion" || + value === "google-docs" || + value === "google-calendar" || + value === "gmail" || + value === "slack" ); } diff --git a/apps/oauth-service/src/types.ts b/apps/oauth-service/src/types.ts index 015fac08..04748145 100644 --- a/apps/oauth-service/src/types.ts +++ b/apps/oauth-service/src/types.ts @@ -2,6 +2,7 @@ export interface BrokerEnv { LOCALITY_BROKER_SESSION_SECRET: string; LOCALITY_REFRESH_HANDLE_KEY?: string; LOCALITY_TOKEN_MODE?: "handle" | "raw"; + LOCALITY_BROKER_PUBLIC_BASE_URL?: string; LOCALITY_NOTION_CLIENT_ID: string; LOCALITY_NOTION_CLIENT_SECRET: string; LOCALITY_NOTION_REDIRECT_URIS?: string; diff --git a/apps/oauth-service/test/app.test.ts b/apps/oauth-service/test/app.test.ts index f9a30bf8..3e6b48b0 100644 --- a/apps/oauth-service/test/app.test.ts +++ b/apps/oauth-service/test/app.test.ts @@ -8,6 +8,7 @@ interface StartResponse { client_id: string; authorization_url: string; redirect_uri: string; + provider_redirect_uri: string; session: string; state: string; } @@ -34,6 +35,7 @@ const env: BrokerEnv = { LOCALITY_BROKER_SESSION_SECRET: "test-session-secret-with-enough-entropy", LOCALITY_REFRESH_HANDLE_KEY: "test-refresh-handle-key-with-enough-entropy", LOCALITY_TOKEN_MODE: "handle", + LOCALITY_BROKER_PUBLIC_BASE_URL: "https://oauth.locality.test", LOCALITY_NOTION_CLIENT_ID: "notion-client-id", LOCALITY_NOTION_CLIENT_SECRET: "notion-client-secret", LOCALITY_NOTION_API_BASE_URL: "https://notion.example.test", @@ -118,6 +120,21 @@ describe("auth broker", () => { expect(body.state).toBeTruthy(); }); + it("uses the HTTPS broker callback as the provider redirect URI", async () => { + const response = await app.request("/v1/oauth/notion/start", { method: "POST" }, env); + expect(response.status).toBe(200); + const body = (await response.json()) as StartResponse & { provider_redirect_uri: string }; + const authorizationUrl = new URL(body.authorization_url); + + expect(body.redirect_uri).toBe("http://localhost:8757/oauth/notion/callback"); + expect(body.provider_redirect_uri).toBe("https://oauth.locality.test/v1/oauth/notion/callback"); + expect(authorizationUrl.searchParams.get("redirect_uri")).toBe( + "https://oauth.locality.test/v1/oauth/notion/callback" + ); + expect(authorizationUrl.searchParams.get("state")).toBe(body.state); + expect(body.state).toBe(body.session); + }); + it("rejects unconfigured redirect URIs", async () => { const response = await app.request( "/v1/oauth/notion/start", @@ -177,6 +194,42 @@ describe("auth broker", () => { }) }) ); + const requestBody = JSON.parse((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string); + expect(requestBody.redirect_uri).toBe("https://oauth.locality.test/v1/oauth/notion/callback"); + }); + + it("relays the provider callback back to the localhost client redirect", async () => { + const start = await startSession(); + const callback = await app.request( + `/v1/oauth/notion/callback?code=provider-code&state=${encodeURIComponent(start.state)}`, + { method: "GET" }, + env + ); + + expect(callback.status).toBe(302); + expect(callback.headers.get("cache-control")).toBe("no-store"); + expect(callback.headers.get("referrer-policy")).toBe("no-referrer"); + const location = callback.headers.get("location"); + expect(location).toBeTruthy(); + const redirected = new URL(location!); + expect(redirected.origin).toBe("http://localhost:8757"); + expect(redirected.pathname).toBe("/oauth/notion/callback"); + expect(redirected.searchParams.get("code")).toBe("provider-code"); + expect(redirected.searchParams.get("state")).toBe(start.state); + }); + + it("rejects a callback state signed for another connector", async () => { + const start = await startGmailSession(); + const callback = await app.request( + `/v1/oauth/notion/callback?code=provider-code&state=${encodeURIComponent(start.state)}`, + { method: "GET" }, + env + ); + + expect(callback.status).toBe(400); + await expect(callback.json()).resolves.toMatchObject({ + error: { code: "oauth_session_mismatch" } + }); }); it("refreshes through an opaque refresh handle", async () => { @@ -346,6 +399,7 @@ describe("auth broker", () => { expect(requestBody.get("client_id")).toBe("google-client-id"); expect(requestBody.get("client_secret")).toBe("google-client-secret"); expect(requestBody.get("grant_type")).toBe("authorization_code"); + expect(requestBody.get("redirect_uri")).toBe("https://oauth.locality.test/v1/oauth/google-docs/callback"); }); it("refreshes Google Docs credentials through an opaque refresh handle", async () => { @@ -416,7 +470,7 @@ describe("auth broker", () => { expect(authorizationUrl.searchParams.get("client_id")).toBe("google-client-id"); expect(authorizationUrl.searchParams.get("response_type")).toBe("code"); expect(authorizationUrl.searchParams.get("redirect_uri")).toBe( - "http://localhost:8757/oauth/google-calendar/callback" + "https://oauth.locality.test/v1/oauth/google-calendar/callback" ); expect(authorizationUrl.searchParams.get("scope")?.split(" ").sort()).toEqual( [ @@ -430,6 +484,7 @@ describe("auth broker", () => { expect(authorizationUrl.searchParams.get("prompt")).toBe("consent"); expect(authorizationUrl.searchParams.get("include_granted_scopes")).toBeNull(); expect(body.redirect_uri).toBe("http://localhost:8757/oauth/google-calendar/callback"); + expect(body.provider_redirect_uri).toBe("https://oauth.locality.test/v1/oauth/google-calendar/callback"); expect(body.session).toBeTruthy(); expect(body.state).toBeTruthy(); }); @@ -488,7 +543,7 @@ describe("auth broker", () => { expect(requestBody.get("client_secret")).toBe("google-client-secret"); expect(requestBody.get("grant_type")).toBe("authorization_code"); expect(requestBody.get("code")).toBe("authorization-code"); - expect(requestBody.get("redirect_uri")).toBe("http://localhost:8757/oauth/google-calendar/callback"); + expect(requestBody.get("redirect_uri")).toBe("https://oauth.locality.test/v1/oauth/google-calendar/callback"); }); it("creates a Gmail OAuth session and authorization URL", async () => { @@ -501,7 +556,9 @@ describe("auth broker", () => { expect(`${authorizationUrl.origin}${authorizationUrl.pathname}`).toBe("https://accounts.example.test/o/oauth2/v2/auth"); expect(authorizationUrl.searchParams.get("client_id")).toBe("google-client-id"); expect(authorizationUrl.searchParams.get("response_type")).toBe("code"); - expect(authorizationUrl.searchParams.get("redirect_uri")).toBe("http://localhost:8757/oauth/gmail/callback"); + expect(authorizationUrl.searchParams.get("redirect_uri")).toBe( + "https://oauth.locality.test/v1/oauth/gmail/callback" + ); expect(authorizationUrl.searchParams.get("scope")?.split(" ").sort()).toEqual( [ "openid", @@ -516,6 +573,7 @@ describe("auth broker", () => { expect(authorizationUrl.searchParams.get("prompt")).toBe("consent"); expect(authorizationUrl.searchParams.get("include_granted_scopes")).toBeNull(); expect(body.redirect_uri).toBe("http://localhost:8757/oauth/gmail/callback"); + expect(body.provider_redirect_uri).toBe("https://oauth.locality.test/v1/oauth/gmail/callback"); expect(body.session).toBeTruthy(); expect(body.state).toBeTruthy(); }); @@ -573,7 +631,7 @@ describe("auth broker", () => { expect(requestBody.get("client_secret")).toBe("google-client-secret"); expect(requestBody.get("grant_type")).toBe("authorization_code"); expect(requestBody.get("code")).toBe("authorization-code"); - expect(requestBody.get("redirect_uri")).toBe("http://localhost:8757/oauth/gmail/callback"); + expect(requestBody.get("redirect_uri")).toBe("https://oauth.locality.test/v1/oauth/gmail/callback"); }); it("refreshes Gmail credentials through an opaque refresh handle", async () => { @@ -685,7 +743,9 @@ describe("auth broker", () => { "https://slack-auth.example.test/oauth/v2/authorize" ); expect(authorizationUrl.searchParams.get("client_id")).toBe("slack-client-id"); - expect(authorizationUrl.searchParams.get("redirect_uri")).toBe("http://localhost:8757/oauth/slack/callback"); + expect(authorizationUrl.searchParams.get("redirect_uri")).toBe( + "https://oauth.locality.test/v1/oauth/slack/callback" + ); expect(authorizationUrl.searchParams.get("state")).toBe(body.state); const scopes = authorizationUrl.searchParams.get("scope")?.split(",") ?? []; expect(scopes).toContain("channels:history"); @@ -693,6 +753,7 @@ describe("auth broker", () => { expect(scopes).toContain("files:read"); expect(scopes).not.toContain("chat:write"); expect(body.redirect_uri).toBe("http://localhost:8757/oauth/slack/callback"); + expect(body.provider_redirect_uri).toBe("https://oauth.locality.test/v1/oauth/slack/callback"); expect(body.session).toBeTruthy(); expect(body.state).toBeTruthy(); }); @@ -773,7 +834,7 @@ describe("auth broker", () => { expect(requestBody.get("client_secret")).toBe("slack-client-secret"); expect(requestBody.get("grant_type")).toBe("authorization_code"); expect(requestBody.get("code")).toBe("authorization-code"); - expect(requestBody.get("redirect_uri")).toBe("http://localhost:8757/oauth/slack/callback"); + expect(requestBody.get("redirect_uri")).toBe("https://oauth.locality.test/v1/oauth/slack/callback"); }); it("does not expose raw Slack OAuth error text to callers", async () => { diff --git a/apps/oauth-service/test/google-calendar.test.ts b/apps/oauth-service/test/google-calendar.test.ts index ddbdb450..c56e76c9 100644 --- a/apps/oauth-service/test/google-calendar.test.ts +++ b/apps/oauth-service/test/google-calendar.test.ts @@ -7,6 +7,7 @@ interface StartResponse { client_id: string; authorization_url: string; redirect_uri: string; + provider_redirect_uri: string; session: string; state: string; } @@ -27,6 +28,7 @@ const env: BrokerEnv = { LOCALITY_BROKER_SESSION_SECRET: "test-session-secret-with-enough-entropy", LOCALITY_REFRESH_HANDLE_KEY: "test-refresh-handle-key-with-enough-entropy", LOCALITY_TOKEN_MODE: "handle", + LOCALITY_BROKER_PUBLIC_BASE_URL: "https://oauth.locality.test", LOCALITY_NOTION_CLIENT_ID: "notion-client-id", LOCALITY_NOTION_CLIENT_SECRET: "notion-client-secret", LOCALITY_GOOGLE_CLIENT_ID: "google-client-id", @@ -77,7 +79,7 @@ describe("Google Calendar OAuth broker", () => { expect(authorizationUrl.searchParams.get("client_id")).toBe("google-client-id"); expect(authorizationUrl.searchParams.get("response_type")).toBe("code"); expect(authorizationUrl.searchParams.get("redirect_uri")).toBe( - "http://localhost:8757/oauth/google-calendar/callback" + "https://oauth.locality.test/v1/oauth/google-calendar/callback" ); expect(authorizationUrl.searchParams.get("scope")?.split(" ").sort()).toEqual( [ @@ -91,6 +93,9 @@ describe("Google Calendar OAuth broker", () => { expect(authorizationUrl.searchParams.get("prompt")).toBe("consent"); expect(authorizationUrl.searchParams.get("include_granted_scopes")).toBeNull(); expect(body.redirect_uri).toBe("http://localhost:8757/oauth/google-calendar/callback"); + expect(body.provider_redirect_uri).toBe( + "https://oauth.locality.test/v1/oauth/google-calendar/callback" + ); expect(body.session).toBeTruthy(); expect(body.state).toBeTruthy(); }); @@ -149,7 +154,9 @@ describe("Google Calendar OAuth broker", () => { expect(requestBody.get("client_secret")).toBe("google-client-secret"); expect(requestBody.get("grant_type")).toBe("authorization_code"); expect(requestBody.get("code")).toBe("authorization-code"); - expect(requestBody.get("redirect_uri")).toBe("http://localhost:8757/oauth/google-calendar/callback"); + expect(requestBody.get("redirect_uri")).toBe( + "https://oauth.locality.test/v1/oauth/google-calendar/callback" + ); }); it("refreshes Google Calendar credentials through an opaque refresh handle", async () => { From 071c2e2f4b2c5a3c4774da5281c4d3beb59329dc Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 15:49:39 +0300 Subject: [PATCH 06/16] test: cover calendar oauth callback relay --- .../test/google-calendar.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/oauth-service/test/google-calendar.test.ts b/apps/oauth-service/test/google-calendar.test.ts index c56e76c9..fb8aa910 100644 --- a/apps/oauth-service/test/google-calendar.test.ts +++ b/apps/oauth-service/test/google-calendar.test.ts @@ -159,6 +159,26 @@ describe("Google Calendar OAuth broker", () => { ); }); + it("relays the provider callback back to the localhost Google Calendar client redirect", async () => { + const start = await startGoogleCalendarSession(); + const callback = await app.request( + `/v1/oauth/google-calendar/callback?code=provider-code&state=${encodeURIComponent(start.state)}`, + { method: "GET" }, + env + ); + + expect(callback.status).toBe(302); + expect(callback.headers.get("cache-control")).toBe("no-store"); + expect(callback.headers.get("referrer-policy")).toBe("no-referrer"); + const location = callback.headers.get("location"); + expect(location).toBeTruthy(); + const redirected = new URL(location!); + expect(redirected.origin).toBe("http://localhost:8757"); + expect(redirected.pathname).toBe("/oauth/google-calendar/callback"); + expect(redirected.searchParams.get("code")).toBe("provider-code"); + expect(redirected.searchParams.get("state")).toBe(start.state); + }); + it("refreshes Google Calendar credentials through an opaque refresh handle", async () => { const start = await startGoogleCalendarSession(); let calls = 0; From 0c884bbf758dc8878985197b1f1c381538d6bad8 Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 16:00:56 +0300 Subject: [PATCH 07/16] feat: expose provider oauth redirect uri --- crates/locality-connector/src/oauth_broker.rs | 25 ++++++++++++- crates/locality-notion/src/oauth.rs | 36 +++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/locality-connector/src/oauth_broker.rs b/crates/locality-connector/src/oauth_broker.rs index 1616ad88..c7cf4cb3 100644 --- a/crates/locality-connector/src/oauth_broker.rs +++ b/crates/locality-connector/src/oauth_broker.rs @@ -12,6 +12,8 @@ pub struct OAuthBrokerStartResponse { pub client_id: String, pub authorization_url: String, pub redirect_uri: String, + #[serde(default)] + pub provider_redirect_uri: Option, pub session: String, pub state: String, pub expires_in: u64, @@ -69,7 +71,7 @@ where #[cfg(test)] mod tests { - use super::{OAuthBrokerStart, OAuthBrokerToken}; + use super::{OAuthBrokerStart, OAuthBrokerStartResponse, OAuthBrokerToken}; #[test] fn start_request_carries_connector_and_redirect_uri() { @@ -87,6 +89,27 @@ mod tests { ); } + #[test] + fn start_response_accepts_provider_redirect_uri() { + let payload = serde_json::json!({ + "connector": "gmail", + "client_id": "client-id", + "authorization_url": "https://accounts.example.test/o/oauth2/v2/auth", + "redirect_uri": "http://localhost:8757/oauth/gmail/callback", + "provider_redirect_uri": "https://oauth.locality.test/v1/oauth/gmail/callback", + "session": "signed-session", + "state": "signed-session", + "expires_in": 600 + }); + + let start: OAuthBrokerStartResponse = + serde_json::from_value(payload).expect("decode start response"); + assert_eq!( + start.provider_redirect_uri.as_deref(), + Some("https://oauth.locality.test/v1/oauth/gmail/callback") + ); + } + #[test] fn token_payload_can_carry_refresh_handle_and_scopes_without_refresh_token() { let payload = serde_json::json!({ diff --git a/crates/locality-notion/src/oauth.rs b/crates/locality-notion/src/oauth.rs index af4e10d3..b655ccc3 100644 --- a/crates/locality-notion/src/oauth.rs +++ b/crates/locality-notion/src/oauth.rs @@ -81,6 +81,8 @@ pub struct NotionOAuthBrokerStartResponse { pub client_id: String, pub authorization_url: String, pub redirect_uri: String, + #[serde(default)] + pub provider_redirect_uri: Option, pub session: String, pub state: String, pub expires_in: u64, @@ -94,6 +96,7 @@ impl fmt::Debug for NotionOAuthBrokerStartResponse { .field("client_id", &self.client_id) .field("authorization_url", &REDACTED) .field("redirect_uri", &self.redirect_uri) + .field("provider_redirect_uri", &self.provider_redirect_uri) .field("session", &REDACTED) .field("state", &REDACTED) .field("expires_in", &self.expires_in) @@ -103,10 +106,14 @@ impl fmt::Debug for NotionOAuthBrokerStartResponse { impl NotionOAuthBrokerStartResponse { pub fn normalized_authorization_url(&self) -> String { + let redirect_uri = self + .provider_redirect_uri + .as_deref() + .unwrap_or(self.redirect_uri.as_str()); normalize_notion_authorization_url( &self.authorization_url, &self.client_id, - &self.redirect_uri, + redirect_uri, &self.state, ) } @@ -486,6 +493,7 @@ mod tests { "https://api.notion.com/v1/oauth/authorize?client_id=client-id&prompt=select" .to_string(), redirect_uri: "http://localhost:8757/oauth/notion/callback".to_string(), + provider_redirect_uri: None, session: "session-1".to_string(), state: "state-1".to_string(), expires_in: 300, @@ -508,6 +516,29 @@ mod tests { assert_eq!(query_value(&url, "state").as_deref(), Some("state-1")); } + #[test] + fn notion_normalized_authorization_url_uses_provider_redirect_when_present() { + let start = NotionOAuthBrokerStartResponse { + connector: "notion".to_string(), + client_id: "client-id".to_string(), + authorization_url: "https://api.notion.com/v1/oauth/authorize?client_id=wrong" + .to_string(), + redirect_uri: "http://localhost:8757/oauth/notion/callback".to_string(), + provider_redirect_uri: Some( + "https://oauth.locality.test/v1/oauth/notion/callback".to_string(), + ), + session: "signed-session".to_string(), + state: "signed-session".to_string(), + expires_in: 600, + }; + + let url = Url::parse(&start.normalized_authorization_url()).expect("normalized URL"); + assert_eq!( + query_value(&url, "redirect_uri").as_deref(), + Some("https://oauth.locality.test/v1/oauth/notion/callback") + ); + } + #[test] fn normalize_notion_authorization_url_replaces_managed_parameters() { let normalized = normalize_notion_authorization_url( @@ -649,6 +680,7 @@ mod tests { authorization_url: "https://api.notion.com/v1/oauth/authorize?state=secret-state" .to_string(), redirect_uri: "http://localhost/callback".to_string(), + provider_redirect_uri: None, session: "secret-session".to_string(), state: "secret-state".to_string(), expires_in: 300, @@ -703,7 +735,7 @@ mod tests { ); assert_eq!( format!("{broker_start:?}"), - "NotionOAuthBrokerStartResponse { connector: \"notion\", client_id: \"client-id\", authorization_url: \"\", redirect_uri: \"http://localhost/callback\", session: \"\", state: \"\", expires_in: 300 }" + "NotionOAuthBrokerStartResponse { connector: \"notion\", client_id: \"client-id\", authorization_url: \"\", redirect_uri: \"http://localhost/callback\", provider_redirect_uri: None, session: \"\", state: \"\", expires_in: 300 }" ); assert_eq!( format!("{broker_exchange:?}"), From c511ef7a8157c00a82c2c663b3296e581c1a1cd3 Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 16:11:14 +0300 Subject: [PATCH 08/16] test: cover legacy oauth start responses --- crates/locality-connector/src/oauth_broker.rs | 18 ++++++++++++++ crates/locality-notion/src/oauth.rs | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/locality-connector/src/oauth_broker.rs b/crates/locality-connector/src/oauth_broker.rs index c7cf4cb3..d765b10d 100644 --- a/crates/locality-connector/src/oauth_broker.rs +++ b/crates/locality-connector/src/oauth_broker.rs @@ -110,6 +110,24 @@ mod tests { ); } + #[test] + fn start_response_defaults_missing_provider_redirect_uri() { + let payload = serde_json::json!({ + "connector": "gmail", + "client_id": "client-id", + "authorization_url": "https://accounts.example.test/o/oauth2/v2/auth", + "redirect_uri": "http://localhost:8757/oauth/gmail/callback", + "session": "signed-session", + "state": "signed-session", + "expires_in": 600 + }); + + let start: OAuthBrokerStartResponse = + serde_json::from_value(payload).expect("decode legacy start response"); + + assert_eq!(start.provider_redirect_uri, None); + } + #[test] fn token_payload_can_carry_refresh_handle_and_scopes_without_refresh_token() { let payload = serde_json::json!({ diff --git a/crates/locality-notion/src/oauth.rs b/crates/locality-notion/src/oauth.rs index b655ccc3..572d0283 100644 --- a/crates/locality-notion/src/oauth.rs +++ b/crates/locality-notion/src/oauth.rs @@ -539,6 +539,30 @@ mod tests { ); } + #[test] + fn notion_start_response_without_provider_redirect_uses_local_redirect_fallback() { + let payload = serde_json::json!({ + "connector": "notion", + "client_id": "client-id", + "authorization_url": "https://api.notion.com/v1/oauth/authorize?client_id=wrong&redirect_uri=https%3A%2F%2Foauth.locality.test%2Fwrong", + "redirect_uri": "http://localhost:8757/oauth/notion/callback", + "session": "signed-session", + "state": "signed-session", + "expires_in": 600 + }); + + let start: NotionOAuthBrokerStartResponse = + serde_json::from_value(payload).expect("decode legacy start response"); + + assert_eq!(start.provider_redirect_uri, None); + + let url = Url::parse(&start.normalized_authorization_url()).expect("normalized URL"); + assert_eq!( + query_value(&url, "redirect_uri").as_deref(), + Some("http://localhost:8757/oauth/notion/callback") + ); + } + #[test] fn normalize_notion_authorization_url_replaces_managed_parameters() { let normalized = normalize_notion_authorization_url( From 0689a8c4f399f8be280a2093392305e46cd274fa Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 16:19:05 +0300 Subject: [PATCH 09/16] docs: describe shared oauth architecture --- apps/oauth-service/README.md | 23 ++++++------ apps/oauth-service/docs/deployment.md | 50 +++++++++++++++++---------- apps/oauth-service/docs/security.md | 12 +++++++ docs/connector-development.md | 12 ++++--- docs/oauth-architecture.md | 42 ++++++++++++++++++++++ 5 files changed, 106 insertions(+), 33 deletions(-) create mode 100644 docs/oauth-architecture.md diff --git a/apps/oauth-service/README.md b/apps/oauth-service/README.md index 9c1e3186..bf76bc53 100644 --- a/apps/oauth-service/README.md +++ b/apps/oauth-service/README.md @@ -11,23 +11,26 @@ only performs the confidential token exchange and refresh calls. ## Flow ```text -loc CLI -> broker /start -loc CLI <- authorization_url, state, signed session -loc CLI -> browser -> provider OAuth consent -provider -> localhost callback on the user's machine -loc CLI -> broker /exchange with code, state, session, redirect_uri -broker -> provider token endpoint with client_secret +loc CLI -> broker /start with localhost client redirect_uri +broker -> loc CLI with authorization_url, session, state, provider_redirect_uri +browser -> provider consent using provider_redirect_uri=https:///v1/oauth//callback +provider -> broker HTTPS callback with code/state +broker -> localhost client redirect_uri with code/state +loc CLI -> broker /exchange with code, state, session, and localhost client redirect_uri +broker -> provider token endpoint with provider_redirect_uri and client_secret broker -> loc CLI with access token and refresh handle -``` - -Refresh is similarly narrow: -```text +later: loc CLI -> broker /refresh with refresh_token_handle broker -> provider token endpoint with client_secret broker -> loc CLI with new access token and new refresh handle ``` +Provider OAuth apps should register only the broker HTTPS callback URLs, such as +`https://oauth.locality.example/v1/oauth/notion/callback`. The localhost URL is +only the desktop completion URL used after the broker receives and verifies the +provider callback. + The broker does not persist page content or tokens. In `handle` mode, it returns an encrypted opaque refresh handle instead of the raw provider refresh token. diff --git a/apps/oauth-service/docs/deployment.md b/apps/oauth-service/docs/deployment.md index da4f0014..dff8b1a5 100644 --- a/apps/oauth-service/docs/deployment.md +++ b/apps/oauth-service/docs/deployment.md @@ -18,47 +18,58 @@ wrangler secret put LOCALITY_GOOGLE_CLIENT_ID wrangler secret put LOCALITY_GOOGLE_CLIENT_SECRET wrangler secret put LOCALITY_SLACK_CLIENT_ID wrangler secret put LOCALITY_SLACK_CLIENT_SECRET -wrangler deploy ``` -Configure the Notion OAuth integration with the exact localhost callback used by -Locality: +Before deploying the double-redirect broker, configure the required public base +URL: + +- `LOCALITY_BROKER_PUBLIC_BASE_URL`: HTTPS public origin for the broker, for + example `https://oauth.locality.example`. Provider OAuth apps must register + callback URLs under this origin: + - `/v1/oauth/notion/callback` + - `/v1/oauth/google-docs/callback` + - `/v1/oauth/google-calendar/callback` + - `/v1/oauth/gmail/callback` + - `/v1/oauth/slack/callback` + +Configure the Notion OAuth integration with the broker HTTPS callback: ```text -http://localhost:8757/oauth/notion/callback -http://127.0.0.1:8757/oauth/notion/callback +https://oauth.locality.example/v1/oauth/notion/callback ``` -Configure one Google OAuth client with the exact localhost callbacks used by +Configure one Google OAuth client with the broker HTTPS callbacks used by Locality for Google Docs, Google Calendar, and Gmail: ```text -http://localhost:8757/oauth/google-docs/callback -http://127.0.0.1:8757/oauth/google-docs/callback -http://localhost:8757/oauth/google-calendar/callback -http://127.0.0.1:8757/oauth/google-calendar/callback -http://localhost:8757/oauth/gmail/callback -http://127.0.0.1:8757/oauth/gmail/callback +https://oauth.locality.example/v1/oauth/google-docs/callback +https://oauth.locality.example/v1/oauth/google-calendar/callback +https://oauth.locality.example/v1/oauth/gmail/callback ``` -Configure the Slack OAuth app with the exact localhost callbacks used by -Locality: +Configure the Slack OAuth app with the broker HTTPS callback: ```text -http://localhost:8757/oauth/slack/callback -http://127.0.0.1:8757/oauth/slack/callback +https://oauth.locality.example/v1/oauth/slack/callback +``` + +After the secrets, `LOCALITY_BROKER_PUBLIC_BASE_URL`, and provider callbacks are +configured, deploy: + +```sh +wrangler deploy ``` Use a stable production URL such as: ```text -https://auth.locality.dev +https://oauth.locality.example ``` The Locality client should have: ```text -LOCALITY_AUTH_BROKER_URL=https://auth.locality.dev +LOCALITY_AUTH_BROKER_URL=https://oauth.locality.example LOCALITY_NOTION_OAUTH_CLIENT_ID= ``` @@ -68,7 +79,8 @@ The client ID may also be fetched from `/v1/oauth/notion/start`, binary is fine because it is not confidential. The three Google start endpoints return the same shared Google OAuth client ID. -Optional broker environment overrides for connector local testing: +Optional broker environment overrides for connector local completion URI +allowlist testing: ```text LOCALITY_GOOGLE_CALENDAR_REDIRECT_URIS=http://localhost:8757/oauth/google-calendar/callback,http://127.0.0.1:8757/oauth/google-calendar/callback diff --git a/apps/oauth-service/docs/security.md b/apps/oauth-service/docs/security.md index f317dae6..8a73fe62 100644 --- a/apps/oauth-service/docs/security.md +++ b/apps/oauth-service/docs/security.md @@ -59,3 +59,15 @@ Deployment controls to add before public launch: The broker accepts only configured loopback redirect URIs for Notion, Google Docs, and Gmail. The Locality CLI should use stable localhost callbacks so each provider integration can keep a small static redirect allowlist. + +## Provider Callback Boundary + +Production brokered OAuth uses a double redirect. Provider applications redirect +to the broker over HTTPS. The broker verifies the signed OAuth state, checks that +the session is for the callback connector, and then redirects back to the +validated loopback client completion URI. The provider never sees +`http://localhost` as its registered redirect URI. + +The localhost completion URI remains restricted to `localhost` and `127.0.0.1` +allowlists. The broker callback response sets `Cache-Control: no-store` and +`Referrer-Policy: no-referrer`. diff --git a/docs/connector-development.md b/docs/connector-development.md index 40a793ac..25ab4a70 100644 --- a/docs/connector-development.md +++ b/docs/connector-development.md @@ -32,16 +32,20 @@ crate cannot ship by itself. 6. Add CLI connect and mount routing without changing existing commands. Keep provider-specific mount settings in the provider crate and serialize the default represented by the manifest. -7. Add the desktop source ID, setup/auth classification, display metadata, and +7. Add or update the connector's OAuth profile in `crates/locality-auth-core` + before adding per-runtime OAuth code. Public broker, CLI, and hosted adapters + must consume the shared ID/scope/callback profile instead of duplicating + connector auth constants. +8. Add the desktop source ID, setup/auth classification, display metadata, and `apps/desktop/src/assets/connectors/.svg` icon. Add OAuth-service routing only when the connector actually uses the hosted OAuth broker. -8. Add `docs/-connector.md`, public +9. Add `docs/-connector.md`, public `docs-site/connectors/.mdx`, docs navigation, README support, and any provider-specific security or live-test instructions. -9. Add the direct fixture layout below and use +10. Add the direct fixture layout below and use `locality_connector::conformance` for identity, capability/operation, safe path, read-only, redaction, and fixture checks. -10. Run the contract, provider, daemon, CLI, docs, formatting, and workspace +11. Run the contract, provider, daemon, CLI, docs, formatting, and workspace commands listed below. Verify live behavior only with a dedicated scratch account and explicit live-test credentials. diff --git a/docs/oauth-architecture.md b/docs/oauth-architecture.md new file mode 100644 index 00000000..01156ba6 --- /dev/null +++ b/docs/oauth-architecture.md @@ -0,0 +1,42 @@ +# OAuth Architecture + +Locality has two OAuth hosts: + +- Local desktop OAuth: `loc`, the public OAuth broker, localhost completion, and + the local credential store. +- Hosted admin OAuth: `locality-internal`, admin intents, backend provider + callbacks, Postgres finalization, and managed secret storage. + +The shared layer is `locality-auth-core`. It owns connector IDs, callback paths, +and scope profiles. It does not own token storage, tenant authorization, hosted +source finalization, or background job scheduling. + +## Public Brokered Desktop Flow + +Provider applications redirect to the broker over HTTPS: + +```text +provider -> https:///v1/oauth//callback +``` + +The broker verifies signed state and redirects back to the localhost completion +URI: + +```text +broker -> http://localhost:8757/oauth//callback +``` + +`loc` then exchanges the code through the broker. The broker sends the provider +token request using the HTTPS provider callback URI, not the localhost +completion URI. + +## Hosted Flow + +Hosted connector OAuth stays in `locality-internal`. It creates hash-only admin +intents, receives provider callbacks on the backend, writes credentials to +managed secret storage, and stores only opaque credential references in +Postgres. + +Hosted connectors may consume `locality-auth-core` for IDs and scope profiles, +but hosted availability, tenant binding, finalization, grants, and jobs remain +private runtime responsibilities. From dcb088b56756d16e25d1467f68cc9b48c0813d4e Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 16:27:58 +0300 Subject: [PATCH 10/16] docs: clarify oauth broker rollout requirements --- apps/oauth-service/.dev.vars.example | 1 + apps/oauth-service/README.md | 13 +++++++++++++ apps/oauth-service/docs/deployment.md | 11 +++++++++++ apps/oauth-service/docs/security.md | 11 ++++++----- docs/connector-development.md | 11 ++++++----- 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/apps/oauth-service/.dev.vars.example b/apps/oauth-service/.dev.vars.example index 5f82b431..6b24c0fd 100644 --- a/apps/oauth-service/.dev.vars.example +++ b/apps/oauth-service/.dev.vars.example @@ -1,4 +1,5 @@ # Local development only. Never commit .dev.vars. +LOCALITY_BROKER_PUBLIC_BASE_URL="https://oauth.locality.example" LOCALITY_BROKER_SESSION_SECRET=replace-with-at-least-32-random-bytes LOCALITY_REFRESH_HANDLE_KEY=replace-with-at-least-32-random-bytes LOCALITY_NOTION_CLIENT_ID=notion-oauth-client-id diff --git a/apps/oauth-service/README.md b/apps/oauth-service/README.md index bf76bc53..b1b26f1d 100644 --- a/apps/oauth-service/README.md +++ b/apps/oauth-service/README.md @@ -54,6 +54,7 @@ Response: "client_id": "public-client-id", "authorization_url": "https://api.notion.com/v1/oauth/authorize?...", "redirect_uri": "http://localhost:8757/oauth/notion/callback", + "provider_redirect_uri": "https://oauth.locality.example/v1/oauth/notion/callback", "session": "signed-session", "state": "opaque-state", "expires_in": 600 @@ -104,6 +105,7 @@ Response: "client_id": "public-client-id", "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?...", "redirect_uri": "http://localhost:8757/oauth/google-docs/callback", + "provider_redirect_uri": "https://oauth.locality.example/v1/oauth/google-docs/callback", "session": "signed-session", "state": "opaque-state", "expires_in": 600 @@ -160,6 +162,7 @@ Response: "client_id": "public-client-id", "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?...", "redirect_uri": "http://localhost:8757/oauth/google-calendar/callback", + "provider_redirect_uri": "https://oauth.locality.example/v1/oauth/google-calendar/callback", "session": "signed-session", "state": "opaque-state", "expires_in": 600 @@ -212,6 +215,7 @@ Response: "client_id": "public-client-id", "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?...", "redirect_uri": "http://localhost:8757/oauth/gmail/callback", + "provider_redirect_uri": "https://oauth.locality.example/v1/oauth/gmail/callback", "session": "signed-session", "state": "opaque-state", "expires_in": 600 @@ -263,6 +267,7 @@ Response: "client_id": "public-client-id", "authorization_url": "https://slack.com/oauth/v2/authorize?...", "redirect_uri": "http://localhost:8757/oauth/slack/callback", + "provider_redirect_uri": "https://oauth.locality.example/v1/oauth/slack/callback", "session": "signed-session", "state": "opaque-state", "expires_in": 600 @@ -310,6 +315,14 @@ Run checks: npm run check ``` +## Required Configuration + +- `LOCALITY_BROKER_PUBLIC_BASE_URL`: HTTPS public origin for the broker, for + example `https://oauth.locality.example`. The broker uses this value to build + provider callback URLs returned as `provider_redirect_uri`. `/start` endpoints + fail with `broker_config_error` until it is configured. See + [`docs/deployment.md`](docs/deployment.md) for Cloudflare Workers setup. + ## Required Secrets - `LOCALITY_BROKER_SESSION_SECRET`: signs short-lived OAuth sessions. diff --git a/apps/oauth-service/docs/deployment.md b/apps/oauth-service/docs/deployment.md index dff8b1a5..1e73d5bc 100644 --- a/apps/oauth-service/docs/deployment.md +++ b/apps/oauth-service/docs/deployment.md @@ -32,6 +32,17 @@ URL: - `/v1/oauth/gmail/callback` - `/v1/oauth/slack/callback` +Configure it as a non-secret Worker variable, either in the Cloudflare dashboard +or in `wrangler.toml`: + +```toml +[vars] +LOCALITY_BROKER_PUBLIC_BASE_URL = "https://oauth.locality.example" +``` + +Broker `/start` endpoints fail with `broker_config_error` until +`LOCALITY_BROKER_PUBLIC_BASE_URL` is configured. + Configure the Notion OAuth integration with the broker HTTPS callback: ```text diff --git a/apps/oauth-service/docs/security.md b/apps/oauth-service/docs/security.md index 8a73fe62..05c1e12c 100644 --- a/apps/oauth-service/docs/security.md +++ b/apps/oauth-service/docs/security.md @@ -34,8 +34,8 @@ The broker supports two refresh modes: - OAuth sessions are short-lived HMAC-signed payloads. - Session verification checks state, connector, redirect URI, expiry, and payload shape before exchanging a code. -- Notion, Google Docs, and Gmail redirect URIs are restricted to configured - loopback callback URLs. +- Brokered OAuth connector redirect URIs are restricted to configured loopback + callback URLs. - Production handle mode keeps provider refresh tokens inside encrypted opaque handles before returning them to local clients. - Upstream OAuth error bodies are not returned to callers. @@ -56,9 +56,10 @@ Deployment controls to add before public launch: ## Redirects -The broker accepts only configured loopback redirect URIs for Notion, Google -Docs, and Gmail. The Locality CLI should use stable localhost callbacks so each -provider integration can keep a small static redirect allowlist. +The broker accepts only configured loopback redirect URIs for brokered OAuth +connectors, including Notion, Google Docs, Google Calendar, Gmail, and Slack. +The Locality CLI should use stable localhost callbacks so each provider +integration can keep a small static redirect allowlist. ## Provider Callback Boundary diff --git a/docs/connector-development.md b/docs/connector-development.md index 25ab4a70..05aec3cf 100644 --- a/docs/connector-development.md +++ b/docs/connector-development.md @@ -32,13 +32,14 @@ crate cannot ship by itself. 6. Add CLI connect and mount routing without changing existing commands. Keep provider-specific mount settings in the provider crate and serialize the default represented by the manifest. -7. Add or update the connector's OAuth profile in `crates/locality-auth-core` - before adding per-runtime OAuth code. Public broker, CLI, and hosted adapters - must consume the shared ID/scope/callback profile instead of duplicating - connector auth constants. +7. For OAuth connectors, add or update the connector's OAuth profile in + `crates/locality-auth-core` before adding per-runtime OAuth code. Public + broker, CLI, and hosted adapters that implement that connector's OAuth must + consume the shared ID/scope/callback profile instead of duplicating connector + auth constants. 8. Add the desktop source ID, setup/auth classification, display metadata, and `apps/desktop/src/assets/connectors/.svg` icon. Add OAuth-service routing - only when the connector actually uses the hosted OAuth broker. + only when the connector actually uses the public OAuth broker. 9. Add `docs/-connector.md`, public `docs-site/connectors/.mdx`, docs navigation, README support, and any provider-specific security or live-test instructions. From 5aa2b9ca06874616feec9249d2f66bca9f686b1e Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 16:33:59 +0300 Subject: [PATCH 11/16] docs: align oauth examples with signed state --- apps/oauth-service/.dev.vars.example | 6 ++---- apps/oauth-service/README.md | 23 +++++++++++++---------- docs/connector-development.md | 10 ++++++---- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/apps/oauth-service/.dev.vars.example b/apps/oauth-service/.dev.vars.example index 6b24c0fd..80aaa12a 100644 --- a/apps/oauth-service/.dev.vars.example +++ b/apps/oauth-service/.dev.vars.example @@ -4,10 +4,8 @@ LOCALITY_BROKER_SESSION_SECRET=replace-with-at-least-32-random-bytes LOCALITY_REFRESH_HANDLE_KEY=replace-with-at-least-32-random-bytes LOCALITY_NOTION_CLIENT_ID=notion-oauth-client-id LOCALITY_NOTION_CLIENT_SECRET=notion-oauth-client-secret -LOCALITY_GOOGLE_DOCS_CLIENT_ID=google-docs-oauth-client-id -LOCALITY_GOOGLE_DOCS_CLIENT_SECRET=google-docs-oauth-client-secret -LOCALITY_GMAIL_CLIENT_ID=gmail-oauth-client-id -LOCALITY_GMAIL_CLIENT_SECRET=gmail-oauth-client-secret +LOCALITY_GOOGLE_CLIENT_ID=google-oauth-client-id +LOCALITY_GOOGLE_CLIENT_SECRET=google-oauth-client-secret LOCALITY_TOKEN_MODE=handle LOCALITY_NOTION_REDIRECT_URIS=http://localhost:8757/oauth/notion/callback,http://127.0.0.1:8757/oauth/notion/callback LOCALITY_GOOGLE_DOCS_REDIRECT_URIS=http://localhost:8757/oauth/google-docs/callback,http://127.0.0.1:8757/oauth/google-docs/callback diff --git a/apps/oauth-service/README.md b/apps/oauth-service/README.md index b1b26f1d..4fec6344 100644 --- a/apps/oauth-service/README.md +++ b/apps/oauth-service/README.md @@ -31,6 +31,9 @@ Provider OAuth apps should register only the broker HTTPS callback URLs, such as only the desktop completion URL used after the broker receives and verifies the provider callback. +The current stateless broker uses the signed session token as the OAuth `state`, +so `session` and `state` match in `/start` and `/exchange` payloads. + The broker does not persist page content or tokens. In `handle` mode, it returns an encrypted opaque refresh handle instead of the raw provider refresh token. @@ -56,7 +59,7 @@ Response: "redirect_uri": "http://localhost:8757/oauth/notion/callback", "provider_redirect_uri": "https://oauth.locality.example/v1/oauth/notion/callback", "session": "signed-session", - "state": "opaque-state", + "state": "signed-session", "expires_in": 600 } ``` @@ -68,7 +71,7 @@ Request: ```json { "session": "signed-session", - "state": "opaque-state", + "state": "signed-session", "code": "provider-authorization-code", "redirect_uri": "http://localhost:8757/oauth/notion/callback" } @@ -107,7 +110,7 @@ Response: "redirect_uri": "http://localhost:8757/oauth/google-docs/callback", "provider_redirect_uri": "https://oauth.locality.example/v1/oauth/google-docs/callback", "session": "signed-session", - "state": "opaque-state", + "state": "signed-session", "expires_in": 600 } ``` @@ -119,7 +122,7 @@ Request: ```json { "session": "signed-session", - "state": "opaque-state", + "state": "signed-session", "code": "provider-authorization-code", "redirect_uri": "http://localhost:8757/oauth/google-docs/callback" } @@ -164,7 +167,7 @@ Response: "redirect_uri": "http://localhost:8757/oauth/google-calendar/callback", "provider_redirect_uri": "https://oauth.locality.example/v1/oauth/google-calendar/callback", "session": "signed-session", - "state": "opaque-state", + "state": "signed-session", "expires_in": 600 } ``` @@ -176,7 +179,7 @@ Request: ```json { "session": "signed-session", - "state": "opaque-state", + "state": "signed-session", "code": "provider-authorization-code", "redirect_uri": "http://localhost:8757/oauth/google-calendar/callback" } @@ -217,7 +220,7 @@ Response: "redirect_uri": "http://localhost:8757/oauth/gmail/callback", "provider_redirect_uri": "https://oauth.locality.example/v1/oauth/gmail/callback", "session": "signed-session", - "state": "opaque-state", + "state": "signed-session", "expires_in": 600 } ``` @@ -229,7 +232,7 @@ Request: ```json { "session": "signed-session", - "state": "opaque-state", + "state": "signed-session", "code": "provider-authorization-code", "redirect_uri": "http://localhost:8757/oauth/gmail/callback" } @@ -269,7 +272,7 @@ Response: "redirect_uri": "http://localhost:8757/oauth/slack/callback", "provider_redirect_uri": "https://oauth.locality.example/v1/oauth/slack/callback", "session": "signed-session", - "state": "opaque-state", + "state": "signed-session", "expires_in": 600 } ``` @@ -281,7 +284,7 @@ Request: ```json { "session": "signed-session", - "state": "opaque-state", + "state": "signed-session", "code": "provider-authorization-code", "redirect_uri": "http://localhost:8757/oauth/slack/callback" } diff --git a/docs/connector-development.md b/docs/connector-development.md index 05aec3cf..856a7241 100644 --- a/docs/connector-development.md +++ b/docs/connector-development.md @@ -33,10 +33,12 @@ crate cannot ship by itself. provider-specific mount settings in the provider crate and serialize the default represented by the manifest. 7. For OAuth connectors, add or update the connector's OAuth profile in - `crates/locality-auth-core` before adding per-runtime OAuth code. Public - broker, CLI, and hosted adapters that implement that connector's OAuth must - consume the shared ID/scope/callback profile instead of duplicating connector - auth constants. + `crates/locality-auth-core` before adding per-runtime OAuth code. Rust + runtimes and adapters that implement that connector's OAuth must consume the + shared ID/scope/callback profile instead of duplicating connector auth + constants. Public broker routing and configuration must stay aligned with the + shared profile through generated metadata, drift tests, or explicit matching + updates. 8. Add the desktop source ID, setup/auth classification, display metadata, and `apps/desktop/src/assets/connectors/.svg` icon. Add OAuth-service routing only when the connector actually uses the public OAuth broker. From eef273195e19f50c77d310639209b9171da04c30 Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 20:30:28 +0300 Subject: [PATCH 12/16] feat: share hosted google oauth contracts --- crates/locality-auth-core/src/oauth.rs | 274 +++++++++++++++++++++++++ 1 file changed, 274 insertions(+) diff --git a/crates/locality-auth-core/src/oauth.rs b/crates/locality-auth-core/src/oauth.rs index 85bc4745..a8ab1fa9 100644 --- a/crates/locality-auth-core/src/oauth.rs +++ b/crates/locality-auth-core/src/oauth.rs @@ -74,11 +74,19 @@ pub enum OAuthProfileError { BrokerBaseUrlMustNotBeEmpty, } +pub const GOOGLE_OAUTH_AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; +pub const GOOGLE_OAUTH_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; + pub const GOOGLE_IDENTITY_SCOPES: &[&str] = &["openid", "email", "profile"]; pub const NOTION_LOCAL_BROKER_SCOPES: &[&str] = &[]; pub const NOTION_HOSTED_ADMIN_SCOPES: &[&str] = &[]; +pub const GOOGLE_DOCS_REQUIRED_API_SCOPES: &[&str] = &[ + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/drive.metadata", +]; pub const GOOGLE_DOCS_LOCAL_BROKER_SCOPES: &[&str] = &[ "openid", "email", @@ -113,6 +121,48 @@ pub const GMAIL_REQUIRED_API_SCOPES: &[&str] = &[ ]; pub const GMAIL_FULL_MAILBOX_SCOPE: &str = "https://mail.google.com/"; +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GoogleOAuthProfileError { + UnsupportedConnector, + InvalidClientId, + InvalidRedirectUri, + InvalidState, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GoogleOAuthScopeError { + UnsupportedConnector, + FullMailboxScope, + MissingRequiredScope(&'static str), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GoogleOAuthTokenResponse { + pub access_token: String, + pub token_type: Option, + pub refresh_token: Option, + pub expires_in: Option, + pub scope: Option, + pub id_token: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GoogleHostedCredential { + pub kind: String, + pub connector: String, + pub access_token: String, + pub refresh_token: String, + pub token_type: Option, + pub oauth_client_id: String, + pub account_id: Option, + pub account_label: Option, + pub workspace_id: Option, + pub workspace_name: Option, + pub scopes: Vec, + pub acquired_at: u64, + pub expires_at: Option, +} + pub const SLACK_AUTO_JOIN_PUBLIC_CHANNELS_SCOPE: &str = "channels:join"; pub const SLACK_LOCAL_BROKER_SCOPES: &[&str] = &[ "channels:read", @@ -157,6 +207,7 @@ pub const fn oauth_profile(connector: OAuthConnector, host: OAuthHostMode) -> Op (OAuthConnector::Slack, OAuthHostMode::HostedAdmin) => SLACK_HOSTED_ADMIN_SCOPES, }; let required_scopes = match (connector, host) { + (OAuthConnector::GoogleDocs, _) => GOOGLE_DOCS_REQUIRED_API_SCOPES, (OAuthConnector::GoogleCalendar, _) => GOOGLE_CALENDAR_REQUIRED_API_SCOPES, (OAuthConnector::Gmail, _) => GMAIL_REQUIRED_API_SCOPES, (OAuthConnector::Slack, _) => scopes, @@ -196,6 +247,143 @@ pub fn broker_callback_uri( )) } +pub fn google_authorization_url( + connector: OAuthConnector, + client_id: &str, + redirect_uri: &str, + state: &str, +) -> Result { + let profile = google_hosted_profile(connector)?; + if !valid_google_oauth_client_id(client_id) { + return Err(GoogleOAuthProfileError::InvalidClientId); + } + if !valid_google_hosted_redirect_uri(connector, redirect_uri) { + return Err(GoogleOAuthProfileError::InvalidRedirectUri); + } + if state.is_empty() || state.chars().any(char::is_control) { + return Err(GoogleOAuthProfileError::InvalidState); + } + + let mut url = String::from(GOOGLE_OAUTH_AUTHORIZE_URL); + url.push('?'); + append_query_param(&mut url, "client_id", client_id); + append_query_param(&mut url, "response_type", "code"); + append_query_param(&mut url, "redirect_uri", redirect_uri); + append_query_param(&mut url, "scope", &profile.scopes.join(" ")); + append_query_param(&mut url, "state", state); + append_query_param(&mut url, "access_type", "offline"); + append_query_param(&mut url, "prompt", "consent"); + Ok(url) +} + +pub fn validate_google_oauth_scopes( + connector: OAuthConnector, + granted: &[String], +) -> Result<(), GoogleOAuthScopeError> { + let required_scopes = google_required_api_scopes(connector)?; + if connector == OAuthConnector::Gmail + && granted + .iter() + .any(|scope| scope.as_str() == GMAIL_FULL_MAILBOX_SCOPE) + { + return Err(GoogleOAuthScopeError::FullMailboxScope); + } + + for required in required_scopes { + if !granted.iter().any(|scope| scope == required) { + return Err(GoogleOAuthScopeError::MissingRequiredScope(required)); + } + } + + Ok(()) +} + +fn google_hosted_profile( + connector: OAuthConnector, +) -> Result { + match connector { + OAuthConnector::GoogleDocs | OAuthConnector::GoogleCalendar | OAuthConnector::Gmail => { + oauth_profile(connector, OAuthHostMode::HostedAdmin) + .ok_or(GoogleOAuthProfileError::UnsupportedConnector) + } + _ => Err(GoogleOAuthProfileError::UnsupportedConnector), + } +} + +fn google_required_api_scopes( + connector: OAuthConnector, +) -> Result<&'static [&'static str], GoogleOAuthScopeError> { + match connector { + OAuthConnector::GoogleDocs => Ok(GOOGLE_DOCS_REQUIRED_API_SCOPES), + OAuthConnector::GoogleCalendar => Ok(GOOGLE_CALENDAR_REQUIRED_API_SCOPES), + OAuthConnector::Gmail => Ok(GMAIL_REQUIRED_API_SCOPES), + _ => Err(GoogleOAuthScopeError::UnsupportedConnector), + } +} + +fn valid_google_oauth_client_id(client_id: &str) -> bool { + !client_id.is_empty() + && client_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_')) +} + +fn valid_google_hosted_redirect_uri(connector: OAuthConnector, redirect_uri: &str) -> bool { + if redirect_uri.is_empty() || redirect_uri.chars().any(char::is_whitespace) { + return false; + } + if redirect_uri.contains('?') || redirect_uri.contains('#') { + return false; + } + let Some(after_scheme) = redirect_uri.strip_prefix("https://") else { + return false; + }; + let Some((authority, path)) = after_scheme.split_once('/') else { + return false; + }; + if authority.is_empty() + || authority.contains('@') + || authority.contains(':') + || authority.chars().any(char::is_control) + { + return false; + } + if is_loopback_google_redirect_host(authority) { + return false; + } + + path == connector.broker_callback_path().trim_start_matches('/') +} + +fn is_loopback_google_redirect_host(host: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + host == "localhost" || host.ends_with(".localhost") || host == "127.0.0.1" || host == "0.0.0.0" +} + +fn append_query_param(url: &mut String, key: &str, value: &str) { + if !url.ends_with('?') { + url.push('&'); + } + url.push_str(&percent_encode_query_component(key)); + url.push('='); + url.push_str(&percent_encode_query_component(value)); +} + +fn percent_encode_query_component(value: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push('%'); + encoded.push(char::from(HEX[(byte >> 4) as usize])); + encoded.push(char::from(HEX[(byte & 0x0f) as usize])); + } + } + encoded +} + fn https_base_url_host(public_base_url: &str) -> Option<&str> { let after_scheme = public_base_url.strip_prefix("https://")?; let authority = after_scheme.split('/').next().unwrap_or_default(); @@ -341,6 +529,92 @@ mod tests { } } + #[test] + fn google_hosted_authorization_urls_are_profile_driven_and_https() { + let url = google_authorization_url( + OAuthConnector::GoogleDocs, + "google-client.apps.googleusercontent.com", + "https://api.locality.test/v1/oauth/google-docs/callback", + "intent.random", + ) + .expect("authorization URL"); + + assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth?")); + assert!(url.contains("client_id=google-client.apps.googleusercontent.com")); + assert!(url.contains("response_type=code")); + assert!(url.contains( + "redirect_uri=https%3A%2F%2Fapi.locality.test%2Fv1%2Foauth%2Fgoogle-docs%2Fcallback" + )); + assert!(url.contains("scope=openid%20email%20profile%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdocuments%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdrive.file%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdrive.metadata")); + assert!(url.contains("state=intent.random")); + assert!(url.contains("access_type=offline")); + assert!(url.contains("prompt=consent")); + } + + #[test] + fn google_hosted_authorization_rejects_wrong_callback_shape() { + assert_eq!( + google_authorization_url( + OAuthConnector::Gmail, + "google-client", + "http://localhost:8757/oauth/gmail/callback", + "intent.random", + ), + Err(GoogleOAuthProfileError::InvalidRedirectUri) + ); + assert_eq!( + google_authorization_url( + OAuthConnector::Slack, + "slack-client", + "https://api.locality.test/v1/oauth/slack/callback", + "intent.random", + ), + Err(GoogleOAuthProfileError::UnsupportedConnector) + ); + } + + #[test] + fn google_scope_validation_is_provider_bound() { + let docs = [ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/drive.metadata", + ] + .into_iter() + .map(str::to_owned) + .collect::>(); + assert_eq!( + validate_google_oauth_scopes(OAuthConnector::GoogleDocs, &docs), + Ok(()) + ); + + let gmail_full = [ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.compose", + "https://mail.google.com/", + ] + .into_iter() + .map(str::to_owned) + .collect::>(); + assert_eq!( + validate_google_oauth_scopes(OAuthConnector::Gmail, &gmail_full), + Err(GoogleOAuthScopeError::FullMailboxScope) + ); + + assert_eq!( + validate_google_oauth_scopes(OAuthConnector::GoogleCalendar, &docs), + Err(GoogleOAuthScopeError::MissingRequiredScope( + "https://www.googleapis.com/auth/calendar.events" + )) + ); + } + #[test] fn scope_csv_uses_provider_expected_order() { let profile = oauth_profile(OAuthConnector::Slack, OAuthHostMode::HostedAdmin) From 1bf665e9eb6ecbf41f054b13ef356d95ba2763db Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 20:49:10 +0300 Subject: [PATCH 13/16] fix: harden hosted google oauth contracts --- Cargo.lock | 3 + crates/locality-auth-core/Cargo.toml | 1 + crates/locality-auth-core/src/oauth.rs | 279 +++++++++++++++++++++++-- 3 files changed, 262 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 95267fee..84ce2360 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2301,6 +2301,9 @@ dependencies = [ [[package]] name = "locality-auth-core" version = "0.1.0" +dependencies = [ + "serde", +] [[package]] name = "locality-cloud-files" diff --git a/crates/locality-auth-core/Cargo.toml b/crates/locality-auth-core/Cargo.toml index 9ac94e84..cb8c78b2 100644 --- a/crates/locality-auth-core/Cargo.toml +++ b/crates/locality-auth-core/Cargo.toml @@ -11,3 +11,4 @@ name = "locality_auth_core" path = "src/lib.rs" [dependencies] +serde = { version = "1.0", features = ["derive"] } diff --git a/crates/locality-auth-core/src/oauth.rs b/crates/locality-auth-core/src/oauth.rs index a8ab1fa9..9492d619 100644 --- a/crates/locality-auth-core/src/oauth.rs +++ b/crates/locality-auth-core/src/oauth.rs @@ -1,5 +1,10 @@ //! Shared OAuth connector profiles. +use std::fmt; +use std::net::IpAddr; + +use serde::{Deserialize, Serialize}; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum OAuthConnector { Notion, @@ -134,9 +139,10 @@ pub enum GoogleOAuthScopeError { UnsupportedConnector, FullMailboxScope, MissingRequiredScope(&'static str), + UnsupportedScope(String), } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct GoogleOAuthTokenResponse { pub access_token: String, pub token_type: Option, @@ -146,7 +152,20 @@ pub struct GoogleOAuthTokenResponse { pub id_token: Option, } -#[derive(Clone, Debug, PartialEq, Eq)] +impl fmt::Debug for GoogleOAuthTokenResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GoogleOAuthTokenResponse") + .field("access_token", &REDACTED) + .field("token_type", &self.token_type) + .field("refresh_token", &redacted_if_present(&self.refresh_token)) + .field("expires_in", &self.expires_in) + .field("scope", &self.scope) + .field("id_token", &redacted_if_present(&self.id_token)) + .finish() + } +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct GoogleHostedCredential { pub kind: String, pub connector: String, @@ -163,6 +182,32 @@ pub struct GoogleHostedCredential { pub expires_at: Option, } +impl fmt::Debug for GoogleHostedCredential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GoogleHostedCredential") + .field("kind", &self.kind) + .field("connector", &self.connector) + .field("access_token", &REDACTED) + .field("refresh_token", &REDACTED) + .field("token_type", &self.token_type) + .field("oauth_client_id", &self.oauth_client_id) + .field("account_id", &self.account_id) + .field("account_label", &self.account_label) + .field("workspace_id", &self.workspace_id) + .field("workspace_name", &self.workspace_name) + .field("scopes", &self.scopes) + .field("acquired_at", &self.acquired_at) + .field("expires_at", &self.expires_at) + .finish() + } +} + +const REDACTED: &str = ""; + +fn redacted_if_present(value: &Option) -> Option<&'static str> { + value.as_ref().map(|_| REDACTED) +} + pub const SLACK_AUTO_JOIN_PUBLIC_CHANNELS_SCOPE: &str = "channels:join"; pub const SLACK_LOCAL_BROKER_SCOPES: &[&str] = &[ "channels:read", @@ -280,7 +325,7 @@ pub fn validate_google_oauth_scopes( connector: OAuthConnector, granted: &[String], ) -> Result<(), GoogleOAuthScopeError> { - let required_scopes = google_required_api_scopes(connector)?; + let allowed_scopes = google_hosted_scope_set(connector)?; if connector == OAuthConnector::Gmail && granted .iter() @@ -289,12 +334,19 @@ pub fn validate_google_oauth_scopes( return Err(GoogleOAuthScopeError::FullMailboxScope); } + let required_scopes = google_required_api_scopes(connector)?; for required in required_scopes { if !granted.iter().any(|scope| scope == required) { return Err(GoogleOAuthScopeError::MissingRequiredScope(required)); } } + for scope in granted { + if !allowed_scopes.iter().any(|allowed| scope == allowed) { + return Err(GoogleOAuthScopeError::UnsupportedScope(scope.clone())); + } + } + Ok(()) } @@ -310,6 +362,17 @@ fn google_hosted_profile( } } +fn google_hosted_scope_set( + connector: OAuthConnector, +) -> Result<&'static [&'static str], GoogleOAuthScopeError> { + match connector { + OAuthConnector::GoogleDocs => Ok(GOOGLE_DOCS_HOSTED_ADMIN_SCOPES), + OAuthConnector::GoogleCalendar => Ok(GOOGLE_CALENDAR_HOSTED_ADMIN_SCOPES), + OAuthConnector::Gmail => Ok(GMAIL_HOSTED_ADMIN_SCOPES), + _ => Err(GoogleOAuthScopeError::UnsupportedConnector), + } +} + fn google_required_api_scopes( connector: OAuthConnector, ) -> Result<&'static [&'static str], GoogleOAuthScopeError> { @@ -329,35 +392,93 @@ fn valid_google_oauth_client_id(client_id: &str) -> bool { } fn valid_google_hosted_redirect_uri(connector: OAuthConnector, redirect_uri: &str) -> bool { - if redirect_uri.is_empty() || redirect_uri.chars().any(char::is_whitespace) { + let Some(parsed) = parse_hosted_https_redirect_uri(redirect_uri) else { return false; + }; + + !is_loopback_google_redirect_host(parsed.host) + && parsed.path == connector.broker_callback_path() +} + +fn is_loopback_google_redirect_host(host: &str) -> bool { + let host = host.trim_end_matches('.').to_ascii_lowercase(); + if host == "localhost" || host.ends_with(".localhost") { + return true; } - if redirect_uri.contains('?') || redirect_uri.contains('#') { - return false; + match host.parse::() { + Ok(ip) => ip.is_loopback() || ip.is_unspecified(), + Err(_) => false, } - let Some(after_scheme) = redirect_uri.strip_prefix("https://") else { - return false; - }; - let Some((authority, path)) = after_scheme.split_once('/') else { - return false; - }; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct HostedHttpsRedirectUri<'a> { + host: &'a str, + path: &'a str, +} + +fn parse_hosted_https_redirect_uri(uri: &str) -> Option> { + if uri.is_empty() || uri.chars().any(char::is_whitespace) { + return None; + } + if uri.contains('?') || uri.contains('#') { + return None; + } + let after_scheme = uri.strip_prefix("https://")?; + let (authority, path) = after_scheme.split_once('/')?; + if path.is_empty() { + return None; + } + let host = parse_https_authority_host(authority)?; + Some(HostedHttpsRedirectUri { + host, + path: &uri[uri.len() - path.len() - 1..], + }) +} + +fn parse_https_authority_host(authority: &str) -> Option<&str> { if authority.is_empty() || authority.contains('@') - || authority.contains(':') - || authority.chars().any(char::is_control) + || authority + .chars() + .any(|char| char.is_control() || char.is_whitespace()) { - return false; + return None; } - if is_loopback_google_redirect_host(authority) { - return false; + + if let Some(bracketed_host) = authority.strip_prefix('[') { + let closing_bracket = bracketed_host.find(']')?; + let host = &bracketed_host[..closing_bracket]; + let suffix = &bracketed_host[closing_bracket + 1..]; + if host.is_empty() || !valid_optional_https_port_suffix(suffix) { + return None; + } + return Some(host); } - path == connector.broker_callback_path().trim_start_matches('/') + let Some((host, port)) = authority.split_once(':') else { + return (!authority.is_empty()).then_some(authority); + }; + if host.is_empty() || port.contains(':') || !valid_https_port(port) { + return None; + } + Some(host) } -fn is_loopback_google_redirect_host(host: &str) -> bool { - let host = host.trim_end_matches('.').to_ascii_lowercase(); - host == "localhost" || host.ends_with(".localhost") || host == "127.0.0.1" || host == "0.0.0.0" +fn valid_optional_https_port_suffix(suffix: &str) -> bool { + if suffix.is_empty() { + return true; + } + let Some(port) = suffix.strip_prefix(':') else { + return false; + }; + valid_https_port(port) +} + +fn valid_https_port(port: &str) -> bool { + !port.is_empty() + && port.bytes().all(|byte| byte.is_ascii_digit()) + && port.parse::().is_ok() } fn append_query_param(url: &mut String, key: &str, value: &str) { @@ -573,6 +694,47 @@ mod tests { ); } + #[test] + fn google_hosted_authorization_accepts_supported_connectors_with_https_ports() { + for connector in [ + OAuthConnector::GoogleDocs, + OAuthConnector::GoogleCalendar, + OAuthConnector::Gmail, + ] { + let redirect_uri = format!( + "https://api.locality.test:8443{}", + connector.broker_callback_path() + ); + + let url = google_authorization_url( + connector, + "google-client.apps.googleusercontent.com", + &redirect_uri, + "intent.random", + ) + .expect("authorization URL"); + + assert!(url.starts_with("https://accounts.google.com/o/oauth2/v2/auth?")); + assert!(url.contains(&format!( + "redirect_uri={}", + percent_encode_query_component(&redirect_uri) + ))); + } + } + + #[test] + fn google_hosted_authorization_rejects_supported_connector_wrong_callback_path() { + assert_eq!( + google_authorization_url( + OAuthConnector::Gmail, + "google-client", + "https://api.locality.test/v1/oauth/google-docs/callback", + "intent.random", + ), + Err(GoogleOAuthProfileError::InvalidRedirectUri) + ); + } + #[test] fn google_scope_validation_is_provider_bound() { let docs = [ @@ -615,6 +777,72 @@ mod tests { ); } + #[test] + fn google_scope_validation_rejects_extra_scopes() { + let mut docs = hosted_scope_strings(OAuthConnector::GoogleDocs); + docs.push("https://www.googleapis.com/auth/calendar.events".to_string()); + + assert_eq!( + validate_google_oauth_scopes(OAuthConnector::GoogleDocs, &docs), + Err(GoogleOAuthScopeError::UnsupportedScope( + "https://www.googleapis.com/auth/calendar.events".to_string() + )) + ); + + let mut gmail = hosted_scope_strings(OAuthConnector::Gmail); + gmail.push(GMAIL_FULL_MAILBOX_SCOPE.to_string()); + + assert_eq!( + validate_google_oauth_scopes(OAuthConnector::Gmail, &gmail), + Err(GoogleOAuthScopeError::FullMailboxScope) + ); + } + + #[test] + fn google_oauth_json_contracts_require_serde_traits_and_redact_debug() { + fn assert_json_contract() + where + T: serde::Serialize + for<'de> serde::Deserialize<'de>, + { + } + assert_json_contract::(); + assert_json_contract::(); + + let token = GoogleOAuthTokenResponse { + access_token: "access-secret".to_string(), + token_type: Some("Bearer".to_string()), + refresh_token: Some("refresh-secret".to_string()), + expires_in: Some(3600), + scope: Some("openid email".to_string()), + id_token: Some("id-secret".to_string()), + }; + let token_debug = format!("{token:?}"); + assert!(token_debug.contains("")); + assert!(!token_debug.contains("access-secret")); + assert!(!token_debug.contains("refresh-secret")); + assert!(!token_debug.contains("id-secret")); + + let credential = GoogleHostedCredential { + kind: "hosted_oauth".to_string(), + connector: "gmail".to_string(), + access_token: "credential-access-secret".to_string(), + refresh_token: "credential-refresh-secret".to_string(), + token_type: Some("Bearer".to_string()), + oauth_client_id: "google-client".to_string(), + account_id: Some("account-id".to_string()), + account_label: Some("account@example.test".to_string()), + workspace_id: Some("workspace-id".to_string()), + workspace_name: Some("Workspace".to_string()), + scopes: hosted_scope_strings(OAuthConnector::Gmail), + acquired_at: 100, + expires_at: Some(3700), + }; + let credential_debug = format!("{credential:?}"); + assert!(credential_debug.contains("")); + assert!(!credential_debug.contains("credential-access-secret")); + assert!(!credential_debug.contains("credential-refresh-secret")); + } + #[test] fn scope_csv_uses_provider_expected_order() { let profile = oauth_profile(OAuthConnector::Slack, OAuthHostMode::HostedAdmin) @@ -664,4 +892,13 @@ mod tests { fn scope_strings(scopes: &[&str]) -> Vec { scopes.iter().map(|scope| (*scope).to_string()).collect() } + + fn hosted_scope_strings(connector: OAuthConnector) -> Vec { + oauth_profile(connector, OAuthHostMode::HostedAdmin) + .expect("hosted profile") + .scopes + .iter() + .map(|scope| (*scope).to_string()) + .collect() + } } From 161a892fad59188fe8fddd57c913703a7bbbbdc8 Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 20:55:10 +0300 Subject: [PATCH 14/16] fix: reject malformed google oauth redirects --- crates/locality-auth-core/src/oauth.rs | 40 ++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/locality-auth-core/src/oauth.rs b/crates/locality-auth-core/src/oauth.rs index 9492d619..ad1f6942 100644 --- a/crates/locality-auth-core/src/oauth.rs +++ b/crates/locality-auth-core/src/oauth.rs @@ -406,7 +406,11 @@ fn is_loopback_google_redirect_host(host: &str) -> bool { return true; } match host.parse::() { - Ok(ip) => ip.is_loopback() || ip.is_unspecified(), + Ok(IpAddr::V4(ip)) => ip.is_loopback() || ip.is_unspecified(), + Ok(IpAddr::V6(ip)) => match ip.to_ipv4_mapped() { + Some(mapped) => mapped.is_loopback() || mapped.is_unspecified(), + None => ip.is_loopback() || ip.is_unspecified(), + }, Err(_) => false, } } @@ -450,11 +454,14 @@ fn parse_https_authority_host(authority: &str) -> Option<&str> { let closing_bracket = bracketed_host.find(']')?; let host = &bracketed_host[..closing_bracket]; let suffix = &bracketed_host[closing_bracket + 1..]; - if host.is_empty() || !valid_optional_https_port_suffix(suffix) { + if host.parse::().is_err() || !valid_optional_https_port_suffix(suffix) { return None; } return Some(host); } + if authority.contains('[') || authority.contains(']') { + return None; + } let Some((host, port)) = authority.split_once(':') else { return (!authority.is_empty()).then_some(authority); @@ -722,6 +729,35 @@ mod tests { } } + #[test] + fn google_hosted_authorization_rejects_local_and_malformed_authorities() { + for redirect_uri in [ + "https://[::ffff:127.0.0.1]/v1/oauth/gmail/callback", + "https://[::ffff:0.0.0.0]/v1/oauth/gmail/callback", + "https://[api.locality.test]/v1/oauth/gmail/callback", + "https://api.locality.test]/v1/oauth/gmail/callback", + ] { + assert_eq!( + google_authorization_url( + OAuthConnector::Gmail, + "google-client", + redirect_uri, + "intent.random", + ), + Err(GoogleOAuthProfileError::InvalidRedirectUri), + "{redirect_uri} must be rejected" + ); + } + + google_authorization_url( + OAuthConnector::Gmail, + "google-client", + "https://api.locality.test:8443/v1/oauth/gmail/callback", + "intent.random", + ) + .expect("valid explicit HTTPS port"); + } + #[test] fn google_hosted_authorization_rejects_supported_connector_wrong_callback_path() { assert_eq!( From f30593a0d91dde51624f16c5f7f689acd2c5f5bf Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 21:02:39 +0300 Subject: [PATCH 15/16] feat: share google oauth scope validation --- crates/locality-gmail/src/oauth.rs | 51 ++++++--- crates/locality-google-calendar/src/oauth.rs | 57 +++++++--- crates/locality-google-docs/src/oauth.rs | 105 ++++++++++++++++++- 3 files changed, 181 insertions(+), 32 deletions(-) diff --git a/crates/locality-gmail/src/oauth.rs b/crates/locality-gmail/src/oauth.rs index 827f9b12..b56c9d91 100644 --- a/crates/locality-gmail/src/oauth.rs +++ b/crates/locality-gmail/src/oauth.rs @@ -1,10 +1,10 @@ -use std::collections::BTreeSet; use std::fmt; use std::sync::OnceLock; use locality_auth_core::oauth::{ GMAIL_FULL_MAILBOX_SCOPE as AUTH_CORE_GMAIL_FULL_MAILBOX_SCOPE, GMAIL_LOCAL_BROKER_SCOPES, - GMAIL_REQUIRED_API_SCOPES, OAuthConnector, + GoogleOAuthScopeError as SharedGoogleOAuthScopeError, OAuthConnector, + validate_google_oauth_scopes, }; use locality_connector::ConnectorCapabilities; use locality_connector::oauth_broker::{ @@ -21,7 +21,6 @@ pub const DEFAULT_GMAIL_OAUTH_BROKER_URL: &str = "https://afs-oauth-broker.saura pub const DEFAULT_GMAIL_OAUTH_REDIRECT_URI: &str = OAuthConnector::Gmail.default_local_callback_uri(); pub const GMAIL_OAUTH_SCOPES: &[&str] = GMAIL_LOCAL_BROKER_SCOPES; -const REQUIRED_GMAIL_API_SCOPES: &[&str] = GMAIL_REQUIRED_API_SCOPES; pub const GMAIL_FULL_MAILBOX_SCOPE: &str = AUTH_CORE_GMAIL_FULL_MAILBOX_SCOPE; static REQWEST_CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); @@ -140,6 +139,7 @@ impl StoredGmailCredential { pub enum GmailOAuthScopeError { FullMailboxScope, MissingRequiredScope(&'static str), + UnsupportedScope(String), } impl fmt::Display for GmailOAuthScopeError { @@ -153,6 +153,10 @@ impl fmt::Display for GmailOAuthScopeError { f, "Gmail OAuth broker response missing required Gmail OAuth scope `{scope}`; reconnect with the default Gmail OAuth broker configuration" ), + Self::UnsupportedScope(scope) => write!( + f, + "Gmail OAuth broker returned unsupported Gmail OAuth scope `{scope}`; reconnect with the default Gmail OAuth broker configuration" + ), } } } @@ -160,21 +164,18 @@ impl fmt::Display for GmailOAuthScopeError { impl std::error::Error for GmailOAuthScopeError {} pub fn validate_gmail_oauth_scopes(scopes: &[String]) -> Result<(), GmailOAuthScopeError> { - if scopes - .iter() - .any(|scope| scope.as_str() == GMAIL_FULL_MAILBOX_SCOPE) - { - return Err(GmailOAuthScopeError::FullMailboxScope); - } - - let granted = scopes.iter().map(String::as_str).collect::>(); - for required in REQUIRED_GMAIL_API_SCOPES { - if !granted.contains(required) { - return Err(GmailOAuthScopeError::MissingRequiredScope(required)); + validate_google_oauth_scopes(OAuthConnector::Gmail, scopes).map_err(|error| match error { + SharedGoogleOAuthScopeError::FullMailboxScope => GmailOAuthScopeError::FullMailboxScope, + SharedGoogleOAuthScopeError::MissingRequiredScope(scope) => { + GmailOAuthScopeError::MissingRequiredScope(scope) } - } - - Ok(()) + SharedGoogleOAuthScopeError::UnsupportedScope(scope) => { + GmailOAuthScopeError::UnsupportedScope(scope) + } + SharedGoogleOAuthScopeError::UnsupportedConnector => { + unreachable!("Gmail is a supported Google OAuth connector") + } + }) } #[derive(Clone, Debug)] @@ -350,6 +351,22 @@ mod tests { assert!(error.to_string().contains(GMAIL_FULL_MAILBOX_SCOPE)); } + #[test] + fn gmail_scope_validation_rejects_unsupported_extra_scope() { + let mut scopes = gmail_scopes(); + scopes.push("https://www.googleapis.com/auth/documents".to_string()); + + let error = validate_gmail_oauth_scopes(&scopes).expect_err("unsupported Docs scope"); + + assert_eq!( + error, + GmailOAuthScopeError::UnsupportedScope( + "https://www.googleapis.com/auth/documents".to_string() + ) + ); + assert!(error.to_string().contains("unsupported Gmail OAuth scope")); + } + #[test] fn stored_capabilities_match_gmail_v1() { let capabilities: ConnectorCapabilities = diff --git a/crates/locality-google-calendar/src/oauth.rs b/crates/locality-google-calendar/src/oauth.rs index 9dce76c3..c6ac98fa 100644 --- a/crates/locality-google-calendar/src/oauth.rs +++ b/crates/locality-google-calendar/src/oauth.rs @@ -1,9 +1,9 @@ -use std::collections::BTreeSet; use std::fmt; use std::sync::OnceLock; use locality_auth_core::oauth::{ - GOOGLE_CALENDAR_LOCAL_BROKER_SCOPES, GOOGLE_CALENDAR_REQUIRED_API_SCOPES, OAuthConnector, + GOOGLE_CALENDAR_LOCAL_BROKER_SCOPES, GoogleOAuthScopeError as SharedGoogleOAuthScopeError, + OAuthConnector, validate_google_oauth_scopes, }; use locality_connector::ConnectorCapabilities; use locality_connector::oauth_broker::{ @@ -21,7 +21,6 @@ pub const DEFAULT_GOOGLE_CALENDAR_OAUTH_BROKER_URL: &str = pub const DEFAULT_GOOGLE_CALENDAR_OAUTH_REDIRECT_URI: &str = OAuthConnector::GoogleCalendar.default_local_callback_uri(); pub const GOOGLE_CALENDAR_OAUTH_SCOPES: &[&str] = GOOGLE_CALENDAR_LOCAL_BROKER_SCOPES; -const REQUIRED_GOOGLE_CALENDAR_API_SCOPES: &[&str] = GOOGLE_CALENDAR_REQUIRED_API_SCOPES; static REQWEST_CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new(); @@ -138,6 +137,7 @@ impl StoredGoogleCalendarCredential { #[derive(Clone, Debug, PartialEq, Eq)] pub enum GoogleCalendarOAuthScopeError { MissingRequiredScope(&'static str), + UnsupportedScope(String), } impl fmt::Display for GoogleCalendarOAuthScopeError { @@ -147,6 +147,10 @@ impl fmt::Display for GoogleCalendarOAuthScopeError { f, "Google Calendar OAuth broker response missing required Google Calendar OAuth scope `{scope}`; reconnect with the default Google Calendar OAuth broker configuration" ), + Self::UnsupportedScope(scope) => write!( + f, + "Google Calendar OAuth broker returned unsupported Google Calendar OAuth scope `{scope}`; reconnect with the default Google Calendar OAuth broker configuration" + ), } } } @@ -156,16 +160,22 @@ impl std::error::Error for GoogleCalendarOAuthScopeError {} pub fn validate_google_calendar_oauth_scopes( scopes: &[String], ) -> Result<(), GoogleCalendarOAuthScopeError> { - let granted = scopes.iter().map(String::as_str).collect::>(); - for required in REQUIRED_GOOGLE_CALENDAR_API_SCOPES { - if !granted.contains(required) { - return Err(GoogleCalendarOAuthScopeError::MissingRequiredScope( - required, - )); - } - } - - Ok(()) + validate_google_oauth_scopes(OAuthConnector::GoogleCalendar, scopes).map_err( + |error| match error { + SharedGoogleOAuthScopeError::MissingRequiredScope(scope) => { + GoogleCalendarOAuthScopeError::MissingRequiredScope(scope) + } + SharedGoogleOAuthScopeError::UnsupportedScope(scope) => { + GoogleCalendarOAuthScopeError::UnsupportedScope(scope) + } + SharedGoogleOAuthScopeError::FullMailboxScope => { + unreachable!("full mailbox scope is only special-cased for Gmail") + } + SharedGoogleOAuthScopeError::UnsupportedConnector => { + unreachable!("Google Calendar is a supported Google OAuth connector") + } + }, + ) } #[derive(Clone, Debug)] @@ -349,6 +359,27 @@ mod tests { ); } + #[test] + fn google_calendar_scope_validation_rejects_unsupported_extra_scope() { + let mut scopes = calendar_scopes(); + scopes.push("https://www.googleapis.com/auth/documents".to_string()); + + let error = validate_google_calendar_oauth_scopes(&scopes) + .expect_err("unsupported Google Docs scope"); + + assert_eq!( + error, + GoogleCalendarOAuthScopeError::UnsupportedScope( + "https://www.googleapis.com/auth/documents".to_string() + ) + ); + assert!( + error + .to_string() + .contains("unsupported Google Calendar OAuth scope") + ); + } + #[test] fn stored_capabilities_match_google_calendar_connector_support() { let capabilities: ConnectorCapabilities = diff --git a/crates/locality-google-docs/src/oauth.rs b/crates/locality-google-docs/src/oauth.rs index 4c6013b6..e9f6900f 100644 --- a/crates/locality-google-docs/src/oauth.rs +++ b/crates/locality-google-docs/src/oauth.rs @@ -1,7 +1,10 @@ use std::fmt; use std::sync::OnceLock; -use locality_auth_core::oauth::{GOOGLE_DOCS_LOCAL_BROKER_SCOPES, OAuthConnector}; +use locality_auth_core::oauth::{ + GOOGLE_DOCS_LOCAL_BROKER_SCOPES, GoogleOAuthScopeError as SharedGoogleOAuthScopeError, + OAuthConnector, validate_google_oauth_scopes, +}; use locality_connector::ConnectorCapabilities; use locality_connector::oauth_broker::{ OAuthBrokerCodeExchange, OAuthBrokerRefresh, OAuthBrokerStart, OAuthBrokerStartResponse, @@ -128,6 +131,48 @@ impl StoredGoogleDocsCredential { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GoogleDocsOAuthScopeError { + MissingRequiredScope(&'static str), + UnsupportedScope(String), +} + +impl fmt::Display for GoogleDocsOAuthScopeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingRequiredScope(scope) => write!( + f, + "Google Docs OAuth broker response missing required Google Docs OAuth scope `{scope}`; reconnect with the default Google Docs OAuth broker configuration" + ), + Self::UnsupportedScope(scope) => write!( + f, + "Google Docs OAuth broker returned unsupported Google Docs OAuth scope `{scope}`; reconnect with the default Google Docs OAuth broker configuration" + ), + } + } +} + +impl std::error::Error for GoogleDocsOAuthScopeError {} + +pub fn validate_google_docs_oauth_scopes( + scopes: &[String], +) -> Result<(), GoogleDocsOAuthScopeError> { + validate_google_oauth_scopes(OAuthConnector::GoogleDocs, scopes).map_err(|error| match error { + SharedGoogleOAuthScopeError::MissingRequiredScope(scope) => { + GoogleDocsOAuthScopeError::MissingRequiredScope(scope) + } + SharedGoogleOAuthScopeError::UnsupportedScope(scope) => { + GoogleDocsOAuthScopeError::UnsupportedScope(scope) + } + SharedGoogleOAuthScopeError::FullMailboxScope => { + unreachable!("full mailbox scope is only special-cased for Gmail") + } + SharedGoogleOAuthScopeError::UnsupportedConnector => { + unreachable!("Google Docs is a supported Google OAuth connector") + } + }) +} + #[derive(Clone, Debug)] pub struct HttpGoogleDocsOAuthBrokerClient { base_url: String, @@ -220,7 +265,8 @@ mod tests { use super::{ DEFAULT_GOOGLE_DOCS_OAUTH_REDIRECT_URI, GOOGLE_DOCS_CONNECTOR_ID, GOOGLE_DOCS_OAUTH_SCOPES, - StoredGoogleDocsCredential, google_docs_capabilities_json, + GoogleDocsOAuthScopeError, StoredGoogleDocsCredential, google_docs_capabilities_json, + validate_google_docs_oauth_scopes, }; #[test] @@ -243,6 +289,61 @@ mod tests { ); } + #[test] + fn google_docs_scope_validation_requires_docs_and_drive_scopes() { + let scopes = GOOGLE_DOCS_OAUTH_SCOPES + .iter() + .map(|scope| scope.to_string()) + .collect::>(); + + validate_google_docs_oauth_scopes(&scopes).expect("valid Google Docs scopes"); + + let missing_drive_metadata = GOOGLE_DOCS_OAUTH_SCOPES + .iter() + .filter(|scope| **scope != "https://www.googleapis.com/auth/drive.metadata") + .map(|scope| scope.to_string()) + .collect::>(); + + let error = validate_google_docs_oauth_scopes(&missing_drive_metadata) + .expect_err("missing drive.metadata scope"); + + assert_eq!( + error, + GoogleDocsOAuthScopeError::MissingRequiredScope( + "https://www.googleapis.com/auth/drive.metadata" + ) + ); + assert!( + error + .to_string() + .contains("missing required Google Docs OAuth scope") + ); + } + + #[test] + fn google_docs_scope_validation_rejects_unsupported_extra_scope() { + let mut scopes = GOOGLE_DOCS_OAUTH_SCOPES + .iter() + .map(|scope| scope.to_string()) + .collect::>(); + scopes.push("https://www.googleapis.com/auth/calendar.events".to_string()); + + let error = validate_google_docs_oauth_scopes(&scopes) + .expect_err("unsupported Google Calendar scope"); + + assert_eq!( + error, + GoogleDocsOAuthScopeError::UnsupportedScope( + "https://www.googleapis.com/auth/calendar.events".to_string() + ) + ); + assert!( + error + .to_string() + .contains("unsupported Google Docs OAuth scope") + ); + } + #[test] fn stored_capabilities_match_google_docs_connector_support() { let capabilities: ConnectorCapabilities = From 75e8f1b41acada81bdcc2fca1ef53132f78f1a3d Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 6 Aug 2026 21:20:17 +0300 Subject: [PATCH 16/16] fix: enforce google docs oauth scopes --- crates/loc-cli/src/connect.rs | 4 + crates/loc-cli/tests/connect.rs | 80 +++++++++++++++ crates/locality-google-docs/src/oauth.rs | 105 ++++++++++++++++---- crates/localityd/src/google_docs.rs | 19 +++- crates/localityd/tests/source_descriptor.rs | 82 ++++++++++++++- 5 files changed, 266 insertions(+), 24 deletions(-) diff --git a/crates/loc-cli/src/connect.rs b/crates/loc-cli/src/connect.rs index 3f7a954c..04344f66 100644 --- a/crates/loc-cli/src/connect.rs +++ b/crates/loc-cli/src/connect.rs @@ -13,6 +13,7 @@ use locality_google_calendar::{ GOOGLE_CALENDAR_CONNECTOR_ID, GOOGLE_CALENDAR_OAUTH_SCOPES, HttpGoogleCalendarOAuthBrokerClient, StoredGoogleCalendarCredential, }; +use locality_google_docs::oauth::validate_google_docs_oauth_scopes; use locality_google_docs::{ GOOGLE_DOCS_CONNECTOR_ID, GOOGLE_DOCS_OAUTH_SCOPES, HttpGoogleDocsOAuthBrokerClient, StoredGoogleDocsCredential, google_docs_capabilities_json, @@ -754,6 +755,9 @@ where redirect_uri: options.redirect_uri, }; let token = exchange.exchange_code(&exchange_request)?; + validate_google_docs_oauth_scopes(&token.scopes).map_err(|error| { + ConnectError::OAuthExchangeFailed(OAuthExchangeFailure::google_docs(error.to_string())) + })?; let acquired_at = timestamp_secs(); let secret_ref = format!("connection:{}", connection_id.0); let stored = StoredGoogleDocsCredential::from_broker_token( diff --git a/crates/loc-cli/tests/connect.rs b/crates/loc-cli/tests/connect.rs index ff20ead9..29738eef 100644 --- a/crates/loc-cli/tests/connect.rs +++ b/crates/loc-cli/tests/connect.rs @@ -329,6 +329,54 @@ fn connect_google_docs_broker_oauth_stores_refresh_handle_without_secrets() { assert!(!json.contains("secret_ref")); } +#[test] +fn connect_google_docs_broker_oauth_rejects_unsupported_scope_before_persistence() { + let mut store = InMemoryStateStore::new(); + let credentials = InMemoryCredentialStore::new(); + let mut scopes = GOOGLE_DOCS_OAUTH_SCOPES + .iter() + .map(|scope| scope.to_string()) + .collect::>(); + scopes.push("https://www.googleapis.com/auth/calendar.events".to_string()); + let exchange = ScopedFakeGoogleDocsBrokerOAuthExchange { scopes }; + + let error = run_connect_google_docs_broker_oauth( + &mut store, + &credentials, + GoogleDocsBrokerOAuthConnectOptions { + connection_id: Some(ConnectionId::new("docs-work")), + broker_url: "https://auth.example.test".to_string(), + client_id: "google-client-id".to_string(), + session: "broker-session".to_string(), + state: "state-1".to_string(), + code: "oauth-code".to_string(), + redirect_uri: "http://localhost:8757/oauth/google-docs/callback".to_string(), + }, + &exchange, + ) + .expect_err("unsupported Google Docs scope must be rejected"); + + assert_eq!(error.code(), "oauth_exchange_failed"); + assert!( + error + .message() + .contains("unsupported Google Docs OAuth scope") + ); + assert!( + error + .message() + .contains("https://www.googleapis.com/auth/calendar.events") + ); + assert_eq!(error.suggested_command(), Some("loc connect google-docs")); + assert!(credentials.get("connection:docs-work").is_err()); + assert!( + store + .get_connection(&ConnectionId::new("docs-work")) + .expect("lookup connection") + .is_none() + ); +} + #[test] fn connect_gmail_broker_oauth_stores_refresh_handle_without_secrets() { let mut store = InMemoryStateStore::new(); @@ -1196,6 +1244,38 @@ impl GoogleDocsOAuthBrokerExchange for FakeGoogleDocsBrokerOAuthExchange { } } +#[derive(Clone, Debug)] +struct ScopedFakeGoogleDocsBrokerOAuthExchange { + scopes: Vec, +} + +impl GoogleDocsOAuthBrokerExchange for ScopedFakeGoogleDocsBrokerOAuthExchange { + fn exchange_code( + &self, + request: &OAuthBrokerCodeExchange, + ) -> Result { + assert_eq!(request.connector, "google-docs"); + assert_eq!(request.session, "broker-session"); + assert_eq!(request.state, "state-1"); + assert_eq!(request.code, "oauth-code"); + assert_eq!( + request.redirect_uri, + "http://localhost:8757/oauth/google-docs/callback" + ); + Ok(OAuthBrokerToken { + access_token: "oauth-access-token".to_string(), + token_type: Some("Bearer".to_string()), + expires_in: Some(3600), + refresh_token_handle: Some("opaque-refresh-handle".to_string()), + account_id: Some("acct-1".to_string()), + account_label: Some("user@example.com".to_string()), + workspace_id: Some("google-drive".to_string()), + workspace_name: Some("Google Drive".to_string()), + scopes: self.scopes.clone(), + }) + } +} + #[derive(Clone, Debug)] struct FakeGmailBrokerOAuthExchange; diff --git a/crates/locality-google-docs/src/oauth.rs b/crates/locality-google-docs/src/oauth.rs index e9f6900f..f12fc310 100644 --- a/crates/locality-google-docs/src/oauth.rs +++ b/crates/locality-google-docs/src/oauth.rs @@ -97,11 +97,21 @@ impl StoredGoogleDocsCredential { } } - pub fn refreshed(&self, token: OAuthBrokerToken, acquired_at: u64) -> Self { + pub fn refreshed( + &self, + token: OAuthBrokerToken, + acquired_at: u64, + ) -> Result { let expires_at = token .expires_in .and_then(|expires_in| acquired_at.checked_add(expires_in)); - Self { + let scopes = if token.scopes.is_empty() { + self.scopes.clone() + } else { + validate_google_docs_oauth_scopes(&token.scopes)?; + token.scopes + }; + Ok(Self { kind: "oauth".to_string(), connector: GOOGLE_DOCS_CONNECTOR_ID.to_string(), access_token: token.access_token, @@ -112,17 +122,13 @@ impl StoredGoogleDocsCredential { account_label: token.account_label.or_else(|| self.account_label.clone()), workspace_id: token.workspace_id.or_else(|| self.workspace_id.clone()), workspace_name: token.workspace_name.or_else(|| self.workspace_name.clone()), - scopes: if token.scopes.is_empty() { - self.scopes.clone() - } else { - token.scopes - }, + scopes, refresh_token_handle: token .refresh_token_handle .or_else(|| self.refresh_token_handle.clone()), acquired_at, expires_at, - } + }) } pub fn expires_soon(&self, now: u64) -> bool { @@ -409,20 +415,22 @@ mod tests { 100, ); - let refreshed = stored.refreshed( - OAuthBrokerToken { - access_token: "new-access-token".to_string(), - token_type: Some("Bearer".to_string()), - expires_in: Some(7200), - refresh_token_handle: Some("handle-2".to_string()), - account_id: None, - account_label: None, - workspace_id: None, - workspace_name: None, - scopes: vec![], - }, - 200, - ); + let refreshed = stored + .refreshed( + OAuthBrokerToken { + access_token: "new-access-token".to_string(), + token_type: Some("Bearer".to_string()), + expires_in: Some(7200), + refresh_token_handle: Some("handle-2".to_string()), + account_id: None, + account_label: None, + workspace_id: None, + workspace_name: None, + scopes: vec![], + }, + 200, + ) + .expect("refresh with omitted scopes"); assert_eq!(refreshed.access_token, "new-access-token"); assert_eq!(refreshed.refresh_token_handle.as_deref(), Some("handle-2")); @@ -430,4 +438,57 @@ mod tests { assert_eq!(refreshed.scopes, vec!["openid".to_string()]); assert_eq!(refreshed.expires_at, Some(7400)); } + + #[test] + fn refreshed_broker_credential_rejects_invalid_non_empty_scopes() { + let stored = StoredGoogleDocsCredential::from_broker_token( + OAuthBrokerToken { + access_token: "access-token".to_string(), + token_type: Some("Bearer".to_string()), + expires_in: Some(3600), + refresh_token_handle: Some("handle-1".to_string()), + account_id: Some("acct-1".to_string()), + account_label: Some("user@example.com".to_string()), + workspace_id: Some("google-drive".to_string()), + workspace_name: Some("Google Drive".to_string()), + scopes: GOOGLE_DOCS_OAUTH_SCOPES + .iter() + .map(|scope| scope.to_string()) + .collect(), + }, + "client-id".to_string(), + "https://auth.example.test".to_string(), + 100, + ); + + let mut refreshed_scopes = GOOGLE_DOCS_OAUTH_SCOPES + .iter() + .map(|scope| scope.to_string()) + .collect::>(); + refreshed_scopes.push("https://www.googleapis.com/auth/calendar.events".to_string()); + + let error = stored + .refreshed( + OAuthBrokerToken { + access_token: "new-access-token".to_string(), + token_type: Some("Bearer".to_string()), + expires_in: Some(7200), + refresh_token_handle: Some("handle-2".to_string()), + account_id: None, + account_label: None, + workspace_id: None, + workspace_name: None, + scopes: refreshed_scopes, + }, + 200, + ) + .expect_err("unsupported refreshed scope"); + + assert_eq!( + error, + GoogleDocsOAuthScopeError::UnsupportedScope( + "https://www.googleapis.com/auth/calendar.events".to_string() + ) + ); + } } diff --git a/crates/localityd/src/google_docs.rs b/crates/localityd/src/google_docs.rs index 4ec4622e..b7289a2a 100644 --- a/crates/localityd/src/google_docs.rs +++ b/crates/localityd/src/google_docs.rs @@ -9,6 +9,7 @@ use locality_core::{LocalityError, LocalityResult}; use locality_google_docs::{ GOOGLE_DOCS_CONNECTOR_ID, GoogleDocsConfig, GoogleDocsConnector, HttpGoogleDocsOAuthBrokerClient, StoredGoogleDocsCredential, + oauth::GoogleDocsOAuthScopeError, render::{GOOGLE_DOCS_INLINE_OBJECT_NATIVE_KIND, GOOGLE_DOCS_TABLE_NATIVE_KIND}, }; use locality_store::{ @@ -98,7 +99,9 @@ fn connection_access_token( .map_err(|error| ConnectorResolveError::CredentialStoreUnavailable(error.to_string()))?; if stored.expires_soon(timestamp_secs()) { let refreshed = refresh_oauth_credential(connection, &stored)?; - stored = stored.refreshed(refreshed, timestamp_secs()); + stored = stored + .refreshed(refreshed, timestamp_secs()) + .map_err(|error| google_docs_refresh_scope_error(connection, error))?; let secret = serde_json::to_string(&stored).map_err(|error| { ConnectorResolveError::CredentialStoreUnavailable(error.to_string()) })?; @@ -136,6 +139,20 @@ fn refresh_oauth_credential( .map_err(|error| google_docs_refresh_error(connection, &broker_url, error)) } +fn google_docs_refresh_scope_error( + connection: &ConnectionRecord, + error: GoogleDocsOAuthScopeError, +) -> ConnectorResolveError { + ConnectorResolveError::AuthRequired { + connection_id: connection.connection_id.0.clone(), + message: Some(format!( + "Google Docs credential for connection `{}` could not be refreshed through OAuth broker: {error}", + connection.connection_id.0 + )), + suggested_command: "loc connect google-docs".to_string(), + } +} + fn is_loopback_broker_url(url: &str) -> bool { let Some(authority) = url .strip_prefix("http://") diff --git a/crates/localityd/tests/source_descriptor.rs b/crates/localityd/tests/source_descriptor.rs index 5adfe657..7f55689e 100644 --- a/crates/localityd/tests/source_descriptor.rs +++ b/crates/localityd/tests/source_descriptor.rs @@ -10,7 +10,9 @@ use locality_gmail::{GMAIL_CONNECTOR_ID, GMAIL_OAUTH_SCOPES, StoredGmailCredenti use locality_google_calendar::{ GOOGLE_CALENDAR_CONNECTOR_ID, GOOGLE_CALENDAR_OAUTH_SCOPES, StoredGoogleCalendarCredential, }; -use locality_google_docs::{GOOGLE_DOCS_CONNECTOR_ID, StoredGoogleDocsCredential}; +use locality_google_docs::{ + GOOGLE_DOCS_CONNECTOR_ID, GOOGLE_DOCS_OAUTH_SCOPES, StoredGoogleDocsCredential, +}; use locality_granola::GRANOLA_CONNECTOR_ID; use locality_linear::LINEAR_CONNECTOR_ID; use locality_notion::client::DEFAULT_NOTION_TOKEN_ENV; @@ -2008,6 +2010,84 @@ fn resolving_expired_google_docs_credential_refreshes_with_broker_handle() { assert_eq!(saved.refresh_token_handle.as_deref(), Some("handle-2")); } +#[test] +fn resolving_expired_google_docs_credential_rejects_refresh_unsupported_scope() { + let mut store = InMemoryStateStore::new(); + let credentials = InMemoryCredentialStore::new(); + let (connection_id, secret_ref) = save_google_docs_oauth_connection(&mut store); + let mut refresh_scopes = GOOGLE_DOCS_OAUTH_SCOPES + .iter() + .map(|scope| scope.to_string()) + .collect::>(); + refresh_scopes.push("https://www.googleapis.com/auth/calendar.events".to_string()); + let refresh_response = serde_json::json!({ + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token_handle": "handle-2", + "account_id": "acct-1", + "account_label": "user@example.com", + "workspace_id": "google-drive", + "workspace_name": "Google Drive", + "scopes": refresh_scopes, + }) + .to_string(); + let (broker_url, broker) = spawn_refresh_broker("HTTP/1.1 200 OK", refresh_response); + + let mut stored = StoredGoogleDocsCredential::from_broker_token( + OAuthBrokerToken { + access_token: "expired-access-token".to_string(), + token_type: Some("Bearer".to_string()), + expires_in: Some(1), + refresh_token_handle: Some("handle-1".to_string()), + account_id: Some("acct-1".to_string()), + account_label: Some("user@example.com".to_string()), + workspace_id: Some("google-drive".to_string()), + workspace_name: Some("Google Drive".to_string()), + scopes: GOOGLE_DOCS_OAUTH_SCOPES + .iter() + .map(|scope| scope.to_string()) + .collect(), + }, + "client-id".to_string(), + broker_url, + 1, + ); + stored.expires_at = Some(1); + let original_secret = serde_json::to_string(&stored).expect("credential json"); + credentials + .put(&secret_ref, &original_secret) + .expect("save credential"); + let mount = MountConfig::new( + MountId::new("google-docs-main"), + GOOGLE_DOCS_CONNECTOR_ID, + "/tmp/locality/google-docs", + ) + .with_remote_root_id(RemoteId::new("workspace-folder")) + .with_connection_id(connection_id); + + let error = resolve_source_for_mount(&store, &credentials, &mount) + .expect_err("unsupported refreshed Google Docs scope must be rejected"); + broker.join().expect("broker thread"); + + assert_eq!(error.code(), "auth_required"); + assert!( + error + .message() + .contains("unsupported Google Docs OAuth scope") + ); + assert!( + error + .message() + .contains("https://www.googleapis.com/auth/calendar.events") + ); + assert_eq!(error.suggested_command(), Some("loc connect google-docs")); + assert_eq!( + credentials.get(&secret_ref).expect("saved credential"), + original_secret + ); +} + #[test] fn resolving_expired_google_docs_credential_with_stopped_local_broker_requires_reconnect() { let mut store = InMemoryStateStore::new();