use core::future::Future;
use core::pin::Pin;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use serde_json::{Value, json};
use url::Url;
use crate::application::client::bearer::{BearerAuth, BearerError};
use crate::application::oauth::callback::{CallbackError, authorize_callback};
use crate::application::oauth::client::{AuthorizationSecrets, BeginError, begin_authorization};
use crate::application::oauth::environment::{EnvironmentError, Fetched, OAuthEnvironment};
use crate::application::oauth::flow::{
initial_scope, may_step_up, step_up_scope, with_offline_access,
};
use crate::application::oauth::metadata::{AuthorizationServer, MetadataError, ProtectedResource};
use crate::application::oauth::redeem::{
RedeemError, RefreshRequest, TokenEndpoint as _, redeem_with_scope,
};
use crate::application::oauth::registration::ClientMetadata;
use crate::domain::ProtocolVersion;
use crate::domain::mcp::authorization::{AuthorizationModel, authorization_model};
use crate::domain::mcp::resource_metadata::ResourceUri;
use crate::domain::oauth::challenge::{BearerChallenge, parse_bearer};
use crate::domain::oauth::discovery::{
authorization_server_metadata_urls, canonical_resource, protected_resource_metadata_urls,
};
use crate::domain::oauth::pkce::ChallengeMethod;
use crate::domain::oauth::registration::{
ClientId, ClientSecret, RegisteredClient, TokenAuthMethod,
};
use crate::domain::oauth::resource::ResourceParameters;
#[derive(Debug, thiserror::Error)]
pub enum AuthorizeError {
#[error("{what} could not be read: {reason}")]
Discovery {
what: &'static str,
reason: String,
},
#[error("the resource metadata is for `{found}`, not `{expected}`")]
ResourceMismatch {
expected: String,
found: String,
},
#[error("the authorization server does not advertise PKCE with S256")]
NoPkce,
#[error("no client registration is possible with this authorization server")]
NoRegistration,
#[error("dynamic client registration failed: {0}")]
Registration(String),
#[error(
"the pre-registered credentials belong to `{registered}`, but the server now names `{named}`"
)]
CredentialsForAnotherIssuer {
registered: String,
named: String,
},
#[error(transparent)]
Begin(#[from] BeginError),
#[error(transparent)]
Callback(#[from] CallbackError),
#[error(transparent)]
Redeem(#[from] RedeemError),
#[error(transparent)]
Environment(#[from] EnvironmentError),
#[error(transparent)]
Metadata(#[from] MetadataError),
#[error("the server still refused after {0} authorization attempts")]
AttemptsExhausted(u32),
}
#[derive(Debug, Clone)]
pub struct PreRegistered {
issuer: Option<String>,
client_id: ClientId,
client_secret: Option<ClientSecret>,
}
impl PreRegistered {
#[must_use]
pub fn new(client_id: &str, client_secret: Option<&str>) -> Self {
Self {
issuer: None,
client_id: ClientId::opaque(client_id),
client_secret: client_secret.map(ClientSecret::issued),
}
}
#[must_use]
pub fn issued_by(mut self, issuer: impl Into<String>) -> Self {
self.issuer = Some(issuer.into());
self
}
}
#[derive(Debug, Clone)]
pub struct OAuthConfig {
server: Url,
model: AuthorizationModel,
redirect_uri: String,
client_name: String,
client_metadata_url: Option<String>,
pre_registered: Option<PreRegistered>,
refresh_token: bool,
}
impl OAuthConfig {
pub fn new(server: &str, version: ProtocolVersion) -> Result<Self, url::ParseError> {
Ok(Self {
server: Url::parse(server)?,
model: authorization_model(version),
redirect_uri: "http://localhost:3000/callback".to_owned(),
client_name: "protocol-droid".to_owned(),
client_metadata_url: None,
pre_registered: None,
refresh_token: false,
})
}
#[must_use]
pub fn redirect_to(mut self, redirect_uri: impl Into<String>) -> Self {
self.redirect_uri = redirect_uri.into();
self
}
#[must_use]
pub fn named(mut self, client_name: impl Into<String>) -> Self {
self.client_name = client_name.into();
self
}
#[must_use]
pub fn with_metadata_document(mut self, url: impl Into<String>) -> Self {
self.client_metadata_url = Some(url.into());
self
}
#[must_use]
pub fn with_pre_registered(mut self, credentials: PreRegistered) -> Self {
self.pre_registered = Some(credentials);
self
}
#[must_use]
pub const fn wanting_refresh_tokens(mut self) -> Self {
self.refresh_token = true;
self
}
}
#[derive(Clone)]
struct Refreshable {
token: String,
token_endpoint: String,
client: RegisteredClient,
resource: ResourceParameters,
}
impl core::fmt::Debug for Refreshable {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Refreshable").finish_non_exhaustive()
}
}
#[derive(Default)]
struct State {
token: Option<String>,
granted: Option<String>,
refresh: Option<Refreshable>,
clients: HashMap<String, RegisteredClient>,
pre_registered: Option<PreRegistered>,
attempts: u32,
}
pub struct OAuthAuthorizer<E, S> {
config: OAuthConfig,
environment: E,
secrets: S,
state: Mutex<State>,
}
impl<E, S> core::fmt::Debug for OAuthAuthorizer<E, S> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("OAuthAuthorizer")
.field("server", &self.config.server.as_str())
.finish_non_exhaustive()
}
}
impl<E: OAuthEnvironment, S: AuthorizationSecrets + Send + Sync + 'static> OAuthAuthorizer<E, S> {
#[must_use]
pub fn new(config: OAuthConfig, environment: E, secrets: S) -> Arc<Self> {
let pre_registered = config.pre_registered.clone();
Arc::new(Self {
config,
environment,
secrets,
state: Mutex::new(State {
pre_registered,
..State::default()
}),
})
}
fn state(&self) -> std::sync::MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
fn record(&self, token: String, granted: Option<String>, refresh: Option<Refreshable>) {
let mut state = self.state();
state.token = Some(token);
state.granted = granted;
state.refresh = refresh;
}
async fn try_refresh(&self) -> bool {
let Some(held) = self.state().refresh.clone() else {
return false;
};
let endpoint = self.environment.token_endpoint(&held.token_endpoint);
let request = RefreshRequest::new(&held.token, &held.client, &held.resource, None);
let outcome = endpoint.refresh(request).await;
let mut state = self.state();
if let Ok(tokens) = outcome {
let rotated = tokens
.refresh_token()
.map_or_else(|| held.token.clone(), |token| token.as_str().to_owned());
state.token = Some(tokens.access_token().as_str().to_owned());
state.refresh = Some(Refreshable {
token: rotated,
..held
});
true
} else {
state.refresh = None;
false
}
}
fn held_client(&self, server: &AuthorizationServer) -> Option<RegisteredClient> {
self.state().clients.get(&server.issuer).cloned()
}
fn bind_pre_registered(
&self,
server: &AuthorizationServer,
) -> Result<Option<RegisteredClient>, AuthorizeError> {
let mut pending = self.state().pre_registered.take();
let Some(credentials) = pending.take() else {
return Ok(None);
};
if let Some(registered) = &credentials.issuer
&& *registered != server.issuer
{
self.state().pre_registered = Some(credentials.clone());
return Err(AuthorizeError::CredentialsForAnotherIssuer {
registered: registered.clone(),
named: server.issuer.clone(),
});
}
let method = auth_method(credentials.client_secret.is_some(), server);
let Ok(client) = RegisteredClient::dynamic(
&server.issuer,
credentials.client_id,
credentials.client_secret,
) else {
return Ok(None);
};
let client = client.with_auth_method(method);
self.keep_client(&server.issuer, &client);
Ok(Some(client))
}
fn keep_client(&self, issuer: &str, client: &RegisteredClient) {
self.state()
.clients
.insert(issuer.to_owned(), client.clone());
}
pub async fn authorize(&self, challenge: &BearerChallenge) -> Result<(), AuthorizeError> {
let resource = self.protected_resource(challenge).await?;
let issuer = resource
.authorization_servers
.first()
.cloned()
.ok_or_else(|| AuthorizeError::Discovery {
what: "the protected resource metadata",
reason: "it lists no authorization server".to_owned(),
})?;
let server = self.authorization_server(&issuer).await?;
let advertises_s256 = server
.code_challenge_methods_supported
.as_ref()
.is_some_and(|methods| methods.iter().any(|method| method == "S256"));
if !advertises_s256 {
return Err(AuthorizeError::NoPkce);
}
let client = self.client_for(&server).await?;
let scope = self.scope_for(challenge, &resource, &server);
let resource_uri =
ResourceUri::new(&resource.resource).map_err(|error| AuthorizeError::Discovery {
what: "the resource identifier in the protected resource metadata",
reason: error.to_string(),
})?;
let mut pending = begin_authorization(
&self.config.model,
&client,
&self.config.redirect_uri,
ResourceParameters::single(&resource_uri),
&self.secrets,
)?
.with_iss_advertised(server.authorization_response_iss_parameter_supported);
if let Some(scope) = &scope {
pending = pending.with_scope(scope);
}
let url =
pending.authorization_url(&server.authorization_endpoint, ChallengeMethod::S256)?;
let callback = self.environment.authorize(url.as_str()).await?;
let code = authorize_callback(&self.config.model, &pending, &callback)?;
let endpoint = self.environment.token_endpoint(&server.token_endpoint);
let tokens = redeem_with_scope(code, &client, &endpoint, scope.as_deref()).await?;
let granted = tokens.scope().map(ToOwned::to_owned).or(scope);
let refresh = tokens.refresh_token().map(|token| Refreshable {
token: token.as_str().to_owned(),
token_endpoint: server.token_endpoint.clone(),
client: client.clone(),
resource: ResourceParameters::single(&resource_uri),
});
self.record(tokens.access_token().as_str().to_owned(), granted, refresh);
Ok(())
}
fn scope_for(
&self,
challenge: &BearerChallenge,
resource: &ProtectedResource,
server: &AuthorizationServer,
) -> Option<String> {
let previous = self.state().granted.clone();
let scope = if previous.is_some() {
step_up_scope(previous.as_deref(), challenge)
} else {
initial_scope(challenge, resource.scopes_supported.as_deref())
};
if self.config.refresh_token {
with_offline_access(scope, &server.scopes_supported)
} else {
scope
}
}
async fn get_document(&self, url: &str) -> Result<Fetched, EnvironmentError> {
self.environment.get(url).await
}
async fn protected_resource(
&self,
challenge: &BearerChallenge,
) -> Result<ProtectedResource, AuthorizeError> {
let urls = challenge.resource_metadata.as_ref().map_or_else(
|| protected_resource_metadata_urls(&self.config.server),
|url| vec![url.clone()],
);
let mut last = "no location answered".to_owned();
for url in urls {
let fetched = match self.get_document(&url).await {
Ok(fetched) if fetched.is_success() => fetched,
Ok(fetched) => {
last = format!("{url} answered {}", fetched.status);
continue;
}
Err(error) => {
last = error.to_string();
continue;
}
};
let document = ProtectedResource::parse(&fetched.body)?;
return self.check_resource(document);
}
Err(AuthorizeError::Discovery {
what: "the protected resource metadata",
reason: last,
})
}
fn check_resource(
&self,
document: ProtectedResource,
) -> Result<ProtectedResource, AuthorizeError> {
let expected = canonical_resource(&self.config.server);
let found = Url::parse(&document.resource).map_or_else(
|_| document.resource.clone(),
|url| canonical_resource(&url),
);
let covers = expected == found
|| expected
.strip_prefix(&found)
.is_some_and(|rest| rest.starts_with('/'));
if covers {
Ok(document)
} else {
Err(AuthorizeError::ResourceMismatch { expected, found })
}
}
async fn authorization_server(
&self,
issuer: &str,
) -> Result<AuthorizationServer, AuthorizeError> {
let parsed = Url::parse(issuer).map_err(|error| AuthorizeError::Discovery {
what: "the authorization server's issuer",
reason: error.to_string(),
})?;
let mut last = "no location answered".to_owned();
for url in authorization_server_metadata_urls(&parsed) {
let Ok(fetched) = self.get_document(&url).await else {
continue;
};
if !fetched.is_success() {
last = format!("{url} answered {}", fetched.status);
continue;
}
match AuthorizationServer::parse(&fetched.body, issuer) {
Ok(server) => return Ok(server),
Err(error) => last = error.to_string(),
}
}
Err(AuthorizeError::Discovery {
what: "the authorization server metadata",
reason: last,
})
}
async fn client_for(
&self,
server: &AuthorizationServer,
) -> Result<RegisteredClient, AuthorizeError> {
if let Some(held) = self.held_client(server) {
return Ok(held);
}
if let Some(bound) = self.bind_pre_registered(server)? {
return Ok(bound);
}
let model = &self.config.model;
if server.client_id_metadata_document_supported
&& model.client_registration.supports_metadata_document()
&& let Some(url) = &self.config.client_metadata_url
&& let Ok(id) = ClientId::metadata_url(url)
{
let client = RegisteredClient::from_metadata_document(&server.issuer, id);
self.keep_client(&server.issuer, &client);
return Ok(client);
}
if model.client_registration.supports_dynamic()
&& let Some(endpoint) = &server.registration_endpoint
{
let client = self.register_dynamic(endpoint, server).await?;
self.keep_client(&server.issuer, &client);
return Ok(client);
}
Err(AuthorizeError::NoRegistration)
}
async fn register_dynamic(
&self,
endpoint: &str,
server: &AuthorizationServer,
) -> Result<RegisteredClient, AuthorizeError> {
let mut grants = vec!["authorization_code"];
if self.config.refresh_token {
grants.push("refresh_token");
}
let metadata = ClientMetadata::new(vec![self.config.redirect_uri.clone()])
.map_err(|error| AuthorizeError::Registration(error.to_string()))?
.with_client_name(&self.config.client_name)
.with_application_type("native")
.with_field("grant_types", json!(grants))
.and_then(|metadata| metadata.with_field("response_types", json!(["code"])))
.map_err(|error| AuthorizeError::Registration(error.to_string()))?;
let metadata = match preferred_method(server) {
Some(method) => metadata.with_token_endpoint_auth_method(method),
None => metadata,
};
let body = serde_json::to_string(&metadata)
.map_err(|error| AuthorizeError::Registration(error.to_string()))?;
let fetched = self.environment.post_json(endpoint, &body).await?;
if !fetched.is_success() {
return Err(AuthorizeError::Registration(format!(
"the registration endpoint answered {}",
fetched.status
)));
}
let response: Value = serde_json::from_slice(&fetched.body)
.map_err(|error| AuthorizeError::Registration(error.to_string()))?;
let id = response
.get("client_id")
.and_then(Value::as_str)
.ok_or_else(|| {
AuthorizeError::Registration("the response has no client_id".to_owned())
})?;
let secret = response
.get("client_secret")
.and_then(Value::as_str)
.map(ClientSecret::issued);
let method = response
.get("token_endpoint_auth_method")
.and_then(Value::as_str)
.and_then(TokenAuthMethod::parse)
.unwrap_or_else(|| auth_method(secret.is_some(), server));
let client = RegisteredClient::dynamic(&server.issuer, ClientId::opaque(id), secret)
.map_err(|error| AuthorizeError::Registration(error.to_string()))?;
Ok(client.with_auth_method(method))
}
}
fn auth_method(has_secret: bool, server: &AuthorizationServer) -> TokenAuthMethod {
if !has_secret {
return TokenAuthMethod::None;
}
let offered = &server.token_endpoint_auth_methods_supported;
if offered.is_empty() || offered.iter().any(|method| method == "client_secret_basic") {
TokenAuthMethod::ClientSecretBasic
} else if offered.iter().any(|method| method == "client_secret_post") {
TokenAuthMethod::ClientSecretPost
} else {
TokenAuthMethod::None
}
}
fn preferred_method(server: &AuthorizationServer) -> Option<&'static str> {
let offered = &server.token_endpoint_auth_methods_supported;
["client_secret_basic", "client_secret_post", "none"]
.into_iter()
.find(|method| offered.iter().any(|candidate| candidate == method))
}
impl<E: OAuthEnvironment, S: AuthorizationSecrets + Send + Sync + 'static> BearerAuth
for OAuthAuthorizer<E, S>
{
fn bearer(&self) -> Option<String> {
self.state().token.clone()
}
fn challenged<'a>(
&'a self,
status: u16,
www_authenticate: Option<&'a str>,
) -> Pin<Box<dyn Future<Output = Result<(), BearerError>> + Send + 'a>> {
Box::pin(async move {
let attempts = {
let mut state = self.state();
state.attempts = state.attempts.saturating_add(1);
state.attempts
};
if !may_step_up(attempts.saturating_sub(1)) {
return Err(BearerError(
AuthorizeError::AttemptsExhausted(attempts.saturating_sub(1)).to_string(),
));
}
let challenge = www_authenticate.and_then(parse_bearer).unwrap_or_default();
let _ = status;
if !challenge.is_insufficient_scope() && self.try_refresh().await {
return Ok(());
}
self.authorize(&challenge)
.await
.map_err(|error| BearerError(error.to_string()))
})
}
fn accepted(&self) {
self.state().attempts = 0;
}
}
#[cfg(test)]
mod tests;