Skip to main content

lexe_api_core/
revocable_clients.rs

1//! Information about a client
2
3use std::{collections::HashMap, sync::RwLock};
4
5use anyhow::anyhow;
6use lexe_common::{
7    api::{
8        auth::LexeScope,
9        revocable_clients::{GetRevocableClientStatus, RevocableClientStatus},
10    },
11    time::TimestampMs,
12};
13use lexe_crypto::ed25519;
14#[cfg(any(test, feature = "test-utils"))]
15use proptest_derive::Arbitrary;
16use serde::{Deserialize, Serialize};
17
18use self::models::UpdateClientRequest;
19
20/// Request and response types for the revocable client endpoints.
21pub mod models;
22
23/// A locked [`RevocableClients`], newtyped so it can implement
24/// [`GetRevocableClientStatus`]. Share via `Arc<RevocableClientsHandle>`.
25#[derive(Debug)]
26pub struct RevocableClientsHandle(pub RwLock<RevocableClients>);
27
28impl GetRevocableClientStatus for RevocableClientsHandle {
29    fn get_client_status(
30        &self,
31        client_pk: &ed25519::PublicKey,
32        now: TimestampMs,
33    ) -> Option<RevocableClientStatus> {
34        let clients = self.0.read().unwrap();
35        let client = clients.clients.get(client_pk)?;
36        let status = if client.is_revoked {
37            RevocableClientStatus::Revoked
38        } else if client.is_expired_at(now) {
39            RevocableClientStatus::Expired
40        } else {
41            RevocableClientStatus::Valid
42        };
43        Some(status)
44    }
45}
46
47/// All revocable clients which have ever been created.
48///
49/// This struct must be persisted in a rollback-resistant data store.
50// We don't *really* need to persist revoked clients but might as well keep them
51// around for historical reference. We can prune them later if needed.
52#[derive(Clone, Debug, Default, Serialize, Deserialize)]
53pub struct RevocableClients {
54    pub clients: HashMap<ed25519::PublicKey, RevocableClient>,
55}
56
57impl RevocableClients {
58    /// A user shouldn't need more than 100 clients.
59    pub const MAX_LEN: usize = 100;
60
61    /// An iterator over all clients which are valid right now.
62    pub fn iter_valid(
63        &self,
64    ) -> impl Iterator<Item = (&ed25519::PublicKey, &RevocableClient)> {
65        self.iter_valid_at(TimestampMs::now())
66    }
67
68    /// An iterator over all clients which are valid at the given time.
69    pub fn iter_valid_at(
70        &self,
71        now: TimestampMs,
72    ) -> impl Iterator<Item = (&ed25519::PublicKey, &RevocableClient)> {
73        self.clients
74            .iter()
75            .filter(|(_k, v)| !v.is_revoked)
76            .filter(move |(_k, v)| !v.is_expired_at(now))
77    }
78}
79
80/// Information about a revocable client.
81/// Each client is issued a `RevocableClientCert` whose pubkey is saved here.
82#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
83#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
84pub struct RevocableClient {
85    /// The client's cert pubkey.
86    // TODO(max): In the future, bearer auth tokens could be issued to this pk.
87    pub pubkey: ed25519::PublicKey,
88    /// When we first issued the client cert and created this client.
89    pub created_at: TimestampMs,
90    /// The time after which the server will no longer accept this client.
91    /// [`None`] indicates that the client will never expire (use carefully!).
92    /// This expiration time can be extended at any time.
93    pub expires_at: Option<TimestampMs>,
94    /// Optional user-provided label for this client.
95    #[cfg_attr(
96        any(test, feature = "test-utils"),
97        proptest(strategy = "arb::any_label()")
98    )]
99    pub label: Option<String>,
100    /// The authorization scopes allowed for this client.
101    // TODO(max): This scope is currently ineffective.
102    pub scope: LexeScope,
103    /// Whether this client has been revoked. Revocation is permanent.
104    pub is_revoked: bool,
105    // TODO(phlip9): add "pausing" a client's access temporarily?
106}
107
108impl RevocableClient {
109    /// Limit label length to 64 bytes
110    pub const MAX_LABEL_LEN: usize = 64;
111
112    /// Whether the client is valid at a given time (not revoked, not expired).
113    #[must_use]
114    pub fn is_valid_at(&self, now: TimestampMs) -> bool {
115        !self.is_revoked && !self.is_expired_at(now)
116    }
117
118    /// Whether the client is expired at the given time.
119    #[must_use]
120    pub fn is_expired_at(&self, now: TimestampMs) -> bool {
121        if let Some(expiration) = self.expires_at
122            && now > expiration
123        {
124            return true;
125        }
126
127        false
128    }
129
130    /// Apply an update to this client, returning a copy with updates applied.
131    pub fn update(&self, req: UpdateClientRequest) -> anyhow::Result<Self> {
132        let UpdateClientRequest {
133            pubkey: req_pubkey,
134            expires_at: req_expires_at,
135            label: req_label,
136            scope: req_scope,
137            is_revoked: req_is_revoked,
138        } = req;
139
140        let mut out = self.clone();
141
142        if self.pubkey != req_pubkey {
143            debug_assert!(false);
144            return Err(anyhow!("Cannot update a different client"));
145        }
146
147        if let Some(expires_at) = req_expires_at {
148            // TODO(max): Maybe need some validation here
149            out.expires_at = expires_at;
150        }
151
152        if let Some(maybe_label) = req_label {
153            if let Some(label) = &maybe_label
154                && label.len() > Self::MAX_LABEL_LEN
155            {
156                return Err(anyhow!(
157                    "Label must not be longer than {} bytes",
158                    Self::MAX_LABEL_LEN,
159                ));
160            }
161            out.label = maybe_label;
162        }
163
164        if let Some(scope) = req_scope {
165            // TODO(max): Need some validation here; can't request broader
166            // scope, only some clients can call, etc.
167            out.scope = scope;
168        }
169
170        if let Some(revoke) = req_is_revoked {
171            if self.is_revoked && !revoke {
172                return Err(anyhow!("Cannot unrevoke a client"));
173            }
174            out.is_revoked = revoke;
175        }
176
177        Ok(out)
178    }
179}
180
181#[cfg(any(test, feature = "test-utils"))]
182mod arb {
183    use std::ops::RangeInclusive;
184
185    use proptest::{collection::vec, option, strategy::Strategy};
186
187    use super::*;
188
189    pub fn any_label() -> impl Strategy<Value = Option<String>> {
190        static RANGES: &[RangeInclusive<char>] =
191            &['0'..='9', 'A'..='Z', 'a'..='z'];
192        let any_alphanum_char = proptest::char::ranges(RANGES.into());
193        option::of(
194            vec(any_alphanum_char, 0..=RevocableClient::MAX_LABEL_LEN)
195                .prop_map(String::from_iter),
196        )
197    }
198}
199
200#[cfg(test)]
201mod test {
202    use lexe_common::root_seed::RootSeed;
203
204    use super::*;
205
206    #[test]
207    fn rev_client_ser_basic() {
208        let client1 = RevocableClient {
209            pubkey: *RootSeed::from_u64(1).derive_user_key_pair().public_key(),
210            created_at: TimestampMs::from_secs_u32(69),
211            expires_at: Some(TimestampMs::from_secs_u32(420)),
212            label: Some("deez".to_string()),
213            scope: LexeScope::All,
214            is_revoked: false,
215        };
216        let client_json = serde_json::to_string_pretty(&client1).unwrap();
217        // println!("{client_json}");
218        let client_json_snapshot = r#"{
219  "pubkey": "aa8e3e1a9bffdb073507f23474100619fdd4e392ef0ff1e89348252f287a06fc",
220  "created_at": 69000,
221  "expires_at": 420000,
222  "label": "deez",
223  "scope": "All",
224  "is_revoked": false
225}"#;
226        assert_eq!(client_json, client_json_snapshot);
227
228        let client2 =
229            serde_json::from_str::<RevocableClient>(&client_json).unwrap();
230        assert_eq!(client1, client2);
231    }
232}