1use argon2::{
4 password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
5 Argon2,
6};
7use rand::Rng;
8use serde::{Deserialize, Serialize};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use crate::error::{CompatError, Result};
12use crate::user::UserId;
13
14pub type TokenId = u64;
16
17const TOKEN_PREFIX_LEN: usize = 8;
21const TOKEN_SECRET_LEN: usize = 32;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PersonalAccessToken {
26 pub id: TokenId,
28 pub user_id: UserId,
30 pub name: String,
32 pub token_hash: String,
34 pub token_prefix: String,
36 pub scopes: Vec<TokenScope>,
38 pub expires_at: Option<u64>,
40 pub last_used_at: Option<u64>,
42 pub created_at: u64,
44}
45
46impl PersonalAccessToken {
47 pub fn generate(
51 id: TokenId,
52 user_id: UserId,
53 name: String,
54 scopes: Vec<TokenScope>,
55 expires_at: Option<u64>,
56 ) -> Result<(Self, String)> {
57 let token_value = TokenValue::generate();
58 let plaintext = token_value.to_string();
59
60 let token_hash = hash_token_secret(&token_value.secret)?;
62
63 let now = SystemTime::now()
64 .duration_since(UNIX_EPOCH)
65 .unwrap()
66 .as_secs();
67
68 let token = Self {
69 id,
70 user_id,
71 name,
72 token_hash,
73 token_prefix: token_value.prefix,
74 scopes,
75 expires_at,
76 last_used_at: None,
77 created_at: now,
78 };
79
80 Ok((token, plaintext))
81 }
82
83 pub fn verify(&self, secret: &str) -> Result<()> {
85 verify_token_secret(secret, &self.token_hash)
86 }
87
88 pub fn is_expired(&self) -> bool {
90 if let Some(expires_at) = self.expires_at {
91 let now = SystemTime::now()
92 .duration_since(UNIX_EPOCH)
93 .unwrap()
94 .as_secs();
95 now >= expires_at
96 } else {
97 false
98 }
99 }
100
101 pub fn has_scope(&self, required: TokenScope) -> bool {
103 if self.scopes.contains(&TokenScope::Admin) {
105 return true;
106 }
107
108 if self.scopes.contains(&required) {
110 return true;
111 }
112
113 match required {
115 TokenScope::RepoRead => {
116 self.scopes.contains(&TokenScope::RepoWrite)
117 || self.scopes.contains(&TokenScope::RepoAdmin)
118 }
119 TokenScope::RepoWrite => self.scopes.contains(&TokenScope::RepoAdmin),
120 TokenScope::UserRead => self.scopes.contains(&TokenScope::UserWrite),
121 TokenScope::OrgRead => {
122 self.scopes.contains(&TokenScope::OrgWrite)
123 || self.scopes.contains(&TokenScope::OrgAdmin)
124 }
125 TokenScope::OrgWrite => self.scopes.contains(&TokenScope::OrgAdmin),
126 TokenScope::SshKeyRead => self.scopes.contains(&TokenScope::SshKeyWrite),
127 TokenScope::WorkflowRead => self.scopes.contains(&TokenScope::WorkflowWrite),
128 TokenScope::WebhookRead => self.scopes.contains(&TokenScope::WebhookWrite),
129 _ => false,
130 }
131 }
132
133 pub fn touch(&mut self) {
135 self.last_used_at = Some(
136 SystemTime::now()
137 .duration_since(UNIX_EPOCH)
138 .unwrap()
139 .as_secs(),
140 );
141 }
142
143 pub fn to_response(&self, plaintext: Option<&str>) -> TokenResponse {
145 TokenResponse {
146 id: self.id,
147 name: self.name.clone(),
148 scopes: self.scopes.clone(),
149 token_prefix: self.token_prefix.clone(),
150 token: plaintext.map(|s| s.to_string()),
151 expires_at: self.expires_at.map(format_timestamp),
152 last_used_at: self.last_used_at.map(format_timestamp),
153 created_at: format_timestamp(self.created_at),
154 }
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum TokenScope {
162 RepoRead,
165 RepoWrite,
167 RepoAdmin,
169 RepoDelete,
171
172 UserRead,
175 UserWrite,
177 UserEmail,
179
180 OrgRead,
183 OrgWrite,
185 OrgAdmin,
187
188 SshKeyRead,
191 SshKeyWrite,
193
194 WorkflowRead,
197 WorkflowWrite,
199
200 WebhookRead,
203 WebhookWrite,
205
206 Admin,
209}
210
211impl TokenScope {
212 pub fn all() -> Vec<Self> {
214 vec![
215 Self::RepoRead,
216 Self::RepoWrite,
217 Self::RepoAdmin,
218 Self::RepoDelete,
219 Self::UserRead,
220 Self::UserWrite,
221 Self::UserEmail,
222 Self::OrgRead,
223 Self::OrgWrite,
224 Self::OrgAdmin,
225 Self::SshKeyRead,
226 Self::SshKeyWrite,
227 Self::WorkflowRead,
228 Self::WorkflowWrite,
229 Self::WebhookRead,
230 Self::WebhookWrite,
231 Self::Admin,
232 ]
233 }
234
235 pub fn display_name(&self) -> &'static str {
237 match self {
238 Self::RepoRead => "repo:read",
239 Self::RepoWrite => "repo:write",
240 Self::RepoAdmin => "repo:admin",
241 Self::RepoDelete => "repo:delete",
242 Self::UserRead => "user:read",
243 Self::UserWrite => "user:write",
244 Self::UserEmail => "user:email",
245 Self::OrgRead => "org:read",
246 Self::OrgWrite => "org:write",
247 Self::OrgAdmin => "org:admin",
248 Self::SshKeyRead => "ssh_key:read",
249 Self::SshKeyWrite => "ssh_key:write",
250 Self::WorkflowRead => "workflow:read",
251 Self::WorkflowWrite => "workflow:write",
252 Self::WebhookRead => "webhook:read",
253 Self::WebhookWrite => "webhook:write",
254 Self::Admin => "admin",
255 }
256 }
257}
258
259#[derive(Debug, Clone)]
261pub struct TokenValue {
262 pub prefix: String,
264 pub secret: String,
266}
267
268impl TokenValue {
269 pub fn generate() -> Self {
271 let mut rng = rand::thread_rng();
272
273 let prefix: String = (0..TOKEN_PREFIX_LEN)
275 .map(|_| {
276 let idx = rng.gen_range(0..36);
277 if idx < 10 {
278 (b'0' + idx) as char
279 } else {
280 (b'a' + idx - 10) as char
281 }
282 })
283 .collect();
284
285 let secret: String = (0..TOKEN_SECRET_LEN)
287 .map(|_| {
288 let idx = rng.gen_range(0..62);
289 if idx < 10 {
290 (b'0' + idx) as char
291 } else if idx < 36 {
292 (b'a' + idx - 10) as char
293 } else {
294 (b'A' + idx - 36) as char
295 }
296 })
297 .collect();
298
299 Self { prefix, secret }
300 }
301
302 pub fn parse(token: &str) -> Result<Self> {
304 let parts: Vec<&str> = token.split('_').collect();
306 if parts.len() != 3 || parts[0] != "guts" {
307 return Err(CompatError::InvalidTokenFormat);
308 }
309
310 let prefix = parts[1];
311 let secret = parts[2];
312
313 if prefix.len() != TOKEN_PREFIX_LEN || secret.len() != TOKEN_SECRET_LEN {
314 return Err(CompatError::InvalidTokenFormat);
315 }
316
317 Ok(Self {
318 prefix: prefix.to_string(),
319 secret: secret.to_string(),
320 })
321 }
322}
323
324impl std::fmt::Display for TokenValue {
325 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326 write!(f, "guts_{}_{}", self.prefix, self.secret)
327 }
328}
329
330fn hash_token_secret(secret: &str) -> Result<String> {
332 let salt = SaltString::generate(&mut OsRng);
333 let argon2 = Argon2::default();
334
335 argon2
336 .hash_password(secret.as_bytes(), &salt)
337 .map(|hash| hash.to_string())
338 .map_err(|e| CompatError::Crypto(e.to_string()))
339}
340
341fn verify_token_secret(secret: &str, hash: &str) -> Result<()> {
343 let parsed_hash = PasswordHash::new(hash).map_err(|e| CompatError::Crypto(e.to_string()))?;
344
345 Argon2::default()
346 .verify_password(secret.as_bytes(), &parsed_hash)
347 .map_err(|_| CompatError::InvalidToken)
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct TokenResponse {
353 pub id: TokenId,
355 pub name: String,
357 pub scopes: Vec<TokenScope>,
359 pub token_prefix: String,
361 #[serde(skip_serializing_if = "Option::is_none")]
363 pub token: Option<String>,
364 #[serde(skip_serializing_if = "Option::is_none")]
366 pub expires_at: Option<String>,
367 #[serde(skip_serializing_if = "Option::is_none")]
369 pub last_used_at: Option<String>,
370 pub created_at: String,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct CreateTokenRequest {
377 pub name: String,
379 pub scopes: Vec<TokenScope>,
381 #[serde(default)]
383 pub expires_in_days: Option<u32>,
384}
385
386fn format_timestamp(timestamp: u64) -> String {
388 let secs_per_day = 86400;
389 let secs_per_hour = 3600;
390 let secs_per_min = 60;
391
392 let mut days = timestamp / secs_per_day;
393 let remaining = timestamp % secs_per_day;
394 let hours = remaining / secs_per_hour;
395 let remaining = remaining % secs_per_hour;
396 let minutes = remaining / secs_per_min;
397 let seconds = remaining % secs_per_min;
398
399 let mut year = 1970;
400 loop {
401 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
402 if days < days_in_year {
403 break;
404 }
405 days -= days_in_year;
406 year += 1;
407 }
408
409 let days_in_month = if is_leap_year(year) {
410 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
411 } else {
412 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
413 };
414
415 let mut month = 0;
416 for (i, &dim) in days_in_month.iter().enumerate() {
417 if days < dim as u64 {
418 month = i + 1;
419 break;
420 }
421 days -= dim as u64;
422 }
423 let day = days + 1;
424
425 format!(
426 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
427 year, month, day, hours, minutes, seconds
428 )
429}
430
431fn is_leap_year(year: u64) -> bool {
432 (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
440 fn test_token_generation() {
441 let (token, plaintext) =
442 PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::RepoRead], None)
443 .unwrap();
444
445 assert_eq!(token.id, 1);
446 assert_eq!(token.user_id, 1);
447 assert_eq!(token.name, "test");
448 assert!(plaintext.starts_with("guts_"));
449
450 let parsed = TokenValue::parse(&plaintext).unwrap();
452 assert!(token.verify(&parsed.secret).is_ok());
453 }
454
455 #[test]
456 fn test_token_value_format() {
457 let token = TokenValue::generate();
458 let s = token.to_string();
459
460 assert!(s.starts_with("guts_"));
461 let parts: Vec<&str> = s.split('_').collect();
462 assert_eq!(parts.len(), 3);
463 assert_eq!(parts[0], "guts");
464 assert_eq!(parts[1].len(), 8);
465 assert_eq!(parts[2].len(), 32);
466 }
467
468 #[test]
469 fn test_token_parse() {
470 let token = TokenValue::generate();
471 let s = token.to_string();
472 let parsed = TokenValue::parse(&s).unwrap();
473
474 assert_eq!(parsed.prefix, token.prefix);
475 assert_eq!(parsed.secret, token.secret);
476 }
477
478 #[test]
479 fn test_token_parse_invalid() {
480 assert!(TokenValue::parse("invalid").is_err());
481 assert!(TokenValue::parse("guts_short_secret").is_err());
482 assert!(TokenValue::parse("github_abc12345_12345678901234567890123456789012").is_err());
483 }
484
485 #[test]
486 fn test_token_parse_wrong_prefix() {
487 assert!(TokenValue::parse("github_abc12345_12345678901234567890123456789012").is_err());
489 assert!(TokenValue::parse("pat_12345678_12345678901234567890123456789012").is_err());
490 }
491
492 #[test]
493 fn test_token_parse_wrong_part_count() {
494 assert!(TokenValue::parse("guts_12345678901234567890123456789012").is_err());
496 assert!(TokenValue::parse("guts_abc12345_12345678901234567890123456789012_extra").is_err());
498 }
499
500 #[test]
501 fn test_token_parse_wrong_prefix_length() {
502 assert!(TokenValue::parse("guts_abc_12345678901234567890123456789012").is_err());
504 assert!(TokenValue::parse("guts_abc123456789_12345678901234567890123456789012").is_err());
506 }
507
508 #[test]
509 fn test_token_parse_wrong_secret_length() {
510 assert!(TokenValue::parse("guts_abc12345_short").is_err());
512 assert!(
514 TokenValue::parse("guts_abc12345_123456789012345678901234567890123456789012345")
515 .is_err()
516 );
517 }
518
519 #[test]
520 fn test_token_expiration() {
521 let now = SystemTime::now()
522 .duration_since(UNIX_EPOCH)
523 .unwrap()
524 .as_secs();
525
526 let (mut token, _) =
527 PersonalAccessToken::generate(1, 1, "test".into(), vec![], Some(now - 1)).unwrap();
528 assert!(token.is_expired());
529
530 token.expires_at = Some(now + 3600);
531 assert!(!token.is_expired());
532
533 token.expires_at = None;
534 assert!(!token.is_expired());
535 }
536
537 #[test]
538 fn test_token_expiration_boundary() {
539 let now = SystemTime::now()
540 .duration_since(UNIX_EPOCH)
541 .unwrap()
542 .as_secs();
543
544 let (token, _) =
546 PersonalAccessToken::generate(1, 1, "test".into(), vec![], Some(now)).unwrap();
547 assert!(token.is_expired());
548 }
549
550 #[test]
551 fn test_token_scope_hierarchy() {
552 let (token, _) =
553 PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::RepoAdmin], None)
554 .unwrap();
555
556 assert!(token.has_scope(TokenScope::RepoAdmin));
557 assert!(token.has_scope(TokenScope::RepoWrite));
558 assert!(token.has_scope(TokenScope::RepoRead));
559 assert!(!token.has_scope(TokenScope::OrgRead));
560 }
561
562 #[test]
563 fn test_token_scope_user_hierarchy() {
564 let (token, _) =
565 PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::UserWrite], None)
566 .unwrap();
567
568 assert!(token.has_scope(TokenScope::UserWrite));
569 assert!(token.has_scope(TokenScope::UserRead));
570 assert!(!token.has_scope(TokenScope::UserEmail));
571 }
572
573 #[test]
574 fn test_token_scope_org_hierarchy() {
575 let (token, _) =
576 PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::OrgAdmin], None)
577 .unwrap();
578
579 assert!(token.has_scope(TokenScope::OrgAdmin));
580 assert!(token.has_scope(TokenScope::OrgWrite));
581 assert!(token.has_scope(TokenScope::OrgRead));
582 }
583
584 #[test]
585 fn test_token_scope_ssh_hierarchy() {
586 let (token, _) =
587 PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::SshKeyWrite], None)
588 .unwrap();
589
590 assert!(token.has_scope(TokenScope::SshKeyWrite));
591 assert!(token.has_scope(TokenScope::SshKeyRead));
592 }
593
594 #[test]
595 fn test_token_scope_workflow_hierarchy() {
596 let (token, _) = PersonalAccessToken::generate(
597 1,
598 1,
599 "test".into(),
600 vec![TokenScope::WorkflowWrite],
601 None,
602 )
603 .unwrap();
604
605 assert!(token.has_scope(TokenScope::WorkflowWrite));
606 assert!(token.has_scope(TokenScope::WorkflowRead));
607 }
608
609 #[test]
610 fn test_token_scope_webhook_hierarchy() {
611 let (token, _) = PersonalAccessToken::generate(
612 1,
613 1,
614 "test".into(),
615 vec![TokenScope::WebhookWrite],
616 None,
617 )
618 .unwrap();
619
620 assert!(token.has_scope(TokenScope::WebhookWrite));
621 assert!(token.has_scope(TokenScope::WebhookRead));
622 }
623
624 #[test]
625 fn test_admin_scope_grants_all() {
626 let (token, _) =
627 PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::Admin], None)
628 .unwrap();
629
630 assert!(token.has_scope(TokenScope::RepoRead));
631 assert!(token.has_scope(TokenScope::UserWrite));
632 assert!(token.has_scope(TokenScope::OrgAdmin));
633 assert!(token.has_scope(TokenScope::Admin));
634 }
635
636 #[test]
637 fn test_scope_display_names() {
638 assert_eq!(TokenScope::RepoRead.display_name(), "repo:read");
639 assert_eq!(TokenScope::Admin.display_name(), "admin");
640 }
641
642 #[test]
643 fn test_all_scopes() {
644 let scopes = TokenScope::all();
645 assert_eq!(scopes.len(), 17);
646 assert!(scopes.contains(&TokenScope::RepoRead));
647 assert!(scopes.contains(&TokenScope::Admin));
648 }
649
650 #[test]
651 fn test_all_scope_display_names() {
652 let scopes = TokenScope::all();
654 let display_names: Vec<_> = scopes.iter().map(|s| s.display_name()).collect();
655 let unique: std::collections::HashSet<_> = display_names.iter().collect();
656 assert_eq!(unique.len(), scopes.len());
657 }
658
659 #[test]
660 fn test_token_verify_wrong_secret() {
661 let (token, _plaintext) =
662 PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::RepoRead], None)
663 .unwrap();
664
665 assert!(token.verify("wrongsecret").is_err());
667 assert!(token.verify("12345678901234567890123456789012").is_err());
668 }
669
670 #[test]
671 fn test_token_touch() {
672 let (mut token, _) =
673 PersonalAccessToken::generate(1, 1, "test".into(), vec![], None).unwrap();
674
675 assert!(token.last_used_at.is_none());
676 token.touch();
677 assert!(token.last_used_at.is_some());
678 }
679
680 #[test]
681 fn test_token_to_response() {
682 let (token, plaintext) = PersonalAccessToken::generate(
683 1,
684 1,
685 "My Token".into(),
686 vec![TokenScope::RepoRead],
687 None,
688 )
689 .unwrap();
690
691 let response = token.to_response(Some(&plaintext));
693 assert_eq!(response.id, 1);
694 assert_eq!(response.name, "My Token");
695 assert!(response.token.is_some());
696 assert_eq!(response.token.as_ref().unwrap(), &plaintext);
697
698 let response = token.to_response(None);
700 assert!(response.token.is_none());
701 }
702
703 #[test]
704 fn test_token_response_timestamps() {
705 let (token, _) = PersonalAccessToken::generate(1, 1, "test".into(), vec![], None).unwrap();
706
707 let response = token.to_response(None);
708 assert!(!response.created_at.is_empty());
709 assert!(response.created_at.contains('T'));
710 assert!(response.created_at.ends_with('Z'));
711 }
712
713 #[test]
714 fn test_token_uniqueness() {
715 let mut tokens = Vec::new();
717 for _ in 0..10 {
718 let token = TokenValue::generate();
719 tokens.push(token.to_string());
720 }
721
722 let unique: std::collections::HashSet<_> = tokens.iter().collect();
723 assert_eq!(unique.len(), tokens.len());
724 }
725
726 #[test]
727 fn test_token_prefix_format() {
728 for _ in 0..10 {
730 let token = TokenValue::generate();
731 assert!(token
733 .prefix
734 .chars()
735 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
736 assert_eq!(token.prefix.len(), 8);
737 }
738 }
739
740 #[test]
741 fn test_token_secret_format() {
742 for _ in 0..10 {
744 let token = TokenValue::generate();
745 assert!(token.secret.chars().all(|c| c.is_ascii_alphanumeric()));
747 assert_eq!(token.secret.len(), 32);
748 }
749 }
750
751 #[test]
752 fn test_token_scope_no_read_without_write() {
753 let (token, _) =
755 PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::RepoWrite], None)
756 .unwrap();
757
758 assert!(token.has_scope(TokenScope::RepoRead));
759 assert!(token.has_scope(TokenScope::RepoWrite));
760 assert!(!token.has_scope(TokenScope::RepoAdmin));
761 }
762
763 #[test]
764 fn test_token_scope_exact_match() {
765 let (token, _) =
767 PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::RepoRead], None)
768 .unwrap();
769
770 assert!(token.has_scope(TokenScope::RepoRead));
771 assert!(!token.has_scope(TokenScope::RepoWrite));
772 assert!(!token.has_scope(TokenScope::RepoAdmin));
773 }
774
775 #[test]
776 fn test_token_multiple_scopes() {
777 let (token, _) = PersonalAccessToken::generate(
778 1,
779 1,
780 "test".into(),
781 vec![TokenScope::RepoRead, TokenScope::UserRead],
782 None,
783 )
784 .unwrap();
785
786 assert!(token.has_scope(TokenScope::RepoRead));
787 assert!(token.has_scope(TokenScope::UserRead));
788 assert!(!token.has_scope(TokenScope::RepoWrite));
789 assert!(!token.has_scope(TokenScope::UserWrite));
790 }
791
792 #[test]
793 fn test_format_timestamp_epoch() {
794 let ts = format_timestamp(0);
795 assert_eq!(ts, "1970-01-01T00:00:00Z");
796 }
797
798 #[test]
799 fn test_format_timestamp_2024() {
800 let ts = format_timestamp(1704067200);
801 assert_eq!(ts, "2024-01-01T00:00:00Z");
802 }
803
804 #[test]
805 fn test_token_scope_delete() {
806 let (token, _) =
808 PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::RepoDelete], None)
809 .unwrap();
810
811 assert!(token.has_scope(TokenScope::RepoDelete));
812 assert!(!token.has_scope(TokenScope::RepoRead));
813 assert!(!token.has_scope(TokenScope::RepoWrite));
814 }
815
816 #[test]
817 fn test_token_scope_email() {
818 let (token, _) =
820 PersonalAccessToken::generate(1, 1, "test".into(), vec![TokenScope::UserEmail], None)
821 .unwrap();
822
823 assert!(token.has_scope(TokenScope::UserEmail));
824 assert!(!token.has_scope(TokenScope::UserRead));
825 }
826}
827
828#[cfg(test)]
829mod proptests {
830 use super::*;
831 use proptest::prelude::*;
832
833 proptest! {
834 #[test]
836 fn prop_token_generation_parseable(
837 id in 0u64..1000,
838 user_id in 0u64..1000,
839 name in "[a-zA-Z0-9 ]{1,50}"
840 ) {
841 let (_, plaintext) = PersonalAccessToken::generate(
842 id,
843 user_id,
844 name,
845 vec![TokenScope::RepoRead],
846 None,
847 ).unwrap();
848
849 let parsed = TokenValue::parse(&plaintext);
850 prop_assert!(parsed.is_ok());
851 }
852
853 #[test]
855 fn prop_token_verification_correct(
856 id in 0u64..100,
857 user_id in 0u64..100
858 ) {
859 let (token, plaintext) = PersonalAccessToken::generate(
860 id,
861 user_id,
862 "test".to_string(),
863 vec![TokenScope::RepoRead],
864 None,
865 ).unwrap();
866
867 let parsed = TokenValue::parse(&plaintext).unwrap();
868 let result = token.verify(&parsed.secret);
869 prop_assert!(result.is_ok());
870 }
871
872 #[test]
874 fn prop_token_verification_wrong_secret(
875 wrong_secret in "[a-zA-Z0-9]{32}"
876 ) {
877 let (token, plaintext) = PersonalAccessToken::generate(
878 1,
879 1,
880 "test".to_string(),
881 vec![TokenScope::RepoRead],
882 None,
883 ).unwrap();
884
885 let parsed = TokenValue::parse(&plaintext).unwrap();
886
887 if wrong_secret != parsed.secret {
889 let result = token.verify(&wrong_secret);
890 prop_assert!(result.is_err());
891 }
892 }
893
894 #[test]
896 fn prop_admin_grants_all(_seed in 0u32..100) {
897 let (token, _) = PersonalAccessToken::generate(
898 1,
899 1,
900 "test".to_string(),
901 vec![TokenScope::Admin],
902 None,
903 ).unwrap();
904
905 for scope in TokenScope::all() {
906 prop_assert!(token.has_scope(scope), "Admin should grant {:?}", scope);
907 }
908 }
909
910 #[test]
912 fn prop_token_prefix_format(_seed in 0u32..100) {
913 let token = TokenValue::generate();
914 prop_assert_eq!(token.prefix.len(), 8);
915 prop_assert!(token.prefix.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
916 }
917
918 #[test]
920 fn prop_token_secret_format(_seed in 0u32..100) {
921 let token = TokenValue::generate();
922 prop_assert_eq!(token.secret.len(), 32);
923 prop_assert!(token.secret.chars().all(|c| c.is_ascii_alphanumeric()));
924 }
925
926 #[test]
928 fn prop_token_uniqueness(_seed in 0u32..100) {
929 let token1 = TokenValue::generate();
930 let token2 = TokenValue::generate();
931 prop_assert!(token1.to_string() != token2.to_string());
933 }
934
935 #[test]
937 fn prop_invalid_token_rejected(s in ".*") {
938 if !s.starts_with("guts_") || s.split('_').count() != 3 {
940 let result = TokenValue::parse(&s);
941 prop_assert!(result.is_err());
942 }
943 }
944 }
945}