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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ jobs:
clippy:
name: Clippy
runs-on: ubuntu-latest
environment: GitHub composable-delivery
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
Expand All @@ -40,18 +39,17 @@ jobs:

- name: Configure git credentials for private repos
env:
GH_TOKEN_BUILDS: ${{ secrets.GH_TOKEN_BUILDS }}
KANTEXT_BUILD_GITHUB_TOKEN: ${{ secrets.KANTEXT_BUILD_GITHUB_TOKEN }}
run: |
git config --global credential.helper store
echo "https://${GH_TOKEN_BUILDS}:x-oauth-basic@github.com" > ~/.git-credentials
echo "https://${KANTEXT_BUILD_GITHUB_TOKEN}:x-oauth-basic@github.com" > ~/.git-credentials

- run: cargo clippy --workspace --all-targets --all-features -- -D warnings

# ── Unit tests with coverage ──────────────────────────────────────────
test:
name: Test
runs-on: ubuntu-latest
environment: GitHub composable-delivery
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
Expand All @@ -60,10 +58,10 @@ jobs:

- name: Configure git credentials for private repos
env:
GH_TOKEN_BUILDS: ${{ secrets.GH_TOKEN_BUILDS }}
KANTEXT_BUILD_GITHUB_TOKEN: ${{ secrets.KANTEXT_BUILD_GITHUB_TOKEN }}
run: |
git config --global credential.helper store
echo "https://${GH_TOKEN_BUILDS}:x-oauth-basic@github.com" > ~/.git-credentials
echo "https://${KANTEXT_BUILD_GITHUB_TOKEN}:x-oauth-basic@github.com" > ~/.git-credentials

- name: Clean coverage artifacts
run: cargo llvm-cov clean --workspace
Expand Down Expand Up @@ -96,18 +94,17 @@ jobs:
docs:
name: Documentation
runs-on: ubuntu-latest
environment: GitHub composable-delivery
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2

- name: Configure git credentials for private repos
env:
GH_TOKEN_BUILDS: ${{ secrets.GH_TOKEN_BUILDS }}
KANTEXT_BUILD_GITHUB_TOKEN: ${{ secrets.KANTEXT_BUILD_GITHUB_TOKEN }}
run: |
git config --global credential.helper store
echo "https://${GH_TOKEN_BUILDS}:x-oauth-basic@github.com" > ~/.git-credentials
echo "https://${KANTEXT_BUILD_GITHUB_TOKEN}:x-oauth-basic@github.com" > ~/.git-credentials

- run: cargo doc --workspace --no-deps --all-features
env:
Expand All @@ -123,6 +120,14 @@ jobs:
with:
toolchain: "1.88"
- uses: Swatinem/rust-cache@v2

- name: Configure git credentials for private repos
env:
KANTEXT_BUILD_GITHUB_TOKEN: ${{ secrets.KANTEXT_BUILD_GITHUB_TOKEN }}
run: |
git config --global credential.helper store
echo "https://${KANTEXT_BUILD_GITHUB_TOKEN}:x-oauth-basic@github.com" > ~/.git-credentials

- run: cargo check --workspace

# ── Integration tests (real Salesforce org, excluding WASM bridge) ──
Expand All @@ -138,10 +143,10 @@ jobs:

- name: Configure git credentials for private repos
env:
GH_TOKEN_BUILDS: ${{ secrets.GH_TOKEN_BUILDS }}
KANTEXT_BUILD_GITHUB_TOKEN: ${{ secrets.KANTEXT_BUILD_GITHUB_TOKEN }}
run: |
git config --global credential.helper store
echo "https://${GH_TOKEN_BUILDS}:x-oauth-basic@github.com" > ~/.git-credentials
echo "https://${KANTEXT_BUILD_GITHUB_TOKEN}:x-oauth-basic@github.com" > ~/.git-credentials

- name: Verify credentials
env:
Expand Down Expand Up @@ -200,10 +205,10 @@ jobs:

- name: Configure git credentials for private repos
env:
GH_TOKEN_BUILDS: ${{ secrets.GH_TOKEN_BUILDS }}
KANTEXT_BUILD_GITHUB_TOKEN: ${{ secrets.KANTEXT_BUILD_GITHUB_TOKEN }}
run: |
git config --global credential.helper store
echo "https://${GH_TOKEN_BUILDS}:x-oauth-basic@github.com" > ~/.git-credentials
echo "https://${KANTEXT_BUILD_GITHUB_TOKEN}:x-oauth-basic@github.com" > ~/.git-credentials

- name: Verify credentials
env:
Expand Down
3 changes: 2 additions & 1 deletion config/project-scratch-def.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"description": "Scratch org for busbar-sf-api integration tests. Create with: sf org create scratch -f config/project-scratch-def.json -a busbar-test --duration-days 7",
"features": [
"Knowledge",
"ServiceCloud"
"ServiceCloud",
"DataCloud"
]
}
4 changes: 3 additions & 1 deletion crates/sf-auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ mod storage;
pub use credentials::{Credentials, SalesforceCredentials};
pub use error::{Error, ErrorKind, Result};
pub use jwt::JwtAuth;
pub use oauth::{OAuthClient, OAuthConfig, TokenInfo, TokenResponse, WebFlowAuth};
pub use oauth::{
DataCloudTokenResponse, OAuthClient, OAuthConfig, TokenInfo, TokenResponse, WebFlowAuth,
};
pub use storage::{FileTokenStorage, TokenStorage};

/// Default Salesforce login URL for production.
Expand Down
197 changes: 197 additions & 0 deletions crates/sf-auth/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,80 @@ impl OAuthClient {
Ok(())
}

/// Exchange a Salesforce access token for a Data Cloud (Data 360 / TSE) token.
///
/// Data Cloud endpoints use a different instance URL (the TSE URL) and a
/// separate access token obtained via this OAuth 2.0 Token Exchange flow
/// ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)).
///
/// # Arguments
///
/// * `sf_access_token` — A valid Salesforce access token for the connected org.
/// * `login_url` — The Salesforce login/instance URL
/// (e.g. `https://login.salesforce.com` or your org's My Domain URL).
///
/// # Returns
///
/// A [`DataCloudTokenResponse`] containing:
/// - `access_token` — The Data Cloud access token to use with `DataCloudClient`.
/// - `instance_url` — The TSE (Tenant Service Endpoint) base URL for Data Cloud calls.
///
/// # Example
///
/// ```no_run
/// # use busbar_sf_auth::{OAuthClient, OAuthConfig};
/// # async fn example() -> Result<(), busbar_sf_auth::Error> {
/// let config = OAuthConfig::new("consumer_key");
/// let client = OAuthClient::new(config);
///
/// let dc_token = client
/// .exchange_for_data_cloud("sf_access_token", "https://myorg.my.salesforce.com")
/// .await?;
///
/// println!("Data Cloud TSE URL: {}", dc_token.instance_url);
/// // Use dc_token.access_token + dc_token.instance_url with DataCloudClient
/// # Ok(())
/// # }
/// ```
///
/// The `sf_access_token` parameter is not logged to prevent credential exposure.
#[instrument(skip(self, sf_access_token))]
pub async fn exchange_for_data_cloud(
&self,
sf_access_token: &str,
login_url: &str,
) -> Result<DataCloudTokenResponse> {
let params = [
("grant_type", "urn:salesforce:grant-type:external:cdp"),
("subject_token", sf_access_token),
(
"subject_token_type",
"urn:ietf:params:oauth:token-type:access_token",
),
];

let body = serde_urlencoded::to_string(params)?;

let response = self
.http_client
.post(format!("{}/services/oauth2/token", login_url))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await?;

if !response.status().is_success() {
let error: OAuthErrorResponse = response.json().await?;
return Err(Error::new(ErrorKind::OAuth {
error: error.error,
description: error.error_description,
}));
}

let token: DataCloudTokenResponse = response.json().await?;
Ok(token)
}

/// Handle a token response, checking for errors.
async fn handle_token_response(&self, response: reqwest::Response) -> Result<TokenResponse> {
if !response.status().is_success() {
Expand Down Expand Up @@ -431,6 +505,41 @@ pub struct TokenInfo {
pub sub: Option<String>,
}

/// Response from a Data Cloud (Data 360) token exchange.
///
/// Contains the Data Cloud access token and the TSE (Tenant Service Endpoint)
/// URL, which is the base URL for all Data Cloud API calls.
///
/// Sensitive fields are redacted in Debug output.
#[derive(Clone, Deserialize, Serialize)]
pub struct DataCloudTokenResponse {
/// Data Cloud access token. Use this with the `DataCloudClient` from `busbar-sf-rest`.
pub access_token: String,
/// TSE (Tenant Service Endpoint) URL — the base URL for Data Cloud API calls.
pub instance_url: String,
/// Token type (usually "Bearer").
#[serde(default)]
pub token_type: Option<String>,
/// Issued at timestamp.
#[serde(default)]
pub issued_at: Option<String>,
/// Signature for verification.
#[serde(default)]
pub signature: Option<String>,
}

impl std::fmt::Debug for DataCloudTokenResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DataCloudTokenResponse")
.field("access_token", &"[REDACTED]")
.field("instance_url", &self.instance_url)
.field("token_type", &self.token_type)
.field("issued_at", &self.issued_at)
.field("signature", &self.signature.as_ref().map(|_| "[REDACTED]"))
.finish()
}
}

/// OAuth error response.
#[derive(Debug, Deserialize)]
struct OAuthErrorResponse {
Expand Down Expand Up @@ -644,4 +753,92 @@ mod tests {
"Error should mention revocation failed"
);
}

#[test]
fn test_data_cloud_token_response_debug_redacts_tokens() {
let response = DataCloudTokenResponse {
access_token: "super_secret_dc_token".to_string(),
instance_url: "https://something.c360a.salesforce.com".to_string(),
token_type: Some("Bearer".to_string()),
issued_at: Some("1234567890".to_string()),
signature: Some("signature_value".to_string()),
};

let debug_output = format!("{:?}", response);
assert!(debug_output.contains("[REDACTED]"));
assert!(!debug_output.contains("super_secret_dc_token"));
assert!(!debug_output.contains("signature_value"));
assert!(debug_output.contains("c360a.salesforce.com"));
}

#[tokio::test]
async fn test_exchange_for_data_cloud_success() {
use wiremock::matchers::{body_string_contains, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

let mock_server = MockServer::start().await;

Mock::given(method("POST"))
.and(path("/services/oauth2/token"))
.and(header("Content-Type", "application/x-www-form-urlencoded"))
.and(body_string_contains(
"grant_type=urn%3Asalesforce%3Agrant-type%3Aexternal%3Acdp",
))
.and(body_string_contains("subject_token=test_sf_access_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "dc_access_token_123",
"instance_url": "https://something.c360a.salesforce.com",
"token_type": "Bearer",
"issued_at": "1234567890"
})))
.mount(&mock_server)
.await;

let config = OAuthConfig::new("test_client_id");
let client = OAuthClient::new(config);

let result = client
.exchange_for_data_cloud("test_sf_access_token", &mock_server.uri())
.await;

assert!(result.is_ok(), "Token exchange should succeed");
let dc_token = result.unwrap();
assert_eq!(dc_token.access_token, "dc_access_token_123");
assert_eq!(
dc_token.instance_url,
"https://something.c360a.salesforce.com"
);
assert_eq!(dc_token.token_type, Some("Bearer".to_string()));
}

#[tokio::test]
async fn test_exchange_for_data_cloud_failure() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

let mock_server = MockServer::start().await;

Mock::given(method("POST"))
.and(path("/services/oauth2/token"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": "invalid_grant",
"error_description": "Access token expired"
})))
.mount(&mock_server)
.await;

let config = OAuthConfig::new("test_client_id");
let client = OAuthClient::new(config);

let result = client
.exchange_for_data_cloud("expired_token", &mock_server.uri())
.await;

assert!(result.is_err(), "Token exchange should fail");
let err = result.unwrap_err();
assert!(
matches!(err.kind, ErrorKind::OAuth { .. }),
"Should return OAuth error"
);
}
}
Loading
Loading