gsm_core/platforms/webchat/
auth.rs

1use std::sync::Arc;
2
3use anyhow::Context;
4use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
5use once_cell::sync::OnceCell;
6use serde::{Deserialize, Serialize};
7use time::{Duration as TimeDuration, OffsetDateTime};
8
9use super::config::SigningKeys;
10
11const ISSUER: &str = "greentic.webchat";
12const AUDIENCE: &str = "directline";
13
14/// Encoded tenant context inside Direct Line tokens.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct TenantClaims {
17    pub env: String,
18    pub tenant: String,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub team: Option<String>,
21}
22
23/// JWT payload used for Direct Line tokens.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct Claims {
26    pub iss: String,
27    pub aud: String,
28    pub sub: String,
29    pub exp: i64,
30    pub iat: i64,
31    pub nbf: i64,
32    pub ctx: TenantClaims,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub conv: Option<String>,
35}
36
37impl Claims {
38    /// Creates a new claim with the provided subject and tenant context.
39    pub fn new(sub: String, ctx: TenantClaims, ttl: TimeDuration) -> Self {
40        let now = OffsetDateTime::now_utc();
41        let exp = now + ttl;
42        Self {
43            iss: ISSUER.into(),
44            aud: AUDIENCE.into(),
45            sub,
46            exp: exp.unix_timestamp(),
47            iat: now.unix_timestamp(),
48            nbf: now.unix_timestamp(),
49            ctx,
50            conv: None,
51        }
52    }
53
54    /// Returns a copy bound to the supplied conversation identifier.
55    pub fn with_conversation(mut self, conversation_id: impl Into<String>) -> Self {
56        self.conv = Some(conversation_id.into());
57        self
58    }
59
60    /// Returns true when the claim has been bound to a conversation.
61    pub fn has_conversation(&self, conversation_id: &str) -> bool {
62        self.conv
63            .as_ref()
64            .map(|conv| conv.eq_ignore_ascii_case(conversation_id))
65            .unwrap_or(false)
66    }
67}
68
69/// Signing/verification entry point. Lazily initialised from configuration.
70#[derive(Clone)]
71pub struct JwtKeys {
72    encoding: EncodingKey,
73    decoding: DecodingKey,
74}
75
76impl JwtKeys {
77    fn from_config(keys: &SigningKeys) -> anyhow::Result<Self> {
78        let encoding = EncodingKey::from_secret(keys.secret.as_bytes());
79        let decoding = DecodingKey::from_secret(keys.secret.as_bytes());
80        Ok(Self { encoding, decoding })
81    }
82}
83
84static ACTIVE_KEYS: OnceCell<Arc<JwtKeys>> = OnceCell::new();
85
86/// Installs the JWT signing keys from configuration.
87pub fn install_keys(keys: SigningKeys) -> anyhow::Result<()> {
88    ACTIVE_KEYS
89        .set(Arc::new(JwtKeys::from_config(&keys)?))
90        .map_err(|_| anyhow::anyhow!("JWT keys have already been installed"))
91}
92
93fn active_keys() -> anyhow::Result<Arc<JwtKeys>> {
94    ACTIVE_KEYS
95        .get()
96        .cloned()
97        .context("JWT signing keys not initialised")
98}
99
100/// Serialises and signs the supplied claims returning the encoded JWT.
101pub fn sign(claims: &Claims) -> anyhow::Result<String> {
102    let keys = active_keys()?;
103    let header = Header {
104        alg: Algorithm::HS256,
105        ..Header::default()
106    };
107    let token = jsonwebtoken::encode(&header, claims, &keys.encoding)?;
108    Ok(token)
109}
110
111/// Validates a token and returns the decoded claims.
112pub fn verify(token: &str) -> anyhow::Result<Claims> {
113    let keys = active_keys()?;
114    let mut validation = Validation::new(Algorithm::HS256);
115    validation.set_audience(&[AUDIENCE]);
116    validation.set_issuer(&[ISSUER]);
117    validation.leeway = 5; // seconds
118    let data = jsonwebtoken::decode::<Claims>(token, &keys.decoding, &validation)?;
119    Ok(data.claims)
120}
121
122/// Convenience for converting a chrono-like duration into seconds.
123pub fn ttl(seconds: u64) -> TimeDuration {
124    TimeDuration::seconds(seconds as i64)
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn install_test_keys() {
132        static INSTALL: once_cell::sync::OnceCell<()> = once_cell::sync::OnceCell::new();
133        INSTALL.get_or_init(|| {
134            let _ = install_keys(SigningKeys {
135                secret: "test-signing-key".into(),
136            });
137        });
138    }
139
140    #[test]
141    fn sign_and_verify_round_trip() {
142        install_test_keys();
143        let claims = Claims::new(
144            "user-1".into(),
145            TenantClaims {
146                env: "dev".into(),
147                tenant: "acme".into(),
148                team: Some("support".into()),
149            },
150            ttl(600),
151        )
152        .with_conversation("conv-42");
153        let token = sign(&claims).expect("token");
154        let parsed = verify(&token).expect("verify");
155        assert_eq!(parsed.sub, claims.sub);
156        assert_eq!(parsed.ctx.tenant, claims.ctx.tenant);
157        assert_eq!(parsed.conv, Some("conv-42".into()));
158        assert!(parsed.exp >= parsed.iat);
159    }
160}