diff --git a/Cargo.toml b/Cargo.toml index b918d3d..e8402f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ rustls-pemfile = { version = "0.3", optional = true } seahash = "4" serde = {version = "1.0", features = ["derive"]} serde_json = "1.0" -time = { version = "0.3.7", features = ["local-offset", "serde"] } +time = { version = "0.3.7", features = ["local-offset", "parsing", "serde"] } tokio = { version = "1.0", features = ["fs", "macros", "io-std", "io-util", "time", "sync", "rt"] } tower-service = "^0.3.1" url = "2" diff --git a/examples/service_account_impersonation.rs b/examples/service_account_impersonation.rs new file mode 100644 index 0000000..a741cd7 --- /dev/null +++ b/examples/service_account_impersonation.rs @@ -0,0 +1,23 @@ +use yup_oauth2::{read_authorized_user_secret, ServiceAccountImpersonationAuthenticator}; + +#[tokio::main] +async fn main() { + let svc_email = std::env::args().skip(1).next().unwrap(); + let home = std::env::var("HOME").unwrap(); + + let user_secret = + read_authorized_user_secret(format!("{}/.config/gcloud/application_default_credentials.json", home)) + .await + .expect("user secret"); + + let auth = ServiceAccountImpersonationAuthenticator::builder(user_secret, &svc_email) + .build() + .await + .expect("authenticator"); + + let scopes = &["https://www.googleapis.com/auth/youtube.readonly"]; + match auth.token(scopes).await { + Err(e) => println!("error: {:?}", e), + Ok(t) => println!("token: {:?}", t), + } +} diff --git a/src/authenticator.rs b/src/authenticator.rs index 5e73aa3..480400b 100644 --- a/src/authenticator.rs +++ b/src/authenticator.rs @@ -8,6 +8,7 @@ use crate::device::DeviceFlow; use crate::error::Error; use crate::installed::{InstalledFlow, InstalledFlowReturnMethod}; use crate::refresh::RefreshFlow; +use crate::service_account_impersonator::ServiceAccountImpersonationFlow; #[cfg(feature = "service_account")] use crate::service_account::{self, ServiceAccountFlow, ServiceAccountFlowOpts, ServiceAccountKey}; @@ -15,10 +16,10 @@ use crate::storage::{self, Storage, TokenStorage}; use crate::types::{AccessToken, ApplicationSecret, TokenInfo}; use private::AuthFlow; -use crate::access_token::{AccessTokenFlow}; +use crate::access_token::AccessTokenFlow; use futures::lock::Mutex; -use http::{Uri}; +use http::Uri; use hyper::client::connect::Connection; use std::borrow::Cow; use std::error::Error as StdError; @@ -428,22 +429,69 @@ pub struct AccessTokenAuthenticator; impl AccessTokenAuthenticator { /// the builder pattern for the authenticator pub fn builder( - access_token: String, + access_token: String, ) -> AuthenticatorBuilder { - Self::with_client(access_token, DefaultHyperClient) + Self::with_client(access_token, DefaultHyperClient) } /// Construct a new Authenticator that uses the installed flow and the provided http client. /// the client itself is not used pub fn with_client( - access_token: String, - client: C, + access_token: String, + client: C, ) -> AuthenticatorBuilder { - AuthenticatorBuilder::new( - AccessTokenFlow { - access_token: access_token, - }, - client, - ) + AuthenticatorBuilder::new( + AccessTokenFlow { + access_token: access_token, + }, + client, + ) + } +} + +/// Create a access token authenticator that uses user secrets to impersonate +/// a service account. +/// +/// ``` +/// # #[cfg(any(feature = "hyper-rustls", feature = "hyper-tls"))] +/// # async fn foo() { +/// # use yup_oauth2::authenticator::AuthorizedUserAuthenticator; +/// # let secret = yup_oauth2::read_authorized_user_secret("/tmp/foo").await.unwrap(); +/// # let email = "my-test-account@my-test-project.iam.gserviceaccount.com"; +/// let authenticator = yup_oauth2::ServiceAccountImpersonationAuthenticator::builder(secret, email) +/// .build() +/// .await +/// .expect("failed to create authenticator"); +/// # } +/// ``` +pub struct ServiceAccountImpersonationAuthenticator; +impl ServiceAccountImpersonationAuthenticator { + /// Use the builder pattern to create an Authenticator that uses the device flow. + #[cfg(any(feature = "hyper-rustls", feature = "hyper-tls"))] + #[cfg_attr( + yup_oauth2_docsrs, + doc(cfg(any(feature = "hyper-rustls", feature = "hyper-tls"))) + )] + pub fn builder( + authorized_user_secret: AuthorizedUserSecret, + service_account_email: &str, + ) -> AuthenticatorBuilder { + Self::with_client( + authorized_user_secret, + service_account_email, + DefaultHyperClient, + ) + } + + /// Construct a new Authenticator that uses the installed flow and the provided http client. + pub fn with_client( + authorized_user_secret: AuthorizedUserSecret, + service_account_email: &str, + client: C, + ) -> AuthenticatorBuilder { + AuthenticatorBuilder::new( + ServiceAccountImpersonationFlow::new(authorized_user_secret, service_account_email), + client, + ) } } @@ -706,6 +754,22 @@ impl AuthenticatorBuilder { } } +/// ## Methods available when building a service account impersonation Authenticator. +impl AuthenticatorBuilder { + /// Create the authenticator. + pub async fn build(self) -> io::Result> + where + C: HyperClientBuilder, + { + Self::common_build( + self.hyper_client_builder, + self.storage_type, + AuthFlow::ServiceAccountImpersonationFlow(self.auth_flow), + ) + .await + } +} + /// ## Methods available when building an access token flow Authenticator. impl AuthenticatorBuilder { /// Create the authenticator. @@ -722,25 +786,27 @@ impl AuthenticatorBuilder { } } mod private { + use crate::access_token::AccessTokenFlow; use crate::application_default_credentials::ApplicationDefaultCredentialsFlow; - use crate::authorized_user::AuthorizedUserFlow; use crate::authenticator::{AsyncRead, AsyncWrite, Connection, Service, StdError, Uri}; + use crate::authorized_user::AuthorizedUserFlow; use crate::device::DeviceFlow; use crate::error::Error; use crate::installed::InstalledFlow; #[cfg(feature = "service_account")] use crate::service_account::ServiceAccountFlow; + use crate::service_account_impersonator::ServiceAccountImpersonationFlow; use crate::types::{ApplicationSecret, TokenInfo}; - use crate::access_token::AccessTokenFlow; pub enum AuthFlow { DeviceFlow(DeviceFlow), InstalledFlow(InstalledFlow), #[cfg(feature = "service_account")] ServiceAccountFlow(ServiceAccountFlow), + ServiceAccountImpersonationFlow(ServiceAccountImpersonationFlow), ApplicationDefaultCredentialsFlow(ApplicationDefaultCredentialsFlow), AuthorizedUserFlow(AuthorizedUserFlow), - AccessTokenFlow(AccessTokenFlow), + AccessTokenFlow(AccessTokenFlow), } impl AuthFlow { @@ -750,9 +816,10 @@ mod private { AuthFlow::InstalledFlow(installed_flow) => Some(&installed_flow.app_secret), #[cfg(feature = "service_account")] AuthFlow::ServiceAccountFlow(_) => None, + AuthFlow::ServiceAccountImpersonationFlow(_) => None, AuthFlow::ApplicationDefaultCredentialsFlow(_) => None, AuthFlow::AuthorizedUserFlow(_) => None, - AuthFlow::AccessTokenFlow(_) => None, + AuthFlow::AccessTokenFlow(_) => None, } } @@ -777,15 +844,20 @@ mod private { AuthFlow::ServiceAccountFlow(service_account_flow) => { service_account_flow.token(hyper_client, scopes).await } + AuthFlow::ServiceAccountImpersonationFlow(service_account_impersonation_flow) => { + service_account_impersonation_flow + .token(hyper_client, scopes) + .await + } AuthFlow::ApplicationDefaultCredentialsFlow(adc_flow) => { adc_flow.token(hyper_client, scopes).await } AuthFlow::AuthorizedUserFlow(authorized_user_flow) => { authorized_user_flow.token(hyper_client, scopes).await } - AuthFlow::AccessTokenFlow(access_token_flow) => { - access_token_flow.token(hyper_client, scopes).await - } + AuthFlow::AccessTokenFlow(access_token_flow) => { + access_token_flow.token(hyper_client, scopes).await + } } } } diff --git a/src/error.rs b/src/error.rs index d43073b..fc665f8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -154,6 +154,8 @@ pub enum Error { UserError(String), /// A lower level IO error. LowLevelError(io::Error), + /// We required an access token, but received a response that didn't contain one. + MissingAccessToken, /// Other errors produced by a storage provider OtherError(anyhow::Error), } @@ -206,6 +208,13 @@ impl fmt::Display for Error { } Error::UserError(ref s) => s.fmt(f), Error::LowLevelError(ref e) => e.fmt(f), + Error::MissingAccessToken => { + write!( + f, + "Expected an access token, but received a response without one" + )?; + Ok(()) + } Error::OtherError(ref e) => e.fmt(f), } } diff --git a/src/lib.rs b/src/lib.rs index b274277..56d4acd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -72,16 +72,17 @@ #![deny(missing_docs)] #![cfg_attr(yup_oauth2_docsrs, feature(doc_cfg))] +pub mod access_token; mod application_default_credentials; pub mod authenticator; pub mod authenticator_delegate; pub mod authorized_user; -pub mod access_token; mod device; pub mod error; mod helper; mod installed; mod refresh; +pub mod service_account_impersonator; #[cfg(feature = "service_account")] mod service_account; @@ -97,9 +98,9 @@ mod types; pub use crate::authenticator::ServiceAccountAuthenticator; #[doc(inline)] pub use crate::authenticator::{ - ApplicationDefaultCredentialsAuthenticator, AuthorizedUserAuthenticator, - DeviceFlowAuthenticator, InstalledFlowAuthenticator, - AccessTokenAuthenticator, + AccessTokenAuthenticator, ApplicationDefaultCredentialsAuthenticator, + AuthorizedUserAuthenticator, DeviceFlowAuthenticator, InstalledFlowAuthenticator, + ServiceAccountImpersonationAuthenticator, }; pub use crate::helper::*; diff --git a/src/service_account_impersonator.rs b/src/service_account_impersonator.rs new file mode 100644 index 0000000..3acb06b --- /dev/null +++ b/src/service_account_impersonator.rs @@ -0,0 +1,128 @@ +//! This module provides an authenticator that uses authorized user secrets +//! to generate impersonated service account tokens. +//! +//! Resources: +//! - [service account impersonation](https://cloud.google.com/iam/docs/create-short-lived-credentials-direct#sa-credentials-oauth) + +use http::{header, Uri}; +use hyper::client::connect::Connection; +use serde::Serialize; +use std::error::Error as StdError; +use tokio::io::{AsyncRead, AsyncWrite}; +use tower_service::Service; + +use crate::{ + authorized_user::{AuthorizedUserFlow, AuthorizedUserSecret}, + storage::TokenInfo, + Error, +}; + +const IAM_CREDENTIALS_ENDPOINT: &'static str = "https://iamcredentials.googleapis.com"; + +fn uri(email: &str) -> String { + format!( + "{}/v1/projects/-/serviceAccounts/{}:generateAccessToken", + IAM_CREDENTIALS_ENDPOINT, email + ) +} + +#[derive(Serialize)] +struct Request<'a> { + scope: &'a [&'a str], + lifetime: &'a str, +} + +// The impersonation response is in a different format from the other GCP +// responses. Why, Google, why? The response to our impersonation request. +// (Note that the naming is different from `types::AccessToken` even though +// the data is equivalent.) +#[derive(serde::Deserialize, Debug)] +struct TokenResponse { + /// The actual token + #[serde(rename = "accessToken")] + access_token: String, + /// The time until the token expires and a new one needs to be requested. + /// In RFC3339 format. + #[serde(rename = "expireTime")] + expires_time: String, +} + +impl From for TokenInfo { + fn from(resp: TokenResponse) -> TokenInfo { + let expires_at = time::OffsetDateTime::parse( + &resp.expires_time, + &time::format_description::well_known::Rfc3339, + ) + .ok(); + TokenInfo { + access_token: Some(resp.access_token), + refresh_token: None, + expires_at, + id_token: None, + } + } +} + +/// ServiceAccountImpersonationFlow uses user credentials to impersonate a service +/// account. +pub struct ServiceAccountImpersonationFlow { + pub(crate) inner_flow: AuthorizedUserFlow, + pub(crate) service_account_email: String, +} + +impl ServiceAccountImpersonationFlow { + pub(crate) fn new( + user_secret: AuthorizedUserSecret, + service_account_email: &str, + ) -> ServiceAccountImpersonationFlow { + ServiceAccountImpersonationFlow { + inner_flow: AuthorizedUserFlow { + secret: user_secret, + }, + service_account_email: service_account_email.to_string(), + } + } + + pub(crate) async fn token( + &self, + hyper_client: &hyper::Client, + scopes: &[T], + ) -> Result + where + T: AsRef, + S: Service + Clone + Send + Sync + 'static, + S::Response: Connection + AsyncRead + AsyncWrite + Send + Unpin + 'static, + S::Future: Send + Unpin + 'static, + S::Error: Into>, + { + let inner_token = self + .inner_flow + .token(hyper_client, scopes) + .await? + .access_token + .ok_or(Error::MissingAccessToken)?; + + let scopes: Vec<_> = scopes.iter().map(|s| s.as_ref()).collect(); + let req_body = Request { + scope: &scopes, + // Max validity is 1h. + lifetime: "3600s", + }; + let req_body = serde_json::to_vec(&req_body)?; + + let request = hyper::Request::post(uri(&self.service_account_email)) + .header(header::CONTENT_TYPE, "application/json; charset=utf-8") + .header(header::CONTENT_LENGTH, req_body.len()) + .header(header::AUTHORIZATION, format!("Bearer {}", inner_token)) + .body(req_body.into()) + .unwrap(); + + log::debug!("requesting impersonated token {:?}", request); + let (head, body) = hyper_client.request(request).await?.into_parts(); + let body = hyper::body::to_bytes(body).await?; + log::debug!("received response; head: {:?}, body: {:?}", head, body); + + let response: TokenResponse = serde_json::from_slice(&body)?; + Ok(response.into()) + } +}