use reqsign_core::{Result, SigningCredential as KeyTrait, time::Timestamp, utils::Redact};
use std::fmt::{self, Debug};
use std::time::Duration;
#[derive(Clone, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ServiceAccount {
pub private_key: String,
pub client_email: String,
}
impl Debug for ServiceAccount {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ServiceAccount")
.field("client_email", &self.client_email)
.field("private_key", &Redact::from(&self.private_key))
.finish()
}
}
#[derive(Clone, serde::Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub struct ImpersonatedServiceAccount {
pub service_account_impersonation_url: String,
pub source_credentials: OAuth2Credentials,
#[serde(default)]
pub delegates: Vec<String>,
}
#[derive(Clone, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct OAuth2Credentials {
pub client_id: String,
pub client_secret: String,
pub refresh_token: String,
}
impl Debug for OAuth2Credentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OAuth2Credentials")
.field("client_id", &self.client_id)
.field("client_secret", &Redact::from(&self.client_secret))
.field("refresh_token", &Redact::from(&self.refresh_token))
.finish()
}
}
#[derive(Clone, serde::Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub struct ExternalAccount {
pub audience: String,
pub subject_token_type: String,
pub token_url: String,
pub credential_source: external_account::Source,
pub service_account_impersonation_url: Option<String>,
pub service_account_impersonation: Option<external_account::ImpersonationOptions>,
}
pub mod external_account {
use reqsign_core::Result;
use serde::Deserialize;
#[derive(Clone, Deserialize, Debug)]
#[serde(untagged)]
pub enum Source {
#[serde(rename_all = "snake_case")]
Url(UrlSource),
#[serde(rename_all = "snake_case")]
File(FileSource),
}
#[derive(Clone, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub struct UrlSource {
pub url: String,
pub format: Format,
pub headers: Option<std::collections::HashMap<String, String>>,
}
#[derive(Clone, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub struct FileSource {
pub file: String,
pub format: Format,
}
#[derive(Clone, Deserialize, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Format {
Json {
subject_token_field_name: String,
},
Text,
}
impl Format {
pub fn parse(&self, slice: &[u8]) -> Result<String> {
match &self {
Self::Text => Ok(String::from_utf8(slice.to_vec()).map_err(|e| {
reqsign_core::Error::unexpected("invalid UTF-8").with_source(e)
})?),
Self::Json {
subject_token_field_name,
} => {
let value: serde_json::Value = serde_json::from_slice(slice).map_err(|e| {
reqsign_core::Error::unexpected("failed to parse JSON").with_source(e)
})?;
match value.get(subject_token_field_name) {
Some(serde_json::Value::String(access_token)) => Ok(access_token.clone()),
_ => Err(reqsign_core::Error::unexpected(format!(
"JSON missing token field {subject_token_field_name}"
))),
}
}
}
}
}
#[derive(Clone, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub struct ImpersonationOptions {
pub token_lifetime_seconds: Option<usize>,
}
}
#[derive(Clone, Default)]
pub struct Token {
pub access_token: String,
pub expires_at: Option<Timestamp>,
}
impl Debug for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Token")
.field("access_token", &Redact::from(&self.access_token))
.field("expires_at", &self.expires_at)
.finish()
}
}
impl KeyTrait for Token {
fn is_valid(&self) -> bool {
if self.access_token.is_empty() {
return false;
}
match self.expires_at {
Some(expires_at) => {
let buffer = Duration::from_secs(120);
Timestamp::now() < expires_at - buffer
}
None => true, }
}
}
#[derive(Clone, Debug, Default)]
pub struct Credential {
pub service_account: Option<ServiceAccount>,
pub token: Option<Token>,
}
impl Credential {
pub fn with_service_account(service_account: ServiceAccount) -> Self {
Self {
service_account: Some(service_account),
token: None,
}
}
pub fn with_token(token: Token) -> Self {
Self {
service_account: None,
token: Some(token),
}
}
pub fn has_service_account(&self) -> bool {
self.service_account.is_some()
}
pub fn has_token(&self) -> bool {
self.token.is_some()
}
pub fn has_valid_token(&self) -> bool {
self.token.as_ref().is_some_and(|t| t.is_valid())
}
}
impl KeyTrait for Credential {
fn is_valid(&self) -> bool {
self.service_account.is_some() || self.has_valid_token()
}
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CredentialFile {
ServiceAccount(ServiceAccount),
ExternalAccount(ExternalAccount),
ImpersonatedServiceAccount(ImpersonatedServiceAccount),
AuthorizedUser(OAuth2Credentials),
}
impl CredentialFile {
pub fn from_slice(v: &[u8]) -> Result<Self> {
serde_json::from_slice(v).map_err(|e| {
reqsign_core::Error::unexpected("failed to parse credential file").with_source(e)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_external_account_format_parse_text() {
let format = external_account::Format::Text;
let data = b"test-token";
let result = format.parse(data).unwrap();
assert_eq!("test-token", result);
}
#[test]
fn test_external_account_format_parse_json() {
let format = external_account::Format::Json {
subject_token_field_name: "access_token".to_string(),
};
let data = br#"{"access_token": "test-token", "expires_in": 3600}"#;
let result = format.parse(data).unwrap();
assert_eq!("test-token", result);
}
#[test]
fn test_external_account_format_parse_json_missing_field() {
let format = external_account::Format::Json {
subject_token_field_name: "access_token".to_string(),
};
let data = br#"{"wrong_field": "test-token"}"#;
let result = format.parse(data);
assert!(result.is_err());
}
#[test]
fn test_token_is_valid() {
let mut token = Token {
access_token: "test".to_string(),
expires_at: None,
};
assert!(token.is_valid());
token.expires_at = Some(Timestamp::now() + Duration::from_secs(3600));
assert!(token.is_valid());
token.expires_at = Some(Timestamp::now() + Duration::from_secs(30));
assert!(!token.is_valid());
token.expires_at = Some(Timestamp::now() - Duration::from_secs(3600));
assert!(!token.is_valid());
token.access_token = String::new();
assert!(!token.is_valid());
}
#[test]
fn test_credential_file_deserialize() {
let sa_json = r#"{
"type": "service_account",
"private_key": "test_key",
"client_email": "test@example.com"
}"#;
let cred = CredentialFile::from_slice(sa_json.as_bytes()).unwrap();
match cred {
CredentialFile::ServiceAccount(sa) => {
assert_eq!(sa.client_email, "test@example.com");
}
_ => panic!("Expected ServiceAccount"),
}
let ea_json = r#"{
"type": "external_account",
"audience": "test_audience",
"subject_token_type": "test_type",
"token_url": "https://example.com/token",
"credential_source": {
"file": "/path/to/file",
"format": {
"type": "text"
}
}
}"#;
let cred = CredentialFile::from_slice(ea_json.as_bytes()).unwrap();
assert!(matches!(cred, CredentialFile::ExternalAccount(_)));
let au_json = r#"{
"type": "authorized_user",
"client_id": "test_id",
"client_secret": "test_secret",
"refresh_token": "test_token"
}"#;
let cred = CredentialFile::from_slice(au_json.as_bytes()).unwrap();
match cred {
CredentialFile::AuthorizedUser(oauth2) => {
assert_eq!(oauth2.client_id, "test_id");
assert_eq!(oauth2.client_secret, "test_secret");
assert_eq!(oauth2.refresh_token, "test_token");
}
_ => panic!("Expected AuthorizedUser"),
}
}
#[test]
fn test_credential_is_valid() {
let cred = Credential::with_service_account(ServiceAccount {
client_email: "test@example.com".to_string(),
private_key: "key".to_string(),
});
assert!(cred.is_valid());
assert!(cred.has_service_account());
assert!(!cred.has_token());
let cred = Credential::with_token(Token {
access_token: "test".to_string(),
expires_at: Some(Timestamp::now() + Duration::from_secs(3600)),
});
assert!(cred.is_valid());
assert!(!cred.has_service_account());
assert!(cred.has_token());
assert!(cred.has_valid_token());
let cred = Credential::with_token(Token {
access_token: String::new(),
expires_at: None,
});
assert!(!cred.is_valid());
assert!(!cred.has_valid_token());
let mut cred = Credential::with_service_account(ServiceAccount {
client_email: "test@example.com".to_string(),
private_key: "key".to_string(),
});
cred.token = Some(Token {
access_token: "test".to_string(),
expires_at: Some(Timestamp::now() + Duration::from_secs(3600)),
});
assert!(cred.is_valid());
assert!(cred.has_service_account());
assert!(cred.has_valid_token());
let mut cred = Credential::with_service_account(ServiceAccount {
client_email: "test@example.com".to_string(),
private_key: "key".to_string(),
});
cred.token = Some(Token {
access_token: "test".to_string(),
expires_at: Some(Timestamp::now() - Duration::from_secs(3600)),
});
assert!(cred.is_valid()); assert!(!cred.has_valid_token());
}
}