feat(auth): implement SEP-2207 OIDC-flavored refresh token guidance (#676)
* feat: implement sep-2207 refresh token guidance * fix: update client-metadata.json to allow refresh tokens
This commit is contained in:
parent
baf22d37bb
commit
a32a9c83a1
2 changed files with 171 additions and 4 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"client_id": "https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/refs/heads/main/client-metadata.json",
|
"client_id": "https://raw.githubusercontent.com/modelcontextprotocol/rust-sdk/refs/heads/main/client-metadata.json",
|
||||||
"redirect_uris": ["http://127.0.0.1:8080/callback"],
|
"redirect_uris": ["http://127.0.0.1:8080/callback"],
|
||||||
"grant_types": ["authorization_code"],
|
"grant_types": ["authorization_code", "refresh_token"],
|
||||||
"response_types": ["code"],
|
"response_types": ["code"],
|
||||||
"token_endpoint_auth_method": "none"
|
"token_endpoint_auth_method": "none"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -971,12 +971,23 @@ impl AuthorizationManager {
|
||||||
attempts < self.scope_upgrade_config.max_upgrade_attempts
|
attempts < self.scope_upgrade_config.max_upgrade_attempts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// select scopes to request from authorization server
|
||||||
|
pub fn select_scopes(
|
||||||
|
&self,
|
||||||
|
www_authenticate_scope: Option<&str>,
|
||||||
|
default_scopes: &[&str],
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut scopes = self.select_base_scopes(www_authenticate_scope, default_scopes);
|
||||||
|
self.add_offline_access_if_supported(&mut scopes);
|
||||||
|
scopes
|
||||||
|
}
|
||||||
|
|
||||||
/// select scopes based on SEP-835 priority:
|
/// select scopes based on SEP-835 priority:
|
||||||
/// 1. scope from WWW-Authenticate header (argument or stored from initial 401 probe)
|
/// 1. scope from WWW-Authenticate header (argument or stored from initial 401 probe)
|
||||||
/// 2. scopes_supported from protected resource metadata (RFC 9728)
|
/// 2. scopes_supported from protected resource metadata (RFC 9728)
|
||||||
/// 3. scopes_supported from authorization server metadata
|
/// 3. scopes_supported from authorization server metadata
|
||||||
/// 4. provided default scopes
|
/// 4. provided default scopes
|
||||||
pub fn select_scopes(
|
fn select_base_scopes(
|
||||||
&self,
|
&self,
|
||||||
www_authenticate_scope: Option<&str>,
|
www_authenticate_scope: Option<&str>,
|
||||||
default_scopes: &[&str],
|
default_scopes: &[&str],
|
||||||
|
|
@ -1011,6 +1022,21 @@ impl AuthorizationManager {
|
||||||
default_scopes.iter().map(|s| s.to_string()).collect()
|
default_scopes.iter().map(|s| s.to_string()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SEP-2207: when the AS advertises `offline_access` in `scopes_supported`, append
|
||||||
|
/// it so OIDC-flavored Authorization Servers will issue refresh tokens.
|
||||||
|
fn add_offline_access_if_supported(&self, scopes: &mut Vec<String>) {
|
||||||
|
if scopes.is_empty() || scopes.iter().any(|s| s == "offline_access") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(metadata) = &self.metadata {
|
||||||
|
if let Some(supported) = &metadata.scopes_supported {
|
||||||
|
if supported.iter().any(|s| s == "offline_access") {
|
||||||
|
scopes.push("offline_access".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// attempt to upgrade scopes after receiving a 403 insufficient_scope error.
|
/// attempt to upgrade scopes after receiving a 403 insufficient_scope error.
|
||||||
/// returns the authorization URL for re-authorization with upgraded scopes.
|
/// returns the authorization URL for re-authorization with upgraded scopes.
|
||||||
pub async fn request_scope_upgrade(&self, required_scope: &str) -> Result<String, AuthError> {
|
pub async fn request_scope_upgrade(&self, required_scope: &str) -> Result<String, AuthError> {
|
||||||
|
|
@ -1143,7 +1169,11 @@ impl AuthorizationManager {
|
||||||
/// to avoid races between token retrieval and the actual HTTP request.
|
/// to avoid races between token retrieval and the actual HTTP request.
|
||||||
const REFRESH_BUFFER_SECS: u64 = 30;
|
const REFRESH_BUFFER_SECS: u64 = 30;
|
||||||
|
|
||||||
/// get access token, if expired, refresh it automatically
|
/// Get access token from local credential store.
|
||||||
|
/// If expired, refresh it automatically when a refresh token is available.
|
||||||
|
/// When the access token has expired and no refresh token is available (or
|
||||||
|
/// the refresh itself fails), returns [`AuthError::AuthorizationRequired`]
|
||||||
|
/// so the caller can re-authenticate.
|
||||||
pub async fn get_access_token(&self) -> Result<String, AuthError> {
|
pub async fn get_access_token(&self) -> Result<String, AuthError> {
|
||||||
let stored = self.credential_store.load().await?;
|
let stored = self.credential_store.load().await?;
|
||||||
let Some(stored_creds) = stored else {
|
let Some(stored_creds) = stored else {
|
||||||
|
|
@ -2275,7 +2305,9 @@ impl OAuthState {
|
||||||
let selected_scopes: Vec<String> = if scopes.is_empty() {
|
let selected_scopes: Vec<String> = if scopes.is_empty() {
|
||||||
manager.select_scopes(None, &[])
|
manager.select_scopes(None, &[])
|
||||||
} else {
|
} else {
|
||||||
scopes.iter().map(|s| s.to_string()).collect()
|
let mut s: Vec<String> = scopes.iter().map(|s| s.to_string()).collect();
|
||||||
|
manager.add_offline_access_if_supported(&mut s);
|
||||||
|
s
|
||||||
};
|
};
|
||||||
let scope_refs: Vec<&str> = selected_scopes.iter().map(|s| s.as_str()).collect();
|
let scope_refs: Vec<&str> = selected_scopes.iter().map(|s| s.as_str()).collect();
|
||||||
debug!("start session");
|
debug!("start session");
|
||||||
|
|
@ -3279,6 +3311,141 @@ mod tests {
|
||||||
assert_eq!(result.len(), 2);
|
assert_eq!(result.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- SEP-2207: offline_access --
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn select_scopes_adds_offline_access_when_as_supports_it() {
|
||||||
|
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
|
||||||
|
authorization_endpoint: "http://localhost/authorize".to_string(),
|
||||||
|
token_endpoint: "http://localhost/token".to_string(),
|
||||||
|
scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
*mgr.resource_scopes.write().await = vec!["profile".to_string()];
|
||||||
|
|
||||||
|
let scopes = mgr.select_scopes(None, &[]);
|
||||||
|
assert!(
|
||||||
|
scopes.contains(&"offline_access".to_string()),
|
||||||
|
"offline_access should be added when AS supports it"
|
||||||
|
);
|
||||||
|
assert!(scopes.contains(&"profile".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn select_scopes_does_not_add_offline_access_when_as_does_not_support_it() {
|
||||||
|
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
|
||||||
|
authorization_endpoint: "http://localhost/authorize".to_string(),
|
||||||
|
token_endpoint: "http://localhost/token".to_string(),
|
||||||
|
scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
*mgr.resource_scopes.write().await = vec!["profile".to_string()];
|
||||||
|
|
||||||
|
let scopes = mgr.select_scopes(None, &[]);
|
||||||
|
assert!(
|
||||||
|
!scopes.contains(&"offline_access".to_string()),
|
||||||
|
"offline_access should not be added when AS does not support it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn select_scopes_falls_back_to_defaults() {
|
||||||
|
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
|
||||||
|
authorization_endpoint: "http://localhost/authorize".to_string(),
|
||||||
|
token_endpoint: "http://localhost/token".to_string(),
|
||||||
|
scopes_supported: None,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let scopes = mgr.select_scopes(None, &["default_scope"]);
|
||||||
|
assert_eq!(scopes, vec!["default_scope".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn select_scopes_does_not_duplicate_offline_access() {
|
||||||
|
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
|
||||||
|
authorization_endpoint: "http://localhost/authorize".to_string(),
|
||||||
|
token_endpoint: "http://localhost/token".to_string(),
|
||||||
|
scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// When AS metadata is the scope source and already contains offline_access,
|
||||||
|
// it should appear exactly once.
|
||||||
|
let scopes = mgr.select_scopes(None, &[]);
|
||||||
|
let count = scopes.iter().filter(|s| *s == "offline_access").count();
|
||||||
|
assert_eq!(count, 1, "offline_access should not be duplicated");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn select_scopes_adds_offline_access_to_www_authenticate_scopes() {
|
||||||
|
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
|
||||||
|
authorization_endpoint: "http://localhost/authorize".to_string(),
|
||||||
|
token_endpoint: "http://localhost/token".to_string(),
|
||||||
|
scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
*mgr.www_auth_scopes.write().await = vec!["profile".to_string()];
|
||||||
|
|
||||||
|
let scopes = mgr.select_scopes(None, &[]);
|
||||||
|
assert!(scopes.contains(&"offline_access".to_string()));
|
||||||
|
assert!(scopes.contains(&"profile".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn select_scopes_adds_offline_access_to_www_authenticate_argument() {
|
||||||
|
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
|
||||||
|
authorization_endpoint: "http://localhost/authorize".to_string(),
|
||||||
|
token_endpoint: "http://localhost/token".to_string(),
|
||||||
|
scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let scopes = mgr.select_scopes(Some("profile email"), &[]);
|
||||||
|
assert!(scopes.contains(&"offline_access".to_string()));
|
||||||
|
assert!(scopes.contains(&"profile".to_string()));
|
||||||
|
assert!(scopes.contains(&"email".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn add_offline_access_if_supported_works_with_explicit_scopes() {
|
||||||
|
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
|
||||||
|
authorization_endpoint: "http://localhost/authorize".to_string(),
|
||||||
|
token_endpoint: "http://localhost/token".to_string(),
|
||||||
|
scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut explicit = vec!["read".to_string(), "write".to_string()];
|
||||||
|
mgr.add_offline_access_if_supported(&mut explicit);
|
||||||
|
assert!(explicit.contains(&"offline_access".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn add_offline_access_if_supported_skips_empty_scopes() {
|
||||||
|
let mgr = manager_with_metadata(Some(AuthorizationMetadata {
|
||||||
|
authorization_endpoint: "http://localhost/authorize".to_string(),
|
||||||
|
token_endpoint: "http://localhost/token".to_string(),
|
||||||
|
scopes_supported: Some(vec!["profile".to_string(), "offline_access".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut empty: Vec<String> = vec![];
|
||||||
|
mgr.add_offline_access_if_supported(&mut empty);
|
||||||
|
assert!(
|
||||||
|
empty.is_empty(),
|
||||||
|
"offline_access should not be the only scope"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scope_upgrade_config_default_values() {
|
fn scope_upgrade_config_default_values() {
|
||||||
let config = ScopeUpgradeConfig::default();
|
let config = ScopeUpgradeConfig::default();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue