From 925dbbae063b251b926bb3ca7557858fd3731655 Mon Sep 17 00:00:00 2001 From: Scott Driggers Date: Fri, 18 Aug 2023 09:15:12 -0400 Subject: [PATCH 1/7] Supporting service account key format OR user credential formats This PR supports parsing both formats in either the `GOOGLE_APPLICATION_CREDENTIALS` env variable or the `~/.config/gcloud/application_default_credentials.json` file. --- src/authentication_manager.rs | 22 +++- src/custom_service_account.rs | 3 +- src/default_authorized_user.rs | 20 +-- src/flexible_credential_source.rs | 195 ++++++++++++++++++++++++++++++ src/lib.rs | 1 + 5 files changed, 218 insertions(+), 23 deletions(-) create mode 100644 src/flexible_credential_source.rs diff --git a/src/authentication_manager.rs b/src/authentication_manager.rs index 108ff98..123cb05 100644 --- a/src/authentication_manager.rs +++ b/src/authentication_manager.rs @@ -2,9 +2,9 @@ use async_trait::async_trait; use tokio::sync::Mutex; use crate::custom_service_account::CustomServiceAccount; -use crate::default_authorized_user::ConfigDefaultCredentials; use crate::default_service_account::MetadataServiceAccount; use crate::error::Error; +use crate::flexible_credential_source::FlexibleCredentialSource; use crate::gcloud_authorized_user::GCloudAuthorizedUser; use crate::types::{self, HyperClient, Token}; @@ -43,15 +43,25 @@ impl AuthenticationManager { #[tracing::instrument] pub async fn new() -> Result { tracing::debug!("Initializing gcp_auth"); - if let Some(service_account) = CustomServiceAccount::from_env()? { - return Ok(service_account.into()); + let client = types::client(); + if let Some(service_account) = FlexibleCredentialSource::from_env().await? { + tracing::debug!("Using GOOGLE_APPLICATION_CREDENTIALS env"); + + return Ok(Self { + service_account: service_account.try_into_service_account(&client).await?, + client, + refresh_mutex: Mutex::new(()), + }); } - let client = types::client(); - let default_user_error = match ConfigDefaultCredentials::new(&client).await { + let default_user_error = match FlexibleCredentialSource::from_default_credentials().await { Ok(service_account) => { tracing::debug!("Using ConfigDefaultCredentials"); - return Ok(Self::build(client, service_account)); + return Ok(Self { + service_account: service_account.try_into_service_account(&client).await?, + client, + refresh_mutex: Mutex::new(()), + }); } Err(e) => e, }; diff --git a/src/custom_service_account.rs b/src/custom_service_account.rs index 43edf44..c8c00ef 100644 --- a/src/custom_service_account.rs +++ b/src/custom_service_account.rs @@ -54,7 +54,7 @@ impl CustomServiceAccount { } } - fn new(credentials: ApplicationCredentials) -> Result { + pub(crate) fn new(credentials: ApplicationCredentials) -> Result { Ok(Self { signer: Signer::new(&credentials.private_key)?, credentials, @@ -137,7 +137,6 @@ impl ServiceAccount for CustomServiceAccount { #[derive(Serialize, Deserialize, Clone)] pub(crate) struct ApplicationCredentials { - pub(crate) r#type: Option, /// project_id pub(crate) project_id: Option, /// private_key_id diff --git a/src/default_authorized_user.rs b/src/default_authorized_user.rs index afaa2cb..8dcd6b6 100644 --- a/src/default_authorized_user.rs +++ b/src/default_authorized_user.rs @@ -1,4 +1,3 @@ -use std::fs; use std::sync::RwLock; use async_trait::async_trait; @@ -19,18 +18,11 @@ pub(crate) struct ConfigDefaultCredentials { impl ConfigDefaultCredentials { const DEFAULT_TOKEN_GCP_URI: &'static str = "https://accounts.google.com/o/oauth2/token"; - const USER_CREDENTIALS_PATH: &'static str = - ".config/gcloud/application_default_credentials.json"; - - pub(crate) async fn new(client: &HyperClient) -> Result { - tracing::debug!("Loading user credentials file"); - let mut home = dirs_next::home_dir().ok_or(Error::NoHomeDir)?; - home.push(Self::USER_CREDENTIALS_PATH); - - let file = fs::File::open(home).map_err(Error::UserProfilePath)?; - let credentials = serde_json::from_reader::<_, UserCredentials>(file) - .map_err(Error::UserProfileFormat)?; + pub(crate) async fn from_user_credentials( + credentials: UserCredentials, + client: &HyperClient, + ) -> Result { Ok(Self { token: RwLock::new(Self::get_token(&credentials, client).await?), credentials, @@ -105,7 +97,7 @@ struct RefreshRequest<'a> { } #[derive(Serialize, Deserialize, Debug, Clone)] -struct UserCredentials { +pub(crate) struct UserCredentials { /// Client id pub(crate) client_id: String, /// Client secret @@ -114,8 +106,6 @@ struct UserCredentials { pub(crate) quota_project_id: Option, /// Refresh Token pub(crate) refresh_token: String, - /// Type - pub(crate) r#type: String, } /// How many times to attempt to fetch a token from the GCP token endpoint. diff --git a/src/flexible_credential_source.rs b/src/flexible_credential_source.rs new file mode 100644 index 0000000..33b6b6c --- /dev/null +++ b/src/flexible_credential_source.rs @@ -0,0 +1,195 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use tokio::fs; + +use crate::{ + authentication_manager::ServiceAccount, + custom_service_account::ApplicationCredentials, + default_authorized_user::{ConfigDefaultCredentials, UserCredentials}, + types::HyperClient, + CustomServiceAccount, Error, +}; + +// Implementation referenced from +// https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/google.go#L158 +// Currently not implementing external account credentials +// Currently not implementing impersonating service accounts (coming soon !) +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum FlexibleCredentialSource { + // This credential parses the `key.json` file created when running + // `gcloud iam service-accounts keys create key.json --iam-account=SA_NAME@PROJECT_ID.iam.gserviceaccount.com` + ServiceAccount(ApplicationCredentials), + // This credential parses the `~/.config/gcloud/application_default_credentials.json` file + // created when running `gcloud auth application-default login` + AuthorizedUser(UserCredentials), +} + +impl FlexibleCredentialSource { + const USER_CREDENTIALS_PATH: &'static str = + ".config/gcloud/application_default_credentials.json"; + + pub(crate) async fn from_env() -> Result, Error> { + let creds_path = std::env::var_os("GOOGLE_APPLICATION_CREDENTIALS"); + if let Some(path) = creds_path { + tracing::debug!("Reading credentials file from GOOGLE_APPLICATION_CREDENTIALS env var"); + let creds = Self::from_file(PathBuf::from(path)).await?; + Ok(Some(creds)) + } else { + Ok(None) + } + } + + pub(crate) async fn from_default_credentials() -> Result { + tracing::debug!("Loading user credentials file"); + let mut home = dirs_next::home_dir().ok_or(Error::NoHomeDir)?; + home.push(Self::USER_CREDENTIALS_PATH); + Self::from_file(home).await + } + + pub(crate) async fn try_into_service_account( + self, + client: &HyperClient, + ) -> Result, Error> { + match self { + FlexibleCredentialSource::ServiceAccount(creds) => { + let service_account = CustomServiceAccount::new(creds)?; + Ok(Box::new(service_account)) + } + FlexibleCredentialSource::AuthorizedUser(creds) => { + let service_account = + ConfigDefaultCredentials::from_user_credentials(creds, client).await?; + Ok(Box::new(service_account)) + } + } + } + + /// Read service account credentials from the given JSON file + async fn from_file>(path: T) -> Result { + let creds_string = fs::read_to_string(&path) + .await + .map_err(Error::UserProfilePath)?; + + serde_json::from_str::(&creds_string) + .map_err(Error::CustomServiceAccountCredentials) + } +} + +#[cfg(test)] +mod tests { + use crate::{flexible_credential_source::FlexibleCredentialSource, types}; + + #[tokio::test] + async fn test_parse_application_default_credentials() { + let test_creds = r#"{ + "client_id": "***id***.apps.googleusercontent.com", + "client_secret": "***secret***", + "quota_project_id": "test_project", + "refresh_token": "***refresh***", + "type": "authorized_user" + }"#; + + let cred_source: FlexibleCredentialSource = + serde_json::from_str(test_creds).expect("Valid creds to parse"); + + assert!(matches!( + cred_source, + FlexibleCredentialSource::AuthorizedUser(_) + )); + + // Can't test converting this into a service account because it requires actually getting a key + } + + #[tokio::test] + async fn test_parse_service_account_key() { + // Don't worry, even though the key is a real private_key, it's not used for anything + let test_creds = r#" { + "type": "service_account", + "project_id": "test_project", + "private_key_id": "***key_id***", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC5M5y3WwsRk8NX\npF9fKaZukNspot9Ecmk1PAkupcHLKVhalwPxU4sMNWXgM9H2LTWSvvyOT//rDQpn\n3SGYri/lMhzb4lI8h10E7k6zyFQUPujxkXFBkMOzhIDUgtiiht0WvIw6M8nbaPqI\nxn/aYmPsFhvJfKCthYAt2UUz+D3enI9QjCuhic8iSMnvKT8m0QkOG2eALYGUaLF1\ngRkbV4BiBUGZfXfNEBdux3Wf4kNUau32LA0XotomlvNvf1oH77v5Hc1R/KMMIk5F\nJWVBuAr4jwkN9hwtOozpJ/52wSpddxsZuj+0nP1a3f0UyvrmMnuwszardPK39BoH\nJ+5+HZM3AgMBAAECggEADrHZrXK73hkrVrjkGFjlq8Ayo4sYzAWH84Ff+SONzODq\n8cUpuuw2DDHwc2mpLy9HIO2mfGQ8mhneyX7yO3sWscjYIVpDzCmxZ8LA2+L5SOH0\n+bXglqM14/iPgE0hg0PQJw2u0q9pRM9/kXquilVkOEdIzSPmW95L3Vdv9j+sKQ2A\nOL23l4dsaG4+i1lWRBKiGsLh1kB9FRnm4BzcOxd3WGooy7L1/jo9BoYRss1YABls\nmmyZ9f7r28zjclhpOBkE3OXX0zNbp4yIu1O1Bt9X2p87EOuYqlFA5eEvDbiTPZbk\n6wKEX3BPUkeIo8OaGvsGhHCWx0lv/sDPw/UofycOgQKBgQD4BD059aXEV13Byc5D\nh8LQSejjeM/Vx+YeCFI66biaIOvUs+unyxkH+qxXTuW6AgOgcvrJo93xkyAZ9SeR\nc6Vj9g5mZ5vqSJz5Hg8h8iZBAYtf40qWq0pHcmUIm2Z9LvrG5ZFHU5EEcCtLyBVS\nAv+pLLLf3OsAkJuuqTAgygBbOwKBgQC/KcBa9sUg2u9qIpq020UOW/n4KFWhSJ8h\ngXqqmjOnPqmDc5AnYg1ZdYdqSSgdiK8lJpRL/S2UjYUQp3H+56z0eK/b1iKM51n+\n6D80nIxWeKJ+n7VKI7cBXwc/KokaXgkz0It2UEZSlhPUMImnYcOvGIZ7cMr3Q6mf\n6FwD15UQNQKBgQDyAsDz454DvvS/+noJL1qMAPL9tI+pncwQljIXRqVZ0LIO9hoH\nu4kLXjH5aAWGwhxj3o6VYA9cgSIb8jrQFbbXmexnRMbBkGWMOSavCykE2cr0oEfS\nSgbLPPcVtP4HPWZ72tsubH7fg8zbv7v+MOrkW7eX9mxiOrmPb4yFElfSrQKBgA7y\nMLvr91WuSHG/6uChFDEfN9gTLz7A8tAn03NrQwace5xveKHbpLeN3NyOg7hra2Y4\nMfgO/3VR60l2Dg+kBX3HwdgqUeE6ZWrstaRjaQWJwQqtafs196T/zQ0/QiDxoT6P\n25eQhy8F1N8OPHT9y9Lw0/LqyrOycpyyCh+yx1DRAoGAJ/6dlhyQnwSfMAe3mfRC\noiBQG6FkyoeXHHYcoQ/0cSzwp0BwBlar1Z28P7KTGcUNqV+YfK9nF47eoLaTLCmG\nG5du0Ds6m2Eg0sOBBqXHnw6R1PC878tgT/XokNxIsVlF5qRz88q7Rn0J1lzB7+Tl\n2HSAcyIUcmr0gxlhRmC2Jq4=\n-----END PRIVATE KEY-----\n", + "client_email": "test_account@test.iam.gserviceaccount.com", + "client_id": "***id***", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test_account%40test.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" + }"#; + + let cred_source: FlexibleCredentialSource = + serde_json::from_str(test_creds).expect("Valid creds to parse"); + + assert!(matches!( + cred_source, + FlexibleCredentialSource::ServiceAccount(_) + )); + + let client = types::client(); + let creds = cred_source + .try_into_service_account(&client) + .await + .expect("Valid creds to parse"); + + assert_eq!( + creds + .project_id(&client) + .await + .expect("Project ID to be present"), + "test_project".to_string(), + "Project ID should be parsed" + ); + } + + #[tokio::test] + async fn test_additional_service_account_keys() { + // Using test cases from https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/google_test.go#L40 + // We have to use a real private key because we validate private keys on parsing as well. + let k1 = r#"{ + "private_key_id": "268f54e43a1af97cfc71731688434f45aca15c8b", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC5M5y3WwsRk8NX\npF9fKaZukNspot9Ecmk1PAkupcHLKVhalwPxU4sMNWXgM9H2LTWSvvyOT//rDQpn\n3SGYri/lMhzb4lI8h10E7k6zyFQUPujxkXFBkMOzhIDUgtiiht0WvIw6M8nbaPqI\nxn/aYmPsFhvJfKCthYAt2UUz+D3enI9QjCuhic8iSMnvKT8m0QkOG2eALYGUaLF1\ngRkbV4BiBUGZfXfNEBdux3Wf4kNUau32LA0XotomlvNvf1oH77v5Hc1R/KMMIk5F\nJWVBuAr4jwkN9hwtOozpJ/52wSpddxsZuj+0nP1a3f0UyvrmMnuwszardPK39BoH\nJ+5+HZM3AgMBAAECggEADrHZrXK73hkrVrjkGFjlq8Ayo4sYzAWH84Ff+SONzODq\n8cUpuuw2DDHwc2mpLy9HIO2mfGQ8mhneyX7yO3sWscjYIVpDzCmxZ8LA2+L5SOH0\n+bXglqM14/iPgE0hg0PQJw2u0q9pRM9/kXquilVkOEdIzSPmW95L3Vdv9j+sKQ2A\nOL23l4dsaG4+i1lWRBKiGsLh1kB9FRnm4BzcOxd3WGooy7L1/jo9BoYRss1YABls\nmmyZ9f7r28zjclhpOBkE3OXX0zNbp4yIu1O1Bt9X2p87EOuYqlFA5eEvDbiTPZbk\n6wKEX3BPUkeIo8OaGvsGhHCWx0lv/sDPw/UofycOgQKBgQD4BD059aXEV13Byc5D\nh8LQSejjeM/Vx+YeCFI66biaIOvUs+unyxkH+qxXTuW6AgOgcvrJo93xkyAZ9SeR\nc6Vj9g5mZ5vqSJz5Hg8h8iZBAYtf40qWq0pHcmUIm2Z9LvrG5ZFHU5EEcCtLyBVS\nAv+pLLLf3OsAkJuuqTAgygBbOwKBgQC/KcBa9sUg2u9qIpq020UOW/n4KFWhSJ8h\ngXqqmjOnPqmDc5AnYg1ZdYdqSSgdiK8lJpRL/S2UjYUQp3H+56z0eK/b1iKM51n+\n6D80nIxWeKJ+n7VKI7cBXwc/KokaXgkz0It2UEZSlhPUMImnYcOvGIZ7cMr3Q6mf\n6FwD15UQNQKBgQDyAsDz454DvvS/+noJL1qMAPL9tI+pncwQljIXRqVZ0LIO9hoH\nu4kLXjH5aAWGwhxj3o6VYA9cgSIb8jrQFbbXmexnRMbBkGWMOSavCykE2cr0oEfS\nSgbLPPcVtP4HPWZ72tsubH7fg8zbv7v+MOrkW7eX9mxiOrmPb4yFElfSrQKBgA7y\nMLvr91WuSHG/6uChFDEfN9gTLz7A8tAn03NrQwace5xveKHbpLeN3NyOg7hra2Y4\nMfgO/3VR60l2Dg+kBX3HwdgqUeE6ZWrstaRjaQWJwQqtafs196T/zQ0/QiDxoT6P\n25eQhy8F1N8OPHT9y9Lw0/LqyrOycpyyCh+yx1DRAoGAJ/6dlhyQnwSfMAe3mfRC\noiBQG6FkyoeXHHYcoQ/0cSzwp0BwBlar1Z28P7KTGcUNqV+YfK9nF47eoLaTLCmG\nG5du0Ds6m2Eg0sOBBqXHnw6R1PC878tgT/XokNxIsVlF5qRz88q7Rn0J1lzB7+Tl\n2HSAcyIUcmr0gxlhRmC2Jq4=\n-----END PRIVATE KEY-----\n", + "client_email": "gopher@developer.gserviceaccount.com", + "client_id": "gopher.apps.googleusercontent.com", + "token_uri": "https://accounts.google.com/o/gophers/token", + "type": "service_account", + "audience": "https://testservice.googleapis.com/" + }"#; + + let k3 = r#"{ + "private_key_id": "268f54e43a1af97cfc71731688434f45aca15c8b", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC5M5y3WwsRk8NX\npF9fKaZukNspot9Ecmk1PAkupcHLKVhalwPxU4sMNWXgM9H2LTWSvvyOT//rDQpn\n3SGYri/lMhzb4lI8h10E7k6zyFQUPujxkXFBkMOzhIDUgtiiht0WvIw6M8nbaPqI\nxn/aYmPsFhvJfKCthYAt2UUz+D3enI9QjCuhic8iSMnvKT8m0QkOG2eALYGUaLF1\ngRkbV4BiBUGZfXfNEBdux3Wf4kNUau32LA0XotomlvNvf1oH77v5Hc1R/KMMIk5F\nJWVBuAr4jwkN9hwtOozpJ/52wSpddxsZuj+0nP1a3f0UyvrmMnuwszardPK39BoH\nJ+5+HZM3AgMBAAECggEADrHZrXK73hkrVrjkGFjlq8Ayo4sYzAWH84Ff+SONzODq\n8cUpuuw2DDHwc2mpLy9HIO2mfGQ8mhneyX7yO3sWscjYIVpDzCmxZ8LA2+L5SOH0\n+bXglqM14/iPgE0hg0PQJw2u0q9pRM9/kXquilVkOEdIzSPmW95L3Vdv9j+sKQ2A\nOL23l4dsaG4+i1lWRBKiGsLh1kB9FRnm4BzcOxd3WGooy7L1/jo9BoYRss1YABls\nmmyZ9f7r28zjclhpOBkE3OXX0zNbp4yIu1O1Bt9X2p87EOuYqlFA5eEvDbiTPZbk\n6wKEX3BPUkeIo8OaGvsGhHCWx0lv/sDPw/UofycOgQKBgQD4BD059aXEV13Byc5D\nh8LQSejjeM/Vx+YeCFI66biaIOvUs+unyxkH+qxXTuW6AgOgcvrJo93xkyAZ9SeR\nc6Vj9g5mZ5vqSJz5Hg8h8iZBAYtf40qWq0pHcmUIm2Z9LvrG5ZFHU5EEcCtLyBVS\nAv+pLLLf3OsAkJuuqTAgygBbOwKBgQC/KcBa9sUg2u9qIpq020UOW/n4KFWhSJ8h\ngXqqmjOnPqmDc5AnYg1ZdYdqSSgdiK8lJpRL/S2UjYUQp3H+56z0eK/b1iKM51n+\n6D80nIxWeKJ+n7VKI7cBXwc/KokaXgkz0It2UEZSlhPUMImnYcOvGIZ7cMr3Q6mf\n6FwD15UQNQKBgQDyAsDz454DvvS/+noJL1qMAPL9tI+pncwQljIXRqVZ0LIO9hoH\nu4kLXjH5aAWGwhxj3o6VYA9cgSIb8jrQFbbXmexnRMbBkGWMOSavCykE2cr0oEfS\nSgbLPPcVtP4HPWZ72tsubH7fg8zbv7v+MOrkW7eX9mxiOrmPb4yFElfSrQKBgA7y\nMLvr91WuSHG/6uChFDEfN9gTLz7A8tAn03NrQwace5xveKHbpLeN3NyOg7hra2Y4\nMfgO/3VR60l2Dg+kBX3HwdgqUeE6ZWrstaRjaQWJwQqtafs196T/zQ0/QiDxoT6P\n25eQhy8F1N8OPHT9y9Lw0/LqyrOycpyyCh+yx1DRAoGAJ/6dlhyQnwSfMAe3mfRC\noiBQG6FkyoeXHHYcoQ/0cSzwp0BwBlar1Z28P7KTGcUNqV+YfK9nF47eoLaTLCmG\nG5du0Ds6m2Eg0sOBBqXHnw6R1PC878tgT/XokNxIsVlF5qRz88q7Rn0J1lzB7+Tl\n2HSAcyIUcmr0gxlhRmC2Jq4=\n-----END PRIVATE KEY-----\n", + "client_email": "gopher@developer.gserviceaccount.com", + "client_id": "gopher.apps.googleusercontent.com", + "token_uri": "https://accounts.google.com/o/gophers/token", + "type": "service_account" + }"#; + + let client = types::client(); + for key in [k1, k3] { + let cred_source: FlexibleCredentialSource = + serde_json::from_str(key).expect("Valid creds to parse"); + + assert!(matches!( + cred_source, + FlexibleCredentialSource::ServiceAccount(_) + )); + + let creds = cred_source + .try_into_service_account(&client) + .await + .expect("Valid creds to parse"); + + assert!( + matches!( + creds + .project_id(&client) + .await + .expect_err("Project ID to not be present"), + crate::Error::ProjectIdNotFound, + ), + "Project id should not be found here", + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 08c29c5..9aa5013 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -94,6 +94,7 @@ mod custom_service_account; mod default_authorized_user; mod default_service_account; mod error; +mod flexible_credential_source; mod gcloud_authorized_user; mod jwt; mod types; From baa65d3791e774e673c5a16c05a3297e797320ab Mon Sep 17 00:00:00 2001 From: Scott Driggers Date: Fri, 18 Aug 2023 11:03:45 -0400 Subject: [PATCH 2/7] Adding service account impersonation (waiting on #76) This PR adds a new `ServiceAccount` format that takes credentials from `source_credentials: ServiceAccount` and then makes a request to get a service account token using those credentials. This also adds the ability to parse the token format created by `gcloud auth application-default login --impersonate-service-account ` --- Cargo.toml | 2 +- src/error.rs | 4 + src/flexible_credential_source.rs | 106 +++++++++++++++- src/lib.rs | 1 + src/service_account_impersonation.rs | 173 +++++++++++++++++++++++++++ src/types.rs | 9 ++ 6 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 src/service_account_impersonation.rs diff --git a/Cargo.toml b/Cargo.toml index a0e985d..57bf7de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ rustls-pemfile = "1.0.0" serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" thiserror = "1.0" -time = { version = "0.3.5", features = ["serde"] } +time = { version = "0.3.5", features = ["serde", "parsing"] } tokio = { version = "1.1", features = ["fs", "sync"] } tracing = "0.1.29" tracing-futures = "0.2.5" diff --git a/src/error.rs b/src/error.rs index 64d7b0f..32bc862 100644 --- a/src/error.rs +++ b/src/error.rs @@ -96,6 +96,10 @@ pub enum Error { #[error("Failed to parse output of GCloud")] GCloudParseError, + /// Currently, nested service account impersonation is not supported + #[error("Nested impersonation is not supported")] + NestedImpersonation, + /// Represents all other cases of `std::io::Error`. #[error(transparent)] IOError(#[from] std::io::Error), diff --git a/src/flexible_credential_source.rs b/src/flexible_credential_source.rs index 33b6b6c..768d99a 100644 --- a/src/flexible_credential_source.rs +++ b/src/flexible_credential_source.rs @@ -1,12 +1,13 @@ use std::path::{Path, PathBuf}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use tokio::fs; use crate::{ authentication_manager::ServiceAccount, custom_service_account::ApplicationCredentials, default_authorized_user::{ConfigDefaultCredentials, UserCredentials}, + service_account_impersonation::ImpersonatedServiceAccount, types::HyperClient, CustomServiceAccount, Error, }; @@ -15,7 +16,7 @@ use crate::{ // https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/google.go#L158 // Currently not implementing external account credentials // Currently not implementing impersonating service accounts (coming soon !) -#[derive(Serialize, Deserialize, Debug)] +#[derive(Deserialize, Debug)] #[serde(tag = "type", rename_all = "snake_case")] pub(crate) enum FlexibleCredentialSource { // This credential parses the `key.json` file created when running @@ -24,6 +25,9 @@ pub(crate) enum FlexibleCredentialSource { // This credential parses the `~/.config/gcloud/application_default_credentials.json` file // created when running `gcloud auth application-default login` AuthorizedUser(UserCredentials), + // This credential parses the `~/.config/gcloud/application_default_credentials.json` file + // created when running `gcloud auth application-default login --impersonate-service-account ` + ImpersonatedServiceAccount(ImpersonatedServiceAccountCredentials), } impl FlexibleCredentialSource { @@ -62,6 +66,30 @@ impl FlexibleCredentialSource { ConfigDefaultCredentials::from_user_credentials(creds, client).await?; Ok(Box::new(service_account)) } + FlexibleCredentialSource::ImpersonatedServiceAccount(creds) => { + let source_creds: Box = match *creds.source_credentials { + FlexibleCredentialSource::AuthorizedUser(creds) => { + let service_account = + ConfigDefaultCredentials::from_user_credentials(creds, client).await?; + Box::new(service_account) + } + FlexibleCredentialSource::ServiceAccount(creds) => { + let service_account = CustomServiceAccount::new(creds)?; + Box::new(service_account) + } + FlexibleCredentialSource::ImpersonatedServiceAccount(_) => { + return Err(Error::NestedImpersonation) + } + }; + + let service_account = ImpersonatedServiceAccount::new( + source_creds, + creds.service_account_impersonation_url, + creds.delegates, + ); + + Ok(Box::new(service_account)) + } } } @@ -76,6 +104,17 @@ impl FlexibleCredentialSource { } } +// This credential uses the `source_credentials` to get a token +// and then uses that token to get a token impersonating the service +// account specified by `service_account_impersonation_url`. +// refresh logic https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/internal/externalaccount/impersonate.go#L57 +#[derive(Deserialize, Debug)] +pub(crate) struct ImpersonatedServiceAccountCredentials { + service_account_impersonation_url: String, + source_credentials: Box, + delegates: Vec, +} + #[cfg(test)] mod tests { use crate::{flexible_credential_source::FlexibleCredentialSource, types}; @@ -192,4 +231,67 @@ mod tests { ); } } + + #[tokio::test] + async fn test_parse_impersonating_service_account() { + let impersonate_from_user_creds = r#"{ + "delegates": [], + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test_account@test_project.iam.gserviceaccount.com:generateAccessToken", + "source_credentials": { + "client_id": "***id***.apps.googleusercontent.com", + "client_secret": "***secret***", + "refresh_token": "***refresh***", + "type": "authorized_user", + "quota_project_id": "test_project" + }, + "type": "impersonated_service_account" + }"#; + + let cred_source: FlexibleCredentialSource = + serde_json::from_str(impersonate_from_user_creds).expect("Valid creds to parse"); + + assert!(matches!( + cred_source, + FlexibleCredentialSource::ImpersonatedServiceAccount(_) + )); + + let impersonate_from_service_key = r#"{ + "delegates": [], + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test_account@test_project.iam.gserviceaccount.com:generateAccessToken", + "source_credentials": { + "private_key_id": "268f54e43a1af97cfc71731688434f45aca15c8b", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC5M5y3WwsRk8NX\npF9fKaZukNspot9Ecmk1PAkupcHLKVhalwPxU4sMNWXgM9H2LTWSvvyOT//rDQpn\n3SGYri/lMhzb4lI8h10E7k6zyFQUPujxkXFBkMOzhIDUgtiiht0WvIw6M8nbaPqI\nxn/aYmPsFhvJfKCthYAt2UUz+D3enI9QjCuhic8iSMnvKT8m0QkOG2eALYGUaLF1\ngRkbV4BiBUGZfXfNEBdux3Wf4kNUau32LA0XotomlvNvf1oH77v5Hc1R/KMMIk5F\nJWVBuAr4jwkN9hwtOozpJ/52wSpddxsZuj+0nP1a3f0UyvrmMnuwszardPK39BoH\nJ+5+HZM3AgMBAAECggEADrHZrXK73hkrVrjkGFjlq8Ayo4sYzAWH84Ff+SONzODq\n8cUpuuw2DDHwc2mpLy9HIO2mfGQ8mhneyX7yO3sWscjYIVpDzCmxZ8LA2+L5SOH0\n+bXglqM14/iPgE0hg0PQJw2u0q9pRM9/kXquilVkOEdIzSPmW95L3Vdv9j+sKQ2A\nOL23l4dsaG4+i1lWRBKiGsLh1kB9FRnm4BzcOxd3WGooy7L1/jo9BoYRss1YABls\nmmyZ9f7r28zjclhpOBkE3OXX0zNbp4yIu1O1Bt9X2p87EOuYqlFA5eEvDbiTPZbk\n6wKEX3BPUkeIo8OaGvsGhHCWx0lv/sDPw/UofycOgQKBgQD4BD059aXEV13Byc5D\nh8LQSejjeM/Vx+YeCFI66biaIOvUs+unyxkH+qxXTuW6AgOgcvrJo93xkyAZ9SeR\nc6Vj9g5mZ5vqSJz5Hg8h8iZBAYtf40qWq0pHcmUIm2Z9LvrG5ZFHU5EEcCtLyBVS\nAv+pLLLf3OsAkJuuqTAgygBbOwKBgQC/KcBa9sUg2u9qIpq020UOW/n4KFWhSJ8h\ngXqqmjOnPqmDc5AnYg1ZdYdqSSgdiK8lJpRL/S2UjYUQp3H+56z0eK/b1iKM51n+\n6D80nIxWeKJ+n7VKI7cBXwc/KokaXgkz0It2UEZSlhPUMImnYcOvGIZ7cMr3Q6mf\n6FwD15UQNQKBgQDyAsDz454DvvS/+noJL1qMAPL9tI+pncwQljIXRqVZ0LIO9hoH\nu4kLXjH5aAWGwhxj3o6VYA9cgSIb8jrQFbbXmexnRMbBkGWMOSavCykE2cr0oEfS\nSgbLPPcVtP4HPWZ72tsubH7fg8zbv7v+MOrkW7eX9mxiOrmPb4yFElfSrQKBgA7y\nMLvr91WuSHG/6uChFDEfN9gTLz7A8tAn03NrQwace5xveKHbpLeN3NyOg7hra2Y4\nMfgO/3VR60l2Dg+kBX3HwdgqUeE6ZWrstaRjaQWJwQqtafs196T/zQ0/QiDxoT6P\n25eQhy8F1N8OPHT9y9Lw0/LqyrOycpyyCh+yx1DRAoGAJ/6dlhyQnwSfMAe3mfRC\noiBQG6FkyoeXHHYcoQ/0cSzwp0BwBlar1Z28P7KTGcUNqV+YfK9nF47eoLaTLCmG\nG5du0Ds6m2Eg0sOBBqXHnw6R1PC878tgT/XokNxIsVlF5qRz88q7Rn0J1lzB7+Tl\n2HSAcyIUcmr0gxlhRmC2Jq4=\n-----END PRIVATE KEY-----\n", + "client_email": "gopher@developer.gserviceaccount.com", + "client_id": "gopher.apps.googleusercontent.com", + "token_uri": "https://accounts.google.com/o/gophers/token", + "type": "service_account", + "audience": "https://testservice.googleapis.com/", + "project_id": "test_project" + }, + "type": "impersonated_service_account" + }"#; + + let cred_source: FlexibleCredentialSource = + serde_json::from_str(impersonate_from_service_key).expect("Valid creds to parse"); + + assert!(matches!( + cred_source, + FlexibleCredentialSource::ImpersonatedServiceAccount(_) + )); + + let client = types::client(); + let creds = cred_source + .try_into_service_account(&client) + .await + .expect("Valid creds to parse"); + + assert_eq!( + creds + .project_id(&client) + .await + .expect("Project ID to be present"), + "test_project".to_string(), + "Project ID should be parsed" + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 9aa5013..4a6f57f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -97,6 +97,7 @@ mod error; mod flexible_credential_source; mod gcloud_authorized_user; mod jwt; +mod service_account_impersonation; mod types; mod util; diff --git a/src/service_account_impersonation.rs b/src/service_account_impersonation.rs new file mode 100644 index 0000000..6259ee3 --- /dev/null +++ b/src/service_account_impersonation.rs @@ -0,0 +1,173 @@ +use async_trait::async_trait; +use std::{collections::HashMap, sync::RwLock}; +use time::{format_description::well_known::Iso8601, OffsetDateTime}; + +use hyper::header; +use serde::{de, Deserialize, Deserializer, Serialize}; + +use crate::{ + authentication_manager::ServiceAccount, gcloud_authorized_user::DEFAULT_TOKEN_DURATION, + types::HyperClient, util::HyperExt, Error, Token, +}; + +// This credential uses the `source_credentials` to get a token +// and then uses that token to get a token impersonating the service +// account specified by `service_account_impersonation_url`. +// refresh logic https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/internal/externalaccount/impersonate.go#L57 +// +// In practice, the api currently referred to by `service_account_impersonation_url` is +// https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken +pub(crate) struct ImpersonatedServiceAccount { + service_account_impersonation_url: String, + source_credentials: Box, + delegates: Vec, + tokens: RwLock, Token>>, +} + +impl ImpersonatedServiceAccount { + pub(crate) fn new( + source_credentials: Box, + service_account_impersonation_url: String, + delegates: Vec, + ) -> Self { + Self { + service_account_impersonation_url, + source_credentials, + delegates, + tokens: RwLock::new(HashMap::new()), + } + } +} + +impl std::fmt::Debug for ImpersonatedServiceAccount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImpersonatedServiceAccount") + .field( + "service_account_impersonation_url", + &self.service_account_impersonation_url, + ) + .field("source_credentials", &"Box") + .field("delegates", &self.delegates) + .finish() + } +} + +// This is the impersonation token described by this documentation +// https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImpersonationTokenResponse { + access_token: String, + #[serde(deserialize_with = "deserialize_rfc3339")] + expire_time: OffsetDateTime, +} + +fn deserialize_rfc3339<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + // First try to deserialize seconds + let time_str: String = Deserialize::deserialize(deserializer)?; + + OffsetDateTime::parse(&time_str, &Iso8601::PARSING).map_err(de::Error::custom) +} + +impl From for Token { + fn from(value: ImpersonationTokenResponse) -> Self { + Token::new(value.access_token, value.expire_time) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_impersonation_token() { + let resp_body = "{\n \"accessToken\": \"secret_token\",\n \"expireTime\": \"2023-08-18T04:09:45Z\"\n}"; + let token: ImpersonationTokenResponse = + serde_json::from_str(resp_body).expect("Failed to parse token"); + assert_eq!(token.access_token, "secret_token"); + } +} + +#[async_trait] +impl ServiceAccount for ImpersonatedServiceAccount { + async fn project_id(&self, hc: &HyperClient) -> Result { + self.source_credentials.project_id(hc).await + } + + fn get_token(&self, scopes: &[&str]) -> Option { + let key: Vec<_> = scopes.iter().map(|x| x.to_string()).collect(); + self.tokens.read().unwrap().get(&key).cloned() + } + + async fn refresh_token(&self, client: &HyperClient, scopes: &[&str]) -> Result { + let source_token = self + .source_credentials + .refresh_token(client, scopes) + .await?; + + // Then we do a request to get the impersonated token + let lifetime_seconds = DEFAULT_TOKEN_DURATION.whole_seconds(); + #[derive(Serialize, Clone)] + // Format from https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/internal/externalaccount/impersonate.go#L21 + struct AccessTokenRequest { + lifetime: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + scope: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + delegates: Vec, + } + + let request = AccessTokenRequest { + lifetime: format!("{lifetime_seconds}s"), + scope: scopes.iter().map(|s| s.to_string()).collect(), + delegates: self.delegates.clone(), + }; + let rqbody = + serde_json::to_string(&request).expect("access token request failed to serialize"); + + let token_uri = self.service_account_impersonation_url.as_str(); + + let mut retries = 0; + let response = loop { + // We assume bearer tokens only. In the referenced code, other token types are possible + // https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/token.go#L84 + let request = hyper::Request::post(token_uri) + .header( + header::AUTHORIZATION, + format!("Bearer {}", source_token.as_str()), + ) + .header(header::CONTENT_TYPE, "application/json") + .body(hyper::Body::from(rqbody.clone())) + .unwrap(); + + tracing::debug!("requesting impersonation token from service account: {request:?}"); + let err = match client.request(request).await { + // Early return when the request succeeds + Ok(response) => break response, + Err(err) => err, + }; + + tracing::warn!( + "Failed to refresh impersonation token with service token endpoint {token_uri}: {err}, trying again..." + ); + retries += 1; + if retries >= RETRY_COUNT { + return Err(Error::OAuthConnectionError(err)); + } + }; + + let token_response: ImpersonationTokenResponse = response.deserialize().await?; + let token: Token = token_response.into(); + + let key = scopes.iter().map(|x| (*x).to_string()).collect(); + self.tokens.write().unwrap().insert(key, token.clone()); + + Ok(token) + } +} + +/// How many times to attempt to fetch a token from the set credentials token endpoint. +const RETRY_COUNT: u8 = 5; diff --git a/src/types.rs b/src/types.rs index db0870f..f43c39c 100644 --- a/src/types.rs +++ b/src/types.rs @@ -31,6 +31,15 @@ pub struct Token { } impl Token { + pub(crate) fn new(access_token: String, expires_at: OffsetDateTime) -> Self { + Token { + inner: Arc::new(InnerToken { + access_token, + expires_at, + }), + } + } + pub(crate) fn from_string(access_token: String, expires_in: Duration) -> Self { Token { inner: Arc::new(InnerToken { From 8063616840bd310b4bb516076235082b3e724216 Mon Sep 17 00:00:00 2001 From: Scott Driggers Date: Fri, 18 Aug 2023 12:06:41 -0400 Subject: [PATCH 3/7] No longer require token_uri in CustomServiceAccount This parameter should be optional and fall back to the default of "https://oauth2.googleapis.com/token". This is demonstrated here: https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/google.go#L152 --- src/custom_service_account.rs | 13 +++++++++++-- src/flexible_credential_source.rs | 10 +++++++++- src/jwt.rs | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/custom_service_account.rs b/src/custom_service_account.rs index c8c00ef..96a64fd 100644 --- a/src/custom_service_account.rs +++ b/src/custom_service_account.rs @@ -11,6 +11,9 @@ use crate::error::Error; use crate::types::{HyperClient, Signer, Token}; use crate::util::HyperExt; +// Comes from https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/google.go#L25 +const DEFAULT_TOKEN_URI: &'static str = "https://oauth2.googleapis.com/token"; + /// A custom service account containing credentials /// /// Once initialized, a [`CustomServiceAccount`] can be converted into an [`AuthenticationManager`] @@ -106,7 +109,7 @@ impl ServiceAccount for CustomServiceAccount { let mut retries = 0; let response = loop { - let request = hyper::Request::post(&self.credentials.token_uri) + let request = hyper::Request::post(self.credentials.token_uri()) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(hyper::Body::from(rqbody.clone())) .unwrap(); @@ -150,13 +153,19 @@ pub(crate) struct ApplicationCredentials { /// auth_uri pub(crate) auth_uri: Option, /// token_uri - pub(crate) token_uri: String, + pub(crate) token_uri: Option, /// auth_provider_x509_cert_url pub(crate) auth_provider_x509_cert_url: Option, /// client_x509_cert_url pub(crate) client_x509_cert_url: Option, } +impl ApplicationCredentials { + pub(crate) fn token_uri(&self) -> &str { + self.token_uri.as_deref().unwrap_or(DEFAULT_TOKEN_URI) + } +} + impl fmt::Debug for ApplicationCredentials { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("ApplicationCredentials") diff --git a/src/flexible_credential_source.rs b/src/flexible_credential_source.rs index 768d99a..eaca317 100644 --- a/src/flexible_credential_source.rs +++ b/src/flexible_credential_source.rs @@ -195,6 +195,14 @@ mod tests { "audience": "https://testservice.googleapis.com/" }"#; + let k2 = r#"{ + "private_key_id": "268f54e43a1af97cfc71731688434f45aca15c8b", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC5M5y3WwsRk8NX\npF9fKaZukNspot9Ecmk1PAkupcHLKVhalwPxU4sMNWXgM9H2LTWSvvyOT//rDQpn\n3SGYri/lMhzb4lI8h10E7k6zyFQUPujxkXFBkMOzhIDUgtiiht0WvIw6M8nbaPqI\nxn/aYmPsFhvJfKCthYAt2UUz+D3enI9QjCuhic8iSMnvKT8m0QkOG2eALYGUaLF1\ngRkbV4BiBUGZfXfNEBdux3Wf4kNUau32LA0XotomlvNvf1oH77v5Hc1R/KMMIk5F\nJWVBuAr4jwkN9hwtOozpJ/52wSpddxsZuj+0nP1a3f0UyvrmMnuwszardPK39BoH\nJ+5+HZM3AgMBAAECggEADrHZrXK73hkrVrjkGFjlq8Ayo4sYzAWH84Ff+SONzODq\n8cUpuuw2DDHwc2mpLy9HIO2mfGQ8mhneyX7yO3sWscjYIVpDzCmxZ8LA2+L5SOH0\n+bXglqM14/iPgE0hg0PQJw2u0q9pRM9/kXquilVkOEdIzSPmW95L3Vdv9j+sKQ2A\nOL23l4dsaG4+i1lWRBKiGsLh1kB9FRnm4BzcOxd3WGooy7L1/jo9BoYRss1YABls\nmmyZ9f7r28zjclhpOBkE3OXX0zNbp4yIu1O1Bt9X2p87EOuYqlFA5eEvDbiTPZbk\n6wKEX3BPUkeIo8OaGvsGhHCWx0lv/sDPw/UofycOgQKBgQD4BD059aXEV13Byc5D\nh8LQSejjeM/Vx+YeCFI66biaIOvUs+unyxkH+qxXTuW6AgOgcvrJo93xkyAZ9SeR\nc6Vj9g5mZ5vqSJz5Hg8h8iZBAYtf40qWq0pHcmUIm2Z9LvrG5ZFHU5EEcCtLyBVS\nAv+pLLLf3OsAkJuuqTAgygBbOwKBgQC/KcBa9sUg2u9qIpq020UOW/n4KFWhSJ8h\ngXqqmjOnPqmDc5AnYg1ZdYdqSSgdiK8lJpRL/S2UjYUQp3H+56z0eK/b1iKM51n+\n6D80nIxWeKJ+n7VKI7cBXwc/KokaXgkz0It2UEZSlhPUMImnYcOvGIZ7cMr3Q6mf\n6FwD15UQNQKBgQDyAsDz454DvvS/+noJL1qMAPL9tI+pncwQljIXRqVZ0LIO9hoH\nu4kLXjH5aAWGwhxj3o6VYA9cgSIb8jrQFbbXmexnRMbBkGWMOSavCykE2cr0oEfS\nSgbLPPcVtP4HPWZ72tsubH7fg8zbv7v+MOrkW7eX9mxiOrmPb4yFElfSrQKBgA7y\nMLvr91WuSHG/6uChFDEfN9gTLz7A8tAn03NrQwace5xveKHbpLeN3NyOg7hra2Y4\nMfgO/3VR60l2Dg+kBX3HwdgqUeE6ZWrstaRjaQWJwQqtafs196T/zQ0/QiDxoT6P\n25eQhy8F1N8OPHT9y9Lw0/LqyrOycpyyCh+yx1DRAoGAJ/6dlhyQnwSfMAe3mfRC\noiBQG6FkyoeXHHYcoQ/0cSzwp0BwBlar1Z28P7KTGcUNqV+YfK9nF47eoLaTLCmG\nG5du0Ds6m2Eg0sOBBqXHnw6R1PC878tgT/XokNxIsVlF5qRz88q7Rn0J1lzB7+Tl\n2HSAcyIUcmr0gxlhRmC2Jq4=\n-----END PRIVATE KEY-----\n", + "client_email": "gopher@developer.gserviceaccount.com", + "client_id": "gopher.apps.googleusercontent.com", + "type": "service_account" + }"#; + let k3 = r#"{ "private_key_id": "268f54e43a1af97cfc71731688434f45aca15c8b", "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC5M5y3WwsRk8NX\npF9fKaZukNspot9Ecmk1PAkupcHLKVhalwPxU4sMNWXgM9H2LTWSvvyOT//rDQpn\n3SGYri/lMhzb4lI8h10E7k6zyFQUPujxkXFBkMOzhIDUgtiiht0WvIw6M8nbaPqI\nxn/aYmPsFhvJfKCthYAt2UUz+D3enI9QjCuhic8iSMnvKT8m0QkOG2eALYGUaLF1\ngRkbV4BiBUGZfXfNEBdux3Wf4kNUau32LA0XotomlvNvf1oH77v5Hc1R/KMMIk5F\nJWVBuAr4jwkN9hwtOozpJ/52wSpddxsZuj+0nP1a3f0UyvrmMnuwszardPK39BoH\nJ+5+HZM3AgMBAAECggEADrHZrXK73hkrVrjkGFjlq8Ayo4sYzAWH84Ff+SONzODq\n8cUpuuw2DDHwc2mpLy9HIO2mfGQ8mhneyX7yO3sWscjYIVpDzCmxZ8LA2+L5SOH0\n+bXglqM14/iPgE0hg0PQJw2u0q9pRM9/kXquilVkOEdIzSPmW95L3Vdv9j+sKQ2A\nOL23l4dsaG4+i1lWRBKiGsLh1kB9FRnm4BzcOxd3WGooy7L1/jo9BoYRss1YABls\nmmyZ9f7r28zjclhpOBkE3OXX0zNbp4yIu1O1Bt9X2p87EOuYqlFA5eEvDbiTPZbk\n6wKEX3BPUkeIo8OaGvsGhHCWx0lv/sDPw/UofycOgQKBgQD4BD059aXEV13Byc5D\nh8LQSejjeM/Vx+YeCFI66biaIOvUs+unyxkH+qxXTuW6AgOgcvrJo93xkyAZ9SeR\nc6Vj9g5mZ5vqSJz5Hg8h8iZBAYtf40qWq0pHcmUIm2Z9LvrG5ZFHU5EEcCtLyBVS\nAv+pLLLf3OsAkJuuqTAgygBbOwKBgQC/KcBa9sUg2u9qIpq020UOW/n4KFWhSJ8h\ngXqqmjOnPqmDc5AnYg1ZdYdqSSgdiK8lJpRL/S2UjYUQp3H+56z0eK/b1iKM51n+\n6D80nIxWeKJ+n7VKI7cBXwc/KokaXgkz0It2UEZSlhPUMImnYcOvGIZ7cMr3Q6mf\n6FwD15UQNQKBgQDyAsDz454DvvS/+noJL1qMAPL9tI+pncwQljIXRqVZ0LIO9hoH\nu4kLXjH5aAWGwhxj3o6VYA9cgSIb8jrQFbbXmexnRMbBkGWMOSavCykE2cr0oEfS\nSgbLPPcVtP4HPWZ72tsubH7fg8zbv7v+MOrkW7eX9mxiOrmPb4yFElfSrQKBgA7y\nMLvr91WuSHG/6uChFDEfN9gTLz7A8tAn03NrQwace5xveKHbpLeN3NyOg7hra2Y4\nMfgO/3VR60l2Dg+kBX3HwdgqUeE6ZWrstaRjaQWJwQqtafs196T/zQ0/QiDxoT6P\n25eQhy8F1N8OPHT9y9Lw0/LqyrOycpyyCh+yx1DRAoGAJ/6dlhyQnwSfMAe3mfRC\noiBQG6FkyoeXHHYcoQ/0cSzwp0BwBlar1Z28P7KTGcUNqV+YfK9nF47eoLaTLCmG\nG5du0Ds6m2Eg0sOBBqXHnw6R1PC878tgT/XokNxIsVlF5qRz88q7Rn0J1lzB7+Tl\n2HSAcyIUcmr0gxlhRmC2Jq4=\n-----END PRIVATE KEY-----\n", @@ -205,7 +213,7 @@ mod tests { }"#; let client = types::client(); - for key in [k1, k3] { + for key in [k1, k2, k3] { let cred_source: FlexibleCredentialSource = serde_json::from_str(key).expect("Valid creds to parse"); diff --git a/src/jwt.rs b/src/jwt.rs index 5b1ecac..172775b 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -41,7 +41,7 @@ impl<'a> Claims<'a> { .join(" "); Claims { iss: &key.client_email, - aud: &key.token_uri, + aud: &key.token_uri(), exp: expiry, iat, subject, From 71542d1b9b4ca1bda1d1294a68a88b35474bf6a7 Mon Sep 17 00:00:00 2001 From: Scott Driggers Date: Fri, 18 Aug 2023 12:11:31 -0400 Subject: [PATCH 4/7] Allowing UserCredentials to support an optional token_uri parameter This comes from go source here: https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/google.go#L181C22-L181C22 --- src/default_authorized_user.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/default_authorized_user.rs b/src/default_authorized_user.rs index 8dcd6b6..8cfd069 100644 --- a/src/default_authorized_user.rs +++ b/src/default_authorized_user.rs @@ -29,10 +29,10 @@ impl ConfigDefaultCredentials { }) } - fn build_token_request(json: &T) -> Request { + fn build_token_request(url: &str, json: &T) -> Request { Request::builder() .method(Method::POST) - .uri(Self::DEFAULT_TOKEN_GCP_URI) + .uri(url) .header("content-type", "application/json") .body(Body::from(serde_json::to_string(json).unwrap())) .unwrap() @@ -42,12 +42,17 @@ impl ConfigDefaultCredentials { async fn get_token(cred: &UserCredentials, client: &HyperClient) -> Result { let mut retries = 0; let response = loop { - let req = Self::build_token_request(&RefreshRequest { - client_id: &cred.client_id, - client_secret: &cred.client_secret, - grant_type: "refresh_token", - refresh_token: &cred.refresh_token, - }); + let req = Self::build_token_request( + cred.token_uri + .as_deref() + .unwrap_or(Self::DEFAULT_TOKEN_GCP_URI), + &RefreshRequest { + client_id: &cred.client_id, + client_secret: &cred.client_secret, + grant_type: "refresh_token", + refresh_token: &cred.refresh_token, + }, + ); let err = match client.request(req).await { // Early return when the request succeeds @@ -106,6 +111,8 @@ pub(crate) struct UserCredentials { pub(crate) quota_project_id: Option, /// Refresh Token pub(crate) refresh_token: String, + /// Token URI (defaults to DEFAULT_TOKEN_GCP_URI if missing) + pub(crate) token_uri: Option, } /// How many times to attempt to fetch a token from the GCP token endpoint. From 8576b2af53770e6ba0a88e85aac02fc9e6ebf122 Mon Sep 17 00:00:00 2001 From: Scott Driggers Date: Fri, 18 Aug 2023 12:14:38 -0400 Subject: [PATCH 5/7] Supporting optional audience on ApplicationCredentials See https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/jwt/jwt.go#L123C8-L123C8 for reference --- src/custom_service_account.rs | 8 +++++++- src/jwt.rs | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/custom_service_account.rs b/src/custom_service_account.rs index 96a64fd..ac1b4d2 100644 --- a/src/custom_service_account.rs +++ b/src/custom_service_account.rs @@ -158,12 +158,18 @@ pub(crate) struct ApplicationCredentials { pub(crate) auth_provider_x509_cert_url: Option, /// client_x509_cert_url pub(crate) client_x509_cert_url: Option, + /// audience (defaults to token_uri if missing) + pub(crate) audience: Option, } impl ApplicationCredentials { - pub(crate) fn token_uri(&self) -> &str { + fn token_uri(&self) -> &str { self.token_uri.as_deref().unwrap_or(DEFAULT_TOKEN_URI) } + + pub(crate) fn audience(&self) -> &str { + self.audience.as_deref().unwrap_or_else(|| self.token_uri()) + } } impl fmt::Debug for ApplicationCredentials { diff --git a/src/jwt.rs b/src/jwt.rs index 172775b..d8d7fda 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -41,7 +41,7 @@ impl<'a> Claims<'a> { .join(" "); Claims { iss: &key.client_email, - aud: &key.token_uri(), + aud: &key.audience(), exp: expiry, iat, subject, From cd903929610f2aea5a3f7dafc31f46e73aec5630 Mon Sep 17 00:00:00 2001 From: Scott Driggers Date: Fri, 18 Aug 2023 12:24:17 -0400 Subject: [PATCH 6/7] Internal improvements - Sort scope vec before using it as a hashmap key. Vecs of scopes aren't dependent on order, so the hashmap key shouldn't be either - Reduce complexity by moving all calls to `token::from_string` to `token::new` --- src/custom_service_account.rs | 3 ++- src/gcloud_authorized_user.rs | 8 ++++---- src/service_account_impersonation.rs | 3 ++- src/types.rs | 9 --------- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/custom_service_account.rs b/src/custom_service_account.rs index ac1b4d2..cfe2967 100644 --- a/src/custom_service_account.rs +++ b/src/custom_service_account.rs @@ -91,7 +91,8 @@ impl ServiceAccount for CustomServiceAccount { } fn get_token(&self, scopes: &[&str]) -> Option { - let key: Vec<_> = scopes.iter().map(|x| x.to_string()).collect(); + let mut key: Vec<_> = scopes.iter().map(|x| x.to_string()).collect(); + key.sort(); self.tokens.read().unwrap().get(&key).cloned() } diff --git a/src/gcloud_authorized_user.rs b/src/gcloud_authorized_user.rs index 33473db..d5059dc 100644 --- a/src/gcloud_authorized_user.rs +++ b/src/gcloud_authorized_user.rs @@ -3,7 +3,7 @@ use std::process::Command; use std::sync::RwLock; use async_trait::async_trait; -use time::Duration; +use time::{Duration, OffsetDateTime}; use which::which; use crate::authentication_manager::ServiceAccount; @@ -37,9 +37,9 @@ impl GCloudAuthorizedUser { } fn token(gcloud: &Path) -> Result { - Ok(Token::from_string( + Ok(Token::new( run(gcloud, &["auth", "print-access-token", "--quiet"])?, - DEFAULT_TOKEN_DURATION, + OffsetDateTime::now_utc() + DEFAULT_TOKEN_DURATION, )) } } @@ -105,7 +105,7 @@ mod tests { #[test] fn test_token_from_string() { let s = String::from("abc123"); - let token = Token::from_string(s, DEFAULT_TOKEN_DURATION); + let token = Token::new(s, OffsetDateTime::now_utc() + DEFAULT_TOKEN_DURATION); let expires = OffsetDateTime::now_utc() + DEFAULT_TOKEN_DURATION; assert_eq!(token.as_str(), "abc123"); diff --git a/src/service_account_impersonation.rs b/src/service_account_impersonation.rs index 6259ee3..eb700f4 100644 --- a/src/service_account_impersonation.rs +++ b/src/service_account_impersonation.rs @@ -98,7 +98,8 @@ impl ServiceAccount for ImpersonatedServiceAccount { } fn get_token(&self, scopes: &[&str]) -> Option { - let key: Vec<_> = scopes.iter().map(|x| x.to_string()).collect(); + let mut key: Vec<_> = scopes.iter().map(|x| x.to_string()).collect(); + key.sort(); self.tokens.read().unwrap().get(&key).cloned() } diff --git a/src/types.rs b/src/types.rs index f43c39c..1ec6dc4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -39,15 +39,6 @@ impl Token { }), } } - - pub(crate) fn from_string(access_token: String, expires_in: Duration) -> Self { - Token { - inner: Arc::new(InnerToken { - access_token, - expires_at: OffsetDateTime::now_utc() + expires_in, - }), - } - } } impl fmt::Debug for Token { From 15a9d622880ae3aafec81717435bdad951802dc2 Mon Sep 17 00:00:00 2001 From: Scott Driggers Date: Fri, 18 Aug 2023 12:27:31 -0400 Subject: [PATCH 7/7] Fixing clippy warnings --- src/custom_service_account.rs | 2 +- src/jwt.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/custom_service_account.rs b/src/custom_service_account.rs index cfe2967..1899edd 100644 --- a/src/custom_service_account.rs +++ b/src/custom_service_account.rs @@ -12,7 +12,7 @@ use crate::types::{HyperClient, Signer, Token}; use crate::util::HyperExt; // Comes from https://github.com/golang/oauth2/blob/a835fc4358f6852f50c4c5c33fddcd1adade5b0a/google/google.go#L25 -const DEFAULT_TOKEN_URI: &'static str = "https://oauth2.googleapis.com/token"; +const DEFAULT_TOKEN_URI: &str = "https://oauth2.googleapis.com/token"; /// A custom service account containing credentials /// diff --git a/src/jwt.rs b/src/jwt.rs index d8d7fda..303e79a 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -41,7 +41,7 @@ impl<'a> Claims<'a> { .join(" "); Claims { iss: &key.client_email, - aud: &key.audience(), + aud: key.audience(), exp: expiry, iat, subject,