68b8c5d19d
Signed-off-by: Uddhav Kambli <uddhav@kambli.net>
1116 lines
39 KiB
Rust
1116 lines
39 KiB
Rust
use async_trait::async_trait;
|
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
|
use std::{env, fmt, io};
|
|
use tokio::sync::RwLock;
|
|
|
|
/// Represents errors that can occur during GCP authentication.
|
|
///
|
|
/// This enum encompasses various error conditions that might arise during
|
|
/// the authentication process, including credential loading, token creation,
|
|
/// and token exchange operations.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AuthError {
|
|
/// Error when loading credentials from the filesystem or environment
|
|
#[error("Failed to load credentials: {0}")]
|
|
Credentials(String),
|
|
|
|
/// Error during JWT token creation
|
|
#[error("Token creation failed: {0}")]
|
|
TokenCreation(String),
|
|
|
|
/// Error during OAuth token exchange
|
|
#[error("Token exchange failed: {0}")]
|
|
TokenExchange(String),
|
|
}
|
|
|
|
/// Represents an authentication token with its type and value.
|
|
///
|
|
/// This structure holds both the token type (e.g., "Bearer") and its
|
|
/// actual value, typically used for authentication with GCP services.
|
|
/// The token is obtained either through service account or user credentials.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AuthToken {
|
|
/// The type of the token (e.g., "Bearer")
|
|
pub token_type: String,
|
|
/// The actual token value
|
|
pub token_value: String,
|
|
}
|
|
|
|
impl fmt::Display for AuthToken {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{} {}", self.token_type, self.token_value)
|
|
}
|
|
}
|
|
|
|
/// Represents the types of Application Default Credentials (ADC) supported.
|
|
///
|
|
/// GCP supports multiple credential types for authentication. This enum
|
|
/// represents the two main types: authorized user and service account.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
enum AdcCredentials {
|
|
/// Credentials for an authorized user (typically from gcloud auth)
|
|
AuthorizedUser(AuthorizedUserCredentials),
|
|
/// Credentials for a service account
|
|
ServiceAccount(ServiceAccountCredentials),
|
|
/// Credentials for the GCP native default account
|
|
DefaultAccount(TokenResponse),
|
|
}
|
|
|
|
/// Credentials for an authorized user account.
|
|
///
|
|
/// These credentials are typically obtained through interactive login
|
|
/// with the gcloud CLI tool.
|
|
#[derive(Debug, Deserialize)]
|
|
struct AuthorizedUserCredentials {
|
|
/// OAuth 2.0 client ID
|
|
client_id: String,
|
|
/// OAuth 2.0 client secret
|
|
client_secret: String,
|
|
/// OAuth 2.0 refresh token
|
|
refresh_token: String,
|
|
/// URI for token refresh requests
|
|
#[serde(default = "default_token_uri")]
|
|
token_uri: String,
|
|
}
|
|
|
|
/// Credentials for a service account.
|
|
///
|
|
/// These credentials are typically obtained from a JSON key file
|
|
/// downloaded from the Google Cloud Console.
|
|
#[derive(Debug, Deserialize)]
|
|
struct ServiceAccountCredentials {
|
|
/// Service account email address
|
|
client_email: String,
|
|
/// The private key from JSON credential for signing JWT tokens
|
|
private_key: String,
|
|
/// URI for token exchange requests
|
|
token_uri: String,
|
|
}
|
|
|
|
/// Returns the default OAuth 2.0 token endpoint.
|
|
fn default_token_uri() -> String {
|
|
"https://oauth2.googleapis.com/token".to_string()
|
|
}
|
|
|
|
/// A trait that defines operations for interacting with the filesystem.
|
|
///
|
|
/// This trait provides an abstraction over filesystem operations, primarily
|
|
/// for reading credential files. It enables testing through mock implementations.
|
|
#[async_trait]
|
|
pub trait FilesystemOps {
|
|
/// Reads the contents of a file into a string.
|
|
///
|
|
/// # Arguments
|
|
/// * `path` - The path to the file to read
|
|
///
|
|
/// # Returns
|
|
/// * `Result<String, io::Error>` - The contents of the file or an error
|
|
async fn read_to_string(&self, path: String) -> Result<String, io::Error>;
|
|
}
|
|
|
|
/// A trait that defines operations for accessing environment variables.
|
|
///
|
|
/// This trait provides an abstraction over environment variable access,
|
|
/// enabling testing through mock implementations.
|
|
pub trait EnvOps {
|
|
/// Retrieves the value of an environment variable.
|
|
///
|
|
/// # Arguments
|
|
/// * `key` - The name of the environment variable
|
|
///
|
|
/// # Returns
|
|
/// * `Result<String, env::VarError>` - The value of the variable or an error if not found
|
|
fn get_var(&self, key: &str) -> Result<String, env::VarError>;
|
|
}
|
|
|
|
/// A concrete implementation of FilesystemOps using the actual filesystem.
|
|
///
|
|
/// This implementation uses tokio's async filesystem operations for
|
|
/// reading files in an asynchronous manner.
|
|
pub struct RealFilesystemOps;
|
|
|
|
/// A concrete implementation of EnvOps using the actual environment.
|
|
///
|
|
/// This implementation directly accesses system environment variables
|
|
/// through the standard library.
|
|
pub struct RealEnvOps;
|
|
|
|
#[async_trait]
|
|
impl FilesystemOps for RealFilesystemOps {
|
|
async fn read_to_string(&self, path: String) -> Result<String, io::Error> {
|
|
tokio::fs::read_to_string(path).await
|
|
}
|
|
}
|
|
|
|
impl EnvOps for RealEnvOps {
|
|
fn get_var(&self, key: &str) -> Result<String, env::VarError> {
|
|
env::var(key)
|
|
}
|
|
}
|
|
|
|
impl AdcCredentials {
|
|
/// Loads credentials from the default locations.
|
|
/// https://cloud.google.com/docs/authentication/application-default-credentials#personal
|
|
///
|
|
/// Attempts to load credentials in the following order:
|
|
/// 1. GOOGLE_APPLICATION_CREDENTIALS environment variable
|
|
/// 2. Default gcloud credentials path (~/.config/gcloud/application_default_credentials.json)
|
|
/// 3. Metadata server if running in GCP
|
|
async fn load() -> Result<Self, AuthError> {
|
|
Self::load_impl(
|
|
&RealFilesystemOps,
|
|
&RealEnvOps,
|
|
"http://metadata.google.internal",
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn load_impl(
|
|
fs_ops: &impl FilesystemOps,
|
|
env_ops: &impl EnvOps,
|
|
metadata_base_url: &str,
|
|
) -> Result<Self, AuthError> {
|
|
// Try GOOGLE_APPLICATION_CREDENTIALS first
|
|
if let Ok(cred_path) = Self::get_env_credentials_path(env_ops) {
|
|
if let Ok(creds) = Self::load_from_file(fs_ops, &cred_path).await {
|
|
return Ok(creds);
|
|
}
|
|
}
|
|
|
|
// Try default gcloud credentials path
|
|
if let Ok(cred_path) = Self::get_default_credentials_path(env_ops) {
|
|
if let Ok(creds) = Self::load_from_file(fs_ops, &cred_path).await {
|
|
return Ok(creds);
|
|
}
|
|
}
|
|
|
|
// Try metadata server if running on GCP
|
|
if let Ok(creds) = Self::load_from_metadata_server(metadata_base_url).await {
|
|
return Ok(creds);
|
|
}
|
|
|
|
Err(AuthError::Credentials(
|
|
"No valid credentials found in any location".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn load_from_file(fs_ops: &impl FilesystemOps, path: &str) -> Result<Self, AuthError> {
|
|
let content = fs_ops.read_to_string(path.to_string()).await.map_err(|e| {
|
|
AuthError::Credentials(format!("Failed to read credentials from {}: {}", path, e))
|
|
})?;
|
|
|
|
serde_json::from_str(&content)
|
|
.map_err(|e| AuthError::Credentials(format!("Invalid credentials format: {}", e)))
|
|
}
|
|
|
|
fn get_env_credentials_path(env_ops: &impl EnvOps) -> Result<String, AuthError> {
|
|
env_ops
|
|
.get_var("GOOGLE_APPLICATION_CREDENTIALS")
|
|
.map_err(|_| {
|
|
AuthError::Credentials("GOOGLE_APPLICATION_CREDENTIALS not set".to_string())
|
|
})
|
|
}
|
|
|
|
fn get_default_credentials_path(env_ops: &impl EnvOps) -> Result<String, AuthError> {
|
|
let (env_var, subpath) = if cfg!(windows) {
|
|
("APPDATA", "gcloud\\application_default_credentials.json")
|
|
} else {
|
|
(
|
|
"HOME",
|
|
".config/gcloud/application_default_credentials.json",
|
|
)
|
|
};
|
|
|
|
env_ops
|
|
.get_var(env_var)
|
|
.map(|dir| {
|
|
PathBuf::from(dir)
|
|
.join(subpath)
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
})
|
|
.map_err(|_| {
|
|
AuthError::Credentials("Could not determine user home directory".to_string())
|
|
})
|
|
}
|
|
|
|
async fn load_from_metadata_server(base_url: &str) -> Result<Self, AuthError> {
|
|
let client = reqwest::Client::new();
|
|
let metadata_path = "/computeMetadata/v1/instance/service-accounts/default/token";
|
|
|
|
let response = client
|
|
.get(format!("{}{}", base_url, metadata_path))
|
|
.header("Metadata-Flavor", "Google")
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
AuthError::Credentials(format!("Metadata server request failed: {}", e))
|
|
})?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(AuthError::Credentials(
|
|
"Not running on GCP or metadata server unavailable".to_string(),
|
|
));
|
|
}
|
|
|
|
// Get the identity token and credentials from metadata server
|
|
let token_response = response
|
|
.json::<TokenResponse>()
|
|
.await
|
|
.map_err(|e| AuthError::Credentials(format!("Invalid metadata response: {}", e)))?;
|
|
|
|
// Note: When using metadata server, we have access to the OAuth2 access token
|
|
// that can be used to authenticate applications.
|
|
Ok(AdcCredentials::DefaultAccount(TokenResponse {
|
|
token_type: token_response.token_type,
|
|
access_token: token_response.access_token,
|
|
expires_in: token_response.expires_in,
|
|
}))
|
|
}
|
|
}
|
|
|
|
/// Claims structure for JWT tokens.
|
|
///
|
|
/// These claims are included in the JWT token used for service account
|
|
/// authentication.
|
|
#[derive(Debug, Serialize)]
|
|
struct JwtClaims {
|
|
/// Token issuer (service account email)
|
|
iss: String,
|
|
/// Token subject (service account email)
|
|
sub: String,
|
|
/// Service account scope within role
|
|
scope: String,
|
|
/// Token audience (OAuth endpoint)
|
|
aud: String,
|
|
/// Token issued at timestamp
|
|
iat: u64,
|
|
/// Token expiration timestamp
|
|
exp: u64,
|
|
}
|
|
|
|
/// Holds a cached token and its expiration time.
|
|
///
|
|
/// Used internally to implement token caching and automatic refresh.
|
|
#[derive(Debug, Clone)]
|
|
struct CachedToken {
|
|
/// The cached authentication token
|
|
token: AuthToken,
|
|
/// When the token will expire
|
|
expires_at: Instant,
|
|
}
|
|
|
|
/// Response structure for token exchange requests.
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
struct TokenResponse {
|
|
/// The access token string
|
|
access_token: String,
|
|
/// Token lifetime in seconds
|
|
expires_in: u64,
|
|
/// Token type (e.g., "Bearer")
|
|
#[serde(default)]
|
|
token_type: String,
|
|
}
|
|
|
|
/// Handles authentication with Google Cloud Platform services.
|
|
///
|
|
/// This struct manages the complete authentication lifecycle including:
|
|
/// - Loading and validating credentials
|
|
/// - Creating and refreshing tokens
|
|
/// - Caching tokens for efficient reuse
|
|
/// - Managing concurrent access through atomic operations
|
|
///
|
|
/// It supports both service account and authorized user authentication methods,
|
|
/// automatically selecting the appropriate method based on available credentials.
|
|
/// ```
|
|
#[derive(Debug)]
|
|
pub struct GcpAuth {
|
|
/// The loaded credentials (service account or authorized user)
|
|
credentials: AdcCredentials,
|
|
/// HTTP client for making token exchange requests
|
|
client: reqwest::Client,
|
|
/// Thread-safe cache for the current token
|
|
cached_token: Arc<RwLock<Option<CachedToken>>>,
|
|
}
|
|
|
|
impl GcpAuth {
|
|
/// Creates a new GCP authentication handler.
|
|
///
|
|
/// Initializes the authentication handler by:
|
|
/// 1. Loading credentials from default locations
|
|
/// 2. Setting up an HTTP client for token requests
|
|
/// 3. Initializing the token cache
|
|
///
|
|
/// The credentials are loaded in the following order:
|
|
/// 1. GOOGLE_APPLICATION_CREDENTIALS environment variable
|
|
/// 2. Default gcloud credentials path
|
|
/// 3. GCP metadata server (when running on GCP)
|
|
///
|
|
/// # Returns
|
|
/// * `Result<Self, AuthError>` - A new GcpAuth instance or an error if initialization fails
|
|
pub async fn new() -> Result<Self, AuthError> {
|
|
Ok(Self {
|
|
credentials: AdcCredentials::load().await?,
|
|
client: reqwest::Client::new(),
|
|
cached_token: Arc::new(RwLock::new(None)),
|
|
})
|
|
}
|
|
|
|
/// Retrieves a valid authentication token.
|
|
///
|
|
/// This method implements an efficient token management strategy:
|
|
/// 1. Checks the cache for a valid token
|
|
/// 2. Returns the cached token if not expired
|
|
/// 3. Obtains a new token if needed or expired
|
|
/// 4. Uses double-checked locking for thread safety
|
|
///
|
|
/// The returned token includes a type (usually "Bearer") and the actual
|
|
/// token value used for authentication with GCP services.
|
|
///
|
|
/// # Returns
|
|
/// * `Result<AuthToken, AuthError>` - A valid authentication token or an error
|
|
pub async fn get_token(&self) -> Result<AuthToken, AuthError> {
|
|
// Try read lock first for better concurrency
|
|
if let Some(cached) = self.cached_token.read().await.as_ref() {
|
|
if cached.expires_at > Instant::now() {
|
|
return Ok(cached.token.clone());
|
|
}
|
|
}
|
|
|
|
// Take write lock only if needed
|
|
let mut token_guard = self.cached_token.write().await;
|
|
|
|
// Double-check expiration after acquiring write lock
|
|
if let Some(cached) = token_guard.as_ref() {
|
|
if cached.expires_at > Instant::now() {
|
|
return Ok(cached.token.clone());
|
|
}
|
|
}
|
|
|
|
// Get new token
|
|
let token_response = match &self.credentials {
|
|
AdcCredentials::ServiceAccount(creds) => self.get_service_account_token(creds).await?,
|
|
AdcCredentials::AuthorizedUser(creds) => self.get_authorized_user_token(creds).await?,
|
|
AdcCredentials::DefaultAccount(creds) => self.get_default_access_token(creds).await?,
|
|
};
|
|
|
|
let auth_token = AuthToken {
|
|
token_type: if token_response.token_type.is_empty() {
|
|
"Bearer".to_string()
|
|
} else {
|
|
token_response.token_type
|
|
},
|
|
token_value: token_response.access_token,
|
|
};
|
|
|
|
let expires_at = Instant::now()
|
|
+ Duration::from_secs(
|
|
token_response.expires_in.saturating_sub(30), // 30 second buffer
|
|
);
|
|
|
|
*token_guard = Some(CachedToken {
|
|
token: auth_token.clone(),
|
|
expires_at,
|
|
});
|
|
|
|
Ok(auth_token)
|
|
}
|
|
|
|
/// Creates a JWT token for service account authentication.
|
|
///
|
|
/// # Arguments
|
|
/// * `creds` - Service account credentials for signing the token
|
|
///
|
|
/// # Returns
|
|
/// * `Result<String>` - A signed JWT token
|
|
fn create_jwt_token(&self, creds: &ServiceAccountCredentials) -> Result<String, AuthError> {
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map_err(|e| AuthError::TokenCreation(e.to_string()))?
|
|
.as_secs();
|
|
|
|
let claims = JwtClaims {
|
|
iss: creds.client_email.clone(),
|
|
sub: creds.client_email.clone(),
|
|
scope: "https://www.googleapis.com/auth/cloud-platform".to_string(),
|
|
aud: creds.token_uri.clone(),
|
|
iat: now,
|
|
exp: now + 3600, // 1 hours validity
|
|
};
|
|
|
|
let encoding_key = EncodingKey::from_rsa_pem(creds.private_key.as_bytes())
|
|
.map_err(|e| AuthError::TokenCreation(format!("Invalid private key: {}", e)))?;
|
|
|
|
encode(
|
|
&Header::new(jsonwebtoken::Algorithm::RS256),
|
|
&claims,
|
|
&encoding_key,
|
|
)
|
|
.map_err(|e| AuthError::TokenCreation(format!("Failed to create JWT: {}", e)))
|
|
}
|
|
|
|
/// Exchanges a token or assertion for an access token.
|
|
///
|
|
/// # Arguments
|
|
/// * `token_uri` - The token exchange endpoint
|
|
/// * `params` - Parameters for the token exchange request
|
|
///
|
|
/// # Returns
|
|
/// * `Result<TokenResponse>` - The token exchange response
|
|
async fn exchange_token(
|
|
&self,
|
|
token_uri: &str,
|
|
params: &[(&str, &str)],
|
|
) -> Result<TokenResponse, AuthError> {
|
|
let response = self
|
|
.client
|
|
.post(token_uri)
|
|
.form(params)
|
|
.send()
|
|
.await
|
|
.map_err(|e| AuthError::TokenExchange(e.to_string()))?;
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let error_text = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
|
return Err(AuthError::TokenExchange(format!(
|
|
"Status {}: {}",
|
|
status, error_text
|
|
)));
|
|
}
|
|
|
|
response
|
|
.json::<TokenResponse>()
|
|
.await
|
|
.map_err(|e| AuthError::TokenExchange(format!("Invalid response: {}", e)))
|
|
}
|
|
|
|
/// Gets a token using service account credentials.
|
|
///
|
|
/// # Arguments
|
|
/// * `creds` - Service account credentials
|
|
///
|
|
/// # Returns
|
|
/// * `Result<TokenResponse>` - The token response
|
|
async fn get_service_account_token(
|
|
&self,
|
|
creds: &ServiceAccountCredentials,
|
|
) -> Result<TokenResponse, AuthError> {
|
|
let jwt = self.create_jwt_token(creds)?;
|
|
let params = [
|
|
("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
|
|
("assertion", &jwt),
|
|
("scope", "https://www.googleapis.com/auth/cloud-platform"),
|
|
];
|
|
|
|
self.exchange_token(&creds.token_uri, ¶ms).await
|
|
}
|
|
|
|
/// Gets a token using authorized user credentials.
|
|
///
|
|
/// # Arguments
|
|
/// * `creds` - Authorized user credentials
|
|
///
|
|
/// # Returns
|
|
/// * `Result<TokenResponse>` - The token response
|
|
async fn get_authorized_user_token(
|
|
&self,
|
|
creds: &AuthorizedUserCredentials,
|
|
) -> Result<TokenResponse, AuthError> {
|
|
let params = [
|
|
("client_id", creds.client_id.as_str()),
|
|
("client_secret", creds.client_secret.as_str()),
|
|
("refresh_token", creds.refresh_token.as_str()),
|
|
("grant_type", "refresh_token"),
|
|
("scope", "https://www.googleapis.com/auth/cloud-platform"),
|
|
];
|
|
|
|
self.exchange_token(&creds.token_uri, ¶ms).await
|
|
}
|
|
|
|
/// Gets a token directly from the GCP metadata endpoint.
|
|
///
|
|
/// # Arguments
|
|
/// * `creds` - Default Access Token Response
|
|
///
|
|
/// # Returns
|
|
/// * `Result<TokenResponse>` - The token response
|
|
async fn get_default_access_token(
|
|
&self,
|
|
creds: &TokenResponse,
|
|
) -> Result<TokenResponse, AuthError> {
|
|
Ok(creds.clone())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use mockall::predicate::eq;
|
|
use tokio::time::sleep;
|
|
use wiremock::matchers::{header, method, path};
|
|
// Only import what we need
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
mockall::mock! {
|
|
#[derive(Debug)]
|
|
FilesystemOpsMock {}
|
|
|
|
#[async_trait]
|
|
impl FilesystemOps for FilesystemOpsMock {
|
|
async fn read_to_string(&self, path: String) -> Result<String, std::io::Error>;
|
|
}
|
|
}
|
|
|
|
mockall::mock! {
|
|
#[derive(Debug)]
|
|
EnvOpsMock {}
|
|
|
|
impl EnvOps for EnvOpsMock {
|
|
fn get_var(&self, key: &str) -> Result<String, env::VarError>;
|
|
}
|
|
}
|
|
|
|
struct TestContext {
|
|
fs_mock: MockFilesystemOpsMock,
|
|
env_mock: MockEnvOpsMock,
|
|
mock_server: Option<MockServer>,
|
|
}
|
|
|
|
impl TestContext {
|
|
fn new() -> Self {
|
|
Self {
|
|
fs_mock: MockFilesystemOpsMock::new(),
|
|
env_mock: MockEnvOpsMock::new(),
|
|
mock_server: None,
|
|
}
|
|
}
|
|
|
|
async fn with_metadata_server(mut self) -> Self {
|
|
self.mock_server = Some(MockServer::start().await);
|
|
self
|
|
}
|
|
}
|
|
|
|
// Test fixtures for credentials
|
|
fn mock_service_account() -> ServiceAccountCredentials {
|
|
ServiceAccountCredentials {
|
|
client_email: "test@test.com".to_string(),
|
|
// This is a generated test credential
|
|
private_key: "-----BEGIN RSA PRIVATE KEY-----
|
|
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
|
|
-----END RSA PRIVATE KEY-----"
|
|
.to_string(),
|
|
token_uri: "https://oauth2.googleapis.com/token".to_string(),
|
|
}
|
|
}
|
|
|
|
fn mock_authorized_user() -> AuthorizedUserCredentials {
|
|
AuthorizedUserCredentials {
|
|
client_id: "test_client".to_string(),
|
|
client_secret: "test_secret".to_string(),
|
|
refresh_token: "test_refresh".to_string(),
|
|
token_uri: "https://oauth2.googleapis.com/token".to_string(),
|
|
}
|
|
}
|
|
|
|
// Helper function to create a test GcpAuth instance with credentials
|
|
async fn create_test_auth_with_creds(creds: AdcCredentials) -> GcpAuth {
|
|
GcpAuth {
|
|
credentials: creds,
|
|
client: reqwest::Client::new(),
|
|
cached_token: Arc::new(RwLock::new(None)),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_token_caching() {
|
|
let auth = GcpAuth {
|
|
credentials: AdcCredentials::ServiceAccount(mock_service_account()),
|
|
client: reqwest::Client::new(),
|
|
cached_token: Arc::new(RwLock::new(Some(CachedToken {
|
|
token: AuthToken {
|
|
token_type: "Bearer".to_string(),
|
|
token_value: "cached_token".to_string(),
|
|
},
|
|
expires_at: Instant::now() + Duration::from_secs(3600),
|
|
}))),
|
|
};
|
|
|
|
// First call should return cached token
|
|
let token1 = auth.get_token().await.unwrap();
|
|
assert_eq!(token1.token_value, "cached_token");
|
|
|
|
// Second call should return same cached token
|
|
let token2 = auth.get_token().await.unwrap();
|
|
assert_eq!(token2.token_value, "cached_token");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_token_expiration() {
|
|
let auth = GcpAuth {
|
|
credentials: AdcCredentials::ServiceAccount(mock_service_account()),
|
|
client: reqwest::Client::new(),
|
|
cached_token: Arc::new(RwLock::new(Some(CachedToken {
|
|
token: AuthToken {
|
|
token_type: "Bearer".to_string(),
|
|
token_value: "expired_token".to_string(),
|
|
},
|
|
expires_at: Instant::now() - Duration::from_secs(1),
|
|
}))),
|
|
};
|
|
|
|
// Should fail as token is expired and real credentials aren't available
|
|
let result = auth.get_token().await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_invalid_credentials() {
|
|
let auth = create_test_auth_with_creds(AdcCredentials::ServiceAccount(
|
|
ServiceAccountCredentials {
|
|
client_email: "".to_string(),
|
|
private_key: "invalid".to_string(),
|
|
token_uri: "https://invalid.example.com".to_string(),
|
|
},
|
|
))
|
|
.await;
|
|
|
|
let result = auth.get_token().await;
|
|
assert!(result.is_err());
|
|
match result {
|
|
Err(AuthError::TokenCreation(_)) => (),
|
|
_ => panic!("Expected TokenCreationError"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_concurrent_token_access() {
|
|
let auth = Arc::new(GcpAuth {
|
|
credentials: AdcCredentials::ServiceAccount(mock_service_account()),
|
|
client: reqwest::Client::new(),
|
|
cached_token: Arc::new(RwLock::new(Some(CachedToken {
|
|
token: AuthToken {
|
|
token_type: "Bearer".to_string(),
|
|
token_value: "concurrent_token".to_string(),
|
|
},
|
|
expires_at: Instant::now() + Duration::from_secs(3600),
|
|
}))),
|
|
});
|
|
|
|
let mut handles = vec![];
|
|
|
|
// Spawn multiple concurrent token requests
|
|
for _ in 0..10 {
|
|
let auth_clone = Arc::clone(&auth);
|
|
handles.push(tokio::spawn(async move {
|
|
auth_clone.get_token().await.unwrap()
|
|
}));
|
|
}
|
|
|
|
// All requests should return the same cached token
|
|
for handle in handles {
|
|
let token = handle.await.unwrap();
|
|
assert_eq!(token.token_value, "concurrent_token");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_token_refresh_race_condition() {
|
|
let auth = Arc::new(GcpAuth {
|
|
credentials: AdcCredentials::ServiceAccount(mock_service_account()),
|
|
client: reqwest::Client::new(),
|
|
cached_token: Arc::new(RwLock::new(Some(CachedToken {
|
|
token: AuthToken {
|
|
token_type: "Bearer".to_string(),
|
|
token_value: "about_to_expire".to_string(),
|
|
},
|
|
expires_at: Instant::now() + Duration::from_millis(100),
|
|
}))),
|
|
});
|
|
|
|
let mut handles = vec![];
|
|
|
|
for i in 0..5 {
|
|
let auth_clone = Arc::clone(&auth);
|
|
handles.push(tokio::spawn(async move {
|
|
sleep(Duration::from_millis(i * 50)).await;
|
|
let result = auth_clone.get_token().await;
|
|
match result {
|
|
Ok(token) => {
|
|
// Should be the cached token since we can't actually exchange tokens in tests
|
|
assert_eq!(
|
|
token.token_value, "about_to_expire",
|
|
"Expected cached token, got: {}",
|
|
token.token_value
|
|
);
|
|
}
|
|
Err(e) => {
|
|
match e {
|
|
AuthError::TokenExchange(err) => {
|
|
// This is expected - we can't actually exchange tokens in tests
|
|
assert!(
|
|
err.contains("invalid_scope") || err.contains("400"),
|
|
"Unexpected error message: {}",
|
|
err
|
|
);
|
|
}
|
|
other => panic!("Unexpected error type: {:?}", other),
|
|
}
|
|
}
|
|
}
|
|
}));
|
|
}
|
|
|
|
// Wait for all handles
|
|
for handle in handles {
|
|
handle.await.unwrap();
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_authorized_user_token() {
|
|
let auth = GcpAuth {
|
|
credentials: AdcCredentials::AuthorizedUser(mock_authorized_user()),
|
|
client: reqwest::Client::new(),
|
|
cached_token: Arc::new(RwLock::new(None)),
|
|
};
|
|
|
|
// This should fail since we can't actually make the token exchange request
|
|
let result = auth.get_token().await;
|
|
assert!(result.is_err());
|
|
match result {
|
|
Err(AuthError::TokenExchange(_)) => (),
|
|
_ => panic!("Expected TokenExchangeError"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_service_account_jwt_creation() {
|
|
let auth = GcpAuth {
|
|
credentials: AdcCredentials::ServiceAccount(mock_service_account()),
|
|
client: reqwest::Client::new(),
|
|
cached_token: Arc::new(RwLock::new(None)),
|
|
};
|
|
|
|
let jwt = auth.create_jwt_token(&mock_service_account());
|
|
assert!(jwt.is_ok(), "JWT creation failed: {:?}", jwt.err());
|
|
let jwt_str = jwt.unwrap();
|
|
assert!(jwt_str.starts_with("ey"), "JWT should start with 'ey'");
|
|
assert_eq!(
|
|
jwt_str.matches('.').count(),
|
|
2,
|
|
"JWT should have exactly 2 dots"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_from_env_credentials() {
|
|
let mut context = TestContext::new();
|
|
|
|
// Mock environment variable
|
|
context
|
|
.env_mock
|
|
.expect_get_var()
|
|
.with(eq("GOOGLE_APPLICATION_CREDENTIALS"))
|
|
.times(1)
|
|
.return_once(|_| Ok("/path/to/credentials.json".to_string()));
|
|
|
|
// Mock file content - convert &str to String for comparison
|
|
let creds_content = r#"{
|
|
"type": "service_account",
|
|
"client_email": "test@example.com",
|
|
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIE...test...key\n-----END PRIVATE KEY-----\n",
|
|
"token_uri": "https://oauth2.googleapis.com/token"
|
|
}"#;
|
|
|
|
context
|
|
.fs_mock
|
|
.expect_read_to_string()
|
|
.with(eq("/path/to/credentials.json".to_string())) // Convert to String
|
|
.times(1)
|
|
.return_once(move |_| Ok(creds_content.to_string()));
|
|
|
|
let result = AdcCredentials::load_impl(
|
|
&context.fs_mock,
|
|
&context.env_mock,
|
|
"http://metadata.example.com",
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
if let Ok(AdcCredentials::ServiceAccount(sa)) = result {
|
|
assert_eq!(sa.client_email, "test@example.com");
|
|
assert!(sa.private_key.contains("test...key"));
|
|
} else {
|
|
panic!("Expected ServiceAccount credentials");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_from_default_path() {
|
|
let mut context = TestContext::new();
|
|
|
|
// Mock environment variables
|
|
context
|
|
.env_mock
|
|
.expect_get_var()
|
|
.with(eq("GOOGLE_APPLICATION_CREDENTIALS"))
|
|
.times(1)
|
|
.return_once(|_| Err(env::VarError::NotPresent));
|
|
|
|
let home_var = if cfg!(windows) { "APPDATA" } else { "HOME" };
|
|
context
|
|
.env_mock
|
|
.expect_get_var()
|
|
.with(eq(home_var))
|
|
.times(1)
|
|
.return_once(|_| Ok("/home/testuser".to_string()));
|
|
|
|
// Mock file content
|
|
let creds_content = r#"{
|
|
"type": "authorized_user",
|
|
"client_id": "test_client",
|
|
"client_secret": "test_secret",
|
|
"refresh_token": "test_refresh"
|
|
}"#;
|
|
|
|
let expected_path = if cfg!(windows) {
|
|
"/home/testuser/gcloud/application_default_credentials.json".to_string()
|
|
} else {
|
|
"/home/testuser/.config/gcloud/application_default_credentials.json".to_string()
|
|
};
|
|
|
|
context
|
|
.fs_mock
|
|
.expect_read_to_string()
|
|
.with(eq(expected_path.clone())) // Use clone() to avoid borrowing issues
|
|
.times(1)
|
|
.return_once(move |_| Ok(creds_content.to_string()));
|
|
|
|
let result = AdcCredentials::load_impl(
|
|
&context.fs_mock,
|
|
&context.env_mock,
|
|
"http://metadata.example.com",
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
if let Ok(AdcCredentials::AuthorizedUser(au)) = result {
|
|
assert_eq!(au.client_id, "test_client");
|
|
assert_eq!(au.client_secret, "test_secret");
|
|
assert_eq!(au.refresh_token, "test_refresh");
|
|
} else {
|
|
panic!("Expected AuthorizedUser credentials");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_from_metadata_server() {
|
|
let mut context = TestContext::new();
|
|
|
|
// Mock environment variable lookups to fail
|
|
context
|
|
.env_mock
|
|
.expect_get_var()
|
|
.with(eq("GOOGLE_APPLICATION_CREDENTIALS"))
|
|
.times(1)
|
|
.return_once(|_| Err(env::VarError::NotPresent));
|
|
|
|
let home_var = if cfg!(windows) { "APPDATA" } else { "HOME" };
|
|
context
|
|
.env_mock
|
|
.expect_get_var()
|
|
.with(eq(home_var))
|
|
.times(1)
|
|
.return_once(|_| Err(env::VarError::NotPresent));
|
|
|
|
// Initialize mock server
|
|
let context = context.with_metadata_server().await;
|
|
let mock_server = context
|
|
.mock_server
|
|
.as_ref()
|
|
.expect("Mock server should be initialized");
|
|
|
|
// Define expected token values
|
|
let expected_token = "test_token";
|
|
let expected_type = "Bearer";
|
|
let expected_expires = 3600;
|
|
|
|
// Configure mock response
|
|
Mock::given(method("GET"))
|
|
.and(path(
|
|
"/computeMetadata/v1/instance/service-accounts/default/token",
|
|
))
|
|
.and(header("Metadata-Flavor", "Google"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"access_token": expected_token,
|
|
"expires_in": expected_expires,
|
|
"token_type": expected_type,
|
|
})))
|
|
.mount(mock_server)
|
|
.await;
|
|
|
|
// Execute the code under test
|
|
let result =
|
|
AdcCredentials::load_impl(&context.fs_mock, &context.env_mock, &mock_server.uri())
|
|
.await;
|
|
|
|
// Assertions
|
|
assert!(
|
|
result.is_ok(),
|
|
"Expected successful result, got {:?}",
|
|
result
|
|
);
|
|
|
|
if let Ok(AdcCredentials::DefaultAccount(token_response)) = result {
|
|
assert_eq!(token_response.access_token, expected_token);
|
|
assert_eq!(token_response.token_type, expected_type);
|
|
assert_eq!(token_response.expires_in, expected_expires);
|
|
} else {
|
|
panic!("Expected DefaultAccount credentials, got {:?}", result);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_invalid_credentials_file() {
|
|
let mut context = TestContext::new();
|
|
|
|
// Mock GOOGLE_APPLICATION_CREDENTIALS environment variable
|
|
context
|
|
.env_mock
|
|
.expect_get_var()
|
|
.with(eq("GOOGLE_APPLICATION_CREDENTIALS"))
|
|
.times(1)
|
|
.return_once(|_| Ok("/path/to/credentials.json".to_string()));
|
|
|
|
// Mock filesystem read for the invalid credentials file
|
|
context
|
|
.fs_mock
|
|
.expect_read_to_string()
|
|
.with(eq("/path/to/credentials.json".to_string()))
|
|
.times(1)
|
|
.return_once(|_| Ok("invalid json".to_string()));
|
|
|
|
// Mock HOME/APPDATA environment variable
|
|
let home_var = if cfg!(windows) { "APPDATA" } else { "HOME" };
|
|
context
|
|
.env_mock
|
|
.expect_get_var()
|
|
.with(eq(home_var))
|
|
.times(1)
|
|
.return_once(|_| Ok("/home/user".to_string()));
|
|
|
|
// Mock filesystem read for the default credentials path
|
|
let default_creds_path = if cfg!(windows) {
|
|
"/home/user/gcloud/application_default_credentials.json"
|
|
} else {
|
|
"/home/user/.config/gcloud/application_default_credentials.json"
|
|
};
|
|
context
|
|
.fs_mock
|
|
.expect_read_to_string()
|
|
.with(eq(default_creds_path.to_string()))
|
|
.times(1)
|
|
.return_once(|_| {
|
|
Err(std::io::Error::new(
|
|
std::io::ErrorKind::NotFound,
|
|
"File not found",
|
|
))
|
|
});
|
|
|
|
let result = AdcCredentials::load_impl(
|
|
&context.fs_mock,
|
|
&context.env_mock,
|
|
"http://metadata.example.com",
|
|
)
|
|
.await;
|
|
|
|
assert!(matches!(result, Err(AuthError::Credentials(_))));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_no_credentials_found() {
|
|
let mut context = TestContext::new();
|
|
|
|
// Mock all credential sources to fail
|
|
context
|
|
.env_mock
|
|
.expect_get_var()
|
|
.with(eq("GOOGLE_APPLICATION_CREDENTIALS"))
|
|
.times(1)
|
|
.return_once(|_| Err(env::VarError::NotPresent));
|
|
|
|
context
|
|
.env_mock
|
|
.expect_get_var()
|
|
.with(eq(if cfg!(windows) { "APPDATA" } else { "HOME" }))
|
|
.times(1)
|
|
.return_once(|_| Err(env::VarError::NotPresent));
|
|
|
|
let result = AdcCredentials::load_impl(
|
|
&context.fs_mock,
|
|
&context.env_mock,
|
|
"http://metadata.example.com",
|
|
)
|
|
.await;
|
|
assert!(matches!(result, Err(AuthError::Credentials(_))));
|
|
}
|
|
}
|