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