static_authn_plugin/domain/
service.rs1use std::collections::HashMap;
4
5use modkit_macros::domain_model;
6use modkit_security::SecurityContext;
7use secrecy::{ExposeSecret, SecretString};
8
9use crate::config::{AuthNMode, IdentityConfig, StaticAuthNPluginConfig};
10use authn_resolver_sdk::{AuthenticationResult, ClientCredentialsRequest};
11
12#[domain_model]
18pub struct Service {
19 mode: AuthNMode,
20 default_identity: IdentityConfig,
21 token_map: HashMap<String, IdentityConfig>,
22 s2s_credentials: HashMap<String, S2sEntry>,
23}
24
25#[domain_model]
27struct S2sEntry {
28 client_secret: SecretString,
29 identity: IdentityConfig,
30}
31
32impl Service {
33 #[must_use]
35 pub fn from_config(cfg: &StaticAuthNPluginConfig) -> Self {
36 let token_map: HashMap<String, IdentityConfig> = cfg
37 .tokens
38 .iter()
39 .map(|m| (m.token.clone(), m.identity.clone()))
40 .collect();
41
42 let s2s_credentials: HashMap<String, S2sEntry> = cfg
43 .s2s_credentials
44 .iter()
45 .map(|m| {
46 (
47 m.client_id.clone(),
48 S2sEntry {
49 client_secret: SecretString::from(
50 m.client_secret.expose_secret().to_owned(),
51 ),
52 identity: m.identity.clone(),
53 },
54 )
55 })
56 .collect();
57
58 Self {
59 mode: cfg.mode.clone(),
60 default_identity: cfg.default_identity.clone(),
61 token_map,
62 s2s_credentials,
63 }
64 }
65
66 #[must_use]
71 pub fn authenticate(&self, bearer_token: &str) -> Option<AuthenticationResult> {
72 if bearer_token.is_empty() {
73 return None;
74 }
75
76 let identity = match &self.mode {
77 AuthNMode::AcceptAll => &self.default_identity,
78 AuthNMode::StaticTokens => self.token_map.get(bearer_token)?,
79 };
80
81 build_result(identity, Some(bearer_token))
82 }
83
84 #[must_use]
90 pub fn exchange_client_credentials(
91 &self,
92 request: &ClientCredentialsRequest,
93 ) -> Option<AuthenticationResult> {
94 let entry = self.s2s_credentials.get(&request.client_id)?;
95 if entry.client_secret.expose_secret() != request.client_secret.expose_secret() {
96 return None;
97 }
98 build_result(&entry.identity, None)
99 }
100}
101
102fn build_result(
103 identity: &IdentityConfig,
104 bearer_token: Option<&str>,
105) -> Option<AuthenticationResult> {
106 let mut builder = SecurityContext::builder()
107 .subject_id(identity.subject_id)
108 .subject_tenant_id(identity.subject_tenant_id)
109 .token_scopes(identity.token_scopes.clone());
110
111 if let Some(st) = &identity.subject_type {
112 builder = builder.subject_type(st);
113 }
114 if let Some(token) = bearer_token {
115 builder = builder.bearer_token(token.to_owned());
116 }
117
118 let ctx = builder
119 .build()
120 .map_err(|e| tracing::error!("Failed to build SecurityContext from config: {e}"))
121 .ok()?;
122
123 Some(AuthenticationResult {
124 security_context: ctx,
125 })
126}
127
128#[cfg(test)]
129#[cfg_attr(coverage_nightly, coverage(off))]
130mod tests {
131 use secrecy::{ExposeSecret, SecretString};
132
133 use super::*;
134 use crate::config::{S2sCredentialMapping, TokenMapping};
135 use uuid::Uuid;
136
137 fn default_config() -> StaticAuthNPluginConfig {
138 StaticAuthNPluginConfig::default()
139 }
140
141 #[test]
142 fn accept_all_mode_returns_default_identity() {
143 let service = Service::from_config(&default_config());
144
145 let result = service.authenticate("any-token-value");
146 assert!(result.is_some());
147
148 let auth = result.unwrap();
149 let ctx = &auth.security_context;
150 assert_eq!(
151 ctx.subject_id(),
152 modkit_security::constants::DEFAULT_SUBJECT_ID
153 );
154 assert_eq!(
155 ctx.subject_tenant_id(),
156 modkit_security::constants::DEFAULT_TENANT_ID
157 );
158 assert_eq!(ctx.token_scopes(), &["*"]);
159 assert_eq!(
160 ctx.bearer_token().map(ExposeSecret::expose_secret),
161 Some("any-token-value"),
162 );
163 }
164
165 #[test]
166 fn accept_all_mode_rejects_empty_token() {
167 let service = Service::from_config(&default_config());
168
169 let result = service.authenticate("");
170 assert!(result.is_none());
171 }
172
173 #[test]
174 fn static_tokens_mode_returns_mapped_identity() {
175 let user_a_id = Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap();
176 let tenant_a = Uuid::parse_str("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb").unwrap();
177
178 let cfg = StaticAuthNPluginConfig {
179 mode: AuthNMode::StaticTokens,
180 tokens: vec![TokenMapping {
181 token: "token-user-a".to_owned(),
182 identity: IdentityConfig {
183 subject_id: user_a_id,
184 subject_tenant_id: tenant_a,
185 token_scopes: vec!["read:data".to_owned()],
186 subject_type: None,
187 },
188 }],
189 ..default_config()
190 };
191
192 let service = Service::from_config(&cfg);
193
194 let result = service.authenticate("token-user-a");
195 assert!(result.is_some());
196
197 let auth = result.unwrap();
198 let ctx = &auth.security_context;
199 assert_eq!(ctx.subject_id(), user_a_id);
200 assert_eq!(ctx.subject_tenant_id(), tenant_a);
201 assert_eq!(ctx.token_scopes(), &["read:data"]);
202 assert_eq!(
203 ctx.bearer_token().map(ExposeSecret::expose_secret),
204 Some("token-user-a"),
205 );
206 }
207
208 #[test]
209 fn static_tokens_mode_rejects_unknown_token() {
210 let cfg = StaticAuthNPluginConfig {
211 mode: AuthNMode::StaticTokens,
212 tokens: vec![TokenMapping {
213 token: "known-token".to_owned(),
214 identity: IdentityConfig::default(),
215 }],
216 ..default_config()
217 };
218
219 let service = Service::from_config(&cfg);
220
221 let result = service.authenticate("unknown-token");
222 assert!(result.is_none());
223 }
224
225 #[test]
226 fn static_tokens_mode_rejects_empty_token() {
227 let cfg = StaticAuthNPluginConfig {
228 mode: AuthNMode::StaticTokens,
229 tokens: vec![],
230 ..default_config()
231 };
232
233 let service = Service::from_config(&cfg);
234
235 let result = service.authenticate("");
236 assert!(result.is_none());
237 }
238
239 #[test]
240 fn subject_type_propagated_in_security_context() {
241 let cfg = StaticAuthNPluginConfig {
242 default_identity: IdentityConfig {
243 subject_type: Some("user".to_owned()),
244 ..IdentityConfig::default()
245 },
246 ..default_config()
247 };
248
249 let service = Service::from_config(&cfg);
250 let result = service.authenticate("any-token").unwrap();
251 assert_eq!(result.security_context.subject_type(), Some("user"));
252 }
253
254 #[test]
255 fn subject_type_none_when_not_configured() {
256 let service = Service::from_config(&default_config());
257 let result = service.authenticate("any-token").unwrap();
258 assert_eq!(result.security_context.subject_type(), None);
259 }
260
261 fn s2s_config() -> StaticAuthNPluginConfig {
262 let svc_id = Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap();
263 let svc_tenant = Uuid::parse_str("dddddddd-dddd-dddd-dddd-dddddddddddd").unwrap();
264
265 StaticAuthNPluginConfig {
266 s2s_credentials: vec![S2sCredentialMapping {
267 client_id: "my-service".to_owned(),
268 client_secret: SecretString::from("my-secret"),
269 identity: IdentityConfig {
270 subject_id: svc_id,
271 subject_tenant_id: svc_tenant,
272 token_scopes: vec!["platform.internal".to_owned()],
273 subject_type: Some("service".to_owned()),
274 },
275 }],
276 ..default_config()
277 }
278 }
279
280 #[test]
281 fn s2s_exchange_returns_identity_for_valid_credentials() {
282 let service = Service::from_config(&s2s_config());
283
284 let request = ClientCredentialsRequest {
285 client_id: "my-service".to_owned(),
286 client_secret: SecretString::from("my-secret"),
287 scopes: vec![],
288 };
289
290 let result = service.exchange_client_credentials(&request);
291 assert!(result.is_some());
292
293 let auth = result.unwrap();
294 let ctx = &auth.security_context;
295 assert_eq!(
296 ctx.subject_id(),
297 Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap()
298 );
299 assert_eq!(
300 ctx.subject_tenant_id(),
301 Uuid::parse_str("dddddddd-dddd-dddd-dddd-dddddddddddd").unwrap()
302 );
303 assert_eq!(ctx.token_scopes(), &["platform.internal"]);
304 assert_eq!(ctx.subject_type(), Some("service"));
305 assert!(ctx.bearer_token().is_none());
307 }
308
309 #[test]
310 fn s2s_exchange_rejects_wrong_secret() {
311 let service = Service::from_config(&s2s_config());
312
313 let request = ClientCredentialsRequest {
314 client_id: "my-service".to_owned(),
315 client_secret: SecretString::from("wrong-secret"),
316 scopes: vec![],
317 };
318
319 let result = service.exchange_client_credentials(&request);
320 assert!(result.is_none());
321 }
322
323 #[test]
324 fn s2s_exchange_rejects_unknown_client_id() {
325 let service = Service::from_config(&s2s_config());
326
327 let request = ClientCredentialsRequest {
328 client_id: "unknown-service".to_owned(),
329 client_secret: SecretString::from("my-secret"),
330 scopes: vec![],
331 };
332
333 let result = service.exchange_client_credentials(&request);
334 assert!(result.is_none());
335 }
336
337 #[test]
338 fn s2s_exchange_returns_none_with_no_credentials_configured() {
339 let service = Service::from_config(&default_config());
340
341 let request = ClientCredentialsRequest {
342 client_id: "any-service".to_owned(),
343 client_secret: SecretString::from("any-secret"),
344 scopes: vec![],
345 };
346
347 let result = service.exchange_client_credentials(&request);
348 assert!(result.is_none());
349 }
350}