Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make the public API more modular #49

Merged
merged 30 commits into from
Feb 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cf0ffba
Clean up unintentionally added .DS_Store file
djc Jan 24, 2022
148eb79
Simplify parsing CustomServiceAccount setup
djc Jan 24, 2022
a0df627
Remove unused Error variants
djc Jan 24, 2022
d5c543a
Move initialization of AuthenticationManager into associated function
djc Jan 24, 2022
a73537c
Make GCloudAuthorizedUser::new() synchronous
djc Jan 24, 2022
0e6fd5a
Use matching to avoid unwrapping errors
djc Jan 24, 2022
ee892c3
Allow non-UTF-8 paths in GOOGLE_APPLICATION_CREDENTIALS
djc Jan 24, 2022
8976520
Extract initialization of HyperClient into function
djc Jan 24, 2022
4240558
Clarify internal API for turning CustomServiceAccount into Authentica…
djc Jan 24, 2022
9da6f85
Move checking environment for credentials into AuthenticationManager:…
djc Jan 24, 2022
8076fdb
Rename AuthenticationManager::select() to new()
djc Jan 24, 2022
11c1d9e
Make AuthenticationManager::new() public and document it
djc Jan 24, 2022
f95113c
Make CustomServiceAccount public
djc Jan 24, 2022
ed6203b
Add methods to access custom service account credentials
djc Jan 24, 2022
a220fad
Rename TLSError variant to TlsError
djc Jan 24, 2022
a4da35a
Deserialize user credentials directly from file
djc Jan 24, 2022
467c076
Add code to support project_id() in GCloud tokens
djc Jan 24, 2022
b7c0fd5
Rewrite code to be easier to follow
djc Jan 24, 2022
8b8c07e
Bump version for API changes
djc Jan 24, 2022
4c688ca
Inline decode_rsa_key() function into JwtSigner::new()
djc Jan 24, 2022
c29a7df
Sort dependencies
djc Jan 24, 2022
6c2c460
Use ring instead of rustls wrappers for signing
djc Jan 24, 2022
c0dc296
Move JWT-specific parts of signing into Claims method
djc Jan 24, 2022
562795d
Move Signer from jwt into types
djc Jan 24, 2022
88ae957
Reuse signer for the lifetime of the CustomServiceAccount
djc Jan 24, 2022
b62d18b
Expose API access to the CustomServiceAccount's Signer
djc Jan 24, 2022
3963a98
Switch from log to tracing
djc Jan 24, 2022
b312228
Instrument core code paths with tracing spans
djc Jan 24, 2022
7e202ee
Remove init() wrapper
djc Feb 1, 2022
3c6185d
Remove AuthenticationManager::from_custom_service_account() in favor …
djc Feb 1, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed .DS_Store
Binary file not shown.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
target
Cargo.lock
.DS_Store

# Intellij project files
*.iml
Expand Down
14 changes: 8 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "gcp_auth"
version = "0.6.2"
version = "0.7.0"
repository = "https://github.com/hrvolapeter/gcp_auth"
description = "Google cloud platform (GCP) authentication using default and custom service accounts"
documentation = "https://docs.rs/gcp_auth/"
Expand All @@ -15,21 +15,23 @@ default = ["hyper-rustls/rustls-native-certs"]
webpki-roots = ["hyper-rustls/webpki-roots"]

[dependencies]
async-trait = "0.1"
base64 = "0.13"
time = { version = "0.3.5", features = ["serde"] }
dirs-next = "2.0"
hyper = { version = "0.14.2", features = ["client", "runtime", "http2"] }
hyper-rustls = { version = "0.23.0", default-features = false, features = ["native-tokio", "http1", "http2"] }
log = "0.4"
ring = "0.16.20"
rustls = "0.20.2"
rustls-pemfile = "0.2.1"
serde = {version = "1.0", features = ["derive", "rc"]}
serde_json = "1.0"
thiserror = "1.0"
time = { version = "0.3.5", features = ["serde"] }
tokio = { version = "1.1", features = ["fs", "sync"] }
tracing = "0.1.29"
tracing-futures = "0.2.5"
url = "2"
which = "4.2"
async-trait = "0.1"
thiserror = "1.0"
dirs-next = "2.0"

[dev-dependencies]
env_logger = "0.9"
Expand Down
2 changes: 1 addition & 1 deletion examples/simple.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
let authentication_manager = gcp_auth::init().await?;
let authentication_manager = gcp_auth::AuthenticationManager::new().await?;
let _token = authentication_manager
.get_token(&["https://www.googleapis.com/auth/cloud-platform"])
.await?;
Expand Down
71 changes: 67 additions & 4 deletions src/authentication_manager.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
use async_trait::async_trait;
use tokio::sync::Mutex;

use crate::custom_service_account::CustomServiceAccount;
use crate::default_authorized_user::DefaultAuthorizedUser;
use crate::default_service_account::DefaultServiceAccount;
use crate::error::Error;
use crate::types::{HyperClient, Token};
use crate::gcloud_authorized_user::GCloudAuthorizedUser;
use crate::types::{self, HyperClient, Token};

#[async_trait]
pub(crate) trait ServiceAccount: Send + Sync {
Expand All @@ -13,18 +17,71 @@ pub(crate) trait ServiceAccount: Send + Sync {

/// Authentication manager is responsible for caching and obtaing credentials for the required scope
///
/// Cacheing for the full life time is ensured
/// Construct the authentication manager with [`AuthenticationManager::new()`] or by creating
/// a [`CustomServiceAccount`], then converting it into an `AuthenticationManager` using the `From`
/// impl.
pub struct AuthenticationManager {
pub(crate) client: HyperClient,
pub(crate) service_account: Box<dyn ServiceAccount>,
refresh_mutex: Mutex<()>,
}

impl AuthenticationManager {
pub(crate) fn new(client: HyperClient, service_account: Box<dyn ServiceAccount>) -> Self {
/// Finds a service account provider to get authentication tokens from
///
/// Tries the following approaches, in order:
///
/// 1. Check if the `GOOGLE_APPLICATION_CREDENTIALS` environment variable if set;
/// if so, use a custom service account as the token source.
/// 2. Check if the `gcloud` tool is available on the `PATH`; if so, use the
/// `gcloud auth print-access-token` command as the token source.
/// 3. Send a HTTP request to the internal metadata server to retrieve a token;
/// if it succeeds, use the default service account as the token source.
/// 4. Look for credentials in `.config/gcloud/application_default_credentials.json`;
/// if found, use these credentials to request refresh tokens.
#[tracing::instrument]
pub async fn new() -> Result<Self, Error> {
tracing::debug!("Initializing gcp_auth");
if let Some(service_account) = CustomServiceAccount::from_env()? {
return Ok(service_account.into());
}

let client = types::client();
let gcloud_error = match GCloudAuthorizedUser::new() {
Ok(service_account) => {
tracing::debug!("Using GCloudAuthorizedUser");
return Ok(Self::build(client, service_account));
}
Err(e) => e,
};

let default_service_error = match DefaultServiceAccount::new(&client).await {
Ok(service_account) => {
tracing::debug!("Using DefaultServiceAccount");
return Ok(Self::build(client, service_account));
}
Err(e) => e,
};

let default_user_error = match DefaultAuthorizedUser::new(&client).await {
Ok(service_account) => {
tracing::debug!("Using DefaultAuthorizedUser");
return Ok(Self::build(client, service_account));
}
Err(e) => e,
};

Err(Error::NoAuthMethod(
Box::new(gcloud_error),
Box::new(default_service_error),
Box::new(default_user_error),
))
}

fn build(client: HyperClient, service_account: impl ServiceAccount + 'static) -> Self {
Self {
client,
service_account,
service_account: Box::new(service_account),
refresh_mutex: Mutex::new(()),
}
}
Expand Down Expand Up @@ -58,3 +115,9 @@ impl AuthenticationManager {
self.service_account.project_id(&self.client).await
}
}

impl From<CustomServiceAccount> for AuthenticationManager {
fn from(service_account: CustomServiceAccount) -> Self {
Self::build(types::client(), service_account)
}
}
93 changes: 61 additions & 32 deletions src/custom_service_account.rs
Original file line number Diff line number Diff line change
@@ -1,36 +1,78 @@
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::RwLock;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::fs;

use crate::authentication_manager::ServiceAccount;
use crate::error::Error;
use crate::types::{HyperClient, Token};
use crate::types::{HyperClient, Signer, Token};
use crate::util::HyperExt;

/// A custom service account containing credentials
///
/// Once initialized, a [`CustomServiceAccount`] can be converted into an [`AuthenticationManager`]
/// using the applicable `From` implementation.
#[derive(Debug)]
pub(crate) struct CustomServiceAccount {
tokens: RwLock<HashMap<Vec<String>, Token>>,
pub struct CustomServiceAccount {
credentials: ApplicationCredentials,
signer: Signer,
tokens: RwLock<HashMap<Vec<String>, Token>>,
}

impl CustomServiceAccount {
pub(crate) async fn from_file(path: &Path) -> Result<Self, Error> {
Ok(Self {
credentials: ApplicationCredentials::from_file(path).await?,
tokens: RwLock::new(HashMap::new()),
})
/// Check `GOOGLE_APPLICATION_CREDENTIALS` environment variable for a path to JSON credentials
pub fn from_env() -> Result<Option<Self>, Error> {
std::env::var_os("GOOGLE_APPLICATION_CREDENTIALS")
.map(|path| {
tracing::debug!(
"Reading credentials file from GOOGLE_APPLICATION_CREDENTIALS env var"
);
Self::from_file(&PathBuf::from(path))
})
.transpose()
}

/// Read service account credentials from the given JSON file
pub fn from_file<T: AsRef<Path>>(path: T) -> Result<Self, Error> {
let file = std::fs::File::open(path.as_ref()).map_err(Error::CustomServiceAccountPath)?;
match serde_json::from_reader::<_, ApplicationCredentials>(file) {
Ok(credentials) => Self::new(credentials),
Err(e) => Err(Error::CustomServiceAccountCredentials(e)),
}
}

/// Read service account credentials from the given JSON string
pub fn from_json(s: &str) -> Result<Self, Error> {
match serde_json::from_str::<ApplicationCredentials>(s) {
Ok(credentials) => Self::new(credentials),
Err(e) => Err(Error::CustomServiceAccountCredentials(e)),
}
}

pub(crate) fn from_json(s: &str) -> Result<Self, Error> {
fn new(credentials: ApplicationCredentials) -> Result<Self, Error> {
Ok(Self {
credentials: ApplicationCredentials::from_json(s)?,
signer: Signer::new(&credentials.private_key)?,
credentials,
tokens: RwLock::new(HashMap::new()),
})
}

/// The RSA PKCS1 SHA256 [`Signer`] used to sign JWT tokens
pub fn signer(&self) -> &Signer {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why make the signer public?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So there's a use case here: ThouCheese/cloud-storage-rs#92. This is for the https://cloud.google.com/storage/docs/access-control/signed-urls feature, which uses the same encryption mechanism as far as I can tell.

More broadly, this is basically code that we already have in gcp_auth, so we might as well expose it to users as an easy API -- they might also want to produce JWTs.

&self.signer
}

/// The project ID as found in the credentials
pub fn project_id(&self) -> Option<&str> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems odd to me to provide two public functions with the same name on the same struct. I realise this is a slightly different signature that the ServiceAccount trait, but this seems hardly worth it?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ServiceAccount trait is pub(crate), so that implementation is actually not public.

self.credentials.project_id.as_deref()
}

/// The private key as found in the credentials
pub fn private_key_pem(&self) -> &str {
&self.credentials.private_key
}
}

#[async_trait]
Expand All @@ -47,31 +89,31 @@ impl ServiceAccount for CustomServiceAccount {
self.tokens.read().unwrap().get(&key).cloned()
}

#[tracing::instrument]
async fn refresh_token(&self, client: &HyperClient, scopes: &[&str]) -> Result<Token, Error> {
use crate::jwt::Claims;
use crate::jwt::JwtSigner;
use crate::jwt::GRANT_TYPE;
use hyper::header;
use url::form_urlencoded;

let signer = JwtSigner::new(&self.credentials.private_key)?;

let claims = Claims::new(&self.credentials, scopes, None);
let signed = signer.sign_claims(&claims).map_err(Error::TLSError)?;
let jwt = Claims::new(&self.credentials, scopes, None).to_jwt(&self.signer)?;
let rqbody = form_urlencoded::Serializer::new(String::new())
.extend_pairs(&[("grant_type", GRANT_TYPE), ("assertion", signed.as_str())])
.extend_pairs(&[("grant_type", GRANT_TYPE), ("assertion", jwt.as_str())])
.finish();

let request = hyper::Request::post(&self.credentials.token_uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(hyper::Body::from(rqbody))
.unwrap();
log::debug!("requesting token from service account: {:?}", request);

tracing::debug!("requesting token from service account: {:?}", request);
let token = client
.request(request)
.await
.map_err(Error::OAuthConnectionError)?
.deserialize::<Token>()
.await?;

let key = scopes.iter().map(|x| (*x).to_string()).collect();
self.tokens.write().unwrap().insert(key, token.clone());
Ok(token)
Expand Down Expand Up @@ -100,16 +142,3 @@ pub(crate) struct ApplicationCredentials {
/// client_x509_cert_url
pub(crate) client_x509_cert_url: Option<String>,
}

impl ApplicationCredentials {
async fn from_file<T: AsRef<Path>>(path: T) -> Result<ApplicationCredentials, Error> {
let content = fs::read_to_string(path)
.await
.map_err(Error::ApplicationProfilePath)?;
ApplicationCredentials::from_json(&content)
}

fn from_json(s: &str) -> Result<ApplicationCredentials, Error> {
serde_json::from_str(s).map_err(Error::ApplicationProfileFormat)
}
}
22 changes: 9 additions & 13 deletions src/default_authorized_user.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
use std::path::Path;
use std::fs;
use std::sync::RwLock;

use async_trait::async_trait;
use hyper::body::Body;
use hyper::{Method, Request};
use serde::{Deserialize, Serialize};
use tokio::fs;

use crate::authentication_manager::ServiceAccount;
use crate::error::Error;
Expand Down Expand Up @@ -36,17 +35,23 @@ impl DefaultAuthorizedUser {
.unwrap()
}

#[tracing::instrument]
async fn get_token(client: &HyperClient) -> Result<Token, Error> {
log::debug!("Loading user credentials file");
tracing::debug!("Loading user credentials file");
let mut home = dirs_next::home_dir().ok_or(Error::NoHomeDir)?;
home.push(Self::USER_CREDENTIALS_PATH);
let cred = UserCredentials::from_file(home.display().to_string()).await?;

let file = fs::File::open(home).map_err(Error::UserProfilePath)?;
let cred = serde_json::from_reader::<_, UserCredentials>(file)
.map_err(Error::UserProfileFormat)?;

let req = Self::build_token_request(&RefreshRequest {
client_id: cred.client_id,
client_secret: cred.client_secret,
grant_type: "refresh_token".to_string(),
refresh_token: cred.refresh_token,
});

let token = client
.request(req)
.await
Expand Down Expand Up @@ -93,12 +98,3 @@ struct UserCredentials {
/// Type
pub(crate) r#type: String,
}

impl UserCredentials {
async fn from_file<T: AsRef<Path>>(path: T) -> Result<UserCredentials, Error> {
let content = fs::read_to_string(path)
.await
.map_err(Error::UserProfilePath)?;
Ok(serde_json::from_str(&content).map_err(Error::UserProfileFormat)?)
}
}
5 changes: 3 additions & 2 deletions src/default_service_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ impl DefaultServiceAccount {
.unwrap()
}

#[tracing::instrument]
async fn get_token(client: &HyperClient) -> Result<Token, Error> {
log::debug!("Getting token from GCP instance metadata server");
tracing::debug!("Getting token from GCP instance metadata server");
let req = Self::build_token_request(Self::DEFAULT_TOKEN_GCP_URI);
let token = client
.request(req)
Expand All @@ -50,7 +51,7 @@ impl DefaultServiceAccount {
#[async_trait]
impl ServiceAccount for DefaultServiceAccount {
async fn project_id(&self, client: &HyperClient) -> Result<String, Error> {
log::debug!("Getting project ID from GCP instance metadata server");
tracing::debug!("Getting project ID from GCP instance metadata server");
let req = Self::build_token_request(Self::DEFAULT_PROJECT_ID_GCP_URI);
let rsp = client.request(req).await.map_err(Error::ConnectionError)?;

Expand Down
Loading