1use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17use uuid::Uuid;
18
19#[derive(thiserror::Error, Debug)]
21pub enum Error {
22 #[error("password hashing failed: {0}")]
23 Hash(String),
24 #[error("invalid or expired token")]
25 Token,
26}
27
28pub const ADMIN_ROLE: &str = "admin";
36
37#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct OrgMembership {
40 pub org_id: Uuid,
41 pub role: Option<String>,
44 pub roles: Vec<String>,
47}
48
49impl OrgMembership {
50 pub fn new(
53 org_id: Uuid,
54 role: Option<String>,
55 extra: impl IntoIterator<Item = String>,
56 ) -> Self {
57 let mut roles: Vec<String> = Vec::new();
58 for candidate in role.clone().into_iter().chain(extra) {
59 if !candidate.is_empty() && !roles.contains(&candidate) {
60 roles.push(candidate);
61 }
62 }
63 OrgMembership {
64 org_id,
65 role,
66 roles,
67 }
68 }
69
70 pub fn has_role(&self, role: &str) -> bool {
72 self.roles.iter().any(|held| held == role) || self.is_admin()
73 }
74
75 pub fn is_admin(&self) -> bool {
77 self.roles.iter().any(|held| held == ADMIN_ROLE)
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct Principal {
84 pub user_id: Uuid,
85 pub organizations: Vec<OrgMembership>,
88}
89
90impl Principal {
91 pub fn membership(&self, org: Uuid) -> Option<&OrgMembership> {
93 self.organizations.iter().find(|m| m.org_id == org)
94 }
95
96 pub fn is_member(&self, org: Uuid) -> bool {
98 self.membership(org).is_some()
99 }
100
101 pub fn role_in(&self, org: Uuid) -> Option<&str> {
107 self.membership(org).and_then(|m| m.role.as_deref())
108 }
109
110 pub fn roles_in(&self, org: Uuid) -> &[String] {
112 self.membership(org)
113 .map(|m| m.roles.as_slice())
114 .unwrap_or(&[])
115 }
116
117 pub fn has_role_in(&self, org: Uuid, role: &str) -> bool {
120 self.membership(org).is_some_and(|m| m.has_role(role))
121 }
122
123 pub fn is_admin_of(&self, org: Uuid) -> bool {
125 self.membership(org).is_some_and(OrgMembership::is_admin)
126 }
127
128 pub fn org_ids(&self) -> Vec<Uuid> {
130 self.organizations.iter().map(|m| m.org_id).collect()
131 }
132
133 pub fn org_ids_with_role(&self, role: &str) -> Vec<Uuid> {
136 self.organizations
137 .iter()
138 .filter(|m| m.has_role(role))
139 .map(|m| m.org_id)
140 .collect()
141 }
142}
143
144#[derive(Debug, Serialize, Deserialize)]
147struct Claims {
148 sub: String,
150 exp: i64,
152}
153
154#[derive(Clone)]
156pub struct Authenticator {
157 secret: Vec<u8>,
158 session_ttl_secs: i64,
159}
160
161impl Authenticator {
162 pub fn new(secret: impl Into<Vec<u8>>, session_ttl_secs: u64) -> Self {
163 Authenticator {
164 secret: secret.into(),
165 session_ttl_secs: session_ttl_secs as i64,
166 }
167 }
168
169 pub fn hash_password(&self, plaintext: &str) -> Result<String, Error> {
173 Self::hash_password_with_argon2(plaintext)
174 }
175
176 pub fn hash_password_with_argon2(plaintext: &str) -> Result<String, Error> {
183 use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
184 use argon2::Argon2;
185 let salt = SaltString::generate(&mut OsRng);
186 Argon2::default()
187 .hash_password(plaintext.as_bytes(), &salt)
188 .map(|h| h.to_string())
189 .map_err(|e| Error::Hash(e.to_string()))
190 }
191
192 pub fn verify_password(&self, plaintext: &str, hash: &str) -> bool {
194 use argon2::password_hash::{PasswordHash, PasswordVerifier};
195 use argon2::Argon2;
196 match PasswordHash::new(hash) {
197 Ok(parsed) => Argon2::default()
198 .verify_password(plaintext.as_bytes(), &parsed)
199 .is_ok(),
200 Err(_) => false,
201 }
202 }
203
204 pub fn issue_token(&self, user_id: Uuid) -> Result<String, Error> {
208 use jsonwebtoken::{encode, EncodingKey, Header};
209 let exp = chrono::Utc::now().timestamp() + self.session_ttl_secs;
210 let claims = Claims {
211 sub: user_id.to_string(),
212 exp,
213 };
214 encode(
215 &Header::default(),
216 &claims,
217 &EncodingKey::from_secret(&self.secret),
218 )
219 .map_err(|_| Error::Token)
220 }
221
222 pub fn verify_token(&self, token: &str) -> Result<Uuid, Error> {
224 use jsonwebtoken::{decode, DecodingKey, Validation};
225 let data = decode::<Claims>(
226 token,
227 &DecodingKey::from_secret(&self.secret),
228 &Validation::default(),
229 )
230 .map_err(|_| Error::Token)?;
231 Uuid::parse_str(&data.claims.sub).map_err(|_| Error::Token)
232 }
233
234 pub fn generate_api_key(&self) -> (String, String) {
239 use rand::RngCore;
240 let mut bytes = [0u8; 32];
241 rand::thread_rng().fill_bytes(&mut bytes);
242 let plaintext = format!("apik_{}", hex::encode(bytes));
243 let hash = Self::hash_api_key(&plaintext);
244 (plaintext, hash)
245 }
246
247 pub fn hash_api_key(plaintext: &str) -> String {
250 let mut hasher = Sha256::new();
251 hasher.update(plaintext.as_bytes());
252 hex::encode(hasher.finalize())
253 }
254
255 pub fn generate_link_token(prefix: &str) -> (String, String) {
270 use rand::RngCore;
271 let mut bytes = [0u8; 32];
272 rand::thread_rng().fill_bytes(&mut bytes);
273 let plaintext = format!("{prefix}_{}", hex::encode(bytes));
274 let hash = Self::hash_link_token(&plaintext);
275 (plaintext, hash)
276 }
277
278 pub fn hash_link_token(plaintext: &str) -> String {
281 Self::hash_api_key(plaintext)
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn password_roundtrip() {
291 let auth = Authenticator::new(b"secret".to_vec(), 3600);
292 let hash = auth.hash_password("hunter2").unwrap();
293 assert!(auth.verify_password("hunter2", &hash));
294 assert!(!auth.verify_password("wrong", &hash));
295 }
296
297 #[test]
298 fn token_roundtrip() {
299 let auth = Authenticator::new(b"secret".to_vec(), 3600);
300 let id = Uuid::new_v4();
301 let token = auth.issue_token(id).unwrap();
302 assert_eq!(auth.verify_token(&token).unwrap(), id);
303 }
304
305 #[test]
306 fn api_key_hash_is_deterministic() {
307 assert_eq!(
308 Authenticator::hash_api_key("apik_abc"),
309 Authenticator::hash_api_key("apik_abc")
310 );
311 }
312
313 #[test]
314 fn a_link_token_is_stored_only_as_its_hash() {
315 let (plaintext, hash) = Authenticator::generate_link_token("inv");
316 assert!(plaintext.starts_with("inv_"));
317 assert_ne!(plaintext, hash);
320 assert_eq!(Authenticator::hash_link_token(&plaintext), hash);
321
322 let (other, _) = Authenticator::generate_link_token("inv");
323 assert_ne!(plaintext, other);
324 }
325
326 #[test]
327 fn membership_lookup() {
328 let org = Uuid::new_v4();
329 let p = Principal {
330 user_id: Uuid::new_v4(),
331 organizations: vec![OrgMembership::new(org, Some("support".into()), [])],
332 };
333 assert!(p.is_member(org));
334 assert_eq!(p.role_in(org), Some("support"));
335 assert!(!p.is_member(Uuid::new_v4()));
336 assert_eq!(p.org_ids_with_role("support"), vec![org]);
337 assert!(p.org_ids_with_role("billing").is_empty());
338 }
339
340 #[test]
341 fn a_member_holds_every_role_granted_to_them() {
342 let org = Uuid::new_v4();
343 let p = Principal {
344 user_id: Uuid::new_v4(),
345 organizations: vec![OrgMembership::new(
346 org,
347 Some("support".into()),
348 ["billing".to_string()],
349 )],
350 };
351
352 assert_eq!(p.roles_in(org), ["support", "billing"]);
354 assert!(p.has_role_in(org, "support"));
355 assert!(p.has_role_in(org, "billing"));
356 assert!(!p.has_role_in(org, "admin"));
357
358 assert_eq!(p.role_in(org), Some("support"));
361 }
362
363 #[test]
364 fn an_admin_holds_every_role_without_being_granted_them() {
365 let org = Uuid::new_v4();
366 let other = Uuid::new_v4();
367 let p = Principal {
368 user_id: Uuid::new_v4(),
369 organizations: vec![
370 OrgMembership::new(org, Some("admin".into()), []),
371 OrgMembership::new(other, Some("support".into()), []),
372 ],
373 };
374
375 assert!(p.is_admin_of(org));
376 assert!(p.has_role_in(org, "billing"));
377 assert!(p.has_role_in(org, "anything-at-all"));
378 assert_eq!(p.org_ids_with_role("billing"), vec![org]);
379
380 assert!(!p.has_role_in(other, "billing"));
382 assert!(!p.is_admin_of(other));
383
384 assert_eq!(p.roles_in(org), ["admin"]);
388 }
389
390 #[test]
391 fn a_role_held_twice_is_still_one_role() {
392 let org = Uuid::new_v4();
393 let membership = OrgMembership::new(
396 org,
397 Some("admin".into()),
398 ["admin".to_string(), "billing".to_string(), String::new()],
399 );
400 assert_eq!(membership.roles, ["admin", "billing"]);
401 }
402
403 #[test]
404 fn a_member_with_no_role_holds_none() {
405 let org = Uuid::new_v4();
406 let membership = OrgMembership::new(org, None, []);
407 assert!(membership.roles.is_empty());
408 assert!(!membership.is_admin());
409 assert!(!membership.has_role("member"));
410 }
411}