use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::DataTenantId;
use crate::auth::{AbsoluteUrl, ClaimMappingPayload, IssuerTokenPolicy, IssuerUrl, SecretBearer};
use crate::error::WyrdError;
const PRIVATE_KEY_JWT: &str = "PrivateKeyJwt";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub enum HumanConnectionState {
Candidate,
Active,
Inactive,
}
impl HumanConnectionState {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Candidate => "Candidate",
Self::Active => "Active",
Self::Inactive => "Inactive",
}
}
pub fn parse(value: &str) -> Result<Self, WyrdError> {
match value {
"Candidate" => Ok(Self::Candidate),
"Active" => Ok(Self::Active),
"Inactive" => Ok(Self::Inactive),
other => Err(WyrdError::Internal {
message: "stored human connection state is unknown".to_owned(),
details: serde_json::json!({ "state": other }),
}),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub enum HumanClientAuth {
SecretBasic,
SecretPost,
Public,
}
impl HumanClientAuth {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::SecretBasic => "SecretBasic",
Self::SecretPost => "SecretPost",
Self::Public => "Public",
}
}
pub fn parse(value: &str) -> Result<Self, WyrdError> {
match value {
"SecretBasic" => Ok(Self::SecretBasic),
"SecretPost" => Ok(Self::SecretPost),
"Public" => Ok(Self::Public),
other => Err(WyrdError::Internal {
message: "stored human connection client auth is unknown".to_owned(),
details: serde_json::json!({ "client_auth": other }),
}),
}
}
#[must_use]
pub fn requires_secret(self) -> bool {
!matches!(self, Self::Public)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct HumanConnectionView {
pub id: Uuid,
pub tenant_id: DataTenantId,
pub revision: u64,
pub state: HumanConnectionState,
pub issuer: String,
pub client_id: String,
pub client_auth: HumanClientAuth,
pub claim_mapping: ClaimMappingPayload,
pub group_role_map: HashMap<String, Vec<String>>,
pub jwks_ttl_secs: u64,
pub tested_revision: Option<u64>,
pub tested_until: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub callback_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct HumanConnectionsResponse {
pub active: Option<HumanConnectionView>,
pub candidate: Option<HumanConnectionView>,
pub callback_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct ConnectionInput {
pub issuer: IssuerUrl,
pub client_id: String,
pub client_auth: HumanClientAuth,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_secret: Option<SecretBearer>,
pub claim_mapping: ClaimMappingPayload,
#[serde(default)]
pub group_role_map: HashMap<String, Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_revision: Option<u64>,
}
pub const HUMAN_SUBJECT_CLAIM: &str = "sub";
impl ConnectionInput {
pub fn from_slice(body: &[u8]) -> Result<Self, WyrdError> {
let body: Value = serde_json::from_slice(body).map_err(invalid_input)?;
if body.get("client_auth").and_then(Value::as_str) == Some(PRIVATE_KEY_JWT) {
return Err(unsupported_client_auth());
}
let input: Self = serde_json::from_value(body).map_err(invalid_input)?;
input.validate()?;
Ok(input)
}
fn validate(&self) -> Result<(), WyrdError> {
if self.client_id.trim().is_empty() {
return Err(validation("client_id must not be empty", "client_id"));
}
if self.claim_mapping.subject != HUMAN_SUBJECT_CLAIM {
return Err(validation(
"claim_mapping.subject must be \"sub\": a human connection identifies people by \
the provider's OIDC subject",
"claim_mapping",
));
}
match (self.client_auth.requires_secret(), &self.client_secret) {
(true, Some(secret)) if !secret.expose().is_empty() => Ok(()),
(true, _) => Err(validation(
"client_secret is required and must not be empty for SecretBasic and SecretPost",
"client_secret",
)),
(false, Some(_)) => Err(validation(
"client_secret must be omitted for a Public client",
"client_secret",
)),
(false, None) => Ok(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct ConnectionTestRequest {
pub expected_revision: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct ConnectionTestResponse {
pub authorization_url: AbsoluteUrl,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct ConnectionActivate {
pub expected_revision: u64,
pub recovery_api_key: SecretBearer,
}
pub fn refuse_human_trusted_issuer(principal_kind: IssuerTokenPolicy) -> Result<(), WyrdError> {
if principal_kind == IssuerTokenPolicy::Human {
return Err(WyrdError::HumanConnectionRequired {
message: "trusted issuers are workload-only; configure human login through the \
tenant OIDC connection API (/v1/identity/oidc)"
.to_owned(),
details: serde_json::json!({ "principal_kind": "human" }),
});
}
Ok(())
}
fn unsupported_client_auth() -> WyrdError {
WyrdError::UnsupportedClientAuth {
message: "PrivateKeyJwt client authentication is not supported for tenant connections"
.to_owned(),
details: serde_json::json!({ "field": "client_auth" }),
}
}
fn invalid_input(error: serde_json::Error) -> WyrdError {
WyrdError::Validation {
message: format!("connection input is invalid: {error}"),
details: serde_json::json!({}),
}
}
fn validation(message: &str, field: &str) -> WyrdError {
WyrdError::Validation {
message: message.to_owned(),
details: serde_json::json!({ "field": field }),
}
}
#[cfg(test)]
mod tests {
use super::{ConnectionInput, HumanClientAuth, refuse_human_trusted_issuer};
use crate::auth::IssuerTokenPolicy;
use crate::error::WyrdError;
fn body() -> serde_json::Value {
serde_json::json!({
"issuer": "https://idp.example.com",
"client_id": "wyrd",
"client_auth": "Public",
"claim_mapping": { "subject": "sub" },
})
}
fn bytes(body: &serde_json::Value) -> Vec<u8> {
serde_json::to_vec(body).expect("test body encodes")
}
#[test]
fn private_key_jwt_precedes_validation_and_malformed_bytes_are_invalid() {
let raw = serde_json::json!({ "client_auth": "PrivateKeyJwt", "unknown": true });
assert!(matches!(
ConnectionInput::from_slice(&bytes(&raw)),
Err(WyrdError::UnsupportedClientAuth { .. })
));
for malformed in [&b"{not json"[..], b"", b"[]"] {
assert!(
matches!(
ConnectionInput::from_slice(malformed),
Err(WyrdError::Validation { .. })
),
"{malformed:?} is a validation refusal"
);
}
}
#[test]
fn private_key_jwt_is_refused_with_its_own_code() {
let mut raw = body();
raw["client_auth"] = serde_json::json!("PrivateKeyJwt");
assert!(matches!(
ConnectionInput::from_slice(&bytes(&raw)),
Err(WyrdError::UnsupportedClientAuth { .. })
));
}
#[test]
fn secret_presence_follows_the_method() {
let input = ConnectionInput::from_slice(&bytes(&body())).expect("public client decodes");
assert_eq!(input.client_auth, HumanClientAuth::Public);
let mut missing = body();
missing["client_auth"] = serde_json::json!("SecretPost");
assert!(matches!(
ConnectionInput::from_slice(&bytes(&missing)),
Err(WyrdError::Validation { .. })
));
for secret in ["s3cret", ""] {
let mut extra = body();
extra["client_secret"] = serde_json::json!(secret);
assert!(
matches!(
ConnectionInput::from_slice(&bytes(&extra)),
Err(WyrdError::Validation { .. })
),
"Public refuses a supplied secret {secret:?}"
);
}
for method in ["SecretBasic", "SecretPost"] {
let mut empty = body();
empty["client_auth"] = serde_json::json!(method);
empty["client_secret"] = serde_json::json!("");
assert!(
matches!(
ConnectionInput::from_slice(&bytes(&empty)),
Err(WyrdError::Validation { .. })
),
"{method} refuses an empty secret"
);
let mut valid = body();
valid["client_auth"] = serde_json::json!(method);
valid["client_secret"] = serde_json::json!("s3cret");
let input =
ConnectionInput::from_slice(&bytes(&valid)).expect("a nonempty secret decodes");
assert!(input.client_auth.requires_secret());
}
}
#[test]
fn human_subject_must_be_exactly_sub() {
let mut valid = body();
valid["claim_mapping"] =
serde_json::json!({ "subject": "sub", "email": "email", "groups": "roles" });
ConnectionInput::from_slice(&bytes(&valid)).expect("sub with email and groups decodes");
for subject in ["email", "preferred_username", "sub.id", " sub", ""] {
let mut other = body();
other["claim_mapping"] = serde_json::json!({ "subject": subject });
assert!(
matches!(
ConnectionInput::from_slice(&bytes(&other)),
Err(WyrdError::Validation { .. })
),
"subject {subject:?} is refused"
);
}
}
#[test]
fn schema_does_not_offer_private_key_jwt() {
let schema = schemars::schema_for!(ConnectionInput);
let text = serde_json::to_string(&schema).expect("schema serializes");
assert!(!text.contains("PrivateKeyJwt"));
}
#[test]
fn human_trusted_issuer_is_refused() {
assert!(refuse_human_trusted_issuer(IssuerTokenPolicy::Workload).is_ok());
assert!(matches!(
refuse_human_trusted_issuer(IssuerTokenPolicy::Human),
Err(WyrdError::HumanConnectionRequired { .. })
));
}
}