From a006f6cf69b3dd26d72ec03e724ae4804df25788 Mon Sep 17 00:00:00 2001 From: Sean Purcell Date: Wed, 8 Jan 2025 02:54:35 +0000 Subject: [PATCH 1/2] feat(Authenticator): add a guaranteed noninteractive authenticator Add an authenticator that stores refresh tokens an an application secret, so that it can be serialized/deserialized and used in a non-interactive program. --- src/authenticator.rs | 55 +++++++++++++++++++++++++++++++ src/lib.rs | 3 +- src/noninteractive.rs | 77 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 src/noninteractive.rs diff --git a/src/authenticator.rs b/src/authenticator.rs index 4e153b4c..924b9d0b 100644 --- a/src/authenticator.rs +++ b/src/authenticator.rs @@ -11,6 +11,7 @@ use crate::device::DeviceFlow; use crate::error::Error; use crate::external_account::{ExternalAccountFlow, ExternalAccountSecret}; use crate::installed::{InstalledFlow, InstalledFlowReturnMethod}; +use crate::noninteractive::{NoninteractiveFlow, NoninteractiveTokens}; use crate::refresh::RefreshFlow; use crate::service_account_impersonator::ServiceAccountImpersonationFlow; @@ -523,6 +524,35 @@ impl ServiceAccountImpersonationAuthenticator { } } +/// Create an authenticator that uses a pre-constructed `NoninteractiveTokens` to authenticate. +/// This authenticator is guaranteed to be non-interactive, the tokens can be created in advance. +pub struct NoninteractiveAuthenticator; +impl NoninteractiveAuthenticator { + /// Use the builder pattern to create an Authenticator that uses previously constructed + /// `NoninteractiveTokens`. + #[cfg(any(feature = "hyper-rustls", feature = "hyper-tls"))] + #[cfg_attr(docsrs, doc(cfg(any(feature = "hyper-rustls", feature = "hyper-tls"))))] + pub fn builder( + tokens: NoninteractiveTokens, + ) -> AuthenticatorBuilder { + Self::with_client( + tokens, + DefaultHyperClientBuilder::default(), + ) + } + + /// Construct a new Authenticator that uses the installed flow and the provided http client. + pub fn with_client( + tokens: NoninteractiveTokens, + client: C, + ) -> AuthenticatorBuilder { + AuthenticatorBuilder::new( + NoninteractiveFlow(tokens), + client, + ) + } +} + /// ## Methods available when building any Authenticator. /// ``` /// # async fn foo() { @@ -853,6 +883,23 @@ impl AuthenticatorBuilder { .await } } + +/// ## Methods available when building a noninteractive flow 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::NoninteractiveFlow(self.auth_flow), + ) + .await + } +} + mod private { use crate::access_token::AccessTokenFlow; use crate::application_default_credentials::ApplicationDefaultCredentialsFlow; @@ -862,6 +909,7 @@ mod private { use crate::error::Error; use crate::external_account::ExternalAccountFlow; use crate::installed::InstalledFlow; + use crate::noninteractive::NoninteractiveFlow; #[cfg(feature = "service-account")] use crate::service_account::ServiceAccountFlow; use crate::service_account_impersonator::ServiceAccountImpersonationFlow; @@ -878,6 +926,7 @@ mod private { AuthorizedUserFlow(AuthorizedUserFlow), ExternalAccountFlow(ExternalAccountFlow), AccessTokenFlow(AccessTokenFlow), + NoninteractiveFlow(NoninteractiveFlow), } impl AuthFlow { @@ -892,6 +941,9 @@ mod private { AuthFlow::AuthorizedUserFlow(_) => None, AuthFlow::ExternalAccountFlow(_) => None, AuthFlow::AccessTokenFlow(_) => None, + AuthFlow::NoninteractiveFlow(noninteractive_flow) => { + Some(noninteractive_flow.app_secret()) + } } } @@ -929,6 +981,9 @@ mod private { AuthFlow::AccessTokenFlow(access_token_flow) => { access_token_flow.token(hyper_client, scopes).await } + AuthFlow::NoninteractiveFlow(noninteractive_flow) => { + noninteractive_flow.token(hyper_client, scopes).await + } } } } diff --git a/src/lib.rs b/src/lib.rs index 46b940b4..e8cb08f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -83,6 +83,7 @@ pub mod error; pub mod external_account; mod helper; mod installed; +pub mod noninteractive; mod refresh; pub mod service_account_impersonator; @@ -115,7 +116,7 @@ pub use crate::client::{CustomHyperClientBuilder, HttpClient, HyperClientBuilder pub use crate::authenticator::{ ApplicationDefaultCredentialsAuthenticator, AuthorizedUserAuthenticator, DeviceFlowAuthenticator, ExternalAccountAuthenticator, InstalledFlowAuthenticator, - ServiceAccountImpersonationAuthenticator, + NoninteractiveAuthenticator, ServiceAccountImpersonationAuthenticator, }; pub use crate::helper::*; diff --git a/src/noninteractive.rs b/src/noninteractive.rs new file mode 100644 index 00000000..6f6de1f7 --- /dev/null +++ b/src/noninteractive.rs @@ -0,0 +1,77 @@ +//! Module containing functionality for serializing tokens and using them at a later point for +//! non-interactive services. +use crate::client::SendRequest; +use crate::error::Error; +use crate::refresh::RefreshFlow; +use crate::types::{ApplicationSecret, TokenInfo}; + +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone, Default, Debug)] +struct Entry { + scopes: Vec, + refresh_token: String, +} + +impl Entry { + fn is_subset(&self, scopes: &[T]) -> bool + where + T: AsRef, + { + scopes + .iter() + .all(|scope| self.scopes.iter().any(|s| s.as_str() == scope.as_ref())) + } +} + +/// These tokens are meant to be constructed interactively using another flow, and then can be +/// serialized to be deserialized and used non-interactively later on. Since access tokens are +/// typically short-lived, this authenticator assumes it will be expired and only stores the +/// refresh token. +#[derive(Deserialize, Serialize, Clone, Default, Debug)] +pub struct NoninteractiveTokens { + app_secret: ApplicationSecret, + refresh_tokens: Vec, +} + +impl NoninteractiveTokens { + fn entry_for_scopes(&self, scopes: &[T]) -> Option<&Entry> + where + T: AsRef, + { + self.refresh_tokens + .iter() + .find(|entry| entry.is_subset(scopes)) + } +} + +/// A flow that uses a `NoninteractiveTokens` instance to provide access tokens. +pub struct NoninteractiveFlow(pub(crate) NoninteractiveTokens); + +impl NoninteractiveFlow { + pub(crate) fn app_secret(&self) -> &ApplicationSecret { + &self.0.app_secret + } + + pub(crate) async fn token( + &self, + hyper_client: &impl SendRequest, + scopes: &[T], + ) -> Result + where + T: AsRef + { + let refresh_token = (match self.0.entry_for_scopes(scopes) { + None => Err(Error::UserError(format!( + "No matching token found for scopes {:?}", + scopes + .iter() + .map(|x| x.as_ref().to_string()) + .collect::>() + ))), + Some(entry) => Ok(&entry.refresh_token), + })?; + + RefreshFlow::refresh_token(hyper_client, self.app_secret(), refresh_token.as_str()).await + } +} From 63ee4dc3a58813b5156987eb35933717be18b5d1 Mon Sep 17 00:00:00 2001 From: Sean Purcell Date: Wed, 8 Jan 2025 03:02:27 +0000 Subject: [PATCH 2/2] feat(NoninteractiveAuthenticator): add a builder for serializable tokens using another authenticator Expose a way to create `NoninteractiveTokens` using another (presumably `InstalledFlow` or `DeviceFlow`) authenticator. It makes a request for tokens, and then stores just the refresh token, assuming that the access token will be expired by the time it's used. There's no clean exposed way to refresh the refresh tokens, but it's easy to do (if somewhat awkwardly) by creating a new builder using a current NoninteractiveFlow authenticator, so this feature doesn't add it. --- src/authenticator.rs | 13 +++++--- src/noninteractive.rs | 77 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/authenticator.rs b/src/authenticator.rs index 924b9d0b..d611a88a 100644 --- a/src/authenticator.rs +++ b/src/authenticator.rs @@ -79,9 +79,10 @@ where where T: AsRef, { - self.find_token_info(scopes, /* force_refresh = */ false) - .await - .map(|info| info.into()) + Ok(self + .find_token_info(scopes, /* force_refresh = */ false) + .await? + .into()) } /// Return a token for the provided scopes, but don't reuse cached tokens. Instead, @@ -109,7 +110,7 @@ where } /// Return a cached token or fetch a new one from the server. - async fn find_token_info<'a, T>( + pub(crate) async fn find_token_info<'a, T>( &'a self, scopes: &'a [T], force_refresh: bool, @@ -175,6 +176,10 @@ where } } } + + pub(crate) fn app_secret(&self) -> Option<&ApplicationSecret> { + self.inner.auth_flow.app_secret() + } } /// Configure an Authenticator using the builder pattern. diff --git a/src/noninteractive.rs b/src/noninteractive.rs index 6f6de1f7..0c656d58 100644 --- a/src/noninteractive.rs +++ b/src/noninteractive.rs @@ -1,10 +1,12 @@ //! Module containing functionality for serializing tokens and using them at a later point for //! non-interactive services. +use crate::authenticator::Authenticator; use crate::client::SendRequest; use crate::error::Error; use crate::refresh::RefreshFlow; use crate::types::{ApplicationSecret, TokenInfo}; +use hyper_util::client::legacy::connect::Connect; use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize, Clone, Default, Debug)] @@ -14,6 +16,16 @@ struct Entry { } impl Entry { + fn create(scopes: &[T], refresh_token: String) -> Self + where + T: AsRef, + { + Entry { + scopes: (scopes.iter().map(|x| x.as_ref().to_string()).collect()), + refresh_token, + } + } + fn is_subset(&self, scopes: &[T]) -> bool where T: AsRef, @@ -43,6 +55,71 @@ impl NoninteractiveTokens { .iter() .find(|entry| entry.is_subset(scopes)) } + + /// Create a builder using an existing authenticator to get tokens interactively, which can be + /// saved and used later non-interactively.. + pub fn builder<'a, C>( + authenticator: &'a Authenticator, + ) -> Result, Error> + where + C: Connect + Clone + Send + Sync + 'static, + { + let app_secret = (match authenticator.app_secret() { + Some(secret) => Ok(secret.clone()), + None => Err(Error::UserError( + "No application secret present in authenticator".into(), + )), + })?; + + Ok(NoninteractiveTokensBuilder { + authenticator, + tokens: NoninteractiveTokens { + app_secret, + refresh_tokens: vec![], + }, + }) + } +} + +/// A builder to construct `NoninteractiveTokens` using an existing authenticator. +#[derive(Clone)] +pub struct NoninteractiveTokensBuilder<'a, C> +where C: Connect + Clone + Send + Sync + 'static { + authenticator: &'a Authenticator, + tokens: NoninteractiveTokens, +} + +impl<'a, C> NoninteractiveTokensBuilder<'a, C> +where + C: Connect + Clone + Send + Sync + 'static, +{ + /// Finalize the `NoninteractiveTokens`. + pub fn build(self) -> NoninteractiveTokens { + self.tokens + } + + /// Add a cached refresh token for a given set of scopes. + pub async fn add_token_for( + mut self, + scopes: &[T], + force_refresh: bool, + ) -> Result, Error> + where + T: AsRef, + { + let info = self.authenticator.find_token_info(scopes, force_refresh).await?; + match info.refresh_token { + Some(token) => { + self.tokens + .refresh_tokens + .push(Entry::create(scopes, token.clone())); + Ok(self) + } + None => Err(Error::UserError( + "Returned token doesn't contain a refresh token".into(), + )), + } + } } /// A flow that uses a `NoninteractiveTokens` instance to provide access tokens.