#![cfg(feature = "oauth")]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use mockito::{Mock, Server, ServerGuard};
use pmcp::client::oauth::{BrowserLauncher, Interactivity, OAuthConfig, OAuthHelper};
use pmcp::shared::credential_store::normalize_server_key;
use pmcp::{CredentialKey, CredentialStore, InMemoryCredentialStore, StoredCredentials};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use url::Url;
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.expect("a loopback port")
.local_addr()
.expect("local_addr")
.port()
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_secs())
}
async fn settle() {
tokio::time::sleep(Duration::from_millis(20)).await;
}
fn discovery_body(base: &str, scopes_supported: &[&str]) -> String {
json!({
"issuer": base,
"authorization_endpoint": format!("{base}/authorize"),
"token_endpoint": format!("{base}/token"),
"registration_endpoint": format!("{base}/register"),
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"scopes_supported": scopes_supported,
"token_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
})
.to_string()
}
#[derive(Clone, Debug, Default)]
struct WireBodies(Arc<Mutex<Vec<String>>>);
impl WireBodies {
fn record(&self, body: &str) {
self.0.lock().expect("wire bodies").push(body.to_string());
}
fn refreshes(&self) -> Vec<Vec<(String, String)>> {
self.0
.lock()
.expect("wire bodies")
.iter()
.map(|body| form_pairs(body))
.filter(|pairs| value(pairs, "grant_type") == Some("refresh_token"))
.collect()
}
fn only_refresh(&self) -> Vec<(String, String)> {
let mut all = self.refreshes();
assert_eq!(
all.len(),
1,
"expected exactly one refresh request, saw {}",
all.len()
);
all.remove(0)
}
}
fn form_pairs(body: &str) -> Vec<(String, String)> {
url::form_urlencoded::parse(body.as_bytes())
.map(|(key, val)| (key.into_owned(), val.into_owned()))
.collect()
}
fn value<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> {
pairs
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
struct TokenReply {
status: usize,
body: String,
}
impl TokenReply {
fn ok(access_token: &str, refresh_token: Option<&str>, expires_in: Option<u64>) -> Self {
let mut body = json!({ "access_token": access_token, "token_type": "Bearer" });
if let Some(refresh) = refresh_token {
body["refresh_token"] = json!(refresh);
}
if let Some(ttl) = expires_in {
body["expires_in"] = json!(ttl);
}
Self {
status: 200,
body: body.to_string(),
}
}
fn failure(status: usize, body: String) -> Self {
Self { status, body }
}
}
async fn refresh_server(
scopes_supported: &[&str],
reply: TokenReply,
) -> (ServerGuard, Vec<Mock>, String, WireBodies) {
let mut server = Server::new_async().await;
let base = server.url();
let bodies = WireBodies::default();
let mut mocks = Vec::new();
mocks.push(
server
.mock("GET", "/.well-known/openid-configuration")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(discovery_body(&base, scopes_supported))
.expect_at_least(0)
.create_async()
.await,
);
mocks.push(
server
.mock("POST", "/token")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
json!({
"access_token": "interactive-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid",
})
.to_string(),
)
.expect_at_least(0)
.create_async()
.await,
);
let recorder = bodies.clone();
mocks.push(
server
.mock("POST", "/token")
.match_request(move |request| {
let Ok(raw) = request.body() else {
return false;
};
let body = String::from_utf8_lossy(raw);
if !body.contains("grant_type=refresh_token") {
return false;
}
recorder.record(&body);
true
})
.with_status(reply.status)
.with_header("content-type", "application/json")
.with_body(reply.body)
.expect_at_least(0)
.create_async()
.await,
);
mocks.push(
server
.mock("POST", "/register")
.with_status(201)
.with_header("content-type", "application/json")
.with_body(json!({ "client_id": "freshly-registered-id" }).to_string())
.expect_at_least(0)
.create_async()
.await,
);
(server, mocks, base, bodies)
}
#[derive(Debug)]
struct CountingCallbackLauncher {
port: u16,
opened: Arc<AtomicUsize>,
}
impl CountingCallbackLauncher {
fn new(port: u16) -> (Arc<Self>, Arc<AtomicUsize>) {
let opened = Arc::new(AtomicUsize::new(0));
(
Arc::new(Self {
port,
opened: opened.clone(),
}),
opened,
)
}
}
impl BrowserLauncher for CountingCallbackLauncher {
fn open(&self, url: &str) -> pmcp::Result<()> {
self.opened.fetch_add(1, Ordering::SeqCst);
let state = Url::parse(url)
.ok()
.and_then(|parsed| {
parsed
.query_pairs()
.find(|(key, _)| key == "state")
.map(|(_, val)| val.into_owned())
})
.unwrap_or_default();
let port = self.port;
tokio::spawn(async move {
let Ok(mut stream) = TcpStream::connect(("127.0.0.1", port)).await else {
return;
};
let request = format!(
"GET /callback?code=granted-code&state={state} HTTP/1.1\r\n\
Host: 127.0.0.1\r\nConnection: close\r\n\r\n"
);
if stream.write_all(request.as_bytes()).await.is_err() {
return;
}
let _ = stream.flush().await;
let mut response = Vec::new();
let _ = stream.read_to_end(&mut response).await;
});
Ok(())
}
}
struct HelperSpec<'a> {
base: &'a str,
client_id: Option<&'a str>,
config_scopes: &'a [&'a str],
}
impl<'a> HelperSpec<'a> {
fn new(base: &'a str) -> Self {
Self {
base,
client_id: Some("preset-client"),
config_scopes: &["openid"],
}
}
fn dcr(mut self) -> Self {
self.client_id = None;
self
}
fn config_scopes(mut self, scopes: &'a [&'a str]) -> Self {
self.config_scopes = scopes;
self
}
}
fn helper_for(
spec: &HelperSpec<'_>,
store: &Arc<dyn CredentialStore>,
) -> (OAuthHelper, Arc<AtomicUsize>, u16) {
let port = free_port();
let (launcher, opened) = CountingCallbackLauncher::new(port);
let helper = OAuthHelper::new(OAuthConfig {
mcp_server_url: Some(spec.base.to_string()),
client_id: spec.client_id.map(str::to_string),
dcr_enabled: spec.client_id.is_none(),
scopes: spec
.config_scopes
.iter()
.map(|s| (*s).to_string())
.collect(),
redirect_port: port,
..OAuthConfig::default()
})
.expect("helper")
.with_browser_launcher(launcher)
.with_credential_store(store.clone());
(helper, opened, port)
}
fn key_for(base: &str) -> CredentialKey {
CredentialKey::new(base, "", normalize_server_key(base).expect("normalized"))
}
struct SeedSpec<'a> {
client_id: &'a str,
refresh_token: Option<&'a str>,
granted_scopes: &'a [&'a str],
}
impl<'a> SeedSpec<'a> {
fn new() -> Self {
Self {
client_id: "preset-client",
refresh_token: Some("stored-refresh-token"),
granted_scopes: &["openid"],
}
}
fn client_id(mut self, client_id: &'a str) -> Self {
self.client_id = client_id;
self
}
fn refresh_token(mut self, refresh_token: Option<&'a str>) -> Self {
self.refresh_token = refresh_token;
self
}
fn granted_scopes(mut self, scopes: &'a [&'a str]) -> Self {
self.granted_scopes = scopes;
self
}
}
async fn seed_expired(
store: &Arc<dyn CredentialStore>,
base: &str,
spec: &SeedSpec<'_>,
) -> CredentialKey {
let key = key_for(base);
let mut credentials = StoredCredentials::new("STALE-ACCESS-TOKEN", spec.client_id)
.with_granted_scopes(spec.granted_scopes.iter().map(|s| (*s).to_string()))
.with_expires_at(unix_now().saturating_sub(60));
if let Some(refresh) = spec.refresh_token {
credentials = credentials.with_refresh_token(refresh);
}
store
.save(&key, &credentials)
.await
.expect("seeding the expired record");
key
}
async fn stored(store: &Arc<dyn CredentialStore>, key: &CredentialKey) -> StoredCredentials {
store
.load(key)
.await
.expect("a readable store")
.expect("a record under the seeded key")
}
#[derive(Debug, Default)]
struct WarnCapture {
messages: Arc<Mutex<Vec<String>>>,
}
struct MessageVisitor<'a>(&'a mut Vec<String>);
impl tracing::field::Visit for MessageVisitor<'_> {
fn record_debug(&mut self, field: &tracing::field::Field, val: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.0.push(format!("{val:?}"));
}
}
}
impl tracing::Subscriber for WarnCapture {
fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
*metadata.level() <= tracing::Level::WARN
}
fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
if *event.metadata().level() != tracing::Level::WARN {
return;
}
let mut held = self.messages.lock().expect("captured warnings");
event.record(&mut MessageVisitor(&mut held));
}
fn enter(&self, _span: &tracing::span::Id) {}
fn exit(&self, _span: &tracing::span::Id) {}
}
fn refresh_failure_warnings(captured: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
captured
.lock()
.expect("captured warnings")
.iter()
.filter(|message| message.contains("refresh") && message.contains("failed"))
.cloned()
.collect()
}
#[tokio::test]
async fn an_omitted_refresh_token_in_the_response_preserves_the_stored_one() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::ok("refreshed-access-token", None, Some(0)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
let key = seed_expired(&store, &base, &SeedSpec::new()).await;
let spec = HelperSpec::new(&base);
let (first, first_opened, _) = helper_for(&spec, &store);
assert_eq!(
first.get_access_token().await.expect("a refresh"),
"refreshed-access-token"
);
assert_eq!(
first_opened.load(Ordering::SeqCst),
0,
"a successful refresh must not open a browser"
);
assert_eq!(
stored(&store, &key).await.refresh_token(),
Some("stored-refresh-token"),
"an omitted refresh_token means KEEP the stored one, not discard it"
);
let (second, second_opened, _) = helper_for(&spec, &store);
assert_eq!(
second.get_access_token().await.expect("a second refresh"),
"refreshed-access-token"
);
assert_eq!(
second_opened.load(Ordering::SeqCst),
0,
"the second cycle must also be unattended"
);
let refreshes = wire.refreshes();
assert_eq!(refreshes.len(), 2, "two refresh cycles, got {refreshes:?}");
for (cycle, pairs) in refreshes.iter().enumerate() {
assert_eq!(
value(pairs, "refresh_token"),
Some("stored-refresh-token"),
"cycle {cycle} must present the surviving refresh token"
);
}
}
#[tokio::test]
async fn a_refresh_response_that_supplies_a_new_refresh_token_replaces_the_stored_one() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::ok(
"refreshed-access-token",
Some("rotated-refresh-token"),
Some(3600),
),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
let key = seed_expired(&store, &base, &SeedSpec::new()).await;
let (helper, opened, _) = helper_for(&HelperSpec::new(&base), &store);
helper.get_access_token().await.expect("a refresh");
assert_eq!(opened.load(Ordering::SeqCst), 0);
assert_eq!(
stored(&store, &key).await.refresh_token(),
Some("rotated-refresh-token"),
"a supplied refresh_token must REPLACE the stored one"
);
assert_eq!(
value(&wire.only_refresh(), "refresh_token"),
Some("stored-refresh-token"),
"the request itself still presents the OLD token"
);
}
#[tokio::test]
async fn a_refresh_response_that_omits_expires_in_does_not_corrupt_the_stored_expiry() {
let (_server, _mocks, base, _wire) = refresh_server(
&["openid"],
TokenReply::ok("refreshed-access-token", Some("rotated"), None),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
let key = seed_expired(&store, &base, &SeedSpec::new()).await;
let seeded_expiry = stored(&store, &key).await.expires_at();
let (helper, _opened, _) = helper_for(&HelperSpec::new(&base), &store);
helper.get_access_token().await.expect("a refresh");
let after = stored(&store, &key).await;
assert_eq!(
after.access_token(),
"refreshed-access-token",
"the new token is what is stored"
);
assert_ne!(
after.expires_at(),
seeded_expiry,
"the stale expiry must not be carried over onto a brand-new token"
);
assert!(
!matches!(after.expires_at(), Some(at) if at <= unix_now()),
"an unknown expiry is recorded as unknown, never as a moment in the past: {:?}",
after.expires_at()
);
}
#[tokio::test]
async fn an_authorization_that_issued_no_refresh_token_stores_none_and_falls_through() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::ok("interactive-access-token", None, Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
let key = seed_expired(&store, &base, &SeedSpec::new().refresh_token(None)).await;
let (helper, opened, _) = helper_for(&HelperSpec::new(&base), &store);
let token = helper
.get_access_token()
.await
.expect("no refresh token is a fall-through, never a panic");
settle().await;
assert_eq!(token, "interactive-access-token");
assert_eq!(
opened.load(Ordering::SeqCst),
1,
"with nothing to refresh, the interactive flow is the correct answer"
);
assert!(
wire.refreshes().is_empty(),
"no refresh may be attempted when no refresh token was ever issued"
);
assert_eq!(stored(&store, &key).await.refresh_token(), None);
}
#[tokio::test]
async fn a_dcr_registered_client_refreshes_with_the_stored_issued_client_id() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::ok("refreshed-access-token", Some("rotated"), Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
seed_expired(
&store,
&base,
&SeedSpec::new().client_id("dcr-issued-client-id"),
)
.await;
let (helper, opened, _) = helper_for(&HelperSpec::new(&base).dcr(), &store);
assert_eq!(
helper
.get_access_token()
.await
.expect("a DCR client must be able to refresh"),
"refreshed-access-token"
);
assert_eq!(
opened.load(Ordering::SeqCst),
0,
"a working refresh must not re-open a browser"
);
assert_eq!(
value(&wire.only_refresh(), "client_id"),
Some("dcr-issued-client-id"),
"the refresh must carry the client_id the authorization server ISSUED"
);
}
#[tokio::test]
async fn the_stored_client_id_is_preferred_over_the_configured_one() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::ok("refreshed-access-token", Some("rotated"), Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
seed_expired(&store, &base, &SeedSpec::new().client_id("stored-id")).await;
let (helper, _opened, _) = helper_for(&HelperSpec::new(&base), &store);
helper.get_access_token().await.expect("a refresh");
assert_eq!(
value(&wire.only_refresh(), "client_id"),
Some("stored-id"),
"the record's own client_id is the one paired with its refresh token"
);
}
#[tokio::test]
async fn a_refresh_with_no_client_id_anywhere_names_both_places_it_looked() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::ok("interactive-access-token", Some("r"), Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
seed_expired(&store, &base, &SeedSpec::new().client_id("")).await;
let capture = WarnCapture::default();
let messages = capture.messages.clone();
let _guard = tracing::subscriber::set_default(capture);
let (helper, opened, _) = helper_for(&HelperSpec::new(&base).dcr(), &store);
helper
.get_access_token()
.await
.expect("a missing client_id falls through, it does not panic");
settle().await;
assert!(
wire.refreshes().is_empty(),
"the refusal must happen BEFORE any request reaches the token endpoint"
);
assert_eq!(
opened.load(Ordering::SeqCst),
1,
"the interactive flow is the fall-through"
);
let warnings = refresh_failure_warnings(&messages);
assert_eq!(
warnings.len(),
1,
"exactly one refresh-failure warning, got {warnings:?}"
);
let warning = &warnings[0];
assert!(
warning.contains("client_id"),
"the refusal must name what is missing: {warning}"
);
assert!(
warning.contains("OAuthConfig::client_id"),
"the refusal must name the CONFIG place it looked: {warning}"
);
assert!(
warning.contains("stored credential"),
"the refusal must name the STORE place it looked: {warning}"
);
}
#[tokio::test]
async fn the_refresh_body_carries_exactly_the_stored_granted_scopes_in_order() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid", "mcp:read", "offline_access"],
TokenReply::ok("refreshed-access-token", Some("rotated"), Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
seed_expired(
&store,
&base,
&SeedSpec::new().granted_scopes(&["mcp:read", "openid", "offline_access"]),
)
.await;
let (helper, _opened, _) = helper_for(&HelperSpec::new(&base), &store);
helper.get_access_token().await.expect("a refresh");
assert_eq!(
value(&wire.only_refresh(), "scope"),
Some("mcp:read openid offline_access"),
"exactly the stored granted scopes, in the stored order"
);
}
#[tokio::test]
async fn empty_stored_granted_scopes_omit_the_scope_key_entirely() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid", "profile", "email"],
TokenReply::ok("refreshed-access-token", Some("rotated"), Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
seed_expired(&store, &base, &SeedSpec::new().granted_scopes(&[])).await;
let spec = HelperSpec::new(&base).config_scopes(&["profile", "email"]);
let (helper, _opened, _) = helper_for(&spec, &store);
helper.get_access_token().await.expect("a refresh");
let pairs = wire.only_refresh();
assert_eq!(
value(&pairs, "scope"),
None,
"no granted scope means NO scope key — not an empty one: {pairs:?}"
);
assert!(
!pairs.iter().any(|(key, _)| key == "scope"),
"the key itself must be absent from the wire body: {pairs:?}"
);
}
#[tokio::test]
async fn an_advertised_but_never_granted_offline_access_is_absent_from_the_refresh() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid", "offline_access"],
TokenReply::ok("refreshed-access-token", Some("rotated"), Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
seed_expired(&store, &base, &SeedSpec::new().granted_scopes(&["openid"])).await;
let spec = HelperSpec::new(&base).config_scopes(&["openid", "offline_access"]);
let (helper, _opened, _) = helper_for(&spec, &store);
helper.get_access_token().await.expect("a refresh");
let pairs = wire.only_refresh();
let scope = value(&pairs, "scope").expect("a granted scope is sent");
assert_eq!(scope, "openid");
assert!(
!scope.contains("offline_access"),
"advertised is not granted: {scope}"
);
}
#[tokio::test]
async fn a_refresh_never_widens_beyond_the_granted_scope_rfc6749_section_6() {
use proptest::strategy::{Strategy, ValueTree};
use proptest::test_runner::TestRunner;
let scope_atom = proptest::sample::select(vec![
"openid".to_string(),
"profile".to_string(),
"email".to_string(),
"offline_access".to_string(),
"mcp:read".to_string(),
"mcp:write".to_string(),
]);
let grants = proptest::collection::vec(scope_atom, 0..6);
let mut runner = TestRunner::deterministic();
let cases: Vec<Vec<String>> = (0..24)
.map(|_| {
grants
.new_tree(&mut runner)
.expect("a generated grant set")
.current()
})
.collect();
let (_server, _mocks, base, wire) = refresh_server(
&[
"openid",
"profile",
"email",
"offline_access",
"mcp:read",
"mcp:write",
],
TokenReply::ok("refreshed-access-token", Some("rotated"), Some(0)),
)
.await;
for granted in &cases {
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
let owned: Vec<&str> = granted.iter().map(String::as_str).collect();
seed_expired(&store, &base, &SeedSpec::new().granted_scopes(&owned)).await;
let spec = HelperSpec::new(&base).config_scopes(&[
"openid",
"profile",
"email",
"offline_access",
"mcp:read",
"mcp:write",
]);
let (helper, opened, _) = helper_for(&spec, &store);
helper
.get_access_token()
.await
.expect("a refresh for every generated grant");
assert_eq!(opened.load(Ordering::SeqCst), 0);
let pairs = wire
.refreshes()
.pop()
.expect("the refresh this case just drove");
let sent: Vec<&str> = value(&pairs, "scope")
.map(|scope| scope.split_whitespace().collect())
.unwrap_or_default();
for scope in &sent {
assert!(
granted.iter().any(|g| g == scope),
"RFC 6749 §6: sent {sent:?} includes {scope:?}, which was never granted \
({granted:?})"
);
}
if granted.is_empty() {
assert!(
sent.is_empty(),
"an empty grant must send no scope at all, sent {sent:?}"
);
}
}
}
#[tokio::test]
async fn an_oversized_refresh_error_body_is_refused_naming_the_cap_and_no_content() {
const CANARY: &str = "CANARY-FROM-A-HOSTILE-REFRESH-ERROR-BODY";
let mut huge = String::with_capacity(1_300_000);
huge.push_str(CANARY);
huge.push_str(&"A".repeat(1_300_000));
let (_server, _mocks, base, _wire) =
refresh_server(&["openid"], TokenReply::failure(400, huge)).await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
seed_expired(&store, &base, &SeedSpec::new()).await;
let capture = WarnCapture::default();
let messages = capture.messages.clone();
let _guard = tracing::subscriber::set_default(capture);
let (helper, opened, _) = helper_for(&HelperSpec::new(&base), &store);
helper
.get_access_token()
.await
.expect("an oversized refusal falls through, it does not abort the caller");
settle().await;
assert_eq!(opened.load(Ordering::SeqCst), 1);
let warnings = refresh_failure_warnings(&messages);
assert_eq!(
warnings.len(),
1,
"exactly one refresh-failure warning, got {warnings:?}"
);
let warning = &warnings[0];
assert!(
warning.contains("1048576"),
"the refusal must name the cap: {warning}"
);
assert!(
!warning.contains(CANARY),
"the refusal must not become a channel for the bytes it refused"
);
assert!(
!warning.contains("AAAAAAAAAA"),
"no padding from the refused body may appear either"
);
}
#[tokio::test]
async fn a_rejected_refresh_falls_through_and_says_why() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::failure(400, json!({ "error": "invalid_grant" }).to_string()),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
seed_expired(&store, &base, &SeedSpec::new()).await;
let capture = WarnCapture::default();
let messages = capture.messages.clone();
let _guard = tracing::subscriber::set_default(capture);
let (helper, opened, _) = helper_for(&HelperSpec::new(&base), &store);
helper
.get_access_token()
.await
.expect("a rejected refresh falls through to an interactive login");
settle().await;
assert_eq!(wire.refreshes().len(), 1, "the refresh was attempted");
assert_eq!(opened.load(Ordering::SeqCst), 1);
let warnings = refresh_failure_warnings(&messages);
assert_eq!(warnings.len(), 1, "got {warnings:?}");
assert!(
warnings[0].contains("invalid_grant"),
"the authorization server's own reason must survive: {}",
warnings[0]
);
}
fn refresh_only_helper_for(
spec: &HelperSpec<'_>,
store: &Arc<dyn CredentialStore>,
) -> (OAuthHelper, Arc<AtomicUsize>, u16) {
let (helper, opened, port) = helper_for(spec, store);
(
helper.with_interactivity(Interactivity::RefreshOnly),
opened,
port,
)
}
fn assert_nothing_interactive_happened(opened: &Arc<AtomicUsize>, port: u16) {
assert_eq!(
opened.load(Ordering::SeqCst),
0,
"RefreshOnly must never invoke the browser launcher"
);
assert!(
std::net::TcpListener::bind(("127.0.0.1", port)).is_ok(),
"the redirect port {port} must still be bindable: RefreshOnly must bind no loopback \
listener"
);
}
#[tokio::test]
async fn the_default_mode_still_falls_through_to_the_browser_flow() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::failure(400, json!({ "error": "invalid_grant" }).to_string()),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
seed_expired(&store, &base, &SeedSpec::new()).await;
let (helper, opened, _) = helper_for(&HelperSpec::new(&base), &store);
let token = helper
.get_access_token()
.await
.expect("the default must still fall through");
settle().await;
assert_eq!(token, "interactive-access-token");
assert_eq!(wire.refreshes().len(), 1, "the refresh was still attempted");
assert_eq!(
opened.load(Ordering::SeqCst),
1,
"the default mode still opens a browser on a failed refresh"
);
}
#[tokio::test]
async fn refresh_only_with_a_live_cached_token_returns_it() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::ok("refreshed-access-token", Some("rotated"), Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
let key = key_for(&base);
store
.save(
&key,
&StoredCredentials::new("LIVE-ACCESS-TOKEN", "preset-client")
.with_refresh_token("stored-refresh-token")
.with_granted_scopes(vec!["openid".to_string()])
.with_expires_at(unix_now() + 9_000),
)
.await
.expect("seeding a live record");
let (helper, opened, port) = refresh_only_helper_for(&HelperSpec::new(&base), &store);
assert_eq!(
helper
.get_access_token()
.await
.expect("a live cached token"),
"LIVE-ACCESS-TOKEN"
);
assert!(
wire.refreshes().is_empty(),
"a live token needs no network at all"
);
assert_nothing_interactive_happened(&opened, port);
}
#[tokio::test]
async fn refresh_only_with_an_expired_token_and_a_working_refresh_returns_the_new_token() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::ok("refreshed-access-token", Some("rotated"), Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
let key = seed_expired(&store, &base, &SeedSpec::new()).await;
let (helper, opened, port) = refresh_only_helper_for(&HelperSpec::new(&base), &store);
assert_eq!(
helper
.get_access_token()
.await
.expect("an unattended refresh"),
"refreshed-access-token"
);
assert_eq!(wire.refreshes().len(), 1);
assert_nothing_interactive_happened(&opened, port);
assert_eq!(
stored(&store, &key).await.access_token(),
"refreshed-access-token",
"the refreshed credential is persisted, so the next call is a cache hit"
);
}
#[tokio::test]
async fn refresh_only_with_a_failing_refresh_is_reauth_required_and_starts_nothing() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::failure(400, json!({ "error": "invalid_grant" }).to_string()),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
seed_expired(&store, &base, &SeedSpec::new()).await;
let (helper, opened, port) = refresh_only_helper_for(&HelperSpec::new(&base), &store);
let started = std::time::Instant::now();
let err = helper
.get_access_token()
.await
.expect_err("a failing refresh under RefreshOnly is an error, not a browser");
let elapsed = started.elapsed();
assert!(
err.is_reauth_required(),
"the refusal must carry the programmatic reauth-required identity: {err}"
);
assert_eq!(
err.reauth_issuer(),
Some(base.as_str()),
"and must name the issuer the caller has to re-authorize against"
);
assert_eq!(wire.refreshes().len(), 1, "the refresh WAS attempted first");
assert_nothing_interactive_happened(&opened, port);
assert!(
elapsed < Duration::from_secs(5),
"corroboration only: the five-minute callback wait was plainly not entered ({elapsed:?})"
);
}
#[tokio::test]
async fn refresh_only_with_no_cached_credentials_is_the_same_typed_refusal() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::ok("refreshed-access-token", Some("rotated"), Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
let (helper, opened, port) = refresh_only_helper_for(&HelperSpec::new(&base), &store);
let err = helper
.get_access_token()
.await
.expect_err("no credentials under RefreshOnly is an error");
assert!(err.is_reauth_required(), "{err}");
assert_eq!(err.reauth_issuer(), Some(base.as_str()));
assert!(
wire.refreshes().is_empty(),
"with nothing stored there is nothing to refresh"
);
assert_nothing_interactive_happened(&opened, port);
}
#[tokio::test]
async fn refresh_only_with_an_expired_token_and_no_refresh_token_refuses_distinctly() {
let (_server, _mocks, base, wire) = refresh_server(
&["openid"],
TokenReply::ok("refreshed-access-token", Some("rotated"), Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
seed_expired(&store, &base, &SeedSpec::new().refresh_token(None)).await;
let (helper, opened, port) = refresh_only_helper_for(&HelperSpec::new(&base), &store);
let err = helper
.get_access_token()
.await
.expect_err("no refresh token under RefreshOnly is an error");
assert!(err.is_reauth_required(), "{err}");
let message = err.to_string();
assert!(
message.contains("refresh token"),
"the refusal must say WHICH condition it is: {message}"
);
assert!(
wire.refreshes().is_empty(),
"there was no refresh token to present"
);
assert_nothing_interactive_happened(&opened, port);
}
#[tokio::test]
async fn refresh_only_refuses_the_explicit_login_entry_point_too() {
let (_server, _mocks, base, _wire) = refresh_server(
&["openid"],
TokenReply::ok("refreshed-access-token", Some("rotated"), Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
let (helper, opened, port) = refresh_only_helper_for(&HelperSpec::new(&base), &store);
let err = helper
.authorize_with_details()
.await
.expect_err("an explicit login under RefreshOnly is a contradiction");
assert!(err.is_reauth_required(), "{err}");
assert_nothing_interactive_happened(&opened, port);
}
#[tokio::test]
async fn a_reauth_required_from_an_issuer_change_still_names_the_change() {
let (_server, _mocks, base, _wire) = refresh_server(
&["openid"],
TokenReply::ok("refreshed-access-token", Some("rotated"), Some(3600)),
)
.await;
let store: Arc<dyn CredentialStore> = Arc::new(InMemoryCredentialStore::new());
let server_key = normalize_server_key(&base).expect("normalized");
store
.record_issuer(&server_key, "https://previous-as.example")
.await
.expect("seeding the previous issuer");
let (helper, opened, port) = refresh_only_helper_for(&HelperSpec::new(&base), &store);
let err = helper
.get_access_token()
.await
.expect_err("a substitution with a pre-registered client_id is fatal");
assert!(err.is_reauth_required(), "{err}");
let message = err.to_string();
assert!(
message.contains("https://previous-as.example"),
"the refusal must name the OLD authorization server: {message}"
);
assert!(
message.contains(&base),
"the refusal must name the NEW authorization server: {message}"
);
assert!(
message.contains(&server_key),
"the refusal must name the MCP server the change is about: {message}"
);
assert_nothing_interactive_happened(&opened, port);
}