feat: validate OAuth authorization response issuer (#896)
* feat: validate OAuth authorization response issuer * fix: tighten issuer validation callbacks
This commit is contained in:
parent
f1ef2ec86c
commit
2d3d1879ad
1 changed files with 420 additions and 41 deletions
|
|
@ -159,6 +159,10 @@ impl CredentialStore for InMemoryCredentialStore {
|
|||
pub struct StoredAuthorizationState {
|
||||
pub pkce_verifier: String,
|
||||
pub csrf_token: String,
|
||||
#[serde(default)]
|
||||
pub expected_issuer: Option<String>,
|
||||
#[serde(default)]
|
||||
pub require_issuer: bool,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
|
|
@ -167,6 +171,8 @@ impl std::fmt::Debug for StoredAuthorizationState {
|
|||
f.debug_struct("StoredAuthorizationState")
|
||||
.field("pkce_verifier", &"[REDACTED]")
|
||||
.field("csrf_token", &"[REDACTED]")
|
||||
.field("expected_issuer", &self.expected_issuer)
|
||||
.field("require_issuer", &self.require_issuer)
|
||||
.field("created_at", &self.created_at)
|
||||
.finish()
|
||||
}
|
||||
|
|
@ -201,9 +207,20 @@ impl ExtraTokenFields for VendorExtraTokenFields {}
|
|||
|
||||
impl StoredAuthorizationState {
|
||||
pub fn new(pkce_verifier: &PkceCodeVerifier, csrf_token: &CsrfToken) -> Self {
|
||||
Self::new_with_expected_issuer(pkce_verifier, csrf_token, None, false)
|
||||
}
|
||||
|
||||
pub fn new_with_expected_issuer(
|
||||
pkce_verifier: &PkceCodeVerifier,
|
||||
csrf_token: &CsrfToken,
|
||||
expected_issuer: Option<String>,
|
||||
require_issuer: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
pkce_verifier: pkce_verifier.secret().to_string(),
|
||||
csrf_token: csrf_token.secret().to_string(),
|
||||
expected_issuer,
|
||||
require_issuer,
|
||||
created_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
|
|
@ -364,6 +381,17 @@ pub enum AuthError {
|
|||
upgrade_url: Option<String>,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"Authorization server issuer mismatch: expected {expected_issuer}, received {received_issuer}"
|
||||
)]
|
||||
AuthorizationServerMismatch {
|
||||
expected_issuer: String,
|
||||
received_issuer: String,
|
||||
},
|
||||
|
||||
#[error("Authorization server response missing required issuer: expected {expected_issuer}")]
|
||||
AuthorizationServerMissingIssuer { expected_issuer: String },
|
||||
|
||||
#[error("Client credentials error: {0}")]
|
||||
ClientCredentialsError(String),
|
||||
|
||||
|
|
@ -1026,8 +1054,27 @@ impl AuthorizationManager {
|
|||
|
||||
let (auth_url, csrf_token) = auth_request.url();
|
||||
|
||||
// store pkce verifier for later use via state store
|
||||
let stored_state = StoredAuthorizationState::new(&pkce_verifier, &csrf_token);
|
||||
// store pkce verifier and expected issuer for later use via state store
|
||||
let expected_issuer = self
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.issuer.clone());
|
||||
let require_issuer = self
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| {
|
||||
metadata
|
||||
.additional_fields
|
||||
.get("authorization_response_iss_parameter_supported")
|
||||
.and_then(|value| value.as_bool())
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let stored_state = StoredAuthorizationState::new_with_expected_issuer(
|
||||
&pkce_verifier,
|
||||
&csrf_token,
|
||||
expected_issuer,
|
||||
require_issuer,
|
||||
);
|
||||
self.state_store
|
||||
.save(csrf_token.secret(), stored_state)
|
||||
.await?;
|
||||
|
|
@ -1166,11 +1213,58 @@ impl AuthorizationManager {
|
|||
*self.scope_upgrade_attempts.read().await
|
||||
}
|
||||
|
||||
fn validate_authorization_response_issuer(
|
||||
stored_state: &StoredAuthorizationState,
|
||||
received_issuer: Option<&str>,
|
||||
) -> Result<(), AuthError> {
|
||||
let Some(expected_issuer) = stored_state.expected_issuer.as_deref() else {
|
||||
if received_issuer.is_some() || stored_state.require_issuer {
|
||||
return Err(AuthError::AuthorizationFailed(
|
||||
"Authorization callback issuer cannot be validated because expected issuer was not recorded"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
// Without issuer metadata from discovery and without an issuer-bearing
|
||||
// callback, there is no stable value to bind to. This preserves
|
||||
// compatibility with older authorization servers.
|
||||
return Ok(());
|
||||
};
|
||||
let Some(received_issuer) = received_issuer else {
|
||||
if stored_state.require_issuer {
|
||||
return Err(AuthError::AuthorizationServerMissingIssuer {
|
||||
expected_issuer: expected_issuer.to_string(),
|
||||
});
|
||||
}
|
||||
// SEP-2468 recommends RFC 9207 `iss`, but tolerate older authorization
|
||||
// servers that do not advertise support for backwards compatibility.
|
||||
return Ok(());
|
||||
};
|
||||
if received_issuer != expected_issuer {
|
||||
return Err(AuthError::AuthorizationServerMismatch {
|
||||
expected_issuer: expected_issuer.to_string(),
|
||||
received_issuer: received_issuer.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// exchange authorization code for access token
|
||||
pub async fn exchange_code_for_token(
|
||||
&self,
|
||||
code: &str,
|
||||
csrf_token: &str,
|
||||
) -> Result<OAuthTokenResponse, AuthError> {
|
||||
self.exchange_code_for_token_with_issuer(code, csrf_token, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// exchange authorization code for access token, validating the optional
|
||||
/// RFC 9207 authorization response issuer (`iss`) when present.
|
||||
pub async fn exchange_code_for_token_with_issuer(
|
||||
&self,
|
||||
code: &str,
|
||||
csrf_token: &str,
|
||||
received_issuer: Option<&str>,
|
||||
) -> Result<OAuthTokenResponse, AuthError> {
|
||||
debug!("start exchange code for token: {:?}", code);
|
||||
let oauth_client = self
|
||||
|
|
@ -1187,6 +1281,8 @@ impl AuthorizationManager {
|
|||
// Delete state after retrieval (one-time use)
|
||||
self.state_store.delete(csrf_token).await?;
|
||||
|
||||
Self::validate_authorization_response_issuer(&stored_state, received_issuer)?;
|
||||
|
||||
// Reconstruct the PKCE verifier
|
||||
let pkce_verifier = stored_state.into_pkce_verifier();
|
||||
|
||||
|
|
@ -2132,6 +2228,47 @@ impl AuthorizationManager {
|
|||
}
|
||||
}
|
||||
|
||||
/// Parameters returned by an OAuth authorization redirect.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct AuthorizationCallback {
|
||||
pub code: String,
|
||||
pub csrf_token: String,
|
||||
pub issuer: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthorizationCallback {
|
||||
/// Parse an OAuth redirect URL and extract `code`, `state`, and optional RFC 9207 `iss`.
|
||||
pub fn from_redirect_url(url: &str) -> Result<Self, AuthError> {
|
||||
let url = Url::parse(url)?;
|
||||
let mut code = None;
|
||||
let mut csrf_token = None;
|
||||
let mut issuer = None;
|
||||
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"code" => code = Some(value.into_owned()),
|
||||
"state" => csrf_token = Some(value.into_owned()),
|
||||
"iss" => issuer = Some(value.into_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let code = code.ok_or_else(|| {
|
||||
AuthError::AuthorizationFailed("Authorization callback missing code".to_string())
|
||||
})?;
|
||||
let csrf_token = csrf_token.ok_or_else(|| {
|
||||
AuthError::AuthorizationFailed("Authorization callback missing state".to_string())
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
code,
|
||||
csrf_token,
|
||||
issuer,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// oauth2 authorization session, for guiding user to complete the authorization process
|
||||
#[non_exhaustive]
|
||||
pub struct AuthorizationSession {
|
||||
|
|
@ -2240,10 +2377,36 @@ impl AuthorizationSession {
|
|||
code: &str,
|
||||
csrf_token: &str,
|
||||
) -> Result<OAuthTokenResponse, AuthError> {
|
||||
self.auth_manager
|
||||
.exchange_code_for_token(code, csrf_token)
|
||||
self.handle_callback_with_issuer(code, csrf_token, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// handle authorization code callback, validating the optional RFC 9207
|
||||
/// authorization response issuer (`iss`) when present.
|
||||
pub async fn handle_callback_with_issuer(
|
||||
&self,
|
||||
code: &str,
|
||||
csrf_token: &str,
|
||||
issuer: Option<&str>,
|
||||
) -> Result<OAuthTokenResponse, AuthError> {
|
||||
self.auth_manager
|
||||
.exchange_code_for_token_with_issuer(code, csrf_token, issuer)
|
||||
.await
|
||||
}
|
||||
|
||||
/// handle an OAuth redirect URL, including optional RFC 9207 `iss` validation.
|
||||
pub async fn handle_callback_url(
|
||||
&self,
|
||||
callback_url: &str,
|
||||
) -> Result<OAuthTokenResponse, AuthError> {
|
||||
let callback = AuthorizationCallback::from_redirect_url(callback_url)?;
|
||||
self.handle_callback_with_issuer(
|
||||
&callback.code,
|
||||
&callback.csrf_token,
|
||||
callback.issuer.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// http client extension, automatically add authorization header
|
||||
|
|
@ -2496,9 +2659,23 @@ impl OAuthState {
|
|||
|
||||
/// handle authorization callback
|
||||
pub async fn handle_callback(&mut self, code: &str, csrf_token: &str) -> Result<(), AuthError> {
|
||||
self.handle_callback_with_issuer(code, csrf_token, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// handle authorization callback, validating the optional RFC 9207
|
||||
/// authorization response issuer (`iss`) when present.
|
||||
pub async fn handle_callback_with_issuer(
|
||||
&mut self,
|
||||
code: &str,
|
||||
csrf_token: &str,
|
||||
issuer: Option<&str>,
|
||||
) -> Result<(), AuthError> {
|
||||
match self {
|
||||
OAuthState::Session(session) => {
|
||||
session.handle_callback(code, csrf_token).await?;
|
||||
session
|
||||
.handle_callback_with_issuer(code, csrf_token, issuer)
|
||||
.await?;
|
||||
self.complete_authorization().await
|
||||
}
|
||||
OAuthState::Unauthorized(_) => {
|
||||
|
|
@ -2513,6 +2690,17 @@ impl OAuthState {
|
|||
}
|
||||
}
|
||||
|
||||
/// handle an OAuth redirect URL, including optional RFC 9207 `iss` validation.
|
||||
pub async fn handle_callback_url(&mut self, callback_url: &str) -> Result<(), AuthError> {
|
||||
let callback = AuthorizationCallback::from_redirect_url(callback_url)?;
|
||||
self.handle_callback_with_issuer(
|
||||
&callback.code,
|
||||
&callback.csrf_token,
|
||||
callback.issuer.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// get access token
|
||||
pub async fn get_access_token(&self) -> Result<String, AuthError> {
|
||||
match self {
|
||||
|
|
@ -2599,8 +2787,9 @@ mod tests {
|
|||
use url::Url;
|
||||
|
||||
use super::{
|
||||
AuthError, AuthorizationManager, AuthorizationMetadata, InMemoryStateStore,
|
||||
OAuthClientConfig, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url,
|
||||
AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata,
|
||||
InMemoryStateStore, OAuthClientConfig, ScopeUpgradeConfig, StateStore,
|
||||
StoredAuthorizationState, is_https_url,
|
||||
};
|
||||
use crate::transport::auth::VendorExtraTokenFields;
|
||||
|
||||
|
|
@ -2922,6 +3111,29 @@ mod tests {
|
|||
|
||||
assert_eq!(deserialized.pkce_verifier, "my-verifier");
|
||||
assert_eq!(deserialized.csrf_token, "my-csrf");
|
||||
assert_eq!(deserialized.expected_issuer, None);
|
||||
assert!(!deserialized.require_issuer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stored_authorization_state_records_expected_issuer() {
|
||||
let pkce = PkceCodeVerifier::new("my-verifier".to_string());
|
||||
let csrf = CsrfToken::new("my-csrf".to_string());
|
||||
let state = StoredAuthorizationState::new_with_expected_issuer(
|
||||
&pkce,
|
||||
&csrf,
|
||||
Some("https://auth.example.com".to_string()),
|
||||
false,
|
||||
);
|
||||
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let deserialized: StoredAuthorizationState = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
deserialized.expected_issuer.as_deref(),
|
||||
Some("https://auth.example.com")
|
||||
);
|
||||
assert!(!deserialized.require_issuer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2934,7 +3146,7 @@ mod tests {
|
|||
assert!(!debug_output.contains("super-secret-verifier"));
|
||||
assert!(!debug_output.contains("super-secret-csrf"));
|
||||
assert!(debug_output.contains("[REDACTED]"));
|
||||
assert!(debug_output.contains("created_at"));
|
||||
assert!(debug_output.contains("expected_issuer"));
|
||||
assert!(debug_output.contains("created_at"));
|
||||
}
|
||||
|
||||
|
|
@ -3369,6 +3581,167 @@ mod tests {
|
|||
assert!(scope.contains("write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_callback_parses_optional_issuer() {
|
||||
let callback = AuthorizationCallback::from_redirect_url(
|
||||
"http://localhost/callback?code=abc&state=csrf&iss=https%3A%2F%2Fauth.example.com",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(callback.code, "abc");
|
||||
assert_eq!(callback.csrf_token, "csrf");
|
||||
assert_eq!(callback.issuer.as_deref(), Some("https://auth.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_callback_requires_code_and_state() {
|
||||
let missing_code = AuthorizationCallback::from_redirect_url(
|
||||
"http://localhost/callback?state=csrf&iss=https%3A%2F%2Fauth.example.com",
|
||||
);
|
||||
assert!(matches!(
|
||||
missing_code,
|
||||
Err(AuthError::AuthorizationFailed(message)) if message.contains("missing code")
|
||||
));
|
||||
|
||||
let missing_state = AuthorizationCallback::from_redirect_url(
|
||||
"http://localhost/callback?code=abc&iss=https%3A%2F%2Fauth.example.com",
|
||||
);
|
||||
assert!(matches!(
|
||||
missing_state,
|
||||
Err(AuthError::AuthorizationFailed(message)) if message.contains("missing state")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_authorization_response_issuer_accepts_match_and_missing_issuer() {
|
||||
let pkce = PkceCodeVerifier::new("verifier".to_string());
|
||||
let csrf = CsrfToken::new("csrf".to_string());
|
||||
let state = StoredAuthorizationState::new_with_expected_issuer(
|
||||
&pkce,
|
||||
&csrf,
|
||||
Some("https://auth.example.com".to_string()),
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(
|
||||
AuthorizationManager::validate_authorization_response_issuer(
|
||||
&state,
|
||||
Some("https://auth.example.com")
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(AuthorizationManager::validate_authorization_response_issuer(&state, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_authorization_response_issuer_requires_issuer_when_advertised() {
|
||||
let pkce = PkceCodeVerifier::new("verifier".to_string());
|
||||
let csrf = CsrfToken::new("csrf".to_string());
|
||||
let state = StoredAuthorizationState::new_with_expected_issuer(
|
||||
&pkce,
|
||||
&csrf,
|
||||
Some("https://auth.example.com".to_string()),
|
||||
true,
|
||||
);
|
||||
|
||||
let error =
|
||||
AuthorizationManager::validate_authorization_response_issuer(&state, None).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
AuthError::AuthorizationServerMissingIssuer { expected_issuer }
|
||||
if expected_issuer == "https://auth.example.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_authorization_response_issuer_rejects_present_issuer_without_expected_issuer() {
|
||||
let pkce = PkceCodeVerifier::new("verifier".to_string());
|
||||
let csrf = CsrfToken::new("csrf".to_string());
|
||||
let state = StoredAuthorizationState::new_with_expected_issuer(&pkce, &csrf, None, false);
|
||||
|
||||
let error = AuthorizationManager::validate_authorization_response_issuer(
|
||||
&state,
|
||||
Some("https://auth.example.com"),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(error, AuthError::AuthorizationFailed(message) if message.contains("expected issuer was not recorded"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_authorization_response_issuer_rejects_required_issuer_without_expected_issuer() {
|
||||
let pkce = PkceCodeVerifier::new("verifier".to_string());
|
||||
let csrf = CsrfToken::new("csrf".to_string());
|
||||
let state = StoredAuthorizationState::new_with_expected_issuer(&pkce, &csrf, None, true);
|
||||
|
||||
let error =
|
||||
AuthorizationManager::validate_authorization_response_issuer(&state, None).unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(error, AuthError::AuthorizationFailed(message) if message.contains("expected issuer was not recorded"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_authorization_response_issuer_rejects_mismatch() {
|
||||
let pkce = PkceCodeVerifier::new("verifier".to_string());
|
||||
let csrf = CsrfToken::new("csrf".to_string());
|
||||
let state = StoredAuthorizationState::new_with_expected_issuer(
|
||||
&pkce,
|
||||
&csrf,
|
||||
Some("https://auth.example.com".to_string()),
|
||||
false,
|
||||
);
|
||||
|
||||
let error = AuthorizationManager::validate_authorization_response_issuer(
|
||||
&state,
|
||||
Some("https://evil.example.com"),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
AuthError::AuthorizationServerMismatch {
|
||||
expected_issuer,
|
||||
received_issuer
|
||||
} if expected_issuer == "https://auth.example.com"
|
||||
&& received_issuer == "https://evil.example.com"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorization_url_stores_expected_issuer_for_callback_validation() {
|
||||
let mut manager = manager_with_metadata(Some(AuthorizationMetadata {
|
||||
authorization_endpoint: "https://auth.example.com/authorize".to_string(),
|
||||
token_endpoint: "https://auth.example.com/token".to_string(),
|
||||
issuer: Some("https://auth.example.com".to_string()),
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
manager.configure_client_id("test-client-id").unwrap();
|
||||
|
||||
let auth_url = manager.get_authorization_url(&[]).await.unwrap();
|
||||
let parsed = Url::parse(&auth_url).unwrap();
|
||||
let state = parsed
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "state").then(|| value.into_owned()))
|
||||
.expect("authorization URL should contain state");
|
||||
|
||||
let stored_state = manager
|
||||
.state_store
|
||||
.load(&state)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("authorization state should be stored");
|
||||
assert_eq!(
|
||||
stored_state.expected_issuer.as_deref(),
|
||||
Some("https://auth.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
// -- scope management --
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue