Skip to main content

webx_core/security/
tokens.rs

1//! Token issuance port ([`ITokenIssuer`]).
2//!
3//! The framework only *declares* the port: signing needs a concrete algorithm
4//! and key management, so the default JWT implementation lives in
5//! `rust-webx-host` (`JwtTokenIssuer`). Applications
6//! register their issuer in DI and call it from the login endpoint.
7
8use crate::error::Result;
9use std::collections::BTreeMap;
10use std::time::Duration;
11
12/// Claims a caller hands to an [`ITokenIssuer`].
13///
14/// Values are deliberately plain (`String` / `Vec<String>`) so the issuer does
15/// not need to know the application's claim structs. Anything that is not a
16/// first-class field — `tenant_id`, `name`, `email`, custom scopes — goes into
17/// [`TokenClaims::extra`] and is embedded verbatim.
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub struct TokenClaims {
20    /// Subject (`sub`) — the stable user identifier. Required.
21    pub subject: String,
22
23    /// Roles to embed (`roles` claim).
24    pub roles: Vec<String>,
25
26    /// Fine-grained permissions to embed (`permissions` claim).
27    pub permissions: Vec<String>,
28
29    /// Additional string claims, embedded verbatim (e.g. `tenant_id`).
30    ///
31    /// The issuer owns `sub` / `jti` / `exp` / `iat`: a key collision in `extra`
32    /// must not be able to override those.
33    pub extra: BTreeMap<String, String>,
34
35    /// Requested lifetime. `None` means the issuer's configured default.
36    pub expires_in: Option<Duration>,
37}
38
39impl TokenClaims {
40    /// Claims for `subject` with no roles, permissions or extra claims.
41    pub fn new(subject: impl Into<String>) -> Self {
42        Self {
43            subject: subject.into(),
44            ..Self::default()
45        }
46    }
47
48    /// Set the `roles` claim.
49    pub fn with_roles<I, S>(mut self, roles: I) -> Self
50    where
51        I: IntoIterator<Item = S>,
52        S: Into<String>,
53    {
54        self.roles = roles.into_iter().map(Into::into).collect();
55        self
56    }
57
58    /// Set the `permissions` claim.
59    pub fn with_permissions<I, S>(mut self, permissions: I) -> Self
60    where
61        I: IntoIterator<Item = S>,
62        S: Into<String>,
63    {
64        self.permissions = permissions.into_iter().map(Into::into).collect();
65        self
66    }
67
68    /// Add one extra string claim.
69    pub fn with_claim(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
70        self.extra.insert(key.into(), value.into());
71        self
72    }
73
74    /// Override the issuer's default lifetime.
75    pub fn expires_in(mut self, lifetime: Duration) -> Self {
76        self.expires_in = Some(lifetime);
77        self
78    }
79}
80
81/// A token minted by an [`ITokenIssuer`].
82///
83/// `jti` is what [`ISessionStore::revoke`](crate::security::ISessionStore::revoke)
84/// stores: the login endpoint persists it (or hands it to the client for
85/// revocation), and the authentication middleware rejects the token once the
86/// `jti` is revoked — no need to wait for `expires_at_unix_secs`.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct IssuedToken {
89    /// The serialized token (JWT compact form for the default issuer).
90    pub token: String,
91
92    /// Revocation identifier (`jti` claim) embedded in the token.
93    pub jti: String,
94
95    /// Expiry as a **Unix timestamp in seconds** (`exp` claim).
96    ///
97    /// Deliberately not milliseconds (Travel-style `expires_at` fields are
98    /// milliseconds): convert with `* 1000` when writing a millisecond column or
99    /// returning a millisecond field.
100    pub expires_at_unix_secs: u64,
101}
102
103/// Mint a token from [`TokenClaims`].
104///
105/// Implementations embed a unique [`IssuedToken::jti`] and an absolute expiry in
106/// Unix seconds (`expires_at_unix_secs`), so callers can revoke the token later
107/// without decoding it again.
108///
109/// ```ignore
110/// let issued = token_issuer.issue(
111///     TokenClaims::new(&user.id)
112///         .with_roles(user.roles.clone())
113///         .with_claim("tenant_id", &user.tenant_id),
114/// ).await?;
115/// store.revoke(&issued.jti).await?; // logout
116/// ```
117#[async_trait::async_trait]
118pub trait ITokenIssuer: Send + Sync {
119    /// Sign `claims` into a token.
120    ///
121    /// Returns a typed [`Error`](crate::error::Error) for caller mistakes (for
122    /// example an empty subject) and for signing failures; it must not panic.
123    async fn issue(&self, claims: TokenClaims) -> Result<IssuedToken>;
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn token_claims_builder_sets_every_dimension() {
132        let claims = TokenClaims::new("user-1")
133            .with_roles(["admin", "user"])
134            .with_permissions(["order.create"])
135            .with_claim("tenant_id", "acme")
136            .expires_in(Duration::from_secs(60));
137
138        assert_eq!(claims.subject, "user-1");
139        assert_eq!(claims.roles, vec!["admin", "user"]);
140        assert_eq!(claims.permissions, vec!["order.create"]);
141        assert_eq!(
142            claims.extra.get("tenant_id").map(String::as_str),
143            Some("acme")
144        );
145        assert_eq!(claims.expires_in, Some(Duration::from_secs(60)));
146    }
147
148    #[test]
149    fn token_claims_default_lifetime_is_the_issuers_default() {
150        assert_eq!(TokenClaims::new("user-1").expires_in, None);
151    }
152}