Skip to main content

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}