Skip to main content

opentalk_service_auth/
api_key.rs

1// SPDX-License-Identifier: EUPL-1.2
2// SPDX-FileCopyrightText: OpenTalk Team <mail@opentalk.eu>
3
4//! The api key for service authorization
5
6use std::{str::FromStr, time::Duration};
7
8use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
9use rand::{RngExt, distr::Alphanumeric, rng};
10use serde::{Deserialize, Serialize};
11
12use crate::{ApiKeyId, ApiKeySecret};
13
14pub const JWT_EXPIRY: Duration = Duration::from_secs(60);
15
16#[derive(Debug, Clone, thiserror::Error)]
17#[error("Failed to encode JSON web token: {0:?}")]
18pub struct EncodingError(#[from] jsonwebtoken::errors::Error);
19
20/// Claims for a minimal JSON Web Token
21#[derive(Serialize)]
22struct ExpiryClaims {
23    /// Expiry
24    exp: u64,
25    /// Issued at
26    iat: u64,
27}
28
29/// The API key
30#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
31pub struct ApiKey {
32    /// The API key identifier
33    pub id: ApiKeyId,
34    /// The secret API key
35    pub secret: ApiKeySecret,
36}
37
38impl ApiKey {
39    /// Create a new API key
40    pub fn new<I, K>(id: I, secret: K) -> Self
41    where
42        I: Into<ApiKeyId>,
43        K: Into<ApiKeySecret>,
44    {
45        Self {
46            id: id.into(),
47            secret: secret.into(),
48        }
49    }
50
51    /// Create a new JSON Web Token based on the API key
52    ///
53    /// The created token has a random nonce and default expiry of [`JWT_EXPIRY`]
54    pub fn generate_jwt(&self) -> Result<String, EncodingError> {
55        let iat = get_current_timestamp();
56        let exp = iat + JWT_EXPIRY.as_secs();
57
58        let mut header = Header::new(Algorithm::HS256);
59
60        let nonce = rng()
61            .sample_iter(Alphanumeric)
62            .take(16)
63            .map(|ascii_byte| ascii_byte as char)
64            .collect();
65
66        header.kid = Some(self.id.0.clone());
67        header.nonce = Some(nonce);
68
69        Ok(encode(
70            &header,
71            &ExpiryClaims { exp, iat },
72            &EncodingKey::from_secret(self.secret.0.as_bytes()),
73        )?)
74    }
75}
76
77#[derive(Debug, thiserror::Error)]
78pub enum ParseApiKeyError {
79    #[error("missing colon delimiter")]
80    MissingColon,
81    #[error("key id must not be empty")]
82    EmptyKeyId,
83    #[error("key secret must not be empty")]
84    EmptyKeySecret,
85}
86
87impl FromStr for ApiKey {
88    type Err = ParseApiKeyError;
89
90    fn from_str(s: &str) -> Result<Self, Self::Err> {
91        match s.split_once(':') {
92            Some((key_id, secret)) => {
93                if key_id.is_empty() {
94                    return Err(ParseApiKeyError::EmptyKeyId);
95                }
96
97                if secret.is_empty() {
98                    return Err(ParseApiKeyError::EmptyKeySecret);
99                }
100
101                Ok(ApiKey::new(key_id, secret))
102            }
103            None => Err(ParseApiKeyError::MissingColon),
104        }
105    }
106}
107
108impl<'de> serde::Deserialize<'de> for ApiKey {
109    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
110    where
111        D: serde::Deserializer<'de>,
112    {
113        #[derive(Deserialize)]
114        #[serde(untagged)]
115        enum ApiKeyDeserializeHelper {
116            String(String),
117            Struct { id: ApiKeyId, secret: ApiKeySecret },
118        }
119
120        match ApiKeyDeserializeHelper::deserialize(deserializer).map_err(|_| {
121            serde::de::Error::custom(
122                "\
123                Failed to deserialize API key\n\
124                Expected structure with fields 'id' and 'secret' or string with format '<id>:<secret>'\
125                ",
126            )
127        })? {
128            ApiKeyDeserializeHelper::String(s) => ApiKey::from_str(&s)
129                .map_err(|e| serde::de::Error::custom(e.to_string())),
130            ApiKeyDeserializeHelper::Struct { id, secret } => {
131                if id.0.is_empty() {
132                    return Err(<D::Error as serde::de::Error>::custom(ParseApiKeyError::EmptyKeyId.to_string()));
133                }
134
135                if secret.0.is_empty() {
136                    return Err(<D::Error as serde::de::Error>::custom(ParseApiKeyError::EmptyKeySecret.to_string()));
137                }
138
139                Ok(ApiKey { id, secret })
140            }
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use serde::Deserialize;
148
149    use crate::api_key::ApiKey;
150
151    #[derive(Deserialize)]
152    struct Test {
153        api_key: ApiKey,
154    }
155
156    #[test]
157    fn deserialize_str() {
158        let toml = r#"        
159        api_key = "roomserver:secret"
160        "#;
161
162        let Test { api_key } = match toml::from_str(toml) {
163            Ok(c) => c,
164            Err(e) => panic!("{e}"),
165        };
166
167        assert_eq!(api_key, ApiKey::new("roomserver", "secret"))
168    }
169
170    #[test]
171    fn deserialize_missing_key_str() {
172        let toml = r#"        
173        api_key = ":secret"
174        "#;
175
176        if toml::from_str::<Test>(toml).is_ok() {
177            panic!("Expected error")
178        };
179    }
180
181    #[test]
182    fn deserialize_missing_secret_str() {
183        let toml = r#"        
184        api_key = "key_id:"
185        "#;
186
187        if toml::from_str::<Test>(toml).is_ok() {
188            panic!("Expected error")
189        };
190    }
191
192    #[test]
193    fn deserialize_missing_colon_str() {
194        let toml = r#"        
195        api_key = "key_id:"
196        "#;
197
198        if toml::from_str::<Test>(toml).is_ok() {
199            panic!("Expected error")
200        };
201    }
202
203    #[test]
204    fn deserialize_struct() {
205        let toml = r#"        
206        [api_key]
207            id = "roomserver"
208            secret = "secret"
209        "#;
210
211        let Test { api_key } = match toml::from_str(toml) {
212            Ok(c) => c,
213            Err(e) => panic!("{e}"),
214        };
215
216        assert_eq!(api_key, ApiKey::new("roomserver", "secret"))
217    }
218}