Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion conformance/src/bin/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use rmcp::{
service::RequestContext,
transport::{
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
auth::{AuthorizationCallback, OAuthState},
auth::{AuthorizationCallback, InMemoryCredentialStore, OAuthState},
streamable_http_client::StreamableHttpClientTransportConfig,
},
};
Expand Down Expand Up @@ -495,6 +495,66 @@ async fn run_auth_scope_retry_limit_client(
Ok(())
}

async fn migration_token(
server_url: &str,
store: &InMemoryCredentialStore,
) -> anyhow::Result<String> {
let mut manager = AuthorizationManager::new(server_url).await?;
manager.set_credential_store(store.clone());

if manager.initialize_from_store().await? {
return Ok(manager.get_access_token().await?);
}

let metadata = manager.discover_metadata().await?;
manager.set_metadata(metadata);
manager
.register_client("conformance-client", REDIRECT_URI, &[])
.await?;

let scopes = manager.select_scopes(None, &[]);
let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let auth_url = manager.get_authorization_url(&scope_refs).await?;
let callback = headless_authorize(&auth_url).await?;
manager
.exchange_code_for_token_with_issuer(
&callback.code,
&callback.csrf_token,
callback.issuer.as_deref(),
)
.await?;

Ok(manager.get_access_token().await?)
}

async fn run_auth_server_migration_client(
server_url: &str,
_ctx: &ConformanceContext,
) -> anyhow::Result<()> {
let store = InMemoryCredentialStore::new();
let http = reqwest::Client::new();
let body = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}});

let mut token = migration_token(server_url, &store).await?;
for _ in 0..3 {
let resp = http
.post(server_url)
.header(
"MCP-Protocol-Version",
conformance_protocol_version().as_str(),
)
.bearer_auth(&token)
.json(&body)
.send()
.await?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
token = migration_token(server_url, &store).await?;
}
}

Ok(())
}

/// Auth flow with pre-registered credentials (from context).
async fn run_auth_preregistered_client(
server_url: &str,
Expand Down Expand Up @@ -956,6 +1016,11 @@ async fn main() -> anyhow::Result<()> {
// Auth - scope retry limit
"auth/scope-retry-limit" => run_auth_scope_retry_limit_client(&server_url, &ctx).await?,

// Auth - authorization server migration (SEP-2352)
"auth/authorization-server-migration" => {
run_auth_server_migration_client(&server_url, &ctx).await?
}

// Auth - pre-registration
"auth/pre-registration" => run_auth_preregistered_client(&server_url, &ctx).await?,

Expand Down
83 changes: 80 additions & 3 deletions crates/rmcp/src/transport/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ pub struct StoredCredentials {
pub granted_scopes: Vec<String>,
#[serde(default)]
pub token_received_at: Option<u64>,
#[serde(default)]
pub issuer: Option<String>,
}

impl std::fmt::Debug for StoredCredentials {
Expand All @@ -213,6 +215,7 @@ impl std::fmt::Debug for StoredCredentials {
)
.field("granted_scopes", &self.granted_scopes)
.field("token_received_at", &self.token_received_at)
.field("issuer", &self.issuer)
.finish()
}
}
Expand All @@ -230,8 +233,14 @@ impl StoredCredentials {
token_response,
granted_scopes,
token_received_at,
issuer: None,
Comment thread
DaleSeo marked this conversation as resolved.
}
}

pub fn with_issuer(mut self, issuer: Option<String>) -> Self {
self.issuer = issuer;
self
}
}

/// Trait for storing and retrieving OAuth2 credentials
Expand Down Expand Up @@ -1088,6 +1097,49 @@ impl AuthorizationManager {
self.metadata = Some(metadata);
}

if let (Some(stored_issuer), Some(current_issuer)) =
(stored.issuer.as_deref(), self.metadata_issuer().as_deref())
Comment thread
DaleSeo marked this conversation as resolved.
{
// A CIMD client ID is the client's metadata URL, so it is
// portable across authorization servers and exempt here.
if stored_issuer != current_issuer {
if is_https_url(&stored.client_id) {
// A CIMD client ID is the client's metadata URL, so it is
// portable across authorization servers — but the tokens
// were minted by the previous AS and must not be reused.
tracing::warn!(
stored_issuer,
current_issuer,
"authorization server issuer changed; discarding tokens but keeping portable CIMD client ID"
);
self.credential_store
.save(
StoredCredentials::new(
stored.client_id.clone(),
None,
vec![],
None,
)
.with_issuer(self.metadata_issuer()),
)
.await?;
self.configure_client_id(&stored.client_id)?;
return Ok(false);
}

tracing::warn!(
stored_issuer,
current_issuer,
"authorization server issuer changed; clearing stored credentials bound to the previous issuer"
);
self.credential_store.clear().await?;
return Err(AuthError::AuthorizationServerMismatch {
expected_issuer: stored_issuer.to_string(),
received_issuer: current_issuer.to_string(),
});
}
}

self.configure_client_id(&stored.client_id)?;
return Ok(true);
}
Expand Down Expand Up @@ -1703,6 +1755,7 @@ impl AuthorizationManager {
token_response: Some(token_result.clone()),
granted_scopes,
token_received_at: Some(Self::now_epoch_secs()),
issuer: self.metadata_issuer(),
};
self.credential_store.save(stored).await?;

Expand All @@ -1716,6 +1769,10 @@ impl AuthorizationManager {
.as_secs()
}

fn metadata_issuer(&self) -> Option<String> {
self.metadata.as_ref().and_then(|m| m.issuer.clone())
}

/// Proactive refresh buffer: refresh tokens this many seconds before they expire
/// to avoid races between token retrieval and the actual HTTP request.
const REFRESH_BUFFER_SECS: u64 = 30;
Expand Down Expand Up @@ -1838,6 +1895,7 @@ impl AuthorizationManager {
token_response: Some(token_result.clone()),
granted_scopes,
token_received_at: Some(Self::now_epoch_secs()),
issuer: self.metadata_issuer(),
};
self.credential_store.save(stored).await?;

Expand Down Expand Up @@ -2658,6 +2716,7 @@ impl AuthorizationManager {
token_response: Some(token_result.clone()),
granted_scopes,
token_received_at: Some(Self::now_epoch_secs()),
issuer: self.metadata_issuer(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now stamp issuer: self.metadata_issuer() here, but nothing reads it back. After a migration, what stops a static pre-registered credential from being silently sent to the new AS, the case where SEP-2352 says clients "SHOULD surface an error"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch. initialize_from_store now returns an AuthorizationServerMismatch error on issuer change

};
self.credential_store.save(stored).await?;

Expand Down Expand Up @@ -2780,6 +2839,7 @@ impl AuthorizationManager {
token_response: Some(token_result.clone()),
granted_scopes,
token_received_at: Some(Self::now_epoch_secs()),
issuer: self.metadata_issuer(),
};
self.credential_store.save(stored).await?;

Expand Down Expand Up @@ -3142,17 +3202,18 @@ impl OAuthState {

*manager.current_scopes.write().await = granted_scopes.clone();

let metadata = manager.discover_metadata().await?;
manager.metadata = Some(metadata);

let stored = StoredCredentials {
client_id: client_id.to_string(),
token_response: Some(credentials),
granted_scopes,
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: manager.metadata_issuer(),
};
manager.credential_store.save(stored).await?;

let metadata = manager.discover_metadata().await?;
manager.metadata = Some(metadata);

manager.configure_client_id(client_id)?;

*self = OAuthState::Authorized(manager);
Expand Down Expand Up @@ -4617,6 +4678,7 @@ mod tests {
token_response: Some(token_response),
granted_scopes: vec![],
token_received_at: None,
issuer: None,
};
let debug_output = format!("{:?}", creds);

Expand Down Expand Up @@ -5594,6 +5656,7 @@ mod tests {
token_response: Some(make_token_response("my-access-token", Some(3600))),
granted_scopes: vec![],
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand All @@ -5611,6 +5674,7 @@ mod tests {
token_response: Some(make_token_response("stale-token", Some(3600))),
granted_scopes: vec![],
token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200),
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand All @@ -5629,6 +5693,7 @@ mod tests {
token_response: Some(make_token_response("no-expiry-token", None)),
granted_scopes: vec![],
token_received_at: None,
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand All @@ -5646,6 +5711,7 @@ mod tests {
token_response: Some(make_token_response("almost-expired", Some(3600))),
granted_scopes: vec![],
token_received_at: Some(AuthorizationManager::now_epoch_secs() - 3590),
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand All @@ -5664,6 +5730,7 @@ mod tests {
token_response: Some(make_token_response("stale-token", Some(3600))),
granted_scopes: vec![],
token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200),
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand Down Expand Up @@ -5965,6 +6032,7 @@ mod tests {
)),
granted_scopes: vec![],
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: None,
})
.await
.unwrap();
Expand Down Expand Up @@ -5993,6 +6061,7 @@ mod tests {
token_response: None,
granted_scopes: vec![],
token_received_at: None,
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand All @@ -6013,6 +6082,7 @@ mod tests {
token_response: Some(make_token_response("old-token", Some(3600))),
granted_scopes: vec![],
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand Down Expand Up @@ -6103,6 +6173,7 @@ mod tests {
)),
granted_scopes: vec![],
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: None,
})
.await
.unwrap();
Expand Down Expand Up @@ -6308,6 +6379,7 @@ mod tests {
)),
granted_scopes: vec!["read".to_string(), "write".to_string()],
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand Down Expand Up @@ -6345,6 +6417,7 @@ mod tests {
)),
granted_scopes: vec![],
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand Down Expand Up @@ -6382,6 +6455,7 @@ mod tests {
)),
granted_scopes: vec!["read".to_string()],
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand Down Expand Up @@ -6419,6 +6493,7 @@ mod tests {
)),
granted_scopes: vec![],
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand Down Expand Up @@ -6457,6 +6532,7 @@ mod tests {
)),
granted_scopes: vec![],
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand Down Expand Up @@ -6520,6 +6596,7 @@ mod tests {
)),
granted_scopes: vec![],
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand Down