use crate::configuration::{secrets::SecretAccessToken, tokens::insecure_validate_token_exp};
#[cfg(feature = "tracing-config")]
use crate::tracing_configuration::TracingConfiguration;
use derive_builder::Builder;
use std::{env, path::PathBuf};
use tokio_util::sync::CancellationToken;
#[cfg(feature = "stubs")]
use rigetti_pyo3::pyo3_stub_gen::derive::gen_stub_pyclass;
use self::{
secrets::{Credential, Secrets, TokenPayload},
settings::Settings,
};
pub(crate) mod error;
mod external_command;
pub mod fs;
mod login;
pub mod oidc;
mod pkce;
mod secret_string;
pub mod secrets;
pub mod settings;
pub mod tokens;
pub use error::{DiscoveryError, LoadError, TokenError};
pub use tokens::default_http_client;
#[cfg(feature = "python")]
pub(crate) mod py;
use pkce::RedirectBinding;
use settings::AuthServer;
use tokens::{
AuthTokens, OAuthGrant, OAuthSession, RefreshToken, TokenDispatcher, persist_oauth_session,
};
pub const DEFAULT_PROFILE_NAME: &str = "default";
pub const PROFILE_NAME_VAR: &str = "QCS_PROFILE_NAME";
fn env_or_default_profile_name() -> String {
env::var(PROFILE_NAME_VAR).unwrap_or_else(|_| DEFAULT_PROFILE_NAME.to_string())
}
pub const DEFAULT_API_URL: &str = "https://api.qcs.rigetti.com";
pub const API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_API_URL";
fn env_or_default_api_url() -> String {
env::var(API_URL_VAR).unwrap_or_else(|_| DEFAULT_API_URL.to_string())
}
pub const DEFAULT_GRPC_API_URL: &str = "https://grpc.qcs.rigetti.com";
pub const GRPC_API_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_GRPC_URL";
fn env_or_default_grpc_url() -> String {
env::var(GRPC_API_URL_VAR).unwrap_or_else(|_| DEFAULT_GRPC_API_URL.to_string())
}
pub const DEFAULT_QVM_URL: &str = "http://127.0.0.1:5000";
pub const QVM_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QVM_URL";
fn env_or_default_qvm_url() -> String {
env::var(QVM_URL_VAR).unwrap_or_else(|_| DEFAULT_QVM_URL.to_string())
}
pub const DEFAULT_QUILC_URL: &str = "tcp://127.0.0.1:5555";
pub const QUILC_URL_VAR: &str = "QCS_SETTINGS_APPLICATIONS_QUILC_URL";
fn env_or_default_quilc_url() -> String {
env::var(QUILC_URL_VAR).unwrap_or_else(|_| DEFAULT_QUILC_URL.to_string())
}
#[derive(Clone, Debug, Builder)]
#[cfg_attr(
not(feature = "stubs"),
builder_struct_attr(optipy::strip_pyo3(only_stubs)),
optipy::strip_pyo3(only_stubs)
)]
#[cfg_attr(
not(feature = "python"),
builder_struct_attr(optipy::strip_pyo3),
optipy::strip_pyo3
)]
#[cfg_attr(
feature = "stubs",
builder_struct_attr(gen_stub_pyclass),
gen_stub_pyclass
)]
#[cfg_attr(
feature = "python",
builder_struct_attr(pyo3::pyclass(
module = "qcs_api_client_common._qcs_api_client_common.configuration",
from_py_object
)),
pyo3::pyclass(
module = "qcs_api_client_common._qcs_api_client_common.configuration",
frozen,
from_py_object
)
)]
pub struct ClientConfiguration {
#[builder(private, default = "env_or_default_profile_name()")]
#[builder_field_attr(gen_stub(skip))]
profile: String,
#[builder(private, default = "env_or_default_profile_name()")]
#[builder_field_attr(gen_stub(skip))]
credentials_name: String,
#[doc = "The URL for the QCS REST API."]
#[builder(default = "env_or_default_api_url()")]
#[builder_field_attr(pyo3(get, set))]
#[pyo3(get)]
api_url: String,
#[doc = "The URL for the QCS gRPC API."]
#[builder(default = "env_or_default_grpc_url()")]
#[builder_field_attr(pyo3(get, set))]
#[pyo3(get)]
grpc_api_url: String,
#[doc = "The URL of the quilc server."]
#[builder(default = "env_or_default_quilc_url()")]
#[builder_field_attr(pyo3(get, set))]
#[pyo3(get)]
quilc_url: String,
#[doc = "The URL of the QVM server."]
#[builder(default = "env_or_default_qvm_url()")]
#[builder_field_attr(pyo3(get, set))]
#[pyo3(get)]
qvm_url: String,
#[builder(default, setter(custom))]
#[builder_field_attr(pyo3(get))]
pub(crate) oauth_session: Option<TokenDispatcher>,
#[builder(private, default = "ConfigSource::Builder")]
#[builder_field_attr(gen_stub(skip))]
source: ConfigSource,
#[cfg(feature = "tracing-config")]
#[builder(default)]
#[builder_field_attr(gen_stub(skip))]
tracing_configuration: Option<TracingConfiguration>,
}
impl ClientConfigurationBuilder {
pub fn oauth_session(&mut self, oauth_session: Option<OAuthSession>) -> &mut Self {
self.oauth_session = Some(oauth_session.map(Into::into));
self
}
}
struct ConfigurationContext {
builder: ClientConfigurationBuilder,
auth_server: AuthServer,
credential: Option<Credential>,
source: ConfigSource,
credentials_name: String,
}
impl ConfigurationContext {
fn from_profile(profile_name: Option<String>) -> Result<Self, LoadError> {
#[cfg(feature = "tracing-config")]
match profile_name.as_ref() {
None => tracing::debug!("loading default QCS profile"),
Some(profile) => {
tracing::debug!("loading QCS profile {profile}")
}
}
let settings = Settings::load()?;
let secrets = Secrets::load()?;
Self::from_sources(settings, secrets, profile_name)
}
fn from_sources(
settings: Settings,
mut secrets: Secrets,
profile_name: Option<String>,
) -> Result<Self, LoadError> {
let Settings {
default_profile_name,
mut profiles,
mut auth_servers,
file_path: settings_path,
} = settings;
let profile_name = profile_name
.or_else(|| env::var(PROFILE_NAME_VAR).ok())
.unwrap_or(default_profile_name);
let profile = profiles
.remove(&profile_name)
.ok_or(LoadError::ProfileNotFound(profile_name.clone()))?;
let auth_server = auth_servers
.remove(&profile.auth_server_name)
.ok_or_else(|| LoadError::AuthServerNotFound(profile.auth_server_name.clone()))?;
let secrets_path = secrets.file_path;
let credentials_name = profile.credentials_name;
let credential = secrets.credentials.remove(&credentials_name);
let api_url = env::var(API_URL_VAR)
.unwrap_or(profile.api_url)
.trim_end_matches('/')
.to_string();
let quilc_url = env::var(QUILC_URL_VAR).unwrap_or(profile.applications.pyquil.quilc_url);
let qvm_url = env::var(QVM_URL_VAR).unwrap_or(profile.applications.pyquil.qvm_url);
let grpc_api_url = env::var(GRPC_API_URL_VAR)
.unwrap_or(profile.grpc_api_url)
.trim_end_matches('/')
.to_string();
#[cfg(feature = "tracing-config")]
let tracing_configuration =
TracingConfiguration::from_env().map_err(LoadError::TracingFilterParseError)?;
let source = match (settings_path, secrets_path) {
(Some(settings_path), Some(secrets_path)) => ConfigSource::File {
settings_path,
secrets_path,
},
_ => ConfigSource::Default,
};
let mut builder = ClientConfiguration::builder();
builder
.profile(profile_name)
.credentials_name(credentials_name.clone())
.source(source.clone())
.api_url(api_url)
.quilc_url(quilc_url)
.qvm_url(qvm_url)
.grpc_api_url(grpc_api_url);
#[cfg(feature = "tracing-config")]
{
builder.tracing_configuration(tracing_configuration);
}
Ok(Self {
builder,
auth_server,
credential,
source,
credentials_name,
})
}
}
async fn persist_or_warn(
oauth_session: &OAuthSession,
source: &ConfigSource,
credentials_name: &str,
) {
if let Err(_error) = persist_oauth_session(oauth_session, source, credentials_name).await {
#[cfg(feature = "tracing")]
tracing::warn!(
"Refreshed QCS credentials but failed to persist them to the secrets file: {_error}"
);
}
}
fn credential_to_oauth_session(
credential: Option<Credential>,
auth_server: AuthServer,
) -> Option<OAuthSession> {
match credential? {
Credential::TokenPayload(token_payload) => {
let TokenPayload {
access_token,
refresh_token,
..
} = token_payload;
Some(OAuthSession::new(
OAuthGrant::RefreshToken(RefreshToken::new(refresh_token.unwrap_or_default())),
auth_server,
access_token,
))
}
Credential::ExternallyManaged(externally_managed) => Some(OAuthSession::new(
OAuthGrant::ExternallyManaged(externally_managed.into()),
auth_server,
None,
)),
Credential::ClientCredentials(client_credentials) => {
let grant = OAuthGrant::ClientCredentials(client_credentials);
Some(OAuthSession::new(grant, auth_server, None))
}
}
}
impl ClientConfiguration {
#[cfg(test)]
fn new(
settings: Settings,
secrets: Secrets,
profile_name: Option<String>,
) -> Result<Self, LoadError> {
let ConfigurationContext {
mut builder,
auth_server,
credential,
..
} = ConfigurationContext::from_sources(settings, secrets, profile_name)?;
let oauth_session = credential_to_oauth_session(credential, auth_server);
Ok(builder.oauth_session(oauth_session).build()?)
}
pub fn load_default() -> Result<Self, LoadError> {
let base_config = Self::load(None)?;
Ok(base_config)
}
pub fn load_profile(profile_name: String) -> Result<Self, LoadError> {
Self::load(Some(profile_name))
}
pub async fn load_with_login(
cancel_token: CancellationToken,
profile_name: Option<String>,
) -> Result<Self, LoadError> {
Self::load_with_login_with_redirect(cancel_token, profile_name, RedirectBinding::default())
.await
}
pub(crate) async fn load_with_login_with_redirect(
cancel_token: CancellationToken,
profile_name: Option<String>,
redirect: RedirectBinding,
) -> Result<Self, LoadError> {
let ConfigurationContext {
mut builder,
auth_server,
credential,
source,
credentials_name,
} = ConfigurationContext::from_profile(profile_name)?;
match credential {
credential @ Some(
Credential::ExternallyManaged(_) | Credential::ClientCredentials(_),
) => {
let oauth_session = credential_to_oauth_session(credential, auth_server);
return Ok(builder.oauth_session(oauth_session).build()?);
}
Some(Credential::TokenPayload(TokenPayload {
access_token,
refresh_token,
..
})) => {
if let Some(access_token) = access_token
&& insecure_validate_token_exp(&access_token).is_ok()
{
let refresh_token = refresh_token.clone().unwrap_or_default();
let oauth_session = OAuthSession::new(
OAuthGrant::RefreshToken(RefreshToken::new(refresh_token)),
auth_server,
Some(access_token),
);
return Ok(builder.oauth_session(Some(oauth_session)).build()?);
}
if let Some(refresh_token) = refresh_token
&& !refresh_token.is_empty()
{
let mut refresh_token = RefreshToken::new(refresh_token);
if let Ok(access_token) = refresh_token.request_access_token(&auth_server).await
{
let oauth_session = OAuthSession::new(
OAuthGrant::RefreshToken(refresh_token),
auth_server,
Some(access_token),
);
persist_or_warn(&oauth_session, &source, &credentials_name).await;
return Ok(builder.oauth_session(Some(oauth_session)).build()?);
}
}
#[cfg(feature = "tracing")]
tracing::debug!("Stored credentials are invalid, falling back to login flow");
}
None => {
#[cfg(feature = "tracing")]
tracing::debug!("No stored credentials found, falling back to login flow",);
}
}
let login_tokens =
AuthTokens::interactive_login_with_redirect(cancel_token, &auth_server, redirect)
.await?;
let access_token = login_tokens.access_token.clone();
let oauth_session =
OAuthSession::from_interactive_login(login_tokens, auth_server, Some(access_token));
persist_or_warn(&oauth_session, &source, &credentials_name).await;
Ok(builder.oauth_session(Some(oauth_session)).build()?)
}
fn load(profile_name: Option<String>) -> Result<Self, LoadError> {
let ConfigurationContext {
mut builder,
auth_server,
credential,
..
} = ConfigurationContext::from_profile(profile_name)?;
let oauth_session = credential_to_oauth_session(credential, auth_server);
Ok(builder.oauth_session(oauth_session).build()?)
}
#[must_use]
pub fn builder() -> ClientConfigurationBuilder {
ClientConfigurationBuilder::default()
}
#[must_use]
pub fn profile(&self) -> &str {
&self.profile
}
#[must_use]
pub fn credentials_name(&self) -> &str {
&self.credentials_name
}
#[must_use]
pub fn api_url(&self) -> &str {
&self.api_url
}
#[must_use]
pub fn grpc_api_url(&self) -> &str {
&self.grpc_api_url
}
#[must_use]
pub fn quilc_url(&self) -> &str {
&self.quilc_url
}
#[must_use]
pub fn qvm_url(&self) -> &str {
&self.qvm_url
}
#[cfg(feature = "tracing-config")]
#[must_use]
pub const fn tracing_configuration(&self) -> Option<&TracingConfiguration> {
self.tracing_configuration.as_ref()
}
#[must_use]
pub const fn source(&self) -> &ConfigSource {
&self.source
}
pub async fn oauth_session(&self) -> Result<OAuthSession, TokenError> {
Ok(self
.oauth_session
.as_ref()
.ok_or(TokenError::NoRefreshToken)?
.tokens()
.await)
}
pub async fn get_bearer_access_token(&self) -> Result<SecretAccessToken, TokenError> {
let dispatcher = self
.oauth_session
.as_ref()
.ok_or_else(|| TokenError::NoCredentials)?;
match dispatcher.validate().await {
Ok(tokens) => Ok(tokens),
#[allow(unused_variables)]
Err(e) => {
#[cfg(feature = "tracing-config")]
tracing::debug!("Refreshing access token because current one is invalid: {e}");
dispatcher
.refresh(self.source(), self.credentials_name())
.await
.map(|e| e.access_token().cloned())?
}
}
}
pub async fn refresh(&self) -> Result<OAuthSession, TokenError> {
self.oauth_session
.as_ref()
.ok_or(TokenError::NoRefreshToken)?
.refresh(self.source(), self.credentials_name())
.await
}
}
#[derive(Clone, Debug)]
pub enum ConfigSource {
Builder,
File {
settings_path: PathBuf,
secrets_path: PathBuf,
},
Default,
}
fn expand_path_from_env_or_default(
env_var_name: &str,
default: &str,
) -> Result<PathBuf, LoadError> {
match env::var(env_var_name) {
Ok(path) => {
let expanded_path = shellexpand::env(&path).map_err(LoadError::from)?;
let path_buf: PathBuf = expanded_path.as_ref().into();
if !path_buf.exists() {
return Err(LoadError::Path {
path: path_buf,
message: format!("The given path does not exist: {path}"),
});
}
Ok(path_buf)
}
Err(env::VarError::NotPresent) => {
let expanded_path = shellexpand::tilde_with_context(default, || {
env::home_dir().map(|path| path.display().to_string())
});
let path_buf: PathBuf = expanded_path.as_ref().into();
if !path_buf.exists() {
return Err(LoadError::Path {
path: path_buf,
message: format!(
"Could not find a QCS configuration at the default path: {default}"
),
});
}
Ok(path_buf)
}
Err(other_error) => Err(LoadError::EnvVar {
variable_name: env_var_name.to_string(),
message: other_error.to_string(),
}),
}
}
#[cfg(test)]
mod test {
#![allow(clippy::result_large_err, reason = "happens in figment tests")]
use httpmock::prelude::*;
use jsonwebtoken::{EncodingKey, Header, encode};
use serde::Serialize;
use time::{Duration, OffsetDateTime};
use tokio_util::sync::CancellationToken;
use crate::configuration::{
API_URL_VAR, AuthServer, ClientConfiguration, DEFAULT_QUILC_URL, GRPC_API_URL_VAR,
OAuthGrant, OAuthSession, QUILC_URL_VAR, QVM_URL_VAR, RefreshToken,
expand_path_from_env_or_default, oidc,
pkce::{RedirectBinding, tests::PkceTestServerHarness},
secrets::{
Credential, SECRETS_PATH_VAR, SECRETS_READ_ONLY_VAR, SecretAccessToken,
SecretRefreshToken, Secrets, TokenPayload,
},
settings::{SETTINGS_PATH_VAR, Settings},
tokens::{ClientCredentialsResponse, RefreshTokenResponse, TokenRefresher},
};
use super::{settings::QCS_DEFAULT_AUTH_ISSUER_PRODUCTION, tokens::ClientCredentials};
fn expect_token_payload(credential: Credential) -> TokenPayload {
match credential {
Credential::TokenPayload(payload) => payload,
Credential::ExternallyManaged(_) | Credential::ClientCredentials(_) => {
panic!("expected a token payload credential")
}
}
}
#[test]
fn expands_env_var() {
figment::Jail::expect_with(|jail| {
let dir = jail.create_dir("~/blah/blah/")?;
jail.create_file(dir.join("file.toml"), "")?;
jail.set_env("SOME_PATH", "blah/blah");
jail.set_env("SOME_VAR", "~/$SOME_PATH/file.toml");
let secrets_path = expand_path_from_env_or_default("SOME_VAR", "default").unwrap();
assert_eq!(secrets_path.to_str().unwrap(), "~/blah/blah/file.toml");
Ok(())
});
}
#[test]
fn uses_env_var_overrides() {
figment::Jail::expect_with(|jail| {
let quilc_url = "tcp://quilc:5555";
let qvm_url = "http://qvm:5000";
let grpc_url = "http://grpc:80";
let api_url = "http://api:80";
jail.set_env(QUILC_URL_VAR, quilc_url);
jail.set_env(QVM_URL_VAR, qvm_url);
jail.set_env(API_URL_VAR, api_url);
jail.set_env(GRPC_API_URL_VAR, grpc_url);
let config = ClientConfiguration::new(
Settings::default(),
Secrets::default(),
Some("default".to_string()),
)
.expect("Should be able to build default config.");
assert_eq!(config.quilc_url, quilc_url);
assert_eq!(config.qvm_url, qvm_url);
assert_eq!(config.grpc_api_url, grpc_url);
Ok(())
});
}
#[tokio::test]
async fn test_default_uses_env_var_overrides() {
figment::Jail::expect_with(|jail| {
let quilc_url = "quilc_url";
let qvm_url = "qvm_url";
let grpc_url = "grpc_url";
let api_url = "api_url";
jail.set_env(QUILC_URL_VAR, quilc_url);
jail.set_env(QVM_URL_VAR, qvm_url);
jail.set_env(GRPC_API_URL_VAR, grpc_url);
jail.set_env(API_URL_VAR, api_url);
let config = ClientConfiguration::load_default().unwrap();
assert_eq!(config.quilc_url, quilc_url);
assert_eq!(config.qvm_url, qvm_url);
assert_eq!(config.grpc_api_url, grpc_url);
assert_eq!(config.api_url, api_url);
Ok(())
});
}
#[test]
fn test_default_loads_settings_with_partial_profile_applications() {
figment::Jail::expect_with(|jail| {
let directory = jail.directory();
let settings_file_name = "settings.toml";
let settings_file_path = directory.join(settings_file_name);
let quilc_url_env_var = "env-var://quilc.url/after";
let settings_file_contents = r#"
default_profile_name = "default"
[profiles]
[profiles.default]
api_url = ""
auth_server_name = "default"
credentials_name = "default"
applications = {}
[auth_servers]
[auth_servers.default]
client_id = ""
issuer = ""
"#;
jail.create_file(settings_file_name, settings_file_contents)
.expect("should create test settings.toml");
jail.set_env(
"QCS_SETTINGS_FILE_PATH",
settings_file_path
.to_str()
.expect("settings file path should be a string"),
);
let config = ClientConfiguration::load_default().unwrap();
assert_eq!(config.quilc_url, DEFAULT_QUILC_URL);
jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
let config = ClientConfiguration::load_default().unwrap();
assert_eq!(config.quilc_url, quilc_url_env_var);
Ok(())
});
}
#[test]
fn test_default_loads_settings_with_partial_profile_applications_pyquil() {
figment::Jail::expect_with(|jail| {
let directory = jail.directory();
let settings_file_name = "settings.toml";
let settings_file_path = directory.join(settings_file_name);
let quilc_url_settings_toml = "settings-toml://quilc.url";
let quilc_url_env_var = "env-var://quilc.url/after";
let settings_file_contents = format!(
r#"
default_profile_name = "default"
[profiles]
[profiles.default]
api_url = ""
auth_server_name = "default"
credentials_name = "default"
applications.pyquil.quilc_url = "{quilc_url_settings_toml}"
[auth_servers]
[auth_servers.default]
client_id = ""
issuer = ""
"#
);
jail.create_file(settings_file_name, &settings_file_contents)
.expect("should create test settings.toml");
jail.set_env(
"QCS_SETTINGS_FILE_PATH",
settings_file_path
.to_str()
.expect("settings file path should be a string"),
);
let config = ClientConfiguration::load_default().unwrap();
assert_eq!(config.quilc_url, quilc_url_settings_toml);
jail.set_env("QCS_SETTINGS_APPLICATIONS_QUILC_URL", quilc_url_env_var);
let config = ClientConfiguration::load_default().unwrap();
assert_eq!(config.quilc_url, quilc_url_env_var);
Ok(())
});
}
#[tokio::test]
async fn test_hydrate_access_token_on_load() {
let mut config = ClientConfiguration::builder().build().unwrap();
let access_token = "test_access_token";
figment::Jail::expect_with(|jail| {
let directory = jail.directory();
let settings_file_name = "settings.toml";
let settings_file_path = directory.join(settings_file_name);
let secrets_file_name = "secrets.toml";
let secrets_file_path = directory.join(secrets_file_name);
let settings_file_contents = r#"
default_profile_name = "default"
[profiles]
[profiles.default]
api_url = ""
auth_server_name = "default"
credentials_name = "default"
[auth_servers]
[auth_servers.default]
client_id = ""
issuer = ""
"#;
let secrets_file_contents = format!(
r#"
[credentials]
[credentials.default]
[credentials.default.token_payload]
access_token = "{access_token}"
expires_in = 3600
id_token = "id_token"
refresh_token = "refresh_token"
scope = "offline_access openid profile email"
token_type = "Bearer"
"#
);
jail.create_file(settings_file_name, settings_file_contents)
.expect("should create test settings.toml");
jail.create_file(secrets_file_name, &secrets_file_contents)
.expect("should create test settings.toml");
jail.set_env(
"QCS_SETTINGS_FILE_PATH",
settings_file_path
.to_str()
.expect("settings file path should be a string"),
);
jail.set_env(
"QCS_SECRETS_FILE_PATH",
secrets_file_path
.to_str()
.expect("secrets file path should be a string"),
);
config = ClientConfiguration::load_default().unwrap();
Ok(())
});
assert_eq!(
config.get_access_token().await.unwrap().unwrap(),
SecretAccessToken::from(access_token)
);
}
#[derive(Clone, Debug, Serialize)]
struct Claims {
exp: i64,
iss: String,
sub: String,
}
impl Default for Claims {
fn default() -> Self {
Self {
exp: 0,
iss: QCS_DEFAULT_AUTH_ISSUER_PRODUCTION.to_string(),
sub: "qcs@rigetti.com".to_string(),
}
}
}
impl Claims {
fn new_valid() -> Self {
Self {
exp: (OffsetDateTime::now_utc() + Duration::seconds(100)).unix_timestamp(),
..Self::default()
}
}
fn new_expired() -> Self {
Self {
exp: (OffsetDateTime::now_utc() - Duration::seconds(100)).unix_timestamp(),
..Self::default()
}
}
fn to_encoded(&self) -> String {
encode(&Header::default(), &self, &EncodingKey::from_secret(&[])).unwrap()
}
fn to_access_token(&self) -> SecretAccessToken {
SecretAccessToken::from(self.to_encoded())
}
}
#[test]
fn test_valid_token() {
let valid_token = Claims::new_valid().to_access_token();
let tokens = OAuthSession::from_refresh_token(
RefreshToken::new(SecretRefreshToken::from("unused")),
AuthServer::default(),
Some(valid_token.clone()),
);
assert_eq!(
tokens
.validate()
.expect("Token should not fail validation."),
valid_token
);
}
#[test]
fn test_expired_token() {
let invalid_token = Claims::new_expired().to_access_token();
let tokens = OAuthSession::from_refresh_token(
RefreshToken::new(SecretRefreshToken::from("unused")),
AuthServer::default(),
Some(invalid_token),
);
assert!(tokens.validate().is_err());
}
#[test]
fn test_client_credentials_without_access_token() {
let tokens = OAuthSession::from_client_credentials(
ClientCredentials::new("client_id", "client_secret"),
AuthServer::default(),
None,
);
assert!(tokens.validate().is_err());
}
#[tokio::test]
async fn test_session_is_present_with_empty_refresh_token_and_valid_access_token() {
let access_token = Claims::new_valid().to_encoded();
let mut config = ClientConfiguration::builder().build().unwrap();
figment::Jail::expect_with(|jail| {
let directory = jail.directory();
let settings_file_name = "settings.toml";
let settings_file_path = directory.join(settings_file_name);
let secrets_file_name = "secrets.toml";
let secrets_file_path = directory.join(secrets_file_name);
let settings_file_contents = r#"
default_profile_name = "default"
[profiles]
[profiles.default]
api_url = ""
auth_server_name = "default"
credentials_name = "default"
[auth_servers]
[auth_servers.default]
client_id = ""
issuer = ""
"#;
let secrets_file_contents = format!(
r#"
[credentials]
[credentials.default]
[credentials.default.token_payload]
access_token = "{access_token}"
expires_in = 3600
id_token = "id_token"
scope = "offline_access openid profile email"
token_type = "Bearer"
"#
);
jail.create_file(settings_file_name, settings_file_contents)
.expect("should create test settings.toml");
jail.create_file(secrets_file_name, &secrets_file_contents)
.expect("should create test secrets.toml");
jail.set_env(
"QCS_SETTINGS_FILE_PATH",
settings_file_path
.to_str()
.expect("settings file path should be a string"),
);
jail.set_env(
"QCS_SECRETS_FILE_PATH",
secrets_file_path
.to_str()
.expect("secrets file path should be a string"),
);
config = ClientConfiguration::load_default().unwrap();
Ok(())
});
assert_eq!(
config.get_bearer_access_token().await.unwrap(),
SecretAccessToken::from(access_token)
);
}
#[test]
fn test_pkce_flow_persists_token() {
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
let PkceTestServerHarness {
server,
client,
discovery: _,
redirect_listener,
} = runtime.block_on(PkceTestServerHarness::new());
let client_id = client.client_id;
let issuer = server.issuer().to_string();
figment::Jail::expect_with(move |jail| {
jail.set_env(SECRETS_READ_ONLY_VAR, "false");
let directory = jail.directory();
let settings_file_name = "settings.toml";
let settings_file_path = directory.join(settings_file_name);
let secrets_file_name = "secrets.toml";
let secrets_file_path = directory.join(secrets_file_name);
let settings_file_contents = format!(
r#"
default_profile_name = "default"
[profiles]
[profiles.default]
api_url = ""
auth_server_name = "default"
credentials_name = "default"
[auth_servers]
[auth_servers.default]
client_id = "{client_id}"
issuer = "{issuer}"
"#
);
let secrets_file_contents = r#"
[credentials]
[credentials.default]
[credentials.default.token_payload]
access_token = ""
"#;
jail.create_file(settings_file_name, &settings_file_contents)
.expect("should create test settings.toml");
jail.set_env(
SETTINGS_PATH_VAR,
settings_file_path
.to_str()
.expect("settings file path should be a string"),
);
jail.create_file(secrets_file_name, secrets_file_contents)
.expect("should create test secrets.toml");
jail.set_env(
SECRETS_PATH_VAR,
secrets_file_path
.to_str()
.expect("secrets file path should be a string"),
);
runtime.block_on(async {
let cancel_token = CancellationToken::new();
let configuration = ClientConfiguration::load_with_login_with_redirect(
cancel_token,
None,
RedirectBinding::Bound(redirect_listener),
)
.await
.expect("should load configuration");
let oauth_session = configuration.refresh().await.expect("should refresh");
let token = oauth_session.validate().expect("token should be valid");
let configuration =
ClientConfiguration::load_default().expect("should load configuration");
let oauth_session = configuration
.oauth_session()
.await
.expect("should get oauth session");
let token_payload = expect_token_payload(
Secrets::load_from_path(&secrets_file_path)
.expect("should load secrets")
.credentials
.remove("default")
.expect("should get default credentials"),
);
assert_eq!(
token,
oauth_session.validate().expect("should contain token"),
"session: {oauth_session:?}, token_payload: {token_payload:?}",
);
assert_eq!(
token_payload.access_token,
Some(token),
"session: {oauth_session:?}, token_payload: {token_payload:?}"
);
assert_ne!(
token_payload.refresh_token, None,
"session: {oauth_session:?}, token_payload: {token_payload:?}"
);
});
Ok(())
});
drop(server);
}
#[test]
fn test_load_with_login_persists_login_flow_token_without_explicit_refresh() {
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
let PkceTestServerHarness {
server,
client,
discovery: _,
redirect_listener,
} = runtime.block_on(PkceTestServerHarness::new());
let client_id = client.client_id;
let issuer = server.issuer().to_string();
figment::Jail::expect_with(move |jail| {
jail.set_env(SECRETS_READ_ONLY_VAR, "false");
let directory = jail.directory();
let settings_file_name = "settings.toml";
let settings_file_path = directory.join(settings_file_name);
let secrets_file_name = "secrets.toml";
let secrets_file_path = directory.join(secrets_file_name);
let settings_file_contents = format!(
r#"
default_profile_name = "default"
[profiles]
[profiles.default]
api_url = ""
auth_server_name = "default"
credentials_name = "default"
[auth_servers]
[auth_servers.default]
client_id = "{client_id}"
issuer = "{issuer}"
"#
);
let secrets_file_contents = r#"
[credentials]
[credentials.default]
[credentials.default.token_payload]
access_token = ""
"#;
jail.create_file(settings_file_name, &settings_file_contents)
.expect("should create test settings.toml");
jail.set_env(
SETTINGS_PATH_VAR,
settings_file_path
.to_str()
.expect("settings file path should be a string"),
);
jail.create_file(secrets_file_name, secrets_file_contents)
.expect("should create test secrets.toml");
jail.set_env(
SECRETS_PATH_VAR,
secrets_file_path
.to_str()
.expect("secrets file path should be a string"),
);
runtime.block_on(async {
let cancel_token = CancellationToken::new();
let configuration = ClientConfiguration::load_with_login_with_redirect(
cancel_token,
None,
RedirectBinding::Bound(redirect_listener),
)
.await
.expect("should perform a login flow");
let oauth_session = configuration
.oauth_session()
.await
.expect("should get oauth session");
let token = oauth_session.validate().expect("token should be valid");
let token_payload = expect_token_payload(
Secrets::load_from_path(&secrets_file_path)
.expect("should load secrets")
.credentials
.remove("default")
.expect("should get default credentials"),
);
assert_eq!(
token_payload.access_token,
Some(token),
"the access token from the login flow should be persisted without an \
explicit follow-up refresh"
);
assert!(
token_payload.refresh_token.is_some(),
"the refresh token from the login flow should be persisted without an \
explicit follow-up refresh"
);
});
Ok(())
});
drop(server);
}
#[test]
fn test_load_with_login_persists_rotated_refresh_token_on_refresh() {
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
let mock_server = runtime.block_on(MockServer::start_async());
let new_access_token = Claims::new_valid().to_encoded();
let rotated_refresh_token = "rotated_refresh_token".to_string();
let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
when.method(GET).path("/.well-known/openid-configuration");
then.status(200)
.json_body_obj(&oidc::Discovery::new_for_test(
mock_server.base_url().parse().unwrap(),
));
}));
let issuer_mock = runtime.block_on(mock_server.mock_async(|when, then| {
when.method(POST).path("/v1/token");
then.status(200).json_body_obj(&RefreshTokenResponse {
access_token: SecretAccessToken::from(new_access_token.clone()),
refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
});
}));
let client_id = "client_id";
let issuer = mock_server.base_url();
let initial_refresh_token = "initial_refresh_token";
let expired_access_token = Claims::new_expired().to_encoded();
figment::Jail::expect_with(|jail| {
jail.set_env(SECRETS_READ_ONLY_VAR, "false");
let directory = jail.directory();
let settings_file_name = "settings.toml";
let settings_file_path = directory.join(settings_file_name);
let secrets_file_name = "secrets.toml";
let secrets_file_path = directory.join(secrets_file_name);
let settings_file_contents = format!(
r#"
default_profile_name = "default"
[profiles]
[profiles.default]
api_url = ""
auth_server_name = "default"
credentials_name = "default"
[auth_servers]
[auth_servers.default]
client_id = "{client_id}"
issuer = "{issuer}"
"#
);
let secrets_file_contents = format!(
r#"
[credentials]
[credentials.default]
[credentials.default.token_payload]
access_token = "{expired_access_token}"
refresh_token = "{initial_refresh_token}"
"#
);
jail.create_file(settings_file_name, &settings_file_contents)
.expect("should create test settings.toml");
jail.set_env(
SETTINGS_PATH_VAR,
settings_file_path
.to_str()
.expect("settings file path should be a string"),
);
jail.create_file(secrets_file_name, &secrets_file_contents)
.expect("should create test secrets.toml");
jail.set_env(
SECRETS_PATH_VAR,
secrets_file_path
.to_str()
.expect("secrets file path should be a string"),
);
runtime.block_on(async {
let cancel_token = CancellationToken::new();
let configuration = ClientConfiguration::load_with_login(cancel_token, None)
.await
.expect("should refresh using the stored refresh token");
oidc_mock.assert_async().await;
issuer_mock.assert_async().await;
let oauth_session = configuration
.oauth_session()
.await
.expect("should get oauth session");
assert_eq!(
oauth_session.access_token().cloned().ok(),
Some(SecretAccessToken::from(new_access_token.clone())),
"in-memory access token should be the freshly refreshed one"
);
match oauth_session.payload() {
OAuthGrant::RefreshToken(payload) => {
assert_eq!(
payload.refresh_token,
SecretRefreshToken::from(rotated_refresh_token.clone()),
"in-memory refresh token should be updated to the rotated value"
);
}
other => panic!("expected a RefreshToken grant, got {other:?}"),
}
let token_payload = expect_token_payload(
Secrets::load_from_path(&secrets_file_path)
.expect("should load secrets")
.credentials
.remove("default")
.expect("should get default credentials"),
);
assert_eq!(
token_payload.access_token,
Some(SecretAccessToken::from(new_access_token.clone())),
"new access token should be persisted to the secrets file"
);
assert_eq!(
token_payload.refresh_token,
Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
"rotated refresh token should be persisted to the secrets file, otherwise \
the next process to load this profile will retry the stale, \
already-consumed refresh token and be forced back into a login flow"
);
});
Ok(())
});
}
#[test]
fn test_refresh_persists_to_credentials_name_not_profile_name() {
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
let mock_server = runtime.block_on(MockServer::start_async());
let new_access_token = Claims::new_valid().to_encoded();
let rotated_refresh_token = "rotated_refresh_token".to_string();
let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
when.method(GET).path("/.well-known/openid-configuration");
then.status(200)
.json_body_obj(&oidc::Discovery::new_for_test(
mock_server.base_url().parse().unwrap(),
));
}));
let issuer_mock = runtime.block_on(mock_server.mock_async(|when, then| {
when.method(POST).path("/v1/token");
then.status(200).json_body_obj(&RefreshTokenResponse {
access_token: SecretAccessToken::from(new_access_token.clone()),
refresh_token: Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
});
}));
let client_id = "client_id";
let issuer = mock_server.base_url();
let initial_refresh_token = "initial_refresh_token";
let expired_access_token = Claims::new_expired().to_encoded();
let profile_name = "funnel";
let credentials_name = "shared";
figment::Jail::expect_with(|jail| {
jail.set_env(SECRETS_READ_ONLY_VAR, "false");
let directory = jail.directory();
let settings_file_name = "settings.toml";
let settings_file_path = directory.join(settings_file_name);
let secrets_file_name = "secrets.toml";
let secrets_file_path = directory.join(secrets_file_name);
let settings_file_contents = format!(
r#"
default_profile_name = "{profile_name}"
[profiles]
[profiles.{profile_name}]
api_url = ""
auth_server_name = "default"
credentials_name = "{credentials_name}"
[auth_servers]
[auth_servers.default]
client_id = "{client_id}"
issuer = "{issuer}"
"#
);
let secrets_file_contents = format!(
r#"
[credentials]
[credentials.{credentials_name}]
[credentials.{credentials_name}.token_payload]
access_token = "{expired_access_token}"
refresh_token = "{initial_refresh_token}"
[credentials.{profile_name}]
[credentials.{profile_name}.token_payload]
access_token = "decoy_access_token"
refresh_token = "decoy_refresh_token"
"#
);
jail.create_file(settings_file_name, &settings_file_contents)
.expect("should create test settings.toml");
jail.set_env(
SETTINGS_PATH_VAR,
settings_file_path
.to_str()
.expect("settings file path should be a string"),
);
jail.create_file(secrets_file_name, &secrets_file_contents)
.expect("should create test secrets.toml");
jail.set_env(
SECRETS_PATH_VAR,
secrets_file_path
.to_str()
.expect("secrets file path should be a string"),
);
runtime.block_on(async {
let configuration = ClientConfiguration::load_profile(profile_name.to_string())
.expect("should load the profile");
assert_eq!(configuration.profile(), profile_name);
assert_eq!(configuration.credentials_name(), credentials_name);
let access_token = configuration
.get_bearer_access_token()
.await
.expect("should refresh the expired access token");
oidc_mock.assert_async().await;
issuer_mock.assert_async().await;
assert_eq!(
access_token,
SecretAccessToken::from(new_access_token.clone())
);
let mut credentials = Secrets::load_from_path(&secrets_file_path)
.expect("should load secrets")
.credentials;
let token_payload = expect_token_payload(
credentials
.remove(credentials_name)
.expect("should get the credential the profile points at"),
);
assert_eq!(
token_payload.access_token,
Some(SecretAccessToken::from(new_access_token.clone())),
"the refreshed access token should be persisted under `credentials_name`, \
which is where the next load reads it from"
);
assert_eq!(
token_payload.refresh_token,
Some(SecretRefreshToken::from(rotated_refresh_token.clone())),
"the rotated refresh token should be persisted under `credentials_name`"
);
let decoy_payload = expect_token_payload(
credentials
.remove(profile_name)
.expect("decoy credential should still exist"),
);
assert_eq!(
decoy_payload.access_token,
Some(SecretAccessToken::from("decoy_access_token".to_string())),
"the credential named after the profile is not the one in use and \
should be left untouched"
);
});
Ok(())
});
}
fn externally_managed_config_files(access_token: &str) -> (&'static str, String) {
let settings = r#"
default_profile_name = "default"
[profiles]
[profiles.default]
api_url = ""
auth_server_name = "default"
credentials_name = "default"
[auth_servers]
[auth_servers.default]
client_id = ""
issuer = ""
"#;
let (program, flag) = super::external_command::shell();
let args = format!(r#"["{flag}", "echo {access_token}"]"#);
let secrets = format!(
r"
[credentials]
[credentials.default]
[credentials.default.externally_managed]
command = {program:?}
args = {args}
"
);
(settings, secrets)
}
fn jail_config_files(
jail: &mut figment::Jail,
settings: &str,
secrets: &str,
) -> std::path::PathBuf {
let directory = jail.directory().to_path_buf();
jail.create_file("settings.toml", settings)
.expect("should create test settings.toml");
jail.create_file("secrets.toml", secrets)
.expect("should create test secrets.toml");
jail.set_env(
SETTINGS_PATH_VAR,
directory
.join("settings.toml")
.to_str()
.expect("settings path should be a string"),
);
let secrets_path = directory.join("secrets.toml");
jail.set_env(
SECRETS_PATH_VAR,
secrets_path
.to_str()
.expect("secrets path should be a string"),
);
secrets_path
}
#[test]
fn test_externally_managed_credential_runs_the_configured_program() {
let access_token = Claims::new_valid().to_encoded();
let (settings, secrets) = externally_managed_config_files(&access_token);
figment::Jail::expect_with(|jail| {
jail_config_files(jail, settings, &secrets);
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
runtime.block_on(async {
let config = ClientConfiguration::load_default().expect("should load config");
assert_eq!(
config
.get_bearer_access_token()
.await
.expect("should get an access token from the external program"),
SecretAccessToken::from(access_token.clone())
);
});
Ok(())
});
}
#[test]
fn test_externally_managed_credential_is_never_persisted() {
let access_token = Claims::new_valid().to_encoded();
let (settings, secrets) = externally_managed_config_files(&access_token);
figment::Jail::expect_with(|jail| {
jail.set_env(SECRETS_READ_ONLY_VAR, "false");
let secrets_path = jail_config_files(jail, settings, &secrets);
let before =
std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
runtime.block_on(async {
let config = ClientConfiguration::load_default().expect("should load config");
config
.refresh()
.await
.expect("should refresh via the external program");
});
let after =
std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
assert_eq!(
before, after,
"an externally managed credential's tokens should never be written to disk"
);
Ok(())
});
}
#[test]
fn test_externally_managed_credential_skips_the_login_flow() {
let access_token = Claims::new_valid().to_encoded();
let (settings, secrets) = externally_managed_config_files(&access_token);
figment::Jail::expect_with(|jail| {
jail_config_files(jail, settings, &secrets);
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
runtime.block_on(async {
let config = ClientConfiguration::load_with_login(CancellationToken::new(), None)
.await
.expect("should load without an interactive login");
assert_eq!(
config
.get_bearer_access_token()
.await
.expect("should get an access token from the external program"),
SecretAccessToken::from(access_token.clone())
);
});
Ok(())
});
}
#[cfg(unix)]
#[test]
fn test_externally_managed_credential_reuses_a_valid_token() {
let access_token = Claims::new_valid().to_encoded();
let (settings, _) = externally_managed_config_files(&access_token);
figment::Jail::expect_with(|jail| {
let counter = jail.directory().join("invocations");
let secrets = format!(
r#"
[credentials]
[credentials.default]
[credentials.default.externally_managed]
command = "/bin/sh"
args = ["-c", "echo run >> {counter} && printf '%s' '{access_token}'"]
"#,
counter = counter.display(),
);
jail_config_files(jail, settings, &secrets);
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
runtime.block_on(async {
let config = ClientConfiguration::load_default().expect("should load config");
for _ in 0..5_u8 {
config
.get_bearer_access_token()
.await
.expect("should get an access token");
}
});
let invocations = std::fs::read_to_string(&counter)
.expect("the program should have run at least once")
.lines()
.count();
assert_eq!(
invocations, 1,
"a valid token should be reused across requests, not re-fetched"
);
Ok(())
});
}
#[cfg(unix)]
#[test]
fn test_externally_managed_credential_does_not_loop_on_invalid_tokens() {
let (settings, _) = externally_managed_config_files("unused");
let expired = Claims::new_expired().to_encoded();
for (label, token) in [("expired", expired.as_str()), ("opaque", "not-a-jwt")] {
figment::Jail::expect_with(|jail| {
let counter = jail.directory().join("invocations");
let secrets = format!(
r#"
[credentials]
[credentials.default]
[credentials.default.externally_managed]
command = "/bin/sh"
args = ["-c", "echo run >> {counter} && printf '%s' '{token}'"]
"#,
counter = counter.display(),
);
jail_config_files(jail, settings, &secrets);
let requests = 3_usize;
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
runtime.block_on(async {
let config = ClientConfiguration::load_default().expect("should load config");
tokio::time::timeout(std::time::Duration::from_secs(30), async {
for _ in 0..requests {
assert_eq!(
config
.get_bearer_access_token()
.await
.expect(
"an unvalidatable token is still returned to the caller"
)
.secret(),
token,
"the {label} token from the program should be returned as-is"
);
}
})
.await
.expect("requesting a token should not loop on the external program");
});
let invocations = std::fs::read_to_string(&counter)
.expect("the program should have run at least once")
.lines()
.count();
assert_eq!(
invocations, requests,
"a program returning {label} tokens should run once per request, not \
repeatedly until it produces a valid one"
);
Ok(())
});
}
}
#[test]
fn test_externally_managed_credential_takes_precedence_over_stored_tokens() {
let access_token = Claims::new_valid().to_encoded();
let (settings, secrets) = externally_managed_config_files(&access_token);
let secrets = format!(
"{secrets}\n[credentials.default.token_payload]\naccess_token = \"{}\"\n",
Claims::new_valid().to_encoded()
);
figment::Jail::expect_with(|jail| {
jail_config_files(jail, settings, &secrets);
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
runtime.block_on(async {
let config = ClientConfiguration::load_default().expect("should load config");
assert_eq!(
config
.get_bearer_access_token()
.await
.expect("should get an access token from the external program"),
SecretAccessToken::from(access_token.clone()),
"the external program should win over the stored access token"
);
});
Ok(())
});
}
const TEST_CLIENT_ID: &str = "a-client-id";
const TEST_CLIENT_SECRET: &str = "a-client-secret";
fn expected_basic_auth(client_id: &str, client_secret: &str) -> String {
use base64::Engine as _;
let encoded = base64::engine::general_purpose::STANDARD
.encode(format!("{client_id}:{client_secret}"));
format!("Basic {encoded}")
}
fn client_credentials_fixture_files(issuer: &str) -> (String, String) {
let settings = format!(
r#"
default_profile_name = "default"
[profiles]
[profiles.default]
api_url = ""
auth_server_name = "default"
credentials_name = "default"
[auth_servers]
[auth_servers.default]
client_id = "ignored"
issuer = "{issuer}"
"#
);
let secrets = format!(
r#"
[credentials]
[credentials.default.client_credentials]
client_id = "{TEST_CLIENT_ID}"
client_secret = "{TEST_CLIENT_SECRET}"
"#
);
(settings, secrets)
}
fn mock_client_credentials_issuer<'server>(
runtime: &tokio::runtime::Runtime,
mock_server: &'server MockServer,
access_token: &str,
) -> (httpmock::Mock<'server>, httpmock::Mock<'server>) {
let oidc_mock = runtime.block_on(mock_server.mock_async(|when, then| {
when.method(GET).path("/.well-known/openid-configuration");
then.status(200)
.json_body_obj(&oidc::Discovery::new_for_test(
mock_server.base_url().parse().unwrap(),
));
}));
let authorization = expected_basic_auth(TEST_CLIENT_ID, TEST_CLIENT_SECRET);
let access_token = access_token.to_string();
let token_mock = runtime.block_on(mock_server.mock_async(|when, then| {
when.method(POST)
.path("/v1/token")
.header("authorization", authorization)
.body_includes("grant_type=client_credentials");
then.status(200).json_body_obj(&ClientCredentialsResponse {
access_token: SecretAccessToken::from(access_token),
});
}));
(oidc_mock, token_mock)
}
#[test]
fn test_client_credentials_credential_authenticates_as_its_own_client_id() {
let access_token = Claims::new_valid().to_encoded();
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
let mock_server = runtime.block_on(MockServer::start_async());
let (oidc_mock, token_mock) =
mock_client_credentials_issuer(&runtime, &mock_server, &access_token);
let (settings, secrets) = client_credentials_fixture_files(&mock_server.base_url());
figment::Jail::expect_with(|jail| {
jail_config_files(jail, &settings, &secrets);
runtime.block_on(async {
let config = ClientConfiguration::load_default().expect("should load config");
assert_eq!(
config
.get_bearer_access_token()
.await
.expect("should exchange the client secret for an access token"),
SecretAccessToken::from(access_token.clone()),
);
});
oidc_mock.assert();
token_mock.assert();
Ok(())
});
}
#[test]
fn test_client_credentials_credential_is_never_persisted() {
let access_token = Claims::new_valid().to_encoded();
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
let mock_server = runtime.block_on(MockServer::start_async());
let (_oidc_mock, _token_mock) =
mock_client_credentials_issuer(&runtime, &mock_server, &access_token);
let (settings, secrets) = client_credentials_fixture_files(&mock_server.base_url());
figment::Jail::expect_with(|jail| {
jail.set_env(SECRETS_READ_ONLY_VAR, "false");
let secrets_path = jail_config_files(jail, &settings, &secrets);
let before =
std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
runtime.block_on(async {
let config = ClientConfiguration::load_default().expect("should load config");
config
.refresh()
.await
.expect("should exchange the client secret for an access token");
});
let after =
std::fs::read_to_string(&secrets_path).expect("should read the secrets file");
assert_eq!(
before, after,
"a client credentials grant has nothing to persist: no refresh token, and an \
access token the secret can mint again at any time"
);
Ok(())
});
}
#[test]
fn test_client_credentials_credential_skips_the_login_flow() {
let access_token = Claims::new_valid().to_encoded();
let runtime = tokio::runtime::Runtime::new().expect("should create runtime");
let mock_server = runtime.block_on(MockServer::start_async());
let (_oidc_mock, token_mock) =
mock_client_credentials_issuer(&runtime, &mock_server, &access_token);
let (settings, secrets) = client_credentials_fixture_files(&mock_server.base_url());
figment::Jail::expect_with(|jail| {
jail_config_files(jail, &settings, &secrets);
runtime.block_on(async {
let config = ClientConfiguration::load_with_login(CancellationToken::new(), None)
.await
.expect("should load without an interactive login");
assert_eq!(
config
.get_bearer_access_token()
.await
.expect("should exchange the client secret for an access token"),
SecretAccessToken::from(access_token.clone()),
);
});
token_mock.assert();
Ok(())
});
}
}