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}