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}