use crate::shared::BoxFuture;
use std::sync::{Arc, RwLock};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
sync::Mutex,
};
use crate::error::{Error, ErrorCode};
use url::{Host, ParseError, Url, form_urlencoded};
use volga_oauth_client::{
AuthorizationServerMetadata, BearerChallenge, ClientConfig, ClientError, DiscoveryClient,
OAuthClient, RegistrationClient, canonicalize_resource_uri, protected_resource_metadata_url,
};
pub use volga_oauth_client::{ClientMetadata, InMemoryTokenStore, TokenSet, TokenStore};
const DEFAULT_AUTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
const DEFAULT_CLIENT_NAME: &str = "neva MCP client";
#[derive(Debug, Clone)]
pub struct CallbackParams {
pub code: String,
pub state: String,
pub iss: Option<String>,
}
impl CallbackParams {
pub fn from_query(query: &str) -> Result<Self, Error> {
let mut code = None;
let mut state = None;
let mut iss = None;
let mut error = None;
let mut error_description = None;
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"code" => code = Some(value.into_owned()),
"state" => state = Some(value.into_owned()),
"iss" => iss = Some(value.into_owned()),
"error" => error = Some(value.into_owned()),
"error_description" => error_description = Some(value.into_owned()),
_ => {}
}
}
if let Some(error) = error {
let description = error_description.unwrap_or_default();
return Err(Error::new(
ErrorCode::InvalidRequest,
format!("authorization failed: {error}: {description}"),
));
}
match (code, state) {
(Some(code), Some(state)) => Ok(Self { code, state, iss }),
_ => Err(Error::new(
ErrorCode::InvalidRequest,
"authorization response is missing `code` or `state`",
)),
}
}
}
pub trait AuthorizationHandler: Send + Sync + 'static {
fn redirect_uri(&self) -> BoxFuture<'_, Result<String, Error>>;
fn authorize(&self, authorization_url: String) -> BoxFuture<'_, Result<CallbackParams, Error>>;
}
pub struct LoopbackHandler {
port: u16,
open_browser: bool,
timeout: std::time::Duration,
listener: Mutex<Option<TcpListener>>,
}
impl std::fmt::Debug for LoopbackHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoopbackHandler")
.field("port", &self.port)
.field("open_browser", &self.open_browser)
.field("timeout", &self.timeout)
.finish()
}
}
impl Default for LoopbackHandler {
fn default() -> Self {
Self {
port: 0,
open_browser: true,
timeout: DEFAULT_AUTH_TIMEOUT,
listener: Mutex::new(None),
}
}
}
impl LoopbackHandler {
pub fn new() -> Self {
Self::default()
}
pub fn with_port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn without_browser(mut self) -> Self {
self.open_browser = false;
self
}
pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = timeout;
self
}
async fn accept_callback(&self) -> Result<CallbackParams, Error> {
let listener = self.listener.lock().await.take().ok_or_else(|| {
Error::new(
ErrorCode::InternalError,
"loopback listener is not bound; `redirect_uri` must be called first",
)
})?;
let (mut stream, _) = listener.accept().await.map_err(Error::from)?;
let mut buf = vec![0u8; 8192];
let mut len = 0;
loop {
let n = stream.read(&mut buf[len..]).await.map_err(Error::from)?;
len += n;
if n == 0 || len == buf.len() || buf[..len].windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let params = parse_callback_request(&buf[..len]);
let (status, body) = match ¶ms {
Ok(_) => (
"200 OK",
"<html><body><h3>Authorization complete.</h3>You can close this tab and return to the application.</body></html>",
),
Err(_) => (
"400 Bad Request",
"<html><body><h3>Authorization failed.</h3>Check the application logs.</body></html>",
),
};
let resp = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes()).await;
let _ = stream.shutdown().await;
params
}
}
fn parse_callback_request(raw: &[u8]) -> Result<CallbackParams, Error> {
let line = raw
.split(|&b| b == b'\r' || b == b'\n')
.next()
.unwrap_or_default();
let line = std::str::from_utf8(line)
.map_err(|_| Error::new(ErrorCode::InvalidRequest, "malformed callback request"))?;
let target = line
.split(' ')
.nth(1)
.ok_or_else(|| Error::new(ErrorCode::InvalidRequest, "malformed callback request"))?;
let query = target.split_once('?').map(|(_, q)| q).unwrap_or_default();
CallbackParams::from_query(query)
}
impl AuthorizationHandler for LoopbackHandler {
fn redirect_uri(&self) -> BoxFuture<'_, Result<String, Error>> {
Box::pin(async move {
let listener = TcpListener::bind(("127.0.0.1", self.port))
.await
.map_err(Error::from)?;
let port = listener.local_addr().map_err(Error::from)?.port();
*self.listener.lock().await = Some(listener);
Ok(format!("http://127.0.0.1:{port}/callback"))
})
}
fn authorize(&self, authorization_url: String) -> BoxFuture<'_, Result<CallbackParams, Error>> {
Box::pin(async move {
#[cfg(feature = "tracing")]
tracing::info!(logger = "neva", "authorize at: {authorization_url}");
if self.open_browser {
open_in_browser(&authorization_url);
}
tokio::time::timeout(self.timeout, self.accept_callback())
.await
.map_err(|_| Error::new(ErrorCode::InternalError, "authorization timed out"))?
})
}
}
fn open_in_browser(url: &str) {
#[cfg(target_os = "macos")]
let result = std::process::Command::new("open").arg(url).spawn();
#[cfg(target_os = "linux")]
let result = std::process::Command::new("xdg-open").arg(url).spawn();
#[cfg(target_os = "windows")]
let result = std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.spawn();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let result: std::io::Result<std::process::Child> = Err(std::io::Error::other(
"no known browser launcher for this platform",
));
if let Err(_err) = result {
#[cfg(feature = "tracing")]
tracing::warn!(logger = "neva", "failed to open the browser: {_err}");
}
}
pub struct OAuthClientConfig {
client_id: Option<String>,
client_secret: Option<String>,
client_id_document: Option<String>,
issuer: Option<String>,
scopes: Option<Vec<String>>,
require_https: bool,
store: Arc<dyn TokenStore>,
handler: Arc<dyn AuthorizationHandler>,
}
impl std::fmt::Debug for OAuthClientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthClientConfig")
.field("client_id", &self.client_id)
.field("client_id_document", &self.client_id_document)
.field("issuer", &self.issuer)
.field("scopes", &self.scopes)
.field("require_https", &self.require_https)
.finish()
}
}
impl Default for OAuthClientConfig {
fn default() -> Self {
Self {
client_id: None,
client_secret: None,
client_id_document: None,
issuer: None,
scopes: None,
require_https: true,
store: Arc::new(InMemoryTokenStore::new()),
handler: Arc::new(LoopbackHandler::new()),
}
}
}
impl OAuthClientConfig {
pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
self.client_id = Some(client_id.into());
self
}
pub fn with_client_id_document(mut self, url: impl Into<String>) -> Self {
self.client_id_document = Some(url.into());
self
}
pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
self.issuer = Some(issuer.into());
self
}
pub fn with_client_secret(mut self, secret: impl Into<String>) -> Self {
self.client_secret = Some(secret.into());
self
}
pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.scopes = Some(scopes.into_iter().map(Into::into).collect());
self
}
pub fn require_https(mut self, required: bool) -> Self {
self.require_https = required;
self
}
pub fn with_token_store(mut self, store: impl TokenStore + 'static) -> Self {
self.store = Arc::new(store);
self
}
pub fn with_handler(mut self, handler: impl AuthorizationHandler) -> Self {
self.handler = Arc::new(handler);
self
}
pub fn client_metadata_document<I, S>(&self, redirect_uris: I) -> Result<ClientMetadata, Error>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let Some(client_id) = &self.client_id_document else {
return Err(Error::new(
ErrorCode::InvalidRequest,
"no client id document is configured; set one with `with_client_id_document`",
));
};
validate_client_id_document_url(client_id, self.require_https)?;
let uris = redirect_uris
.into_iter()
.map(|uri| uri.as_ref().to_owned())
.collect::<Vec<_>>();
if uris.is_empty() {
return Err(Error::new(
ErrorCode::InvalidRequest,
"a client id document must list at least one redirect URI",
));
}
let mut metadata = registration_metadata_for(&uris);
metadata.additional_fields.insert(
"client_id".to_owned(),
serde_json::Value::String(client_id.clone()),
);
Ok(metadata)
}
fn validate(&self) -> Result<(), Error> {
match (&self.client_id, &self.client_id_document) {
(Some(_), Some(_)) => Err(Error::new(
ErrorCode::InvalidRequest,
"`with_client_id` and `with_client_id_document` are alternatives; \
configure the pre-registered id or the document URL, not both",
)),
(None, Some(url)) if self.client_secret.is_some() => Err(Error::new(
ErrorCode::InvalidRequest,
format!(
"a client id document describes a public client, so `{url}` \
cannot be paired with a client secret"
),
)),
(None, Some(url)) => validate_client_id_document_url(url, self.require_https),
_ => Ok(()),
}
}
fn client_identity(&self) -> &str {
self.client_id
.as_deref()
.or(self.client_id_document.as_deref())
.unwrap_or_default()
}
fn client_id_source<'a>(&'a self, server: &AuthorizationServerMetadata) -> ClientIdSource<'a> {
if let Some(client_id) = &self.client_id {
return ClientIdSource::PreRegistered(client_id);
}
let advertised = client_id_metadata_document_supported(server);
match &self.client_id_document {
Some(url) if advertised == Some(true) => ClientIdSource::Document(url),
Some(url) if advertised.is_none() && server.registration_endpoint.is_none() => {
ClientIdSource::Document(url)
}
_ => ClientIdSource::Dynamic,
}
}
fn client_config(&self) -> ClientConfig {
ClientConfig::new().require_https(self.require_https)
}
}
struct FlowState {
client: OAuthClient,
metadata: AuthorizationServerMetadata,
store_key: Arc<str>,
}
const REFRESH_LEEWAY: std::time::Duration = std::time::Duration::from_secs(30);
pub(crate) struct OAuthSession {
config: OAuthClientConfig,
resource: String,
store_key: RwLock<Arc<str>>,
token: RwLock<Option<Arc<str>>>,
flow: Mutex<Option<FlowState>>,
requested_scopes: RwLock<Vec<String>>,
}
impl std::fmt::Debug for OAuthSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthSession")
.field("resource", &self.resource)
.finish()
}
}
impl OAuthSession {
pub(crate) fn new(config: OAuthClientConfig, server_url: &str) -> Result<Self, Error> {
config.validate()?;
let resource = canonicalize_resource_uri(server_url)
.map_err(|err| Error::new(ErrorCode::InternalError, err.to_string()))?;
let store_key = Self::initial_store_key(&config, &resource);
let token = config
.store
.get(&store_key)
.filter(|tokens| !tokens.is_expired())
.map(|tokens| tokens.access_token.into());
Ok(Self {
config,
resource,
store_key: RwLock::new(store_key.into()),
token: RwLock::new(token),
flow: Mutex::new(None),
requested_scopes: RwLock::new(Vec::new()),
})
}
fn store_key(&self) -> Arc<str> {
self.store_key
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
fn set_store_key(&self, key: &str) {
let mut current = self
.store_key
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if **current != *key {
*current = Arc::from(key);
}
}
fn initial_store_key(config: &OAuthClientConfig, resource: &str) -> String {
Self::compose_store_key(
config.issuer.as_deref().unwrap_or_default(),
config.client_identity(),
resource,
)
}
fn store_key_for(&self, issuer: &str, source: ClientIdSource<'_>) -> String {
let issuer = match self.config.issuer {
Some(_) => issuer,
None => "",
};
Self::compose_store_key(issuer, source.persistent_id(), &self.resource)
}
fn compose_store_key(issuer: &str, client: &str, resource: &str) -> String {
format!("{issuer}|{client}|{resource}")
}
fn requested_scopes(&self) -> Vec<String> {
let asked = self
.requested_scopes
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
if !asked.is_empty() {
return asked;
}
self.config
.store
.get(&self.store_key())
.and_then(|tokens| tokens.scope)
.map(|granted| split_scopes(&granted))
.filter(|granted| !granted.is_empty())
.or_else(|| self.config.scopes.clone())
.unwrap_or_default()
}
fn set_requested_scopes(&self, scopes: Vec<String>) {
*self
.requested_scopes
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = scopes;
}
pub(crate) fn bearer(&self) -> Option<Arc<str>> {
self.token
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
fn set_token(&self, token: Arc<str>) {
*self
.token
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(token);
}
pub(crate) async fn refreshed_bearer(&self) -> Option<Arc<str>> {
let stale = self
.config
.store
.get(&self.store_key())
.is_some_and(|tokens| tokens.expires_within(REFRESH_LEEWAY));
if !stale {
return self.bearer();
}
let mut flow = self.flow.lock().await;
self.maintain(&mut flow).await.or_else(|| self.bearer())
}
async fn maintain(&self, state: &mut Option<FlowState>) -> Option<Arc<str>> {
let FlowState {
client,
metadata,
store_key,
} = state.as_ref()?;
self.refresh_with(client, metadata, store_key.clone()).await
}
async fn refresh_with(
&self,
client: &OAuthClient,
metadata: &AuthorizationServerMetadata,
store_key: Arc<str>,
) -> Option<Arc<str>> {
let carried = self
.config
.store
.get(&store_key)
.and_then(|tokens| tokens.scope);
match client.token(&store_key, metadata).await {
Ok(Some(mut tokens)) => {
let granted = tokens.scope.clone().or(carried);
if tokens.scope.is_none()
&& let Some(scope) = granted.clone()
{
tokens.scope = Some(scope);
self.config.store.put(&store_key, &tokens);
}
if let Some(scope) = granted.as_deref() {
let scopes = split_scopes(scope);
if !scopes.is_empty() {
self.set_requested_scopes(scopes);
}
}
self.set_store_key(&store_key);
let token: Arc<str> = tokens.access_token.into();
self.set_token(token.clone());
Some(token)
}
Ok(None) => None,
Err(_err) => {
#[cfg(feature = "tracing")]
tracing::warn!(logger = "neva", "token refresh failed: {_err}");
None
}
}
}
pub(crate) async fn authorize(
&self,
www_authenticate: Option<&str>,
used: Option<&str>,
) -> Result<Arc<str>, Error> {
let mut flight = self.flow.lock().await;
let challenge = www_authenticate.and_then(|header| BearerChallenge::parse(header).ok());
let demanded = challenge
.as_ref()
.and_then(|challenge| challenge.scope())
.map(split_scopes)
.unwrap_or_default();
let insufficient = challenge.as_ref().is_some_and(|challenge| {
matches!(
challenge.error(),
Some(volga_oauth_client::OAuthErrorCode::InsufficientScope)
)
});
let held = self.requested_scopes();
let uncovered = demanded.iter().any(|scope| !held.contains(scope));
let step_up = insufficient || uncovered;
let unverifiable = step_up && demanded.is_empty();
if !uncovered
&& !unverifiable
&& let Some(current) = self.bearer()
&& used != Some(&*current)
{
return Ok(current);
}
if step_up && let Some(configured) = &self.config.scopes {
let missing = demanded
.iter()
.filter(|scope| !configured.contains(scope))
.cloned()
.collect::<Vec<_>>();
if !missing.is_empty() {
return Err(Error::new(
ErrorCode::InvalidRequest,
format!(
"the server requires scope `{}`, which this client is not \
configured to request; add it to `with_scopes`",
missing.join(" ")
),
));
}
}
if !step_up
&& let Some(token) = self.maintain(&mut flight).await
&& used != Some(&*token)
{
return Ok(token);
}
let stated = challenge
.as_ref()
.and_then(|challenge| challenge.resource_metadata().map(str::to_owned));
let discovery = DiscoveryClient::with_config(self.config.client_config());
let resource_metadata = match stated {
Some(url) => discovery
.fetch_resource_metadata_from_url(&url, Some(&self.resource))
.await
.map_err(flow_error)?,
None => self.discover_resource_metadata(&discovery).await?,
};
let server_metadata = discovery
.discover_authorization_server(&resource_metadata)
.await
.map_err(flow_error)?;
let source = self.config.client_id_source(&server_metadata);
self.check_issuer_binding(source, &server_metadata)?;
if source == ClientIdSource::Dynamic && server_metadata.registration_endpoint.is_none() {
return Err(Error::new(
ErrorCode::InvalidRequest,
format!(
"`{}` supports neither dynamic client registration nor client id \
metadata documents, so this client cannot obtain a client id there; \
register one out of band and configure it with `with_client_id`",
server_metadata.issuer
),
));
}
let redirect_uri = self.config.handler.redirect_uri().await?;
let client = self
.build_client(source, &server_metadata, &redirect_uri)
.await?;
let store_key: Arc<str> =
Arc::from(self.store_key_for(&server_metadata.issuer, source).as_str());
if !step_up
&& self.may_reuse_stored_refresh(source, &server_metadata)
&& let Some(token) = self
.refresh_with(&client, &server_metadata, store_key.clone())
.await
&& used != Some(&*token)
{
*flight = Some(FlowState {
client,
metadata: server_metadata,
store_key,
});
return Ok(token);
}
let mut scopes = match &self.config.scopes {
Some(configured) => configured.clone(),
None if !demanded.is_empty() => demanded.clone(),
None => resource_metadata.scopes_supported.clone(),
};
for held in self.requested_scopes() {
if !scopes.contains(&held) {
scopes.push(held);
}
}
let request = client
.authorization_request(&server_metadata)
.with_scopes(scopes.clone())
.with_resource(resource_metadata.resource.clone())
.build()
.map_err(flow_error)?;
let params = self.config.handler.authorize(request.url.clone()).await?;
if !request.matches_state(¶ms.state) {
return Err(Error::new(
ErrorCode::InvalidRequest,
"authorization response `state` mismatch",
));
}
validate_issuer(¶ms, &server_metadata)?;
let tokens = client
.exchange_code(&server_metadata, ¶ms.code, &request)
.await
.map_err(flow_error)?;
let mut tokens = tokens;
let granted = tokens
.scope
.as_deref()
.map(split_scopes)
.filter(|granted| !granted.is_empty())
.unwrap_or(scopes);
if tokens.scope.is_none() && !granted.is_empty() {
tokens.scope = Some(granted.join(" "));
}
self.config.store.put(&store_key, &tokens);
self.set_store_key(&store_key);
*flight = Some(FlowState {
client,
metadata: server_metadata,
store_key,
});
self.set_requested_scopes(granted);
let token: Arc<str> = tokens.access_token.into();
self.set_token(token.clone());
Ok(token)
}
fn check_issuer_binding(
&self,
source: ClientIdSource<'_>,
metadata: &AuthorizationServerMetadata,
) -> Result<(), Error> {
let ClientIdSource::PreRegistered(client_id) = source else {
return Ok(());
};
let Some(bound_to) = &self.config.issuer else {
return Ok(());
};
if bound_to == &metadata.issuer {
return Ok(());
}
Err(Error::new(
ErrorCode::InvalidRequest,
format!(
"client `{client_id}` is registered with `{bound_to}`, but \
`{}` now names `{}` as its authorization server; \
credentials are not portable between them",
self.resource, metadata.issuer
),
))
}
fn may_reuse_stored_refresh(
&self,
source: ClientIdSource<'_>,
metadata: &AuthorizationServerMetadata,
) -> bool {
if !source.survives_a_restart() {
return false;
}
if self.config.issuer.is_none() {
#[cfg(feature = "tracing")]
tracing::debug!(
logger = "neva",
"not offering the stored refresh token to {}: the credentials name no \
issuer, so nothing says it came from there. Set `with_issuer` to reuse it.",
metadata.issuer
);
return false;
}
true
}
async fn discover_resource_metadata(
&self,
discovery: &DiscoveryClient,
) -> Result<volga_oauth_client::ProtectedResourceMetadata, Error> {
let path_based = protected_resource_metadata_url(&self.resource)
.map_err(|err| Error::new(ErrorCode::InternalError, err.to_string()))?;
let first = discovery
.fetch_resource_metadata_from_url(&path_based, Some(&self.resource))
.await;
let Err(err) = first else {
return first.map_err(flow_error);
};
if !matches!(err, ClientError::Http(status) if status.as_u16() == 404) {
return Err(flow_error(err));
}
let Some(origin) = origin_of(&self.resource) else {
return Err(flow_error(err));
};
let root = format!("{origin}{WELL_KNOWN_PROTECTED_RESOURCE}");
if root == path_based {
return Err(flow_error(err));
}
#[cfg(feature = "tracing")]
tracing::debug!(
logger = "neva",
"no resource metadata at {path_based}; trying {root}"
);
discovery
.fetch_resource_metadata_from_url(&root, Some(&origin))
.await
.map_err(|root_err| {
Error::new(
ErrorCode::InternalError,
format!(
"OAuth flow failed: no usable resource metadata \
at {path_based} ({err}) or {root} ({root_err})"
),
)
})
}
async fn build_client(
&self,
source: ClientIdSource<'_>,
server_metadata: &AuthorizationServerMetadata,
redirect_uri: &str,
) -> Result<OAuthClient, Error> {
let client = match source {
ClientIdSource::PreRegistered(client_id) => {
let mut client = OAuthClient::new(client_id);
if let Some(secret) = &self.config.client_secret {
client = client.with_secret(secret.clone());
}
client
}
ClientIdSource::Document(url) => OAuthClient::new(url),
ClientIdSource::Dynamic => {
let registration = RegistrationClient::with_config(self.config.client_config());
let response = registration
.register(server_metadata, ®istration_metadata(redirect_uri))
.await
.map_err(flow_error)?;
OAuthClient::from_registration(&response).map_err(flow_error)?
}
};
Ok(client
.with_config(self.config.client_config())
.with_redirect_uri(redirect_uri)
.with_token_store(self.config.store.clone()))
}
}
fn registration_metadata(redirect_uri: &str) -> ClientMetadata {
registration_metadata_for(std::slice::from_ref(&redirect_uri))
}
fn registration_metadata_for<S: AsRef<str>>(redirect_uris: &[S]) -> ClientMetadata {
let mut metadata = ClientMetadata::default()
.with_redirect_uris(redirect_uris.iter().map(AsRef::as_ref))
.with_grant_types(["authorization_code", "refresh_token"])
.with_response_types(["code"])
.with_token_endpoint_auth_method("none")
.with_client_name(DEFAULT_CLIENT_NAME);
if redirect_uris
.iter()
.any(|uri| is_loopback_redirect(uri.as_ref()))
{
metadata = metadata.with_application_type("native");
}
metadata
}
fn client_id_metadata_document_supported(server: &AuthorizationServerMetadata) -> Option<bool> {
server
.additional_fields
.get("client_id_metadata_document_supported")
.and_then(serde_json::Value::as_bool)
}
fn validate_client_id_document_url(url: &str, require_https: bool) -> Result<(), Error> {
let invalid = |reason: &str| {
Err(Error::new(
ErrorCode::InvalidRequest,
format!("client id document URL `{url}` {reason}"),
))
};
if url.contains('#') {
return invalid("must not carry a fragment");
}
let canonical = match canonicalize_resource_uri(url) {
Ok(canonical) => canonical,
Err(err) => return invalid(&format!("is not a valid URL: {err}")),
};
let parsed = match Url::parse(&canonical) {
Ok(parsed) => parsed,
Err(ParseError::InvalidPort) => return invalid("must name a port in the 0-65535 range"),
Err(err) => return invalid(&format!("is not a valid URL: {err}")),
};
match parsed.scheme() {
"https" => {}
"http" if !require_https => {}
"http" => {
return invalid("must use the `https` scheme (or set `require_https(false)`)");
}
_ => return invalid("must be an absolute `https` URL"),
}
if matches!(parsed.path(), "" | "/") {
return invalid("must contain a path component, e.g. `https://example.com/client.json`");
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClientIdSource<'a> {
PreRegistered(&'a str),
Document(&'a str),
Dynamic,
}
impl ClientIdSource<'_> {
fn persistent_id(&self) -> &str {
match self {
Self::PreRegistered(client_id) => client_id,
Self::Document(url) => url,
Self::Dynamic => "",
}
}
fn survives_a_restart(&self) -> bool {
!matches!(self, Self::Dynamic)
}
}
fn is_loopback_redirect(uri: &str) -> bool {
let Ok(url) = Url::parse(uri) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
match url.host() {
Some(Host::Domain(host)) => host == "localhost",
Some(Host::Ipv4(ip)) => ip.is_loopback(),
Some(Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
}
}
fn validate_issuer(
params: &CallbackParams,
metadata: &AuthorizationServerMetadata,
) -> Result<(), Error> {
let supported = metadata.authorization_response_iss_parameter_supported;
match (¶ms.iss, supported) {
(Some(iss), _) if *iss != metadata.issuer => Err(Error::new(
ErrorCode::InvalidRequest,
format!(
"authorization response `iss` mismatch: expected {}, got {iss}",
metadata.issuer
),
)),
(None, true) => Err(Error::new(
ErrorCode::InvalidRequest,
"authorization server advertises RFC 9207 but the response carries no `iss`",
)),
_ => Ok(()),
}
}
fn flow_error(err: ClientError) -> Error {
Error::new(
ErrorCode::InternalError,
format!("OAuth flow failed: {err}"),
)
}
fn split_scopes(scope: &str) -> Vec<String> {
scope
.split_whitespace()
.map(str::to_owned)
.collect::<Vec<_>>()
}
const WELL_KNOWN_PROTECTED_RESOURCE: &str = "/.well-known/oauth-protected-resource";
fn origin_of(resource: &str) -> Option<String> {
let origin = Url::parse(resource).ok()?.origin();
origin.is_tuple().then(|| origin.ascii_serialization())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_parses_callback_query() {
let params =
CallbackParams::from_query("code=abc&state=xyz&iss=https%3A%2F%2Fauth.example.com")
.unwrap();
assert_eq!(params.code, "abc");
assert_eq!(params.state, "xyz");
assert_eq!(params.iss.as_deref(), Some("https://auth.example.com"));
}
#[test]
fn it_rejects_error_responses() {
let err = CallbackParams::from_query("error=access_denied&error_description=nope&state=s")
.unwrap_err();
assert!(err.to_string().contains("access_denied"));
}
#[test]
fn it_rejects_missing_code_or_state() {
assert!(CallbackParams::from_query("code=abc").is_err());
assert!(CallbackParams::from_query("state=xyz").is_err());
}
#[test]
fn token_endpoint_futures_are_send() {
fn assert_send<T: Send>(_: T) {}
let client = OAuthClient::new("client-id");
let metadata = as_metadata(None)
.with_authorization_endpoint("https://auth.example.com/authorize")
.with_token_endpoint("https://auth.example.com/token");
let request = client
.authorization_request(&metadata)
.with_scopes(["openid"])
.build()
.unwrap();
assert_send(client.exchange_code(&metadata, "code", &request));
assert_send(client.refresh(&metadata, "refresh-token"));
}
#[test]
fn loopback_redirects_are_detected() {
assert!(is_loopback_redirect("http://127.0.0.1:8919/callback"));
assert!(is_loopback_redirect("http://localhost/callback"));
assert!(is_loopback_redirect("http://[::1]:9000/callback"));
assert!(!is_loopback_redirect("https://my.app/oauth/callback"));
assert!(!is_loopback_redirect("res://localhost"));
}
#[test]
fn the_whole_loopback_range_counts_as_loopback() {
assert!(is_loopback_redirect("http://127.0.0.2:8919/callback"));
assert!(is_loopback_redirect("http://127.1.2.3/callback"));
assert!(is_loopback_redirect("http://[::0:0:1]:9000/callback"));
assert!(!is_loopback_redirect("http://128.0.0.1/callback"));
assert!(!is_loopback_redirect("http://126.255.255.255/callback"));
assert!(!is_loopback_redirect("http://localhost.evil.com/callback"));
assert!(!is_loopback_redirect("http://not-a-url"));
}
#[test]
fn loopback_registration_declares_a_native_client() {
let metadata = registration_metadata("http://127.0.0.1:8919/callback");
assert_eq!(metadata.application_type.as_deref(), Some("native"));
assert_eq!(metadata.token_endpoint_auth_method.as_deref(), Some("none"));
let json = serde_json::to_value(&metadata).unwrap();
assert_eq!(json["application_type"], serde_json::json!("native"));
}
#[test]
fn the_root_metadata_location_is_derived_from_the_origin() {
assert_eq!(
origin_of("https://api.example.com/mcp").as_deref(),
Some("https://api.example.com")
);
assert_eq!(
origin_of("http://127.0.0.1:8001/deep/path?x=1").as_deref(),
Some("http://127.0.0.1:8001")
);
assert!(origin_of("not-a-url").is_none());
assert!(origin_of("https://").is_none());
}
#[test]
fn scopes_split_on_whitespace() {
assert_eq!(
split_scopes("mcp:basic mcp:write\tmcp:read"),
["mcp:basic", "mcp:write", "mcp:read"]
);
assert!(split_scopes(" ").is_empty());
}
#[test]
fn web_registration_stays_a_web_client() {
let metadata = registration_metadata("https://my.app/oauth/callback");
assert!(metadata.application_type.is_none());
let json = serde_json::to_value(&metadata).unwrap();
assert!(json.get("application_type").is_none());
}
fn key(issuer: &str, client: &str, resource: &str) -> String {
format!("{issuer}|{client}|{resource}")
}
const CIMD_URL: &str = "https://app.example.com/mcp-client.json";
#[test]
fn a_client_id_document_url_must_be_https_with_a_path() {
assert!(validate_client_id_document_url(CIMD_URL, true).is_ok());
assert!(validate_client_id_document_url("https://example.com/c", true).is_ok());
assert!(validate_client_id_document_url("https://example.com:8443/c", true).is_ok());
assert!(validate_client_id_document_url("https://[::1]:8443/c.json", false).is_ok());
assert!(validate_client_id_document_url("https://example.com/c?v=2", true).is_ok());
assert!(validate_client_id_document_url("HTTPS://Example.COM/c.json", true).is_ok());
assert!(validate_client_id_document_url("https://example.com", true).is_err());
assert!(validate_client_id_document_url("https://example.com/", true).is_err());
assert!(validate_client_id_document_url("https://example.com/?x=1", true).is_err());
assert!(
validate_client_id_document_url("https://example.com?to=/client.json", true).is_err()
);
let err = validate_client_id_document_url("https://example.com:99999/c.json", true)
.expect_err("a port outside the range must be refused");
assert!(err.to_string().contains("0-65535"), "{err}");
assert!(validate_client_id_document_url("https://example.com:65535/c.json", true).is_ok());
assert!(validate_client_id_document_url("https://example.com/c#f", true).is_err());
assert!(validate_client_id_document_url("https://example.com/#f", true).is_err());
assert!(validate_client_id_document_url("https:///client.json", true).is_err());
assert!(validate_client_id_document_url("not-a-url", true).is_err());
assert!(validate_client_id_document_url("client.json", true).is_err());
assert!(validate_client_id_document_url("http://localhost:9/c.json", true).is_err());
assert!(validate_client_id_document_url("http://localhost:9/c.json", false).is_ok());
}
#[test]
fn a_malformed_client_id_document_url_is_refused() {
for url in [
"https://[::1/client.json", "https://example.com:bad/c.json", "https://user@example.com/c.json", "https://exa mple.com/c.json", "https://exam\u{00a0}ple.com/c.json", ] {
let err = validate_client_id_document_url(url, true)
.expect_err("a URL a server cannot fetch must be refused");
assert!(err.to_string().contains("not a valid URL"), "{url}: {err}");
}
}
#[test]
fn a_bad_client_id_document_url_fails_when_the_client_is_built() {
let config = OAuthClientConfig::default().with_client_id_document("https://example.com");
let err = OAuthSession::new(config, "https://api.example.com/mcp").unwrap_err();
assert!(err.to_string().contains("path component"), "{err}");
}
#[test]
fn a_client_id_document_cannot_be_paired_with_a_secret() {
let config = OAuthClientConfig::default()
.with_client_id_document(CIMD_URL)
.with_client_secret("s3cret");
let err = OAuthSession::new(config, "https://api.example.com/mcp").unwrap_err();
assert!(err.to_string().contains("public client"), "{err}");
}
#[test]
fn a_pre_registered_id_and_a_document_are_alternatives() {
let config = OAuthClientConfig::default()
.with_client_id("mcp-cli")
.with_client_id_document(CIMD_URL);
let err = OAuthSession::new(config, "https://api.example.com/mcp").unwrap_err();
assert!(err.to_string().contains("alternatives"), "{err}");
}
fn as_supporting_cimd(supported: bool) -> AuthorizationServerMetadata {
serde_json::from_value(serde_json::json!({
"issuer": "https://auth.example.com",
"response_types_supported": ["code"],
"registration_endpoint": "https://auth.example.com/register",
"client_id_metadata_document_supported": supported,
}))
.unwrap()
}
#[test]
fn the_cimd_capability_is_read_off_the_wire_document() {
assert_eq!(
client_id_metadata_document_supported(&as_supporting_cimd(true)),
Some(true)
);
assert_eq!(
client_id_metadata_document_supported(&as_supporting_cimd(false)),
Some(false)
);
assert_eq!(
client_id_metadata_document_supported(&as_metadata(None)),
None,
"a server that never mentions the member has said nothing, not no"
);
}
#[test]
fn the_client_id_source_follows_the_spec_priority_order() {
let pre_registered = OAuthClientConfig::default().with_client_id("mcp-cli");
assert_eq!(
pre_registered.client_id_source(&as_supporting_cimd(true)),
ClientIdSource::PreRegistered("mcp-cli"),
"a configured id outranks everything the server advertises"
);
let document = OAuthClientConfig::default().with_client_id_document(CIMD_URL);
assert_eq!(
document.client_id_source(&as_supporting_cimd(true)),
ClientIdSource::Document(CIMD_URL)
);
assert_eq!(
document.client_id_source(&as_supporting_cimd(false)),
ClientIdSource::Dynamic,
"a server that does not resolve URL ids would see an unknown client"
);
assert_eq!(
OAuthClientConfig::default().client_id_source(&as_supporting_cimd(true)),
ClientIdSource::Dynamic,
"with no document configured there is no URL to send"
);
}
#[test]
fn a_document_is_used_when_registration_is_not_on_offer() {
let document = OAuthClientConfig::default().with_client_id_document(CIMD_URL);
assert_eq!(
document.client_id_source(&as_metadata(None)),
ClientIdSource::Document(CIMD_URL)
);
}
#[test]
fn a_document_is_not_used_where_the_server_said_it_resolves_none() {
let refuses = serde_json::from_value::<AuthorizationServerMetadata>(serde_json::json!({
"issuer": "https://auth.example.com",
"response_types_supported": ["code"],
"client_id_metadata_document_supported": false,
}))
.unwrap();
let document = OAuthClientConfig::default().with_client_id_document(CIMD_URL);
assert_eq!(
document.client_id_source(&refuses),
ClientIdSource::Dynamic,
"however little else the server offers"
);
}
#[tokio::test]
async fn a_server_offering_no_registration_mechanism_says_so() {
let addr = spawn_bare_authorization_server().await;
let resource = format!("http://{addr}/mcp");
let config = OAuthClientConfig::default()
.require_https(false)
.with_client_id_document(CIMD_URL)
.with_handler(NoInteraction);
let session = OAuthSession::new(config, &resource).unwrap();
let err = session
.authorize(None, None)
.await
.expect_err("no mechanism can produce a client id here");
assert!(err.to_string().contains("with_client_id"), "{err}");
}
#[test]
fn the_metadata_document_carries_the_client_id_and_its_redirect_uris() {
let config = OAuthClientConfig::default().with_client_id_document(CIMD_URL);
let document = config
.client_metadata_document([
"http://127.0.0.1:8919/callback",
"http://localhost:8919/callback",
])
.unwrap();
let json = serde_json::to_value(&document).unwrap();
assert_eq!(json["client_id"], serde_json::json!(CIMD_URL));
assert_eq!(json["client_name"], serde_json::json!(DEFAULT_CLIENT_NAME));
assert_eq!(
json["redirect_uris"],
serde_json::json!([
"http://127.0.0.1:8919/callback",
"http://localhost:8919/callback"
])
);
assert_eq!(json["application_type"], serde_json::json!("native"));
assert_eq!(
json["token_endpoint_auth_method"],
serde_json::json!("none")
);
}
#[test]
fn a_metadata_document_needs_a_url_and_a_redirect_uri() {
let no_url = OAuthClientConfig::default();
assert!(
no_url
.client_metadata_document(["https://my.app/cb"])
.is_err()
);
let no_redirect = OAuthClientConfig::default().with_client_id_document(CIMD_URL);
assert!(
no_redirect
.client_metadata_document(Vec::<String>::new())
.is_err()
);
}
fn session(config: OAuthClientConfig) -> OAuthSession {
OAuthSession::new(config, "https://api.example.com/mcp").unwrap()
}
#[test]
fn pre_registered_credentials_are_refused_at_another_issuer() {
let session = session(
OAuthClientConfig::default()
.with_client_id("mcp-cli")
.with_issuer("https://auth.example.com"),
);
let same = as_metadata(None);
let source = ClientIdSource::PreRegistered("mcp-cli");
assert!(session.check_issuer_binding(source, &same).is_ok());
let moved = AuthorizationServerMetadata::new("https://other.example.com");
let err = session.check_issuer_binding(source, &moved).unwrap_err();
assert!(err.to_string().contains("not portable"), "{err}");
assert!(err.to_string().contains("other.example.com"), "{err}");
}
#[test]
fn portable_client_ids_survive_a_change_of_issuer() {
let session = session(
OAuthClientConfig::default()
.with_client_id_document(CIMD_URL)
.with_issuer("https://auth.example.com"),
);
let moved = AuthorizationServerMetadata::new("https://other.example.com");
assert!(
session
.check_issuer_binding(ClientIdSource::Document(CIMD_URL), &moved)
.is_ok()
);
assert!(
session
.check_issuer_binding(ClientIdSource::Dynamic, &moved)
.is_ok()
);
}
#[test]
fn an_unbound_refresh_token_is_never_offered_to_anyone() {
let source = ClientIdSource::PreRegistered("mcp-cli");
let bound = session(
OAuthClientConfig::default()
.with_client_id("mcp-cli")
.with_issuer("https://auth.example.com"),
);
assert!(bound.may_reuse_stored_refresh(source, &as_metadata(None)));
let unbound = session(OAuthClientConfig::default().with_client_id("mcp-cli"));
assert!(!unbound.may_reuse_stored_refresh(source, &as_metadata(None)));
}
#[test]
fn a_migrated_portable_identity_renews_from_the_new_issuers_slot() {
let session = session(
OAuthClientConfig::default()
.with_client_id_document(CIMD_URL)
.with_issuer("https://auth.example.com"),
);
let moved = AuthorizationServerMetadata::new("https://other.example.com");
assert!(session.may_reuse_stored_refresh(ClientIdSource::Document(CIMD_URL), &moved));
assert_ne!(
&*session.store_key_for(&moved.issuer, ClientIdSource::Document(CIMD_URL)),
&*session.store_key(),
"and not from the slot the stale configuration names"
);
assert!(
session
.store_key_for(&moved.issuer, ClientIdSource::Document(CIMD_URL))
.starts_with("https://other.example.com|"),
"the slot is the one that server files its own tokens in"
);
}
#[test]
fn two_client_identities_do_not_share_a_slot() {
const RESOURCE: &str = "https://api.example.com/mcp";
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
let config = |document: &str| OAuthClientConfig {
store: store.clone(),
..OAuthClientConfig::default()
.with_client_id_document(document)
.with_issuer("https://auth.example.com")
};
let first = OAuthSession::new(config(CIMD_URL), RESOURCE).unwrap();
store.put(
&first.store_key(),
&TokenSet {
access_token: "the-first-clients-token".into(),
token_type: "Bearer".into(),
refresh_token: Some("the-first-clients-refresh".into()),
scope: None,
id_token: None,
expires_at: None,
},
);
let second =
OAuthSession::new(config("https://other.example.com/client.json"), RESOURCE).unwrap();
assert_ne!(&*first.store_key(), &*second.store_key());
assert_eq!(
second.bearer(),
None,
"a client must not start out holding another client's token"
);
}
#[test]
fn a_dynamically_registered_client_never_reuses_a_stored_refresh_token() {
let session = session(OAuthClientConfig::default().with_issuer("https://auth.example.com"));
assert!(!session.may_reuse_stored_refresh(ClientIdSource::Dynamic, &as_metadata(None)));
}
fn as_metadata(supported: Option<bool>) -> AuthorizationServerMetadata {
let mut metadata = AuthorizationServerMetadata::new("https://auth.example.com");
if let Some(supported) = supported {
metadata = metadata.with_authorization_response_iss_parameter(supported);
}
metadata
}
#[test]
fn an_advertised_iss_parameter_survives_deserialization() {
let doc = serde_json::json!({
"issuer": "https://auth.example.com",
"response_types_supported": ["code"],
"authorization_response_iss_parameter_supported": true,
});
let metadata: AuthorizationServerMetadata = serde_json::from_value(doc).unwrap();
assert!(metadata.authorization_response_iss_parameter_supported);
assert!(
validate_issuer(&callback(None), &metadata).is_err(),
"a server that advertised `iss` and then omitted it must be refused"
);
}
fn callback(iss: Option<&str>) -> CallbackParams {
CallbackParams {
code: "c".into(),
state: "s".into(),
iss: iss.map(str::to_owned),
}
}
#[test]
fn iss_mismatch_is_rejected() {
let err = validate_issuer(
&callback(Some("https://evil.example.com")),
&as_metadata(None),
)
.unwrap_err();
assert!(err.to_string().contains("mismatch"));
}
#[test]
fn missing_iss_with_rfc9207_support_is_rejected() {
assert!(validate_issuer(&callback(None), &as_metadata(Some(true))).is_err());
}
#[test]
fn matching_iss_passes() {
assert!(
validate_issuer(
&callback(Some("https://auth.example.com")),
&as_metadata(Some(true))
)
.is_ok()
);
}
#[test]
fn missing_iss_without_support_passes() {
assert!(validate_issuer(&callback(None), &as_metadata(None)).is_ok());
assert!(validate_issuer(&callback(None), &as_metadata(Some(false))).is_ok());
}
#[tokio::test]
async fn loopback_handler_round_trip() {
let handler = LoopbackHandler::new().without_browser();
let redirect = handler.redirect_uri().await.unwrap();
assert!(redirect.starts_with("http://127.0.0.1:"));
let addr = redirect
.strip_prefix("http://")
.and_then(|rest| rest.split('/').next())
.unwrap()
.to_owned();
let callback = tokio::spawn(async move {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
stream
.write_all(b"GET /callback?code=abc&state=xyz HTTP/1.1\r\nHost: x\r\n\r\n")
.await
.unwrap();
let mut resp = String::new();
stream.read_to_string(&mut resp).await.unwrap();
resp
});
let params = handler
.authorize("http://unused.example".into())
.await
.unwrap();
assert_eq!(params.code, "abc");
assert_eq!(params.state, "xyz");
let browser_view = callback.await.unwrap();
assert!(browser_view.starts_with("HTTP/1.1 200"));
}
async fn spawn_token_endpoint(body: &'static str) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf).await;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(resp.as_bytes()).await.unwrap();
});
addr
}
async fn spawn_static(status: &'static str, body: &'static str) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf).await;
let resp = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes()).await;
}
});
addr
}
async fn spawn_root_document() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let mut buf = [0u8; 4096];
let read = stream.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..read]).to_string();
let root = format!("http://{addr}");
let resp = if request.contains("/.well-known/oauth-protected-resource/mcp") {
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_string()
} else {
let body =
format!(r#"{{"resource":"{root}","authorization_servers":["{root}"]}}"#);
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
};
let _ = stream.write_all(resp.as_bytes()).await;
}
});
addr
}
#[tokio::test]
async fn only_a_missing_document_opens_the_origin_fallback() {
let addr = spawn_static(
"200 OK",
r#"{"resource":"http://127.0.0.1:1","authorization_servers":["http://127.0.0.1:1"]}"#,
)
.await;
let config = OAuthClientConfig::default().require_https(false);
let session = OAuthSession::new(config, &format!("http://{addr}/mcp")).unwrap();
let discovery = DiscoveryClient::with_config(session.config.client_config());
let err = session
.discover_resource_metadata(&discovery)
.await
.expect_err("a document that names another resource is not usable");
let msg = err.to_string();
assert!(
msg.contains("resource mismatch"),
"the refusal must be the one the path-based document earned: {msg}"
);
assert!(
!msg.contains("no usable resource metadata"),
"the origin must not have been tried at all: {msg}"
);
let root_only = spawn_root_document().await;
let config = OAuthClientConfig::default().require_https(false);
let session = OAuthSession::new(config, &format!("http://{root_only}/mcp")).unwrap();
let discovery = DiscoveryClient::with_config(session.config.client_config());
let found = session
.discover_resource_metadata(&discovery)
.await
.expect("the origin document answers");
assert_eq!(
found.resource,
format!("http://{root_only}"),
"the accepted document describes the origin, and says so"
);
let missing = spawn_static("404 Not Found", "{}").await;
let config = OAuthClientConfig::default().require_https(false);
let session = OAuthSession::new(config, &format!("http://{missing}/mcp")).unwrap();
let discovery = DiscoveryClient::with_config(session.config.client_config());
let err = session
.discover_resource_metadata(&discovery)
.await
.expect_err("nothing is served at either location");
let msg = err.to_string();
assert!(
msg.contains("/.well-known/oauth-protected-resource/mcp")
&& msg.contains("/.well-known/oauth-protected-resource ("),
"a 404 must try the origin and report both: {msg}"
);
}
#[tokio::test]
async fn a_challenge_pointer_is_held_to_the_url_the_client_called() {
let addr = spawn_root_document().await;
let config = OAuthClientConfig::default()
.require_https(false)
.with_handler(NoInteraction);
let session = OAuthSession::new(config, &format!("http://{addr}/mcp")).unwrap();
let challenge = format!(
r#"Bearer resource_metadata="http://{addr}/.well-known/oauth-protected-resource""#
);
let err = session
.authorize(Some(&challenge), None)
.await
.expect_err("a pointed-at document naming something other than the called URL");
let msg = err.to_string();
assert!(
msg.contains("resource mismatch"),
"the refusal must be the validation one, reached before any flow: {msg}"
);
}
fn stale_tokens() -> TokenSet {
TokenSet {
access_token: "stale-token".into(),
token_type: "Bearer".into(),
refresh_token: Some("refresh-1".into()),
scope: None,
id_token: None,
expires_at: Some(std::time::SystemTime::now()),
}
}
fn session_with(store: Arc<dyn TokenStore>, flow: Option<FlowState>) -> OAuthSession {
let config = OAuthClientConfig {
store,
..OAuthClientConfig::default()
};
OAuthSession {
config,
resource: "http://127.0.0.1:3000/mcp".into(),
store_key: RwLock::new(key("", "", "http://127.0.0.1:3000/mcp").into()),
token: RwLock::new(Some("stale-token".into())),
flow: Mutex::new(flow),
requested_scopes: RwLock::new(Vec::new()),
}
}
#[tokio::test]
async fn stale_token_is_refreshed_without_interaction() {
let addr = spawn_token_endpoint(
r#"{"access_token":"fresh-token","token_type":"Bearer","expires_in":3600}"#,
)
.await;
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
store.put(&key("", "", "http://127.0.0.1:3000/mcp"), &stale_tokens());
let flow = FlowState {
client: OAuthClient::new("cid")
.with_config(ClientConfig::new().require_https(false))
.with_token_store(store.clone()),
metadata: AuthorizationServerMetadata::new("http://issuer.local")
.with_token_endpoint(format!("http://{addr}/token")),
store_key: key("", "", "http://127.0.0.1:3000/mcp").into(),
};
let session = session_with(store.clone(), Some(flow));
let token = session.refreshed_bearer().await;
assert_eq!(token.as_deref(), Some("fresh-token"));
let stored = store
.get(&key("", "", "http://127.0.0.1:3000/mcp"))
.unwrap();
assert_eq!(stored.access_token, "fresh-token");
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-1"));
assert!(session.flow.lock().await.is_some());
}
#[tokio::test]
async fn a_renewal_keeps_the_grant_it_did_not_restate() {
let addr = spawn_token_endpoint(
r#"{"access_token":"fresh-token","token_type":"Bearer","expires_in":3600}"#,
)
.await;
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
let mut restored = stale_tokens();
restored.scope = Some("read".into());
store.put(&key("", "", "http://127.0.0.1:3000/mcp"), &restored);
let flow = FlowState {
client: OAuthClient::new("cid")
.with_config(ClientConfig::new().require_https(false))
.with_token_store(store.clone()),
metadata: AuthorizationServerMetadata::new("http://issuer.local")
.with_token_endpoint(format!("http://{addr}/token")),
store_key: key("", "", "http://127.0.0.1:3000/mcp").into(),
};
let session = session_with(store.clone(), Some(flow));
assert_eq!(
session.refreshed_bearer().await.as_deref(),
Some("fresh-token")
);
assert_eq!(
store
.get(&key("", "", "http://127.0.0.1:3000/mcp"))
.and_then(|tokens| tokens.scope)
.as_deref(),
Some("read"),
"a renewal that restated nothing must not erase the granted scope"
);
assert_eq!(
session.requested_scopes(),
vec!["read".to_string()],
"and a step-up must still have that grant to widen"
);
}
#[tokio::test]
async fn a_narrowing_renewal_is_what_the_session_remembers() {
let addr = spawn_token_endpoint(
r#"{"access_token":"fresh-token","token_type":"Bearer","expires_in":3600,"scope":"read"}"#,
)
.await;
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
let mut restored = stale_tokens();
restored.scope = Some("read write".into());
store.put(&key("", "", "http://127.0.0.1:3000/mcp"), &restored);
let flow = FlowState {
client: OAuthClient::new("cid")
.with_config(ClientConfig::new().require_https(false))
.with_token_store(store.clone()),
metadata: AuthorizationServerMetadata::new("http://issuer.local")
.with_token_endpoint(format!("http://{addr}/token")),
store_key: key("", "", "http://127.0.0.1:3000/mcp").into(),
};
let session = session_with(store.clone(), Some(flow));
session.set_requested_scopes(vec!["read".to_string(), "write".to_string()]);
assert_eq!(
session.refreshed_bearer().await.as_deref(),
Some("fresh-token")
);
assert_eq!(
store
.get(&key("", "", "http://127.0.0.1:3000/mcp"))
.and_then(|tokens| tokens.scope)
.as_deref(),
Some("read"),
"the response stated the grant, so nothing is carried over it"
);
assert_eq!(
session.requested_scopes(),
vec!["read".to_string()],
"and the session holds what the token holds, not what it used to"
);
}
#[tokio::test]
async fn fresh_token_skips_refresh() {
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
let mut tokens = stale_tokens();
tokens.expires_at =
Some(std::time::SystemTime::now() + std::time::Duration::from_secs(3600));
store.put(&key("", "", "http://127.0.0.1:3000/mcp"), &tokens);
let session = session_with(store, None);
assert_eq!(
session.refreshed_bearer().await.as_deref(),
Some("stale-token")
);
}
#[tokio::test]
async fn stale_token_without_flow_state_stays_usable() {
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
store.put(&key("", "", "http://127.0.0.1:3000/mcp"), &stale_tokens());
let session = session_with(store, None);
assert_eq!(
session.refreshed_bearer().await.as_deref(),
Some("stale-token")
);
}
#[tokio::test]
async fn session_serves_stored_unexpired_token() {
let store = InMemoryTokenStore::new();
store.put(
&key("", "", "http://127.0.0.1:3000/mcp"),
&TokenSet {
access_token: "stored-token".into(),
token_type: "Bearer".into(),
refresh_token: None,
scope: None,
id_token: None,
expires_at: None,
},
);
let config = OAuthClientConfig::default().with_token_store(store);
let session = OAuthSession::new(config, "http://127.0.0.1:3000/mcp").unwrap();
assert_eq!(session.bearer().as_deref(), Some("stored-token"));
}
#[test]
fn a_restored_grant_is_what_a_step_up_widens() {
let stored = |scope: Option<&str>| {
let store = InMemoryTokenStore::new();
store.put(
&key("", "", "http://127.0.0.1:3000/mcp"),
&TokenSet {
access_token: "stored-token".into(),
token_type: "Bearer".into(),
refresh_token: None,
scope: scope.map(str::to_owned),
id_token: None,
expires_at: None,
},
);
store
};
let config = OAuthClientConfig::default().with_token_store(stored(Some("read write")));
let session = OAuthSession::new(config, "http://127.0.0.1:3000/mcp").unwrap();
assert_eq!(
session.requested_scopes(),
vec!["read".to_string(), "write".to_string()],
"a restored grant must be held, or a step-up replaces it"
);
let config = OAuthClientConfig::default()
.with_token_store(stored(None))
.with_scopes(["read", "write"]);
let session = OAuthSession::new(config, "http://127.0.0.1:3000/mcp").unwrap();
assert_eq!(
session.requested_scopes(),
vec!["read".to_string(), "write".to_string()]
);
let config = OAuthClientConfig::default().with_token_store(stored(None));
let session = OAuthSession::new(config, "http://127.0.0.1:3000/mcp").unwrap();
assert!(session.requested_scopes().is_empty());
let config = OAuthClientConfig::default()
.with_token_store(stored(Some("read")))
.with_scopes(["read", "write"]);
let session = OAuthSession::new(config, "http://127.0.0.1:3000/mcp").unwrap();
assert_eq!(
session.requested_scopes(),
vec!["read".to_string()],
"the granted scope outranks the configured request"
);
let config = OAuthClientConfig::default()
.with_token_store(stored(Some("read")))
.with_scopes(["configured"]);
let session = OAuthSession::new(config, "http://127.0.0.1:3000/mcp").unwrap();
session.set_requested_scopes(vec!["from-this-process".to_string()]);
assert_eq!(
session.requested_scopes(),
vec!["from-this-process".to_string()]
);
}
async fn spawn_authorization_server() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let mut buf = [0u8; 8192];
let read = stream.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..read]).to_string();
let root = format!("http://{addr}");
let body = if request.contains("/.well-known/oauth-protected-resource") {
format!(r#"{{"resource":"{root}/mcp","authorization_servers":["{root}"]}}"#)
} else if request.contains("/.well-known/") {
format!(
r#"{{"issuer":"{root}","token_endpoint":"{root}/token",
"authorization_endpoint":"{root}/authorize",
"response_types_supported":["code"]}}"#
)
} else {
r#"{"access_token":"refreshed-after-restart","token_type":"Bearer","expires_in":3600}"#.to_string()
};
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes()).await;
}
});
addr
}
async fn spawn_registering_authorization_server() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let mut buf = [0u8; 8192];
let read = stream.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..read]).to_string();
let root = format!("http://{addr}");
let body = if request.contains("/.well-known/oauth-protected-resource") {
format!(r#"{{"resource":"{root}/mcp","authorization_servers":["{root}"]}}"#)
} else if request.contains("/.well-known/") {
format!(
r#"{{"issuer":"{root}","token_endpoint":"{root}/token",
"authorization_endpoint":"{root}/authorize",
"registration_endpoint":"{root}/register",
"response_types_supported":["code"]}}"#
)
} else if request.contains("/register") {
r#"{"client_id":"registered-client"}"#.to_string()
} else {
r#"{"access_token":"granted-token","token_type":"Bearer","expires_in":3600}"#
.to_string()
};
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes()).await;
}
});
addr
}
async fn spawn_cimd_authorization_server()
-> (std::net::SocketAddr, Arc<std::sync::Mutex<Vec<String>>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let seen: Arc<std::sync::Mutex<Vec<String>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
let recorder = seen.clone();
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let mut buf = [0u8; 8192];
let read = stream.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..read]).to_string();
let root = format!("http://{addr}");
if let Ok(mut seen) = recorder.lock() {
seen.push(request.clone());
}
let body = if request.contains("/.well-known/oauth-protected-resource") {
format!(r#"{{"resource":"{root}/mcp","authorization_servers":["{root}"]}}"#)
} else if request.contains("/.well-known/") {
format!(
r#"{{"issuer":"{root}","token_endpoint":"{root}/token",
"authorization_endpoint":"{root}/authorize",
"registration_endpoint":"{root}/register",
"client_id_metadata_document_supported":true,
"response_types_supported":["code"]}}"#
)
} else {
r#"{"access_token":"cimd-token","token_type":"Bearer","expires_in":3600}"#
.to_string()
};
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes()).await;
}
});
(addr, seen)
}
async fn spawn_bare_authorization_server() -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let mut buf = [0u8; 8192];
let read = stream.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..read]).to_string();
let root = format!("http://{addr}");
let body = if request.contains("/.well-known/oauth-protected-resource") {
format!(r#"{{"resource":"{root}/mcp","authorization_servers":["{root}"]}}"#)
} else {
format!(
r#"{{"issuer":"{root}","token_endpoint":"{root}/token",
"authorization_endpoint":"{root}/authorize",
"client_id_metadata_document_supported":false,
"response_types_supported":["code"]}}"#
)
};
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes()).await;
}
});
addr
}
struct EchoesState;
impl AuthorizationHandler for EchoesState {
fn redirect_uri(&self) -> BoxFuture<'_, Result<String, Error>> {
Box::pin(async { Ok("http://127.0.0.1:8919/callback".to_string()) })
}
fn authorize(&self, url: String) -> BoxFuture<'_, Result<CallbackParams, Error>> {
Box::pin(async move {
let state = url
.split(['?', '&'])
.find_map(|param| param.strip_prefix("state="))
.ok_or_else(|| {
Error::new(
ErrorCode::InvalidRequest,
"the authorization URL carried no `state`",
)
})?
.to_owned();
Ok(CallbackParams {
code: "the-code".into(),
state,
iss: None,
})
})
}
}
#[derive(Default)]
struct RecordsTheUrl(std::sync::Mutex<Option<String>>);
impl AuthorizationHandler for Arc<RecordsTheUrl> {
fn redirect_uri(&self) -> BoxFuture<'_, Result<String, Error>> {
Box::pin(async { Ok("http://127.0.0.1:8919/callback".to_string()) })
}
fn authorize(&self, url: String) -> BoxFuture<'_, Result<CallbackParams, Error>> {
if let Ok(mut seen) = self.0.lock() {
*seen = Some(url.clone());
}
EchoesState.authorize(url)
}
}
#[tokio::test]
async fn a_cimd_client_authorizes_without_registering() {
let (addr, seen) = spawn_cimd_authorization_server().await;
let resource = format!("http://{addr}/mcp");
let handler = Arc::new(RecordsTheUrl::default());
let config = OAuthClientConfig::default()
.require_https(false)
.with_client_id_document(CIMD_URL)
.with_handler(handler.clone());
let session = OAuthSession::new(config, &resource).unwrap();
let token = session.authorize(None, None).await.expect("the flow runs");
assert_eq!(&*token, "cimd-token");
let url = handler.0.lock().unwrap().clone().expect("a URL was built");
assert!(
url.contains("client_id=https%3A%2F%2Fapp.example.com%2Fmcp-client.json"),
"the document URL is what identifies the client: {url}"
);
let requests = seen.lock().unwrap().clone();
assert!(
!requests.iter().any(|req| req.contains("/register")),
"a CIMD client has nothing to register: {requests:?}"
);
}
#[tokio::test]
async fn a_portable_client_files_tokens_under_the_server_that_minted_them() {
let (addr, _) = spawn_cimd_authorization_server().await;
let resource = format!("http://{addr}/mcp");
let stale_config = "https://old-auth.example.com";
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
let config = OAuthClientConfig {
store: store.clone(),
..OAuthClientConfig::default()
.require_https(false)
.with_client_id_document(CIMD_URL)
.with_issuer(stale_config)
.with_handler(EchoesState)
};
let session = OAuthSession::new(config, &resource).unwrap();
let token = session.authorize(None, None).await.expect("the flow runs");
assert_eq!(&*token, "cimd-token");
assert!(
store.get(&key(stale_config, CIMD_URL, &resource)).is_none(),
"nothing may be filed under a server that minted none of it"
);
assert_eq!(
store
.get(&key(&format!("http://{addr}"), CIMD_URL, &resource))
.map(|tokens| tokens.access_token),
Some("cimd-token".to_owned()),
"the tokens belong to the server the flow actually ran against"
);
assert_eq!(
&*session.store_key(),
key(&format!("http://{addr}"), CIMD_URL, &resource),
"and the session follows them there, so its staleness probe is not \
left watching an empty slot"
);
}
#[tokio::test]
async fn a_migrated_portable_identity_renews_after_a_restart() {
let (addr, seen) = spawn_cimd_authorization_server().await;
let resource = format!("http://{addr}/mcp");
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
store.put(
&key(&format!("http://{addr}"), CIMD_URL, &resource),
&stale_tokens(),
);
let config = OAuthClientConfig {
store,
..OAuthClientConfig::default()
.require_https(false)
.with_client_id_document(CIMD_URL)
.with_issuer("https://old-auth.example.com")
.with_handler(NoInteraction)
};
let session = OAuthSession::new(config, &resource).unwrap();
let token = session
.authorize(None, Some("the-expired-token"))
.await
.expect("the new server's own stored token is what answers this");
assert_eq!(&*token, "cimd-token");
let requests = seen.lock().unwrap().clone();
assert!(
requests.iter().any(|req| req.contains("refresh_token")),
"renewed rather than re-authorized: {requests:?}"
);
}
#[tokio::test]
async fn a_fallback_registration_is_not_filed_under_the_document() {
let addr = spawn_registering_authorization_server().await;
let resource = format!("http://{addr}/mcp");
let issuer = format!("http://{addr}");
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
let config = OAuthClientConfig {
store: store.clone(),
..OAuthClientConfig::default()
.require_https(false)
.with_client_id_document(CIMD_URL)
.with_issuer(&issuer)
.with_handler(EchoesState)
};
let session = OAuthSession::new(config, &resource).unwrap();
let token = session.authorize(None, None).await.expect("the flow runs");
assert_eq!(&*token, "granted-token");
assert!(
store.get(&key(&issuer, CIMD_URL, &resource)).is_none(),
"a registered client's tokens must not be filed under the document"
);
assert_eq!(
store
.get(&key(&issuer, "", &resource))
.map(|tokens| tokens.access_token),
Some("granted-token".to_owned()),
"they belong to an identity that outlives nothing, and are filed as such"
);
}
#[tokio::test]
async fn an_inferred_grant_is_stored_where_a_restart_can_find_it() {
let addr = spawn_registering_authorization_server().await;
let resource = format!("http://{addr}/mcp");
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
let config = OAuthClientConfig {
store: store.clone(),
..OAuthClientConfig::default()
}
.require_https(false)
.with_handler(EchoesState);
let session = OAuthSession::new(config, &resource).unwrap();
let token = session
.authorize(
Some(r#"Bearer error="insufficient_scope", scope="admin""#),
None,
)
.await
.expect("the flow completes");
assert_eq!(&*token, "granted-token");
assert_eq!(
store
.get(&key("", "", &resource))
.and_then(|tokens| tokens.scope)
.as_deref(),
Some("admin"),
"a grant the response left implicit must still be written down"
);
let restarted = OAuthSession::new(
OAuthClientConfig {
store,
..OAuthClientConfig::default()
},
&resource,
)
.unwrap();
assert_eq!(
restarted.requested_scopes(),
vec!["admin".to_string()],
"and be there for the next step-up to widen"
);
}
struct NoInteraction;
impl AuthorizationHandler for NoInteraction {
fn redirect_uri(&self) -> BoxFuture<'_, Result<String, Error>> {
Box::pin(async { Ok("http://127.0.0.1:8919/callback".to_string()) })
}
fn authorize(&self, _url: String) -> BoxFuture<'_, Result<CallbackParams, Error>> {
Box::pin(async {
Err(Error::new(
ErrorCode::InvalidRequest,
"the stored refresh token should have been used instead",
))
})
}
}
#[tokio::test]
async fn a_stored_refresh_token_survives_a_restart() {
let addr = spawn_authorization_server().await;
let resource = format!("http://{addr}/mcp");
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
store.put(
&key(&format!("http://{addr}"), "cid", &resource),
&stale_tokens(),
);
let config = OAuthClientConfig {
store: store.clone(),
..OAuthClientConfig::default()
.require_https(false)
.with_client_id("cid")
.with_issuer(format!("http://{addr}"))
.with_handler(NoInteraction)
};
let session = OAuthSession::new(config, &resource).unwrap();
assert!(
session.flow.lock().await.is_none(),
"a restart starts with nothing cached"
);
let token = session
.authorize(None, Some("the-expired-token"))
.await
.expect("the stored refresh token is what answers this");
assert_eq!(&*token, "refreshed-after-restart");
assert!(
session.flow.lock().await.is_some(),
"and what made it work is kept, so the next refresh is the cheap path"
);
}
#[tokio::test]
async fn an_unbound_refresh_token_is_not_offered_after_a_restart() {
let addr = spawn_authorization_server().await;
let resource = format!("http://{addr}/mcp");
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
store.put(&key("", "cid", &resource), &stale_tokens());
let config = OAuthClientConfig {
store,
..OAuthClientConfig::default()
.require_https(false)
.with_client_id("cid")
.with_handler(NoInteraction)
};
let session = OAuthSession::new(config, &resource).unwrap();
let err = session
.authorize(None, Some("the-expired-token"))
.await
.expect_err("an unbound refresh token must not be spent");
assert!(
err.to_string().contains("should have been used instead",),
"the flow must reach the interactive step, not fail earlier: {err}"
);
}
#[tokio::test]
async fn a_refresh_token_does_not_follow_the_resource_to_a_new_issuer() {
let addr = spawn_authorization_server().await;
let resource = format!("http://{addr}/mcp");
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
let previous_issuer = "https://old-auth.example.com";
store.put(&key(previous_issuer, "cid", &resource), &stale_tokens());
let config = OAuthClientConfig {
store: store.clone(),
..OAuthClientConfig::default()
.require_https(false)
.with_client_id("cid")
.with_issuer(format!("http://{addr}"))
.with_handler(NoInteraction)
};
let session = OAuthSession::new(config, &resource).unwrap();
let err = session
.authorize(None, Some("the-expired-token"))
.await
.expect_err("the old server's refresh token must not be spent at the new one");
assert!(
err.to_string().contains("should have been used instead"),
"the flow must reach the interactive step, not fail earlier: {err}"
);
assert_eq!(
store
.get(&key(previous_issuer, "cid", &resource))
.map(|tokens| tokens.access_token),
Some("stale-token".to_owned()),
"the old entry must be left exactly where it was"
);
}
#[tokio::test]
async fn the_loser_of_a_step_up_takes_the_winners_token() {
let store = InMemoryTokenStore::new();
store.put(
&key("", "", "http://127.0.0.1:9/mcp"),
&TokenSet {
access_token: "widened-token".into(),
token_type: "Bearer".into(),
refresh_token: None,
scope: Some("admin".into()),
id_token: None,
expires_at: None,
},
);
let config = OAuthClientConfig::default()
.require_https(false)
.with_token_store(store);
let session = OAuthSession::new(config, "http://127.0.0.1:9/mcp").unwrap();
let token = session
.authorize(
Some(r#"Bearer error="insufficient_scope", scope="admin""#),
Some("the-refused-token"),
)
.await
.expect("the grant on record already covers the challenge");
assert_eq!(
&*token, "widened-token",
"the loser must reuse what the winner obtained"
);
}
#[tokio::test]
async fn a_scope_less_step_up_is_not_satisfied_by_a_rotated_token() {
const RESOURCE: &str = "http://127.0.0.1:9/mcp";
let store = InMemoryTokenStore::new();
store.put(
&key("", "", RESOURCE),
&TokenSet {
access_token: "rotated-token".into(),
token_type: "Bearer".into(),
refresh_token: None,
scope: Some("read".into()),
id_token: None,
expires_at: None,
},
);
let config = OAuthClientConfig::default()
.require_https(false)
.with_token_store(store);
let session = OAuthSession::new(config, RESOURCE).unwrap();
let err = session
.authorize(
Some(r#"Bearer error="insufficient_scope""#),
Some("the-refused-token"),
)
.await
.expect_err("a rotated token is not evidence of a wider grant");
assert!(
!err.to_string().contains("rotated-token"),
"the flow must be run, not short-circuited: {err}"
);
let store = InMemoryTokenStore::new();
store.put(
&key("", "", RESOURCE),
&TokenSet {
access_token: "widened-token".into(),
token_type: "Bearer".into(),
refresh_token: None,
scope: Some("admin".into()),
id_token: None,
expires_at: None,
},
);
let config = OAuthClientConfig::default()
.require_https(false)
.with_token_store(store);
let session = OAuthSession::new(config, RESOURCE).unwrap();
let token = session
.authorize(
Some(r#"Bearer error="insufficient_scope", scope="admin""#),
Some("the-refused-token"),
)
.await
.expect("a demand the grant on record covers");
assert_eq!(&*token, "widened-token");
}
#[tokio::test]
async fn a_demand_outside_the_configured_scopes_ends_the_call() {
const RESOURCE: &str = "http://127.0.0.1:9/mcp";
let config = OAuthClientConfig::default().with_scopes(["read"]);
let session = OAuthSession::new(config, RESOURCE).unwrap();
let err = session
.authorize(
Some(r#"Bearer error="insufficient_scope", scope="admin""#),
None,
)
.await
.expect_err("a scope this client may not request cannot be obtained");
let msg = err.to_string();
assert!(
msg.contains("admin") && msg.contains("with_scopes"),
"the error must name the scope and how to allow it, got: {msg}"
);
let config = OAuthClientConfig::default().with_scopes(["read", "admin"]);
let session = OAuthSession::new(config, RESOURCE).unwrap();
let err = session
.authorize(
Some(r#"Bearer error="insufficient_scope", scope="admin""#),
None,
)
.await
.expect_err("the resource is unreachable, so the flow cannot finish");
assert!(
!err.to_string().contains("with_scopes"),
"a covered demand must not be refused up front, got: {err}"
);
}
}