Skip to main content

sofie_auth/
principal_token.rs

1use std::fmt;
2
3use sofie_core::{Timestamp, Uuid};
4
5use crate::{Signed, SigningKeys};
6
7/// Long enough for one call between apps, short enough that a leaked token soon expires.
8const LIFETIME_SECONDS: i64 = 60;
9
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct Acting {
12    pub area: String,
13    pub principal: Uuid,
14}
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum TokenRefused {
18    Malformed,
19    Signature,
20    Expired,
21}
22
23impl fmt::Display for TokenRefused {
24    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25        formatter.write_str(match self {
26            Self::Malformed => "the principal token is malformed",
27            Self::Signature => "the principal token's signature does not verify",
28            Self::Expired => "the principal token has expired",
29        })
30    }
31}
32
33impl std::error::Error for TokenRefused {}
34
35impl SigningKeys {
36    /// Valid only for `target`, the path it is sent to, so it cannot be replayed against another.
37    #[must_use]
38    pub fn principal_token(&self, target: &str, acting: Option<&Acting>, now: Timestamp) -> String {
39        let expires = now.as_second() + LIFETIME_SECONDS;
40        let payload = match acting {
41            Some(acting) => format!("{expires}:{}:{}", acting.area, acting.principal),
42            None => format!("{expires}::"),
43        };
44        let signature = self.sign(Signed::PrincipalToken, signed(target, &payload).as_bytes());
45        format!("{payload}.{signature}")
46    }
47
48    pub fn verify_principal_token(
49        &self,
50        token: &str,
51        target: &str,
52        now: Timestamp,
53    ) -> std::result::Result<Option<Acting>, TokenRefused> {
54        let (payload, signature) = token.rsplit_once('.').ok_or(TokenRefused::Malformed)?;
55        if !self.verifies(
56            Signed::PrincipalToken,
57            signed(target, payload).as_bytes(),
58            signature,
59        ) {
60            return Err(TokenRefused::Signature);
61        }
62        let mut parts = payload.splitn(3, ':');
63        let expires: i64 = parts
64            .next()
65            .and_then(|expires| expires.parse().ok())
66            .ok_or(TokenRefused::Malformed)?;
67        // A token living past its lifetime came from an issuer whose clock is wrong.
68        let now = now.as_second();
69        if now >= expires || expires > now + 2 * LIFETIME_SECONDS {
70            return Err(TokenRefused::Expired);
71        }
72        match (parts.next(), parts.next()) {
73            (Some(""), Some("")) => Ok(None),
74            (Some(area), Some(principal)) => Ok(Some(Acting {
75                area: area.to_owned(),
76                principal: principal.parse().map_err(|_| TokenRefused::Malformed)?,
77            })),
78            _ => Err(TokenRefused::Malformed),
79        }
80    }
81}
82
83fn signed(target: &str, payload: &str) -> String {
84    format!("{target}\n{payload}")
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::session::tests::keys;
91
92    fn at(second: i64) -> Timestamp {
93        Timestamp::from_second(second).unwrap()
94    }
95
96    fn customer() -> Acting {
97        Acting {
98            area: "customer".to_owned(),
99            principal: Uuid::now_v7().unwrap(),
100        }
101    }
102
103    #[test]
104    fn a_token_carries_its_principal_to_its_target_until_it_expires() {
105        let acting = customer();
106        let token = keys(1).principal_token("/_sofie/contracts/a/B/c", Some(&acting), at(1000));
107        let verify = |target: &str, keys: SigningKeys, now: i64| {
108            keys.verify_principal_token(&token, target, at(now))
109        };
110
111        assert_eq!(
112            verify("/_sofie/contracts/a/B/c", keys(1), 1059),
113            Ok(Some(acting))
114        );
115        assert_eq!(
116            verify("/_sofie/contracts/a/B/d", keys(1), 1001),
117            Err(TokenRefused::Signature)
118        );
119        assert_eq!(
120            verify("/_sofie/contracts/a/B/c", keys(2), 1001),
121            Err(TokenRefused::Signature)
122        );
123        assert_eq!(
124            verify("/_sofie/contracts/a/B/c", keys(1), 1060),
125            Err(TokenRefused::Expired)
126        );
127        assert_eq!(
128            verify("/_sofie/contracts/a/B/c", keys(1), 800),
129            Err(TokenRefused::Expired)
130        );
131    }
132
133    #[test]
134    fn an_app_s_own_token_acts_for_nobody_and_a_forgery_is_refused() {
135        let token = keys(1).principal_token("/_sofie/feed", None, at(0));
136
137        assert_eq!(
138            keys(1).verify_principal_token(&token, "/_sofie/feed", at(1)),
139            Ok(None)
140        );
141        assert_eq!(
142            keys(1).verify_principal_token("no-dot", "/_sofie/feed", at(1)),
143            Err(TokenRefused::Malformed)
144        );
145        let link = keys(1).sign(Signed::Link, b"/_sofie/feed");
146        assert_eq!(
147            keys(1).verify_principal_token(&format!("x.{link}"), "/_sofie/feed", at(1)),
148            Err(TokenRefused::Signature)
149        );
150    }
151
152    #[test]
153    fn a_token_is_its_plain_payload_and_a_signature() {
154        let acting = customer();
155
156        let token = keys(1).principal_token("/_sofie/feed", Some(&acting), at(1000));
157
158        let prefix = format!("1060:customer:{}.", acting.principal);
159        assert!(token.starts_with(&prefix), "{token}");
160    }
161}