Skip to main content

orbit_auth/
principal.rs

1use std::collections::BTreeSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{Error, Result};
6
7/// Maximum UTF-8 byte length of each realm, issuer, subject, audience or capability.
8pub const MAX_LABEL_LEN: usize = 256;
9pub const MAX_CAPABILITIES: usize = 64;
10
11pub(crate) fn valid_label(value: &str) -> bool {
12    !value.is_empty()
13        && value.len() <= MAX_LABEL_LEN
14        && !value.chars().any(char::is_control)
15}
16
17macro_rules! label {
18    ($name:ident, $doc:literal) => {
19        #[doc = $doc]
20        #[derive(
21            Clone,
22            Debug,
23            PartialEq,
24            Eq,
25            PartialOrd,
26            Ord,
27            Hash,
28            Serialize,
29            Deserialize,
30        )]
31        #[serde(try_from = "String", into = "String")]
32        pub struct $name(String);
33
34        impl $name {
35            pub fn new(value: impl Into<String>) -> Result<Self> {
36                let value = value.into();
37                if !valid_label(&value) {
38                    return Err(Error::InvalidInput);
39                }
40                Ok(Self(value))
41            }
42
43            pub fn as_str(&self) -> &str {
44                &self.0
45            }
46        }
47
48        impl TryFrom<String> for $name {
49            type Error = Error;
50            fn try_from(value: String) -> Result<Self> {
51                Self::new(value)
52            }
53        }
54
55        impl From<$name> for String {
56            fn from(value: $name) -> Self {
57                value.0
58            }
59        }
60    };
61}
62
63label!(Audience, "Exact logical recipient; independent of DNS and transport.");
64label!(
65    Capability,
66    "Exact application-defined permission; no implicit wildcards or hierarchy."
67);
68
69/// Cryptographically separated credential uses, independent of extraction method.
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum Purpose {
73    Session,
74    Refresh,
75    Access,
76    WebSocket,
77    Internal,
78}
79
80impl Purpose {
81    pub(crate) fn as_bytes(self) -> &'static [u8] {
82        match self {
83            Self::Session => b"session",
84            Self::Refresh => b"refresh",
85            Self::Access => b"access",
86            Self::WebSocket => b"websocket",
87            Self::Internal => b"internal",
88        }
89    }
90}
91
92/// Random 128-bit token identifier. Replay stores must also scope by realm and issuer.
93#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
94pub struct TokenId(pub [u8; 16]);
95
96/// Issuer-authorized claims. All times are absolute Unix seconds; no implicit TTL or leeway.
97#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct Claims {
100    pub subject: String,
101    pub audience: Audience,
102    pub purpose: Purpose,
103    pub capabilities: BTreeSet<Capability>,
104    pub issued_at: u64,
105    pub not_before: u64,
106    pub expires_at: u64,
107}
108
109impl Claims {
110    pub(crate) fn check_shape(&self) -> Result<()> {
111        if !valid_label(&self.subject)
112            || self.capabilities.len() > MAX_CAPABILITIES
113            || self.issued_at > self.not_before
114            || self.not_before >= self.expires_at
115        {
116            return Err(Error::InvalidInput);
117        }
118        Ok(())
119    }
120}
121
122/// Authenticated identity snapshot. This value is not a transferable credential.
123#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(deny_unknown_fields)]
125pub struct Principal {
126    pub(crate) realm: String,
127    pub(crate) issuer: String,
128    pub(crate) jti: TokenId,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub(crate) session: Option<crate::fleet::SessionBinding>,
131    pub(crate) claims: Claims,
132}
133
134impl Principal {
135    pub fn session_id(&self) -> Option<crate::SessionId> {
136        self.session.as_ref().map(|session| session.id)
137    }
138    pub fn realm(&self) -> &str {
139        &self.realm
140    }
141    pub fn issuer(&self) -> &str {
142        &self.issuer
143    }
144    pub fn jti(&self) -> TokenId {
145        self.jti
146    }
147    pub fn subject(&self) -> &str {
148        &self.claims.subject
149    }
150    pub fn claims(&self) -> &Claims {
151        &self.claims
152    }
153    pub fn has_capability(
154        &self,
155        capability: &Capability,
156    ) -> bool {
157        self.claims.capabilities.contains(capability)
158    }
159}
160
161/// Expected recipient and use come from trusted routing configuration, never the token.
162#[derive(Clone, Debug)]
163pub struct Validation {
164    pub audience: Audience,
165    pub purpose: Purpose,
166    pub required_capabilities: BTreeSet<Capability>,
167}
168
169/// Called after static validation on EVERY authentication, including cache hits.
170///
171/// A one-use policy must atomically consume `(realm, issuer, jti)` until `expires_at`.
172/// Fail closed with `PolicyUnavailable` if its authoritative store is unavailable.
173/// Revocation checks and application-specific current-state policy also belong here.
174pub trait ValidationHook {
175    fn check(
176        &self,
177        principal: &Principal,
178        now: u64,
179    ) -> Result<()>;
180
181    /// `Validator::validate_lapsed`: policy for a credential past its own
182    /// expiry. Hooks must opt in explicitly; the default fails closed.
183    fn check_lapsed(
184        &self,
185        _: &Principal,
186        _: u64,
187    ) -> Result<()> {
188        Err(Error::Expired)
189    }
190}
191
192/// Explicitly permits token reuse until expiry; provides no revocation or replay protection.
193pub struct AllowReusable;
194
195impl ValidationHook for AllowReusable {
196    fn check(
197        &self,
198        _: &Principal,
199        _: u64,
200    ) -> Result<()> {
201        Ok(())
202    }
203}