Skip to main content

oci_rust_sdk/auth/
federation.rs

1use crate::auth::x509_utils::sanitize_certificate_pem;
2use crate::core::region::Region;
3use base64::{Engine as _, engine::general_purpose};
4use chrono::{DateTime, Utc};
5use reqwest::Client;
6use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey, LineEnding};
7use rsa::{Pkcs1v15Sign, RsaPrivateKey, RsaPublicKey};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::time::Duration;
11use tokio::time::sleep;
12
13const MAX_ATTEMPTS: u32 = 3;
14const MAX_BACKOFF_SECS: u64 = 1;
15const REQUEST_TIMEOUT_SECS: u64 = 10;
16
17/// Session keypair for federation requests
18#[derive(Clone)]
19pub struct SessionKeyPair {
20    pub private_key_pem: String,
21    pub public_key_pem: String,
22    pub private_key: RsaPrivateKey,
23}
24
25/// Security token response from federation service
26#[derive(Debug, Clone)]
27pub struct SecurityToken {
28    pub token: String,
29    pub expires_at: DateTime<Utc>,
30}
31
32/// Request body for X.509 federation
33#[derive(Serialize)]
34struct FederationRequest {
35    certificate: String,
36    #[serde(rename = "publicKey")]
37    public_key: String,
38    #[serde(rename = "intermediateCertificates")]
39    intermediate_certificates: Vec<String>,
40    purpose: String,
41}
42
43/// Response from federation service
44#[derive(Deserialize)]
45struct FederationResponse {
46    token: String,
47}
48
49/// Generate a new RSA 2048-bit session keypair
50pub fn generate_session_keypair() -> crate::core::Result<SessionKeyPair> {
51    use rand::rngs::OsRng;
52
53    let mut rng = OsRng;
54    let private_key = RsaPrivateKey::new(&mut rng, 2048).map_err(|e| {
55        crate::core::OciError::AuthError(format!("Failed to generate session keypair: {}", e))
56    })?;
57
58    let public_key = RsaPublicKey::from(&private_key);
59
60    let private_key_pem = private_key
61        .to_pkcs8_pem(LineEnding::LF)
62        .map_err(|e| {
63            crate::core::OciError::AuthError(format!("Failed to encode private key: {}", e))
64        })?
65        .to_string();
66
67    let public_key_pem = public_key.to_public_key_pem(LineEnding::LF).map_err(|e| {
68        crate::core::OciError::AuthError(format!("Failed to encode public key: {}", e))
69    })?;
70
71    Ok(SessionKeyPair {
72        private_key_pem,
73        public_key_pem,
74        private_key,
75    })
76}
77
78/// Request a security token from the federation service
79pub async fn request_security_token(
80    region: &Region,
81    tenancy_id: &str,
82    leaf_cert_pem: &str,
83    leaf_key_pem: &str,
84    intermediate_certs_pem: &[String],
85    session_public_key_pem: &str,
86) -> crate::core::Result<SecurityToken> {
87    let endpoint = format!("https://auth.{}.oraclecloud.com/v1/x509", region.id());
88
89    // Parse leaf private key for signing
90    let leaf_private_key = RsaPrivateKey::from_pkcs8_pem(leaf_key_pem).map_err(|e| {
91        crate::core::OciError::AuthError(format!("Failed to parse leaf private key: {}", e))
92    })?;
93
94    // Prepare request body
95    let request_body = FederationRequest {
96        certificate: sanitize_certificate_pem(leaf_cert_pem),
97        public_key: sanitize_certificate_pem(session_public_key_pem),
98        intermediate_certificates: intermediate_certs_pem
99            .iter()
100            .map(|cert| sanitize_certificate_pem(cert))
101            .collect(),
102        purpose: "DEFAULT".to_string(),
103    };
104
105    let body_json = serde_json::to_string(&request_body).map_err(|e| {
106        crate::core::OciError::AuthError(format!("Failed to serialize request: {}", e))
107    })?;
108
109    // Retry logic
110    let client = Client::builder()
111        .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
112        .build()
113        .map_err(|e| {
114            crate::core::OciError::AuthError(format!("Failed to create HTTP client: {}", e))
115        })?;
116
117    let mut last_error = None;
118
119    for attempt in 1..=MAX_ATTEMPTS {
120        match make_federation_request(
121            &client,
122            &endpoint,
123            &body_json,
124            tenancy_id,
125            &leaf_private_key,
126        )
127        .await
128        {
129            Ok(token) => return parse_security_token(token),
130            Err(e) => {
131                // Check if error is retryable
132                if !is_retryable_error(&e) {
133                    return Err(e);
134                }
135
136                last_error = Some(e);
137
138                // Don't sleep after the last attempt
139                if attempt < MAX_ATTEMPTS {
140                    let backoff = calculate_backoff_with_jitter(attempt, MAX_BACKOFF_SECS);
141                    sleep(backoff).await;
142                }
143            }
144        }
145    }
146
147    Err(last_error.unwrap_or_else(|| {
148        crate::core::OciError::AuthError("Federation request failed with no error".to_string())
149    }))
150}
151
152/// Make a single federation request
153async fn make_federation_request(
154    client: &Client,
155    endpoint: &str,
156    body_json: &str,
157    tenancy_id: &str,
158    leaf_private_key: &RsaPrivateKey,
159) -> crate::core::Result<String> {
160    let url = url::Url::parse(endpoint).map_err(|e| {
161        crate::core::OciError::AuthError(format!("Invalid federation endpoint: {}", e))
162    })?;
163
164    let mut headers = reqwest::header::HeaderMap::new();
165
166    // Add required headers
167    let now = chrono::Utc::now();
168    headers.insert(
169        "x-date",
170        now.format("%a, %d %b %Y %H:%M:%S GMT")
171            .to_string()
172            .parse()
173            .map_err(|e| crate::core::OciError::AuthError(format!("Invalid date header: {}", e)))?,
174    );
175
176    headers.insert(
177        "host",
178        url.host_str()
179            .ok_or_else(|| crate::core::OciError::AuthError("Missing host in URL".to_string()))?
180            .parse()
181            .map_err(|e| crate::core::OciError::AuthError(format!("Invalid host header: {}", e)))?,
182    );
183
184    headers.insert(
185        "content-type",
186        "application/json".parse().map_err(|e| {
187            crate::core::OciError::AuthError(format!("Invalid content-type: {}", e))
188        })?,
189    );
190
191    headers.insert(
192        "content-length",
193        body_json.len().to_string().parse().map_err(|e| {
194            crate::core::OciError::AuthError(format!("Invalid content-length: {}", e))
195        })?,
196    );
197
198    // Calculate SHA256 of body
199    let mut hasher = Sha256::new();
200    hasher.update(body_json.as_bytes());
201    let hash = hasher.finalize();
202    let b64_hash = general_purpose::STANDARD.encode(hash);
203
204    headers.insert(
205        "x-content-sha256",
206        b64_hash.parse().map_err(|e| {
207            crate::core::OciError::AuthError(format!("Invalid sha256 header: {}", e))
208        })?,
209    );
210
211    // Sign the request
212    sign_federation_request(&url, &mut headers, body_json, tenancy_id, leaf_private_key)?;
213
214    // Make the request
215    let response = client
216        .post(endpoint)
217        .headers(headers)
218        .body(body_json.to_string())
219        .send()
220        .await
221        .map_err(|e| {
222            crate::core::OciError::AuthError(format!("Federation request failed: {}", e))
223        })?;
224
225    let status = response.status();
226
227    if status.is_success() {
228        let federation_response: FederationResponse = response.json().await.map_err(|e| {
229            crate::core::OciError::AuthError(format!("Failed to parse federation response: {}", e))
230        })?;
231        Ok(federation_response.token)
232    } else if status.is_client_error() {
233        // 4XX errors should not be retried
234        let error_body = response.text().await.unwrap_or_default();
235        Err(crate::core::OciError::ConfigError(format!(
236            "Federation request failed with status {}: {}",
237            status, error_body
238        )))
239    } else {
240        // 5XX errors should be retried
241        let error_body = response.text().await.unwrap_or_default();
242        Err(crate::core::OciError::ServiceError {
243            status: status.as_u16(),
244            code: "FederationServiceError".to_string(),
245            message: error_body,
246        })
247    }
248}
249
250/// Sign the federation request using HTTP Signature with leaf certificate key
251fn sign_federation_request(
252    url: &url::Url,
253    headers: &mut reqwest::header::HeaderMap,
254    _body: &str,
255    tenancy_id: &str,
256    leaf_private_key: &RsaPrivateKey,
257) -> crate::core::Result<()> {
258    // Headers to sign (in order)
259    let headers_to_sign = vec![
260        "(request-target)",
261        "host",
262        "x-date",
263        "content-type",
264        "content-length",
265        "x-content-sha256",
266    ];
267
268    // Build signing string
269    let mut parts = Vec::new();
270    for header_name in &headers_to_sign {
271        let value = if *header_name == "(request-target)" {
272            format!("post {}", url.path())
273        } else {
274            headers
275                .get(*header_name)
276                .and_then(|v| v.to_str().ok())
277                .ok_or_else(|| {
278                    crate::core::OciError::SigningError(format!("Missing header: {}", header_name))
279                })?
280                .to_string()
281        };
282        parts.push(format!("{}: {}", header_name, value));
283    }
284    let signing_string = parts.join("\n");
285
286    // Sign the string
287    let mut hasher = Sha256::new();
288    hasher.update(signing_string.as_bytes());
289    let hashed = hasher.finalize();
290
291    let padding = Pkcs1v15Sign::new_unprefixed();
292    let signature = leaf_private_key
293        .sign(padding, &hashed)
294        .map_err(|e| crate::core::OciError::SigningError(format!("Failed to sign: {}", e)))?;
295
296    let signature_b64 = general_purpose::STANDARD.encode(&signature);
297
298    // Build authorization header
299    // KeyId format: tenancyId/fed-x509/certificate_fingerprint
300    // For simplicity, we'll use tenancyId/fed-x509 (the actual fingerprint would be computed from the cert)
301    let key_id = format!("{}/fed-x509", tenancy_id);
302
303    let auth_header = format!(
304        r#"Signature version="1",headers="{}",keyId="{}",algorithm="rsa-sha256",signature="{}""#,
305        headers_to_sign.join(" "),
306        key_id,
307        signature_b64
308    );
309
310    headers.insert(
311        "authorization",
312        auth_header.parse().map_err(|e| {
313            crate::core::OciError::SigningError(format!("Invalid authorization header: {}", e))
314        })?,
315    );
316
317    Ok(())
318}
319
320/// Parse JWT token and extract expiration
321fn parse_security_token(token: String) -> crate::core::Result<SecurityToken> {
322    use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
323
324    #[derive(Deserialize)]
325    struct Claims {
326        exp: i64,
327    }
328
329    // We only need to extract the expiration, so we don't validate the signature
330    let mut validation = Validation::new(Algorithm::RS256);
331    validation.insecure_disable_signature_validation();
332    validation.validate_exp = false;
333
334    let token_data = decode::<Claims>(&token, &DecodingKey::from_secret(&[]), &validation)
335        .map_err(|e| crate::core::OciError::AuthError(format!("Failed to parse JWT: {}", e)))?;
336
337    let expires_at = DateTime::from_timestamp(token_data.claims.exp, 0).ok_or_else(|| {
338        crate::core::OciError::AuthError("Invalid expiration timestamp in JWT".to_string())
339    })?;
340
341    Ok(SecurityToken { token, expires_at })
342}
343
344/// Check if an error is retryable
345fn is_retryable_error(error: &crate::core::OciError) -> bool {
346    match error {
347        crate::core::OciError::ServiceError { status, .. } => *status >= 500,
348        crate::core::OciError::HttpError(_) => true, // Network errors
349        crate::core::OciError::AuthError(_) => true, // Transient auth issues
350        _ => false,
351    }
352}
353
354/// Calculate exponential backoff with jitter
355fn calculate_backoff_with_jitter(attempt: u32, max_secs: u64) -> Duration {
356    use rand::Rng;
357
358    let base_ms = 100u64 * 2u64.pow(attempt - 1);
359    let max_ms = max_secs * 1000;
360    let backoff_ms = base_ms.min(max_ms);
361
362    let mut rng = rand::thread_rng();
363    let jitter_ms = rng.gen_range(0..=backoff_ms);
364
365    Duration::from_millis(jitter_ms)
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn test_generate_session_keypair() {
374        let keypair = generate_session_keypair().unwrap();
375        assert!(keypair.private_key_pem.contains("BEGIN PRIVATE KEY"));
376        assert!(keypair.public_key_pem.contains("BEGIN PUBLIC KEY"));
377    }
378
379    #[test]
380    fn test_sanitize_certificate() {
381        let cert = r#"-----BEGIN CERTIFICATE-----
382MIIC...
383-----END CERTIFICATE-----"#;
384        let sanitized = sanitize_certificate_pem(cert);
385        assert!(!sanitized.contains("BEGIN"));
386        assert!(sanitized.starts_with("MIIC"));
387    }
388
389    #[test]
390    fn test_parse_jwt_token() {
391        // Sample JWT with exp claim (not a real token, just for testing structure)
392        let token =
393            "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MDQwNjcyMDAsInN1YiI6InRlc3QifQ.test";
394
395        let result = parse_security_token(token.to_string()).unwrap();
396        assert_eq!(result.token, token);
397        // exp: 1704067200 = 2024-01-01 00:00:00 UTC
398        assert_eq!(result.expires_at.to_rfc3339(), "2024-01-01T00:00:00+00:00");
399    }
400
401    #[test]
402    fn test_is_retryable_error() {
403        // 5XX errors should be retryable
404        let error = crate::core::OciError::ServiceError {
405            status: 500,
406            code: "InternalError".to_string(),
407            message: "Internal server error".to_string(),
408        };
409        assert!(is_retryable_error(&error));
410
411        // 4XX errors should NOT be retryable
412        let error = crate::core::OciError::ConfigError("Bad request".to_string());
413        assert!(!is_retryable_error(&error));
414    }
415}