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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# CORE CONFIGURATION (Required for all operations)
# =============================================================================

# KEYCLOAK / OIDC AUTHENTICATION
# KEYCLOAK / OIDC AUTHENTICATION (for Keycloak-based examples)
# Your Keycloak/OIDC provider host URL
KEYCLOAK_HOST=https://keycloak.dev.canton.example.com
# The realm name in your Keycloak instance
Expand All @@ -17,6 +17,16 @@ KEYCLOAK_USERNAME=your-username
# Password for password grant authentication
KEYCLOAK_PASSWORD=your-password

# AUTH0 AUTHENTICATION (for Auth0-based examples with *_auth0.rs suffix)
# Your Auth0 tenant domain (e.g., https://your-tenant.auth0.com)
AUTH0_DOMAIN=https://your-tenant.auth0.com
# Your Auth0 application's client ID
AUTH0_CLIENT_ID=your-auth0-client-id
# Your Auth0 application's client secret
AUTH0_CLIENT_SECRET=your-auth0-client-secret
# Your Auth0 API audience identifier
AUTH0_AUDIENCE=https://your-api-audience

# CANTON LEDGER
# Your Canton participant node's ledger API host
LEDGER_HOST=https://participant.example.com
Expand Down
1 change: 1 addition & 0 deletions crates/cbtc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ futures = "0.3"
dotenvy = { workspace = true }
base64 = "0.22"
log = "0.4"
reqwest = { version = "0.12.24", features = ["json"] }

[dev-dependencies]
224 changes: 224 additions & 0 deletions crates/cbtc/src/auth0.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
//! Auth0 Authentication Module
//!
//! This module provides Auth0 client credentials authentication for Canton network access.
//! It's a local implementation that works alongside the external `keycloak` crate,
//! allowing you to use Auth0 instead of Keycloak for authentication.
//!
//! # Usage
//!
//! ```rust,ignore
//! use cbtc::auth0::{client_credentials, ClientCredentialsParams, auth0_url};
//!
//! let params = ClientCredentialsParams {
//! url: auth0_url("https://your-tenant.auth0.com"),
//! client_id: "your-client-id".to_string(),
//! client_secret: "your-client-secret".to_string(),
//! audience: "https://your-api-audience".to_string(),
//! };
//!
//! let auth = client_credentials(params).await?;
//! // Use auth.access_token for API calls
//! ```

use base64::Engine;
use serde::Deserialize;

/// Parameters for Auth0 client credentials authentication
pub struct ClientCredentialsParams {
/// The Auth0 token endpoint URL (use `auth0_url()` to construct)
pub url: String,
/// Your Auth0 application's client ID
pub client_id: String,
/// Your Auth0 application's client secret
pub client_secret: String,
/// The API audience identifier
pub audience: String,
}

/// Authentication response containing the access token
#[derive(Deserialize, Debug, Clone)]
pub struct Response {
/// The JWT access token to use for API requests
pub access_token: String,
/// Token expiration time in seconds
#[serde(default)]
pub expires_in: u32,
/// Token type (usually "Bearer")
#[serde(default)]
pub token_type: String,
}

impl Response {
/// Extract the user ID (subject claim) from the access token JWT
///
/// Returns the 'sub' claim which is typically the Auth0 user/client identifier.
/// For machine-to-machine tokens, this is usually `client_id@clients`.
///
/// # Errors
///
/// Returns an error if the JWT is malformed or doesn't contain a 'sub' claim.
pub fn get_user_id(&self) -> Result<String, String> {
// JWT format: header.payload.signature
let parts: Vec<&str> = self.access_token.split('.').collect();
if parts.len() != 3 {
return Err("Invalid JWT format".to_string());
}

// Decode the payload (second part)
let payload = parts[1];

// URL-safe base64 without padding - we need to add padding for the decoder
let padding_needed = (4 - (payload.len() % 4)) % 4;
let padded = if padding_needed > 0 {
format!("{}{}", payload, "=".repeat(padding_needed))
} else {
payload.to_string()
};

// Decode base64 - use URL_SAFE engine first, fall back to STANDARD
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.or_else(|_| {
base64::engine::general_purpose::STANDARD.decode(&padded)
})
.map_err(|e| format!("Failed to decode JWT payload: {}", e))?;

// Parse JSON
let json: serde_json::Value = serde_json::from_slice(&decoded)
.map_err(|e| format!("Failed to parse JWT payload JSON: {}", e))?;

// Extract 'sub' claim
json.get("sub")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "JWT does not contain 'sub' claim".to_string())
}

/// Extract an arbitrary claim from the access token JWT
///
/// Useful for extracting custom claims like party_id, roles, etc.
pub fn get_claim(&self, claim_name: &str) -> Result<serde_json::Value, String> {
let parts: Vec<&str> = self.access_token.split('.').collect();
if parts.len() != 3 {
return Err("Invalid JWT format".to_string());
}

let payload = parts[1];
let padding_needed = (4 - (payload.len() % 4)) % 4;
let padded = if padding_needed > 0 {
format!("{}{}", payload, "=".repeat(padding_needed))
} else {
payload.to_string()
};

let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.or_else(|_| {
base64::engine::general_purpose::STANDARD.decode(&padded)
})
.map_err(|e| format!("Failed to decode JWT payload: {}", e))?;

let json: serde_json::Value = serde_json::from_slice(&decoded)
.map_err(|e| format!("Failed to parse JWT payload JSON: {}", e))?;

json.get(claim_name)
.cloned()
.ok_or_else(|| format!("JWT does not contain '{}' claim", claim_name))
}
}

/// Perform Auth0 client credentials authentication
///
/// This function exchanges client credentials for an access token using Auth0's
/// OAuth 2.0 client credentials flow. The returned token can be used to
/// authenticate with Canton APIs.
///
/// # Arguments
///
/// * `params` - Authentication parameters including URL, client_id, client_secret, and audience
///
/// # Errors
///
/// Returns an error if:
/// - The HTTP request fails
/// - Auth0 returns an error response
/// - The response cannot be parsed
///
/// # Example
///
/// ```rust,ignore
/// let auth = client_credentials(ClientCredentialsParams {
/// url: auth0_url("https://your-tenant.auth0.com"),
/// client_id: "abc123".to_string(),
/// client_secret: "secret".to_string(),
/// audience: "https://api.example.com".to_string(),
/// }).await?;
/// ```
pub async fn client_credentials(params: ClientCredentialsParams) -> Result<Response, String> {
let client = reqwest::Client::new();

// Auth0 uses JSON body for client credentials
let json_body = serde_json::json!({
"grant_type": "client_credentials",
"client_id": params.client_id,
"client_secret": params.client_secret,
"audience": params.audience,
});

let res = client
.post(&params.url)
.json(&json_body)
.send()
.await
.map_err(|e| format!("Auth0 client_credentials request failed: {}", e))?;

let status = res.status();
let body = res
.text()
.await
.map_err(|e| format!("Failed to read Auth0 response: {}", e))?;

if !status.is_success() {
return Err(format!(
"Auth0 authentication failed [{}]: {}",
status, body
));
}

let response: Response = serde_json::from_str(&body)
.map_err(|e| format!("Failed to parse Auth0 response: {} - body: {}", e, body))?;

Ok(response)
}

/// Construct Auth0 OAuth token endpoint URL
///
/// # Arguments
///
/// * `domain` - Your Auth0 domain (e.g., "https://your-tenant.auth0.com")
///
/// # Returns
///
/// The full token endpoint URL (e.g., "https://your-tenant.auth0.com/oauth/token")
pub fn auth0_url(domain: &str) -> String {
let domain = domain.trim_end_matches('/');
format!("{}/oauth/token", domain)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_auth0_url() {
assert_eq!(
auth0_url("https://example.auth0.com"),
"https://example.auth0.com/oauth/token"
);
assert_eq!(
auth0_url("https://example.auth0.com/"),
"https://example.auth0.com/oauth/token"
);
}
}

1 change: 1 addition & 0 deletions crates/cbtc/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod accept;
pub mod active_contracts;
pub mod auth0;
pub mod batch;
pub mod cancel_offers;
pub mod consolidate;
Expand Down
36 changes: 36 additions & 0 deletions crates/examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,42 @@ path = "src/cancel_offers.rs"
name = "check_withdraw_requests"
path = "src/check_withdraw_requests.rs"

[[bin]]
name = "accept_transfers_auth0"
path = "src/accept_transfers_auth0.rs"

[[bin]]
name = "check_balance_auth0"
path = "src/check_balance_auth0.rs"

[[bin]]
name = "check_withdraw_requests_auth0"
path = "src/check_withdraw_requests_auth0.rs"

[[bin]]
name = "consolidate_utxos_auth0"
path = "src/consolidate_utxos_auth0.rs"

[[bin]]
name = "list_deposit_addresses_auth0"
path = "src/list_deposit_addresses_auth0.rs"

[[bin]]
name = "mint_cbtc_auth0"
path = "src/mint_cbtc_auth0.rs"

[[bin]]
name = "redeem_cbtc_auth0"
path = "src/redeem_cbtc_auth0.rs"

[[bin]]
name = "send_cbtc_auth0"
path = "src/send_cbtc_auth0.rs"

[[bin]]
name = "stream_auth0"
path = "src/stream_auth0.rs"

[dependencies]
cbtc = { path = "../cbtc" }
common = { path = "../common" }
Expand Down
Loading