Skip to main content

static_authn_plugin/domain/
service.rs

1//! Service implementation for the static `AuthN` resolver plugin.
2
3use 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/// Static `AuthN` resolver service.
13///
14/// Provides token-to-identity mapping based on configuration mode:
15/// - `accept_all`: Any non-empty token maps to the default identity
16/// - `static_tokens`: Specific tokens map to specific identities
17#[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/// Internal entry for S2S credential lookup.
26#[domain_model]
27struct S2sEntry {
28    client_secret: SecretString,
29    identity: IdentityConfig,
30}
31
32impl Service {
33    /// Create a service from plugin configuration.
34    #[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    /// Authenticate a bearer token and return the identity.
67    ///
68    /// Returns `None` if the token is not recognized (in `static_tokens` mode)
69    /// or empty.
70    #[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    /// Exchange client credentials for a `SecurityContext`.
85    ///
86    /// Looks up `client_id` in the configured S2S credentials and verifies
87    /// the `client_secret`. Returns `None` if credentials are not found or
88    /// do not match.
89    #[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        // S2S exchange does not set bearer_token (no real token issued)
306        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}