From 069ff170b50729e9656fab71ae62b68d0e8b780f Mon Sep 17 00:00:00 2001 From: scolear Date: Fri, 27 Mar 2026 11:35:46 +0100 Subject: [PATCH 1/3] feat: auth0 support --- Cargo.lock | 10 +++ Cargo.toml | 2 + crates/auth0/Cargo.toml | 11 +++ crates/auth0/src/lib.rs | 1 + crates/auth0/src/login.rs | 143 +++++++++++++++++++++++++++++++++++ crates/keycloak/src/login.rs | 39 ++++++---- 6 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 crates/auth0/Cargo.toml create mode 100644 crates/auth0/src/lib.rs create mode 100644 crates/auth0/src/login.rs diff --git a/Cargo.lock b/Cargo.lock index 584a90f..d199137 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,6 +128,16 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auth0" +version = "0.2.0" +dependencies = [ + "base64", + "reqwest", + "serde", + "serde_json", +] + [[package]] name = "autocfg" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 021e170..84f16ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/auth0", "crates/common", "crates/cryptography", "crates/examples", @@ -24,6 +25,7 @@ lto = true panic = "unwind" [workspace.dependencies] +auth0 = { path = "crates/auth0" } base64 = { version = "0.22.1" } chrono = { version = "0.4" } common = { path = "crates/common" } diff --git a/crates/auth0/Cargo.toml b/crates/auth0/Cargo.toml new file mode 100644 index 0000000..0276096 --- /dev/null +++ b/crates/auth0/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "auth0" +edition.workspace = true +license.workspace = true +version.workspace = true + +[dependencies] +base64 = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/crates/auth0/src/lib.rs b/crates/auth0/src/lib.rs new file mode 100644 index 0000000..320cbbb --- /dev/null +++ b/crates/auth0/src/lib.rs @@ -0,0 +1 @@ +pub mod login; diff --git a/crates/auth0/src/login.rs b/crates/auth0/src/login.rs new file mode 100644 index 0000000..48ae265 --- /dev/null +++ b/crates/auth0/src/login.rs @@ -0,0 +1,143 @@ +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`. + pub fn get_user_id(&self) -> Result { + self.get_claim("sub") + .and_then(|v| { + v.as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "'sub' claim is not a string".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 { + let parts: Vec<&str> = self.access_token.split('.').collect(); + if parts.len() != 3 { + return Err("Invalid JWT format".to_string()); + } + + let payload = parts[1]; + + // Try URL-safe base64 first, fall back to standard with padding + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .or_else(|_| { + let padding_needed = (4 - (payload.len() % 4)) % 4; + let padded = format!("{}{}", payload, "=".repeat(padding_needed)); + 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_name}' claim")) + } +} + +/// Perform Auth0 client credentials authentication +pub async fn client_credentials(params: ClientCredentialsParams) -> Result { + let client = reqwest::Client::new(); + client_credentials_with_client(params, &client).await +} + +/// Perform Auth0 client credentials authentication with a pre-built HTTP client +pub async fn client_credentials_with_client( + params: ClientCredentialsParams, + client: &reqwest::Client, +) -> Result { + 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(¶ms.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: {e}"))?; + + 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!("{domain}/oauth/token") +} + +#[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" + ); + } +} diff --git a/crates/keycloak/src/login.rs b/crates/keycloak/src/login.rs index f9df241..03f4840 100644 --- a/crates/keycloak/src/login.rs +++ b/crates/keycloak/src/login.rs @@ -27,6 +27,18 @@ impl Response { /// Extract the user ID (subject claim) from the access token JWT /// Returns the 'sub' claim which is typically the user's UUID pub fn get_user_id(&self) -> Result { + self.get_claim("sub") + .and_then(|v| { + v.as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "'sub' claim is not a string".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 { // JWT format: header.payload.signature let parts: Vec<&str> = self.access_token.split('.').collect(); if parts.len() != 3 { @@ -36,28 +48,23 @@ impl Response { // 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 STANDARD engine with padding since we added it - let decoded = base64::engine::general_purpose::STANDARD - .decode(&padded) + // URL-safe base64 without padding - try URL_SAFE first, fall back to STANDARD with padding + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .or_else(|_| { + let padding_needed = (4 - (payload.len() % 4)) % 4; + let padded = format!("{}{}", payload, "=".repeat(padding_needed)); + 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()) + json.get(claim_name) + .cloned() + .ok_or_else(|| format!("JWT does not contain '{}' claim", claim_name)) } } From 231783ed275742a3a69b809639cd37e3d7e5d699 Mon Sep 17 00:00:00 2001 From: scolear Date: Fri, 27 Mar 2026 12:35:18 +0100 Subject: [PATCH 2/3] chore: add tests --- Cargo.lock | 1 + crates/keycloak/Cargo.toml | 3 + crates/keycloak/src/login.rs | 138 +++++++++++++++++++++++++++++++++-- 3 files changed, 136 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d199137..35c2160 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1087,6 +1087,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "tokio", ] [[package]] diff --git a/crates/keycloak/Cargo.toml b/crates/keycloak/Cargo.toml index 334158f..33c3da3 100644 --- a/crates/keycloak/Cargo.toml +++ b/crates/keycloak/Cargo.toml @@ -4,6 +4,9 @@ edition.workspace = true license.workspace = true version.workspace = true +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } + [dependencies] base64 = { workspace = true } reqwest = { workspace = true } diff --git a/crates/keycloak/src/login.rs b/crates/keycloak/src/login.rs index 03f4840..4afce0d 100644 --- a/crates/keycloak/src/login.rs +++ b/crates/keycloak/src/login.rs @@ -27,12 +27,11 @@ impl Response { /// Extract the user ID (subject claim) from the access token JWT /// Returns the 'sub' claim which is typically the user's UUID pub fn get_user_id(&self) -> Result { - self.get_claim("sub") - .and_then(|v| { - v.as_str() - .map(|s| s.to_string()) - .ok_or_else(|| "'sub' claim is not a string".to_string()) - }) + self.get_claim("sub").and_then(|v| { + v.as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "'sub' claim is not a string".to_string()) + }) } /// Extract an arbitrary claim from the access token JWT @@ -198,3 +197,130 @@ pub async fn refresh(params: RefreshParams) -> Result { Ok(response) } + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; + + /// Build a fake JWT (header.payload.signature) encoding the payload with the given engine + fn fake_jwt(payload: &serde_json::Value, engine: &impl base64::Engine) -> String { + let header = engine.encode(b"{}"); + let body = engine.encode(payload.to_string().as_bytes()); + let sig = engine.encode(b"sig"); + format!("{}.{}.{}", header, body, sig) + } + + #[test] + fn test_get_claim_url_safe_no_pad() { + // Payload crafted so that base64 output contains '-' and '_' (URL-safe chars) + // which would be '+' and '/' in standard base64. STANDARD.decode would fail on these. + let payload = serde_json::json!({ + "sub": "user>>>???<<<", + "iss": "https://example.com/auth/realms/test" + }); + let token = fake_jwt(&payload, &URL_SAFE_NO_PAD); + + // Verify the token payload actually contains URL-safe-only characters + let raw_payload = token.split('.').nth(1).unwrap(); + assert!( + !raw_payload.contains('+') && !raw_payload.contains('/'), + "URL_SAFE_NO_PAD should not produce + or / characters" + ); + + let resp = Response { + access_token: token, + expires_in: 300, + refresh_token: String::new(), + }; + + assert_eq!(resp.get_user_id().unwrap(), "user>>>???<<<"); + assert_eq!( + resp.get_claim("iss").unwrap(), + serde_json::json!("https://example.com/auth/realms/test") + ); + } + + #[test] + fn test_get_claim_standard_base64_fallback() { + // Some providers may emit standard base64 with padding — verify the fallback works + let payload = serde_json::json!({ + "sub": "user-789", + "role": "admin" + }); + let token = fake_jwt(&payload, &STANDARD); + let resp = Response { + access_token: token, + expires_in: 300, + refresh_token: String::new(), + }; + + assert_eq!(resp.get_user_id().unwrap(), "user-789"); + assert_eq!(resp.get_claim("role").unwrap(), serde_json::json!("admin")); + } + + #[test] + fn test_get_claim_missing_claim() { + let payload = serde_json::json!({"sub": "user-1"}); + let token = fake_jwt(&payload, &URL_SAFE_NO_PAD); + let resp = Response { + access_token: token, + expires_in: 0, + refresh_token: String::new(), + }; + assert!(resp.get_claim("nonexistent").is_err()); + } + + #[test] + fn test_get_claim_invalid_jwt() { + let resp = Response { + access_token: "not-a-jwt".to_string(), + expires_in: 0, + refresh_token: String::new(), + }; + assert!(resp.get_claim("sub").is_err()); + } + + #[tokio::test] + async fn test_password_login() { + let url = std::env::var("KEYCLOAK_HOST").expect("KEYCLOAK_HOST must be set"); + let realm = std::env::var("KEYCLOAK_REALM").expect("KEYCLOAK_REALM must be set"); + let client_id = + std::env::var("KEYCLOAK_CLIENT_ID").expect("KEYCLOAK_CLIENT_ID must be set"); + let username = std::env::var("KEYCLOAK_USERNAME").expect("KEYCLOAK_USERNAME must be set"); + let user_password = + std::env::var("KEYCLOAK_PASSWORD").expect("KEYCLOAK_PASSWORD must be set"); + + let token_url = password_url(&url, &realm); + let response = password(PasswordParams { + url: token_url, + client_id, + username, + password: user_password, + }) + .await + .expect("Password login should succeed"); + + assert!( + !response.access_token.is_empty(), + "Access token should not be empty" + ); + assert!(response.expires_in > 0, "expires_in should be positive"); + + // Verify URL_SAFE_NO_PAD decoding works on a real token + let user_id = response + .get_user_id() + .expect("Should be able to extract user_id (sub) from real token"); + assert!( + !user_id.is_empty(), + "User ID from real token should not be empty" + ); + println!("Decoded user_id (sub): {}", user_id); + + // Also verify we can extract another standard claim + let iss = response + .get_claim("iss") + .expect("Should be able to extract 'iss' claim from real token"); + println!("Decoded issuer (iss): {}", iss); + } +} From 2720cea7e85053eb3c15addff97e40ff60017e91 Mon Sep 17 00:00:00 2001 From: scolear Date: Fri, 27 Mar 2026 17:12:57 +0100 Subject: [PATCH 3/3] fix: review comments --- crates/auth0/src/login.rs | 87 ++++++++++++++++++++++++++++++++++-- crates/keycloak/src/login.rs | 22 ++++----- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/crates/auth0/src/login.rs b/crates/auth0/src/login.rs index 48ae265..bf34ea8 100644 --- a/crates/auth0/src/login.rs +++ b/crates/auth0/src/login.rs @@ -40,9 +40,11 @@ impl Response { }) } - /// Extract an arbitrary claim from the access token JWT + /// Extract an arbitrary claim from the access token JWT (decode-only, no signature verification). /// - /// Useful for extracting custom claims like party_id, roles, etc. + /// This only decodes the JWT payload; it does not verify the signature or validate + /// standard claims (exp, aud, iss). Do not use for authorization decisions — + /// rely on server-side token validation for that. pub fn get_claim(&self, claim_name: &str) -> Result { let parts: Vec<&str> = self.access_token.split('.').collect(); if parts.len() != 3 { @@ -51,13 +53,13 @@ impl Response { let payload = parts[1]; - // Try URL-safe base64 first, fall back to standard with padding + // Try URL-safe base64 first, fall back to URL-safe with padding let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(payload) .or_else(|_| { let padding_needed = (4 - (payload.len() % 4)) % 4; let padded = format!("{}{}", payload, "=".repeat(padding_needed)); - base64::engine::general_purpose::STANDARD.decode(&padded) + base64::engine::general_purpose::URL_SAFE.decode(&padded) }) .map_err(|e| format!("Failed to decode JWT payload: {e}"))?; @@ -128,6 +130,15 @@ pub fn auth0_url(domain: &str) -> String { #[cfg(test)] mod tests { use super::*; + use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}; + + /// Build a fake JWT (header.payload.signature) encoding the payload with the given engine + fn fake_jwt(payload: &serde_json::Value, engine: &impl base64::Engine) -> String { + let header = engine.encode(b"{}"); + let body = engine.encode(payload.to_string().as_bytes()); + let sig = engine.encode(b"sig"); + format!("{}.{}.{}", header, body, sig) + } #[test] fn test_auth0_url() { @@ -140,4 +151,72 @@ mod tests { "https://example.auth0.com/oauth/token" ); } + + #[test] + fn test_get_claim_url_safe_no_pad() { + let payload = serde_json::json!({ + "sub": "user>>>???<<<", + "iss": "https://example.auth0.com/" + }); + let token = fake_jwt(&payload, &URL_SAFE_NO_PAD); + + // Verify the token payload contains no standard base64 chars + let raw_payload = token.split('.').nth(1).unwrap(); + assert!( + !raw_payload.contains('+') && !raw_payload.contains('/'), + "URL_SAFE_NO_PAD should not produce + or / characters" + ); + + let resp = Response { + access_token: token, + expires_in: 300, + token_type: String::new(), + }; + + assert_eq!(resp.get_user_id().unwrap(), "user>>>???<<<"); + assert_eq!( + resp.get_claim("iss").unwrap(), + serde_json::json!("https://example.auth0.com/") + ); + } + + #[test] + fn test_get_claim_url_safe_with_padding_fallback() { + // Tokens with URL-safe chars AND padding — the fallback path + let payload = serde_json::json!({ + "sub": "user>>>???<<<", + "role": "admin" + }); + let token = fake_jwt(&payload, &URL_SAFE); + let resp = Response { + access_token: token, + expires_in: 300, + token_type: String::new(), + }; + + assert_eq!(resp.get_user_id().unwrap(), "user>>>???<<<"); + assert_eq!(resp.get_claim("role").unwrap(), serde_json::json!("admin")); + } + + #[test] + fn test_get_claim_missing_claim() { + let payload = serde_json::json!({"sub": "user-1"}); + let token = fake_jwt(&payload, &URL_SAFE_NO_PAD); + let resp = Response { + access_token: token, + expires_in: 0, + token_type: String::new(), + }; + assert!(resp.get_claim("nonexistent").is_err()); + } + + #[test] + fn test_get_claim_invalid_jwt() { + let resp = Response { + access_token: "not-a-jwt".to_string(), + expires_in: 0, + token_type: String::new(), + }; + assert!(resp.get_claim("sub").is_err()); + } } diff --git a/crates/keycloak/src/login.rs b/crates/keycloak/src/login.rs index 4afce0d..f8688d0 100644 --- a/crates/keycloak/src/login.rs +++ b/crates/keycloak/src/login.rs @@ -34,9 +34,11 @@ impl Response { }) } - /// Extract an arbitrary claim from the access token JWT + /// Extract an arbitrary claim from the access token JWT (decode-only, no signature verification). /// - /// Useful for extracting custom claims like party_id, roles, etc. + /// This only decodes the JWT payload; it does not verify the signature or validate + /// standard claims (exp, aud, iss). Do not use for authorization decisions — + /// rely on server-side token validation for that. pub fn get_claim(&self, claim_name: &str) -> Result { // JWT format: header.payload.signature let parts: Vec<&str> = self.access_token.split('.').collect(); @@ -47,13 +49,13 @@ impl Response { // Decode the payload (second part) let payload = parts[1]; - // URL-safe base64 without padding - try URL_SAFE first, fall back to STANDARD with padding + // URL-safe base64 without padding - try URL_SAFE_NO_PAD first, fall back to URL_SAFE with padding let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(payload) .or_else(|_| { let padding_needed = (4 - (payload.len() % 4)) % 4; let padded = format!("{}{}", payload, "=".repeat(padding_needed)); - base64::engine::general_purpose::STANDARD.decode(&padded) + base64::engine::general_purpose::URL_SAFE.decode(&padded) }) .map_err(|e| format!("Failed to decode JWT payload: {}", e))?; @@ -201,7 +203,7 @@ pub async fn refresh(params: RefreshParams) -> Result { #[cfg(test)] mod tests { use super::*; - use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; + use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}; /// Build a fake JWT (header.payload.signature) encoding the payload with the given engine fn fake_jwt(payload: &serde_json::Value, engine: &impl base64::Engine) -> String { @@ -242,20 +244,20 @@ mod tests { } #[test] - fn test_get_claim_standard_base64_fallback() { - // Some providers may emit standard base64 with padding — verify the fallback works + fn test_get_claim_url_safe_with_padding_fallback() { + // Tokens with URL-safe chars AND padding — the fallback path let payload = serde_json::json!({ - "sub": "user-789", + "sub": "user>>>???<<<", "role": "admin" }); - let token = fake_jwt(&payload, &STANDARD); + let token = fake_jwt(&payload, &URL_SAFE); let resp = Response { access_token: token, expires_in: 300, refresh_token: String::new(), }; - assert_eq!(resp.get_user_id().unwrap(), "user-789"); + assert_eq!(resp.get_user_id().unwrap(), "user>>>???<<<"); assert_eq!(resp.get_claim("role").unwrap(), serde_json::json!("admin")); }