use std::fmt;
use std::str::FromStr;
use schemars::JsonSchema;
use schemars::r#gen::SchemaGenerator;
use schemars::schema::{InstanceType, Metadata, Schema, SchemaObject, StringValidation};
use serde::{Deserialize, Deserializer, Serialize};
use sha2::Digest as _;
#[cfg(feature = "server")]
use utoipa::openapi::schema::{ObjectBuilder, Schema as OpenApiSchema, Type};
use uuid::Uuid;
use crate::auth::{OAuthClientId, PrincipalId, PrincipalKindTag, SecretBearer};
use crate::error::WyrdError;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct AbsoluteUrl(String);
impl AbsoluteUrl {
pub fn new(value: impl Into<String>) -> Result<Self, UrlParseError> {
let value = value.into();
validate_absolute_url(&value)?;
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AbsoluteUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for AbsoluteUrl {
type Err = UrlParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
impl<'de> Deserialize<'de> for AbsoluteUrl {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
impl JsonSchema for AbsoluteUrl {
fn schema_name() -> String {
"AbsoluteUrl".to_owned()
}
fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
url_schema(
"Absolute URL with a scheme and authority.",
Some("^https?://"),
)
}
}
#[cfg(feature = "server")]
impl utoipa::PartialSchema for AbsoluteUrl {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
openapi_url_schema("Absolute URL with a scheme and authority.")
}
}
#[cfg(feature = "server")]
impl utoipa::ToSchema for AbsoluteUrl {}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct IssuerUrl(String);
impl IssuerUrl {
pub fn new(value: impl Into<String>) -> Result<Self, UrlParseError> {
let value = value.into();
validate_issuer_url(&value)?;
Ok(Self(normalize_issuer(&value)))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[cfg(any(test, feature = "test-utils"))]
pub fn new_for_tests(value: impl Into<String>) -> Self {
let value = value.into();
Self(normalize_issuer(&value))
}
}
impl fmt::Display for IssuerUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for IssuerUrl {
type Err = UrlParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
impl<'de> Deserialize<'de> for IssuerUrl {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
impl JsonSchema for IssuerUrl {
fn schema_name() -> String {
"IssuerUrl".to_owned()
}
fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
url_schema(
"Absolute OIDC issuer URL. Trailing slashes are normalized away.",
Some("^https://"),
)
}
}
#[cfg(feature = "server")]
impl utoipa::PartialSchema for IssuerUrl {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
openapi_url_schema("Absolute OIDC issuer URL. Trailing slashes are normalized away.")
}
}
#[cfg(feature = "server")]
impl utoipa::ToSchema for IssuerUrl {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct LoginInitResponse {
pub authorization_url: AbsoluteUrl,
pub state: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Sha256Hex([u8; 32]);
impl Sha256Hex {
pub fn new(value: &str) -> Result<Self, Sha256HexError> {
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(Sha256HexError);
}
let mut digest = [0_u8; 32];
hex::decode_to_slice(value, &mut digest).map_err(|_| Sha256HexError)?;
Ok(Self(digest))
}
#[must_use]
pub fn digest(bytes: &[u8]) -> Self {
Self(sha2::Sha256::digest(bytes).into())
}
#[must_use]
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl From<[u8; 32]> for Sha256Hex {
fn from(digest: [u8; 32]) -> Self {
Self(digest)
}
}
impl fmt::Display for Sha256Hex {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&hex::encode(self.0))
}
}
impl Serialize for Sha256Hex {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Sha256Hex {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(&value).map_err(serde::de::Error::custom)
}
}
impl JsonSchema for Sha256Hex {
fn schema_name() -> String {
"Sha256Hex".to_owned()
}
fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
Schema::Object(SchemaObject {
metadata: Some(Box::new(Metadata {
description: Some(SHA256_HEX_DESCRIPTION.to_owned()),
..Default::default()
})),
instance_type: Some(InstanceType::String.into()),
string: Some(Box::new(StringValidation {
min_length: Some(64),
max_length: Some(64),
pattern: Some(SHA256_HEX_PATTERN.to_owned()),
})),
..Default::default()
})
}
}
#[cfg(feature = "server")]
impl utoipa::PartialSchema for Sha256Hex {
fn schema() -> utoipa::openapi::RefOr<OpenApiSchema> {
utoipa::openapi::RefOr::T(OpenApiSchema::Object(
ObjectBuilder::new()
.schema_type(Type::String)
.min_length(Some(64))
.max_length(Some(64))
.pattern(Some(SHA256_HEX_PATTERN))
.description(Some(SHA256_HEX_DESCRIPTION))
.build(),
))
}
}
#[cfg(feature = "server")]
impl utoipa::ToSchema for Sha256Hex {}
const SHA256_HEX_DESCRIPTION: &str =
"SHA-256 digest as exactly 64 lowercase hexadecimal characters.";
const SHA256_HEX_PATTERN: &str = "^[0-9a-f]{64}$";
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("expected a SHA-256 digest as 64 lowercase hexadecimal characters")]
pub struct Sha256HexError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoginInitiation {
Authorize(ClientAuthorization),
Device(Uuid),
ConnectionTest(ConnectionTester),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClientAuthorization {
pub client: OAuthClientId,
pub redirect_uri: String,
pub code_challenge: String,
pub state: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ConnectionTester {
pub principal_id: PrincipalId,
pub principal_kind: PrincipalKindTag,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct CallbackQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<SecretBearer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[schemars(schema_with = "state_key_schema")]
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
}
impl CallbackQuery {
pub fn response(&self) -> Result<ProviderResponse, WyrdError> {
match (&self.code, &self.error) {
(Some(code), None) => Ok(ProviderResponse::Code(code.clone())),
(None, Some(error)) => Ok(ProviderResponse::Error(error.clone())),
_ => Err(WyrdError::Validation {
message: "the callback carries exactly one of `code` or `error`".to_owned(),
details: serde_json::json!({}),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderResponse {
Code(SecretBearer),
Error(String),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UrlParseError {
#[error("URL failed to parse: {0}")]
ParseFailed(String),
#[error("URL must have an authority (host)")]
NoHost,
#[error("URL scheme '{0}' is not allowed")]
DisallowedScheme(String),
}
fn validate_absolute_url(value: &str) -> Result<(), UrlParseError> {
let parsed = url::Url::parse(value).map_err(|e| UrlParseError::ParseFailed(e.to_string()))?;
if !parsed.has_host() {
return Err(UrlParseError::NoHost);
}
match parsed.scheme() {
"https" | "http" => Ok(()),
scheme => Err(UrlParseError::DisallowedScheme(scheme.to_owned())),
}
}
fn validate_issuer_url(value: &str) -> Result<(), UrlParseError> {
let parsed = url::Url::parse(value).map_err(|e| UrlParseError::ParseFailed(e.to_string()))?;
if !parsed.has_host() {
return Err(UrlParseError::NoHost);
}
match parsed.scheme() {
"https" => Ok(()),
"http" if is_loopback_host(&parsed) => Ok(()),
scheme => Err(UrlParseError::DisallowedScheme(scheme.to_owned())),
}
}
fn is_loopback_host(parsed: &url::Url) -> bool {
match parsed.host() {
Some(url::Host::Domain(host)) => host == "localhost",
Some(url::Host::Ipv4(addr)) => addr.is_loopback(),
Some(url::Host::Ipv6(addr)) => addr.is_loopback(),
None => false,
}
}
fn normalize_issuer(value: &str) -> String {
value.trim_end_matches('/').to_owned()
}
fn url_schema(description: &'static str, pattern: Option<&'static str>) -> Schema {
Schema::Object(SchemaObject {
metadata: Some(Box::new(Metadata {
description: Some(description.to_owned()),
..Default::default()
})),
instance_type: Some(InstanceType::String.into()),
format: Some("uri".to_owned()),
string: pattern.map(|p| {
Box::new(StringValidation {
pattern: Some(p.to_owned()),
..Default::default()
})
}),
..Default::default()
})
}
fn state_key_schema(_gen: &mut SchemaGenerator) -> Schema {
Schema::Object(SchemaObject {
instance_type: Some(InstanceType::String.into()),
string: Some(Box::new(StringValidation {
min_length: Some(1),
max_length: Some(512),
pattern: None,
})),
..Default::default()
})
}
#[cfg(feature = "server")]
fn openapi_url_schema(
description: &'static str,
) -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
use utoipa::openapi::schema::{ObjectBuilder, Schema, SchemaFormat, Type};
utoipa::openapi::RefOr::T(Schema::Object(
ObjectBuilder::new()
.schema_type(Type::String)
.format(Some(SchemaFormat::Custom("uri".to_owned())))
.description(Some(description))
.build(),
))
}
#[cfg(test)]
mod tests {
use super::{
AbsoluteUrl, CallbackQuery, IssuerUrl, LoginInitResponse, ProviderResponse, Sha256Hex,
UrlParseError,
};
use crate::auth::SecretBearer;
#[test]
fn issuer_url_normalizes_trailing_slash() {
let issuer =
IssuerUrl::new("https://idp.example.com/realms/acme/").expect("issuer URL is valid");
assert_eq!(issuer.as_str(), "https://idp.example.com/realms/acme");
}
#[test]
fn issuer_url_rejects_non_url_input() {
assert!(IssuerUrl::new("not a url").is_err());
assert!(IssuerUrl::new("/relative/path").is_err());
}
#[test]
fn issuer_url_accepts_https_any_host() {
assert!(IssuerUrl::new("https://idp.example.com/realms/acme").is_ok());
assert!(IssuerUrl::new("https://localhost:8443/realms/wyrd-test").is_ok());
}
#[test]
fn issuer_url_accepts_http_loopback() {
assert!(IssuerUrl::new("http://localhost:8080/realms/wyrd-test").is_ok());
assert!(IssuerUrl::new("http://127.0.0.1:8080/realms/wyrd-test").is_ok());
assert!(IssuerUrl::new("http://[::1]:8080/realms/wyrd-test").is_ok());
}
#[test]
fn issuer_url_rejects_http_non_loopback() {
assert!(matches!(
IssuerUrl::new("http://idp.example.com"),
Err(UrlParseError::DisallowedScheme(scheme)) if scheme == "http"
));
assert!(matches!(
IssuerUrl::new("http://10.0.0.1:8080"),
Err(UrlParseError::DisallowedScheme(scheme)) if scheme == "http"
));
}
#[test]
fn issuer_url_rejects_non_https() {
assert!(IssuerUrl::new("http://insecure.example.com").is_err());
assert!(IssuerUrl::new("ftp://files.example.com").is_err());
assert!(matches!(
IssuerUrl::new("ftp://localhost"),
Err(UrlParseError::DisallowedScheme(scheme)) if scheme == "ftp"
));
}
#[test]
fn issuer_url_rejects_bad_url_and_no_host() {
assert!(matches!(
IssuerUrl::new("not a url"),
Err(UrlParseError::ParseFailed(_))
));
assert!(matches!(
IssuerUrl::new("mailto:user@example.com"),
Err(UrlParseError::NoHost)
));
}
#[test]
fn url_rejects_non_url_input() {
assert!(AbsoluteUrl::new("not a url").is_err());
assert!(AbsoluteUrl::new("urn:example:login").is_err());
}
#[test]
fn url_rejects_disallowed_schemes() {
assert!(AbsoluteUrl::new("ftp://files.example.com").is_err());
assert!(AbsoluteUrl::new("file://localhost/etc/passwd").is_err());
}
#[test]
fn url_accepts_valid_absolute_url() {
let url = AbsoluteUrl::new("https://idp.example.com/authorize").expect("valid");
assert_eq!(url.as_str(), "https://idp.example.com/authorize");
let from_str: AbsoluteUrl = "https://idp.example.com/authorize"
.parse()
.expect("FromStr");
assert_eq!(from_str, url);
}
#[test]
fn url_deserialize_rejects_invalid() {
assert!(serde_json::from_str::<AbsoluteUrl>("\"not a url\"").is_err());
assert!(serde_json::from_str::<AbsoluteUrl>("\"/relative/path\"").is_err());
assert!(serde_json::from_str::<AbsoluteUrl>("\"ftp://files.example.com\"").is_err());
}
#[test]
fn issuer_url_deserialize_rejects_invalid() {
assert!(serde_json::from_str::<IssuerUrl>("\"not a url\"").is_err());
assert!(serde_json::from_str::<IssuerUrl>("\"/relative/path\"").is_err());
assert!(serde_json::from_str::<IssuerUrl>("\"http://insecure.example.com\"").is_err());
}
#[test]
fn login_init_response_roundtrips() {
let resp = LoginInitResponse {
authorization_url: AbsoluteUrl::new("https://idp.example.com/authorize").unwrap(),
state: "opaque-state".to_owned(),
};
let value = serde_json::to_value(&resp).unwrap();
assert_eq!(
value["authorization_url"],
"https://idp.example.com/authorize"
);
assert_eq!(
serde_json::from_value::<LoginInitResponse>(value).unwrap(),
resp
);
}
#[test]
fn callback_query_retains_the_response_issuer() {
let json = serde_json::json!({
"code": "auth-code",
"state": "state-123",
"iss": "https://idp.example.com/realms/acme"
});
let query = serde_json::from_value::<CallbackQuery>(json).expect("callback parses");
assert_eq!(query.state, "state-123");
assert_eq!(
query.iss.as_deref(),
Some("https://idp.example.com/realms/acme")
);
let bare = serde_json::from_value::<CallbackQuery>(
serde_json::json!({ "code": "auth-code", "state": "state-123" }),
)
.expect("callback without iss parses");
assert_eq!(bare.iss, None);
assert!(
serde_json::from_value::<CallbackQuery>(serde_json::json!({ "code": "auth-code" }))
.is_err()
);
}
#[test]
fn callback_query_carries_exactly_one_provider_response() {
let parse = |json| {
serde_json::from_value::<CallbackQuery>(json)
.expect("callback parses")
.response()
};
assert_eq!(
parse(serde_json::json!({ "code": "auth-code", "state": "s" })).expect("code"),
ProviderResponse::Code(SecretBearer::new("auth-code".to_owned()))
);
assert_eq!(
parse(serde_json::json!({
"error": "access_denied",
"error_description": "<script>",
"state": "s"
}))
.expect("error"),
ProviderResponse::Error("access_denied".to_owned())
);
assert!(parse(serde_json::json!({ "state": "s" })).is_err());
assert!(
parse(serde_json::json!({ "code": "c", "error": "access_denied", "state": "s" }))
.is_err()
);
}
#[test]
fn callback_query_tolerates_unrelated_provider_parameters() {
let json = serde_json::json!({
"code": "auth-code",
"state": "state-123",
"session_state": "kc-session",
"iss": "https://idp.example.com/realms/acme"
});
let query = serde_json::from_value::<CallbackQuery>(json).expect("callback parses");
assert_eq!(query.state, "state-123");
assert_eq!(
query.iss.as_deref(),
Some("https://idp.example.com/realms/acme")
);
}
#[test]
fn sha256_hex_accepts_only_the_canonical_form() {
let digest = Sha256Hex::digest(b"flow");
let wire = digest.to_string();
assert_eq!(wire.len(), 64);
assert_eq!(Sha256Hex::new(&wire), Ok(digest));
assert!(Sha256Hex::new(&wire.to_uppercase()).is_err());
assert!(Sha256Hex::new(&wire[..63]).is_err());
assert!(Sha256Hex::new(&format!("{}g", &wire[..63])).is_err());
assert_eq!(
serde_json::to_value(digest).expect("serializes"),
serde_json::json!(wire)
);
}
}