use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::sync::{Arc, Once, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::oneshot;
use tokio::time::sleep;
use url::Url;
use crate::client::auth::{AuthorizationServerExtras, OidcDiscoveryClient, TokenExchangeClient};
use crate::client::http_middleware::HttpMiddlewareChain;
use crate::client::oauth_middleware::{BearerToken, OAuthClientMiddleware};
use crate::error::{Error, Result};
use crate::server::auth::oauth2::OidcDiscoveryMetadata;
use crate::shared::credential_file::FileCredentialStore;
use crate::shared::credential_store::{
normalize_server_key, CredentialKey, CredentialStore, StoredCredentials,
};
use crate::shared::http_body_cap::{
collect_reqwest_body_within_cap, is_body_over_cap, DEFAULT_AUTH_RESPONSE_BYTES,
};
use crate::shared::oauth_validation::{
derive_application_type, iss_presence_from, parse_iss_env_value,
validate_authorization_response, AuthorizationRequestRecord, IssPresence,
};
use crate::shared::pkce::{code_challenge_s256, generate_code_verifier, generate_state};
const ISS_VALIDATION_ENV_VAR: &str = "PMCP_OAUTH_ISS_VALIDATION";
pub const MAX_CALLBACK_REQUEST_LINE_BYTES: usize = 16_384;
const CALLBACK_SUCCESS_RESPONSE: &str = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\
<html><body style='font-family: sans-serif; text-align: center; padding: 50px;'>\
<h1 style='color: green;'>Authentication Successful!</h1>\
<p>You can close this window and return to the terminal.</p>\
</body></html>";
const CALLBACK_FAILURE_RESPONSE: &str =
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n\
<html><body style='font-family: sans-serif; text-align: center; padding: 50px;'>\
<h1 style='color: red;'>Authentication Failed</h1>\
<p>No authorization code received. Please try again.</p>\
</body></html>";
const OFFLINE_ACCESS_SCOPE: &str = "offline_access";
const MAX_DCR_RESPONSE_BYTES: usize = DEFAULT_AUTH_RESPONSE_BYTES;
const CREDENTIAL_STORE_FILE_NAME: &str = "oauth-cache.json";
const FINGERPRINT_HEX_CHARS: usize = 12;
const HEX_DIGITS: [char; 16] = [
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
];
fn token_fingerprint(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let digest = hasher.finalize();
let mut hex = String::with_capacity(FINGERPRINT_HEX_CHARS);
for byte in digest.iter().take(FINGERPRINT_HEX_CHARS / 2) {
hex.push(HEX_DIGITS[usize::from(byte >> 4)]);
hex.push(HEX_DIGITS[usize::from(byte & 0x0f)]);
}
format!("sha256:{hex}")
}
fn unix_now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_secs())
}
fn compose_scopes_with_offline_access(
configured: &[String],
scopes_supported: &[String],
) -> Vec<String> {
let mut composed: Vec<String> = Vec::with_capacity(configured.len() + 1);
for scope in configured {
if !composed.iter().any(|held| held == scope) {
composed.push(scope.clone());
}
}
let advertised = scopes_supported
.iter()
.any(|scope| scope == OFFLINE_ACCESS_SCOPE);
if advertised && !composed.iter().any(|held| held == OFFLINE_ACCESS_SCOPE) {
composed.push(OFFLINE_ACCESS_SCOPE.to_string());
}
composed
}
fn apply_application_type(request: &mut DcrRequest) -> Result<String> {
if let Some(explicit) = request.application_type() {
return Ok(explicit.to_string());
}
let derived = derive_application_type(&request.redirect_uris)?;
request.set_application_type(derived.as_str());
Ok(derived.as_str().to_string())
}
fn application_type_divergence(sent: &str, echoed: Option<&str>) -> Option<(String, String)> {
match echoed {
Some(registered) if registered != sent => Some((sent.to_string(), registered.to_string())),
_ => None,
}
}
const MAX_DCR_ERROR_FIELD_CHARS: usize = 200;
#[derive(Debug, Default)]
struct DcrRejectionFields {
error: Option<String>,
error_description: Option<String>,
}
fn dcr_rejection_fields(body: &[u8]) -> DcrRejectionFields {
let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(body) else {
return DcrRejectionFields::default();
};
DcrRejectionFields {
error: bounded_error_field(parsed.get("error")),
error_description: bounded_error_field(parsed.get("error_description")),
}
}
fn bounded_error_field(value: Option<&serde_json::Value>) -> Option<String> {
let text = value.and_then(serde_json::Value::as_str)?;
let total = text.chars().count();
if total <= MAX_DCR_ERROR_FIELD_CHARS {
return Some(text.to_string());
}
let kept: String = text.chars().take(MAX_DCR_ERROR_FIELD_CHARS).collect();
Some(format!(
"{kept}… [truncated: {} of {total} characters withheld]",
total - MAX_DCR_ERROR_FIELD_CHARS
))
}
fn registration_rejected(
status: reqwest::StatusCode,
fields: &DcrRejectionFields,
sent_application_type: &str,
sent_redirect_uris: &[String],
) -> Error {
let server_reason = match (&fields.error, &fields.error_description) {
(Some(code), Some(description)) => {
format!("error={code}; error_description={description}")
},
(Some(code), None) => format!("error={code}"),
(None, Some(description)) => format!("error_description={description}"),
(None, None) => {
"the response body carried no RFC 7591 section 3.2.2 `error` field".to_string()
},
};
Error::internal(format!(
"DCR failed ({status}): the authorization server rejected this dynamic client \
registration. Server reason: {server_reason}\n\
\n\
The registration that was rejected declared application_type=\"{sent_application_type}\" \
with redirect_uris={sent_redirect_uris:?}. Those two are the pair an OIDC authorization \
server enforces its redirect-URI constraints over, so they are what to change.\n\
\n\
No other part of the response body is reproduced here. Pass a pre-registered client_id \
to skip DCR."
))
}
#[derive(Debug)]
struct DcrOutcome {
response: crate::server::auth::provider::DcrResponse,
registered_application_type: String,
}
pub trait BrowserLauncher: Send + Sync + std::fmt::Debug {
fn open(&self, url: &str) -> Result<()>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemBrowserLauncher;
impl BrowserLauncher for SystemBrowserLauncher {
fn open(&self, url: &str) -> Result<()> {
if let Err(e) = webbrowser::open(url) {
tracing::warn!(
"Failed to open browser: {}. Please open the URL manually.",
e
);
}
Ok(())
}
}
pub use crate::server::auth::provider::{DcrRequest, DcrResponse};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Interactivity {
#[default]
Interactive,
RefreshOnly,
}
#[derive(Debug, Clone)]
pub struct OAuthConfig {
pub issuer: Option<String>,
pub mcp_server_url: Option<String>,
pub client_id: Option<String>,
pub client_name: Option<String>,
pub dcr_enabled: bool,
pub scopes: Vec<String>,
pub cache_file: Option<PathBuf>,
pub redirect_port: u16,
}
impl Default for OAuthConfig {
fn default() -> Self {
Self {
issuer: None,
mcp_server_url: None,
client_id: None,
client_name: None,
dcr_enabled: true,
scopes: Vec::new(),
cache_file: None,
redirect_port: 8080,
}
}
}
#[derive(Debug, Clone)]
pub struct AuthorizationResult {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: Option<u64>,
pub scopes: Vec<String>,
pub issuer: Option<String>,
pub client_id: String,
}
#[derive(Debug, Clone)]
struct ResolvedClientIdentity {
client_id: String,
registered_application_type: Option<String>,
}
#[derive(Debug)]
enum StoreOutcome {
Token(String),
Miss(StoreMiss),
}
#[derive(Debug)]
enum StoreMiss {
NoCredentials,
NoRefreshToken,
RefreshFailed(Error),
}
#[derive(Debug, Deserialize)]
struct DeviceAuthResponse {
device_code: String,
user_code: String,
verification_uri: String,
#[serde(default)]
verification_uri_complete: Option<String>,
expires_in: u64,
interval: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct TokenResponse {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: Option<u64>,
token_type: String,
}
#[derive(Debug)]
pub struct OAuthHelper {
config: OAuthConfig,
client: reqwest::Client,
iss_validation: Option<IssPresence>,
browser_launcher: Arc<dyn BrowserLauncher>,
credential_store: OnceLock<Arc<dyn CredentialStore>>,
account_scope: String,
legacy_cache_warned: Once,
interactivity: Interactivity,
}
impl OAuthHelper {
pub fn new(config: OAuthConfig) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| Error::internal(format!("Failed to create HTTP client: {e}")))?;
Ok(Self {
config,
client,
iss_validation: None,
browser_launcher: Arc::new(SystemBrowserLauncher),
credential_store: OnceLock::new(),
account_scope: String::new(),
legacy_cache_warned: Once::new(),
interactivity: Interactivity::Interactive,
})
}
#[must_use]
pub fn with_interactivity(mut self, mode: Interactivity) -> Self {
self.interactivity = mode;
self
}
#[must_use]
pub fn with_credential_store(mut self, store: Arc<dyn CredentialStore>) -> Self {
self.credential_store = OnceLock::from(store);
self
}
#[must_use]
pub fn with_account_scope(mut self, account: impl Into<String>) -> Self {
self.account_scope = account.into();
self
}
#[must_use]
pub fn with_iss_validation(mut self, presence: IssPresence) -> Self {
self.iss_validation = Some(presence);
self
}
#[must_use]
pub fn with_browser_launcher(mut self, launcher: Arc<dyn BrowserLauncher>) -> Self {
self.browser_launcher = launcher;
self
}
fn resolve_iss_presence(&self, discovery_flag: Option<bool>) -> IssPresence {
let env_override = match std::env::var(ISS_VALIDATION_ENV_VAR) {
Ok(raw) => {
let parsed = parse_iss_env_value(&raw);
if parsed.is_none() {
tracing::warn!(
"{} is set to `{}`, which is not one of its two accepted values \
`strict` or `lenient`. The variable is IGNORED; the builder setting \
or the authorization server's discovery flag decides instead.",
ISS_VALIDATION_ENV_VAR,
raw
);
}
parsed
},
Err(std::env::VarError::NotUnicode(_)) => {
tracing::warn!(
"{} is set to a value that is not valid Unicode, so it cannot be one of \
the two accepted values `strict` or `lenient`. The variable is IGNORED.",
ISS_VALIDATION_ENV_VAR
);
None
},
Err(std::env::VarError::NotPresent) => None,
};
iss_presence_from(env_override, self.iss_validation, discovery_flag)
}
fn credential_store(&self) -> Option<&Arc<dyn CredentialStore>> {
if let Some(injected) = self.credential_store.get() {
return Some(injected);
}
let legacy = self.config.cache_file.as_ref()?;
let resolved: Arc<dyn CredentialStore> = Arc::new(FileCredentialStore::new(
legacy.with_file_name(CREDENTIAL_STORE_FILE_NAME),
));
Some(self.credential_store.get_or_init(|| resolved))
}
fn server_key(&self) -> Result<String> {
if let Some(ref url) = self.config.mcp_server_url {
return normalize_server_key(url);
}
if let Some(ref issuer) = self.config.issuer {
return normalize_server_key(issuer);
}
Err(Error::internal(
"cannot address stored credentials: neither mcp_server_url nor issuer is configured"
.to_string(),
))
}
fn credential_key(&self, issuer: &str, server_key: &str) -> CredentialKey {
CredentialKey::new(issuer, self.account_scope.as_str(), server_key)
}
async fn announce_authorization_server_change(
&self,
metadata: &OidcDiscoveryMetadata,
) -> Result<()> {
let Some(store) = self.credential_store() else {
return Ok(());
};
let Ok(server_key) = self.server_key() else {
return Ok(());
};
let discovered = metadata.issuer.as_str();
let previous = match store.last_issuer(&server_key).await {
Ok(previous) => previous,
Err(e) => {
tracing::warn!(
"could not read the last-seen authorization server for {server_key} ({e}); \
proceeding without substitution detection"
);
return Ok(());
},
};
let Some(previous) = previous else {
Self::record_issuer_best_effort(store, &server_key, discovered).await;
return Ok(());
};
if previous == discovered {
return Ok(());
}
if self.config.client_id.is_some() {
return Err(Error::reauth_required(
discovered,
&format!(
"the authorization server for MCP server {server_key} changed from \
{previous} to {discovered}. This client is configured with a PRE-REGISTERED \
client_id, which is specific to one authorization server, so it is neither \
reused nor exchanged at the new one and no browser flow is started. If the \
change is expected, register this client with {discovered} and update \
OAuthConfig::client_id; if it is not, treat the MCP server as compromised."
),
));
}
tracing::warn!(
"the authorization server for MCP server {} changed from {} to {}. This client's \
credentials were issued by dynamic registration, so the previous ones are neither \
reused nor sent anywhere — they are simply unreachable under the new issuer — and \
this client is re-registering with {} and asking you to log in there. If you did not \
expect that identity provider, stop and treat the MCP server as compromised.",
server_key,
previous,
discovered,
discovered
);
Self::record_issuer_best_effort(store, &server_key, discovered).await;
Ok(())
}
async fn record_issuer_best_effort(
store: &Arc<dyn CredentialStore>,
server_key: &str,
issuer: &str,
) {
if let Err(e) = store.record_issuer(server_key, issuer).await {
tracing::warn!(
"could not record the authorization server for {server_key} ({e}); a later \
substitution may go undetected until the next successful login"
);
}
}
fn discard_legacy_token_cache(&self) {
self.legacy_cache_warned.call_once(|| {
let legacy = self
.config
.cache_file
.clone()
.unwrap_or_else(default_cache_path);
if !legacy.exists() {
return;
}
tracing::warn!(
"the legacy OAuth token cache at {} is DISCARDED, not migrated: it records no \
issuer, so which authorization server issued its token cannot be determined \
without guessing — and guessing is what SEP-2352 forbids. One re-login is \
required. The file is left in place; delete it when you are ready.",
legacy.display()
);
});
}
async fn load_stored_credentials(&self, issuer: &str) -> Option<StoredCredentials> {
let store = self.credential_store()?;
let server_key = match self.server_key() {
Ok(key) => key,
Err(e) => {
tracing::warn!("cannot address stored credentials ({e}); treating as a cache miss");
return None;
},
};
match store.load(&self.credential_key(issuer, &server_key)).await {
Ok(found) => found,
Err(e) => {
tracing::warn!(
"the credential store could not be read ({e}); treating as a cache miss, \
which costs one re-login"
);
None
},
}
}
async fn persist_credentials(
&self,
issuer: &str,
result: &AuthorizationResult,
registered_application_type: Option<&str>,
) -> Result<()> {
let Some(store) = self.credential_store() else {
return Ok(());
};
let server_key = self.server_key()?;
let mut credentials = StoredCredentials::new(&result.access_token, &result.client_id)
.with_granted_scopes(result.scopes.clone());
if let Some(refresh_token) = result.refresh_token.as_deref() {
credentials = credentials.with_refresh_token(refresh_token);
}
if let Some(expires_at) = result.expires_at {
credentials = credentials.with_expires_at(expires_at);
}
if let Some(application_type) = registered_application_type {
credentials = credentials.with_registered_application_type(application_type);
}
store
.save_with_issuer(
&self.credential_key(issuer, &server_key),
&credentials,
&server_key,
issuer,
)
.await
}
fn effective_issuer(&self, metadata: &OidcDiscoveryMetadata) -> Option<String> {
self.config
.issuer
.clone()
.or_else(|| Some(metadata.issuer.clone()))
}
async fn do_dynamic_client_registration(
&self,
registration_endpoint: &str,
metadata: &OidcDiscoveryMetadata,
) -> Result<DcrOutcome> {
let parsed = Url::parse(registration_endpoint)
.map_err(|e| Error::internal(format!("Invalid registration_endpoint URL: {e}")))?;
let scheme_ok = parsed.scheme() == "https"
|| (parsed.scheme() == "http"
&& matches!(
parsed.host_str(),
Some("localhost") | Some("127.0.0.1") | Some("::1") | Some("[::1]")
));
if !scheme_ok {
return Err(Error::internal(format!(
"registration_endpoint must be https:// (or http://localhost, \
http://127.0.0.1, http://[::1]) — got {}",
registration_endpoint
)));
}
let client_name = self
.config
.client_name
.clone()
.unwrap_or_else(|| "pmcp-sdk".to_string());
let redirect_uri = format!("http://127.0.0.1:{}/callback", self.config.redirect_port);
let registered_scopes =
compose_scopes_with_offline_access(&self.config.scopes, &metadata.scopes_supported);
let mut request = crate::server::auth::provider::DcrRequest {
redirect_uris: vec![redirect_uri],
client_name: Some(client_name),
client_uri: None,
logo_uri: None,
contacts: vec![],
token_endpoint_auth_method: Some("none".to_string()),
grant_types: vec![
"authorization_code".to_string(),
"refresh_token".to_string(),
],
response_types: vec!["code".to_string()],
scope: (!registered_scopes.is_empty()).then(|| registered_scopes.join(" ")),
software_id: None,
software_version: None,
extra: Default::default(),
};
let sent_application_type = apply_application_type(&mut request)?;
let response = self
.client
.post(registration_endpoint)
.json(&request)
.send()
.await
.map_err(|e| Error::internal(format!("DCR request failed: {e}")))?;
if !response.status().is_success() {
let status = response.status();
let body = collect_reqwest_body_within_cap(response, MAX_DCR_RESPONSE_BYTES).await?;
let fields = dcr_rejection_fields(&body);
return Err(registration_rejected(
status,
&fields,
&sent_application_type,
&request.redirect_uris,
));
}
let bytes = collect_reqwest_body_within_cap(response, MAX_DCR_RESPONSE_BYTES)
.await
.map_err(|e| {
if is_body_over_cap(&e) {
Error::internal(format!(
"DCR response exceeds the {MAX_DCR_RESPONSE_BYTES} byte cap — refusing \
to parse it. {e}"
))
} else {
e
}
})?;
let registration = serde_json::from_slice::<crate::server::auth::provider::DcrResponse>(
&bytes,
)
.map_err(|e| {
Error::internal(format!(
"Failed to parse DCR response ({:?} error at line {}, column {}). The parser's \
own message is not reproduced here because a data error echoes the offending \
input, and a registration response body carries a client identity",
e.classify(),
e.line(),
e.column()
))
})?;
let echoed = registration.application_type();
if let Some((requested, registered)) =
application_type_divergence(&sent_application_type, echoed)
{
tracing::warn!(
"the registration endpoint at {} registered this client with \
application_type=\"{}\" although \"{}\" was requested. RFC 7591 section 3.2.1 \
permits an authorization server to modify requested client metadata, so the \
registration STANDS and this is not an error — but this client is now registered \
under redirect-URI constraints it did not choose.",
registration_endpoint,
registered,
requested
);
}
let registered_application_type =
echoed.unwrap_or(sent_application_type.as_str()).to_string();
Ok(DcrOutcome {
response: registration,
registered_application_type,
})
}
async fn resolve_client_identity_for_flow(
&self,
metadata: &OidcDiscoveryMetadata,
) -> Result<ResolvedClientIdentity> {
if let Some(ref id) = self.config.client_id {
return Ok(ResolvedClientIdentity {
client_id: id.clone(),
registered_application_type: None,
});
}
if !self.config.dcr_enabled {
return Err(Error::internal(
"no client_id configured and dcr_enabled is false — \
provide OAuthConfig::client_id or enable dcr_enabled"
.to_string(),
));
}
match metadata.registration_endpoint.as_ref() {
Some(endpoint) => {
tracing::info!("Performing Dynamic Client Registration at {}", endpoint);
let outcome = self
.do_dynamic_client_registration(endpoint, metadata)
.await?;
tracing::info!(
"DCR succeeded — issued client_id, registered with application_type=\"{}\"",
outcome.registered_application_type
);
Ok(ResolvedClientIdentity {
client_id: outcome.response.client_id,
registered_application_type: Some(outcome.registered_application_type),
})
},
None => Err(Error::internal(
"server does not support DCR — pass a pre-registered client_id".to_string(),
)),
}
}
#[doc(hidden)]
#[cfg(any(test, feature = "oauth"))]
pub async fn test_resolve_client_id_from_discovery(&self) -> Result<String> {
let metadata = self.get_metadata().await?;
self.resolve_client_identity_for_flow(&metadata)
.await
.map(|identity| identity.client_id)
}
fn is_terminal_authorization_refusal(error: &Error) -> bool {
error.is_iss_mismatch() || error.is_state_mismatch()
}
fn extract_base_url(mcp_url: &str) -> Result<String> {
let parsed = Url::parse(mcp_url)
.map_err(|e| Error::internal(format!("Invalid MCP server URL: {e}")))?;
let mut base = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or(""));
if let Some(port) = parsed.port() {
let is_default_port = (parsed.scheme() == "https" && port == 443)
|| (parsed.scheme() == "http" && port == 80);
if !is_default_port {
base.push_str(&format!(":{}", port));
}
}
Ok(base)
}
async fn discover_metadata_with_extras(
&self,
mcp_url: &str,
) -> Result<(OidcDiscoveryMetadata, AuthorizationServerExtras)> {
let base_url = Self::extract_base_url(mcp_url)?;
tracing::info!("Discovering OAuth configuration from {}...", base_url);
let discovery_client = OidcDiscoveryClient::new();
match discovery_client.discover_with_extras(&base_url).await {
Ok((metadata, extras)) => {
tracing::info!("OAuth discovery successful");
tracing::debug!("Issuer: {}", metadata.issuer);
if let Some(ref device_endpoint) = metadata.device_authorization_endpoint {
tracing::debug!("Device endpoint: {}", device_endpoint);
}
Ok((metadata, extras))
},
Err(e) => Err(Error::internal(format!(
"Failed to discover OAuth configuration at {}: {}\n\
\n\
Please provide --oauth-issuer explicitly, or ensure the server\n\
exposes OAuth metadata at {}/.well-known/openid-configuration",
base_url, e, base_url
))),
}
}
async fn get_metadata(&self) -> Result<OidcDiscoveryMetadata> {
self.get_metadata_with_extras()
.await
.map(|(metadata, _)| metadata)
}
async fn get_metadata_with_extras(
&self,
) -> Result<(OidcDiscoveryMetadata, AuthorizationServerExtras)> {
if let Some(ref mcp_url) = self.config.mcp_server_url {
self.discover_metadata_with_extras(mcp_url).await
} else if let Some(ref issuer) = self.config.issuer {
tracing::info!("Discovering OAuth configuration from {}...", issuer);
let discovery_client = OidcDiscoveryClient::new();
match discovery_client.discover_with_extras(issuer).await {
Ok(found) => {
tracing::info!("OAuth discovery successful");
Ok(found)
},
Err(e) => Err(Error::internal(format!(
"Failed to discover OAuth configuration from issuer {}: {}\n\
\n\
Please ensure the issuer URL exposes OAuth metadata at\n\
{}/.well-known/openid-configuration",
issuer, e, issuer
))),
}
} else {
Err(Error::internal(
"Either oauth_issuer or mcp_server_url must be provided for OAuth authentication"
.to_string(),
))
}
}
pub async fn get_access_token(&self) -> Result<String> {
self.discard_legacy_token_cache();
let (metadata, extras) = self.get_metadata_with_extras().await?;
let iss_presence = self.resolve_iss_presence(extras.iss_parameter_supported());
self.announce_authorization_server_change(&metadata).await?;
let miss = match self.token_from_store(&metadata).await? {
StoreOutcome::Token(access_token) => return Ok(access_token),
StoreOutcome::Miss(miss) => miss,
};
match self.interactivity {
Interactivity::RefreshOnly => Err(Self::refresh_only_refusal(&metadata, &miss)),
Interactivity::Interactive => self.interactive_token(&metadata, iss_presence).await,
}
}
async fn interactive_token(
&self,
metadata: &OidcDiscoveryMetadata,
iss_presence: IssPresence,
) -> Result<String> {
tracing::info!("No cached token found, starting OAuth flow...");
self.authorize_with_fallback(metadata, iss_presence)
.await
.map(|result| result.access_token)
}
fn refresh_only_refusal(metadata: &OidcDiscoveryMetadata, miss: &StoreMiss) -> Error {
let reason = match miss {
StoreMiss::NoCredentials => {
"no credentials are stored for this authorization server, account and MCP server"
.to_string()
},
StoreMiss::NoRefreshToken => {
"the stored credentials have expired and carry no refresh token".to_string()
},
StoreMiss::RefreshFailed(e) => format!("the stored refresh token was refused: {e}"),
};
Error::reauth_required(
&metadata.issuer,
&format!(
"{reason}. This helper is in Interactivity::RefreshOnly, so no browser was \
opened and no loopback listener was bound. An interactive authorization is \
required; perform one and store the result, then retry."
),
)
}
async fn token_from_store(&self, metadata: &OidcDiscoveryMetadata) -> Result<StoreOutcome> {
let Some(cached) = self.load_stored_credentials(&metadata.issuer).await else {
return Ok(StoreOutcome::Miss(StoreMiss::NoCredentials));
};
if cached.expires_at().is_some_and(|at| unix_now_secs() < at) {
tracing::info!("Using cached OAuth token");
return Ok(StoreOutcome::Token(cached.access_token().to_string()));
}
let Some(refresh_token) = cached.refresh_token() else {
return Ok(StoreOutcome::Miss(StoreMiss::NoRefreshToken));
};
tracing::warn!("OAuth token expired, refreshing...");
let refreshed = match self
.refresh_token(
refresh_token,
Some(cached.client_id()),
cached.granted_scopes(),
)
.await
{
Ok(refreshed) => refreshed,
Err(e) => {
tracing::warn!("OAuth token refresh failed: {e}");
return Ok(StoreOutcome::Miss(StoreMiss::RefreshFailed(e)));
},
};
let result = AuthorizationResult {
access_token: refreshed.access_token,
refresh_token: refreshed
.refresh_token
.or_else(|| Some(refresh_token.to_string())),
expires_at: refreshed
.expires_in
.map(|ttl| unix_now_secs().saturating_add(ttl)),
scopes: cached.granted_scopes().to_vec(),
issuer: self.effective_issuer(metadata),
client_id: cached.client_id().to_string(),
};
self.persist_credentials(
&metadata.issuer,
&result,
cached.registered_application_type(),
)
.await?;
Ok(StoreOutcome::Token(result.access_token))
}
pub async fn authorize_with_details(&self) -> Result<AuthorizationResult> {
if self.interactivity == Interactivity::RefreshOnly {
return Err(Error::reauth_required(
self.config
.issuer
.as_deref()
.unwrap_or("the authorization server"),
"authorize_with_details performs an interactive authorization, and this helper \
is in Interactivity::RefreshOnly. No browser was opened and no loopback \
listener was bound. Use get_access_token to serve the request from stored \
credentials, or build a helper without RefreshOnly to log in.",
));
}
self.discard_legacy_token_cache();
let (metadata, extras) = self.get_metadata_with_extras().await?;
let iss_presence = self.resolve_iss_presence(extras.iss_parameter_supported());
self.announce_authorization_server_change(&metadata).await?;
self.authorize_with_fallback(&metadata, iss_presence).await
}
async fn authorize_with_fallback(
&self,
metadata: &OidcDiscoveryMetadata,
iss_presence: IssPresence,
) -> Result<AuthorizationResult> {
match self
.authorization_code_flow_inner(metadata, iss_presence)
.await
{
Ok((token_response, identity)) => {
let requested_scopes = compose_scopes_with_offline_access(
&self.config.scopes,
&metadata.scopes_supported,
);
let result = Self::build_auth_result(
token_response,
identity.client_id,
self.effective_issuer(metadata),
&requested_scopes,
);
self.persist_credentials(
&metadata.issuer,
&result,
identity.registered_application_type.as_deref(),
)
.await?;
Ok(result)
},
Err(e) if Self::is_terminal_authorization_refusal(&e) => Err(e),
Err(e) => {
tracing::warn!("Authorization code flow failed: {}", e);
if metadata.device_authorization_endpoint.is_some() {
tracing::info!(
"Trying device code flow (refresh_token may be None per RFC 8628)..."
);
return self.device_code_flow_with_metadata(metadata).await;
}
Err(Error::internal(
"No supported OAuth flow available.\n\
\n\
The server must support either:\n\
- Authorization code flow (authorization_endpoint), or\n\
- Device code flow (device_authorization_endpoint)"
.to_string(),
))
},
}
}
fn build_auth_result(
token_response: crate::client::auth::TokenResponse,
client_id: String,
effective_issuer: Option<String>,
requested_scopes: &[String],
) -> AuthorizationResult {
let expires_at = token_response
.expires_in
.map(|ttl| unix_now_secs().saturating_add(ttl));
let granted_scopes = match token_response.scope.as_deref() {
Some(granted) => granted
.split_whitespace()
.map(String::from)
.collect::<Vec<_>>(),
None => requested_scopes.to_vec(),
};
AuthorizationResult {
access_token: token_response.access_token,
refresh_token: token_response.refresh_token,
expires_at,
scopes: granted_scopes,
issuer: effective_issuer,
client_id,
}
}
async fn bind_callback_listener(redirect_port: u16) -> Result<(TcpListener, String)> {
let redirect_uri = format!("http://127.0.0.1:{}/callback", redirect_port);
let listener = TcpListener::bind(format!("127.0.0.1:{}", redirect_port))
.await
.map_err(|e| {
Error::internal(format!(
"Failed to bind to 127.0.0.1:{}.\n\
\n\
This port may already be in use. Try a different port with:\n\
--oauth-redirect-port PORT\n\
\n\
Error: {e}",
redirect_port
))
})?;
tracing::debug!("Local callback server listening on port {}", redirect_port);
tracing::warn!(
"Ensure the redirect URI is registered in your OAuth provider: {}",
redirect_uri
);
Ok((listener, redirect_uri))
}
fn build_authorization_url(
&self,
metadata: &OidcDiscoveryMetadata,
client_id: &str,
redirect_uri: &str,
record: &AuthorizationRequestRecord,
) -> Result<Url> {
let mut auth_url = Url::parse(&metadata.authorization_endpoint)
.map_err(|e| Error::internal(format!("Invalid authorization endpoint: {e}")))?;
let requested_scopes =
compose_scopes_with_offline_access(&self.config.scopes, &metadata.scopes_supported);
auth_url
.query_pairs_mut()
.append_pair("client_id", client_id)
.append_pair("response_type", "code")
.append_pair("redirect_uri", redirect_uri)
.append_pair("scope", &requested_scopes.join(" "))
.append_pair(
"code_challenge",
&code_challenge_s256(record.code_verifier()),
)
.append_pair("code_challenge_method", "S256")
.append_pair("state", record.state());
Ok(auth_url)
}
async fn read_request_line_within_cap(stream: &mut TcpStream) -> Result<String> {
let mut limited = BufReader::new(stream).take(MAX_CALLBACK_REQUEST_LINE_BYTES as u64 + 1);
let mut raw = Vec::with_capacity(256);
limited
.read_until(b'\n', &mut raw)
.await
.map_err(|e| Error::internal(format!("Failed to read OAuth callback request: {e}")))?;
if raw.len() > MAX_CALLBACK_REQUEST_LINE_BYTES {
return Err(Error::internal(format!(
"OAuth callback request line exceeds the \
MAX_CALLBACK_REQUEST_LINE_BYTES limit of {MAX_CALLBACK_REQUEST_LINE_BYTES} \
bytes; refused at the socket, and none of it is reproduced here"
)));
}
String::from_utf8(raw)
.map_err(|_| Error::internal("OAuth callback request line is not UTF-8".to_string()))
}
fn callback_query_from_request_line(request_line: &str) -> Result<String> {
let path = request_line.split_whitespace().nth(1).ok_or_else(|| {
Error::internal("OAuth callback request line has no request target".to_string())
})?;
let callback_url = Url::parse(&format!("http://localhost{}", path)).map_err(|e| {
Error::internal(format!("OAuth callback request target is unparseable: {e}"))
})?;
Ok(callback_url.query().unwrap_or_default().to_string())
}
async fn serve_one_callback(
listener: TcpListener,
record: &AuthorizationRequestRecord,
) -> Result<String> {
let (mut stream, _) = listener
.accept()
.await
.map_err(|e| Error::internal(format!("Failed to accept OAuth callback: {e}")))?;
let outcome = match Self::read_request_line_within_cap(&mut stream).await {
Ok(request_line) => Self::callback_query_from_request_line(&request_line)
.and_then(|raw_query| validate_authorization_response(&raw_query, record)),
Err(e) => Err(e),
};
let response = if outcome.is_ok() {
CALLBACK_SUCCESS_RESPONSE
} else {
CALLBACK_FAILURE_RESPONSE
};
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
outcome
}
async fn await_validated_authorization_code(
listener: TcpListener,
record: AuthorizationRequestRecord,
) -> Result<String> {
let (tx, rx) = oneshot::channel::<Result<String>>();
let callback_task = tokio::spawn(async move {
let _ = tx.send(Self::serve_one_callback(listener, &record).await);
});
tracing::info!("Waiting for authorization...");
let received = tokio::time::timeout(Duration::from_mins(5), rx)
.await
.map_err(|_| {
Error::internal("Timeout waiting for OAuth callback (5 minutes)".to_string())
})?
.map_err(|e| Error::internal(format!("OAuth callback channel error: {e}")))?;
callback_task.abort();
received
}
async fn authorization_code_flow_inner(
&self,
metadata: &OidcDiscoveryMetadata,
iss_presence: IssPresence,
) -> Result<(crate::client::auth::TokenResponse, ResolvedClientIdentity)> {
tracing::info!("Starting OAuth authorization code flow...");
let identity = self.resolve_client_identity_for_flow(metadata).await?;
let resolved_client_id = identity.client_id.clone();
let record = AuthorizationRequestRecord::new(
metadata.issuer.clone(),
generate_code_verifier()?,
generate_state()?,
iss_presence,
);
let (listener, redirect_uri) =
Self::bind_callback_listener(self.config.redirect_port).await?;
let auth_url =
self.build_authorization_url(metadata, &resolved_client_id, &redirect_uri, &record)?;
tracing::info!("OAuth Authentication Required");
tracing::info!("Opening browser for authentication...");
tracing::info!("If the browser doesn't open, visit: {}", auth_url.as_str());
self.browser_launcher.open(auth_url.as_str())?;
let authorization_code =
Self::await_validated_authorization_code(listener, record.clone()).await?;
tracing::info!("Authorization code received");
tracing::debug!("Exchanging authorization code for access token...");
let token_exchange = TokenExchangeClient::new();
let token_response = token_exchange
.exchange_code(
&metadata.token_endpoint,
&authorization_code,
&resolved_client_id,
None, &redirect_uri,
Some(record.code_verifier()), )
.await
.map_err(|e| {
Error::internal(format!(
"Failed to exchange authorization code for token: {e}"
))
})?;
tracing::info!("Authentication successful");
Ok((token_response, identity))
}
async fn device_code_flow_with_metadata(
&self,
metadata: &OidcDiscoveryMetadata,
) -> Result<AuthorizationResult> {
tracing::info!("Starting OAuth device code flow...");
let device_auth_endpoint =
metadata
.device_authorization_endpoint
.as_ref()
.ok_or_else(|| {
Error::internal(
"Device authorization endpoint not found in OAuth metadata.\n\
\n\
The OAuth server does not support device code flow (RFC 8628)."
.to_string(),
)
})?;
self.device_code_flow_internal(metadata, device_auth_endpoint)
.await
}
async fn device_code_flow_internal(
&self,
metadata: &OidcDiscoveryMetadata,
device_auth_endpoint: &str,
) -> Result<AuthorizationResult> {
let identity = self.resolve_client_identity_for_flow(metadata).await?;
let resolved_client_id = identity.client_id.clone();
let scope = self.config.scopes.join(" ");
let response = self
.client
.post(device_auth_endpoint)
.form(&[
("client_id", resolved_client_id.as_str()),
("scope", &scope),
])
.send()
.await
.map_err(|e| Error::internal(format!("Failed to request device code: {e}")))?;
if !response.status().is_success() {
let status = response.status();
let body =
collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES).await?;
return Err(Error::internal(format!(
"Device authorization failed ({status}): {}",
String::from_utf8_lossy(&body)
)));
}
let body = collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES).await?;
let device_auth: DeviceAuthResponse = serde_json::from_slice(&body).map_err(|e| {
Error::internal(format!(
"Failed to parse device authorization response ({:?} error at line {}, \
column {}). The parser's own message is not reproduced here because a data \
error echoes the offending input",
e.classify(),
e.line(),
e.column()
))
})?;
tracing::info!("OAuth device code flow");
tracing::info!("1. Visit: {}", device_auth.verification_uri);
tracing::info!("2. Enter code: {}", device_auth.user_code);
if let Some(complete_uri) = &device_auth.verification_uri_complete {
tracing::info!("Or visit directly: {}", complete_uri);
}
let poll_interval = Duration::from_secs(device_auth.interval.unwrap_or(5));
let token_endpoint = &metadata.token_endpoint;
let expires_at = SystemTime::now() + Duration::from_secs(device_auth.expires_in);
loop {
if SystemTime::now() > expires_at {
return Err(Error::internal(
"Device code expired. Please try again.".to_string(),
));
}
sleep(poll_interval).await;
tracing::debug!("Polling for authorization...");
let response = self
.client
.post(token_endpoint)
.form(&[
("client_id", resolved_client_id.as_str()),
("device_code", &device_auth.device_code),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
.send()
.await
.map_err(|e| Error::internal(format!("Failed to poll for token: {e}")))?;
let status = response.status();
let raw =
collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES).await?;
if status.is_success() {
let token_response: TokenResponse = serde_json::from_slice(&raw).map_err(|e| {
Error::internal(format!(
"Failed to parse token response ({:?} error at line {}, column {}). \
The parser's own message is not reproduced here because a data \
error echoes the offending input, and a token response body carries \
credentials",
e.classify(),
e.line(),
e.column()
))
})?;
tracing::info!("Authentication successful");
let result = AuthorizationResult {
access_token: token_response.access_token,
refresh_token: token_response.refresh_token,
expires_at: token_response
.expires_in
.map(|ttl| unix_now_secs().saturating_add(ttl)),
scopes: self.config.scopes.clone(),
issuer: self.effective_issuer(metadata),
client_id: resolved_client_id,
};
self.persist_credentials(
&metadata.issuer,
&result,
identity.registered_application_type.as_deref(),
)
.await?;
return Ok(result);
}
if let Ok(error) = serde_json::from_slice::<serde_json::Value>(&raw) {
if let Some(error_code) = error.get("error").and_then(|e| e.as_str()) {
match error_code {
"authorization_pending" => continue,
"slow_down" => {
sleep(poll_interval).await;
continue;
},
"access_denied" => {
return Err(Error::internal("User denied authorization".to_string()));
},
"expired_token" => {
return Err(Error::internal("Device code expired".to_string()));
},
_ => {
return Err(Error::internal(format!("OAuth error: {}", error_code)));
},
}
}
}
}
}
async fn refresh_token(
&self,
refresh_token: &str,
stored_client_id: Option<&str>,
granted_scopes: &[String],
) -> Result<TokenResponse> {
let metadata = self.get_metadata().await?;
let token_endpoint = &metadata.token_endpoint;
let client_id = stored_client_id
.filter(|id| !id.is_empty())
.or(self.config.client_id.as_deref())
.ok_or_else(|| {
Error::internal(
"cannot refresh: no client_id in the stored credential record for this \
(issuer, account, server), and none in OAuthConfig::client_id. Both places \
were checked. Run an interactive authorization to register (or re-register) \
this client."
.to_string(),
)
})?;
let scope = granted_scopes.join(" ");
let mut form: Vec<(&str, &str)> = vec![
("client_id", client_id),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
];
if !scope.is_empty() {
form.push(("scope", scope.as_str()));
}
let response = self
.client
.post(token_endpoint)
.form(&form)
.send()
.await
.map_err(|e| Error::internal(format!("Failed to refresh token: {e}")))?;
if !response.status().is_success() {
let status = response.status();
let body =
collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES).await?;
return Err(Error::internal(format!(
"Token refresh failed ({status}): {}",
String::from_utf8_lossy(&body)
)));
}
let body = collect_reqwest_body_within_cap(response, DEFAULT_AUTH_RESPONSE_BYTES).await?;
serde_json::from_slice::<TokenResponse>(&body).map_err(|e| {
Error::internal(format!(
"Failed to parse refresh response ({:?} error at line {}, column {}). The \
parser's own message is not reproduced here because a data error echoes the \
offending input, and a token response body carries credentials",
e.classify(),
e.line(),
e.column()
))
})
}
pub async fn create_middleware_chain(&self) -> Result<Arc<HttpMiddlewareChain>> {
let access_token = self.get_access_token().await?;
tracing::debug!(
"Creating OAuth middleware with token {}",
token_fingerprint(&access_token)
);
let bearer_token = BearerToken::new(access_token);
let oauth_middleware = OAuthClientMiddleware::new(bearer_token);
let mut chain = HttpMiddlewareChain::new();
chain.add(Arc::new(oauth_middleware));
tracing::info!("OAuth middleware added to chain");
Ok(Arc::new(chain))
}
}
pub fn default_cache_path() -> PathBuf {
let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
path.push(".pmcp");
path.push("oauth-tokens.json");
path
}
pub async fn create_oauth_middleware(config: OAuthConfig) -> Result<Arc<HttpMiddlewareChain>> {
let helper = OAuthHelper::new(config)?;
helper.create_middleware_chain().await
}
#[cfg(test)]
mod token_fingerprint_tests {
use super::*;
const TOKEN: &str = "ya29.A0ARrdaM-THIS-IS-A-LIVE-LOOKING-ACCESS-TOKEN-abcdef0123456789";
fn assert_no_token_material(fingerprint: &str, token: &str) {
assert!(
!fingerprint.contains(token),
"the whole token appeared in {fingerprint}"
);
for len in 4..=token.len() {
let prefix = &token[..len];
assert!(
!fingerprint.contains(prefix),
"a {len}-character prefix of the token appeared in {fingerprint}"
);
}
}
#[test]
fn a_fingerprint_reproduces_no_part_of_the_token() {
assert_no_token_material(&token_fingerprint(TOKEN), TOKEN);
}
#[test]
fn the_previous_twenty_character_prefix_is_absent() {
let fingerprint = token_fingerprint(TOKEN);
assert!(
!fingerprint.contains(&TOKEN[..20]),
"the old plaintext prefix is back: {fingerprint}"
);
}
#[test]
fn a_fingerprint_is_stable_for_one_token() {
assert_eq!(token_fingerprint(TOKEN), token_fingerprint(TOKEN));
}
#[test]
fn two_tokens_sharing_a_long_prefix_fingerprint_differently() {
let sibling = format!("{TOKEN}-second");
assert_ne!(token_fingerprint(TOKEN), token_fingerprint(&sibling));
}
#[test]
fn a_fingerprint_is_the_marker_plus_twelve_hex_digits() {
let fingerprint = token_fingerprint(TOKEN);
let hex = fingerprint
.strip_prefix("sha256:")
.expect("the marker prefix");
assert_eq!(hex.len(), FINGERPRINT_HEX_CHARS);
assert!(
hex.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()),
"expected lowercase hex, got {hex}"
);
}
#[test]
fn an_empty_token_is_fingerprinted_without_panicking() {
assert_eq!(
token_fingerprint("").len(),
"sha256:".len() + FINGERPRINT_HEX_CHARS
);
}
}
#[cfg(test)]
mod credential_store_wiring_tests {
use super::*;
#[test]
fn the_credential_store_file_name_matches_default_credential_path() {
let default = crate::shared::credential_file::default_credential_path()
.expect("a resolvable default credential path");
assert_eq!(
default.file_name().and_then(std::ffi::OsStr::to_str),
Some(CREDENTIAL_STORE_FILE_NAME),
"the store file name drifted from default_credential_path"
);
}
#[test]
fn the_legacy_flat_cache_and_the_credential_store_are_different_files() {
let legacy = default_cache_path();
assert_eq!(
legacy.file_name().and_then(std::ffi::OsStr::to_str),
Some("oauth-tokens.json")
);
assert_ne!(
legacy.file_name().and_then(std::ffi::OsStr::to_str),
Some(CREDENTIAL_STORE_FILE_NAME)
);
assert_eq!(
legacy
.with_file_name(CREDENTIAL_STORE_FILE_NAME)
.file_name()
.and_then(std::ffi::OsStr::to_str),
Some(CREDENTIAL_STORE_FILE_NAME),
"the store lives beside the legacy file, not on top of it"
);
}
#[test]
fn no_cache_file_and_no_injected_store_resolves_to_no_store() {
let helper = OAuthHelper::new(OAuthConfig {
mcp_server_url: Some("https://mcp.example".to_string()),
cache_file: None,
..OAuthConfig::default()
})
.expect("helper");
assert!(helper.credential_store().is_none());
}
#[test]
fn a_configured_cache_file_resolves_a_store_beside_it() {
let helper = OAuthHelper::new(OAuthConfig {
mcp_server_url: Some("https://mcp.example".to_string()),
cache_file: Some(PathBuf::from("/nonexistent-116-11/oauth-tokens.json")),
..OAuthConfig::default()
})
.expect("helper");
let store = helper.credential_store().expect("a resolved store");
assert!(
format!("{store:?}").contains("oauth-cache.json"),
"expected a store beside the legacy file, got {store:?}"
);
assert!(
!std::path::Path::new("/nonexistent-116-11").exists(),
"resolving a store must not create anything"
);
}
#[test]
fn the_credential_key_carries_issuer_account_and_normalized_server() {
let helper = OAuthHelper::new(OAuthConfig {
mcp_server_url: Some("https://MCP.Example:443/api/".to_string()),
..OAuthConfig::default()
})
.expect("helper")
.with_account_scope("cognito-sub-123");
let server_key = helper.server_key().expect("a normalizable server URL");
assert_eq!(server_key, "https://mcp.example");
let key = helper.credential_key("https://as.example", &server_key);
assert_eq!(key.issuer(), "https://as.example");
assert_eq!(key.account(), "cognito-sub-123");
assert_eq!(key.server(), "https://mcp.example");
}
#[test]
fn the_default_account_scope_is_empty_and_the_issuer_is_the_server_fallback() {
let helper = OAuthHelper::new(OAuthConfig {
mcp_server_url: None,
issuer: Some("https://as.example/tenant".to_string()),
..OAuthConfig::default()
})
.expect("helper");
let server_key = helper.server_key().expect("a normalizable issuer");
assert_eq!(server_key, "https://as.example");
assert_eq!(
helper
.credential_key("https://as.example", &server_key)
.account(),
""
);
let unaddressable = OAuthHelper::new(OAuthConfig::default()).expect("helper");
assert!(unaddressable.server_key().is_err());
}
}
#[cfg(test)]
mod oauth_config_tests {
use super::*;
#[test]
fn oauth_config_default_has_dcr_enabled_and_none_client_id() {
let c = OAuthConfig::default();
assert!(
c.client_id.is_none(),
"default client_id must be None for DCR auto-fire"
);
assert!(c.dcr_enabled, "default dcr_enabled must be true");
assert!(c.client_name.is_none(), "default client_name is None");
}
#[test]
fn oauth_config_struct_literal_with_some_client_id_compiles() {
let _c = OAuthConfig {
issuer: None,
mcp_server_url: Some("https://x.example".into()),
client_id: Some("my-client".into()),
client_name: None,
dcr_enabled: false,
scopes: vec![],
cache_file: None,
redirect_port: 8080,
};
}
#[test]
fn dcr_types_are_reexported() {
let _r: super::DcrRequest = super::DcrRequest {
redirect_uris: vec!["http://localhost:8080/callback".into()],
client_name: Some("test".into()),
client_uri: None,
logo_uri: None,
contacts: vec![],
token_endpoint_auth_method: Some("none".into()),
grant_types: vec!["authorization_code".into()],
response_types: vec![],
scope: None,
software_id: None,
software_version: None,
extra: Default::default(),
};
let _rsp = super::DcrResponse {
client_id: "x".into(),
client_secret: None,
client_secret_expires_at: None,
registration_access_token: None,
registration_client_uri: None,
token_endpoint_auth_method: None,
extra: Default::default(),
};
}
}
#[cfg(test)]
mod dcr_tests {
use super::*;
use crate::server::auth::oauth2::OidcDiscoveryMetadata;
fn metadata(reg: Option<&str>) -> OidcDiscoveryMetadata {
OidcDiscoveryMetadata {
issuer: "https://issuer.example".into(),
authorization_endpoint: "https://issuer.example/auth".into(),
token_endpoint: "https://issuer.example/token".into(),
jwks_uri: None,
userinfo_endpoint: None,
registration_endpoint: reg.map(String::from),
revocation_endpoint: None,
introspection_endpoint: None,
device_authorization_endpoint: None,
response_types_supported: vec![],
grant_types_supported: vec![],
scopes_supported: vec![],
token_endpoint_auth_methods_supported: vec![],
code_challenge_methods_supported: vec![],
}
}
#[tokio::test]
async fn dcr_skipped_when_client_id_provided() {
let cfg = OAuthConfig {
client_id: Some("preset".into()),
dcr_enabled: true,
..OAuthConfig::default()
};
let helper = OAuthHelper::new(cfg).unwrap();
let resolved = helper
.resolve_client_identity_for_flow(&metadata(Some("https://x/register")))
.await
.unwrap();
assert_eq!(resolved.client_id, "preset");
}
#[tokio::test]
async fn dcr_skipped_when_dcr_disabled_with_client_id() {
let cfg = OAuthConfig {
client_id: Some("preset".into()),
dcr_enabled: false,
..OAuthConfig::default()
};
let helper = OAuthHelper::new(cfg).unwrap();
let resolved = helper
.resolve_client_identity_for_flow(&metadata(None))
.await
.unwrap();
assert_eq!(resolved.client_id, "preset");
}
#[tokio::test]
async fn dcr_needed_but_unsupported_errors_with_actionable_message() {
let cfg = OAuthConfig {
dcr_enabled: true,
..OAuthConfig::default()
};
let helper = OAuthHelper::new(cfg).unwrap();
let err = helper
.resolve_client_identity_for_flow(&metadata(None))
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("server does not support DCR"),
"expected actionable DCR-missing message, got: {msg}"
);
}
#[tokio::test]
async fn dcr_needed_but_disabled_errors_when_client_id_none() {
let cfg = OAuthConfig {
client_id: None,
dcr_enabled: false,
..OAuthConfig::default()
};
let helper = OAuthHelper::new(cfg).unwrap();
let err = helper
.resolve_client_identity_for_flow(&metadata(Some("https://x/register")))
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("dcr_enabled is false"),
"expected dcr_enabled=false error, got: {msg}"
);
}
#[tokio::test]
async fn dcr_rejects_http_non_localhost_endpoint() {
let cfg = OAuthConfig {
dcr_enabled: true,
..OAuthConfig::default()
};
let helper = OAuthHelper::new(cfg).unwrap();
let err = helper
.do_dynamic_client_registration("http://attacker.example/register", &metadata(None))
.await
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("must be https"), "got: {msg}");
}
#[test]
fn dcr_request_body_matches_rfc7591_public_pkce_shape() {
let req = crate::server::auth::provider::DcrRequest {
redirect_uris: vec!["http://localhost:8080/callback".into()],
client_name: Some("pmcp-sdk".into()),
client_uri: None,
logo_uri: None,
contacts: vec![],
token_endpoint_auth_method: Some("none".into()),
grant_types: vec!["authorization_code".into()],
response_types: vec![],
scope: None,
software_id: None,
software_version: None,
extra: Default::default(),
};
let v: serde_json::Value = serde_json::to_value(&req).unwrap();
assert_eq!(v["client_name"], "pmcp-sdk");
assert_eq!(
v["redirect_uris"],
serde_json::json!(["http://localhost:8080/callback"])
);
assert_eq!(v["grant_types"], serde_json::json!(["authorization_code"]));
assert_eq!(v["token_endpoint_auth_method"], "none");
}
#[test]
fn dcr_request_body_contains_response_types_code() {
let req = crate::server::auth::provider::DcrRequest {
redirect_uris: vec!["http://localhost:8080/callback".into()],
client_name: Some("pmcp-sdk".into()),
client_uri: None,
logo_uri: None,
contacts: vec![],
token_endpoint_auth_method: Some("none".into()),
grant_types: vec!["authorization_code".into()],
response_types: vec!["code".into()],
scope: None,
software_id: None,
software_version: None,
extra: Default::default(),
};
let s = serde_json::to_string(&req).unwrap();
assert!(
s.contains(r#""response_types":["code"]"#),
"RFC 7591 §3.1 response_types missing from wire body: {s}"
);
}
#[tokio::test]
async fn dcr_advertises_127_0_0_1_redirect_not_localhost() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/register")
.match_body(mockito::Matcher::PartialJsonString(
serde_json::json!({
"redirect_uris": ["http://127.0.0.1:8080/callback"]
})
.to_string(),
))
.with_status(201)
.with_body(r#"{"client_id":"ok"}"#)
.create_async()
.await;
let helper = OAuthHelper::new(OAuthConfig {
dcr_enabled: true,
redirect_port: 8080,
..OAuthConfig::default()
})
.unwrap();
let result = helper
.do_dynamic_client_registration(&format!("{}/register", server.url()), &metadata(None))
.await;
assert!(
result.is_ok(),
"DCR body did not pin 127.0.0.1 redirect_uri"
);
mock.assert_async().await;
}
#[tokio::test]
async fn dcr_accepts_ipv6_loopback_registration_endpoint() {
let cfg = OAuthConfig {
dcr_enabled: true,
..OAuthConfig::default()
};
let helper = OAuthHelper::new(cfg).unwrap();
let err = helper
.do_dynamic_client_registration("http://[::1]:9/register", &metadata(None))
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
!msg.contains("must be https"),
"scheme guard should accept http://[::1] but rejected: {msg}"
);
}
#[tokio::test]
async fn dcr_accepts_http_localhost_registration_endpoint() {
let cfg = OAuthConfig {
dcr_enabled: true,
..OAuthConfig::default()
};
let helper = OAuthHelper::new(cfg).unwrap();
let err = helper
.do_dynamic_client_registration("http://localhost:9/register", &metadata(None))
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
!msg.contains("must be https"),
"scheme guard should accept http://localhost but rejected: {msg}"
);
}
#[tokio::test]
async fn dcr_accepts_http_ipv4_loopback_registration_endpoint() {
let cfg = OAuthConfig {
dcr_enabled: true,
..OAuthConfig::default()
};
let helper = OAuthHelper::new(cfg).unwrap();
let err = helper
.do_dynamic_client_registration("http://127.0.0.1:9/register", &metadata(None))
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
!msg.contains("must be https"),
"scheme guard should accept http://127.0.0.1 but rejected: {msg}"
);
}
#[tokio::test]
async fn authorize_with_details_fails_cleanly_without_server() {
let cfg = OAuthConfig {
mcp_server_url: Some("http://localhost:1/nonexistent".into()),
client_id: Some("x".into()),
dcr_enabled: false,
..OAuthConfig::default()
};
let helper = OAuthHelper::new(cfg).unwrap();
let err = helper.authorize_with_details().await.unwrap_err();
let _ = format!("{err}");
}
#[test]
fn authorization_result_struct_has_expected_fields() {
let _r = AuthorizationResult {
access_token: "a".into(),
refresh_token: Some("r".into()),
expires_at: Some(1),
scopes: vec!["openid".into()],
issuer: Some("https://i.example".into()),
client_id: "c".into(),
};
}
#[test]
fn build_auth_result_converts_expires_in_to_expires_at() {
let token = crate::client::auth::TokenResponse {
access_token: "a".into(),
token_type: "Bearer".into(),
expires_in: Some(3600),
refresh_token: Some("r".into()),
scope: Some("openid profile".into()),
};
let now = unix_now_secs();
let r = OAuthHelper::build_auth_result(
token,
"c1".into(),
Some("https://i.example".into()),
&["openid".into()],
);
assert_eq!(r.client_id, "c1");
assert_eq!(r.refresh_token.as_deref(), Some("r"));
assert_eq!(r.issuer.as_deref(), Some("https://i.example"));
assert_eq!(r.scopes, vec!["openid".to_string(), "profile".into()]);
let expires_at = r.expires_at.expect("expires_at populated");
assert!(
expires_at >= now + 3599 && expires_at <= now + 3601,
"expires_at ({}) should be approximately now+3600 ({})",
expires_at,
now + 3600
);
}
#[test]
fn build_auth_result_falls_back_to_requested_scopes_when_no_grant() {
let token = crate::client::auth::TokenResponse {
access_token: "a".into(),
token_type: "Bearer".into(),
expires_in: None,
refresh_token: None,
scope: None,
};
let requested = vec!["openid".to_string(), "email".to_string()];
let r = OAuthHelper::build_auth_result(token, "c".into(), None, &requested);
assert_eq!(r.scopes, requested);
assert!(r.expires_at.is_none());
assert!(r.refresh_token.is_none());
}
}
#[cfg(test)]
mod sep837_sep2207_composition_tests {
use super::*;
fn dcr_request_registering(redirect_uris: &[&str]) -> DcrRequest {
DcrRequest {
redirect_uris: redirect_uris.iter().map(|u| (*u).to_string()).collect(),
client_name: Some("pmcp-sdk".to_string()),
client_uri: None,
logo_uri: None,
contacts: vec![],
token_endpoint_auth_method: Some("none".to_string()),
grant_types: vec![
"authorization_code".to_string(),
"refresh_token".to_string(),
],
response_types: vec!["code".to_string()],
scope: None,
software_id: None,
software_version: None,
extra: Default::default(),
}
}
fn owned(values: &[&str]) -> Vec<String> {
values.iter().map(|v| (*v).to_string()).collect()
}
#[test]
fn a_loopback_redirect_derives_native() {
let mut request = dcr_request_registering(&["http://127.0.0.1:8080/callback"]);
let sent = apply_application_type(&mut request).expect("loopback derives");
assert_eq!(sent, "native");
assert_eq!(request.application_type(), Some("native"));
}
#[test]
fn an_https_non_loopback_redirect_derives_web() {
let mut request = dcr_request_registering(&["https://proxy.example.com/callback"]);
let sent = apply_application_type(&mut request).expect("https non-loopback derives");
assert_eq!(sent, "web");
assert_eq!(request.application_type(), Some("web"));
}
#[test]
fn an_explicit_application_type_is_never_clobbered_by_the_derivation() {
let mut request = dcr_request_registering(&["http://127.0.0.1:8080/callback"]);
request.set_application_type("web");
let sent = apply_application_type(&mut request).expect("an override is not re-derived");
assert_eq!(sent, "web");
assert_eq!(request.application_type(), Some("web"));
}
#[test]
fn a_mixed_redirect_vector_is_an_error_and_never_a_pick() {
let mut request = dcr_request_registering(&[
"http://127.0.0.1:8080/callback",
"https://proxy.example.com/callback",
]);
let err = apply_application_type(&mut request)
.expect_err("D-10: a mixed vector is an ERROR, never a silent choice");
let message = err.to_string();
assert!(
message.contains("127.0.0.1"),
"names the native URI: {message}"
);
assert!(
message.contains("proxy.example.com"),
"names the web URI: {message}"
);
assert_eq!(
request.application_type(),
None,
"a refused derivation must not leave a half-written value on the request"
);
}
#[test]
fn offline_access_is_added_only_when_the_server_advertises_it() {
let configured = owned(&["openid", "profile"]);
assert_eq!(
compose_scopes_with_offline_access(&configured, &owned(&["openid", "offline_access"])),
owned(&["openid", "profile", "offline_access"]),
"advertised: appended last, configured order preserved"
);
assert_eq!(
compose_scopes_with_offline_access(&configured, &owned(&["openid", "profile"])),
owned(&["openid", "profile"]),
"NOT advertised: absent, because SEP-2207 conditions the request on support"
);
assert_eq!(
compose_scopes_with_offline_access(&configured, &[]),
owned(&["openid", "profile"]),
"an empty scopes_supported advertises nothing"
);
}
#[test]
fn an_already_configured_offline_access_is_not_duplicated() {
let configured = owned(&["openid", "offline_access"]);
assert_eq!(
compose_scopes_with_offline_access(&configured, &owned(&["offline_access"])),
owned(&["openid", "offline_access"]),
"a duplicated scope token is legal but is what a strict server rejects"
);
}
#[test]
fn the_configured_scopes_are_never_mutated_and_never_accumulate() {
let configured = owned(&["openid"]);
let advertised = owned(&["offline_access"]);
let first = compose_scopes_with_offline_access(&configured, &advertised);
let second = compose_scopes_with_offline_access(&configured, &advertised);
assert_eq!(
configured,
owned(&["openid"]),
"`OAuthConfig::scopes` is a public field; a caller reusing one config \
across two flows must not watch it grow"
);
assert_eq!(
first, second,
"two flows compose the same value, not a longer one"
);
assert_eq!(first, owned(&["openid", "offline_access"]));
}
#[test]
fn duplicate_configured_scopes_collapse_to_one_entry() {
let configured = owned(&["openid", "openid", "profile"]);
assert_eq!(
compose_scopes_with_offline_access(&configured, &[]),
owned(&["openid", "profile"])
);
}
}
#[cfg(test)]
mod application_type_divergence_tests {
use super::*;
use crate::server::auth::provider::DcrResponse;
use serde_json::json;
fn response_echoing(application_type: serde_json::Value) -> DcrResponse {
serde_json::from_value(json!({
"client_id": "issued-id",
"application_type": application_type,
}))
.expect("a DcrResponse parses from a client_id plus an extra key")
}
#[test]
fn an_equal_echo_is_not_a_divergence() {
let response = response_echoing(json!("native"));
assert_eq!(response.application_type(), Some("native"));
assert_eq!(
application_type_divergence("native", response.application_type()),
None,
"the server agreed; there is nothing to warn about"
);
}
#[test]
fn a_different_echo_is_a_divergence_naming_both_values() {
let response = response_echoing(json!("web"));
assert_eq!(
application_type_divergence("native", response.application_type()),
Some(("native".to_string(), "web".to_string())),
"the tuple is (sent, registered) in that order — a warning that named \
them the other way round would send a developer to change the wrong knob"
);
}
#[test]
fn an_absent_echo_is_not_a_divergence() {
let response: DcrResponse =
serde_json::from_value(json!({ "client_id": "issued-id" })).expect("parses");
assert_eq!(response.application_type(), None);
assert_eq!(
application_type_divergence("native", response.application_type()),
None
);
}
#[test]
fn a_non_string_echo_reaches_application_type_divergence_as_an_absence() {
for hostile in [json!(42), json!(null), json!(true), json!(["native"])] {
let response = response_echoing(hostile.clone());
assert_eq!(
response.application_type(),
None,
"a non-string echo must project to None: {hostile}"
);
assert_eq!(
application_type_divergence("native", response.application_type()),
None,
"and must therefore not be reported as a divergence: {hostile}"
);
}
}
#[test]
fn an_oversized_error_field_is_truncated_without_reproducing_what_it_dropped() {
let long = "Z".repeat(MAX_DCR_ERROR_FIELD_CHARS + 500);
let body =
json!({ "error": "invalid_redirect_uri", "error_description": long }).to_string();
let fields = dcr_rejection_fields(body.as_bytes());
assert_eq!(fields.error.as_deref(), Some("invalid_redirect_uri"));
let description = fields.error_description.expect("a description");
assert!(
description.contains("500 of 700 characters withheld"),
"the notice must say how much was dropped: {description}"
);
assert!(
description.chars().count() < MAX_DCR_ERROR_FIELD_CHARS + 100,
"the bounded field must be far shorter than the input"
);
}
#[test]
fn a_non_string_or_unparseable_rejection_body_yields_no_fields() {
let coerced = json!({ "error": 42, "error_description": ["nope"] }).to_string();
let fields = dcr_rejection_fields(coerced.as_bytes());
assert_eq!(fields.error, None, "a non-string error is never coerced");
assert_eq!(fields.error_description, None);
let html = dcr_rejection_fields(b"<html><body>502 Bad Gateway</body></html>");
assert_eq!(html.error, None);
assert_eq!(html.error_description, None);
}
}
#[cfg(test)]
mod dcr_proptest {
use super::*;
use proptest::prelude::*;
fn arb_dcr_request() -> impl Strategy<Value = crate::server::auth::provider::DcrRequest> {
(
prop::collection::vec("[a-z][a-z0-9-]{2,30}", 1..3),
prop::option::of("[a-zA-Z][a-zA-Z0-9 _-]{1,40}"),
prop::option::of(
prop::string::string_regex("(none|client_secret_basic|client_secret_post)")
.unwrap(),
),
)
.prop_map(|(uris, name, auth_method)| {
let redirect_uris = uris
.into_iter()
.map(|u| format!("http://localhost:8080/{u}"))
.collect();
crate::server::auth::provider::DcrRequest {
redirect_uris,
client_name: name,
client_uri: None,
logo_uri: None,
contacts: vec![],
token_endpoint_auth_method: auth_method,
grant_types: vec!["authorization_code".into()],
response_types: vec!["code".into()],
scope: None,
software_id: None,
software_version: None,
extra: Default::default(),
}
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn dcr_request_serde_roundtrip(req in arb_dcr_request()) {
let v = serde_json::to_value(&req).unwrap();
let back: crate::server::auth::provider::DcrRequest =
serde_json::from_value(v).unwrap();
prop_assert_eq!(req.redirect_uris, back.redirect_uris);
prop_assert_eq!(req.client_name, back.client_name);
prop_assert_eq!(req.token_endpoint_auth_method, back.token_endpoint_auth_method);
}
#[test]
fn oauth_config_builder_allows_all_combinations(
has_id in any::<bool>(),
has_name in any::<bool>(),
dcr in any::<bool>(),
) {
let cfg = OAuthConfig {
client_id: has_id.then(|| "id".into()),
client_name: has_name.then(|| "name".into()),
dcr_enabled: dcr,
mcp_server_url: Some("https://x.example".into()),
..OAuthConfig::default()
};
OAuthHelper::new(cfg).unwrap();
}
}
}
#[cfg(test)]
mod dcr_parser_fuzz {
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn parser_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..4096)) {
let _ = serde_json::from_slice::<
crate::server::auth::provider::DcrResponse
>(&bytes);
}
#[test]
fn parser_accepts_minimal_valid_response(
id in "[a-zA-Z0-9-]{8,40}",
has_secret in any::<bool>(),
) {
let mut v = serde_json::json!({"client_id": id});
if has_secret {
v["client_secret"] = serde_json::json!("s3cret");
}
let parsed: crate::server::auth::provider::DcrResponse =
serde_json::from_value(v).unwrap();
prop_assert_eq!(parsed.client_id, id);
prop_assert_eq!(parsed.client_secret.is_some(), has_secret);
}
}
}