use serde::{Deserialize, Serialize};
use crate::client::{Client, ClientAuth, ClientId};
use crate::registration::{
ClientMetadata, RegistrationConfig, RegistrationErrorCode, RegistrationErrorResponse,
RegistrationFailure,
};
use crate::scope::ScopeSet;
pub const MAX_CLIENT_ID_DOCUMENT_BYTES: usize = 5120;
pub const MAX_CLIENT_ID_URL_BYTES: usize = 2048;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CimdError {
NotHttps,
NoHost,
NotAscii,
NoPath,
DotSegment,
Fragment,
Userinfo,
QueryString,
SpecialUseAddress,
UrlTooLong,
DocumentTooLarge,
NotJson,
MissingClientId,
ClientIdMismatch,
ClientSecretPresent,
SharedSecretAuthMethod,
KeyMaterialPresent,
RedirectUriNotSameOrigin,
Metadata(crate::registration::RegistrationErrorResponse),
}
impl std::fmt::Display for CimdError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CimdError::NotHttps => f.write_str("the client identifier must use the https scheme"),
CimdError::NoHost => f.write_str("the client identifier has no host"),
CimdError::NotAscii => f.write_str(
"the client identifier contains a byte outside printable ASCII, which RFC 3986 \
requires to be percent-encoded, or a backslash or percent sign in its authority, \
which a URL parser would read as a different host than this crate does",
),
CimdError::NoPath => {
f.write_str("the client identifier must contain a path component")
}
CimdError::DotSegment => f.write_str(
"the client identifier must not contain single-dot or double-dot path segments",
),
CimdError::Fragment => {
f.write_str("the client identifier must not contain a fragment")
}
CimdError::Userinfo => {
f.write_str("the client identifier must not contain a userinfo component")
}
CimdError::QueryString => f.write_str(
"the client identifier carries a query string, which this deployment does not allow",
),
CimdError::SpecialUseAddress => f.write_str(
"the client identifier names a special-use IP address literal (RFC 6890)",
),
CimdError::UrlTooLong => f.write_str("the client identifier is too long"),
CimdError::DocumentTooLarge => {
f.write_str("the client identifier metadata document is too large")
}
CimdError::NotJson => {
f.write_str("the client identifier metadata document is not a JSON object")
}
CimdError::MissingClientId => f.write_str(
"the client identifier metadata document has no client_id member",
),
CimdError::ClientIdMismatch => f.write_str(
"the document's client_id is not the URL it was fetched from",
),
CimdError::ClientSecretPresent => f.write_str(
"a client identifier metadata document must not carry a client secret",
),
CimdError::SharedSecretAuthMethod => f.write_str(
"token_endpoint_auth_method names a shared-secret method, which a public document \
cannot hold",
),
CimdError::KeyMaterialPresent => f.write_str(
"the document carries jwks or jwks_uri, and this server cannot register a client \
key, so honouring the document would mean registering a public client instead",
),
CimdError::RedirectUriNotSameOrigin => f.write_str(
"a redirect_uri is not same-origin with the client identifier",
),
CimdError::Metadata(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for CimdError {}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CimdPolicy {
pub allow_loopback: bool,
pub max_document_bytes: usize,
pub redirect_uris_same_origin: bool,
pub allow_query_string: bool,
pub registration_bounds: RegistrationConfig,
}
impl Default for CimdPolicy {
fn default() -> Self {
CimdPolicy::new()
}
}
impl CimdPolicy {
pub fn new() -> Self {
CimdPolicy {
allow_loopback: false,
max_document_bytes: MAX_CLIENT_ID_DOCUMENT_BYTES,
redirect_uris_same_origin: true,
allow_query_string: false,
registration_bounds: RegistrationConfig::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct ClientIdUrl(String);
impl ClientIdUrl {
pub fn parse(raw: &str, policy: &CimdPolicy) -> Result<Self, CimdError> {
if raw.len() > MAX_CLIENT_ID_URL_BYTES {
return Err(CimdError::UrlTooLong);
}
if !raw.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
return Err(CimdError::NotAscii);
}
let rest = raw.strip_prefix("https://").ok_or(CimdError::NotHttps)?;
if raw.contains('#') {
return Err(CimdError::Fragment);
}
let authority_end = rest.find(['/', '?']).unwrap_or(rest.len());
let authority = &rest[..authority_end];
if authority.contains('@') {
return Err(CimdError::Userinfo);
}
if authority_end == rest.len() || rest.as_bytes()[authority_end] != b'/' {
return Err(CimdError::NoPath);
}
let after_authority = &rest[authority_end..];
let (path, query) = match after_authority.find('?') {
Some(at) => (&after_authority[..at], Some(&after_authority[at + 1..])),
None => (after_authority, None),
};
if path
.split('/')
.any(|segment| segment == "." || segment == "..")
{
return Err(CimdError::DotSegment);
}
if query.is_some() && !policy.allow_query_string {
return Err(CimdError::QueryString);
}
let host = host_of(authority).ok_or(CimdError::NoHost)?;
if !host.contains(':')
&& ends_in_a_number(host)
&& host.parse::<std::net::Ipv4Addr>().is_err()
{
return Err(CimdError::SpecialUseAddress);
}
if authority.contains('\\') {
return Err(CimdError::NotAscii);
}
if authority.contains('%') {
return Err(CimdError::NotAscii);
}
if is_special_use_literal(host, policy.allow_loopback) {
return Err(CimdError::SpecialUseAddress);
}
Ok(ClientIdUrl(raw.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
fn origin(&self) -> &str {
let Some(rest) = self.0.strip_prefix("https://") else {
return &self.0;
};
let end = rest.find(['/', '?']).unwrap_or(rest.len());
&self.0[.."https://".len() + end]
}
}
impl std::fmt::Display for ClientIdUrl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
fn ends_in_a_number(host: &str) -> bool {
let host = host.strip_suffix('.').unwrap_or(host);
let Some(last) = host.rsplit('.').next() else {
return false;
};
if last.is_empty() {
return false;
}
if let Some(hex) = last.strip_prefix("0x").or_else(|| last.strip_prefix("0X")) {
return hex.chars().all(|c| c.is_ascii_hexdigit());
}
last.chars().all(|c| c.is_ascii_digit())
}
fn host_of(authority: &str) -> Option<&str> {
if authority.is_empty() {
return None;
}
if let Some(rest) = authority.strip_prefix('[') {
return rest.split(']').next().filter(|h| !h.is_empty());
}
authority.split(':').next().filter(|h| !h.is_empty())
}
fn is_special_use_literal(host: &str, allow_loopback: bool) -> bool {
use std::net::{Ipv4Addr, Ipv6Addr};
if let Ok(v4) = host.parse::<Ipv4Addr>() {
let o = v4.octets();
if o[0] == 127 {
return !allow_loopback;
}
return match o {
[0, ..] => true,
[10, ..] => true,
[172, b, ..] if (16..=31).contains(&b) => true,
[192, 168, ..] => true,
[100, b, ..] if (64..=127).contains(&b) => true,
[169, 254, ..] => true,
[192, 0, 0, _] => true,
[192, 0, 2, _] | [198, 51, 100, _] | [203, 0, 113, _] => true,
[192, 88, 99, _] => true,
[198, b, ..] if b == 18 || b == 19 => true,
[a, ..] if a >= 224 => true,
_ => false,
};
}
if let Ok(v6) = host.parse::<Ipv6Addr>() {
let s = v6.segments();
if v6 == Ipv6Addr::LOCALHOST {
return !allow_loopback;
}
if s[0..5] == [0, 0, 0, 0, 0] && (s[5] == 0xffff || s[5] == 0) {
let embedded = Ipv4Addr::new(
(s[6] >> 8) as u8,
(s[6] & 0xff) as u8,
(s[7] >> 8) as u8,
(s[7] & 0xff) as u8,
);
return is_special_use_literal(&embedded.to_string(), allow_loopback);
}
return match s[0] {
0 => true,
0x0064 => true,
0x0100 => s[1] == 0 && s[2] == 0 && s[3] == 0,
0x2001 => s[1] < 0x0200 || s[1] == 0x0db8,
0x2002 => true,
first => {
(first & 0xfe00) == 0xfc00 || (first & 0xffc0) == 0xfe80 || (first >> 8) == 0xff
}
};
}
false
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
struct ClientIdDocument {
#[serde(default)]
client_id: Option<String>,
#[serde(default)]
client_secret: Option<serde_json::Value>,
#[serde(default)]
client_secret_expires_at: Option<serde_json::Value>,
#[serde(default)]
jwks: Option<serde_json::Value>,
#[serde(default)]
jwks_uri: Option<serde_json::Value>,
#[serde(flatten)]
metadata: ClientMetadata,
}
const FORBIDDEN_AUTH_METHODS: &[&str] = &[
"client_secret_basic",
"client_secret_post",
"client_secret_jwt",
];
const AUTH_METHOD_NONE: &str = "none";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidatedClientIdDocument {
url: ClientIdUrl,
registered: crate::registration::Registered,
}
impl ValidatedClientIdDocument {
pub fn validate(
fetched_from: &ClientIdUrl,
body: &[u8],
policy: &CimdPolicy,
) -> Result<Self, CimdError> {
if body.len() > policy.max_document_bytes {
return Err(CimdError::DocumentTooLarge);
}
let document: ClientIdDocument =
serde_json::from_slice(body).map_err(|_| CimdError::NotJson)?;
let claimed = document
.client_id
.as_deref()
.ok_or(CimdError::MissingClientId)?;
if claimed != fetched_from.as_str() {
return Err(CimdError::ClientIdMismatch);
}
if document.client_secret.is_some() || document.client_secret_expires_at.is_some() {
return Err(CimdError::ClientSecretPresent);
}
if document.jwks.is_some() || document.jwks_uri.is_some() {
return Err(CimdError::KeyMaterialPresent);
}
let mut metadata = document.metadata;
match metadata.token_endpoint_auth_method.as_deref() {
Some(m) if FORBIDDEN_AUTH_METHODS.contains(&m) => {
return Err(CimdError::SharedSecretAuthMethod)
}
None => metadata.token_endpoint_auth_method = Some(AUTH_METHOD_NONE.to_string()),
Some(_) => {}
}
let registered = crate::registration::validate(&metadata, &policy.registration_bounds)
.map_err(|failure| match failure {
RegistrationFailure::Invalid(response) => CimdError::Metadata(response),
_ => CimdError::Metadata(RegistrationErrorResponse::new(
RegistrationErrorCode::InvalidClientMetadata,
"the document names metadata this server will not accept",
)),
})?;
if policy.redirect_uris_same_origin {
let origin = fetched_from.origin();
for uri in ®istered.redirect_uris {
let same = uri.strip_prefix(origin).is_some_and(|rest| {
rest.is_empty() || rest.starts_with('/') || rest.starts_with('?')
});
if !same {
return Err(CimdError::RedirectUriNotSameOrigin);
}
}
}
Ok(ValidatedClientIdDocument {
url: fetched_from.clone(),
registered,
})
}
pub fn client_id_url(&self) -> &ClientIdUrl {
&self.url
}
pub fn to_client(&self) -> Client {
Client {
client_id: ClientId::new(self.url.as_str()),
auth: ClientAuth::Public,
grant_types: self.registered.grant_types.clone(),
redirect_uris: self.registered.redirect_uris.clone(),
allowed_scopes: self.registered.scope.clone(),
default_scopes: ScopeSet::empty(),
name: self.registered.client_name.clone(),
registration: None,
}
}
}