1use std::collections::HashMap;
4
5use anyhow::anyhow;
6use lexe_crypto::ed25519;
7use lexe_serde::{
8 base64_or_bytes,
9 optopt::{self, none},
10};
11#[cfg(any(test, feature = "test-utils"))]
12use proptest_derive::Arbitrary;
13use serde::{Deserialize, Serialize};
14
15use crate::{
16 api::{
17 auth::{BearerAuthToken, LexeScope},
18 user::UserPk,
19 },
20 time::TimestampMs,
21};
22
23#[derive(Clone, Debug, Default, Serialize, Deserialize)]
29pub struct RevocableClients {
30 pub clients: HashMap<ed25519::PublicKey, RevocableClient>,
31}
32
33impl RevocableClients {
34 pub const MAX_LEN: usize = 100;
36
37 pub fn iter_valid(
39 &self,
40 ) -> impl Iterator<Item = (&ed25519::PublicKey, &RevocableClient)> {
41 self.iter_valid_at(TimestampMs::now())
42 }
43
44 pub fn iter_valid_at(
46 &self,
47 now: TimestampMs,
48 ) -> impl Iterator<Item = (&ed25519::PublicKey, &RevocableClient)> {
49 self.clients
50 .iter()
51 .filter(|(_k, v)| !v.is_revoked)
52 .filter(move |(_k, v)| !v.is_expired_at(now))
53 }
54}
55
56#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59#[cfg_attr(any(test, feature = "test-utils"), derive(Arbitrary))]
60pub struct RevocableClient {
61 pub pubkey: ed25519::PublicKey,
64 pub created_at: TimestampMs,
66 pub expires_at: Option<TimestampMs>,
70 #[cfg_attr(
72 any(test, feature = "test-utils"),
73 proptest(strategy = "arb::any_label()")
74 )]
75 pub label: Option<String>,
76 pub scope: LexeScope,
79 pub is_revoked: bool,
81 }
83
84impl RevocableClient {
85 pub const MAX_LABEL_LEN: usize = 64;
87
88 #[must_use]
90 pub fn is_valid_at(&self, now: TimestampMs) -> bool {
91 !self.is_revoked && !self.is_expired_at(now)
92 }
93
94 #[must_use]
96 pub fn is_expired_at(&self, now: TimestampMs) -> bool {
97 if let Some(expiration) = self.expires_at
98 && now > expiration
99 {
100 return true;
101 }
102
103 false
104 }
105}
106
107#[derive(Clone, Debug, Serialize, Deserialize)]
109#[cfg_attr(any(test, feature = "test-utils"), derive(Eq, PartialEq, Arbitrary))]
110pub struct GetRevocableClients {
111 pub valid_only: bool,
113}
114
115#[derive(Clone, Debug, Serialize, Deserialize)]
117pub struct CreateRevocableClientRequest {
118 pub expires_at: Option<TimestampMs>,
121 pub label: Option<String>,
123 pub scope: LexeScope,
125}
126
127#[derive(Clone, Debug, Serialize, Deserialize)]
129pub struct CreateRevocableClientResponse {
130 #[serde(skip_serializing_if = "Option::is_none")]
133 pub user_pk: Option<UserPk>,
134
135 pub pubkey: ed25519::PublicKey,
137
138 pub created_at: TimestampMs,
140
141 #[serde(with = "base64_or_bytes")]
146 pub eph_ca_cert_der: Vec<u8>,
147
148 #[serde(with = "base64_or_bytes")]
151 pub rev_client_cert_der: Vec<u8>,
152
153 #[serde(with = "base64_or_bytes")]
155 pub rev_client_cert_key_der: Vec<u8>,
156
157 #[serde(skip_serializing_if = "Option::is_none")]
160 pub gateway_proxy_token: Option<BearerAuthToken>,
161}
162
163#[derive(Serialize, Deserialize)]
172#[cfg_attr(test, derive(Debug, Eq, PartialEq, Arbitrary))]
173pub struct UpdateClientRequest {
174 pub pubkey: ed25519::PublicKey,
176
177 #[serde(default, skip_serializing_if = "none", with = "optopt")]
179 pub expires_at: Option<Option<TimestampMs>>,
180
181 #[serde(default, skip_serializing_if = "none", with = "optopt")]
183 #[cfg_attr(test, proptest(strategy = "arb::any_label_update()"))]
184 pub label: Option<Option<String>>,
185
186 #[serde(skip_serializing_if = "none")]
188 pub scope: Option<LexeScope>,
189
190 #[serde(skip_serializing_if = "none")]
193 pub is_revoked: Option<bool>,
194}
195
196#[derive(Serialize, Deserialize)]
198pub struct UpdateClientResponse {
199 pub client: RevocableClient,
200}
201
202impl RevocableClient {
203 pub fn update(&self, req: UpdateClientRequest) -> anyhow::Result<Self> {
205 let UpdateClientRequest {
206 pubkey: req_pubkey,
207 expires_at: req_expires_at,
208 label: req_label,
209 scope: req_scope,
210 is_revoked: req_is_revoked,
211 } = req;
212
213 let mut out = self.clone();
214
215 if self.pubkey != req_pubkey {
216 debug_assert!(false);
217 return Err(anyhow!("Cannot update a different client"));
218 }
219
220 if let Some(expires_at) = req_expires_at {
221 out.expires_at = expires_at;
223 }
224
225 if let Some(maybe_label) = req_label {
226 if let Some(label) = &maybe_label
227 && label.len() > Self::MAX_LABEL_LEN
228 {
229 return Err(anyhow!(
230 "Label must not be longer than {} bytes",
231 Self::MAX_LABEL_LEN,
232 ));
233 }
234 out.label = maybe_label;
235 }
236
237 if let Some(scope) = req_scope {
238 out.scope = scope;
241 }
242
243 if let Some(revoke) = req_is_revoked {
244 if self.is_revoked && !revoke {
245 return Err(anyhow!("Cannot unrevoke a client"));
246 }
247 out.is_revoked = revoke;
248 }
249
250 Ok(out)
251 }
252}
253
254#[cfg(any(test, feature = "test-utils"))]
255mod arb {
256 use std::ops::RangeInclusive;
257
258 use proptest::{collection::vec, option, strategy::Strategy};
259
260 use super::*;
261 use crate::test_utils::arbitrary;
262
263 pub fn any_label() -> impl Strategy<Value = Option<String>> {
264 static RANGES: &[RangeInclusive<char>] =
265 &['0'..='9', 'A'..='Z', 'a'..='z'];
266 let any_alphanum_char = proptest::char::ranges(RANGES.into());
267 option::of(
268 vec(any_alphanum_char, 0..=RevocableClient::MAX_LABEL_LEN)
269 .prop_map(String::from_iter),
270 )
271 }
272
273 #[allow(dead_code)]
274 pub fn any_label_update() -> impl Strategy<Value = Option<Option<String>>> {
275 option::of(arbitrary::any_option_simple_string())
276 }
277}
278
279#[cfg(test)]
280mod test {
281 use super::*;
282 use crate::{root_seed::RootSeed, test_utils::roundtrip};
283
284 #[test]
285 fn rev_client_ser_basic() {
286 let client1 = RevocableClient {
287 pubkey: *RootSeed::from_u64(1).derive_user_key_pair().public_key(),
288 created_at: TimestampMs::from_secs_u32(69),
289 expires_at: Some(TimestampMs::from_secs_u32(420)),
290 label: Some("deez".to_string()),
291 scope: LexeScope::All,
292 is_revoked: false,
293 };
294 let client_json = serde_json::to_string_pretty(&client1).unwrap();
295 let client_json_snapshot = r#"{
297 "pubkey": "aa8e3e1a9bffdb073507f23474100619fdd4e392ef0ff1e89348252f287a06fc",
298 "created_at": 69000,
299 "expires_at": 420000,
300 "label": "deez",
301 "scope": "All",
302 "is_revoked": false
303}"#;
304 assert_eq!(client_json, client_json_snapshot);
305
306 let client2 =
307 serde_json::from_str::<RevocableClient>(&client_json).unwrap();
308 assert_eq!(client1, client2);
309 }
310
311 #[test]
312 fn test_update_request_serde() {
313 roundtrip::json_string_roundtrip_proptest::<UpdateClientRequest>();
314 }
315
316 #[test]
317 fn test_get_revocable_clients_serde() {
318 roundtrip::query_string_roundtrip_proptest::<GetRevocableClients>();
319 }
320}