use std::fmt::{Debug, Formatter};
use std::time::Duration;
use async_trait::async_trait;
use cloud_detect::ProviderId;
use reqwest_middleware::ClientWithMiddleware as Client;
use tokio::sync::Mutex;
use tokio::time::sleep;
use tracing::{debug, warn};
use url::Url;
use super::{ClientGrant, cloud_provider_id, jwt_bearer, request_token, token_endpoint};
use crate::http::Accepted as _;
use crate::{
CredentialError, CredentialResult, OAuth2ClientAuthentication, OAuth2Grant, OAuth2TokenRequest,
OAuth2TokenResponse, Token,
};
pub struct GcpAuthProvider {
client: Client,
token_endpoint: Url,
workload_id: String,
resource: Option<String>,
}
impl Debug for GcpAuthProvider {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GcpAuthProvider").finish()
}
}
impl GcpAuthProvider {
pub fn new(client: impl Into<Client>, token_endpoint: Url, workload_id: String) -> Self {
GcpAuthProvider {
client: client.into(),
token_endpoint,
workload_id,
resource: None,
}
}
pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
self.resource = Some(resource.into());
self
}
}
impl GcpAuthProvider {
pub async fn token(&self) -> CredentialResult<Option<Token>> {
if !matches!(cloud_provider_id().await, Some(ProviderId::GCP)) {
return Ok(None);
}
let gcp_token = metadata_identity_token(&self.client, self.token_endpoint.as_str()).await?;
let token = jwt_bearer(
&self.client,
&self.token_endpoint,
self.workload_id.clone(),
gcp_token.expose_secret().to_string(),
self.resource.clone(),
)
.await?;
debug!("GcpAuthProvider obtained a workload token");
Ok(Some(token))
}
}
impl_token_source!(GcpAuthProvider);
const METADATA_IDENTITY_URL: &str =
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity";
const IDENTITY_RENEWAL_MARGIN: Duration = Duration::from_secs(20 * 60);
const METADATA_RETRY_DELAYS: [Duration; 2] =
[Duration::from_millis(100), Duration::from_millis(400)];
pub struct GcpServiceAuthProvider {
client: Client,
token_endpoint: Url,
client_id: String,
resource: Option<String>,
identity: HostIdentity,
}
struct HostIdentity {
client: Client,
url: Url,
current: Mutex<Option<Token>>,
}
impl HostIdentity {
fn new(client: Client, metadata_url: &str, audience: &str) -> CredentialResult<Self> {
Ok(Self {
client,
url: identity_url(metadata_url, audience)?,
current: Mutex::new(None),
})
}
async fn token(&self) -> CredentialResult<Token> {
let mut current = self.current.lock().await;
if let Some(token) = current.as_ref()
&& token.remaining_lifetime()? > IDENTITY_RENEWAL_MARGIN
{
return Ok(token.clone());
}
match fetch_identity_token(&self.client, &self.url).await {
Ok(token) => {
*current = Some(token.clone());
Ok(token)
}
Err(error) => match current.as_ref() {
Some(token) if !token.is_expired()? => {
warn!(%error, "renewing the GCP host identity failed; presenting the current one");
Ok(token.clone())
}
_ => Err(error),
},
}
}
}
impl GcpServiceAuthProvider {
pub fn new(
client: impl Into<Client>,
auth_url: &Url,
client_id: String,
resource: Option<String>,
) -> CredentialResult<Self> {
let client = client.into();
let token_endpoint = token_endpoint(auth_url)?;
let identity = HostIdentity::new(
client.clone(),
METADATA_IDENTITY_URL,
token_endpoint.as_str(),
)?;
Ok(Self {
client,
token_endpoint,
client_id,
resource,
identity,
})
}
pub async fn token(&self) -> CredentialResult<Option<Token>> {
let assertion = self.identity.token().await?;
let token = jwt_bearer(
&self.client,
&self.token_endpoint,
self.client_id.clone(),
assertion.expose_secret().to_string(),
self.resource.clone(),
)
.await?;
debug!(client_id = %self.client_id, "exchanged GCP host identity for service token");
Ok(Some(token))
}
}
impl Debug for GcpServiceAuthProvider {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GcpServiceAuthProvider")
.field("client_id", &self.client_id)
.finish_non_exhaustive()
}
}
impl_token_source!(GcpServiceAuthProvider);
#[async_trait]
impl ClientGrant for GcpServiceAuthProvider {
async fn grant(
&self,
resource: &str,
authorization_details: &serde_json::Value,
) -> CredentialResult<OAuth2TokenResponse> {
let assertion = self.identity.token().await?;
let request = OAuth2TokenRequest {
client: OAuth2ClientAuthentication {
client_id: Some(self.client_id.clone()),
..Default::default()
},
grant: OAuth2Grant::JwtBearer {
assertion: assertion.expose_secret().to_owned(),
resource: Some(resource.to_owned()),
authorization_details: Some(authorization_details.to_string()),
},
};
request_token(&self.client, &self.token_endpoint, &request).await
}
}
async fn metadata_identity_token(client: &Client, audience: &str) -> CredentialResult<Token> {
fetch_identity_token(client, &identity_url(METADATA_IDENTITY_URL, audience)?).await
}
fn identity_url(metadata_url: &str, audience: &str) -> CredentialResult<Url> {
let mut url = Url::parse(metadata_url)
.map_err(|error| invalid!("invalid metadata identity URL: {error}"))?;
url.query_pairs_mut().append_pair("audience", audience);
Ok(url)
}
async fn fetch_identity_token(client: &Client, url: &Url) -> CredentialResult<Token> {
let mut delays = METADATA_RETRY_DELAYS.into_iter();
loop {
match request_identity_token(client, url).await {
Err(error) if is_transient(&error) => match delays.next() {
Some(delay) => {
debug!(%error, "retrying a transient metadata server failure");
sleep(delay).await;
}
None => return Err(error),
},
result => return result,
}
}
}
async fn request_identity_token(client: &Client, url: &Url) -> CredentialResult<Token> {
let response = client
.get(url.clone())
.header("Metadata-Flavor", "Google")
.send()
.await?
.accepted()
.await?
.text()
.await?;
Ok(Token::from(response))
}
fn is_transient(error: &CredentialError) -> bool {
match error {
CredentialError::Rejected { status, .. } => {
status.as_u16() == 499 || status.as_u16() == 429 || status.is_server_error()
}
CredentialError::Http(error) => error.is_timeout() || error.is_connect(),
_ => false,
}
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use futures::future::join_all;
use serde_json::json;
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use crate::http::test_client;
use crate::token::unsigned_jwt;
const AUDIENCE: &str = "https://auth.spiraldb.test/oauth/token";
fn id_token(lifetime: Duration) -> Token {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
unsigned_jwt(json!({ "exp": (now + lifetime).as_secs() }))
}
async fn serve_one_id_token(metadata: &MockServer, token: &Token) {
metadata.reset().await;
Mock::given(method("GET"))
.and(path("/identity"))
.and(header("Metadata-Flavor", "Google"))
.and(query_param("audience", AUDIENCE))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(token.expose_secret())
.set_delay(Duration::from_millis(50)),
)
.expect(1)
.mount(metadata)
.await;
}
#[tokio::test]
async fn the_host_identity_is_fetched_once_and_renewed_before_it_expires() {
let metadata = MockServer::start().await;
let identity = HostIdentity::new(
test_client(),
&format!("{}/identity", metadata.uri()),
AUDIENCE,
)
.unwrap();
let fresh = id_token(Duration::from_secs(3600));
serve_one_id_token(&metadata, &fresh).await;
for token in join_all((0..20).map(|_| identity.token())).await {
assert_eq!(token.unwrap().expose_secret(), fresh.expose_secret());
}
metadata.verify().await;
*identity.current.lock().await = Some(id_token(IDENTITY_RENEWAL_MARGIN / 2));
let renewed = id_token(Duration::from_secs(3600));
serve_one_id_token(&metadata, &renewed).await;
assert_eq!(
identity.token().await.unwrap().expose_secret(),
renewed.expose_secret()
);
metadata.verify().await;
}
#[tokio::test]
async fn a_transient_metadata_failure_is_retried_and_renewal_falls_back() {
let metadata = MockServer::start().await;
let identity = HostIdentity::new(
test_client(),
&format!("{}/identity", metadata.uri()),
AUDIENCE,
)
.unwrap();
let cancelled = || {
Mock::given(method("GET"))
.and(path("/identity"))
.respond_with(ResponseTemplate::new(499).set_body_string("context canceled"))
};
let fresh = id_token(Duration::from_secs(3600));
cancelled().up_to_n_times(1).mount(&metadata).await;
Mock::given(method("GET"))
.and(path("/identity"))
.respond_with(ResponseTemplate::new(200).set_body_string(fresh.expose_secret()))
.mount(&metadata)
.await;
assert_eq!(
identity.token().await.unwrap().expose_secret(),
fresh.expose_secret()
);
metadata.reset().await;
cancelled().mount(&metadata).await;
let renewing = id_token(IDENTITY_RENEWAL_MARGIN / 2);
*identity.current.lock().await = Some(renewing.clone());
assert_eq!(
identity.token().await.unwrap().expose_secret(),
renewing.expose_secret()
);
*identity.current.lock().await = Some(id_token(Duration::from_secs(10)));
assert!(identity.token().await.is_err());
}
}