Skip to main content

alibi_plugins/sso/
domain_confirmation.rs

1//! DNS-backed domain ownership confirmation.
2use super::{
3    AuthContext, AuthError, AuthResult, AuthSchema, EndpointResponse, JsValue, ServerEndpoint,
4    SessionRequest, SsoPlugin, Value, json,
5};
6use hickory_resolver::{
7    TokioResolver,
8    config::{NameServerConfig, ResolverConfig},
9    net::runtime::TokioRuntimeProvider,
10};
11impl SsoPlugin {
12    pub fn verify_domain_endpoint(provider_id: &str) -> AuthResult<ServerEndpoint<Value>> {
13        ServerEndpoint::new("sso", "verifyDomain").with_body(&json!({"providerId":provider_id}))
14    }
15    pub(super) async fn verify_domain<S: AuthSchema>(
16        &self,
17        input: &JsValue,
18        request: &impl SessionRequest,
19        ctx: &AuthContext<S>,
20    ) -> AuthResult<EndpointResponse> {
21        let provider = self.authorized_provider(input, request, ctx).await?;
22        if provider.domain_verified == Some(true) {
23            return Err(error(
24                409,
25                "DOMAIN_VERIFIED",
26                "Domain has already been verified",
27            ));
28        }
29        let identifier = self.domain_identifier(&provider.provider_id);
30        if identifier.encode_utf16().count() > 63 {
31            return Err(error(
32                400,
33                "IDENTIFIER_TOO_LONG",
34                "Verification identifier exceeds the DNS label limit of 63 characters",
35            ));
36        }
37        let proof = ctx
38            .verifications()
39            .find(&identifier)
40            .await?
41            .filter(|proof| {
42                proof
43                    .expires_at()
44                    .is_ok_and(|expiry| expiry > chrono::Utc::now())
45            })
46            .ok_or_else(|| {
47                error(
48                    404,
49                    "NO_PENDING_VERIFICATION",
50                    "No pending domain verification exists",
51                )
52            })?;
53        let resolver = if self.config.domain_verification_dns_servers.is_empty() {
54            TokioResolver::builder_tokio().map_err(|_| {
55                error(
56                    500,
57                    "DOMAIN_VERIFICATION_FAILED",
58                    "Unable to verify domain ownership due to server error",
59                )
60            })?
61        } else {
62            let servers = self
63                .config
64                .domain_verification_dns_servers
65                .iter()
66                .map(|address| {
67                    let mut server = NameServerConfig::udp(address.ip());
68                    for connection in &mut server.connections {
69                        connection.port = address.port();
70                    }
71                    server
72                })
73                .collect();
74            TokioResolver::builder_with_config(
75                ResolverConfig::from_name_servers(servers),
76                TokioRuntimeProvider::default(),
77            )
78        }
79        .build()
80        .map_err(|_| {
81            error(
82                500,
83                "DOMAIN_VERIFICATION_FAILED",
84                "Unable to verify domain ownership due to server error",
85            )
86        })?;
87        let domains = domains(&provider.domain)?;
88        let token = proof.value()?;
89        let record = format!("{}={token}", proof.identifier()?);
90        for domain in domains {
91            let answers = resolver.txt_lookup(format!("{identifier}.{domain}.")).await;
92            let matched = answers.is_ok_and(|answers| {
93                answers.answers().iter().any(|answer| {
94                    if let hickory_resolver::proto::rr::RData::TXT(txt) = &answer.data {
95                        let joined = txt
96                            .txt_data
97                            .iter()
98                            .flat_map(|chunk| chunk.iter().copied())
99                            .collect::<Vec<u8>>();
100                        String::from_utf8_lossy(&joined).trim() == record
101                            || String::from_utf8_lossy(&joined).trim() == token
102                    } else {
103                        false
104                    }
105                })
106            });
107            if !matched {
108                return Err(error(
109                    502,
110                    "DOMAIN_VERIFICATION_FAILED",
111                    &format!("Unable to verify domain ownership for {domain}. Try again later"),
112                ));
113            }
114        }
115        if !ctx
116            .database
117            .mark_sso_domain_verified(&provider.id, &provider.domain)
118            .await?
119        {
120            return Err(error(
121                409,
122                "SSO_PROVIDER_CHANGED",
123                "SSO provider changed while domain verification was in progress. Reload the provider and try again",
124            ));
125        }
126        let mut response = EndpointResponse::empty()
127            .with_status(204)
128            .with_header("Content-Type", "application/json");
129        if let Some(http) = response.http_response_mut() {
130            http.status = 204;
131        }
132        Ok(response)
133    }
134}
135fn error(status: u16, code: &str, message: &str) -> AuthError {
136    AuthError::Api {
137        status,
138        code: Some(code.into()),
139        message: message.into(),
140    }
141}
142fn domains(raw: &str) -> AuthResult<Vec<String>> {
143    let mut values = Vec::new();
144    for item in raw
145        .split(',')
146        .map(str::trim)
147        .filter(|item| !item.is_empty())
148    {
149        let url = if item.contains("://") {
150            url::Url::parse(item)
151        } else {
152            url::Url::parse(&format!("https://{item}"))
153        }
154        .map_err(|_| error(400, "INVALID_DOMAIN", "Invalid domain"))?;
155        let domain = url
156            .host_str()
157            .filter(|host| !host.is_empty())
158            .ok_or_else(|| error(400, "INVALID_DOMAIN", "Invalid domain"))?
159            .to_ascii_lowercase();
160        if !values.contains(&domain) {
161            values.push(domain);
162        }
163    }
164    if values.is_empty() {
165        return Err(error(400, "INVALID_DOMAIN", "Invalid domain"));
166    }
167    Ok(values)
168}