use core::fmt;
use std::sync::Arc;
use url::Url;
use crate::domain::mcp::authorization::AuthorizationModel;
use crate::domain::oauth::pkce::{ChallengeMethod, CodeChallenge, CodeVerifier};
use crate::domain::oauth::registration::{ClientId, RegisteredClient};
use crate::domain::oauth::resource::{ResourceBinding, ResourceIndicatorError, ResourceParameters};
pub trait AuthorizationSecrets {
fn new_state(&self) -> Result<State, SecretError>;
fn new_code_verifier(&self) -> Result<CodeVerifier, SecretError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum SecretError {
#[error("the operating system's entropy source could not be read")]
EntropyUnavailable,
#[error("this platform provides no usable entropy source")]
EntropyUnsupported,
#[error("the value drawn was refused by the value object that owns it")]
Rejected,
}
#[derive(Clone, PartialEq, Eq)]
pub struct State(Arc<str>);
impl State {
#[must_use]
pub fn new(value: impl AsRef<str>) -> Self {
Self(Arc::from(value.as_ref()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn matches(&self, presented: &str) -> bool {
self.0.as_ref() == presented
}
}
impl fmt::Debug for State {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("State(…)")
}
}
impl fmt::Display for State {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Clone)]
pub struct PendingAuthorization {
state: State,
verifier: CodeVerifier,
resource: ResourceBinding,
client_id: ClientId,
redirect_uri: Arc<str>,
issuer: Arc<str>,
scope: Option<Arc<str>>,
iss_advertised: bool,
}
impl PendingAuthorization {
#[must_use]
pub const fn state(&self) -> &State {
&self.state
}
#[must_use]
pub const fn code_verifier(&self) -> &CodeVerifier {
&self.verifier
}
#[must_use]
pub const fn resource(&self) -> &ResourceBinding {
&self.resource
}
#[must_use]
pub fn redirect_uri(&self) -> &str {
&self.redirect_uri
}
#[must_use]
pub const fn client_id(&self) -> &ClientId {
&self.client_id
}
#[must_use]
pub fn issuer(&self) -> &str {
&self.issuer
}
#[must_use]
pub fn with_scope(mut self, scope: impl AsRef<str>) -> Self {
self.scope = Some(Arc::from(scope.as_ref()));
self
}
#[must_use]
pub fn scope(&self) -> Option<&str> {
self.scope.as_deref()
}
#[must_use]
pub const fn with_iss_advertised(mut self, advertised: bool) -> Self {
self.iss_advertised = advertised;
self
}
#[must_use]
pub const fn iss_advertised(&self) -> bool {
self.iss_advertised
}
pub fn authorization_url(
&self,
endpoint: &str,
method: ChallengeMethod,
) -> Result<Url, BeginError> {
let mut url = Url::parse(endpoint).map_err(|source| BeginError::Endpoint {
endpoint: endpoint.to_owned(),
source,
})?;
let challenge: CodeChallenge = self.verifier.challenge(method);
{
let mut query = url.query_pairs_mut();
query.append_pair("response_type", "code");
query.append_pair("client_id", self.client_id.as_str());
query.append_pair("redirect_uri", &self.redirect_uri);
query.append_pair("state", self.state.as_str());
query.append_pair("code_challenge", challenge.as_str());
query.append_pair("code_challenge_method", method.as_str());
for resource in self.resource.token_request().iter() {
query.append_pair("resource", resource);
}
if let Some(scope) = &self.scope {
query.append_pair("scope", scope);
}
}
Ok(url)
}
}
impl fmt::Debug for PendingAuthorization {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PendingAuthorization")
.field("state", &self.state)
.field("code_verifier", &self.verifier)
.field("resource", &self.resource)
.field("client_id", &self.client_id)
.field("redirect_uri", &self.redirect_uri)
.field("issuer", &self.issuer)
.field("scope", &self.scope)
.field("iss_advertised", &self.iss_advertised)
.finish()
}
}
#[derive(Debug, thiserror::Error)]
pub enum BeginError {
#[error("the authorization secret source failed")]
Secrets(#[source] SecretError),
#[error(transparent)]
Resource(#[from] ResourceIndicatorError),
#[error("the authorization endpoint `{endpoint}` is not a usable URL: {source}")]
Endpoint {
endpoint: String,
#[source]
source: url::ParseError,
},
}
pub fn begin_authorization(
model: &AuthorizationModel,
client: &RegisteredClient,
redirect_uri: &str,
resource: ResourceParameters,
secrets: &impl AuthorizationSecrets,
) -> Result<PendingAuthorization, BeginError> {
let state = secrets.new_state().map_err(BeginError::Secrets)?;
let verifier = secrets.new_code_verifier().map_err(BeginError::Secrets)?;
let resource = ResourceBinding::bind(model, resource)?;
Ok(PendingAuthorization {
state,
verifier,
resource,
client_id: client.client_id().clone(),
redirect_uri: Arc::from(redirect_uri),
issuer: Arc::from(client.issuer()),
scope: None,
iss_advertised: false,
})
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use super::{
AuthorizationSecrets, PendingAuthorization, SecretError, State, begin_authorization,
};
use crate::domain::ProtocolVersion;
use crate::domain::mcp::authorization::{AuthorizationModel, authorization_model};
use crate::domain::mcp::resource_metadata::{ResourceUri, ResourceUriError};
use crate::domain::oauth::pkce::{ChallengeMethod, CodeVerifier, PkceError};
use crate::domain::oauth::registration::{ClientId, ClientIdError, RegisteredClient};
use crate::domain::oauth::resource::{ResourceIndicatorError, ResourceParameters};
const AS: &str = "https://auth.example.com";
const CLIENT_ID: &str = "https://client.example.com/metadata.json";
const REDIRECT: &str = "https://client.example.com/callback";
const ENDPOINT: &str = "https://auth.example.com/authorize";
const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const STATE: &str = "a-state-value";
#[derive(Debug)]
enum Failure {
Uri(ResourceUriError),
ClientId(ClientIdError),
Pkce(PkceError),
Resource(ResourceIndicatorError),
Begin(super::BeginError),
}
impl From<ResourceUriError> for Failure {
fn from(error: ResourceUriError) -> Self {
Self::Uri(error)
}
}
impl From<ClientIdError> for Failure {
fn from(error: ClientIdError) -> Self {
Self::ClientId(error)
}
}
impl From<PkceError> for Failure {
fn from(error: PkceError) -> Self {
Self::Pkce(error)
}
}
impl From<ResourceIndicatorError> for Failure {
fn from(error: ResourceIndicatorError) -> Self {
Self::Resource(error)
}
}
impl From<super::BeginError> for Failure {
fn from(error: super::BeginError) -> Self {
Self::Begin(error)
}
}
impl core::fmt::Display for Failure {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Uri(error) => write!(formatter, "{error}"),
Self::ClientId(error) => write!(formatter, "{error}"),
Self::Pkce(error) => write!(formatter, "{error}"),
Self::Resource(error) => write!(formatter, "{error}"),
Self::Begin(error) => write!(formatter, "{error}"),
}
}
}
struct Fixed;
impl AuthorizationSecrets for Fixed {
fn new_state(&self) -> Result<State, SecretError> {
Ok(State::new(STATE))
}
fn new_code_verifier(&self) -> Result<CodeVerifier, SecretError> {
CodeVerifier::new(VERIFIER).map_err(|_| SecretError::Rejected)
}
}
struct Unavailable;
impl AuthorizationSecrets for Unavailable {
fn new_state(&self) -> Result<State, SecretError> {
Err(SecretError::EntropyUnavailable)
}
fn new_code_verifier(&self) -> Result<CodeVerifier, SecretError> {
Err(SecretError::EntropyUnavailable)
}
}
fn model_for(version: ProtocolVersion) -> AuthorizationModel {
authorization_model(version)
}
fn client() -> Result<RegisteredClient, ClientIdError> {
Ok(RegisteredClient::from_metadata_document(
AS,
ClientId::metadata_url(CLIENT_ID)?,
))
}
fn resource() -> Result<ResourceParameters, ResourceUriError> {
Ok(ResourceParameters::single(&ResourceUri::new(
"https://api.example.com/app",
)?))
}
fn pending(version: ProtocolVersion) -> Result<PendingAuthorization, Failure> {
Ok(begin_authorization(
&model_for(version),
&client()?,
REDIRECT,
resource()?,
&Fixed,
)?)
}
#[test]
fn the_request_carries_the_challenge_not_the_verifier() -> Result<(), Failure> {
let pending = pending(ProtocolVersion::V2026_07_28)?;
let url = pending.authorization_url(ENDPOINT, ChallengeMethod::S256)?;
let query = url.query().unwrap_or_default();
assert!(
query.contains("code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"),
"{query}"
);
assert!(query.contains("code_challenge_method=S256"), "{query}");
assert!(
!query.contains(VERIFIER),
"the verifier must not appear in the redirect: {query}"
);
Ok(())
}
#[test]
fn the_request_carries_the_four_parameters_a_redirect_needs() -> Result<(), Failure> {
let pending = pending(ProtocolVersion::V2026_07_28)?;
let url = pending.authorization_url(ENDPOINT, ChallengeMethod::S256)?;
let query = url.query().unwrap_or_default();
assert!(query.contains("response_type=code"), "{query}");
assert!(
query.contains(&format!(
"client_id={}",
CLIENT_ID.replace(':', "%3A").replace('/', "%2F")
)),
"{query}"
);
assert!(
query.contains(&format!(
"redirect_uri={}",
REDIRECT.replace(':', "%3A").replace('/', "%2F")
)),
"{query}"
);
assert!(query.contains(&format!("state={STATE}")), "{query}");
Ok(())
}
#[test]
fn an_endpoint_that_already_has_a_query_keeps_it() -> Result<(), Failure> {
let pending = pending(ProtocolVersion::V2026_07_28)?;
let url = pending.authorization_url(
"https://auth.example.com/authorize?tenant=acme",
ChallengeMethod::S256,
)?;
let query = url.query().unwrap_or_default();
assert!(
query.contains("tenant=acme"),
"an existing query was dropped: {query}"
);
assert!(query.contains("response_type=code"), "{query}");
Ok(())
}
#[test]
fn the_resource_indicator_reaches_the_wire() -> Result<(), Failure> {
let pending = pending(ProtocolVersion::V2026_07_28)?;
let url = pending.authorization_url(ENDPOINT, ChallengeMethod::S256)?;
let query = url.query().unwrap_or_default();
assert!(
query.contains("resource=https%3A%2F%2Fapi.example.com%2Fapp"),
"{query}"
);
Ok(())
}
#[test]
fn a_revision_requiring_indicators_refuses_a_request_naming_none() -> Result<(), Failure> {
let outcome = begin_authorization(
&model_for(ProtocolVersion::V2026_07_28),
&client()?,
REDIRECT,
ResourceParameters::default(),
&Fixed,
);
assert_matches!(
outcome.err(),
Some(super::BeginError::Resource(ResourceIndicatorError::Missing))
);
Ok(())
}
#[test]
fn a_failing_secret_source_stops_the_request() -> Result<(), Failure> {
let outcome = begin_authorization(
&model_for(ProtocolVersion::V2026_07_28),
&client()?,
REDIRECT,
resource()?,
&Unavailable,
);
assert_matches!(
outcome.err(),
Some(super::BeginError::Secrets(SecretError::EntropyUnavailable))
);
Ok(())
}
#[test]
fn debug_redacts_both_secrets_and_keeps_the_rest() -> Result<(), Failure> {
let pending = pending(ProtocolVersion::V2026_07_28)?;
let rendered = format!("{pending:?}");
assert!(
!rendered.contains(VERIFIER),
"the verifier leaked: {rendered}"
);
assert!(!rendered.contains(STATE), "the state leaked: {rendered}");
assert!(
rendered.contains(AS),
"the issuer should be readable: {rendered}"
);
assert!(
rendered.contains(REDIRECT),
"the redirect should be readable: {rendered}"
);
Ok(())
}
#[test]
fn state_matches_only_the_value_this_request_issued() -> Result<(), Failure> {
let pending = pending(ProtocolVersion::V2026_07_28)?;
assert!(pending.state().matches(STATE));
assert!(!pending.state().matches("a-different-state"));
assert!(!pending.state().matches(""));
Ok(())
}
#[test]
fn the_pending_value_carries_what_redemption_needs() -> Result<(), Failure> {
let pending = pending(ProtocolVersion::V2026_07_28)?;
assert_eq!(pending.issuer(), AS);
assert_eq!(pending.redirect_uri(), REDIRECT);
assert_eq!(pending.code_verifier().as_str(), VERIFIER);
assert_eq!(pending.resource().token_request().len(), 1);
Ok(())
}
struct Sequence(std::cell::Cell<u32>);
impl AuthorizationSecrets for Sequence {
fn new_state(&self) -> Result<State, SecretError> {
let next = self.0.get() + 1;
self.0.set(next);
Ok(State::new(format!("state-{next}")))
}
fn new_code_verifier(&self) -> Result<CodeVerifier, SecretError> {
let next = self.0.get() + 1;
self.0.set(next);
CodeVerifier::new(format!("{next}{}", "a".repeat(42)))
.map_err(|_| SecretError::Rejected)
}
}
#[test]
fn each_request_draws_its_own_state() -> Result<(), Failure> {
let secrets = Sequence(std::cell::Cell::new(0));
let first = begin_authorization(
&model_for(ProtocolVersion::V2026_07_28),
&client()?,
REDIRECT,
resource()?,
&secrets,
)?;
let second = begin_authorization(
&model_for(ProtocolVersion::V2026_07_28),
&client()?,
REDIRECT,
resource()?,
&secrets,
)?;
assert_ne!(first.state(), second.state());
assert!(
!first.state().matches(second.state().as_str()),
"one request's state must not satisfy another's check"
);
assert_ne!(first.code_verifier(), second.code_verifier());
Ok(())
}
#[test]
fn an_unusable_endpoint_is_named_rather_than_rendered() -> Result<(), Failure> {
let pending = pending(ProtocolVersion::V2026_07_28)?;
match pending.authorization_url("not a url", ChallengeMethod::S256) {
Err(super::BeginError::Endpoint { endpoint, .. }) => {
assert_eq!(endpoint, "not a url");
Ok(())
}
Ok(_) | Err(_) => Err(Failure::Begin(super::BeginError::Secrets(
SecretError::EntropyUnavailable,
))),
}
}
}