webgates_sessions/tokens.rs
1//! Token primitives for session issuance and renewal.
2//!
3//! This module defines the framework-agnostic token boundary types used by the
4//! sessions crate. It intentionally avoids HTTP and cookie concerns so login,
5//! renewal, and logout workflows can compose these values in higher layers.
6
7use crate::errors::TokenError;
8use rand::{Rng, distr::Alphanumeric, rng};
9use sha2::{Digest, Sha256};
10use webgates_codecs::Codec;
11
12/// Short-lived authentication token returned to clients after login or renewal.
13///
14/// The concrete auth-token format is owned by the issuing implementation
15/// (for example a JWT), while this type provides a stable boundary for the
16/// session domain.
17///
18/// # Examples
19///
20/// ```
21/// use webgates_sessions::tokens::AuthToken;
22///
23/// let token = AuthToken::new("eyJhbGciOiJIUzI1NiJ9.payload.sig").unwrap();
24/// assert_eq!(token.as_str(), "eyJhbGciOiJIUzI1NiJ9.payload.sig");
25///
26/// let raw = token.into_inner();
27/// assert_eq!(raw, "eyJhbGciOiJIUzI1NiJ9.payload.sig");
28/// ```
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct AuthToken {
31 value: String,
32}
33
34impl AuthToken {
35 /// Creates a new auth token wrapper from a raw token string.
36 ///
37 /// # Errors
38 ///
39 /// Returns [`TokenError::InvalidTokenMaterial`] when `value` is empty or
40 /// contains only whitespace.
41 pub fn new(value: impl Into<String>) -> Result<Self, TokenError> {
42 let value = value.into();
43
44 if value.trim().is_empty() {
45 return Err(TokenError::InvalidTokenMaterial);
46 }
47
48 Ok(Self { value })
49 }
50
51 /// Returns the raw auth token string.
52 #[must_use]
53 pub fn as_str(&self) -> &str {
54 &self.value
55 }
56
57 /// Consumes the wrapper and returns the raw auth token string.
58 #[must_use]
59 pub fn into_inner(self) -> String {
60 self.value
61 }
62}
63
64/// Plaintext refresh token presented by a client.
65///
66/// Callers should treat this value as sensitive. Persistence layers are expected
67/// to store only a derived hash rather than this plaintext value.
68///
69/// # Examples
70///
71/// ```
72/// use webgates_sessions::tokens::RefreshTokenPlaintext;
73///
74/// let token = RefreshTokenPlaintext::new("a".repeat(64)).unwrap();
75/// assert_eq!(token.as_str().len(), 64);
76///
77/// // Empty or whitespace-only strings are rejected.
78/// assert!(RefreshTokenPlaintext::new(" ").is_err());
79/// ```
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct RefreshTokenPlaintext {
82 value: String,
83}
84
85impl RefreshTokenPlaintext {
86 /// Creates a new plaintext refresh token wrapper from a raw token string.
87 ///
88 /// # Errors
89 ///
90 /// Returns [`TokenError::InvalidTokenMaterial`] when `value` is empty or
91 /// contains only whitespace.
92 pub fn new(value: impl Into<String>) -> Result<Self, TokenError> {
93 let value = value.into();
94
95 if value.trim().is_empty() {
96 return Err(TokenError::InvalidTokenMaterial);
97 }
98
99 Ok(Self { value })
100 }
101
102 /// Returns the raw plaintext refresh token.
103 #[must_use]
104 pub fn as_str(&self) -> &str {
105 &self.value
106 }
107
108 /// Consumes the wrapper and returns the raw plaintext refresh token.
109 #[must_use]
110 pub fn into_inner(self) -> String {
111 self.value
112 }
113}
114
115/// Stable hashed representation of a refresh token suitable for persistence.
116///
117/// The hashing algorithm is intentionally abstracted away from this type so the
118/// sessions domain can depend on the hash value without committing to a
119/// particular implementation strategy here.
120///
121/// # Examples
122///
123/// ```
124/// use webgates_sessions::tokens::RefreshTokenHash;
125///
126/// let hash = RefreshTokenHash::new("abc123def456").unwrap();
127/// assert_eq!(hash.as_str(), "abc123def456");
128///
129/// // Empty strings are rejected.
130/// assert!(RefreshTokenHash::new("").is_err());
131/// ```
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct RefreshTokenHash {
134 value: String,
135}
136
137impl RefreshTokenHash {
138 /// Creates a new hashed refresh-token wrapper.
139 ///
140 /// # Errors
141 ///
142 /// Returns [`TokenError::InvalidTokenMaterial`] when `value` is empty or
143 /// contains only whitespace.
144 pub fn new(value: impl Into<String>) -> Result<Self, TokenError> {
145 let value = value.into();
146
147 if value.trim().is_empty() {
148 return Err(TokenError::InvalidTokenMaterial);
149 }
150
151 Ok(Self { value })
152 }
153
154 /// Returns the persisted hash representation.
155 #[must_use]
156 pub fn as_str(&self) -> &str {
157 &self.value
158 }
159
160 /// Consumes the wrapper and returns the persisted hash representation.
161 #[must_use]
162 pub fn into_inner(self) -> String {
163 self.value
164 }
165}
166
167/// Borrowed view of a refresh-token hash.
168pub type RefreshTokenHashRef<'a> = &'a str;
169
170/// Minimum length accepted by [`RefreshTokenLength::new`].
171pub const MIN_REFRESH_TOKEN_LENGTH: usize = 32;
172
173/// Default length used by [`OpaqueRefreshTokenGenerator::default`].
174pub const DEFAULT_REFRESH_TOKEN_LENGTH: usize = 64;
175
176/// Validated refresh-token length used by [`OpaqueRefreshTokenGenerator`].
177///
178/// # Examples
179///
180/// ```
181/// use webgates_sessions::tokens::{RefreshTokenLength, MIN_REFRESH_TOKEN_LENGTH};
182///
183/// let length = RefreshTokenLength::new(64).unwrap();
184/// assert_eq!(length.get(), 64);
185///
186/// // Values shorter than the minimum are rejected.
187/// assert!(RefreshTokenLength::new(MIN_REFRESH_TOKEN_LENGTH - 1).is_err());
188/// ```
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190pub struct RefreshTokenLength(usize);
191
192impl RefreshTokenLength {
193 /// Creates a validated refresh-token length.
194 ///
195 /// # Errors
196 ///
197 /// Returns [`TokenError::InvalidRefreshTokenLength`] when `value` is shorter
198 /// than [`MIN_REFRESH_TOKEN_LENGTH`].
199 pub fn new(value: usize) -> Result<Self, TokenError> {
200 if value < MIN_REFRESH_TOKEN_LENGTH {
201 return Err(TokenError::InvalidRefreshTokenLength);
202 }
203
204 Ok(Self(value))
205 }
206
207 /// Returns the configured token length.
208 #[must_use]
209 pub fn get(self) -> usize {
210 self.0
211 }
212}
213
214impl Default for RefreshTokenLength {
215 fn default() -> Self {
216 Self(DEFAULT_REFRESH_TOKEN_LENGTH)
217 }
218}
219
220/// Concrete refresh-token generator that emits opaque random token strings.
221///
222/// The generated tokens are intentionally high-entropy, framework-agnostic
223/// values that can be returned to clients and later transformed into a
224/// deterministic persisted fingerprint.
225///
226/// # Examples
227///
228/// ```
229/// use webgates_sessions::tokens::{
230/// OpaqueRefreshTokenGenerator, RefreshTokenGenerator, RefreshTokenLength,
231/// };
232///
233/// # tokio_test::block_on(async {
234/// let length = RefreshTokenLength::new(64).unwrap();
235/// let generator = OpaqueRefreshTokenGenerator::new(length);
236/// let token = generator.generate_refresh_token().await.unwrap();
237///
238/// assert_eq!(token.as_str().len(), 64);
239/// # });
240/// ```
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
242pub struct OpaqueRefreshTokenGenerator {
243 length: RefreshTokenLength,
244}
245
246impl OpaqueRefreshTokenGenerator {
247 /// Creates a generator with the provided token length.
248 #[must_use]
249 pub fn new(length: RefreshTokenLength) -> Self {
250 Self { length }
251 }
252
253 /// Returns the configured plaintext token length.
254 #[must_use]
255 pub fn length(&self) -> RefreshTokenLength {
256 self.length
257 }
258}
259
260impl Default for OpaqueRefreshTokenGenerator {
261 fn default() -> Self {
262 Self::new(RefreshTokenLength::default())
263 }
264}
265
266impl RefreshTokenGenerator for OpaqueRefreshTokenGenerator {
267 type Error = TokenError;
268
269 fn generate_refresh_token(
270 &self,
271 ) -> impl std::future::Future<Output = Result<RefreshTokenPlaintext, Self::Error>> + Send {
272 let length = self.length.get();
273
274 async move {
275 let value: String = rng()
276 .sample_iter(&Alphanumeric)
277 .take(length)
278 .map(char::from)
279 .collect();
280
281 RefreshTokenPlaintext::new(value)
282 }
283 }
284}
285
286/// Deterministic SHA-256 refresh-token hasher suitable for indexed lookups.
287///
288/// Unlike password hashing, refresh-token persistence in this crate needs a
289/// stable lookup key so repositories can locate session state by presented
290/// token material. This hasher therefore fingerprints only opaque,
291/// high-entropy random refresh tokens and must not be reused for user-chosen
292/// secrets such as passwords.
293///
294/// # Examples
295///
296/// ```
297/// use webgates_sessions::tokens::{
298/// RefreshTokenHasher, RefreshTokenPlaintext, Sha256RefreshTokenHasher,
299/// };
300///
301/// # tokio_test::block_on(async {
302/// let token = RefreshTokenPlaintext::new("a".repeat(64)).unwrap();
303/// let hasher = Sha256RefreshTokenHasher;
304/// let hash = hasher.hash_refresh_token(&token).await.unwrap();
305///
306/// // The same plaintext always produces the same hash.
307/// let hash2 = hasher.hash_refresh_token(&token).await.unwrap();
308/// assert_eq!(hash, hash2);
309/// # });
310/// ```
311#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
312pub struct Sha256RefreshTokenHasher;
313
314impl RefreshTokenHasher for Sha256RefreshTokenHasher {
315 type Error = TokenError;
316
317 fn hash_refresh_token(
318 &self,
319 refresh_token: &RefreshTokenPlaintext,
320 ) -> impl std::future::Future<Output = Result<RefreshTokenHash, Self::Error>> + Send {
321 let bytes = refresh_token.as_str().as_bytes().to_owned();
322
323 async move {
324 let digest = Sha256::digest(&bytes);
325 let mut encoded = String::with_capacity(digest.len() * 2);
326
327 for byte in digest {
328 use std::fmt::Write as _;
329
330 write!(&mut encoded, "{byte:02x}").map_err(|_| TokenError::HashFailed)?;
331 }
332
333 RefreshTokenHash::new(encoded).map_err(|_| TokenError::HashFailed)
334 }
335 }
336}
337
338/// Full token material produced by a login or renewal issuance step.
339///
340/// This output keeps the client-facing token pair alongside the persisted
341/// refresh-token hash that repositories need for lookup and rotation.
342///
343/// # Examples
344///
345/// ```
346/// use webgates_sessions::tokens::{
347/// AuthToken, IssuedSessionTokens, IssuedTokenPair, RefreshTokenHash,
348/// RefreshTokenPlaintext,
349/// };
350///
351/// let auth = AuthToken::new("eyJhbGciOiJIUzI1NiJ9.payload.sig").unwrap();
352/// let refresh = RefreshTokenPlaintext::new("a".repeat(64)).unwrap();
353/// let hash = RefreshTokenHash::new("abc123def456").unwrap();
354/// let pair = IssuedTokenPair::new(auth, refresh);
355/// let issued = IssuedSessionTokens::new(pair, hash.clone());
356///
357/// assert_eq!(issued.refresh_token_hash, hash);
358/// ```
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub struct IssuedSessionTokens {
361 /// Newly issued client-facing auth and refresh tokens.
362 pub token_pair: IssuedTokenPair,
363 /// Deterministic persisted fingerprint for the refresh token.
364 pub refresh_token_hash: RefreshTokenHash,
365}
366
367impl IssuedSessionTokens {
368 /// Creates a new issuance result from a token pair and persisted hash.
369 #[must_use]
370 pub fn new(token_pair: IssuedTokenPair, refresh_token_hash: RefreshTokenHash) -> Self {
371 Self {
372 token_pair,
373 refresh_token_hash,
374 }
375 }
376}
377
378/// Auth and refresh tokens issued together for a session lifecycle step.
379///
380/// # Examples
381///
382/// ```
383/// use webgates_sessions::tokens::{AuthToken, IssuedTokenPair, RefreshTokenPlaintext};
384///
385/// let auth = AuthToken::new("eyJhbGciOiJIUzI1NiJ9.payload.sig").unwrap();
386/// let refresh = RefreshTokenPlaintext::new("a".repeat(64)).unwrap();
387/// let pair = IssuedTokenPair::new(auth.clone(), refresh.clone());
388///
389/// assert_eq!(pair.auth_token, auth);
390/// assert_eq!(pair.refresh_token, refresh);
391/// ```
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct IssuedTokenPair {
394 /// Newly issued short-lived auth token.
395 pub auth_token: AuthToken,
396 /// Newly issued long-lived refresh token.
397 pub refresh_token: RefreshTokenPlaintext,
398}
399
400impl IssuedTokenPair {
401 /// Creates a token pair from its component values.
402 #[must_use]
403 pub fn new(auth_token: AuthToken, refresh_token: RefreshTokenPlaintext) -> Self {
404 Self {
405 auth_token,
406 refresh_token,
407 }
408 }
409}
410
411/// Framework-agnostic abstraction for issuing auth tokens for a session subject.
412///
413/// Implementations return a `Send` future so callers can compose issuance into
414/// end-to-end async workflows on multi-threaded runtimes. Purely synchronous
415/// implementations may wrap their result in `std::future::ready`.
416pub trait AuthTokenIssuer<Subject>: Send + Sync {
417 /// Error type returned when token issuance fails.
418 type Error;
419
420 /// Issues a new auth token for the provided subject.
421 fn issue_auth_token(
422 &self,
423 subject: &Subject,
424 ) -> impl std::future::Future<Output = Result<AuthToken, Self::Error>> + Send;
425}
426
427/// Framework-agnostic abstraction for generating opaque refresh tokens.
428///
429/// Implementations return a `Send` future so generation can be delegated to an
430/// async-capable entropy or key-derivation backend without blocking the runtime.
431/// Purely synchronous implementations may wrap their result in
432/// `std::future::ready`.
433pub trait RefreshTokenGenerator: Send + Sync {
434 /// Error type returned when refresh-token generation fails.
435 type Error;
436
437 /// Generates a new plaintext refresh token.
438 fn generate_refresh_token(
439 &self,
440 ) -> impl std::future::Future<Output = Result<RefreshTokenPlaintext, Self::Error>> + Send;
441}
442
443/// Framework-agnostic abstraction for hashing refresh tokens before storage.
444///
445/// Implementations return a `Send` future so hashing can be offloaded to an
446/// async-capable backend without blocking the runtime. Purely synchronous
447/// implementations may wrap their result in `std::future::ready`.
448pub trait RefreshTokenHasher: Send + Sync {
449 /// Error type returned when hashing fails.
450 type Error;
451
452 /// Hashes a plaintext refresh token into a stable persisted representation.
453 fn hash_refresh_token(
454 &self,
455 refresh_token: &RefreshTokenPlaintext,
456 ) -> impl std::future::Future<Output = Result<RefreshTokenHash, Self::Error>> + Send;
457}
458
459/// Auth-token issuer backed by a `webgates-codecs` payload codec.
460///
461/// Callers provide a claims factory so this issuer can stay generic over both
462/// the subject type and the encoded claim shape.
463///
464/// # Examples
465///
466/// ```
467/// use webgates_codecs::jwt::{JsonWebToken, JwtClaims, RegisteredClaims};
468/// use webgates_sessions::tokens::{AuthTokenIssuer, CodecAuthTokenIssuer};
469/// use serde::{Deserialize, Serialize};
470///
471/// #[derive(Debug, Clone, Serialize, Deserialize)]
472/// struct MyClaims {
473/// sub: String,
474/// }
475///
476/// // Install a crypto provider required by jsonwebtoken before using the codec.
477/// webgates_codecs::jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER
478/// .install_default()
479/// .ok();
480///
481/// let codec = JsonWebToken::<JwtClaims<MyClaims>>::default();
482/// let issuer = CodecAuthTokenIssuer::new(codec, |subject: &String| {
483/// JwtClaims::new(
484/// MyClaims { sub: subject.clone() },
485/// RegisteredClaims::new("my-service", 4_102_444_800),
486/// )
487/// });
488///
489/// # tokio_test::block_on(async {
490/// let token = issuer.issue_auth_token(&String::from("user-42")).await.unwrap();
491/// assert!(!token.as_str().is_empty());
492/// # });
493/// ```
494#[derive(Clone)]
495pub struct CodecAuthTokenIssuer<C, F> {
496 codec: C,
497 claims_factory: F,
498}
499
500impl<C, F> CodecAuthTokenIssuer<C, F> {
501 /// Creates a new codec-backed auth-token issuer.
502 #[must_use]
503 pub fn new(codec: C, claims_factory: F) -> Self {
504 Self {
505 codec,
506 claims_factory,
507 }
508 }
509}
510
511impl<Subject, Claims, C, F> AuthTokenIssuer<Subject> for CodecAuthTokenIssuer<C, F>
512where
513 C: Codec<Payload = Claims> + Send + Sync,
514 F: Fn(&Subject) -> Claims + Send + Sync,
515 Subject: Send + Sync,
516 Claims: Send + Sync,
517{
518 type Error = TokenError;
519
520 fn issue_auth_token(
521 &self,
522 subject: &Subject,
523 ) -> impl std::future::Future<Output = Result<AuthToken, Self::Error>> + Send {
524 let claims = (self.claims_factory)(subject);
525 let result = self
526 .codec
527 .encode(&claims)
528 .map_err(|_| TokenError::AuthIssuanceFailed)
529 .and_then(|encoded| {
530 String::from_utf8(encoded).map_err(|_| TokenError::AuthIssuanceFailed)
531 })
532 .and_then(|token| AuthToken::new(token).map_err(|_| TokenError::AuthIssuanceFailed));
533
534 std::future::ready(result)
535 }
536}
537
538/// Cohesive token-pair issuance workflow for login and renewal services.
539///
540/// This type combines auth-token issuance, refresh-token generation, and
541/// persisted refresh-token hashing behind one deterministic boundary.
542///
543/// # Examples
544///
545/// ```
546/// use webgates_sessions::tokens::{
547/// AuthToken, AuthTokenIssuer, OpaqueRefreshTokenGenerator, RefreshTokenGenerator,
548/// RefreshTokenLength, Sha256RefreshTokenHasher, TokenPairIssuer,
549/// };
550///
551/// #[derive(Debug, Clone, Copy)]
552/// struct PrefixAuthTokenIssuer;
553///
554/// impl AuthTokenIssuer<String> for PrefixAuthTokenIssuer {
555/// type Error = webgates_sessions::errors::TokenError;
556///
557/// fn issue_auth_token(
558/// &self,
559/// subject: &String,
560/// ) -> impl std::future::Future<Output = Result<AuthToken, Self::Error>> + Send {
561/// std::future::ready(AuthToken::new(format!("auth-{subject}")))
562/// }
563/// }
564///
565/// # tokio_test::block_on(async {
566/// let length = RefreshTokenLength::new(64).unwrap();
567/// let issuer = TokenPairIssuer::new(
568/// PrefixAuthTokenIssuer,
569/// OpaqueRefreshTokenGenerator::new(length),
570/// Sha256RefreshTokenHasher,
571/// );
572///
573/// let subject = String::from("user-42");
574/// let issued = issuer.issue_for_subject(&subject).await.unwrap();
575/// assert_eq!(issued.token_pair.auth_token.as_str(), "auth-user-42");
576/// assert_eq!(issued.token_pair.refresh_token.as_str().len(), 64);
577/// # });
578/// ```
579#[derive(Debug, Clone)]
580pub struct TokenPairIssuer<A, G, H> {
581 auth_token_issuer: A,
582 refresh_token_generator: G,
583 refresh_token_hasher: H,
584}
585
586impl<A, G, H> TokenPairIssuer<A, G, H> {
587 /// Creates a new token-pair issuer from its component services.
588 #[must_use]
589 pub fn new(auth_token_issuer: A, refresh_token_generator: G, refresh_token_hasher: H) -> Self {
590 Self {
591 auth_token_issuer,
592 refresh_token_generator,
593 refresh_token_hasher,
594 }
595 }
596}
597
598impl<A, G, H> TokenPairIssuer<A, G, H> {
599 /// Hashes an existing plaintext refresh token using the configured hasher.
600 ///
601 /// # Errors
602 ///
603 /// Returns [`TokenError::HashFailed`] when the configured refresh-token
604 /// hasher cannot produce a persisted fingerprint.
605 pub async fn hash_refresh_token(
606 &self,
607 refresh_token: &RefreshTokenPlaintext,
608 ) -> Result<RefreshTokenHash, TokenError>
609 where
610 H: RefreshTokenHasher,
611 {
612 self.refresh_token_hasher
613 .hash_refresh_token(refresh_token)
614 .await
615 .map_err(|_| TokenError::HashFailed)
616 }
617
618 /// Issues an auth token, refresh token, and persisted refresh-token hash.
619 ///
620 /// # Errors
621 ///
622 /// Returns [`TokenError::AuthIssuanceFailed`],
623 /// [`TokenError::GenerationFailed`], or [`TokenError::HashFailed`] when one
624 /// of the underlying issuance steps fails.
625 pub async fn issue_for_subject<Subject>(
626 &self,
627 subject: &Subject,
628 ) -> Result<IssuedSessionTokens, TokenError>
629 where
630 A: AuthTokenIssuer<Subject>,
631 G: RefreshTokenGenerator,
632 H: RefreshTokenHasher,
633 {
634 let auth_token = self
635 .auth_token_issuer
636 .issue_auth_token(subject)
637 .await
638 .map_err(|_| TokenError::AuthIssuanceFailed)?;
639 let refresh_token = self
640 .refresh_token_generator
641 .generate_refresh_token()
642 .await
643 .map_err(|_| TokenError::GenerationFailed)?;
644 let refresh_token_hash = self.hash_refresh_token(&refresh_token).await?;
645
646 Ok(IssuedSessionTokens::new(
647 IssuedTokenPair::new(auth_token, refresh_token),
648 refresh_token_hash,
649 ))
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use serde::{Deserialize, Serialize};
657 use webgates_codecs::jwt::{JsonWebToken, JwtClaims, RegisteredClaims};
658 use webgates_codecs::{Codec, jsonwebtoken};
659
660 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
661 struct TestClaims {
662 session_id: String,
663 }
664
665 #[derive(Debug, Clone, Copy)]
666 struct StaticAuthTokenIssuer;
667
668 impl AuthTokenIssuer<String> for StaticAuthTokenIssuer {
669 type Error = TokenError;
670
671 fn issue_auth_token(
672 &self,
673 subject: &String,
674 ) -> impl std::future::Future<Output = Result<AuthToken, Self::Error>> + Send {
675 std::future::ready(AuthToken::new(format!("auth-{subject}")))
676 }
677 }
678
679 #[derive(Debug, Clone, Copy)]
680 struct StaticRefreshTokenGenerator;
681
682 impl RefreshTokenGenerator for StaticRefreshTokenGenerator {
683 type Error = TokenError;
684
685 fn generate_refresh_token(
686 &self,
687 ) -> impl std::future::Future<Output = Result<RefreshTokenPlaintext, Self::Error>> + Send
688 {
689 std::future::ready(RefreshTokenPlaintext::new("fixed-refresh-token"))
690 }
691 }
692
693 #[test]
694 fn auth_token_rejects_empty_value() {
695 let error = match AuthToken::new(" ") {
696 Ok(token) => panic!("expected invalid auth token, got {}", token.as_str()),
697 Err(error) => error,
698 };
699
700 assert_eq!(error, TokenError::InvalidTokenMaterial);
701 }
702
703 #[test]
704 fn refresh_token_plaintext_rejects_empty_value() {
705 let error = match RefreshTokenPlaintext::new("") {
706 Ok(token) => panic!("expected invalid refresh token, got {}", token.as_str()),
707 Err(error) => error,
708 };
709
710 assert_eq!(error, TokenError::InvalidTokenMaterial);
711 }
712
713 #[test]
714 fn refresh_token_hash_rejects_empty_value() {
715 let error = match RefreshTokenHash::new(" ") {
716 Ok(hash) => panic!("expected invalid refresh token hash, got {}", hash.as_str()),
717 Err(error) => error,
718 };
719
720 assert_eq!(error, TokenError::InvalidTokenMaterial);
721 }
722
723 #[test]
724 fn refresh_token_length_rejects_short_values() {
725 let error = match RefreshTokenLength::new(MIN_REFRESH_TOKEN_LENGTH - 1) {
726 Ok(length) => panic!("expected invalid length, got {}", length.get()),
727 Err(error) => error,
728 };
729
730 assert_eq!(error, TokenError::InvalidRefreshTokenLength);
731 }
732
733 #[test]
734 fn issued_token_pair_keeps_both_tokens() {
735 let auth_token = match AuthToken::new("auth-token") {
736 Ok(token) => token,
737 Err(error) => panic!("expected valid auth token: {error}"),
738 };
739 let refresh_token = match RefreshTokenPlaintext::new("refresh-token") {
740 Ok(token) => token,
741 Err(error) => panic!("expected valid refresh token: {error}"),
742 };
743
744 let pair = IssuedTokenPair::new(auth_token.clone(), refresh_token.clone());
745
746 assert_eq!(pair.auth_token, auth_token);
747 assert_eq!(pair.refresh_token, refresh_token);
748 }
749
750 #[tokio::test]
751 async fn opaque_refresh_token_generator_uses_requested_length() {
752 let length = match RefreshTokenLength::new(48) {
753 Ok(length) => length,
754 Err(error) => panic!("expected valid refresh token length: {error}"),
755 };
756 let generator = OpaqueRefreshTokenGenerator::new(length);
757 let token = match generator.generate_refresh_token().await {
758 Ok(token) => token,
759 Err(error) => panic!("expected generated refresh token: {error}"),
760 };
761
762 assert_eq!(token.as_str().len(), 48);
763 }
764
765 #[tokio::test]
766 async fn sha256_refresh_token_hasher_is_deterministic() {
767 let token = match RefreshTokenPlaintext::new("repeatable-refresh-token") {
768 Ok(token) => token,
769 Err(error) => panic!("expected valid refresh token: {error}"),
770 };
771 let hasher = Sha256RefreshTokenHasher;
772
773 let first_hash = match hasher.hash_refresh_token(&token).await {
774 Ok(hash) => hash,
775 Err(error) => panic!("expected successful hash: {error}"),
776 };
777 let second_hash = match hasher.hash_refresh_token(&token).await {
778 Ok(hash) => hash,
779 Err(error) => panic!("expected successful hash: {error}"),
780 };
781
782 assert_eq!(first_hash, second_hash);
783 }
784
785 #[tokio::test]
786 async fn codec_auth_token_issuer_encodes_claims_with_codec() {
787 let _ = jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER.install_default();
788 let codec = JsonWebToken::<JwtClaims<TestClaims>>::default();
789 let issuer = CodecAuthTokenIssuer::new(codec.clone(), |subject: &String| {
790 JwtClaims::new(
791 TestClaims {
792 session_id: subject.clone(),
793 },
794 RegisteredClaims::new("tests", 4_102_444_800),
795 )
796 });
797 let subject = String::from("session-123");
798
799 let token = match issuer.issue_auth_token(&subject).await {
800 Ok(token) => token,
801 Err(error) => panic!("expected successful auth-token issuance: {error}"),
802 };
803 let decoded = match codec.decode(token.as_str().as_bytes()) {
804 Ok(claims) => claims,
805 Err(error) => panic!("expected decodable auth token: {error}"),
806 };
807
808 assert_eq!(decoded.custom_claims.session_id, subject);
809 }
810
811 #[tokio::test]
812 async fn token_pair_issuer_returns_tokens_and_hash() {
813 let issuer = TokenPairIssuer::new(
814 StaticAuthTokenIssuer,
815 StaticRefreshTokenGenerator,
816 Sha256RefreshTokenHasher,
817 );
818 let subject = String::from("subject-123");
819
820 let issued = match issuer.issue_for_subject(&subject).await {
821 Ok(issued) => issued,
822 Err(error) => panic!("expected successful token-pair issuance: {error}"),
823 };
824 let expected_hash = match Sha256RefreshTokenHasher
825 .hash_refresh_token(&issued.token_pair.refresh_token)
826 .await
827 {
828 Ok(hash) => hash,
829 Err(error) => panic!("expected successful hash calculation: {error}"),
830 };
831
832 assert_eq!(issued.token_pair.auth_token.as_str(), "auth-subject-123");
833 assert_eq!(issued.refresh_token_hash, expected_hash);
834 }
835}