use std::fmt;
use crate::application::oauth::callback::AuthorizedCode;
use crate::domain::oauth::issued_token::{IssuedToken, TokenResponseError};
use crate::domain::oauth::pkce::CodeVerifier;
use crate::domain::oauth::registration::{
ClientId, ClientSecret, RegisteredClient, TokenAuthMethod,
};
use crate::domain::oauth::resource::ResourceParameters;
#[derive(Debug)]
pub struct TokenRequest<'a> {
code: &'a str,
redirect_uri: &'a str,
verifier: &'a CodeVerifier,
client_id: &'a ClientId,
client_secret: Option<&'a ClientSecret>,
resource: &'a ResourceParameters,
auth_method: TokenAuthMethod,
scope: Option<&'a str>,
}
impl<'a> TokenRequest<'a> {
#[must_use]
pub const fn code(&self) -> &'a str {
self.code
}
#[must_use]
pub const fn redirect_uri(&self) -> &'a str {
self.redirect_uri
}
#[must_use]
pub const fn code_verifier(&self) -> &'a CodeVerifier {
self.verifier
}
#[must_use]
pub const fn client_id(&self) -> &'a ClientId {
self.client_id
}
#[must_use]
pub const fn client_secret(&self) -> Option<&'a ClientSecret> {
self.client_secret
}
#[must_use]
pub const fn auth_method(&self) -> TokenAuthMethod {
self.auth_method
}
#[must_use]
pub const fn scope(&self) -> Option<&'a str> {
self.scope
}
#[must_use]
pub const fn resource(&self) -> &'a ResourceParameters {
self.resource
}
}
pub trait TokenEndpoint {
fn redeem(
&self,
request: TokenRequest<'_>,
) -> impl Future<Output = Result<IssuedToken, TokenEndpointError>> + Send;
fn refresh(
&self,
request: RefreshRequest<'_>,
) -> impl Future<Output = Result<IssuedToken, TokenEndpointError>> + Send {
let _ = request;
core::future::ready(Err(TokenEndpointError::Unavailable(
"this token endpoint does not support refresh tokens".to_owned(),
)))
}
}
#[derive(Debug)]
pub struct RefreshRequest<'a> {
refresh_token: &'a str,
client_id: &'a ClientId,
client_secret: Option<&'a ClientSecret>,
resource: &'a ResourceParameters,
auth_method: TokenAuthMethod,
scope: Option<&'a str>,
}
impl<'a> RefreshRequest<'a> {
#[must_use]
pub const fn new(
refresh_token: &'a str,
client: &'a RegisteredClient,
resource: &'a ResourceParameters,
scope: Option<&'a str>,
) -> Self {
Self {
refresh_token,
client_id: client.client_id(),
client_secret: client.secret(),
resource,
auth_method: client.auth_method(),
scope,
}
}
#[must_use]
pub const fn refresh_token(&self) -> &'a str {
self.refresh_token
}
#[must_use]
pub const fn client_id(&self) -> &'a ClientId {
self.client_id
}
#[must_use]
pub const fn client_secret(&self) -> Option<&'a ClientSecret> {
self.client_secret
}
#[must_use]
pub const fn auth_method(&self) -> TokenAuthMethod {
self.auth_method
}
#[must_use]
pub const fn resource(&self) -> &'a ResourceParameters {
self.resource
}
#[must_use]
pub const fn scope(&self) -> Option<&'a str> {
self.scope
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TokenEndpointError {
#[error(transparent)]
Refused(#[from] TokenResponseError),
#[error("the token endpoint could not be reached: {0}")]
Unavailable(String),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum RedeemError {
#[error("the registration is for {presented}, but this code was issued to {expected}")]
ClientMismatch {
expected: ClientId,
presented: ClientId,
},
#[error(transparent)]
Endpoint(#[from] TokenEndpointError),
}
pub async fn redeem(
code: AuthorizedCode,
client: &RegisteredClient,
endpoint: &(impl TokenEndpoint + Sync),
) -> Result<IssuedToken, RedeemError> {
redeem_with_scope(code, client, endpoint, None).await
}
pub async fn redeem_with_scope(
code: AuthorizedCode,
client: &RegisteredClient,
endpoint: &(impl TokenEndpoint + Sync),
scope: Option<&str>,
) -> Result<IssuedToken, RedeemError> {
if code.client_id() != client.client_id() {
return Err(RedeemError::ClientMismatch {
expected: code.client_id().clone(),
presented: client.client_id().clone(),
});
}
let request = TokenRequest {
code: code.code(),
redirect_uri: code.redirect_uri(),
verifier: code.code_verifier(),
client_id: code.client_id(),
client_secret: client.secret(),
resource: code.resource().token_request(),
auth_method: client.auth_method(),
scope,
};
endpoint
.redeem(request)
.await
.map_err(RedeemError::Endpoint)
}
impl fmt::Display for TokenRequest<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"TokenRequest {{ grant_type: authorization_code, code: …, redirect_uri: {}, \
code_verifier: …, client_id: {}, client_secret: {}, resource: [{}] }}",
self.redirect_uri,
self.client_id,
self.client_secret.is_some(),
self.resource.iter().collect::<Vec<_>>().join(", "),
)
}
}
#[cfg(test)]
mod tests {
use std::assert_matches;
use std::future::Future;
use std::sync::Mutex;
use std::task::{Context, Poll, Waker};
use super::{RedeemError, TokenEndpoint, TokenEndpointError, TokenRequest, redeem};
use crate::application::oauth::callback::{
AuthorizationCallback, AuthorizedCode, 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;
use crate::domain::oauth::issued_token::{IssuedToken, TokenResponseError};
use crate::domain::oauth::pkce::CodeVerifier;
use crate::domain::oauth::registration::{ClientId, ClientSecret, RegisteredClient};
use crate::domain::oauth::resource::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";
const GRANTED: &[u8] = br#"{"access_token":"at-1","token_type":"Bearer","expires_in":3600}"#;
type Outcome = Result<(), Box<dyn std::error::Error>>;
fn now<T>(future: impl Future<Output = T>) -> Option<T> {
let mut future = std::pin::pin!(future);
match future
.as_mut()
.poll(&mut Context::from_waker(Waker::noop()))
{
Poll::Ready(output) => Some(output),
Poll::Pending => None,
}
}
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 public_client() -> Result<RegisteredClient, Box<dyn std::error::Error>> {
Ok(RegisteredClient::from_metadata_document(
AS,
ClientId::metadata_url(CLIENT_ID)?,
))
}
fn confidential_client() -> Result<RegisteredClient, Box<dyn std::error::Error>> {
Ok(RegisteredClient::dynamic(
AS,
ClientId::opaque("client-1"),
Some(ClientSecret::issued("s3cret")),
)?)
}
fn authorized(client: &RegisteredClient) -> Result<AuthorizedCode, Box<dyn std::error::Error>> {
let resource = ResourceUri::new(RESOURCE)?;
let pending: PendingAuthorization = begin_authorization(
&model(),
client,
REDIRECT,
ResourceParameters::single(&resource),
&Fixed,
)?;
let callback = AuthorizationCallback::granted("the-code", STATE);
Ok(authorize_callback(&model(), &pending, &callback)?)
}
#[derive(Debug)]
struct Recorded {
code: String,
redirect_uri: String,
verifier: String,
client_id: String,
client_secret: Option<String>,
resources: Vec<String>,
}
enum Outcome_ {
Granted,
Refused(TokenResponseError),
Unreachable,
}
struct Recording {
recorded: Mutex<Option<Recorded>>,
outcome: Outcome_,
}
impl Recording {
fn granting() -> Self {
Self {
recorded: Mutex::new(None),
outcome: Outcome_::Granted,
}
}
fn refusing(error: TokenResponseError) -> Self {
Self {
recorded: Mutex::new(None),
outcome: Outcome_::Refused(error),
}
}
fn unreachable() -> Self {
Self {
recorded: Mutex::new(None),
outcome: Outcome_::Unreachable,
}
}
fn seen(&self) -> Option<Recorded> {
self.recorded.lock().ok().and_then(|mut guard| guard.take())
}
}
impl TokenEndpoint for Recording {
fn redeem(
&self,
request: TokenRequest<'_>,
) -> impl Future<Output = Result<IssuedToken, TokenEndpointError>> + Send {
let recorded = Recorded {
code: request.code().to_owned(),
redirect_uri: request.redirect_uri().to_owned(),
verifier: request.code_verifier().as_str().to_owned(),
client_id: request.client_id().as_str().to_owned(),
client_secret: request
.client_secret()
.map(|secret| secret.expose().to_owned()),
resources: request.resource().iter().map(str::to_owned).collect(),
};
if let Ok(mut guard) = self.recorded.lock() {
*guard = Some(recorded);
}
std::future::ready(match &self.outcome {
Outcome_::Granted => {
IssuedToken::parse(GRANTED).map_err(TokenEndpointError::Refused)
}
Outcome_::Refused(error) => Err(TokenEndpointError::Refused(error.clone())),
Outcome_::Unreachable => Err(TokenEndpointError::Unavailable(
"no route to host".to_owned(),
)),
})
}
}
#[test]
fn a_valid_code_becomes_a_token() -> Outcome {
let client = public_client()?;
let endpoint = Recording::granting();
let issued =
now(redeem(authorized(&client)?, &client, &endpoint)).ok_or("the future pended")??;
assert_eq!(issued.access_token().as_str(), "at-1");
Ok(())
}
#[test]
fn the_request_is_assembled_from_the_code_not_the_caller() -> Outcome {
let client = public_client()?;
let endpoint = Recording::granting();
now(redeem(authorized(&client)?, &client, &endpoint)).ok_or("the future pended")??;
let seen = endpoint.seen().ok_or("the endpoint was never asked")?;
assert_eq!(seen.code, "the-code");
assert_eq!(seen.redirect_uri, REDIRECT);
assert_eq!(seen.verifier, VERIFIER);
assert_eq!(seen.client_id, CLIENT_ID);
Ok(())
}
#[test]
fn the_resource_binding_is_carried_to_the_token_request() -> Outcome {
let client = public_client()?;
let endpoint = Recording::granting();
now(redeem(authorized(&client)?, &client, &endpoint)).ok_or("the future pended")??;
let seen = endpoint.seen().ok_or("the endpoint was never asked")?;
assert_eq!(seen.resources, vec![RESOURCE.to_owned()]);
Ok(())
}
#[test]
fn a_public_client_sends_no_secret() -> Outcome {
let client = public_client()?;
let endpoint = Recording::granting();
now(redeem(authorized(&client)?, &client, &endpoint)).ok_or("the future pended")??;
let seen = endpoint.seen().ok_or("the endpoint was never asked")?;
assert_eq!(seen.client_secret, None);
Ok(())
}
#[test]
fn a_confidential_client_sends_its_secret() -> Outcome {
let client = confidential_client()?;
let endpoint = Recording::granting();
now(redeem(authorized(&client)?, &client, &endpoint)).ok_or("the future pended")??;
let seen = endpoint.seen().ok_or("the endpoint was never asked")?;
assert_eq!(seen.client_secret.as_deref(), Some("s3cret"));
Ok(())
}
#[test]
fn a_registration_for_another_client_is_refused_before_the_request() -> Outcome {
let issued_for = confidential_client()?;
let endpoint = Recording::granting();
let outcome = now(redeem(
authorized(&issued_for)?,
&public_client()?,
&endpoint,
));
assert_matches!(outcome, Some(Err(RedeemError::ClientMismatch { .. })));
assert!(
endpoint.seen().is_none(),
"a mismatched code was sent anyway"
);
Ok(())
}
#[test]
fn the_servers_own_refusal_reaches_the_caller() -> Outcome {
let client = public_client()?;
let endpoint = Recording::refusing(TokenResponseError::AuthorizationServer {
error: "invalid_grant".to_owned(),
description: None,
});
let outcome = now(redeem(authorized(&client)?, &client, &endpoint));
assert_matches!(
outcome,
Some(Err(RedeemError::Endpoint(TokenEndpointError::Refused(
TokenResponseError::AuthorizationServer { error, ..
})))) if error == "invalid_grant"
);
Ok(())
}
#[test]
fn an_unreachable_endpoint_is_not_reported_as_a_refusal() -> Outcome {
let client = public_client()?;
let endpoint = Recording::unreachable();
let outcome = now(redeem(authorized(&client)?, &client, &endpoint));
assert_matches!(
outcome,
Some(Err(RedeemError::Endpoint(TokenEndpointError::Unavailable(
_
))))
);
Ok(())
}
#[test]
fn no_error_text_carries_a_credential() -> Outcome {
let client = confidential_client()?;
let endpoint = Recording::refusing(TokenResponseError::AuthorizationServer {
error: "invalid_grant".to_owned(),
description: None,
});
let outcome = now(redeem(authorized(&client)?, &client, &endpoint));
let rendered = match outcome {
Some(Err(error)) => error.to_string(),
_ => String::from("no error was produced"),
};
for secret in ["the-code", VERIFIER, "s3cret"] {
assert!(!rendered.contains(secret), "{rendered} leaked {secret}");
}
Ok(())
}
}