Skip to main content

zlayer_types/
jwt.rs

1//! JWT claims and related auth wire types.
2//!
3//! Lifted from `zlayer-api` so cross-crate consumers (`zlayer-agent`,
4//! the CLI, future federation code) can name the JWT claims without
5//! depending on `zlayer-api`. Token signing/verification helpers stay
6//! in `zlayer-api::auth`.
7
8use serde::{Deserialize, Serialize};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11/// JWT claims used by every protected endpoint.
12///
13/// `node_id` is `Some` only on node JWTs — tokens issued by the leader
14/// during cluster join with `roles: ["node"]` so a node can authenticate
15/// to its peers' internal endpoints distinct from any user identity.
16/// All other JWT issuers leave it `None`.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Claims {
19    /// Subject — user id, api-key id, or `"node:{node_id}"` for node JWTs.
20    pub sub: String,
21
22    /// Expiration time (Unix seconds).
23    pub exp: u64,
24
25    /// Issued at (Unix seconds).
26    pub iat: u64,
27
28    /// Issuer (canonically `"zlayer"`; federation will use a cluster-id form).
29    pub iss: String,
30
31    /// Role claims, e.g. `["admin"]`, `["operator"]`, `["node"]`.
32    ///
33    /// `#[serde(default)]` preserves back-compat with tokens minted before
34    /// the field existed.
35    #[serde(default)]
36    pub roles: Vec<String>,
37
38    /// Email — embedded for session JWTs so the manager UI doesn't
39    /// have to round-trip to the user store on every request.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub email: Option<String>,
42
43    /// Cluster-wide node UUID. `Some` only for JWTs minted by the
44    /// leader at cluster-join time (where `roles` contains `"node"`).
45    /// All other token kinds leave this `None`.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub node_id: Option<String>,
48}
49
50impl Claims {
51    /// Create new claims.
52    ///
53    /// `node_id` is left `None`; node JWTs are constructed via struct
54    /// literal at the cluster-join site.
55    ///
56    /// # Panics
57    ///
58    /// Panics if the system clock is before the Unix epoch.
59    pub fn new(
60        subject: impl Into<String>,
61        expiry: Duration,
62        roles: Vec<String>,
63        email: Option<String>,
64    ) -> Self {
65        let now = SystemTime::now()
66            .duration_since(UNIX_EPOCH)
67            .expect("system clock before Unix epoch")
68            .as_secs();
69
70        Self {
71            sub: subject.into(),
72            exp: now + expiry.as_secs(),
73            iat: now,
74            iss: "zlayer".to_string(),
75            roles,
76            email,
77            node_id: None,
78        }
79    }
80
81    /// Check if token is expired.
82    ///
83    /// # Panics
84    ///
85    /// Panics if the system clock is before the Unix epoch.
86    #[must_use]
87    pub fn is_expired(&self) -> bool {
88        let now = SystemTime::now()
89            .duration_since(UNIX_EPOCH)
90            .expect("system clock before Unix epoch")
91            .as_secs();
92        self.exp < now
93    }
94
95    /// Check if the user has a specific role
96    #[must_use]
97    pub fn has_role(&self, role: &str) -> bool {
98        self.roles.iter().any(|r| r == role || r == "admin")
99    }
100}