1pub mod middleware;
56pub mod store;
57
58use crate::guard::Identity;
59use crate::{base64, constant_time_eq, random, unix_now};
60use rustlavel_core::{Error, Json};
61use rustlavel_http::response::{IntoResponse, Response};
62use rustlavel_http::Status;
63use sha2::{Digest, Sha256};
64use std::time::Duration;
65
66pub use middleware::{RequireApiToken, RequireScope, TokenExt, bearer};
67pub use store::{MemoryTokenStore, SharedTokenStore, TokenFuture, TokenStore};
68
69pub const SEPARATOR: char = '|';
71
72pub const WILDCARD: &str = "*";
74
75pub const SECRET_BYTES: usize = 32;
81
82pub const MAX_PRESENTED_LENGTH: usize = 512;
88
89pub fn digest(secret: &str) -> [u8; 32] {
100 Sha256::digest(secret.as_bytes()).into()
101}
102
103pub fn split(presented: &str) -> Option<(&str, &str)> {
110 if presented.is_empty() || presented.len() > MAX_PRESENTED_LENGTH {
111 return None;
112 }
113
114 let (id, secret) = presented.split_once(SEPARATOR)?;
115 (!id.is_empty() && !secret.is_empty()).then_some((id, secret))
116}
117
118#[derive(Debug, Clone, Default, PartialEq, Eq)]
125pub struct Scopes(Vec<String>);
126
127impl Scopes {
128 pub fn new() -> Self {
130 Scopes::default()
131 }
132
133 pub fn any() -> Self {
138 Scopes(vec![WILDCARD.to_string()])
139 }
140
141 pub fn push(&mut self, scope: impl Into<String>) {
143 let scope = scope.into();
144 if !self.0.contains(&scope) {
145 self.0.push(scope);
146 }
147 }
148
149 pub fn with(mut self, scope: impl Into<String>) -> Self {
151 self.push(scope);
152 self
153 }
154
155 pub fn can(&self, scope: &str) -> bool {
162 self.0.iter().any(|held| held == WILDCARD || held == scope)
163 }
164
165 pub fn len(&self) -> usize {
166 self.0.len()
167 }
168
169 pub fn is_empty(&self) -> bool {
170 self.0.is_empty()
171 }
172
173 pub fn iter(&self) -> std::slice::Iter<'_, String> {
174 self.0.iter()
175 }
176
177 pub fn as_slice(&self) -> &[String] {
178 &self.0
179 }
180}
181
182impl<S: Into<String>> FromIterator<S> for Scopes {
183 fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
184 let mut scopes = Scopes::new();
185 for scope in iter {
186 scopes.push(scope);
187 }
188 scopes
189 }
190}
191
192impl std::fmt::Display for Scopes {
193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 f.write_str(&self.0.join(" "))
195 }
196}
197
198#[derive(Clone)]
204pub struct Token {
205 id: String,
206 identity: Identity,
207 name: String,
208 digest: [u8; 32],
209 scopes: Scopes,
210 created_at: u64,
211 expires_at: Option<u64>,
212 last_used_at: Option<u64>,
213}
214
215impl Token {
216 pub fn new(
222 id: impl Into<String>,
223 identity: Identity,
224 name: impl Into<String>,
225 digest: [u8; 32],
226 scopes: Scopes,
227 ) -> Self {
228 Token {
229 id: id.into(),
230 identity,
231 name: name.into(),
232 digest,
233 scopes,
234 created_at: unix_now(),
235 expires_at: None,
236 last_used_at: None,
237 }
238 }
239
240 pub fn with_id(mut self, id: impl Into<String>) -> Self {
246 self.id = id.into();
247 self
248 }
249
250 pub fn with_created_at(mut self, at: u64) -> Self {
251 self.created_at = at;
252 self
253 }
254
255 pub fn with_expires_at(mut self, at: Option<u64>) -> Self {
256 self.expires_at = at;
257 self
258 }
259
260 pub fn with_last_used_at(mut self, at: Option<u64>) -> Self {
261 self.last_used_at = at;
262 self
263 }
264
265 pub fn id(&self) -> &str {
266 &self.id
267 }
268
269 pub fn identity(&self) -> &Identity {
271 &self.identity
272 }
273
274 pub fn name(&self) -> &str {
278 &self.name
279 }
280
281 pub fn scopes(&self) -> &Scopes {
282 &self.scopes
283 }
284
285 pub fn digest(&self) -> &[u8; 32] {
291 &self.digest
292 }
293
294 pub fn created_at(&self) -> u64 {
295 self.created_at
296 }
297
298 pub fn expires_at(&self) -> Option<u64> {
299 self.expires_at
300 }
301
302 pub fn last_used_at(&self) -> Option<u64> {
303 self.last_used_at
304 }
305
306 pub fn set_last_used_at(&mut self, at: u64) {
307 self.last_used_at = Some(at);
308 }
309
310 pub fn can(&self, scope: &str) -> bool {
312 self.scopes.can(scope)
313 }
314
315 pub fn is_expired(&self) -> bool {
317 self.is_expired_at(unix_now())
318 }
319
320 pub fn is_expired_at(&self, now: u64) -> bool {
321 self.expires_at.is_some_and(|at| now >= at)
322 }
323
324 pub fn matches_secret(&self, secret: &str) -> bool {
329 constant_time_eq(&self.digest, &digest(secret))
330 }
331}
332
333impl std::fmt::Debug for Token {
339 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340 f.debug_struct("Token")
341 .field("id", &self.id)
342 .field("identity", &self.identity)
343 .field("name", &self.name)
344 .field("digest", &"<redacted>")
345 .field("scopes", &self.scopes)
346 .field("created_at", &self.created_at)
347 .field("expires_at", &self.expires_at)
348 .field("last_used_at", &self.last_used_at)
349 .finish()
350 }
351}
352
353pub struct PlainTextToken {
359 token: Token,
360 plain_text: String,
361}
362
363impl PlainTextToken {
364 pub fn plain_text(&self) -> &str {
366 &self.plain_text
367 }
368
369 pub fn into_plain_text(self) -> String {
371 self.plain_text
372 }
373
374 pub fn token(&self) -> &Token {
376 &self.token
377 }
378
379 pub fn into_token(self) -> Token {
380 self.token
381 }
382
383 pub fn into_parts(self) -> (Token, String) {
384 (self.token, self.plain_text)
385 }
386}
387
388impl std::fmt::Debug for PlainTextToken {
389 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390 f.debug_struct("PlainTextToken")
391 .field("token", &self.token)
392 .field("plain_text", &"<redacted>")
393 .finish()
394 }
395}
396
397#[derive(Debug, Clone)]
407pub struct NewToken {
408 identity: Identity,
409 name: String,
410 scopes: Scopes,
411 expires_at: Option<u64>,
412}
413
414impl NewToken {
415 pub fn new(identity: Identity, name: impl Into<String>) -> Self {
421 NewToken { identity, name: name.into(), scopes: Scopes::new(), expires_at: None }
422 }
423
424 pub fn scope(mut self, scope: impl Into<String>) -> Self {
425 self.scopes.push(scope);
426 self
427 }
428
429 pub fn scopes(mut self, scopes: Scopes) -> Self {
430 self.scopes = scopes;
431 self
432 }
433
434 pub fn any(self) -> Self {
436 self.scopes(Scopes::any())
437 }
438
439 pub fn expires_at(mut self, at: u64) -> Self {
441 self.expires_at = Some(at);
442 self
443 }
444
445 pub fn expires_in(self, after: Duration) -> Self {
447 self.expires_at(unix_now().saturating_add(after.as_secs()))
448 }
449
450 pub async fn issue(self, store: &dyn TokenStore) -> Result<PlainTextToken, Error> {
456 let secret = base64::encode_url(&random::bytes(SECRET_BYTES));
460
461 let id = random::hex(16);
462 let token = Token::new(id, self.identity, self.name, digest(&secret), self.scopes)
463 .with_expires_at(self.expires_at);
464
465 let stored = store.create(token).await?;
466 let plain_text = format!("{}{SEPARATOR}{secret}", stored.id());
467
468 Ok(PlainTextToken { token: stored, plain_text })
469 }
470}
471
472#[derive(Debug)]
479pub enum TokenError {
480 Missing,
482 Malformed,
484 Invalid,
486 Expired,
488 Unavailable(Error),
490}
491
492impl TokenError {
493 pub fn message(&self) -> &'static str {
495 match self {
496 TokenError::Missing => "Unauthenticated.",
497 TokenError::Malformed | TokenError::Invalid => "Invalid API token.",
499 TokenError::Expired => "This API token has expired.",
502 TokenError::Unavailable(_) => "Could not verify the API token.",
503 }
504 }
505
506 pub fn status(&self) -> Status {
507 match self {
508 TokenError::Unavailable(_) => Status(500),
509 _ => Status::UNAUTHORIZED,
510 }
511 }
512
513 pub fn challenge(&self) -> &'static str {
520 match self {
521 TokenError::Missing => "Bearer",
522 _ => r#"Bearer error="invalid_token""#,
523 }
524 }
525}
526
527impl std::fmt::Display for TokenError {
528 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
529 f.write_str(self.message())
530 }
531}
532
533impl std::error::Error for TokenError {}
534
535impl IntoResponse for TokenError {
536 fn into_response(self) -> Response {
537 if let TokenError::Unavailable(error) = self {
541 return error.into_response();
542 }
543
544 Response::new(self.status())
545 .with_header("www-authenticate", self.challenge())
546 .with_json(Json::object([("message", Json::from(self.message()))]))
547 }
548}
549
550pub async fn authenticate(store: &dyn TokenStore, presented: &str) -> Result<Token, TokenError> {
560 let (id, secret) = split(presented).ok_or(TokenError::Malformed)?;
561
562 let found = store.find(id).await.map_err(TokenError::Unavailable)?;
565 let Some(mut token) = found else { return Err(TokenError::Invalid) };
566
567 if !token.matches_secret(secret) {
568 return Err(TokenError::Invalid);
569 }
570 if token.is_expired() {
571 return Err(TokenError::Expired);
572 }
573
574 let now = unix_now();
575 if let Err(error) = store.touch(token.id(), now).await {
576 rustlavel_core::warn!("could not stamp last_used_at on an API token: {error}");
580 }
581 token.set_last_used_at(now);
582
583 Ok(token)
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 fn store() -> MemoryTokenStore {
591 MemoryTokenStore::new()
592 }
593
594 async fn issue(store: &MemoryTokenStore, id: &str, name: &str) -> PlainTextToken {
595 NewToken::new(Identity::new(id), name)
596 .any()
597 .issue(store)
598 .await
599 .expect("the memory store never fails")
600 }
601
602 #[tokio::test]
603 async fn an_issued_token_reads_as_its_id_and_a_secret() {
604 let store = store();
605 let issued = issue(&store, "41", "iPhone").await;
606
607 let (id, secret) = split(issued.plain_text()).expect("the credential should split");
608
609 assert_eq!(id, issued.token().id());
610 assert_eq!(issued.token().identity(), &Identity::new("41"));
611 assert_eq!(issued.token().name(), "iPhone");
612
613 assert_eq!(secret.len(), 43);
615 assert!(secret.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
616 }
617
618 #[tokio::test]
619 async fn two_tokens_issued_together_share_nothing() {
620 let store = store();
621 let first = issue(&store, "41", "iPhone").await;
622 let second = issue(&store, "41", "iPad").await;
623
624 assert_ne!(first.token().id(), second.token().id());
625 assert_ne!(first.plain_text(), second.plain_text());
626 }
627
628 #[tokio::test]
629 async fn the_plain_text_secret_is_never_kept_by_the_store() {
630 let store = store();
631 let issued = issue(&store, "41", "iPhone").await;
632 let (_, secret) = split(issued.plain_text()).unwrap();
633
634 let stored = store.find(issued.token().id()).await.unwrap().expect("it was just created");
635
636 assert_ne!(stored.digest().as_slice(), secret.as_bytes());
637 assert_eq!(stored.digest(), &digest(secret), "only the digest is kept");
638 assert!(
639 !store.dump().contains(secret),
640 "the secret was found somewhere in the store's contents"
641 );
642 }
643
644 #[tokio::test]
645 async fn no_type_here_prints_a_secret_in_its_debug_output() {
646 let store = store();
647 let issued = issue(&store, "41", "iPhone").await;
648 let (_, secret) = split(issued.plain_text()).unwrap();
649
650 let printed = format!(
651 "{:?} {:?} {:?} {:?}",
652 issued,
653 issued.token(),
654 store,
655 RequireApiToken::new(MemoryTokenStore::new()).scope("posts:read")
656 );
657
658 assert!(!printed.contains(secret), "a secret escaped into {printed}");
659 assert!(!printed.contains(issued.plain_text()));
660 assert!(printed.contains("<redacted>"));
661
662 assert!(printed.contains("iPhone"));
665 assert!(printed.contains(issued.token().id()));
666 }
667
668 #[tokio::test]
669 async fn a_valid_token_authenticates() {
670 let store = store();
671 let issued = issue(&store, "41", "iPhone").await;
672
673 let token = authenticate(&store, issued.plain_text()).await.expect("it should be accepted");
674
675 assert_eq!(token.id(), issued.token().id());
676 assert_eq!(token.identity().id(), "41");
677 }
678
679 #[tokio::test]
680 async fn authenticating_stamps_last_used_at() {
681 let store = store();
682 let issued = issue(&store, "41", "iPhone").await;
683
684 assert_eq!(issued.token().last_used_at(), None, "a token starts unused");
685
686 let token = authenticate(&store, issued.plain_text()).await.unwrap();
687 assert!(token.last_used_at().is_some());
688
689 let stored = store.find(token.id()).await.unwrap().unwrap();
690 assert_eq!(stored.last_used_at(), token.last_used_at(), "and it was persisted");
691 }
692
693 #[tokio::test]
694 async fn an_expired_token_is_rejected() {
695 let store = store();
696 let issued = NewToken::new(Identity::new("41"), "expired")
697 .any()
698 .expires_in(Duration::ZERO)
699 .issue(&store)
700 .await
701 .unwrap();
702
703 assert!(issued.token().is_expired());
704 let outcome = authenticate(&store, issued.plain_text()).await;
705 assert!(matches!(outcome, Err(TokenError::Expired)));
706
707 let forever = issue(&store, "41", "forever").await;
709 assert_eq!(forever.token().expires_at(), None);
710 assert!(!forever.token().is_expired());
711 assert!(authenticate(&store, forever.plain_text()).await.is_ok());
712 }
713
714 #[tokio::test]
715 async fn a_known_id_with_the_wrong_secret_is_rejected() {
716 let store = store();
717 let issued = issue(&store, "41", "iPhone").await;
718 let id = issued.token().id();
719
720 for guess in ["x", "not-the-secret", &"a".repeat(43)] {
723 let presented = format!("{id}{SEPARATOR}{guess}");
724 assert!(
725 matches!(authenticate(&store, &presented).await, Err(TokenError::Invalid)),
726 "the secret {guess:?} should not open token {id}"
727 );
728 }
729 }
730
731 #[tokio::test]
732 async fn one_users_token_id_cannot_be_paired_with_anothers_secret() {
733 let store = store();
734 let mine = issue(&store, "41", "mine").await;
735 let yours = issue(&store, "99", "yours").await;
736
737 let (_, my_secret) = split(mine.plain_text()).unwrap();
738 let stolen = format!("{}{SEPARATOR}{my_secret}", yours.token().id());
739
740 assert!(matches!(authenticate(&store, &stolen).await, Err(TokenError::Invalid)));
741
742 assert!(authenticate(&store, mine.plain_text()).await.is_ok());
745 assert!(authenticate(&store, yours.plain_text()).await.is_ok());
746 }
747
748 #[tokio::test]
749 async fn a_malformed_token_is_rejected_without_panicking() {
750 let store = store();
751 let huge = "a".repeat(100_000);
752 let long_id = format!("{}{SEPARATOR}secret", "a".repeat(MAX_PRESENTED_LENGTH));
753
754 for presented in [
755 "",
756 "no-pipe",
757 "|",
758 "a|",
759 "|b",
760 "||",
761 " ",
762 "\0",
763 "él|ok",
764 huge.as_str(),
765 long_id.as_str(),
766 ] {
767 let outcome = authenticate(&store, presented).await;
768 assert!(
769 matches!(outcome, Err(TokenError::Malformed) | Err(TokenError::Invalid)),
770 "{presented:?} was not rejected: {outcome:?}"
771 );
772 }
773 }
774
775 #[test]
776 fn splitting_accepts_only_the_two_part_shape() {
777 assert_eq!(split("7|secret"), Some(("7", "secret")));
778 assert_eq!(split("7|a|b"), Some(("7", "a|b")));
781
782 assert_eq!(split(""), None);
783 assert_eq!(split("|"), None);
784 assert_eq!(split("a|"), None);
785 assert_eq!(split("|b"), None);
786 assert_eq!(split("no-pipe"), None);
787 assert_eq!(split(&"a".repeat(MAX_PRESENTED_LENGTH + 1)), None);
788 }
789
790 #[tokio::test]
791 async fn revoking_a_token_takes_effect_immediately() {
792 let store = store();
793 let issued = issue(&store, "41", "iPhone").await;
794
795 assert!(authenticate(&store, issued.plain_text()).await.is_ok());
796
797 store.delete(issued.token().id()).await.unwrap();
798
799 let outcome = authenticate(&store, issued.plain_text()).await;
800 assert!(matches!(outcome, Err(TokenError::Invalid)));
801 }
802
803 #[tokio::test]
804 async fn revoking_every_token_for_a_user_leaves_the_others_alone() {
805 let store = store();
806 let phone = issue(&store, "41", "iPhone").await;
807 let laptop = issue(&store, "41", "laptop").await;
808 let someone_else = issue(&store, "99", "theirs").await;
809
810 store.delete_for_identity(&Identity::new("41")).await.unwrap();
811
812 for revoked in [&phone, &laptop] {
813 assert!(matches!(
814 authenticate(&store, revoked.plain_text()).await,
815 Err(TokenError::Invalid)
816 ));
817 }
818 assert!(authenticate(&store, someone_else.plain_text()).await.is_ok());
819 }
820
821 #[test]
822 fn can_matches_a_scope_exactly_and_not_by_prefix() {
823 let scopes = Scopes::new().with("posts:write").with("orders:read");
824
825 assert!(scopes.can("posts:write"));
826 assert!(scopes.can("orders:read"));
827
828 assert!(!scopes.can("posts:writeextra"), "a longer scope must not be granted");
829 assert!(!scopes.can("posts:writ"), "nor a shorter one");
830 assert!(!scopes.can("posts:write:all"));
831 assert!(!scopes.can("posts:read"), "read and write are not the same permission");
832 assert!(!scopes.can("POSTS:WRITE"), "the comparison is case sensitive");
833 assert!(!scopes.can(""));
834 }
835
836 #[test]
837 fn the_wildcard_grants_everything_and_an_empty_list_grants_nothing() {
838 let everything = Scopes::any();
839 assert!(everything.can("posts:write"));
840 assert!(everything.can("anything at all"));
841 assert!(everything.can(WILDCARD));
842
843 let nothing = Scopes::new();
844 assert!(nothing.is_empty());
845 assert!(!nothing.can("posts:read"));
846 assert!(!nothing.can(WILDCARD), "the wildcard is not held, so it is not granted");
847 }
848
849 #[test]
850 fn scopes_are_collected_deduplicated_and_kept_in_order() {
851 let scopes: Scopes = ["posts:read", "posts:write", "posts:read"].into_iter().collect();
852
853 assert_eq!(scopes.len(), 2);
854 assert_eq!(scopes.as_slice(), ["posts:read", "posts:write"]);
855 assert_eq!(scopes.to_string(), "posts:read posts:write");
856 assert_eq!(scopes.iter().count(), 2);
857 }
858
859 #[tokio::test]
860 async fn a_tokens_scopes_answer_can() {
861 let store = store();
862 let issued = NewToken::new(Identity::new("41"), "reader")
863 .scope("posts:read")
864 .issue(&store)
865 .await
866 .unwrap();
867
868 assert!(issued.token().can("posts:read"));
869 assert!(!issued.token().can("posts:write"));
870 assert_eq!(issued.token().scopes().as_slice(), ["posts:read"]);
871 }
872
873 #[tokio::test]
874 async fn a_new_token_grants_nothing_until_it_is_asked_to() {
875 let store = store();
876 let issued = NewToken::new(Identity::new("41"), "bare").issue(&store).await.unwrap();
877
878 assert!(issued.token().scopes().is_empty());
879 assert!(!issued.token().can("posts:read"));
880 assert!(!issued.token().can(WILDCARD));
881 }
882
883 #[test]
884 fn a_rejection_says_nothing_about_the_token_that_was_presented() {
885 assert_eq!(TokenError::Missing.message(), "Unauthenticated.");
886 assert_eq!(
887 TokenError::Malformed.message(),
888 TokenError::Invalid.message(),
889 "an unknown id and a wrong secret must be indistinguishable"
890 );
891
892 assert_eq!(TokenError::Missing.challenge(), "Bearer");
893 assert!(TokenError::Invalid.challenge().starts_with("Bearer"));
894 assert!(TokenError::Invalid.challenge().contains("invalid_token"));
895
896 assert_eq!(TokenError::Invalid.status(), Status::UNAUTHORIZED);
897 assert_eq!(TokenError::Unavailable(Error::msg("down")).status(), Status(500));
898 }
899
900 #[test]
901 fn digests_differ_for_different_secrets_and_are_stable_for_the_same_one() {
902 assert_eq!(digest("secret"), digest("secret"));
903 assert_ne!(digest("secret"), digest("secreu"));
904 assert_eq!(digest("").len(), 32);
905 }
906
907 #[test]
908 fn a_token_rebuilt_from_a_row_keeps_every_field() {
909 let token = Token::new("7", Identity::new("41"), "CI", digest("s"), Scopes::any())
910 .with_created_at(1_000)
911 .with_expires_at(Some(2_000))
912 .with_last_used_at(Some(1_500));
913
914 assert_eq!(token.id(), "7");
915 assert_eq!(token.name(), "CI");
916 assert_eq!(token.created_at(), 1_000);
917 assert_eq!(token.expires_at(), Some(2_000));
918 assert_eq!(token.last_used_at(), Some(1_500));
919
920 assert!(!token.is_expired_at(1_999));
921 assert!(token.is_expired_at(2_000), "the expiry second is already past");
922 assert!(token.is_expired_at(2_001));
923
924 assert!(token.matches_secret("s"));
925 assert!(!token.matches_secret("t"));
926 }
927}