11-25-2025 compliant Auth (#651)
* fix: correct discovery for AS metadata * fix: add commitlint to dev container * feat: add RFC 8707 support for resource parameter * feat: pkce method verification * feat(auth): implement SEP-835 scope handling and 403 upgrade flow - add WWWAuthenticateParams for parsing scope and resource_metadata from headers - add ScopeUpgradeConfig and scope tracking in AuthorizationManager - add InsufficientScopeError and 403 handling in streamable HTTP client - add scope union computation for progressive authorization - export new public types: AuthClient, ScopeUpgradeConfig, WWWAuthenticateParams Co-authored-by: fizy069 <fizy069@users.noreply.github.com> * fix: reorg auth tests * feat: add error to www-authenticate header parsing * feat: consider protected resource metadata in scope selection * fix: reorganize auth tests * feat: add examples and docs for updated auth --------- Co-authored-by: fizy069 <fizy069@users.noreply.github.com>
This commit is contained in:
parent
a1c66a8a36
commit
61845d61c4
4 changed files with 996 additions and 256 deletions
|
|
@ -107,9 +107,9 @@ pub mod auth;
|
|||
#[cfg(feature = "auth")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "auth")))]
|
||||
pub use auth::{
|
||||
AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient, CredentialStore,
|
||||
InMemoryCredentialStore, InMemoryStateStore, StateStore, StoredAuthorizationState,
|
||||
StoredCredentials,
|
||||
AuthClient, AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient,
|
||||
CredentialStore, InMemoryCredentialStore, InMemoryStateStore, ScopeUpgradeConfig, StateStore,
|
||||
StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams,
|
||||
};
|
||||
|
||||
// #[cfg(feature = "transport-ws")]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -120,6 +120,22 @@ impl StreamableHttpClient for reqwest::Client {
|
|||
}));
|
||||
}
|
||||
}
|
||||
if response.status() == reqwest::StatusCode::FORBIDDEN {
|
||||
if let Some(header) = response.headers().get(WWW_AUTHENTICATE) {
|
||||
let header_str = header.to_str().map_err(|_| {
|
||||
StreamableHttpError::UnexpectedServerResponse(Cow::from(
|
||||
"invalid www-authenticate header value",
|
||||
))
|
||||
})?;
|
||||
let scope = extract_scope_from_header(header_str);
|
||||
return Err(StreamableHttpError::InsufficientScope(
|
||||
InsufficientScopeError {
|
||||
www_authenticate_header: header_str.to_string(),
|
||||
required_scope: scope,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
let status = response.status();
|
||||
if matches!(
|
||||
status,
|
||||
|
|
@ -197,3 +213,81 @@ impl StreamableHttpClientTransport<reqwest::Client> {
|
|||
StreamableHttpClientTransport::with_client(reqwest::Client::default(), config)
|
||||
}
|
||||
}
|
||||
|
||||
/// extract scope parameter from WWW-Authenticate header
|
||||
fn extract_scope_from_header(header: &str) -> Option<String> {
|
||||
let header_lowercase = header.to_ascii_lowercase();
|
||||
let scope_key = "scope=";
|
||||
|
||||
if let Some(pos) = header_lowercase.find(scope_key) {
|
||||
let start = pos + scope_key.len();
|
||||
let value_slice = &header[start..];
|
||||
|
||||
if let Some(stripped) = value_slice.strip_prefix('"') {
|
||||
if let Some(end_quote) = stripped.find('"') {
|
||||
return Some(stripped[..end_quote].to_string());
|
||||
}
|
||||
} else {
|
||||
let end = value_slice
|
||||
.find(|c: char| c == ',' || c == ';' || c.is_whitespace())
|
||||
.unwrap_or(value_slice.len());
|
||||
if end > 0 {
|
||||
return Some(value_slice[..end].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_scope_from_header;
|
||||
use crate::transport::streamable_http_client::InsufficientScopeError;
|
||||
|
||||
#[test]
|
||||
fn extract_scope_quoted() {
|
||||
let header = r#"Bearer error="insufficient_scope", scope="files:read files:write""#;
|
||||
assert_eq!(
|
||||
extract_scope_from_header(header),
|
||||
Some("files:read files:write".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_scope_unquoted() {
|
||||
let header = r#"Bearer scope=read:data, error="insufficient_scope""#;
|
||||
assert_eq!(
|
||||
extract_scope_from_header(header),
|
||||
Some("read:data".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_scope_missing() {
|
||||
let header = r#"Bearer error="invalid_token""#;
|
||||
assert_eq!(extract_scope_from_header(header), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_scope_empty_header() {
|
||||
assert_eq!(extract_scope_from_header("Bearer"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insufficient_scope_error_can_upgrade() {
|
||||
let with_scope = InsufficientScopeError {
|
||||
www_authenticate_header: "Bearer scope=\"admin\"".to_string(),
|
||||
required_scope: Some("admin".to_string()),
|
||||
};
|
||||
assert!(with_scope.can_upgrade());
|
||||
assert_eq!(with_scope.get_required_scope(), Some("admin"));
|
||||
|
||||
let without_scope = InsufficientScopeError {
|
||||
www_authenticate_header: "Bearer error=\"insufficient_scope\"".to_string(),
|
||||
required_scope: None,
|
||||
};
|
||||
assert!(!without_scope.can_upgrade());
|
||||
assert_eq!(without_scope.get_required_scope(), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,24 @@ pub struct AuthRequiredError {
|
|||
pub www_authenticate_header: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InsufficientScopeError {
|
||||
pub www_authenticate_header: String,
|
||||
pub required_scope: Option<String>,
|
||||
}
|
||||
|
||||
impl InsufficientScopeError {
|
||||
/// check if scope upgrade is possible (i.e., we know what scope is required)
|
||||
pub fn can_upgrade(&self) -> bool {
|
||||
self.required_scope.is_some()
|
||||
}
|
||||
|
||||
/// get the required scope for upgrade
|
||||
pub fn get_required_scope(&self) -> Option<&str> {
|
||||
self.required_scope.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum StreamableHttpError<E: std::error::Error + Send + Sync + 'static> {
|
||||
#[error("SSE error: {0}")]
|
||||
|
|
@ -56,6 +74,8 @@ pub enum StreamableHttpError<E: std::error::Error + Send + Sync + 'static> {
|
|||
Auth(#[from] crate::transport::auth::AuthError),
|
||||
#[error("Auth required")]
|
||||
AuthRequired(AuthRequiredError),
|
||||
#[error("Insufficient scope")]
|
||||
InsufficientScope(InsufficientScopeError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
|
|
|
|||
Loading…
Reference in a new issue