use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::future::BoxFuture;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::connection::{
AuthBindingRef, AuthCredentialIdentity, BindingId, CredentialAccountId, IdentityError,
ProfileId, RealmId,
};
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Ord, PartialOrd)]
#[serde(transparent)]
pub struct TokenKey(AuthCredentialIdentity);
impl<'de> Deserialize<'de> for TokenKey {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let identity = AuthCredentialIdentity::deserialize(deserializer)?;
Ok(Self::from_credential_identity(&identity))
}
}
impl TokenKey {
pub fn new(realm: RealmId, binding: BindingId) -> Self {
Self(AuthCredentialIdentity::Binding(AuthBindingRef {
realm,
binding,
profile: None,
origin: crate::connection::BindingOrigin::Configured,
}))
}
pub fn new_with_profile(
realm: RealmId,
binding: BindingId,
profile: Option<ProfileId>,
) -> Self {
Self(AuthCredentialIdentity::Binding(AuthBindingRef {
realm,
binding,
profile,
origin: crate::connection::BindingOrigin::Configured,
}))
}
pub fn from_auth_binding(auth_binding: &AuthBindingRef) -> Self {
Self(AuthCredentialIdentity::from_auth_binding(auth_binding))
}
pub fn from_credential_identity(identity: &AuthCredentialIdentity) -> Self {
Self(identity.normalized_for_credential_storage())
}
pub fn credential_identity(&self) -> &AuthCredentialIdentity {
&self.0
}
pub fn realm(&self) -> &RealmId {
self.0.realm()
}
pub fn binding(&self) -> Option<&BindingId> {
self.0.binding()
}
pub fn profile(&self) -> Option<&ProfileId> {
self.0.profile()
}
pub fn account(&self) -> Option<&CredentialAccountId> {
self.0.account()
}
pub fn parse(realm: impl AsRef<str>, binding: impl AsRef<str>) -> Result<Self, IdentityError> {
Self::parse_with_profile(realm, binding, None::<&str>)
}
pub fn parse_with_profile(
realm: impl AsRef<str>,
binding: impl AsRef<str>,
profile: Option<impl AsRef<str>>,
) -> Result<Self, IdentityError> {
Ok(Self::new_with_profile(
RealmId::parse(realm.as_ref())?,
BindingId::parse(binding.as_ref())?,
profile
.map(|profile| ProfileId::parse(profile.as_ref()))
.transpose()?,
))
}
pub fn keyring_account(&self) -> String {
match self.credential_identity() {
AuthCredentialIdentity::Binding(binding) => match &binding.profile {
Some(profile) => {
format!("{}:{}:{}", binding.realm, binding.binding, profile)
}
None => format!("{}:{}", binding.realm, binding.binding),
},
AuthCredentialIdentity::Account(account) => {
format!("{}:account~{}", account.realm, account.account)
}
}
}
pub fn storage_stem(&self) -> String {
match self.credential_identity() {
AuthCredentialIdentity::Binding(binding) => match &binding.profile {
Some(profile) => format!("{}@{}", binding.binding, profile),
None => binding.binding.to_string(),
},
AuthCredentialIdentity::Account(account) => format!("account~{}", account.account),
}
}
}
impl std::fmt::Display for TokenKey {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(formatter)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PersistedAuthMode {
ApiKey,
StaticBearer,
ChatgptOauth,
ClaudeAiOauth,
OauthToApiKey,
GoogleOauth,
GithubCopilotOauth,
Adc,
ComputeAdc,
Bedrock,
Vertex,
Foundry,
McpOauth,
ExternalTokens,
ExternalAuthorizer,
Command,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct PersistedTokens {
pub auth_mode: PersistedAuthMode,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub primary_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub refresh_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub id_token: Option<String>,
#[serde(
skip_serializing_if = "Option::is_none",
default,
with = "chrono::serde::ts_seconds_option"
)]
pub expires_at: Option<DateTime<Utc>>,
#[serde(
skip_serializing_if = "Option::is_none",
default,
with = "chrono::serde::ts_seconds_option"
)]
pub last_refresh: Option<DateTime<Utc>>,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub account_id: Option<String>,
#[serde(default)]
pub metadata: serde_json::Value,
}
impl PersistedTokens {
pub fn api_key(secret: impl Into<String>) -> Self {
Self {
auth_mode: PersistedAuthMode::ApiKey,
primary_secret: Some(secret.into()),
refresh_token: None,
id_token: None,
expires_at: None,
last_refresh: None,
scopes: Vec::new(),
account_id: None,
metadata: serde_json::Value::Null,
}
}
pub fn static_bearer(token: impl Into<String>) -> Self {
Self {
auth_mode: PersistedAuthMode::StaticBearer,
primary_secret: Some(token.into()),
refresh_token: None,
id_token: None,
expires_at: None,
last_refresh: None,
scopes: Vec::new(),
account_id: None,
metadata: serde_json::Value::Null,
}
}
}
#[derive(Debug, Error)]
pub enum TokenStoreError {
#[error("io error: {0}")]
Io(String),
#[error("serialization error: {0}")]
Serde(String),
#[error("keyring backend unavailable: {0}")]
KeyringUnavailable(String),
#[error("no credentials found for {realm}:{binding}")]
NotFound { realm: String, binding: String },
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("backend unavailable: {0}")]
Unavailable(String),
}
#[cfg(not(target_arch = "wasm32"))]
impl From<std::io::Error> for TokenStoreError {
fn from(e: std::io::Error) -> Self {
match e.kind() {
std::io::ErrorKind::PermissionDenied => Self::PermissionDenied(e.to_string()),
std::io::ErrorKind::NotFound => Self::Io(e.to_string()),
_ => Self::Io(e.to_string()),
}
}
}
impl From<serde_json::Error> for TokenStoreError {
fn from(e: serde_json::Error) -> Self {
Self::Serde(e.to_string())
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
pub trait TokenStore: Send + Sync {
async fn load(&self, key: &TokenKey) -> Result<Option<PersistedTokens>, TokenStoreError>;
async fn save(&self, key: &TokenKey, tokens: &PersistedTokens) -> Result<(), TokenStoreError>;
async fn clear(&self, key: &TokenKey) -> Result<(), TokenStoreError>;
async fn list(&self) -> Result<Vec<TokenKey>, TokenStoreError>;
fn backend_name(&self) -> &'static str;
}
#[derive(Clone, Debug, Error)]
pub enum RefreshError {
#[error("refresh function failed: {0}")]
Refresh(String),
#[error("refresh function failed: {message}")]
Observed {
message: String,
observation: RefreshFailureObservation,
},
#[error("refresh function failed: {message}")]
Classified {
message: String,
observation: RefreshFailureObservation,
disposition: RefreshFailureDisposition,
},
#[error("refresh requires interactive reauthorization: {0}")]
ReauthRequired(String),
#[error("refresh in progress was cancelled")]
Cancelled,
#[error("cross-process lock acquisition failed: {0}")]
LockFailed(String),
#[error("durable credential terminal commit failed: {message}")]
DurableTerminalCommit {
message: String,
observation: RefreshFailureObservation,
disposition: RefreshFailureDisposition,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RefreshFailureDisposition {
Transient,
ReauthRequired,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct RefreshFailureObservation {
pub http_status: Option<u64>,
pub oauth_error_code: Option<String>,
pub local_credential_unusable: bool,
}
impl RefreshFailureObservation {
pub fn transient() -> Self {
Self::default()
}
pub fn http_status(status: u16) -> Self {
Self {
http_status: Some(u64::from(status)),
..Self::default()
}
}
pub fn oauth_token_endpoint(status: u16, oauth_error_code: Option<String>) -> Self {
Self {
http_status: Some(u64::from(status)),
oauth_error_code,
..Self::default()
}
}
pub fn oauth_error_code(code: impl Into<String>) -> Self {
Self {
oauth_error_code: Some(code.into()),
..Self::default()
}
}
pub fn local_credential_unusable() -> Self {
Self {
local_credential_unusable: true,
..Self::default()
}
}
}
impl RefreshError {
pub fn observation(&self) -> RefreshFailureObservation {
match self {
Self::Observed { observation, .. }
| Self::Classified { observation, .. }
| Self::DurableTerminalCommit { observation, .. } => observation.clone(),
Self::ReauthRequired(_) => RefreshFailureObservation::local_credential_unusable(),
Self::Refresh(_) | Self::Cancelled | Self::LockFailed(_) => {
RefreshFailureObservation::transient()
}
}
}
pub fn refresh_failure_disposition(&self) -> Option<RefreshFailureDisposition> {
match self {
Self::Classified { disposition, .. }
| Self::DurableTerminalCommit { disposition, .. } => Some(*disposition),
Self::Refresh(_)
| Self::Observed { .. }
| Self::ReauthRequired(_)
| Self::Cancelled
| Self::LockFailed(_) => None,
}
}
}
pub type RefreshFn =
Box<dyn FnOnce() -> BoxFuture<'static, Result<PersistedTokens, RefreshError>> + Send + 'static>;
#[derive(Clone, Debug, Error)]
pub enum CredentialMutationError {
#[error("credential mutation failed: {0}")]
Operation(String),
#[error("credential token-store mutation failed: {0}")]
TokenStore(String),
#[error("credential lifecycle mutation failed: {0}")]
AuthLifecycle(String),
#[error("credential mutation was cancelled")]
Cancelled,
#[error("cross-process credential mutation lock acquisition failed: {0}")]
LockFailed(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CredentialMutationOutcome {
Persisted(PersistedTokens),
Cleared,
}
pub type CredentialMutationFn = Box<
dyn FnOnce() -> BoxFuture<'static, Result<CredentialMutationOutcome, CredentialMutationError>>
+ Send
+ 'static,
>;
#[async_trait]
pub trait RefreshCoordinator: Send + Sync {
async fn with_exclusive_mutation(
&self,
key: TokenKey,
mutation_fn: CredentialMutationFn,
) -> Result<CredentialMutationOutcome, CredentialMutationError>;
async fn with_refresh(
&self,
key: TokenKey,
refresh_fn: RefreshFn,
) -> Result<PersistedTokens, RefreshError>;
async fn with_forced_refresh(
&self,
key: TokenKey,
refresh_fn: RefreshFn,
) -> Result<PersistedTokens, RefreshError> {
self.with_refresh(key, refresh_fn).await
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::{CredentialAccountRef, connection::BindingOrigin};
#[test]
fn binding_key_serialization_remains_legacy_compatible() {
let key = TokenKey::from_auth_binding(&AuthBindingRef {
realm: RealmId::parse("global").expect("valid realm"),
binding: BindingId::parse("openai").expect("valid binding"),
profile: None,
origin: BindingOrigin::Configured,
});
assert_eq!(
serde_json::to_value(&key).expect("serialize"),
serde_json::json!({"realm": "global", "binding": "openai"})
);
assert_eq!(
serde_json::from_value::<TokenKey>(
serde_json::json!({"realm": "global", "binding": "openai"})
)
.expect("deserialize legacy key"),
key
);
let synthetic = TokenKey::from_auth_binding(&AuthBindingRef {
realm: RealmId::parse("global").expect("valid realm"),
binding: BindingId::parse("openai").expect("valid binding"),
profile: None,
origin: BindingOrigin::SyntheticEnvDefault,
});
assert_eq!(
synthetic, key,
"route provenance is not credential identity"
);
}
#[test]
fn account_keys_have_disjoint_storage_names() {
let identity = AuthCredentialIdentity::Account(CredentialAccountRef {
realm: RealmId::parse("global").expect("valid realm"),
account: CredentialAccountId::parse("github_copilot").expect("valid account"),
});
let key = TokenKey::from_credential_identity(&identity);
assert_eq!(key.storage_stem(), "account~github_copilot");
assert_eq!(key.keyring_account(), "global:account~github_copilot");
assert_eq!(
serde_json::from_value::<TokenKey>(
serde_json::to_value(&key).expect("serialize account key")
)
.expect("deserialize account key"),
key
);
}
}
static NEXT_PROVIDER_AUTH_PERSISTENCE_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ProviderAuthPersistenceId(u64);
impl ProviderAuthPersistenceId {
fn next() -> Self {
Self(NEXT_PROVIDER_AUTH_PERSISTENCE_ID.fetch_add(1, Ordering::Relaxed))
}
}
#[derive(Clone)]
pub struct ProviderAuthPersistence {
authority_id: ProviderAuthPersistenceId,
token_store: Arc<dyn TokenStore>,
refresh_coordinator: Arc<dyn RefreshCoordinator>,
}
impl ProviderAuthPersistence {
pub fn new(
token_store: Arc<dyn TokenStore>,
refresh_coordinator: Arc<dyn RefreshCoordinator>,
) -> Self {
Self {
authority_id: ProviderAuthPersistenceId::next(),
token_store,
refresh_coordinator,
}
}
pub fn authority_id(&self) -> ProviderAuthPersistenceId {
self.authority_id
}
pub fn token_store(&self) -> Arc<dyn TokenStore> {
Arc::clone(&self.token_store)
}
pub fn refresh_coordinator(&self) -> Arc<dyn RefreshCoordinator> {
Arc::clone(&self.refresh_coordinator)
}
}