Skip to main content

oci_api/auth/providers/
instance_principal.rs

1use std::sync::Arc;
2use std::time::{Duration, SystemTime, UNIX_EPOCH};
3
4use async_trait::async_trait;
5use base64::{
6    Engine as _, engine::general_purpose::STANDARD, engine::general_purpose::URL_SAFE_NO_PAD,
7};
8use reqwest::{Client, blocking::Client as BlockingClient};
9use rsa::RsaPrivateKey;
10use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey, LineEnding};
11use rsa::rand_core::OsRng;
12use serde::Deserialize;
13use sha1::Sha1;
14use sha2::Digest;
15use tokio::sync::Mutex;
16use x509_parser::{parse_x509_certificate, pem::parse_x509_pem};
17
18use crate::auth::providers::{OciAuthProvider, SignRequest, SignedHeaders};
19use crate::client::signer::OciSigner;
20use crate::error::{Error, Result};
21
22pub(crate) const DEFAULT_METADATA_BASE_URL: &str = "http://169.254.169.254/opc/v2";
23pub(crate) const DEFAULT_REALM_DOMAIN_COMPONENT: &str = "oraclecloud.com";
24const METADATA_AUTHORIZATION: &str = "Bearer Oracle";
25const REGION_INFO_PATH: &str = "/instance/regionInfo";
26const LEAF_CERTIFICATE_PATH: &str = "/identity/cert.pem";
27const LEAF_PRIVATE_KEY_PATH: &str = "/identity/key.pem";
28const INTERMEDIATE_CERTIFICATE_PATH: &str = "/identity/intermediate.pem";
29const DEFAULT_REFRESH_WINDOW_SECS: u64 = 300;
30const TENANCY_PREFIX: &str = "opc-tenant:";
31const IDENTITY_PREFIX: &str = "opc-identity:";
32
33#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
34pub struct MetadataRegionInfo {
35    #[serde(rename = "regionIdentifier")]
36    pub region_identifier: String,
37    #[serde(rename = "realmDomainComponent")]
38    pub realm_domain_component: String,
39}
40
41#[derive(Debug, Clone)]
42pub struct InstancePrincipalConfig {
43    pub region: String,
44    pub tenancy_id: String,
45    pub realm_domain_component: String,
46    pub metadata_base_url: String,
47    pub refresh_window: Duration,
48    pub auth_scheme: String,
49    pub auth_host_override: Option<String>,
50}
51
52impl InstancePrincipalConfig {
53    pub fn new(region: impl Into<String>, tenancy_id: impl Into<String>) -> Self {
54        Self {
55            region: region.into(),
56            tenancy_id: tenancy_id.into(),
57            realm_domain_component: DEFAULT_REALM_DOMAIN_COMPONENT.to_owned(),
58            metadata_base_url: DEFAULT_METADATA_BASE_URL.to_owned(),
59            refresh_window: Duration::from_secs(DEFAULT_REFRESH_WINDOW_SECS),
60            auth_scheme: "https".to_owned(),
61            auth_host_override: None,
62        }
63    }
64
65    pub fn metadata_base_url(mut self, metadata_base_url: impl Into<String>) -> Self {
66        self.metadata_base_url = metadata_base_url.into();
67        self
68    }
69
70    pub fn realm_domain_component(mut self, realm_domain_component: impl Into<String>) -> Self {
71        self.realm_domain_component = realm_domain_component.into();
72        self
73    }
74
75    pub fn refresh_window(mut self, refresh_window: Duration) -> Self {
76        self.refresh_window = refresh_window;
77        self
78    }
79
80    pub fn auth_scheme(mut self, auth_scheme: impl Into<String>) -> Self {
81        self.auth_scheme = auth_scheme.into();
82        self
83    }
84
85    pub fn auth_host_override(mut self, auth_host_override: impl Into<String>) -> Self {
86        self.auth_host_override = Some(auth_host_override.into());
87        self
88    }
89}
90
91#[derive(Clone)]
92pub struct InstancePrincipalAuthProvider {
93    client: Client,
94    config: InstancePrincipalConfig,
95    state: Arc<Mutex<Option<InstancePrincipalState>>>,
96}
97
98struct InstancePrincipalState {
99    signer: OciSigner,
100    expires_at: SystemTime,
101}
102
103#[derive(Deserialize)]
104struct FederationResponse {
105    token: String,
106}
107
108impl InstancePrincipalAuthProvider {
109    pub fn new(client: Client, config: InstancePrincipalConfig) -> Self {
110        Self {
111            client,
112            config,
113            state: Arc::new(Mutex::new(None)),
114        }
115    }
116
117    async fn ensure_state(&self) -> Result<OciSigner> {
118        let mut guard = self.state.lock().await;
119
120        if let Some(state) = guard.as_ref() {
121            let refresh_at = state
122                .expires_at
123                .checked_sub(self.config.refresh_window)
124                .unwrap_or(UNIX_EPOCH);
125            if SystemTime::now() < refresh_at {
126                return Ok(state.signer.clone());
127            }
128        }
129
130        let state = self.refresh_state().await?;
131        let signer = state.signer.clone();
132        *guard = Some(state);
133        Ok(signer)
134    }
135
136    async fn refresh_state(&self) -> Result<InstancePrincipalState> {
137        let metadata = self.fetch_metadata_materials().await?;
138        let session_private_key_pem = new_session_private_key_pem()?;
139        let session_public_key = session_public_key_pem(&session_private_key_pem)?;
140
141        let auth_key_id = format!(
142            "{}/fed-x509/{}",
143            self.config.tenancy_id,
144            certificate_fingerprint(&metadata.leaf_certificate)?
145        );
146        let auth_signer = OciSigner::new_with_key_id(auth_key_id, &metadata.leaf_private_key)?;
147
148        let request_body = serde_json::json!({
149            "certificate": sanitize_pem_body(&metadata.leaf_certificate),
150            "publicKey": sanitize_pem_body(&session_public_key),
151            "intermediateCertificates": [sanitize_pem_body(&metadata.intermediate_certificate)],
152        });
153        let body_json = serde_json::to_string(&request_body)?;
154        let path = "/v1/x509";
155        let host = self.auth_host();
156        let signed = auth_signer.sign_request_headers(
157            "POST",
158            path,
159            None,
160            Some(&body_json),
161            Some("application/json"),
162            None,
163        )?;
164
165        let response = self
166            .client
167            .post(format!("{}://{host}{path}", self.config.auth_scheme))
168            .header("date", &signed.date)
169            .header("authorization", &signed.authorization)
170            .header(
171                "content-type",
172                signed
173                    .content_type
174                    .unwrap_or_else(|| "application/json".to_owned()),
175            )
176            .header(
177                "content-length",
178                signed
179                    .content_length
180                    .unwrap_or_else(|| body_json.len().to_string()),
181            )
182            .header(
183                "x-content-sha256",
184                signed
185                    .x_content_sha256
186                    .ok_or_else(|| Error::AuthError("Missing x-content-sha256".to_owned()))?,
187            )
188            .body(body_json)
189            .send()
190            .await?;
191
192        if !response.status().is_success() {
193            let status = response.status();
194            let body = response.text().await?;
195            return Err(Error::ApiError {
196                code: status.to_string(),
197                message: body,
198            });
199        }
200
201        let FederationResponse { token } = response.json().await?;
202        let expires_at = jwt_expiration(&token)?;
203        let session_signer =
204            OciSigner::new_with_key_id(format!("ST${token}"), &session_private_key_pem)?;
205
206        Ok(InstancePrincipalState {
207            signer: session_signer,
208            expires_at,
209        })
210    }
211
212    async fn fetch_metadata_materials(&self) -> Result<MetadataMaterials> {
213        let leaf_certificate = self.fetch_metadata_text(LEAF_CERTIFICATE_PATH).await?;
214        let leaf_private_key = self.fetch_metadata_text(LEAF_PRIVATE_KEY_PATH).await?;
215        let intermediate_certificate = self
216            .fetch_metadata_text(INTERMEDIATE_CERTIFICATE_PATH)
217            .await?;
218
219        Ok(MetadataMaterials {
220            leaf_certificate,
221            leaf_private_key,
222            intermediate_certificate,
223        })
224    }
225
226    async fn fetch_metadata_text(&self, path: &str) -> Result<String> {
227        let response = self
228            .client
229            .get(format!("{}{}", self.config.metadata_base_url, path))
230            .header("authorization", METADATA_AUTHORIZATION)
231            .send()
232            .await?;
233
234        if !response.status().is_success() {
235            let status = response.status();
236            let body = response.text().await?;
237            return Err(Error::ApiError {
238                code: status.to_string(),
239                message: body,
240            });
241        }
242
243        response.text().await.map_err(Into::into)
244    }
245
246    pub async fn metadata_region(client: &Client, metadata_base_url: &str) -> Result<String> {
247        let region_info = Self::metadata_region_info(client, metadata_base_url).await?;
248        Ok(region_info.region_identifier)
249    }
250
251    pub async fn metadata_region_info(
252        client: &Client,
253        metadata_base_url: &str,
254    ) -> Result<MetadataRegionInfo> {
255        let response = client
256            .get(format!("{metadata_base_url}{REGION_INFO_PATH}"))
257            .header("authorization", METADATA_AUTHORIZATION)
258            .send()
259            .await?;
260
261        if !response.status().is_success() {
262            let status = response.status();
263            let body = response.text().await?;
264            return Err(Error::ApiError {
265                code: status.to_string(),
266                message: body,
267            });
268        }
269
270        response.json().await.map_err(Into::into)
271    }
272
273    pub(crate) fn metadata_region_info_blocking(
274        client: &BlockingClient,
275        metadata_base_url: &str,
276    ) -> Result<MetadataRegionInfo> {
277        let response = client
278            .get(format!("{metadata_base_url}{REGION_INFO_PATH}"))
279            .header("authorization", METADATA_AUTHORIZATION)
280            .send()?;
281
282        if !response.status().is_success() {
283            let status = response.status();
284            let body = response.text()?;
285            return Err(Error::ApiError {
286                code: status.to_string(),
287                message: body,
288            });
289        }
290
291        response.json().map_err(Into::into)
292    }
293
294    pub(crate) fn tenancy_id_from_metadata_certificate_blocking(
295        client: &BlockingClient,
296        metadata_base_url: &str,
297    ) -> Result<String> {
298        let certificate_pem =
299            Self::metadata_text_blocking(client, metadata_base_url, LEAF_CERTIFICATE_PATH)?;
300        tenancy_id_from_certificate(&certificate_pem)
301    }
302
303    fn metadata_text_blocking(
304        client: &BlockingClient,
305        metadata_base_url: &str,
306        path: &str,
307    ) -> Result<String> {
308        let response = client
309            .get(format!("{metadata_base_url}{path}"))
310            .header("authorization", METADATA_AUTHORIZATION)
311            .send()?;
312
313        if !response.status().is_success() {
314            let status = response.status();
315            let body = response.text()?;
316            return Err(Error::ApiError {
317                code: status.to_string(),
318                message: body,
319            });
320        }
321
322        response.text().map_err(Into::into)
323    }
324
325    fn auth_host(&self) -> String {
326        self.config.auth_host_override.clone().unwrap_or_else(|| {
327            format!(
328                "auth.{}.{}",
329                self.config.region, self.config.realm_domain_component
330            )
331        })
332    }
333}
334
335#[async_trait]
336impl OciAuthProvider for InstancePrincipalAuthProvider {
337    async fn sign(&self, request: &SignRequest<'_>) -> Result<SignedHeaders> {
338        let signer = self.ensure_state().await?;
339        let signed = signer.sign_request_headers(
340            request.method,
341            request.path,
342            request.host,
343            request.body,
344            request.content_type,
345            None,
346        )?;
347
348        Ok(SignedHeaders {
349            date: signed.date,
350            authorization: signed.authorization,
351            content_type: signed.content_type,
352            content_length: signed.content_length,
353            x_content_sha256: signed.x_content_sha256,
354            extra_headers: Vec::new(),
355        })
356    }
357}
358
359struct MetadataMaterials {
360    leaf_certificate: String,
361    leaf_private_key: String,
362    intermediate_certificate: String,
363}
364
365fn sanitize_pem_body(value: &str) -> String {
366    value
367        .replace("-----BEGIN CERTIFICATE-----", "")
368        .replace("-----END CERTIFICATE-----", "")
369        .replace("-----BEGIN PUBLIC KEY-----", "")
370        .replace("-----END PUBLIC KEY-----", "")
371        .replace('\n', "")
372}
373
374fn certificate_fingerprint(certificate_pem: &str) -> Result<String> {
375    let der_body = sanitize_pem_body(certificate_pem);
376    let der = STANDARD
377        .decode(der_body)
378        .map_err(|e| Error::AuthError(format!("Failed to decode certificate: {e}")))?;
379    let digest = Sha1::digest(der);
380    Ok(digest
381        .iter()
382        .map(|byte| format!("{byte:02X}"))
383        .collect::<Vec<_>>()
384        .join(":"))
385}
386
387fn new_session_private_key_pem() -> Result<String> {
388    let private_key = RsaPrivateKey::new(&mut OsRng, 2048)
389        .map_err(|e| Error::AuthError(format!("Failed to generate session key: {e}")))?;
390    private_key
391        .to_pkcs8_pem(LineEnding::LF)
392        .map(|pem| pem.to_string())
393        .map_err(|e| Error::AuthError(format!("Failed to encode session key: {e}")))
394}
395
396fn session_public_key_pem(private_key_pem: &str) -> Result<String> {
397    let private_key = RsaPrivateKey::from_pkcs8_pem(private_key_pem)
398        .map_err(|e| Error::AuthError(format!("Failed to parse session key: {e}")))?;
399    private_key
400        .to_public_key()
401        .to_public_key_pem(LineEnding::LF)
402        .map_err(|e| Error::AuthError(format!("Failed to encode session public key: {e}")))
403}
404
405fn jwt_expiration(token: &str) -> Result<SystemTime> {
406    let payload = token
407        .split('.')
408        .nth(1)
409        .ok_or_else(|| Error::AuthError("Security token payload is missing".to_owned()))?;
410    let decoded = URL_SAFE_NO_PAD
411        .decode(payload)
412        .map_err(|e| Error::AuthError(format!("Failed to decode security token: {e}")))?;
413    let value: serde_json::Value = serde_json::from_slice(&decoded)?;
414    let exp = value
415        .get("exp")
416        .and_then(|value| value.as_u64())
417        .ok_or_else(|| Error::AuthError("Security token exp claim is missing".to_owned()))?;
418    Ok(UNIX_EPOCH + Duration::from_secs(exp))
419}
420
421fn tenancy_id_from_certificate(certificate_pem: &str) -> Result<String> {
422    let (_, pem) = parse_x509_pem(certificate_pem.as_bytes())
423        .map_err(|e| Error::AuthError(format!("Failed to parse certificate PEM: {e}")))?;
424    let (_, certificate) = parse_x509_certificate(&pem.contents)
425        .map_err(|e| Error::AuthError(format!("Failed to parse certificate DER: {e}")))?;
426
427    let mut fallback: Option<String> = None;
428    for attribute in certificate.subject().iter_attributes() {
429        let value = attribute
430            .as_str()
431            .map_err(|e| Error::AuthError(format!("Failed to decode certificate subject: {e}")))?;
432        if let Some(tenancy_id) = value.strip_prefix(TENANCY_PREFIX) {
433            return Ok(tenancy_id.to_owned());
434        }
435        if let Some(tenancy_id) = value.strip_prefix(IDENTITY_PREFIX) {
436            fallback = Some(tenancy_id.to_owned());
437        }
438    }
439
440    fallback.ok_or_else(|| {
441        Error::AuthError(
442            "Certificate subject does not contain an opc-tenant or opc-identity value".to_owned(),
443        )
444    })
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    use mockito::{Matcher, Server};
452    use rsa::pkcs8::EncodePrivateKey;
453
454    const TENANT_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
455MIIDXzCCAkegAwIBAgIUONFqOCNE1N3Aps1ZQaPpY7SQzngwDQYJKoZIhvcNAQEL\n\
456BQAwPzEuMCwGA1UECgwlb3BjLXRlbmFudDpvY2lkMS50ZW5hbnR5Lm9jMS4uZXhh\n\
457bXBsZTENMAsGA1UEAwwEdGVzdDAeFw0yNjA1MTEwNjQ1NTFaFw0yNjA1MTIwNjQ1\n\
458NTFaMD8xLjAsBgNVBAoMJW9wYy10ZW5hbnQ6b2NpZDEudGVuYW5jeS5vYzEuLmV4\n\
459YW1wbGUxDTALBgNVBAMMBHRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK\n\
460AoIBAQDMblfnza9gqREWumv1mTJbR939nQIYZUynTxusVBXciNRjKaqB0jFSUFg9\n\
461E2pwtr7G/zr6rpIum9yaRT3O/hhIACP7CJvOoIPTV8qDmNcRnlT78nWBN8jnma1A\n\
462T9AZhtR14BJVe03eSSHBTnIDNNDQZu1+p6hUiGPVG1xe/F3/HOwbUrxzsChDnliZ\n\
463C46FL0JMIu/uH/Q/iSg0wYsJQKzE+iIvLo5edTeaTvdaTth8XLmltWM2DEwC/fyU\n\
464D2lxoOmvBhCVl1OCvT3Db0hMXRVV79BAXNS+qUyKbWnAgkiAMDGmEtYzizAoqCl4\n\
465GpDeqNfSI/xo8Zt1RqU1PgleQslDAgMBAAGjUzBRMB0GA1UdDgQWBBRnTn//hXKL\n\
466fWGEt7RY27CGihg+DjAfBgNVHSMEGDAWgBRnTn//hXKLfWGEt7RY27CGihg+DjAP\n\
467BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAwRR1OsfwCP1UF4PWK\n\
468jQLcBHrwEL7q9/HG47G6IsD4YN365ZPKzv7cOVzL7sPXVs18f3XDZwVNhwMiP2lo\n\
469ShLlHDIog2ZMD0kppoZlwf1EdbVVOr30qtHaRpd1/YHY1omuUCdis51iJzO/wMwL\n\
470m3yCFx7OCb46vCHwWc+CwiF9I9HKFMJyVpmhsEw91EPH3JaHWW1wn/RSIXuWpX0Q\n\
471t+CmwNhI9TC99JL2cfr5lFUjA8nQ5Xx68L9gyfQZ2aicx5XD+s+nt0mgc06oOWv3\n\
472ubYEGH/Vy8oK3rEoKdcNVdZUTgA0Fs2g+ItlrBFsJl5A1/TP3f0fbV6j9eY2SpdB\n\
473Eo34\n\
474-----END CERTIFICATE-----\n";
475    const IDENTITY_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\n\
476MIIDZTCCAk2gAwIBAgIUMOZAko5vvssEkoQ2WHQPY7f9x7gwDQYJKoZIhvcNAQEL\n\
477BQAwQjExMC8GA1UECgwob3BjLWlkZW50aXR5Om9jaWQxLnRlbmFuY3kub2MxLi5m\n\
478YWxsYmFjazENMAsGA1UEAwwEdGVzdDAeFw0yNjA1MTEwNjQ1NTFaFw0yNjA1MTIw\n\
479NjQ1NTFaMEIxMTAvBgNVBAoMKG9wYy1pZGVudGl0eTpvY2lkMS50ZW5hbmN5Lm9j\n\
480MS4uZmFsbGJhY2sxDTALBgNVBAMMBHRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IB\n\
481DwAwggEKAoIBAQDmduilwMQ6tEwB/vyl2mtYWJ3H08t444tmq2vxpFl4XUlPT4S4\n\
482A3tdME87tmZdC0e4f5lUnEo+ZVO9H2pXdPP6pD0sBdvPxJ/FBZtTCCQiA4p9TSVR\n\
483grBXJFd9sNGff7Og6HVdWlTt0fj0K3MlBxg4Tae3+Dzlt7qOJ5xE88Fwh5agOxbS\n\
484vvHwKmAOkW47ArK/cIBv8LzJotINAdMhKykBuFRxc9WwIUWSbNQvYYeFu3YD3Ny1\n\
485v8qwbYPVC2HU/3M8SJmQmAbDgWFw1onqWk94fzoVenwdb7uS7fJtkjf7MppyMtx0\n\
486PhgPTt6al22K6sJvKOlN/lkFQ1DwzQqpPYNFAgMBAAGjUzBRMB0GA1UdDgQWBBRe\n\
487VO8o3p/eN6Qak29wCTCQAAnxqTAfBgNVHSMEGDAWgBReVO8o3p/eN6Qak29wCTCQ\n\
488AAnxqTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBCg/E2AjS7\n\
489cMz1GMGEy9zmpJ1OhD0lksrPHZpfp/LyfCiI677HSIKlBxCKjq720CZM5jAqw8eU\n\
490CsLG8fqtBqOmc6lH/h+3LjGMQsnTjNW7e9sX3rzOyfGblrOX+cVpYYXjUVxtJwS3\n\
491p62tIXpRa/waFgKfYyFv3QHFK//QW1ZVeklnIVJ1sTLgMfRmf6inGp51R5x/aclY\n\
492WdHlZRZUqf8KtLhLE+yevBpZh9YRvfIWvCYoNU4PF6c5XhPo6Q1jqzYKwkxVAKvR\n\
493Sp5TG8PoJmFKTSFP71z+N5kIy2Ez7h1YjBfU+46dGJMuIOAdF7fttUj4wjtd0xo8\n\
494tOmUqakVOgtb\n\
495-----END CERTIFICATE-----\n";
496
497    fn test_private_key_pem() -> String {
498        RsaPrivateKey::new(&mut OsRng, 2048)
499            .unwrap()
500            .to_pkcs8_pem(LineEnding::LF)
501            .unwrap()
502            .to_string()
503    }
504
505    fn test_certificate_pem(label: &str) -> String {
506        format!(
507            "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n",
508            STANDARD.encode(label.as_bytes())
509        )
510    }
511
512    fn test_jwt(exp: u64) -> String {
513        let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
514        let payload =
515            URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp},"sub":"instance-principal"}}"#));
516        format!("{header}.{payload}.signature")
517    }
518
519    fn strip_http_scheme(url: &str) -> String {
520        url.trim_start_matches("http://").to_owned()
521    }
522
523    #[tokio::test]
524    async fn test_metadata_region_fetches_imds_value() {
525        let mut server = Server::new_async().await;
526        let _mock = server
527            .mock("GET", "/opc/v2/instance/regionInfo")
528            .match_header("authorization", METADATA_AUTHORIZATION)
529            .with_status(200)
530            .with_body(
531                r#"{"realmKey":"oc1","realmDomainComponent":"oraclecloud.com","regionKey":"ICN","regionIdentifier":"ap-seoul-1"}"#,
532            )
533            .create_async()
534            .await;
535
536        let region = InstancePrincipalAuthProvider::metadata_region(
537            &Client::new(),
538            &format!("{}/opc/v2", server.url()),
539        )
540        .await
541        .unwrap();
542
543        assert_eq!(region, "ap-seoul-1");
544    }
545
546    #[tokio::test]
547    async fn test_metadata_region_info_fetches_realm_domain() {
548        let mut server = Server::new_async().await;
549        let _mock = server
550            .mock("GET", "/opc/v2/instance/regionInfo")
551            .match_header("authorization", METADATA_AUTHORIZATION)
552            .with_status(200)
553            .with_body(
554                r#"{"realmKey":"oc2","realmDomainComponent":"oraclegovcloud.com","regionKey":"IAD","regionIdentifier":"us-langley-1"}"#,
555            )
556            .create_async()
557            .await;
558
559        let region_info = InstancePrincipalAuthProvider::metadata_region_info(
560            &Client::new(),
561            &format!("{}/opc/v2", server.url()),
562        )
563        .await
564        .unwrap();
565
566        assert_eq!(region_info.region_identifier, "us-langley-1");
567        assert_eq!(region_info.realm_domain_component, "oraclegovcloud.com");
568    }
569
570    #[test]
571    fn test_tenancy_id_from_certificate_prefers_opc_tenant_prefix() {
572        let tenancy_id = tenancy_id_from_certificate(TENANT_CERT_PEM).unwrap();
573        assert_eq!(tenancy_id, "ocid1.tenancy.oc1..example");
574    }
575
576    #[test]
577    fn test_tenancy_id_from_certificate_falls_back_to_opc_identity_prefix() {
578        let tenancy_id = tenancy_id_from_certificate(IDENTITY_CERT_PEM).unwrap();
579        assert_eq!(tenancy_id, "ocid1.tenancy.oc1..fallback");
580    }
581
582    #[tokio::test]
583    async fn test_sign_fetches_and_reuses_security_token() {
584        let mut metadata_server = Server::new_async().await;
585        let leaf_private_key = test_private_key_pem();
586        let leaf_certificate = test_certificate_pem("leaf");
587        let intermediate_certificate = test_certificate_pem("intermediate");
588
589        let _leaf_cert = metadata_server
590            .mock("GET", "/opc/v2/identity/cert.pem")
591            .match_header("authorization", METADATA_AUTHORIZATION)
592            .expect(1)
593            .with_status(200)
594            .with_body(leaf_certificate.clone())
595            .create_async()
596            .await;
597        let _leaf_key = metadata_server
598            .mock("GET", "/opc/v2/identity/key.pem")
599            .match_header("authorization", METADATA_AUTHORIZATION)
600            .expect(1)
601            .with_status(200)
602            .with_body(leaf_private_key.clone())
603            .create_async()
604            .await;
605        let _intermediate = metadata_server
606            .mock("GET", "/opc/v2/identity/intermediate.pem")
607            .match_header("authorization", METADATA_AUTHORIZATION)
608            .expect(1)
609            .with_status(200)
610            .with_body(intermediate_certificate.clone())
611            .create_async()
612            .await;
613
614        let exp = SystemTime::now()
615            .duration_since(UNIX_EPOCH)
616            .unwrap()
617            .as_secs()
618            + 3600;
619        let token = test_jwt(exp);
620
621        let mut auth_server = Server::new_async().await;
622        let auth_host = strip_http_scheme(&auth_server.url());
623        let _auth = auth_server
624            .mock("POST", "/v1/x509")
625            .match_header(
626                "content-type",
627                Matcher::Regex("application/json".to_owned()),
628            )
629            .match_header(
630                "authorization",
631                Matcher::Regex(r#"keyId="ocid1\.tenancy\.oc1\.\.example/fed-x509/"#.to_owned()),
632            )
633            .expect(1)
634            .with_status(200)
635            .with_body(format!(r#"{{"token":"{token}"}}"#))
636            .create_async()
637            .await;
638
639        let provider = InstancePrincipalAuthProvider::new(
640            Client::new(),
641            InstancePrincipalConfig::new("ap-seoul-1", "ocid1.tenancy.oc1..example")
642                .metadata_base_url(format!("{}/opc/v2", metadata_server.url()))
643                .auth_scheme("http")
644                .auth_host_override(auth_host)
645                .refresh_window(Duration::from_secs(60)),
646        );
647
648        let request = SignRequest {
649            method: "GET",
650            path: "/n/test_namespace/b/test_bucket/o/test_object",
651            host: Some("objectstorage.ap-seoul-1.oraclecloud.com"),
652            body: None,
653            content_type: None,
654        };
655
656        let first = provider.sign(&request).await.unwrap();
657        let second = provider.sign(&request).await.unwrap();
658
659        assert!(first.authorization.contains("keyId=\"ST$"));
660        assert_eq!(first.authorization, second.authorization);
661    }
662}