modo/auth/apikey/
config.rs1use 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#[non_exhaustive]
31#[derive(Debug, Clone, Deserialize)]
32#[serde(default)]
33pub struct ApiKeyConfig {
34 #[serde(default = "default_prefix")]
38 pub prefix: String,
39 #[serde(default = "default_secret_length")]
42 pub secret_length: usize,
43 #[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 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}