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 use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
174 use argon2::Argon2;
175 let salt = SaltString::generate(&mut OsRng);
176 Argon2::default()
177 .hash_password(plaintext.as_bytes(), &salt)
178 .map(|h| h.to_string())
179 .map_err(|e| Error::Hash(e.to_string()))
180 }
181
182 pub fn verify_password(&self, plaintext: &str, hash: &str) -> bool {
184 use argon2::password_hash::{PasswordHash, PasswordVerifier};
185 use argon2::Argon2;
186 match PasswordHash::new(hash) {
187 Ok(parsed) => Argon2::default()
188 .verify_password(plaintext.as_bytes(), &parsed)
189 .is_ok(),
190 Err(_) => false,
191 }
192 }
193
194 pub fn issue_token(&self, user_id: Uuid) -> Result<String, Error> {
198 use jsonwebtoken::{encode, EncodingKey, Header};
199 let exp = chrono::Utc::now().timestamp() + self.session_ttl_secs;
200 let claims = Claims {
201 sub: user_id.to_string(),
202 exp,
203 };
204 encode(
205 &Header::default(),
206 &claims,
207 &EncodingKey::from_secret(&self.secret),
208 )
209 .map_err(|_| Error::Token)
210 }
211
212 pub fn verify_token(&self, token: &str) -> Result<Uuid, Error> {
214 use jsonwebtoken::{decode, DecodingKey, Validation};
215 let data = decode::<Claims>(
216 token,
217 &DecodingKey::from_secret(&self.secret),
218 &Validation::default(),
219 )
220 .map_err(|_| Error::Token)?;
221 Uuid::parse_str(&data.claims.sub).map_err(|_| Error::Token)
222 }
223
224 pub fn generate_api_key(&self) -> (String, String) {
229 use rand::RngCore;
230 let mut bytes = [0u8; 32];
231 rand::thread_rng().fill_bytes(&mut bytes);
232 let plaintext = format!("apik_{}", hex::encode(bytes));
233 let hash = Self::hash_api_key(&plaintext);
234 (plaintext, hash)
235 }
236
237 pub fn hash_api_key(plaintext: &str) -> String {
240 let mut hasher = Sha256::new();
241 hasher.update(plaintext.as_bytes());
242 hex::encode(hasher.finalize())
243 }
244
245 pub fn generate_link_token(prefix: &str) -> (String, String) {
260 use rand::RngCore;
261 let mut bytes = [0u8; 32];
262 rand::thread_rng().fill_bytes(&mut bytes);
263 let plaintext = format!("{prefix}_{}", hex::encode(bytes));
264 let hash = Self::hash_link_token(&plaintext);
265 (plaintext, hash)
266 }
267
268 pub fn hash_link_token(plaintext: &str) -> String {
271 Self::hash_api_key(plaintext)
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn password_roundtrip() {
281 let auth = Authenticator::new(b"secret".to_vec(), 3600);
282 let hash = auth.hash_password("hunter2").unwrap();
283 assert!(auth.verify_password("hunter2", &hash));
284 assert!(!auth.verify_password("wrong", &hash));
285 }
286
287 #[test]
288 fn token_roundtrip() {
289 let auth = Authenticator::new(b"secret".to_vec(), 3600);
290 let id = Uuid::new_v4();
291 let token = auth.issue_token(id).unwrap();
292 assert_eq!(auth.verify_token(&token).unwrap(), id);
293 }
294
295 #[test]
296 fn api_key_hash_is_deterministic() {
297 assert_eq!(
298 Authenticator::hash_api_key("apik_abc"),
299 Authenticator::hash_api_key("apik_abc")
300 );
301 }
302
303 #[test]
304 fn a_link_token_is_stored_only_as_its_hash() {
305 let (plaintext, hash) = Authenticator::generate_link_token("inv");
306 assert!(plaintext.starts_with("inv_"));
307 assert_ne!(plaintext, hash);
310 assert_eq!(Authenticator::hash_link_token(&plaintext), hash);
311
312 let (other, _) = Authenticator::generate_link_token("inv");
313 assert_ne!(plaintext, other);
314 }
315
316 #[test]
317 fn membership_lookup() {
318 let org = Uuid::new_v4();
319 let p = Principal {
320 user_id: Uuid::new_v4(),
321 organizations: vec![OrgMembership::new(org, Some("support".into()), [])],
322 };
323 assert!(p.is_member(org));
324 assert_eq!(p.role_in(org), Some("support"));
325 assert!(!p.is_member(Uuid::new_v4()));
326 assert_eq!(p.org_ids_with_role("support"), vec![org]);
327 assert!(p.org_ids_with_role("billing").is_empty());
328 }
329
330 #[test]
331 fn a_member_holds_every_role_granted_to_them() {
332 let org = Uuid::new_v4();
333 let p = Principal {
334 user_id: Uuid::new_v4(),
335 organizations: vec![OrgMembership::new(
336 org,
337 Some("support".into()),
338 ["billing".to_string()],
339 )],
340 };
341
342 assert_eq!(p.roles_in(org), ["support", "billing"]);
344 assert!(p.has_role_in(org, "support"));
345 assert!(p.has_role_in(org, "billing"));
346 assert!(!p.has_role_in(org, "admin"));
347
348 assert_eq!(p.role_in(org), Some("support"));
351 }
352
353 #[test]
354 fn an_admin_holds_every_role_without_being_granted_them() {
355 let org = Uuid::new_v4();
356 let other = Uuid::new_v4();
357 let p = Principal {
358 user_id: Uuid::new_v4(),
359 organizations: vec![
360 OrgMembership::new(org, Some("admin".into()), []),
361 OrgMembership::new(other, Some("support".into()), []),
362 ],
363 };
364
365 assert!(p.is_admin_of(org));
366 assert!(p.has_role_in(org, "billing"));
367 assert!(p.has_role_in(org, "anything-at-all"));
368 assert_eq!(p.org_ids_with_role("billing"), vec![org]);
369
370 assert!(!p.has_role_in(other, "billing"));
372 assert!(!p.is_admin_of(other));
373
374 assert_eq!(p.roles_in(org), ["admin"]);
378 }
379
380 #[test]
381 fn a_role_held_twice_is_still_one_role() {
382 let org = Uuid::new_v4();
383 let membership = OrgMembership::new(
386 org,
387 Some("admin".into()),
388 ["admin".to_string(), "billing".to_string(), String::new()],
389 );
390 assert_eq!(membership.roles, ["admin", "billing"]);
391 }
392
393 #[test]
394 fn a_member_with_no_role_holds_none() {
395 let org = Uuid::new_v4();
396 let membership = OrgMembership::new(org, None, []);
397 assert!(membership.roles.is_empty());
398 assert!(!membership.is_admin());
399 assert!(!membership.has_role("member"));
400 }
401}