use std::str::FromStr;
use base64::Engine;
use http::header::{ACCEPT, CONTENT_TYPE};
use rand::rand_core::OsError;
use rand::TryRngCore;
pub use secrecy::ExposeSecret;
use secrecy::SecretString;
use sha2::Digest;
use sha2::Sha256;
use sha2::Sha512;
use url::Url;
use crate::http::CONTENT_TYPE_FORM_URLENCODED;
use crate::{
algorithms::link_rel,
http::{Body, CONTENT_TYPE_JSON},
traits::as_string_or_list,
};
#[derive(thiserror::Error, Debug, miette::Diagnostic)]
pub enum Error {
#[error("No metadata endpoint could be found.")]
NoMetadataEndpoint,
#[error("The metadata endpoint did not declare itself as JSON but as {content_type:?}")]
MetadataContentTypeInvalid { content_type: String },
#[error("Failed to parse the JSON of the metadata endpoint.")]
ParseMetadataJson(#[source] serde_json::Error),
#[error("Client IDs using IndieAuth have to be a URL.")]
ClientIdMustBeUrl,
#[error("A scope can't be an empty string.")]
EmptyScope,
#[error("The size of the raw challenge does not comply with the specification.")]
ChallengeOutOfBounds(usize),
#[error("Failed to parse the JSON of the redemption response.")]
ParseRedemption {
#[source]
source: serde_json::Error,
body: String,
},
#[error("Failed to parse the JSON of the metadata endpoint.")]
ParseTokenRedemption(#[source] serde_json::Error),
#[error("The redemeption endpoint at {url:?} did not declare itself as JSON but as {content_type:?}")]
RedemptionResponseContentType { url: Url, content_type: String },
#[error("Failed to invoke the random number generator: {0:#?}")]
Random(OsError),
#[error("The client ID must be a HTTP-based URL.")]
ClientIdMustBeHttp,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
pub struct Scope(String);
#[derive(Clone, Debug, serde::Deserialize)]
pub struct AccessToken(SecretString);
impl std::ops::Deref for AccessToken {
type Target = SecretString;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AccessToken {
pub fn new(token: impl ToString) -> Self {
Self(SecretString::new(token.to_string().into_boxed_str()))
}
}
impl Scope {
pub fn new(scope: impl ToString) -> Result<Self, crate::Error> {
let scope_str = scope.to_string();
if scope_str.is_empty() {
return Err(Error::EmptyScope.into());
}
Ok(Self(scope_str))
}
}
impl From<String> for Scope {
fn from(scope: String) -> Self {
Self(scope)
}
}
impl std::ops::Deref for Scope {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// A representation of a list of [scopes][Scope].
///
/// This structure allows for the normalization of common behaviors when working
/// with scopes.
///
/// # Examples
/// ```
/// # use indieweb::standards::indieauth::Scopes;
/// # use std::str::FromStr;
/// #
/// let scopes = Scopes::from_str("read create:read").unwrap();
///
/// assert!(scopes.has("read"),
/// "can report if it has a scope");
///
/// assert_eq!(scopes.assert("aircraft"),
/// Err(indieweb::Error::MissingScope(Scopes::from_str("aircraft").unwrap())),
/// "can report if it does not have a scope");
///
/// assert_eq!(scopes.to_string(),
/// "read create:read".to_string(),
/// "easy to pass as a string");
/// ```
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Scopes(Vec<Scope>);
impl std::fmt::Display for Scopes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(
&self
.0
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(" "),
)
}
}
impl FromStr for Scopes {
type Err = crate::Error;
fn from_str(scopes_string: &str) -> Result<Self, Self::Err> {
scopes_string
.split(' ')
.try_fold(Vec::default(), |mut acc, scope_str| {
acc.push(Scope::new(scope_str)?);
Ok(acc)
})
.map(Self)
}
}
impl Scopes {
/// Reports if this has no scopes defined.
///
/// ```
/// # use indieweb::standards::indieauth::Scopes;
/// # use std::str::FromStr;
/// assert_eq!(
/// Ok(false),
/// Scopes::from_str("read")
/// .map(|scopes| scopes.is_empty()),
/// "there's at least one scope in here"
/// )
/// ```
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Confirms if there's a scope matching or beginning to match with the provided value.
///
/// This works with matching a direct scope (like 'create') or a scope with prefixes in
/// its authored order (like 'update:note'). There's no real "format" to what a scope
/// can look like, that's completely up to your implementation. For example, capitalist
/// companies like [Slack](https://en.wikipedia.org/wiki/Slack_(software)) use a combination
/// of colons and dots for scopes (like "files.metadata:read") and monopolies like
/// [Google](https://en.wikipedia.org/wiki/Google) use URLs as scopes.
///
/// # Examples
///
/// ```
/// # use indieweb::standards::indieauth::Scopes;
/// # use std::str::FromStr;
/// #
/// let scopes = Scopes::from_str("read update:note").unwrap();
/// assert!(scopes.has("read"));
/// assert!(scopes.has("update:note"));
/// ```
pub fn has(&self, scope: &str) -> bool {
self.0.iter().any(|s| s.to_string().starts_with(scope))
}
/// Reports if this has no scopes defined.
///
/// Alias for [`has()`][Scopes::has] for more idiomatic usage.
///
/// ```
/// # use indieweb::standards::indieauth::Scopes;
/// # use std::str::FromStr;
/// #
/// let scopes = Scopes::from_str("read update:note").unwrap();
/// assert!(scopes.contains("read"));
/// assert!(scopes.contains("update:note"));
/// ```
pub fn contains(&self, scope: &str) -> bool {
self.has(scope)
}
/// Returns the scopes as a list.
pub fn as_vec(&self) -> &Vec<Scope> {
&self.0
}
/// Confirm the presence of a provided scope.
///
/// # Examples
///
/// ```
/// # use indieweb::standards::indieauth::Scopes;
/// # use std::str::FromStr;
/// #
/// let scopes = "read create:note".parse::<Scopes>();
///
/// assert_eq!(
/// Ok(()),
/// scopes.as_ref().unwrap().assert("read"),
/// "confirms explicit scope match without prefix");
/// assert_eq!(
/// Ok(()),
/// scopes.as_ref().unwrap().assert("create:note"),
/// "confirms explicit scope match with prefix");
/// assert_eq!(
/// Err(indieweb::Error::MissingScope(Scopes::from_str("airplane").unwrap())),
/// scopes.as_ref().unwrap().assert("create:note airplane"),
/// "denies provided scopes if one is not valid");
/// ```
pub fn assert(&self, needed_scopes: &str) -> Result<(), crate::Error> {
tracing::debug!("Asserting that {:?} exists in {:?}", needed_scopes, self);
let scope_list: Scopes = needed_scopes.parse().unwrap_or_else(|_| Scopes::default());
let missing_scopes: Scopes = scope_list
.as_vec()
.iter()
.filter(|expected_scope| !self.has(expected_scope))
.cloned()
.collect::<Vec<_>>()
.into();
if missing_scopes.is_empty() {
Ok(())
} else {
Err(crate::Error::MissingScope(missing_scopes))
}
}
/// Confirm the presence of a provided scope.
///
/// Alias for [`assert()`][Scopes::assert] for more idiomatic usage.
///
/// # Examples
///
/// ```
/// # use indieweb::standards::indieauth::Scopes;
/// # use std::str::FromStr;
/// #
/// let scopes = "read create:note".parse::<Scopes>();
///
/// assert_eq!(
/// Ok(()),
/// scopes.as_ref().unwrap().require("read"),
/// "confirms explicit scope match without prefix");
/// assert_eq!(
/// Ok(()),
/// scopes.as_ref().unwrap().require("create:note"),
/// "confirms explicit scope match with prefix");
/// assert_eq!(
/// Err(indieweb::Error::MissingScope(Scopes::from_str("airplane").unwrap())),
/// scopes.as_ref().unwrap().require("create:note airplane"),
/// "denies provided scopes if one is not valid");
/// ```
pub fn require(&self, needed_scopes: &str) -> Result<(), crate::Error> {
self.assert(needed_scopes)
}
/// Returns a list of scopes that are matching the list of scopes; a union of the two lists.
///
/// # Examples
///
/// ```
/// # use indieweb::standards::indieauth::Scopes;
/// # use std::str::FromStr;
///
/// let wanted_scopes = Scopes::from_str("read update:note").unwrap();
/// let scopes = Scopes::from_str("read create update:note").unwrap();
/// assert_eq!(scopes.matching("read update:note"), wanted_scopes);
/// ```
pub fn matching(&self, wanted_scopes: &str) -> Self {
wanted_scopes
.parse::<Self>()
.unwrap_or_default()
.as_vec()
.iter()
.filter(|expected_scope| self.has(expected_scope))
.cloned()
.collect::<Scopes>()
}
pub fn minimal() -> Self {
Self(vec![Scope("read".to_string())])
}
}
impl From<Vec<String>> for Scopes {
fn from(scopes: Vec<String>) -> Self {
scopes.into_iter().map(Scope::from).collect()
}
}
impl FromIterator<Scope> for Scopes {
fn from_iter<T: IntoIterator<Item = Scope>>(iter: T) -> Self {
Self(iter.into_iter().collect::<Vec<_>>())
}
}
impl From<Vec<Scope>> for Scopes {
fn from(scopes: Vec<Scope>) -> Self {
Self(scopes)
}
}
impl From<Scopes> for Vec<Scope> {
fn from(val: Scopes) -> Self {
val.0
}
}
/// Represents how IndieAuth endpoints should be discovered.
///
/// This enum provides two modes of operation:
/// - **Classic**: Explicitly provide authorization, token, and optionally ticket endpoints
/// - **Metadata**: Provide a metadata URL that will be used to auto-discover all endpoints
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EndpointDiscovery {
/// Classic discovery with explicit endpoints
Classic {
/// The authorization endpoint URL
authorization: Url,
/// The token endpoint URL
token: Url,
/// Optional ticket endpoint URL
ticket: Option<Url>,
},
/// Metadata-based discovery from a metadata endpoint
Metadata {
/// The metadata endpoint URL
metadata: Url,
},
}
impl serde::Serialize for Scopes {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> serde::de::Deserialize<'de> for Scopes {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
as_string_or_list::deserialize(deserializer)?
.into_iter()
.filter(|s: &String| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
.parse()
.map_err(serde::de::Error::custom)
}
}
#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)]
pub enum CodeChallengeMethod {
S256,
S512,
Plain,
}
impl CodeChallengeMethod {
pub fn recommended() -> Self {
Self::S256
}
pub fn validate(
&self,
CodeChallenge { challenge, .. }: &CodeChallenge,
verifier: &str,
) -> bool {
let CodeChallenge {
challenge: expected_challenge,
..
} = CodeChallenge::from_verifier(self, verifier.to_string());
expected_challenge == *challenge
}
}
#[allow(clippy::to_string_trait_impl)]
impl ToString for CodeChallengeMethod {
fn to_string(&self) -> String {
match self {
Self::S256 => "S256".to_string(),
Self::S512 => "S512".to_string(),
Self::Plain => "PLAIN".to_string(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CodeChallenge {
challenge: String,
verifier: String,
}
impl serde::Serialize for CodeChallenge {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.challenge.serialize(serializer)
}
}
impl<'de> serde::Deserialize<'de> for CodeChallenge {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Self {
challenge: String::deserialize(deserializer)?,
verifier: Default::default(),
})
}
}
impl std::ops::Deref for CodeChallenge {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.challenge
}
}
impl CodeChallenge {
pub fn from_verifier(method: &CodeChallengeMethod, verifier: String) -> Self {
let challenge_bytes = match method {
CodeChallengeMethod::S256 => {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
hasher.finalize().to_vec()
}
CodeChallengeMethod::S512 => {
let mut hasher = Sha512::new();
hasher.update(verifier.as_bytes());
hasher.finalize().to_vec()
}
CodeChallengeMethod::Plain => verifier.as_bytes().to_vec(),
};
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(challenge_bytes);
Self {
challenge,
verifier,
}
}
pub fn generate(method: CodeChallengeMethod) -> Result<(Self, CodeChallengeMethod), Error> {
let mut bytes = [0u8; 64];
let mut rng = rand::rngs::OsRng {};
rng.try_fill_bytes(&mut bytes[..]).map_err(Error::Random)?;
let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let challenge = Self::from_verifier(&method, verifier);
Ok((challenge, method))
}
pub fn verifier(&self) -> &str {
&self.verifier
}
}
#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq, Eq)]
pub struct AuthorizationRequestFields {
pub client_id: ClientId,
pub redirect_uri: RedirectUri,
pub state: String,
#[serde(rename = "code_challenge")]
pub challenge: CodeChallenge,
#[serde(rename = "code_challenge_method")]
pub challenge_method: CodeChallengeMethod,
#[serde(rename = "scope", default = "Scopes::minimal")]
pub scope: Scopes,
}
impl AuthorizationRequestFields {
/// Forms a new authorization request for the intending client to the expecting location.
pub fn new(
client_id: &ClientId,
redirect_uri: &Url,
state: impl ToString,
) -> Result<Self, crate::Error> {
let (code_challenge, code_challenge_method) =
CodeChallenge::generate(CodeChallengeMethod::recommended())?;
Ok(Self {
client_id: client_id.clone(),
redirect_uri: redirect_uri.clone().into(),
state: state.to_string(),
challenge: code_challenge,
challenge_method: code_challenge_method,
scope: Default::default(),
})
}
pub fn into_authorization_url(
self,
authorization_endpoint_url: impl Into<Url>,
extra_fields: Vec<(String, String)>,
) -> Result<Url, Error> {
let mut signed_authorization_endpoint_url = authorization_endpoint_url.into();
let mut fields = signed_authorization_endpoint_url.query_pairs_mut();
fields
.append_pair("response_type", "code")
.append_pair("client_id", &self.client_id)
.append_pair("redirect_uri", self.redirect_uri.as_str())
.append_pair("state", &self.state)
.append_pair("code_challenge", &self.challenge)
.append_pair("code_challenge_method", &self.challenge_method.to_string());
if !self.scope.is_empty() {
fields.append_pair("scope", &self.scope.to_string());
}
for (name, value) in extra_fields {
fields.append_pair(&name, &value);
}
fields.finish();
drop(fields);
Ok(signed_authorization_endpoint_url)
}
}
#[derive(Clone, serde::Deserialize, Debug)]
pub struct RedemptionFields {
pub code: String,
pub client_id: ClientId,
pub redirect_uri: RedirectUri,
#[serde(rename = "code_verifier")]
pub verifier: String,
}
impl RedemptionFields {
/// Convert these fields into query parameters for form-encoded POST.
pub fn into_query_parameters(self) -> Vec<(String, String)> {
vec![
("grant_type".to_string(), "authorization_code".to_string()),
("code".to_string(), self.code),
("client_id".to_string(), self.client_id.0),
("redirect_uri".to_string(), self.redirect_uri.to_string()),
("code_verifier".to_string(), self.verifier),
]
}
/// Convert these fields into an HTTP POST request for token redemption.
///
/// This follows the idiomatic pattern used by popular Rust API clients
/// like the `gitlab` crate, allowing request construction separate from execution.
pub fn into_request(self, endpoint: &Url) -> Result<http::Request<crate::http::Body>, crate::Error> {
use http::header;
let body = self
.into_query_parameters()
.into_iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join("&");
http::Request::post(endpoint.as_str())
.method(http::Method::POST)
.header(header::ACCEPT, "application/json")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(crate::http::Body::Bytes(body.into_bytes()))
.map_err(|e| crate::Error::from(e))
}
}
#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq, Eq)]
pub struct ServerMetadata {
pub issuer: Url,
pub authorization_endpoint: Url,
pub token_endpoint: Url,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ticket_endpoint: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introspection_endpoint: Option<Url>,
#[serde(default = "ServerMetadata::recommended_code_challenge_methods")]
pub code_challenge_methods_supported: Vec<String>,
#[serde(default, skip_serializing_if = "Scopes::is_empty")]
pub scopes_supported: Scopes,
}
impl ServerMetadata {
pub fn recommended_code_challenge_methods() -> Vec<String> {
vec!["S256".to_string()]
}
/// A helper method for starting an authorization request with a [request payload][AuthorizationRequestPayload].
pub fn new_authorization_request_url(
&self,
request: AuthorizationRequestFields,
extra_fields: Vec<(String, String)>,
) -> Result<Url, crate::Error> {
Ok(request.into_authorization_url(self.authorization_endpoint.clone(), extra_fields)?)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ClientId(String);
impl ClientId {
pub fn new(client_id: impl ToString) -> Result<Self, crate::Error> {
let client_id_str = client_id.to_string();
if !(client_id_str.starts_with("http://") || client_id_str.starts_with("https://")) {
return Err(Error::ClientIdMustBeHttp.into());
}
if client_id_str.parse::<Url>().is_ok() {
Ok(Self(client_id_str))
} else {
Err(Error::ClientIdMustBeUrl.into())
}
}
}
impl std::ops::Deref for ClientId {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RedirectUri(Url);
impl std::ops::Deref for RedirectUri {
type Target = Url;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<Url> for RedirectUri {
fn from(value: Url) -> Self {
Self(value)
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ResponseErrorCode {
InvalidRequest,
UnauthorizedClient,
AccessDenied,
UnsupportedResponseType,
InvalidScope,
ServerError,
TemporarilyUnavailable,
}
impl std::fmt::Display for ResponseErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ResponseErrorCode::InvalidRequest => "invalid_request",
ResponseErrorCode::UnauthorizedClient => "unauthorized_client",
ResponseErrorCode::AccessDenied => "access_denied",
ResponseErrorCode::UnsupportedResponseType => "unsupported_response_type",
ResponseErrorCode::InvalidScope => "invalid_scope",
ResponseErrorCode::ServerError => "server_error",
ResponseErrorCode::TemporarilyUnavailable => "temporarily_unavailable",
})
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
pub struct RedirectErrorFields {
pub state: String,
#[serde(rename = "error")]
pub reason: ResponseErrorCode,
#[serde(rename = "error_description", skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "error_uri", skip_serializing_if = "Option::is_none")]
pub uri: Option<Url>,
}
impl RedirectErrorFields {
pub fn into_query_parameters(self) -> Vec<(String, String)> {
let mut qps = Vec::default();
qps.push(("state".to_string(), self.state));
if let Some(description) = self.description {
qps.push(("error_description".to_string(), description));
}
if let Some(uri) = self.uri {
qps.push(("error_uri".to_string(), uri.to_string()));
}
qps.push(("error".to_string(), self.reason.to_string()));
qps
}
}
#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)]
pub struct SignedRedirectFields {
pub state: String,
pub code: String,
#[serde(rename = "iss")]
pub issuer: Url,
}
impl SignedRedirectFields {
pub fn into_query_parameters(self) -> Vec<(String, String)> {
vec![
("state".to_string(), self.state),
("code".to_string(), self.code),
("iss".to_string(), self.issuer.to_string()),
]
}
}
impl RedirectUri {
pub fn for_approved_request(&self, fields: SignedRedirectFields) -> Url {
let mut u = self.0.clone();
let mut qp = u.query_pairs_mut();
qp.extend_pairs(fields.into_query_parameters());
qp.finish();
drop(qp);
u
}
pub fn for_rejected_request(&self, fields: RedirectErrorFields) -> Url {
let mut u = self.0.clone();
let mut qp = u.query_pairs_mut();
qp.extend_pairs(fields.into_query_parameters());
qp.finish();
drop(qp);
u
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
pub enum RedemptionTokenType {
Bearer,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct RedemptionClaim<Payload> {
pub token_type: RedemptionTokenType,
pub access_token: String,
pub scope: Scopes,
pub me: Url,
#[serde(default)]
pub expires_in: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
pub payload: Payload,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct RedemptionError {
#[serde(rename = "error")]
pub code: ResponseErrorCode,
#[serde(
default,
skip_serializing_if = "Option::is_none",
rename = "error_description"
)]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "error_uri")]
pub uri: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(untagged, rename_all = "snake_case")]
pub enum RedemptionResponse<Payload> {
/// A [successful response][RedemptionClaim] from a code redemption endpoint.
Claim(RedemptionClaim<Payload>),
/// A [failing resonse][RedemptionError] from a code redemption endpoint.
Error(RedemptionError),
}
#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct ProfileRedemptionFields {
pub name: String,
pub url: Url,
#[serde(skip_serializing_if = "Option::is_none")]
pub photo: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
#[derive(serde::Deserialize, serde::Serialize, Default, Debug, Clone, PartialEq, Eq)]
#[allow(clippy::large_enum_variant)]
#[serde(untagged, rename_all = "snake_case")]
pub enum CommonRedemptionFields {
Profile(ProfileRedemptionFields),
#[default]
Empty,
}
pub struct Client<HttpClient: crate::http::Client> {
pub id: ClientId,
client: HttpClient,
#[allow(dead_code)]
pub(crate) discovery: Option<EndpointDiscovery>,
}
/// Type alias for a Client with the default reqwest HTTP client.
///
/// This provides a more ergonomic API for common use cases without requiring
/// explicit type parameters.
///
/// # Example
///
/// ```
/// # use indieweb::standards::indieauth::IndieAuthClient;
/// # use indieweb::http::reqwest::Client as HttpClient;
/// # async {
/// let http_client = HttpClient::default();
/// let client = IndieAuthClient::builder()
/// .id("https://example.com")
/// .client(http_client)
/// .build()?;
/// # Ok::<_, indieweb::Error>(()) }.
/// ```
#[cfg(feature = "reqwest")]
pub type IndieAuthClient = Client<crate::http::reqwest::Client>;
/// Builder for creating a [`Client`] with optional configuration.
///
/// # Example
/// ```
/// # use indieweb::http::reqwest::Client as HttpClient;
/// # use indieweb::standards::indieauth::Client;
/// # async {
/// let http_client = HttpClient::default();
/// let client = Client::builder()
/// .id("https://example.com")
/// .client(http_client)
/// .build()?;
/// # Ok::<_, indieweb::Error>(()) }.
/// ```
#[derive(Debug)]
pub struct ClientBuilder<HttpClient: crate::http::Client> {
id: Option<ClientId>,
client: Option<HttpClient>,
discovery: Option<EndpointDiscovery>,
}
impl<HttpClient: crate::http::Client> ClientBuilder<HttpClient> {
/// Create a new builder.
pub fn new() -> Self {
Self {
id: None,
client: None,
discovery: None,
}
}
/// Set the client ID.
pub fn id(mut self, id: impl ToString) -> Self {
self.id = ClientId::new(id.to_string()).ok();
self
}
/// Set the HTTP client.
pub fn client(mut self, client: HttpClient) -> Self {
self.client = Some(client);
self
}
/// Set the endpoint discovery method.
///
/// This allows you to specify either explicit endpoints (Classic) or a metadata URL
/// for automatic endpoint discovery.
pub fn discovery(mut self, discovery: EndpointDiscovery) -> Self {
self.discovery = Some(discovery);
self
}
/// Build the client.
///
/// Returns an error if `id` or `client` were not set.
pub fn build(self) -> Result<Client<HttpClient>, crate::Error> {
let id = self.id.ok_or(Error::ClientIdMustBeUrl)?;
let client = self.client.ok_or_else(|| {
crate::Error::from(std::io::Error::new(
std::io::ErrorKind::NotFound,
"HTTP client not provided",
))
})?;
Ok(Client {
id,
client,
discovery: self.discovery,
})
}
}
impl<HttpClient: crate::http::Client> Default for ClientBuilder<HttpClient> {
fn default() -> Self {
Self::new()
}
}
impl<HttpClient: crate::http::Client> Client<HttpClient> {
const LINK_REL: &'static str = "indieauth-metadata";
/// Create a new client with the given ID and HTTP client.
#[deprecated(since = "0.8.0", note = "Use Client::builder() instead")]
pub fn new(id: impl ToString, http_client: HttpClient) -> Result<Self, crate::Error> {
Self::builder()
.id(id)
.client(http_client)
.build()
}
/// Create a new builder for a [`Client`].
pub fn builder<HC: crate::http::Client>() -> ClientBuilder<HC> {
ClientBuilder::new()
}
pub async fn obtain_metadata(&self, remote_url: &Url) -> Result<ServerMetadata, crate::Error> {
// First, try HEAD request for all rel types
let head_rels = link_rel::for_url(
&self.client,
remote_url,
&[Self::LINK_REL, "authorization_endpoint", "token_endpoint"],
"HEAD",
)
.await?;
let mut rels = head_rels
.get(Self::LINK_REL)
.cloned()
.unwrap_or_default();
let mut individual_endpoints = (
head_rels
.get("authorization_endpoint")
.cloned()
.unwrap_or_default(),
head_rels
.get("token_endpoint")
.cloned()
.unwrap_or_default(),
);
// If we found individual endpoints from HEAD and no metadata, use them
if rels.is_empty() && !individual_endpoints.0.is_empty() && !individual_endpoints.1.is_empty()
&& let (Some(auth_endpoint), Some(token_endpoint)) =
(individual_endpoints.0.first(), individual_endpoints.1.first()) {
let issuer = if remote_url.has_authority() {
format!("{}://{}", remote_url.scheme(), remote_url.authority())
} else {
format!(
"{}://{}",
remote_url.scheme(),
remote_url.host_str().unwrap_or_default()
)
}
.parse()?;
let metadata = ServerMetadata {
issuer,
authorization_endpoint: auth_endpoint.clone(),
token_endpoint: token_endpoint.clone(),
ticket_endpoint: None,
introspection_endpoint: None,
code_challenge_methods_supported: ServerMetadata::recommended_code_challenge_methods(),
scopes_supported: Scopes::default(),
};
return Ok(metadata);
}
if rels.is_empty() {
// If HEAD didn't find indieauth-metadata, try GET for all rel types
let all_rels = link_rel::for_url(
&self.client,
remote_url,
&[Self::LINK_REL, "authorization_endpoint", "token_endpoint"],
"GET",
)
.await?;
// Check for indieauth-metadata first
rels = all_rels
.get(Self::LINK_REL)
.cloned()
.unwrap_or_default();
// Update individual endpoints (GET might find more in body)
let auth_endpoints = all_rels
.get("authorization_endpoint")
.cloned()
.unwrap_or_default();
let token_endpoints = all_rels
.get("token_endpoint")
.cloned()
.unwrap_or_default();
individual_endpoints = (auth_endpoints, token_endpoints);
}
let metadata_endpoint_url = if let Some(rel) = rels.first().cloned() {
rel
} else {
let well_known_url: Url = if remote_url.has_authority() {
format!(
"{}://{}/.well-known/oauth-authorization-server",
remote_url.scheme(),
remote_url.authority()
)
} else {
format!(
"{}://{}/.well-known/oauth-authorization-server",
remote_url.scheme(),
remote_url.host_str().unwrap_or_default()
)
}
.parse()?;
let req = crate::http::Request::builder()
.uri(well_known_url.as_str())
.header(ACCEPT, CONTENT_TYPE_JSON)
.body(Body::Empty)?;
let resp = self.client.send_request(req).await?;
let content_type_header_value = resp
.headers()
.get(CONTENT_TYPE)
.and_then(|hv| hv.to_str().ok())
.unwrap_or_default();
if content_type_header_value.starts_with(CONTENT_TYPE_JSON) {
let body = resp.body();
if let Ok(metadata) = serde_json::from_slice::<ServerMetadata>(body.as_bytes()) {
return Ok(metadata);
}
}
// Fallback: Try to find individual authorization_endpoint and token_endpoint rels
// (already fetched in the GET request above)
let (auth_endpoints, token_endpoints) = &individual_endpoints;
if let (Some(auth_endpoint), Some(token_endpoint)) =
(auth_endpoints.first(), token_endpoints.first())
{
// Construct issuer URL from the profile URL
let issuer = if remote_url.has_authority() {
format!("{}://{}", remote_url.scheme(), remote_url.authority())
} else {
format!(
"{}://{}",
remote_url.scheme(),
remote_url.host_str().unwrap_or_default()
)
}
.parse()?;
let metadata = ServerMetadata {
issuer,
authorization_endpoint: auth_endpoint.clone(),
token_endpoint: token_endpoint.clone(),
ticket_endpoint: None,
introspection_endpoint: None,
code_challenge_methods_supported: ServerMetadata::recommended_code_challenge_methods(),
scopes_supported: Scopes::default(),
};
return Ok(metadata);
}
return Err(Error::NoMetadataEndpoint.into());
};
let req = crate::http::Request::builder()
.uri(metadata_endpoint_url.as_str())
.header(ACCEPT, CONTENT_TYPE_JSON)
.body(Body::Empty)?;
let resp = self
.client
.send_request(req)
.await?
.map(|body| body.as_bytes().to_vec());
let content_type_header_value = resp
.headers()
.get(CONTENT_TYPE)
.map(|hv| hv.to_str().unwrap_or_default().to_string())
.unwrap_or_default();
if !content_type_header_value.starts_with(CONTENT_TYPE_JSON) {
return Err(Error::MetadataContentTypeInvalid {
content_type: content_type_header_value,
}
.into());
}
Ok(serde_json::from_slice(&resp.into_body()).map_err(Error::ParseMetadataJson)?)
}
pub async fn redeem<R>(
&self,
endpoint: &Url,
redemption_fields: RedemptionFields,
) -> Result<RedemptionResponse<R>, crate::Error>
where
R: serde::de::DeserializeOwned,
{
let fields = redemption_fields
.into_query_parameters()
.into_iter()
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join("&");
let req = crate::http::Request::builder()
.uri(endpoint.as_str())
.method("POST")
.header(ACCEPT, CONTENT_TYPE_JSON)
.header(CONTENT_TYPE, CONTENT_TYPE_FORM_URLENCODED)
.body(Body::Bytes(fields.into_bytes()))?;
let resp = self
.client
.send_request(req)
.await?
.map(|b| b.as_bytes().to_vec());
let content_type_header_value = resp
.headers()
.get(CONTENT_TYPE)
.map(|hv| hv.to_str().unwrap_or_default().to_string())
.unwrap_or_default();
let body_str = String::from_utf8(resp.body().to_vec())?;
if !content_type_header_value.starts_with(CONTENT_TYPE_JSON) {
return Err(Error::RedemptionResponseContentType {
url: endpoint.to_owned(),
content_type: content_type_header_value,
}
.into());
}
serde_json::from_str(&body_str).map_err(|e| {
Error::ParseRedemption {
source: e,
body: body_str,
}
.into()
})
}
}
mod test;