Skip to content

Commit 8efc142

Browse files
authored
feat(auth): bind DCR client credentials to issuing authorization server (SEP-2352) (#998)
* feat(auth): bind DCR client credentials to issuing authorization server (SEP-2352) * fix(auth): address SEP-2352 review feedback on AS credential binding
1 parent 7d811c4 commit 8efc142

2 files changed

Lines changed: 146 additions & 4 deletions

File tree

conformance/src/bin/client.rs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use rmcp::{
44
service::RequestContext,
55
transport::{
66
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
7-
auth::{AuthorizationCallback, OAuthState},
7+
auth::{AuthorizationCallback, InMemoryCredentialStore, OAuthState},
88
streamable_http_client::StreamableHttpClientTransportConfig,
99
},
1010
};
@@ -481,6 +481,66 @@ async fn run_auth_scope_retry_limit_client(
481481
Ok(())
482482
}
483483

484+
async fn migration_token(
485+
server_url: &str,
486+
store: &InMemoryCredentialStore,
487+
) -> anyhow::Result<String> {
488+
let mut manager = AuthorizationManager::new(server_url).await?;
489+
manager.set_credential_store(store.clone());
490+
491+
if manager.initialize_from_store().await? {
492+
return Ok(manager.get_access_token().await?);
493+
}
494+
495+
let metadata = manager.discover_metadata().await?;
496+
manager.set_metadata(metadata);
497+
manager
498+
.register_client("conformance-client", REDIRECT_URI, &[])
499+
.await?;
500+
501+
let scopes = manager.select_scopes(None, &[]);
502+
let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
503+
let auth_url = manager.get_authorization_url(&scope_refs).await?;
504+
let callback = headless_authorize(&auth_url).await?;
505+
manager
506+
.exchange_code_for_token_with_issuer(
507+
&callback.code,
508+
&callback.csrf_token,
509+
callback.issuer.as_deref(),
510+
)
511+
.await?;
512+
513+
Ok(manager.get_access_token().await?)
514+
}
515+
516+
async fn run_auth_server_migration_client(
517+
server_url: &str,
518+
_ctx: &ConformanceContext,
519+
) -> anyhow::Result<()> {
520+
let store = InMemoryCredentialStore::new();
521+
let http = reqwest::Client::new();
522+
let body = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}});
523+
524+
let mut token = migration_token(server_url, &store).await?;
525+
for _ in 0..3 {
526+
let resp = http
527+
.post(server_url)
528+
.header(
529+
"MCP-Protocol-Version",
530+
conformance_protocol_version().as_str(),
531+
)
532+
.bearer_auth(&token)
533+
.json(&body)
534+
.send()
535+
.await?;
536+
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
537+
token = migration_token(server_url, &store).await?;
538+
}
539+
}
540+
541+
Ok(())
542+
}
543+
484544
/// Auth flow with pre-registered credentials (from context).
485545
async fn run_auth_preregistered_client(
486546
server_url: &str,
@@ -942,6 +1002,11 @@ async fn main() -> anyhow::Result<()> {
9421002
// Auth - scope retry limit
9431003
"auth/scope-retry-limit" => run_auth_scope_retry_limit_client(&server_url, &ctx).await?,
9441004

1005+
// Auth - authorization server migration (SEP-2352)
1006+
"auth/authorization-server-migration" => {
1007+
run_auth_server_migration_client(&server_url, &ctx).await?
1008+
}
1009+
9451010
// Auth - pre-registration
9461011
"auth/pre-registration" => run_auth_preregistered_client(&server_url, &ctx).await?,
9471012

crates/rmcp/src/transport/auth.rs

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ pub struct StoredCredentials {
201201
pub granted_scopes: Vec<String>,
202202
#[serde(default)]
203203
pub token_received_at: Option<u64>,
204+
#[serde(default)]
205+
pub issuer: Option<String>,
204206
}
205207

206208
impl std::fmt::Debug for StoredCredentials {
@@ -213,6 +215,7 @@ impl std::fmt::Debug for StoredCredentials {
213215
)
214216
.field("granted_scopes", &self.granted_scopes)
215217
.field("token_received_at", &self.token_received_at)
218+
.field("issuer", &self.issuer)
216219
.finish()
217220
}
218221
}
@@ -230,8 +233,14 @@ impl StoredCredentials {
230233
token_response,
231234
granted_scopes,
232235
token_received_at,
236+
issuer: None,
233237
}
234238
}
239+
240+
pub fn with_issuer(mut self, issuer: Option<String>) -> Self {
241+
self.issuer = issuer;
242+
self
243+
}
235244
}
236245

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

1100+
if let (Some(stored_issuer), Some(current_issuer)) =
1101+
(stored.issuer.as_deref(), self.metadata_issuer().as_deref())
1102+
{
1103+
// A CIMD client ID is the client's metadata URL, so it is
1104+
// portable across authorization servers and exempt here.
1105+
if stored_issuer != current_issuer {
1106+
if is_https_url(&stored.client_id) {
1107+
// A CIMD client ID is the client's metadata URL, so it is
1108+
// portable across authorization servers — but the tokens
1109+
// were minted by the previous AS and must not be reused.
1110+
tracing::warn!(
1111+
stored_issuer,
1112+
current_issuer,
1113+
"authorization server issuer changed; discarding tokens but keeping portable CIMD client ID"
1114+
);
1115+
self.credential_store
1116+
.save(
1117+
StoredCredentials::new(
1118+
stored.client_id.clone(),
1119+
None,
1120+
vec![],
1121+
None,
1122+
)
1123+
.with_issuer(self.metadata_issuer()),
1124+
)
1125+
.await?;
1126+
self.configure_client_id(&stored.client_id)?;
1127+
return Ok(false);
1128+
}
1129+
1130+
tracing::warn!(
1131+
stored_issuer,
1132+
current_issuer,
1133+
"authorization server issuer changed; clearing stored credentials bound to the previous issuer"
1134+
);
1135+
self.credential_store.clear().await?;
1136+
return Err(AuthError::AuthorizationServerMismatch {
1137+
expected_issuer: stored_issuer.to_string(),
1138+
received_issuer: current_issuer.to_string(),
1139+
});
1140+
}
1141+
}
1142+
10911143
self.configure_client_id(&stored.client_id)?;
10921144
return Ok(true);
10931145
}
@@ -1703,6 +1755,7 @@ impl AuthorizationManager {
17031755
token_response: Some(token_result.clone()),
17041756
granted_scopes,
17051757
token_received_at: Some(Self::now_epoch_secs()),
1758+
issuer: self.metadata_issuer(),
17061759
};
17071760
self.credential_store.save(stored).await?;
17081761

@@ -1716,6 +1769,10 @@ impl AuthorizationManager {
17161769
.as_secs()
17171770
}
17181771

1772+
fn metadata_issuer(&self) -> Option<String> {
1773+
self.metadata.as_ref().and_then(|m| m.issuer.clone())
1774+
}
1775+
17191776
/// Proactive refresh buffer: refresh tokens this many seconds before they expire
17201777
/// to avoid races between token retrieval and the actual HTTP request.
17211778
const REFRESH_BUFFER_SECS: u64 = 30;
@@ -1838,6 +1895,7 @@ impl AuthorizationManager {
18381895
token_response: Some(token_result.clone()),
18391896
granted_scopes,
18401897
token_received_at: Some(Self::now_epoch_secs()),
1898+
issuer: self.metadata_issuer(),
18411899
};
18421900
self.credential_store.save(stored).await?;
18431901

@@ -2658,6 +2716,7 @@ impl AuthorizationManager {
26582716
token_response: Some(token_result.clone()),
26592717
granted_scopes,
26602718
token_received_at: Some(Self::now_epoch_secs()),
2719+
issuer: self.metadata_issuer(),
26612720
};
26622721
self.credential_store.save(stored).await?;
26632722

@@ -2780,6 +2839,7 @@ impl AuthorizationManager {
27802839
token_response: Some(token_result.clone()),
27812840
granted_scopes,
27822841
token_received_at: Some(Self::now_epoch_secs()),
2842+
issuer: self.metadata_issuer(),
27832843
};
27842844
self.credential_store.save(stored).await?;
27852845

@@ -3170,17 +3230,18 @@ impl OAuthState {
31703230

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

3233+
let metadata = manager.discover_metadata().await?;
3234+
manager.metadata = Some(metadata);
3235+
31733236
let stored = StoredCredentials {
31743237
client_id: client_id.to_string(),
31753238
token_response: Some(credentials),
31763239
granted_scopes,
31773240
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
3241+
issuer: manager.metadata_issuer(),
31783242
};
31793243
manager.credential_store.save(stored).await?;
31803244

3181-
let metadata = manager.discover_metadata().await?;
3182-
manager.metadata = Some(metadata);
3183-
31843245
manager.configure_client_id(client_id)?;
31853246

31863247
*self = OAuthState::Authorized(manager);
@@ -4887,6 +4948,7 @@ mod tests {
48874948
token_response: Some(token_response),
48884949
granted_scopes: vec![],
48894950
token_received_at: None,
4951+
issuer: None,
48904952
};
48914953
let debug_output = format!("{:?}", creds);
48924954

@@ -5864,6 +5926,7 @@ mod tests {
58645926
token_response: Some(make_token_response("my-access-token", Some(3600))),
58655927
granted_scopes: vec![],
58665928
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
5929+
issuer: None,
58675930
};
58685931
manager.credential_store.save(stored).await.unwrap();
58695932

@@ -5881,6 +5944,7 @@ mod tests {
58815944
token_response: Some(make_token_response("stale-token", Some(3600))),
58825945
granted_scopes: vec![],
58835946
token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200),
5947+
issuer: None,
58845948
};
58855949
manager.credential_store.save(stored).await.unwrap();
58865950

@@ -5899,6 +5963,7 @@ mod tests {
58995963
token_response: Some(make_token_response("no-expiry-token", None)),
59005964
granted_scopes: vec![],
59015965
token_received_at: None,
5966+
issuer: None,
59025967
};
59035968
manager.credential_store.save(stored).await.unwrap();
59045969

@@ -5916,6 +5981,7 @@ mod tests {
59165981
token_response: Some(make_token_response("almost-expired", Some(3600))),
59175982
granted_scopes: vec![],
59185983
token_received_at: Some(AuthorizationManager::now_epoch_secs() - 3590),
5984+
issuer: None,
59195985
};
59205986
manager.credential_store.save(stored).await.unwrap();
59215987

@@ -5934,6 +6000,7 @@ mod tests {
59346000
token_response: Some(make_token_response("stale-token", Some(3600))),
59356001
granted_scopes: vec![],
59366002
token_received_at: Some(AuthorizationManager::now_epoch_secs() - 7200),
6003+
issuer: None,
59376004
};
59386005
manager.credential_store.save(stored).await.unwrap();
59396006

@@ -6235,6 +6302,7 @@ mod tests {
62356302
)),
62366303
granted_scopes: vec![],
62376304
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
6305+
issuer: None,
62386306
})
62396307
.await
62406308
.unwrap();
@@ -6263,6 +6331,7 @@ mod tests {
62636331
token_response: None,
62646332
granted_scopes: vec![],
62656333
token_received_at: None,
6334+
issuer: None,
62666335
};
62676336
manager.credential_store.save(stored).await.unwrap();
62686337

@@ -6283,6 +6352,7 @@ mod tests {
62836352
token_response: Some(make_token_response("old-token", Some(3600))),
62846353
granted_scopes: vec![],
62856354
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
6355+
issuer: None,
62866356
};
62876357
manager.credential_store.save(stored).await.unwrap();
62886358

@@ -6373,6 +6443,7 @@ mod tests {
63736443
)),
63746444
granted_scopes: vec![],
63756445
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
6446+
issuer: None,
63766447
})
63776448
.await
63786449
.unwrap();
@@ -6578,6 +6649,7 @@ mod tests {
65786649
)),
65796650
granted_scopes: vec!["read".to_string(), "write".to_string()],
65806651
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
6652+
issuer: None,
65816653
};
65826654
manager.credential_store.save(stored).await.unwrap();
65836655

@@ -6615,6 +6687,7 @@ mod tests {
66156687
)),
66166688
granted_scopes: vec![],
66176689
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
6690+
issuer: None,
66186691
};
66196692
manager.credential_store.save(stored).await.unwrap();
66206693

@@ -6652,6 +6725,7 @@ mod tests {
66526725
)),
66536726
granted_scopes: vec!["read".to_string()],
66546727
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
6728+
issuer: None,
66556729
};
66566730
manager.credential_store.save(stored).await.unwrap();
66576731

@@ -6689,6 +6763,7 @@ mod tests {
66896763
)),
66906764
granted_scopes: vec![],
66916765
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
6766+
issuer: None,
66926767
};
66936768
manager.credential_store.save(stored).await.unwrap();
66946769

@@ -6727,6 +6802,7 @@ mod tests {
67276802
)),
67286803
granted_scopes: vec![],
67296804
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
6805+
issuer: None,
67306806
};
67316807
manager.credential_store.save(stored).await.unwrap();
67326808

@@ -6790,6 +6866,7 @@ mod tests {
67906866
)),
67916867
granted_scopes: vec![],
67926868
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
6869+
issuer: None,
67936870
};
67946871
manager.credential_store.save(stored).await.unwrap();
67956872

0 commit comments

Comments
 (0)