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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/locality-auth-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ path = "src/lib.rs"

[dependencies]
serde = { version = "1.0", features = ["derive"] }

[dev-dependencies]
serde_json = "1.0"
6 changes: 3 additions & 3 deletions crates/locality-auth-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +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.
//! This crate owns stable connector IDs, OAuth callback paths, authority modes,
//! and scope profiles. It does not own token storage, tenant authorization,
//! broker route handling, or hosted source finalization.

pub mod oauth;
109 changes: 109 additions & 0 deletions crates/locality-auth-core/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,54 @@ impl OAuthConnector {

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum OAuthHostMode {
/// Backend-brokered OAuth where the completed credential is handed back to
/// a local desktop/CLI host and source sync remains local authority.
LocalBrokered,
/// Administrator-owned OAuth where the backend stores an opaque credential
/// reference and owns hosted source hydration.
HostedAdmin,
}

impl OAuthHostMode {
pub const fn authority_mode(self) -> ConnectorAuthorityMode {
match self {
Self::LocalBrokered => ConnectorAuthorityMode::LocalDirect,
Self::HostedAdmin => ConnectorAuthorityMode::HostedManaged,
}
}
}

/// Data-authority mode created by a connector authorization flow.
///
/// This is intentionally separate from the provider's OAuth mechanics. A
/// backend may broker either flow, but only `hosted_managed` authorizes backend
/// credential retention and hosted source hydration.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConnectorAuthorityMode {
/// Desktop/CLI owns the connection after brokered credential handoff.
LocalDirect,
/// Hosted backend owns credential storage, refresh, and hydration.
HostedManaged,
}

impl ConnectorAuthorityMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::LocalDirect => "local_direct",
Self::HostedManaged => "hosted_managed",
}
}

pub const fn stores_provider_data_in_hosted_backend(self) -> bool {
matches!(self, Self::HostedManaged)
}

pub const fn is_local_direct(self) -> bool {
matches!(self, Self::LocalDirect)
}
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OAuthProfile {
pub connector: OAuthConnector,
Expand All @@ -72,6 +116,12 @@ pub struct OAuthProfile {
pub broker_callback_path: &'static str,
}

impl OAuthProfile {
pub const fn authority_mode(self) -> ConnectorAuthorityMode {
self.host.authority_mode()
}
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OAuthProfileError {
BrokerBaseUrlMustBeHttps,
Expand Down Expand Up @@ -591,6 +641,15 @@ mod tests {
.expect("local broker profile");
assert_eq!(profile.connector, *connector);
assert_eq!(profile.host, OAuthHostMode::LocalBrokered);
assert_eq!(
profile.authority_mode(),
ConnectorAuthorityMode::LocalDirect
);
assert!(
!profile
.authority_mode()
.stores_provider_data_in_hosted_backend()
);
assert!(
profile
.client_completion_redirect_uri
Expand All @@ -601,10 +660,60 @@ mod tests {
}
}

#[test]
fn oauth_host_modes_map_to_explicit_connector_authority_modes() {
assert_eq!(
OAuthHostMode::LocalBrokered.authority_mode(),
ConnectorAuthorityMode::LocalDirect
);
assert_eq!(
OAuthHostMode::HostedAdmin.authority_mode(),
ConnectorAuthorityMode::HostedManaged
);
assert!(!ConnectorAuthorityMode::LocalDirect.stores_provider_data_in_hosted_backend());
assert!(ConnectorAuthorityMode::HostedManaged.stores_provider_data_in_hosted_backend());

for connector in OAuthConnector::all() {
let local = oauth_profile(*connector, OAuthHostMode::LocalBrokered)
.expect("local broker profile");
let hosted = oauth_profile(*connector, OAuthHostMode::HostedAdmin)
.expect("hosted admin profile");

assert_eq!(local.authority_mode(), ConnectorAuthorityMode::LocalDirect);
assert_eq!(
hosted.authority_mode(),
ConnectorAuthorityMode::HostedManaged
);
}
}

#[test]
fn connector_authority_wire_names_are_stable() {
assert_eq!(ConnectorAuthorityMode::LocalDirect.as_str(), "local_direct");
assert_eq!(
ConnectorAuthorityMode::HostedManaged.as_str(),
"hosted_managed"
);
assert_eq!(
serde_json::to_string(&ConnectorAuthorityMode::LocalDirect)
.expect("serialize local authority"),
"\"local_direct\""
);
assert_eq!(
serde_json::from_str::<ConnectorAuthorityMode>("\"hosted_managed\"")
.expect("deserialize hosted authority"),
ConnectorAuthorityMode::HostedManaged
);
}

#[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.authority_mode(),
ConnectorAuthorityMode::HostedManaged
);
assert_eq!(
hosted.scopes,
&[
Expand Down
22 changes: 22 additions & 0 deletions crates/locality-connector/tests/manifest_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,28 @@ fn strict_parser_rejects_unknown_fields_and_enums() {
);
}

#[test]
fn registry_v1_rejects_connector_authority_fields() {
let mut authority_field = registry_value();
authority_field["connectors"][0]["authority_mode"] = json!("hosted_managed");
assert!(
ConnectorRegistry::parse(&authority_field.to_string())
.expect_err("registry v1 must not accept connector authority")
.to_string()
.contains("unknown field")
);

let mut profile_authority_field = registry_value();
profile_authority_field["connectors"][0]["profiles"][0]["authority_mode"] =
json!("local_direct");
assert!(
ConnectorRegistry::parse(&profile_authority_field.to_string())
.expect_err("registry v1 profiles must not accept connector authority")
.to_string()
.contains("unknown field")
);
}

#[test]
fn validation_rejects_duplicate_defaults_and_missing_default_profile() {
let mut duplicate = registry_value();
Expand Down
12 changes: 11 additions & 1 deletion docs/connector-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ crate cannot ship by itself.
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.
updates. Every OAuth profile must also preserve its connector authority mode:
`LocalBrokered` means local direct authority after credential handoff, while
`HostedAdmin` means backend-owned hosted hydration. A backend-brokered OAuth
callback alone must not imply hosted credential retention or hosted source
replication for desktop users.
8. Add the desktop source ID, setup/auth classification, display metadata, and
`apps/desktop/src/assets/connectors/<id>.svg` icon. Add OAuth-service routing
only when the connector actually uses the public OAuth broker.
Expand Down Expand Up @@ -118,6 +122,12 @@ public connector revision, but the public crates must not depend on private
hosted code. Do not use this manifest branch to enable currently unreachable
portable/batch paths or introduce a dynamic ABI/plugin loader.

OAuth brokering and data authority are separate decisions. Locality may use a
backend callback to complete provider OAuth for a desktop connector, but the
credential is handed back to the local host and provider data stays outside the
hosted backend unless an administrator explicitly creates a hosted-managed
connection.

## Minimal read-only example

The connector advertises no push operations and fails closed on apply. Real
Expand Down
16 changes: 14 additions & 2 deletions docs/oauth-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,20 @@ Locality has two OAuth hosts:
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.
authority modes, and scope profiles. It does not own token storage, tenant
authorization, hosted source finalization, or background job scheduling.

## Public Authority Contract

`locality-auth-core` exposes the public connector authority vocabulary:
`local_direct` and `hosted_managed`. `local_direct` means the local Locality
host resolves credentials and calls the provider directly. `hosted_managed`
means a hosted/admin runtime owns managed credential and provider access.

Desktop-originated OAuth uses the `LocalBrokered` host mode and maps to
`local_direct`, even when the public OAuth broker helps complete the provider
authorization. Hosted/admin OAuth uses the `HostedAdmin` host mode and maps to
`hosted_managed`; it must not be silently treated as local direct authority.

## Public Brokered Desktop Flow

Expand Down
Loading