#![cfg(feature = "auth")]
use std::sync::Arc;
use rmcp::transport::auth::{
AuthError, AuthorizationManager, AuthorizationMetadata, CredentialRefreshGuard,
CredentialStore, InMemoryCredentialStore, OAuthClientConfig, OAuthTokenResponse,
StoredCredentials,
};
use tokio::sync::Mutex;
const REALM: &str = "rmcp";
const CLIENT_ID: &str = "rmcp-client";
const CLIENT_SECRET: &str = "rmcp-secret";
fn kc_base() -> String {
std::env::var("KC_BASE").unwrap_or_else(|_| "http://localhost:8081".to_string())
}
fn token_endpoint() -> String {
format!("{}/realms/{REALM}/protocol/openid-connect/token", kc_base())
}
async fn issue_real_credentials() -> OAuthTokenResponse {
let form = format!(
"client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}\
&username=alice&password=alice-pw&grant_type=password&scope=openid+profile"
);
let body = reqwest::Client::new()
.post(token_endpoint())
.header("content-type", "application/x-www-form-urlencoded")
.body(form)
.send()
.await
.expect("keycloak unreachable")
.text()
.await
.unwrap();
serde_json::from_str(&body).unwrap_or_else(|e| panic!("unexpected token response {body}: {e}"))
}
fn metadata() -> AuthorizationMetadata {
let mut metadata = AuthorizationMetadata::default();
metadata.authorization_endpoint =
format!("{}/realms/{REALM}/protocol/openid-connect/auth", kc_base());
metadata.token_endpoint = token_endpoint();
metadata
}
fn client_config() -> OAuthClientConfig {
let mut config = OAuthClientConfig::new(CLIENT_ID, "http://localhost/callback");
config.client_secret = Some(CLIENT_SECRET.to_string());
config
}
async fn manager_with_store<S: CredentialStore + 'static>(store: S) -> AuthorizationManager {
let mut manager = AuthorizationManager::new(kc_base()).await.unwrap();
manager.set_metadata(metadata());
manager.configure_client(client_config()).unwrap();
manager.set_credential_store(store);
manager
}
fn stored(client_id: &str, token: OAuthTokenResponse) -> StoredCredentials {
StoredCredentials::new(
client_id.to_string(),
Some(token),
vec!["openid".into(), "profile".into()],
None,
)
}
#[derive(Clone, Default)]
struct GuardedStore {
inner: InMemoryCredentialStore,
lock: Arc<Mutex<()>>,
}
#[async_trait::async_trait]
impl CredentialStore for GuardedStore {
async fn load(&self) -> Result<Option<StoredCredentials>, AuthError> {
self.inner.load().await
}
async fn save(&self, credentials: StoredCredentials) -> Result<(), AuthError> {
self.inner.save(credentials).await
}
async fn clear(&self) -> Result<(), AuthError> {
self.inner.clear().await
}
async fn acquire_refresh_guard(&self) -> Result<Option<CredentialRefreshGuard>, AuthError> {
Ok(Some(CredentialRefreshGuard::new(
self.lock.clone().lock_owned().await,
)))
}
}
#[tokio::test]
#[ignore = "requires a live Keycloak"]
async fn live_refresh_rotates_the_stored_token() {
use oauth2::TokenResponse;
let issued = issue_real_credentials().await;
let original_refresh = issued.refresh_token().unwrap().secret().clone();
let store = InMemoryCredentialStore::new();
store.save(stored(CLIENT_ID, issued)).await.unwrap();
let manager = manager_with_store(store.clone()).await;
let refreshed = manager.refresh_token().await.expect("live refresh failed");
let saved = store.load().await.unwrap().unwrap();
let saved_token = saved.token_response.unwrap();
assert_ne!(
saved_token.refresh_token().unwrap().secret(),
&original_refresh,
"keycloak rotates refresh tokens, so the store must hold the new one"
);
assert_eq!(
saved_token.access_token().secret(),
refreshed.access_token().secret(),
"the saved credentials must match what the caller received"
);
assert!(
saved.granted_scopes.contains(&"openid".to_string()),
"granted scopes should come from the provider response, got: {:?}",
saved.granted_scopes
);
}
#[tokio::test]
#[ignore = "requires a live Keycloak"]
async fn live_refresh_rejects_credentials_for_another_client() {
use oauth2::TokenResponse;
let issued = issue_real_credentials().await;
let untouched_refresh = issued.refresh_token().unwrap().secret().clone();
let store = InMemoryCredentialStore::new();
store
.save(stored("some-other-client", issued))
.await
.unwrap();
let manager = manager_with_store(store.clone()).await;
let error = manager.refresh_token().await.unwrap_err();
assert!(
matches!(error, AuthError::AuthorizationRequired),
"a client mismatch must require reauthorization, got: {error:?}"
);
let saved = store.load().await.unwrap().unwrap();
assert_eq!(
saved
.token_response
.unwrap()
.refresh_token()
.unwrap()
.secret(),
&untouched_refresh,
"a rejected refresh must leave the stored token untouched"
);
}
#[tokio::test]
#[ignore = "requires a live Keycloak"]
async fn live_concurrent_refreshes_survive_refresh_token_rotation() {
use oauth2::TokenResponse;
let issued = issue_real_credentials().await;
let store = GuardedStore::default();
store.save(stored(CLIENT_ID, issued)).await.unwrap();
let first = manager_with_store(store.clone()).await;
let second = manager_with_store(store.clone()).await;
let (a, b) = tokio::join!(
tokio::spawn(async move { first.refresh_token().await }),
tokio::spawn(async move { second.refresh_token().await })
);
let a = a.unwrap().expect("first concurrent refresh failed");
let b = b.unwrap().expect("second concurrent refresh failed");
assert_ne!(
a.access_token().secret(),
b.access_token().secret(),
"each caller performs its own exchange, so the tokens must differ"
);
}
#[tokio::test]
#[ignore = "requires a live Keycloak with revokeRefreshToken enabled"]
async fn live_reusing_a_rotated_refresh_token_is_rejected() {
let issued = issue_real_credentials().await;
let first_store = InMemoryCredentialStore::new();
first_store
.save(stored(CLIENT_ID, issued.clone()))
.await
.unwrap();
manager_with_store(first_store)
.await
.refresh_token()
.await
.expect("the first refresh should succeed");
let stale_store = InMemoryCredentialStore::new();
stale_store.save(stored(CLIENT_ID, issued)).await.unwrap();
let error = manager_with_store(stale_store)
.await
.refresh_token()
.await
.unwrap_err();
assert!(
matches!(error, AuthError::TokenRefreshRejected(_)),
"reusing a rotated refresh token must be rejected, got: {error:?}"
);
}