authly_client/
builder.rs

1use std::{borrow::Cow, sync::Arc};
2
3use arc_swap::ArcSwap;
4use http::header::AUTHORIZATION;
5use pem::{EncodeConfig, Pem};
6use rcgen::KeyPair;
7
8use crate::{
9    background_worker::{spawn_background_worker, WorkerSenders},
10    connection::{make_connection, ConnectionParams, ReconfigureStrategy},
11    error, get_configuration,
12    identity::{parse_identity_data, Identity},
13    Client, ClientState, Error, IDENTITY_PATH, K8S_SA_TOKENFILE_PATH, LOCAL_CA_CERT_PATH,
14};
15
16#[derive(Clone, Copy)]
17pub(crate) enum Inference {
18    Inferred,
19    Manual,
20}
21
22/// A builder for configuring a [Client].
23pub struct ClientBuilder {
24    pub(crate) inner: ConnectionParamsBuilder,
25}
26
27impl ClientBuilder {
28    /// Infer the Authly client from the environment it runs in.
29    pub async fn from_environment(mut self) -> Result<Self, Error> {
30        self.inner.infer().await?;
31        Ok(self)
32    }
33
34    /// Use the given CA certificate to verify the Authly server
35    pub fn with_authly_local_ca_pem(mut self, ca: Vec<u8>) -> Result<Self, Error> {
36        self.inner.inference = Inference::Manual;
37        self.inner.authly_local_ca = Some(ca);
38        Ok(self)
39    }
40
41    /// Use a pre-certified client identity
42    pub fn with_identity(mut self, identity: Identity) -> Self {
43        self.inner.inference = Inference::Manual;
44        self.inner.identity = Some(identity);
45        self
46    }
47
48    /// Override Authly URL (default is https://authly)
49    pub fn with_url(mut self, url: impl Into<String>) -> Self {
50        self.inner.url = url.into().into();
51        self
52    }
53
54    /// Get the current Authly local CA of the builder as a PEM-encoded byte buffer.
55    pub fn get_local_ca_pem(&self) -> Result<Cow<[u8]>, Error> {
56        self.inner
57            .authly_local_ca
58            .as_ref()
59            .map(|ca| Cow::Borrowed(ca.as_slice()))
60            .ok_or_else(|| Error::AuthlyCA("unconfigured"))
61    }
62
63    /// Get the current Authly identity of the builder as a PEM-encoded byte buffer.
64    pub fn get_identity_pem(&self) -> Result<Cow<[u8]>, Error> {
65        self.inner
66            .identity
67            .as_ref()
68            .ok_or_else(|| Error::Identity("unconfigured"))?
69            .pem()
70    }
71
72    /// Connect to Authly
73    pub async fn connect(self) -> Result<Client, Error> {
74        let params = self.inner.try_into_connection_params()?;
75        let connection = make_connection(params.clone()).await?;
76        let (reconfigured_tx, reconfigured_rx) = tokio::sync::watch::channel(params.clone());
77        let (metadata_invalidated_tx, metadata_invalidated_rx) = tokio::sync::watch::channel(());
78
79        let reconfigure = match params.inference {
80            Inference::Inferred => ReconfigureStrategy::ReInfer {
81                url: params.url.clone(),
82            },
83            Inference::Manual => ReconfigureStrategy::Params(params),
84        };
85
86        let configuration = get_configuration(connection.authly_service.clone()).await?;
87
88        let (closed_tx, closed_rx) = tokio::sync::watch::channel(());
89        let state = Arc::new(ClientState {
90            conn: ArcSwap::new(Arc::new(connection)),
91            reconfigure,
92            reconfigured_rx,
93            metadata_invalidated_rx,
94            closed_tx,
95            configuration: ArcSwap::new(Arc::new(configuration)),
96        });
97
98        spawn_background_worker(
99            state.clone(),
100            WorkerSenders {
101                reconfigured_tx,
102                metadata_invalidated_tx,
103            },
104            closed_rx,
105        )
106        .await?;
107
108        let client = Client { state };
109
110        Ok(client)
111    }
112}
113
114#[derive(Clone)]
115pub(crate) struct ConnectionParamsBuilder {
116    pub inference: Inference,
117    pub url: Cow<'static, str>,
118    pub authly_local_ca: Option<Vec<u8>>,
119    pub identity: Option<Identity>,
120}
121
122impl ConnectionParamsBuilder {
123    pub(crate) fn new(url: Cow<'static, str>) -> Self {
124        Self {
125            inference: Inference::Manual,
126            url,
127            authly_local_ca: None,
128            identity: None,
129        }
130    }
131
132    /// Try to infer the parameters from the environment
133    pub(crate) async fn infer(&mut self) -> Result<(), Error> {
134        self.inference = Inference::Inferred;
135        let authly_local_ca =
136            std::fs::read(LOCAL_CA_CERT_PATH).map_err(|_| Error::AuthlyCAmissingInEtc)?;
137
138        if std::fs::exists(IDENTITY_PATH).unwrap_or(false) {
139            self.authly_local_ca = Some(authly_local_ca);
140            self.identity = Some(
141                Identity::from_pem(std::fs::read(IDENTITY_PATH).unwrap())
142                    .map_err(|_| Error::Identity("invalid identity"))?,
143            );
144
145            Ok(())
146        } else if std::fs::exists(K8S_SA_TOKENFILE_PATH).unwrap_or(false) {
147            let key_pair = KeyPair::generate().map_err(|_err| Error::PrivateKeyGen)?;
148            let token =
149                std::fs::read_to_string(K8S_SA_TOKENFILE_PATH).map_err(error::unclassified)?;
150
151            let client_cert = reqwest::ClientBuilder::new()
152                .add_root_certificate(
153                    reqwest::Certificate::from_pem(&authly_local_ca)
154                        .map_err(error::unclassified)?,
155                )
156                .build()
157                .map_err(error::unclassified)?
158                .post("https://authly-k8s/api/v0/authenticate")
159                .header(AUTHORIZATION, format!("Bearer {token}"))
160                .body(key_pair.public_key_der())
161                .send()
162                .await
163                .map_err(error::unauthorized)?
164                .error_for_status()
165                .map_err(error::unauthorized)?
166                .bytes()
167                .await
168                .map_err(error::unclassified)?;
169            let client_cert_pem = pem::encode_config(
170                &Pem::new("CERTIFICATE", client_cert),
171                EncodeConfig::new().set_line_ending(pem::LineEnding::LF),
172            );
173
174            self.authly_local_ca = Some(authly_local_ca);
175            self.identity = Some(Identity {
176                cert_pem: client_cert_pem.into_bytes(),
177                key_pem: key_pair.serialize_pem().into_bytes(),
178            });
179
180            Ok(())
181        } else {
182            Err(Error::EnvironmentNotInferrable)
183        }
184    }
185
186    pub fn try_into_connection_params(self) -> Result<Arc<ConnectionParams>, Error> {
187        let authly_local_ca = self
188            .authly_local_ca
189            .clone()
190            .ok_or_else(|| Error::AuthlyCA("unconfigured"))?;
191        let identity = self
192            .identity
193            .ok_or_else(|| Error::Identity("unconfigured"))?;
194
195        let jwt_decoding_key = jwt_decoding_key_from_cert(&authly_local_ca)?;
196        let identity_data = parse_identity_data(&identity.cert_pem)?;
197
198        Ok(Arc::new(ConnectionParams {
199            inference: self.inference,
200            url: self.url,
201            authly_local_ca,
202            jwt_decoding_key,
203            identity,
204            entity_id: identity_data.entity_id,
205        }))
206    }
207}
208
209pub fn jwt_decoding_key_from_cert(cert: &[u8]) -> Result<jsonwebtoken::DecodingKey, Error> {
210    let pem = pem::parse(cert).map_err(|_| Error::AuthlyCA("invalid authly certificate"))?;
211
212    let (_, x509_cert) = x509_parser::parse_x509_certificate(pem.contents())
213        .map_err(|_| Error::AuthlyCA("invalid authly certificate"))?;
214
215    let public_key = x509_cert.public_key();
216
217    // Assume that EC is always used
218    Ok(jsonwebtoken::DecodingKey::from_ec_der(
219        &public_key.subject_public_key.data,
220    ))
221}