Skip to main content

modo/auth/apikey/
config.rs

1use serde::Deserialize;
2
3use crate::error::{Error, Result};
4
5fn default_prefix() -> String {
6    "modo".into()
7}
8
9fn default_secret_length() -> usize {
10    32
11}
12
13fn default_touch_threshold_secs() -> u64 {
14    60
15}
16
17/// Configuration for the API key module.
18///
19/// Deserialised from the `apikey` key in the application YAML config.
20/// All fields have defaults, so an empty `apikey:` block is valid.
21///
22/// # YAML example
23///
24/// ```yaml
25/// apikey:
26///   prefix: "modo"
27///   secret_length: 32
28///   touch_threshold_secs: 60
29/// ```
30#[non_exhaustive]
31#[derive(Debug, Clone, Deserialize)]
32#[serde(default)]
33pub struct ApiKeyConfig {
34    /// Key prefix prepended before the underscore separator.
35    /// Must be `[a-zA-Z0-9]`, 1-20 characters.
36    /// Defaults to `"modo"`.
37    #[serde(default = "default_prefix")]
38    pub prefix: String,
39    /// Length of the random secret portion in base62 characters.
40    /// Minimum 16. Defaults to `32`.
41    #[serde(default = "default_secret_length")]
42    pub secret_length: usize,
43    /// Minimum interval between `last_used_at` updates, in seconds.
44    /// Defaults to `60` (1 minute).
45    #[serde(default = "default_touch_threshold_secs")]
46    pub touch_threshold_secs: u64,
47}
48
49impl Default for ApiKeyConfig {
50    fn default() -> Self {
51        Self {
52            prefix: "modo".into(),
53            secret_length: 32,
54            touch_threshold_secs: 60,
55        }
56    }
57}
58
59impl ApiKeyConfig {
60    /// Validate the configuration.
61    ///
62    /// # Errors
63    ///
64    /// Returns `Error::bad_request` if the prefix is invalid or secret length
65    /// is too short.
66    pub fn validate(&self) -> Result<()> {
67        if self.prefix.is_empty() || self.prefix.len() > 20 {
68            return Err(Error::bad_request("apikey prefix must be 1-20 characters"));
69        }
70        if !self.prefix.chars().all(|c| c.is_ascii_alphanumeric()) {
71            return Err(Error::bad_request(
72                "apikey prefix must contain only ASCII alphanumeric characters",
73            ));
74        }
75        if self.secret_length < 16 {
76            return Err(Error::bad_request(
77                "apikey secret_length must be at least 16",
78            ));
79        }
80        Ok(())
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn default_config_is_valid() {
90        let config = ApiKeyConfig::default();
91        assert!(config.validate().is_ok());
92    }
93
94    #[test]
95    fn reject_empty_prefix() {
96        let config = ApiKeyConfig {
97            prefix: "".into(),
98            ..Default::default()
99        };
100        let err = config.validate().unwrap_err();
101        assert_eq!(err.status(), http::StatusCode::BAD_REQUEST);
102    }
103
104    #[test]
105    fn reject_prefix_over_20_chars() {
106        let config = ApiKeyConfig {
107            prefix: "a".repeat(21),
108            ..Default::default()
109        };
110        let err = config.validate().unwrap_err();
111        assert_eq!(err.status(), http::StatusCode::BAD_REQUEST);
112    }
113
114    #[test]
115    fn reject_prefix_with_underscore() {
116        let config = ApiKeyConfig {
117            prefix: "my_prefix".into(),
118            ..Default::default()
119        };
120        let err = config.validate().unwrap_err();
121        assert_eq!(err.status(), http::StatusCode::BAD_REQUEST);
122    }
123
124    #[test]
125    fn reject_prefix_with_special_chars() {
126        let config = ApiKeyConfig {
127            prefix: "my-prefix".into(),
128            ..Default::default()
129        };
130        let err = config.validate().unwrap_err();
131        assert_eq!(err.status(), http::StatusCode::BAD_REQUEST);
132    }
133
134    #[test]
135    fn reject_short_secret_length() {
136        let config = ApiKeyConfig {
137            secret_length: 15,
138            ..Default::default()
139        };
140        let err = config.validate().unwrap_err();
141        assert_eq!(err.status(), http::StatusCode::BAD_REQUEST);
142    }
143
144    #[test]
145    fn accept_minimum_secret_length() {
146        let config = ApiKeyConfig {
147            secret_length: 16,
148            ..Default::default()
149        };
150        assert!(config.validate().is_ok());
151    }
152
153    #[test]
154    fn deserialize_from_yaml() {
155        let yaml = r#"
156prefix: "sk"
157secret_length: 48
158touch_threshold_secs: 120
159"#;
160        let config: ApiKeyConfig = serde_yaml_ng::from_str(yaml).unwrap();
161        assert_eq!(config.prefix, "sk");
162        assert_eq!(config.secret_length, 48);
163        assert_eq!(config.touch_threshold_secs, 120);
164    }
165
166    #[test]
167    fn defaults_applied_when_fields_omitted() {
168        let yaml = "{}";
169        let config: ApiKeyConfig = serde_yaml_ng::from_str(yaml).unwrap();
170        assert_eq!(config.prefix, "modo");
171        assert_eq!(config.secret_length, 32);
172        assert_eq!(config.touch_threshold_secs, 60);
173    }
174}