use std::sync::Arc;
use std::time::Duration;
use super::{REFRESH_SKEW_MS, RefreshError, refresh_at};
use crate::credential_store::{CredentialStore, has_newer_refresh_link, is_same_link};
use crate::subscription::{SubscriptionProvider, SubscriptionToken};
use crate::vendor_cli_refresh::VendorCli;
const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RecoveryRung {
AdoptedStoredToken,
DirectExchange,
AdoptedRotatedLink,
VendorCliRotation,
}
impl RecoveryRung {
pub(super) const fn describe(self) -> &'static str {
match self {
Self::AdoptedStoredToken => {
"adopted a newer credential from disk without spending an exchange"
}
Self::DirectExchange => "exchanged the stored refresh token",
Self::AdoptedRotatedLink => {
"the stored refresh token was rejected; adopted a newer one from disk and retried"
}
Self::VendorCliRotation => {
"every direct exchange was rejected; the vendor client rotated the chain and its \
credential was adopted"
}
}
}
pub(super) const fn is_recovery(self) -> bool {
matches!(self, Self::AdoptedRotatedLink | Self::VendorCliRotation)
}
}
#[derive(Debug)]
pub(super) struct Recovered {
pub(super) token: SubscriptionToken,
pub(super) rung: RecoveryRung,
}
#[derive(Debug)]
pub(super) struct Rejected {
pub(super) error: RefreshError,
pub(super) message: String,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Exchange<'a> {
pub(super) client: &'a reqwest::Client,
pub(super) token_url: &'a str,
pub(super) provider: SubscriptionProvider,
pub(super) now_ms: i64,
pub(super) mode: RecoveryMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RecoveryMode {
Proactive,
AfterRejection,
}
#[derive(Debug, Clone, Copy)]
struct Tried<'a> {
base: &'a SubscriptionToken,
newest: &'a SubscriptionToken,
}
fn is_usable(
candidate: &SubscriptionToken,
base: &SubscriptionToken,
mode: RecoveryMode,
now_ms: i64,
) -> bool {
match mode {
RecoveryMode::Proactive => !candidate.is_expired(now_ms.saturating_add(REFRESH_SKEW_MS)),
RecoveryMode::AfterRejection => {
candidate.access_token != base.access_token && !candidate.is_expired(now_ms)
}
}
}
async fn acquire_lock(
store: Option<&Arc<dyn CredentialStore>>,
provider: SubscriptionProvider,
) -> Option<crate::durable_file::FileLockGuard> {
let path = store?.lock_path()?;
match crate::durable_file::lock_exclusive_async(&path, LOCK_TIMEOUT).await {
Ok(guard) => Some(guard),
Err(error) => {
tracing::debug!(
"proceeding without the {provider} credential lock at {}: {error}",
path.display()
);
None
}
}
}
fn persist_rotation(
store: Option<&Arc<dyn CredentialStore>>,
baseline: &SubscriptionToken,
fresh: &SubscriptionToken,
provider: SubscriptionProvider,
) {
let Some(store) = store else {
return;
};
if !has_newer_refresh_link(baseline, fresh) {
return;
}
match store.persist(fresh) {
Ok(()) => tracing::info!(
"persisted a rotated {provider} refresh token to {}",
store.describe()
),
Err(error) => tracing::warn!(
"could not persist the rotated {provider} refresh token to {}: {error} — this \
process keeps working, but the rotation will not survive a restart",
store.describe()
),
}
}
fn endpoint_answer(error: &RefreshError) -> String {
match error {
RefreshError::Status(code, body, _) => format!("the endpoint answered HTTP {code}: {body}"),
other => other.to_string(),
}
}
fn terminal_message(
provider: SubscriptionProvider,
error: &RefreshError,
store: Option<&Arc<dyn CredentialStore>>,
retried_with_newer_link: bool,
) -> String {
if !error.is_invalid_grant() {
return error.to_string();
}
let Some(store) = store else {
return error.to_string();
};
let location = store.describe();
if retried_with_newer_link {
return format!(
"refresh token is no longer valid (invalid_grant): a newer refresh token found in \
{location} was rejected as well, so the whole token family has been revoked — \
re-authenticate this subscription with `link-assistant-router auth {provider}` ({})",
endpoint_answer(error)
);
}
format!(
"refresh token is no longer valid (invalid_grant): {location} still holds the same \
refresh token that was just rejected, so it was revoked or already spent elsewhere \
rather than rotated past — re-authenticate this subscription with \
`link-assistant-router auth {provider}` ({})",
endpoint_answer(error)
)
}
pub(super) async fn exchange_with_recovery(
exchange: &Exchange<'_>,
store: Option<&Arc<dyn CredentialStore>>,
vendor_cli: Option<&Arc<VendorCli>>,
base: &SubscriptionToken,
) -> Result<Recovered, Rejected> {
let &Exchange {
client,
token_url,
provider,
now_ms,
mode,
} = exchange;
let _lock = acquire_lock(store, provider).await;
let stored = store.and_then(|store| store.reload());
let mut candidate = base.clone();
let mut from_store = false;
if let Some(stored) = stored.as_ref().filter(|stored| !is_same_link(stored, base)) {
if is_usable(stored, base, mode, now_ms) {
tracing::info!(
"{provider} credential recovery: {}",
RecoveryRung::AdoptedStoredToken.describe()
);
return Ok(Recovered {
token: stored.clone(),
rung: RecoveryRung::AdoptedStoredToken,
});
}
if has_newer_refresh_link(base, stored) {
candidate = stored.clone();
from_store = true;
}
}
let baseline = stored.clone().unwrap_or_else(|| base.clone());
let error = match refresh_at(client, token_url, provider, &candidate, now_ms).await {
Ok(fresh) => {
persist_rotation(store, &baseline, &fresh, provider);
let rung = if from_store {
RecoveryRung::AdoptedRotatedLink
} else {
RecoveryRung::DirectExchange
};
if rung.is_recovery() {
tracing::info!("{provider} credential recovery: {}", rung.describe());
}
return Ok(Recovered { token: fresh, rung });
}
Err(error) => error,
};
if !error.is_invalid_grant() {
return Err(Rejected {
message: error.to_string(),
error,
});
}
let Some(reread) = store.and_then(|store| store.reload()) else {
return Err(Rejected {
message: terminal_message(provider, &error, store, false),
error,
});
};
if has_newer_refresh_link(&candidate, &reread) {
tracing::info!(
"{provider} rejected a refresh token that {} has already rotated past; retrying \
once with the newer one",
store.map_or_else(|| String::from("the credential store"), |s| s.describe())
);
match refresh_at(client, token_url, provider, &reread, now_ms).await {
Ok(fresh) => {
persist_rotation(store, &reread, &fresh, provider);
tracing::info!(
"{provider} credential recovery: {}",
RecoveryRung::AdoptedRotatedLink.describe()
);
return Ok(Recovered {
token: fresh,
rung: RecoveryRung::AdoptedRotatedLink,
});
}
Err(second) => {
return vendor_cli_or_reject(
exchange,
store,
vendor_cli,
second,
Tried {
base,
newest: &reread,
},
true,
)
.await;
}
}
}
if is_usable(&reread, base, mode, now_ms) {
tracing::info!(
"{provider} credential recovery: {}",
RecoveryRung::AdoptedStoredToken.describe()
);
return Ok(Recovered {
token: reread,
rung: RecoveryRung::AdoptedStoredToken,
});
}
vendor_cli_or_reject(
exchange,
store,
vendor_cli,
error,
Tried {
base,
newest: &candidate,
},
false,
)
.await
}
async fn vendor_cli_or_reject(
exchange: &Exchange<'_>,
store: Option<&Arc<dyn CredentialStore>>,
vendor_cli: Option<&Arc<VendorCli>>,
error: RefreshError,
tried: Tried<'_>,
retried_with_newer_link: bool,
) -> Result<Recovered, Rejected> {
let &Exchange {
client,
token_url,
provider,
now_ms,
mode,
} = exchange;
let reject = |error: RefreshError| Rejected {
message: terminal_message(provider, &error, store, retried_with_newer_link),
error,
};
let (Some(store), Some(cli)) = (store, vendor_cli) else {
return Err(reject(error));
};
tracing::info!(
"{provider} credential recovery: the exchange the router sent and that was rejected was {}",
crate::refresh::direct_exchange_shape(provider)
);
let Some(rotated) = cli.rotate(store.as_ref(), tried.newest).await else {
return Err(reject(error));
};
if is_usable(&rotated, tried.base, mode, now_ms) {
tracing::info!(
"{provider} credential recovery: {}",
RecoveryRung::VendorCliRotation.describe()
);
return Ok(Recovered {
token: rotated,
rung: RecoveryRung::VendorCliRotation,
});
}
if !has_newer_refresh_link(tried.newest, &rotated) {
return Err(reject(error));
}
match refresh_at(client, token_url, provider, &rotated, now_ms).await {
Ok(fresh) => {
persist_rotation(Some(store), &rotated, &fresh, provider);
tracing::info!(
"{provider} credential recovery: {}",
RecoveryRung::VendorCliRotation.describe()
);
Ok(Recovered {
token: fresh,
rung: RecoveryRung::VendorCliRotation,
})
}
Err(second) => Err(reject(second)),
}
}
#[cfg(test)]
#[path = "refresh_recovery_tests.rs"]
mod tests;