use core::fmt;
use std::sync::Arc;
use super::client::PendingAuthorization;
use crate::domain::mcp::authorization::AuthorizationModel;
use crate::domain::oauth::issuer::{AuthorizationResponseError, check_response_issuer_advertised};
use crate::domain::oauth::pkce::CodeVerifier;
use crate::domain::oauth::registration::ClientId;
use crate::domain::oauth::resource::ResourceBinding;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AuthorizationCallback {
code: Option<Arc<str>>,
state: Option<Arc<str>>,
iss: Option<Arc<str>>,
error: Option<Arc<str>>,
}
impl AuthorizationCallback {
#[must_use]
pub fn granted(code: impl AsRef<str>, state: impl AsRef<str>) -> Self {
Self {
code: Some(Arc::from(code.as_ref())),
state: Some(Arc::from(state.as_ref())),
iss: None,
error: None,
}
}
#[must_use]
pub fn refused(error: impl AsRef<str>, state: impl AsRef<str>) -> Self {
Self {
code: None,
state: Some(Arc::from(state.as_ref())),
iss: None,
error: Some(Arc::from(error.as_ref())),
}
}
#[must_use]
pub fn with_issuer(mut self, iss: Option<Arc<str>>) -> Self {
self.iss = iss;
self
}
#[must_use]
pub fn code(&self) -> Option<&str> {
self.code.as_deref()
}
#[must_use]
pub fn state(&self) -> Option<&str> {
self.state.as_deref()
}
#[must_use]
pub fn issuer(&self) -> Option<&str> {
self.iss.as_deref()
}
#[must_use]
pub fn error(&self) -> Option<&str> {
self.error.as_deref()
}
}
pub struct AuthorizedCode {
code: Arc<str>,
verifier: CodeVerifier,
resource: ResourceBinding,
client_id: ClientId,
redirect_uri: Arc<str>,
issuer: Arc<str>,
}
impl AuthorizedCode {
#[must_use]
pub fn code(&self) -> &str {
&self.code
}
#[must_use]
pub const fn code_verifier(&self) -> &CodeVerifier {
&self.verifier
}
#[must_use]
pub const fn resource(&self) -> &ResourceBinding {
&self.resource
}
#[must_use]
pub const fn client_id(&self) -> &ClientId {
&self.client_id
}
#[must_use]
pub fn redirect_uri(&self) -> &str {
&self.redirect_uri
}
#[must_use]
pub fn issuer(&self) -> &str {
&self.issuer
}
}
impl fmt::Debug for AuthorizedCode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AuthorizedCode")
.field("code", &"…")
.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)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CallbackError {
#[error("the authorization response's state does not match the one this client issued")]
StateMismatch,
#[error(transparent)]
Issuer(#[from] AuthorizationResponseError),
#[error("the authorization server refused the request: {error}")]
Refused {
error: Arc<str>,
},
#[error("the authorization response carried neither a code nor an error")]
NoOutcome,
}
pub fn authorize_callback(
model: &AuthorizationModel,
pending: &PendingAuthorization,
callback: &AuthorizationCallback,
) -> Result<AuthorizedCode, CallbackError> {
if !callback
.state()
.is_some_and(|state| pending.state().matches(state))
{
return Err(CallbackError::StateMismatch);
}
check_response_issuer_advertised(
model,
pending.issuer(),
callback.issuer(),
pending.iss_advertised(),
)?;
if let Some(code) = callback.code() {
return Ok(AuthorizedCode {
code: Arc::from(code),
verifier: pending.code_verifier().clone(),
resource: pending.resource().clone(),
client_id: pending.client_id().clone(),
redirect_uri: Arc::from(pending.redirect_uri()),
issuer: Arc::from(pending.issuer()),
});
}
callback
.error()
.map_or(Err(CallbackError::NoOutcome), |error| {
Err(CallbackError::Refused {
error: Arc::from(error),
})
})
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use super::{AuthorizationCallback, AuthorizedCode, CallbackError, authorize_callback};
use crate::application::oauth::client::{
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::{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 RESOURCE: &str = "https://api.example.com/app";
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::super::client::BeginError),
Callback(CallbackError),
}
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}"),
Self::Callback(error) => write!(formatter, "{error}"),
}
}
}
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::super::client::BeginError> for Failure {
fn from(error: super::super::client::BeginError) -> Self {
Self::Begin(error)
}
}
impl From<CallbackError> for Failure {
fn from(error: CallbackError) -> Self {
Self::Callback(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)
}
}
const fn model() -> AuthorizationModel {
authorization_model(ProtocolVersion::V2026_07_28)
}
fn pending() -> Result<PendingAuthorization, Failure> {
let resource = ResourceUri::new(RESOURCE)?;
let client =
RegisteredClient::from_metadata_document(AS, ClientId::metadata_url(CLIENT_ID)?);
begin_authorization(
&model(),
&client,
REDIRECT,
ResourceParameters::single(&resource),
&Fixed,
)
.map_err(Failure::from)
}
#[test]
fn a_matching_callback_carries_the_request_forward() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback::granted("the-code", STATE);
let authorized = authorize_callback(&model(), &pending, &callback)?;
assert_eq!(authorized.code(), "the-code");
assert_eq!(authorized.code_verifier().as_str(), VERIFIER);
assert_eq!(authorized.redirect_uri(), REDIRECT);
assert_eq!(authorized.issuer(), AS);
assert_eq!(authorized.client_id().as_str(), CLIENT_ID);
Ok(())
}
#[test]
fn the_grant_stays_bound_to_the_resource_it_was_requested_for() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback::granted("the-code", STATE);
let authorized = authorize_callback(&model(), &pending, &callback)?;
let other = ResourceUri::new(RESOURCE)?;
assert!(
authorized
.resource()
.matches_token_request(&ResourceParameters::single(&other))
);
assert!(
!authorized
.resource()
.matches_token_request(&ResourceParameters::many([]))
);
Ok(())
}
#[test]
fn the_verifier_is_the_one_drawn_before_the_redirect_not_a_fresh_one() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback::granted("the-code", STATE);
let authorized = authorize_callback(&model(), &pending, &callback)?;
assert_eq!(authorized.code_verifier(), pending.code_verifier());
Ok(())
}
#[test]
fn a_state_from_another_flow_is_refused() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback::granted("the-code", "some-other-state");
assert_matches!(
authorize_callback(&model(), &pending, &callback),
Err(CallbackError::StateMismatch)
);
Ok(())
}
#[test]
fn a_missing_state_is_refused() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback {
code: Some("the-code".into()),
state: None,
iss: None,
error: None,
};
assert_matches!(
authorize_callback(&model(), &pending, &callback),
Err(CallbackError::StateMismatch)
);
Ok(())
}
#[test]
fn state_is_checked_before_a_refusal_is_reported() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback::refused("access_denied", "not-our-state");
assert_matches!(
authorize_callback(&model(), &pending, &callback),
Err(CallbackError::StateMismatch)
);
Ok(())
}
#[test]
fn state_is_checked_before_a_grant_is_redeemed() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback::granted("attacker-code", "guess");
assert_matches!(
authorize_callback(&model(), &pending, &callback),
Err(CallbackError::StateMismatch)
);
Ok(())
}
#[test]
fn a_response_from_another_issuer_is_refused() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback::granted("the-code", STATE)
.with_issuer(Some("https://evil.example.com".into()));
assert_matches!(
authorize_callback(&model(), &pending, &callback),
Err(CallbackError::Issuer(
crate::domain::oauth::issuer::AuthorizationResponseError::IssuerMismatch { .. }
))
);
Ok(())
}
#[test]
fn a_response_from_the_right_issuer_is_accepted() -> Result<(), Failure> {
let pending = pending()?;
let callback =
AuthorizationCallback::granted("the-code", STATE).with_issuer(Some(AS.into()));
let authorized = authorize_callback(&model(), &pending, &callback)?;
assert_eq!(authorized.code(), "the-code");
Ok(())
}
#[test]
fn an_absent_issuer_is_tolerated() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback::granted("the-code", STATE);
assert_eq!(
authorize_callback(&model(), &pending, &callback)?.code(),
"the-code"
);
Ok(())
}
#[test]
fn a_refusal_carries_the_servers_own_error_code() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback::refused("access_denied", STATE);
assert_matches!(
authorize_callback(&model(), &pending, &callback),
Err(CallbackError::Refused { ref error }) if &**error == "access_denied"
);
Ok(())
}
#[test]
fn a_response_that_is_neither_grant_nor_refusal_is_refused() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback {
code: None,
state: Some(STATE.into()),
iss: None,
error: None,
};
assert_matches!(
authorize_callback(&model(), &pending, &callback),
Err(CallbackError::NoOutcome)
);
Ok(())
}
#[test]
fn a_code_alongside_an_error_is_still_a_code() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback {
code: Some("the-code".into()),
state: Some(STATE.into()),
iss: None,
error: Some("access_denied".into()),
};
assert_eq!(
authorize_callback(&model(), &pending, &callback)?.code(),
"the-code"
);
Ok(())
}
#[test]
fn neither_the_code_nor_the_verifier_reaches_the_log() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback::granted("the-code", STATE);
let rendered = format!("{:?}", authorize_callback(&model(), &pending, &callback)?);
assert!(
!rendered.contains("the-code"),
"the code leaked: {rendered}"
);
assert!(
!rendered.contains(VERIFIER),
"the verifier 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 an_authorized_code_reads_back_what_it_came_from() -> Result<(), Failure> {
let pending = pending()?;
let callback = AuthorizationCallback::granted("the-code", STATE);
let authorized = authorize_callback(&model(), &pending, &callback)?;
let _: &AuthorizedCode = &authorized;
assert_eq!(authorized.code(), "the-code");
assert!(pending.state().matches(STATE));
Ok(())
}
}