Skip to content
Open
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, serve_directly},
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 @@ -1023,6 +1083,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
37 changes: 37 additions & 0 deletions crates/rmcp/src/transport/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,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 @@ -212,6 +214,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 @@ -229,6 +232,7 @@ impl StoredCredentials {
token_response,
granted_scopes,
token_received_at,
issuer: None,

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.

How can external CredentialStore implementors set issuer?

}
}
}
Expand Down Expand Up @@ -1068,6 +1072,15 @@ impl AuthorizationManager {
self.metadata = Some(metadata);
}

if let (Some(stored_issuer), Some(current_issuer)) =
(stored.issuer.as_deref(), self.metadata_issuer().as_deref())
Comment on lines +1075 to +1076

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.

SEP-2352 explicitly says CIMD client IDs "are portable across authorization servers … No re-registration is required." The mismatch check here doesn't distinguish credential types.

{
if stored_issuer != current_issuer {
self.credential_store.clear().await?;
return Ok(false);
}
Comment on lines +1078 to +1081

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.

Clearing stored credentials is destructive. Should we add a warning log here?

}

self.configure_client_id(&stored.client_id)?;
return Ok(true);
}
Expand Down Expand Up @@ -1646,6 +1659,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 @@ -1659,6 +1673,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 @@ -1772,6 +1790,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 @@ -2517,6 +2536,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"?

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

Expand Down Expand Up @@ -2639,6 +2659,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 @@ -3006,6 +3027,7 @@ impl OAuthState {
token_response: Some(credentials),
granted_scopes,
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: None,

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.

Since discover_metadata() runs right after the save, the issuer is actually available in this function. Was keeping the save before discovery intentional, or could the stamp be filled in after discovery succeeds?

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

Expand Down Expand Up @@ -4345,6 +4367,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 @@ -5224,6 +5247,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 @@ -5241,6 +5265,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 @@ -5259,6 +5284,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 @@ -5276,6 +5302,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 @@ -5294,6 +5321,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 @@ -5577,6 +5605,7 @@ mod tests {
token_response: None,
granted_scopes: vec![],
token_received_at: None,
issuer: None,
};
manager.credential_store.save(stored).await.unwrap();

Expand All @@ -5597,6 +5626,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 @@ -5663,6 +5693,7 @@ mod tests {
)),
granted_scopes: vec![],
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
issuer: None,
})
.await
.unwrap();
Expand Down Expand Up @@ -5868,6 +5899,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 @@ -5905,6 +5937,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 @@ -5942,6 +5975,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 @@ -5979,6 +6013,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 @@ -6017,6 +6052,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 @@ -6080,6 +6116,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