authly_client/
lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
//! `authly-client` is an asynchronous Rust client handle for services interfacing with the authly service.

#![forbid(unsafe_code)]
#![warn(missing_docs)]

use std::{borrow::Cow, sync::Arc};

use authly_common::{
    access_token::AuthlyAccessTokenClaims,
    id::Eid,
    proto::service::{self as proto, authly_service_client::AuthlyServiceClient},
};
use http::header::{AUTHORIZATION, COOKIE};
use identity::Identity;
use pem::{EncodeConfig, Pem};
use rcgen::KeyPair;
use token::AccessToken;
use tonic::Request;

/// Client identity.
pub mod identity;

/// Token utilities.
pub mod token;

/// File path for detecting a valid kubernetes environment.
const K8S_SA_TOKENFILE: &str = "/var/run/secrets/kubernetes.io/serviceaccount/token";

/// Errors that can happen either during client configuration or while communicating over the network.
#[derive(thiserror::Error, Debug)]
pub enum Error {
    /// Error generating a private key.
    #[error("private key gen error")]
    PrivateKeyGen,

    /// A problem with the Authly Certificate Authority.
    #[error("Authly CA error: {0}")]
    AuthlyCA(&'static str),

    /// A problem with the client identity.
    #[error("identity error: {0}")]
    Identity(&'static str),

    /// Automatic environment inference did not work.
    #[error("environment not inferrable")]
    EnvironmentNotInferrable,

    /// A party was not authenticated or an operation was forbidden.
    #[error("unauthorized: {0}")]
    Unauthorized(anyhow::Error),

    /// A network problem.
    #[error("network error: {0}")]
    Network(anyhow::Error),

    /// An access token problem.
    #[error("invalid access token: {0}")]
    InvalidAccessToken(anyhow::Error),

    /// Other type of unclassified error.
    #[error("unclassified error: {0}")]
    Unclassified(anyhow::Error),
}

mod err {
    use super::*;

    pub fn unclassified(err: impl std::error::Error + Send + Sync + 'static) -> Error {
        Error::Unclassified(anyhow::Error::from(err))
    }

    pub fn tonic(err: tonic::Status) -> Error {
        match err.code() {
            tonic::Code::Unauthenticated => Error::Unauthorized(err.into()),
            tonic::Code::PermissionDenied => Error::Unauthorized(err.into()),
            _ => Error::Network(err.into()),
        }
    }

    pub fn network(err: impl std::error::Error + Send + Sync + 'static) -> Error {
        Error::Unauthorized(anyhow::Error::from(err))
    }

    pub fn unauthorized(err: impl std::error::Error + Send + Sync + 'static) -> Error {
        Error::Unauthorized(anyhow::Error::from(err))
    }
}

/// The authly client handle.
#[derive(Clone)]
pub struct Client {
    inner: Arc<ClientInner>,
}

struct ClientInner {
    service: AuthlyServiceClient<tonic::transport::Channel>,
    jwt_decoding_key: jsonwebtoken::DecodingKey,
}

/// A builder for configuring a [Client].
pub struct ClientBuilder {
    authly_local_ca: Option<Vec<u8>>,
    identity: Option<Identity>,
    jwt_decoding_key: Option<jsonwebtoken::DecodingKey>,
    url: Cow<'static, str>,
}

impl Client {
    /// Construct a new builder.
    pub fn builder() -> ClientBuilder {
        ClientBuilder {
            authly_local_ca: None,
            identity: None,
            jwt_decoding_key: None,
            url: Cow::Borrowed("https://authly"),
        }
    }

    /// The eid of this client.
    pub async fn eid(&self) -> Result<String, Error> {
        let mut service = self.inner.service.clone();
        let metadata = service
            .get_metadata(proto::Empty::default())
            .await
            .map_err(err::tonic)?
            .into_inner();

        Ok(metadata.eid)
    }

    /// The name of this client.
    pub async fn label(&self) -> Result<String, Error> {
        let mut service = self.inner.service.clone();
        let metadata = service
            .get_metadata(proto::Empty::default())
            .await
            .map_err(err::tonic)?
            .into_inner();

        Ok(metadata.label)
    }

    /// Exchange a session token for an access token suitable for evaluating access control.
    pub async fn get_access_token(&self, session_token: &str) -> Result<AccessToken, Error> {
        let mut service = self.inner.service.clone();
        let mut request = Request::new(proto::Empty::default());

        // TODO: This should use Authorization instead of Cookie?
        request.metadata_mut().append(
            COOKIE.as_str(),
            format!("session-cookie={session_token}")
                .parse()
                .map_err(err::unclassified)?,
        );

        let proto = service
            .get_access_token(request)
            .await
            .map_err(err::tonic)?
            .into_inner();

        let validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::ES256);
        let token_data = jsonwebtoken::decode::<AuthlyAccessTokenClaims>(
            &proto.token,
            &self.inner.jwt_decoding_key,
            &validation,
        )
        .map_err(|err| Error::InvalidAccessToken(err.into()))?;

        Ok(AccessToken {
            token: proto.token,
            claims: token_data.claims,
        })
    }

    /// Perform remote access control for the given resource attributes.
    ///
    /// Returns whether the access control request was successful.
    pub async fn remote_access_control(
        &self,
        resource_attributes: impl IntoIterator<Item = Eid>,
        access_token: Option<&str>,
    ) -> Result<bool, Error> {
        let mut service = self.inner.service.clone();
        let mut request = Request::new(proto::AccessControlRequest {
            resource_attributes: resource_attributes
                .into_iter()
                .map(|attr| attr.to_bytes().to_vec())
                .collect(),
        });
        if let Some(access_token) = access_token {
            request.metadata_mut().append(
                AUTHORIZATION.as_str(),
                format!("Bearer {access_token}")
                    .parse()
                    .map_err(err::unclassified)?,
            );
        }

        let access_control_response = service
            .access_control(request)
            .await
            .map_err(err::tonic)?
            .into_inner();

        Ok(access_control_response.outcome > 0)
    }
}

impl ClientBuilder {
    /// Infer the Authly client from the environment it runs in.
    pub async fn from_environment(mut self) -> Result<Self, Error> {
        let key_pair = KeyPair::generate().map_err(|_err| Error::PrivateKeyGen)?;

        if std::fs::exists(K8S_SA_TOKENFILE).unwrap_or(false) {
            let token = std::fs::read_to_string(K8S_SA_TOKENFILE).map_err(err::unclassified)?;
            let authly_local_ca = std::fs::read("/etc/authly/local-ca.crt")
                .map_err(|_| Error::AuthlyCA("not mounted"))?;

            let client_cert = reqwest::ClientBuilder::new()
                .add_root_certificate(
                    reqwest::Certificate::from_pem(&authly_local_ca).map_err(err::unclassified)?,
                )
                .build()
                .map_err(err::unclassified)?
                .post("https://authly-k8s/api/csr")
                .body(key_pair.public_key_der())
                .header(AUTHORIZATION, format!("Bearer {token}"))
                .send()
                .await
                .map_err(err::unauthorized)?
                .error_for_status()
                .map_err(err::unauthorized)?
                .bytes()
                .await
                .map_err(err::unclassified)?;
            let client_cert_pem = pem::encode_config(
                &Pem::new("CERTIFICATE", client_cert.to_vec()),
                EncodeConfig::new().set_line_ending(pem::LineEnding::LF),
            );

            self.jwt_decoding_key = Some(jwt_decoding_key_from_cert(&authly_local_ca)?);
            self.authly_local_ca = Some(authly_local_ca);
            self.identity = Some(Identity {
                cert_pem: client_cert_pem.into_bytes(),
                key_pem: key_pair.serialize_pem().into_bytes(),
            });

            Ok(self)
        } else {
            Err(Error::EnvironmentNotInferrable)
        }
    }

    /// Use the given CA certificate to verify the Authly server
    pub fn with_authly_local_ca_pem(mut self, ca: Vec<u8>) -> Result<Self, Error> {
        self.jwt_decoding_key = Some(jwt_decoding_key_from_cert(&ca)?);
        self.authly_local_ca = Some(ca);
        Ok(self)
    }

    /// Use a pre-certified client identity
    pub fn with_identity(mut self, identity: Identity) -> Self {
        self.identity = Some(identity);
        self
    }

    /// Override Authly URL (default is https://authly)
    pub fn with_url(mut self, url: impl Into<String>) -> Self {
        self.url = url.into().into();
        self
    }

    /// Connect to Authly
    pub async fn connect(self) -> Result<Client, Error> {
        let authly_local_ca = self
            .authly_local_ca
            .ok_or_else(|| Error::AuthlyCA("not provided"))?;
        let jwt_decoding_key = self
            .jwt_decoding_key
            .ok_or_else(|| Error::AuthlyCA("missing public key"))?;
        let identity = self
            .identity
            .ok_or_else(|| Error::Identity("not provided"))?;

        let tls_config = tonic::transport::ClientTlsConfig::new()
            .ca_certificate(tonic::transport::Certificate::from_pem(authly_local_ca))
            .identity(tonic::transport::Identity::from_pem(
                identity.cert_pem,
                identity.key_pem,
            ));

        let endpoint = tonic::transport::Endpoint::from_shared(self.url.to_string())
            .map_err(err::network)?
            .tls_config(tls_config)
            .map_err(err::network)?;

        Ok(Client {
            inner: Arc::new(ClientInner {
                service: AuthlyServiceClient::new(
                    endpoint.connect().await.map_err(err::unclassified)?,
                ),
                jwt_decoding_key,
            }),
        })
    }
}

fn jwt_decoding_key_from_cert(cert: &[u8]) -> Result<jsonwebtoken::DecodingKey, Error> {
    let pem = pem::parse(cert).map_err(|_| Error::AuthlyCA("invalid authly certificate"))?;

    let (_, x509_cert) = x509_parser::parse_x509_certificate(pem.contents())
        .map_err(|_| Error::AuthlyCA("invalid authly certificate"))?;

    let public_key = x509_cert.public_key();

    // Assume that EC is always used
    Ok(jsonwebtoken::DecodingKey::from_ec_der(
        &public_key.subject_public_key.data,
    ))
}