Skip to main content

rskit_auth/apikey/
middleware.rs

1//! Tower middleware for API key validation.
2
3use async_trait::async_trait;
4use http::{Request, Response, StatusCode, header::HeaderName};
5use rskit_errors::AppError;
6use std::sync::Arc;
7use tower::{Layer, Service};
8
9use super::Key;
10use crate::{AuthOutcome, MissingCredentialPolicy};
11
12/// Validates an API key and returns its metadata.
13#[async_trait]
14pub trait KeyValidator: Send + Sync {
15    /// Look up a key by its plaintext value, validate it, and return metadata.
16    async fn validate_key(&self, plain_key: &str) -> Result<Key, AppError>;
17}
18
19/// Tower Layer for API key validation.
20///
21/// If the configured header is absent, the request is rejected unless
22/// [`ApiKeyLayer::accept_missing`] is enabled.
23/// If present but invalid, returns 401.
24#[derive(Clone)]
25pub struct ApiKeyLayer<V> {
26    validator: Arc<V>,
27    header_name: HeaderName,
28    missing_policy: MissingCredentialPolicy,
29}
30
31impl<V: KeyValidator + 'static> ApiKeyLayer<V> {
32    /// Create a new layer using the default `x-api-key` header.
33    pub fn new(validator: V) -> Self {
34        Self {
35            validator: Arc::new(validator),
36            header_name: HeaderName::from_static("x-api-key"),
37            missing_policy: MissingCredentialPolicy::RejectMissing,
38        }
39    }
40
41    /// Override the header name used for key extraction.
42    #[must_use]
43    pub fn with_header(mut self, name: HeaderName) -> Self {
44        self.header_name = name;
45        self
46    }
47
48    /// Explicitly accept requests with no API-key header.
49    #[must_use]
50    pub const fn accept_missing(mut self) -> Self {
51        self.missing_policy = MissingCredentialPolicy::AcceptMissing;
52        self
53    }
54}
55
56impl<S, V> Layer<S> for ApiKeyLayer<V>
57where
58    V: KeyValidator + 'static,
59{
60    type Service = ApiKeyService<S, V>;
61
62    fn layer(&self, inner: S) -> Self::Service {
63        ApiKeyService {
64            inner,
65            validator: Arc::clone(&self.validator),
66            header_name: self.header_name.clone(),
67            missing_policy: self.missing_policy,
68        }
69    }
70}
71
72#[derive(Clone)]
73pub struct ApiKeyService<S, V> {
74    inner: S,
75    validator: Arc<V>,
76    header_name: HeaderName,
77    missing_policy: MissingCredentialPolicy,
78}
79
80impl<S, V, ReqBody, ResBody> Service<Request<ReqBody>> for ApiKeyService<S, V>
81where
82    S: Service<Request<ReqBody>, Response = Response<ResBody>> + Clone + Send + 'static,
83    S::Future: Send + 'static,
84    V: KeyValidator + 'static,
85    ReqBody: Send + 'static,
86    ResBody: Default + Send + 'static,
87{
88    type Response = S::Response;
89    type Error = S::Error;
90    type Future = std::pin::Pin<
91        Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
92    >;
93
94    fn poll_ready(
95        &mut self,
96        cx: &mut std::task::Context<'_>,
97    ) -> std::task::Poll<Result<(), Self::Error>> {
98        self.inner.poll_ready(cx)
99    }
100
101    fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
102        let clone = self.inner.clone();
103        let mut inner = std::mem::replace(&mut self.inner, clone);
104        let validator = Arc::clone(&self.validator);
105        let header_name = self.header_name.clone();
106        let missing_policy = self.missing_policy;
107
108        Box::pin(async move {
109            match extract_api_key(&req, &header_name) {
110                CredentialExtraction::Missing => {
111                    if missing_policy == MissingCredentialPolicy::AcceptMissing {
112                        let mut req = req;
113                        req.extensions_mut().remove::<Key>();
114                        req.extensions_mut().insert(AuthOutcome::<Key>::Missing);
115                        inner.call(req).await
116                    } else {
117                        Ok(unauthorized_response())
118                    }
119                }
120                CredentialExtraction::Invalid => Ok(unauthorized_response()),
121                CredentialExtraction::Present(plain_key) => {
122                    if let Ok(key) = validator.validate_key(plain_key).await {
123                        let mut req = req;
124                        req.extensions_mut().insert(key.clone());
125                        req.extensions_mut().insert(AuthOutcome::Authenticated(key));
126                        inner.call(req).await
127                    } else {
128                        Ok(unauthorized_response())
129                    }
130                }
131            }
132        })
133    }
134}
135
136enum CredentialExtraction<'a> {
137    Missing,
138    Invalid,
139    Present(&'a str),
140}
141
142fn extract_api_key<'a, B>(
143    req: &'a Request<B>,
144    header_name: &HeaderName,
145) -> CredentialExtraction<'a> {
146    let mut values = req.headers().get_all(header_name).iter();
147    let Some(value) = values.next() else {
148        return CredentialExtraction::Missing;
149    };
150    if values.next().is_some() {
151        return CredentialExtraction::Invalid;
152    }
153    let Ok(value) = value.to_str() else {
154        return CredentialExtraction::Invalid;
155    };
156    if value.trim().is_empty() || value.chars().any(char::is_whitespace) {
157        return CredentialExtraction::Invalid;
158    }
159    CredentialExtraction::Present(value)
160}
161
162fn unauthorized_response<ResBody: Default>() -> Response<ResBody> {
163    let mut response = Response::new(ResBody::default());
164    *response.status_mut() = StatusCode::UNAUTHORIZED;
165    response
166}
167
168#[cfg(test)]
169mod tests {
170    use std::{convert::Infallible, future::Ready};
171
172    use async_trait::async_trait;
173    use chrono::Utc;
174    use http::{Request, Response, StatusCode};
175    use rskit_errors::AppError;
176    use tower::{Layer, Service, ServiceExt};
177
178    use super::{ApiKeyLayer, CredentialExtraction, KeyValidator, extract_api_key};
179    use crate::{AuthOutcome, apikey::Key};
180
181    struct Validator;
182
183    #[async_trait]
184    impl KeyValidator for Validator {
185        async fn validate_key(&self, plain_key: &str) -> Result<Key, AppError> {
186            if plain_key == "pk.secret" {
187                Ok(Key {
188                    id: "key-1".into(),
189                    owner_id: "user-1".into(),
190                    name: "primary".into(),
191                    key_prefix: "pk".into(),
192                    key_digest: "digest".into(),
193                    scopes: vec!["read".into()],
194                    is_active: true,
195                    expires_at: None,
196                    grace_ends_at: None,
197                    rotated_by_id: None,
198                    last_used_at: None,
199                    created_at: Utc::now(),
200                })
201            } else {
202                Err(AppError::invalid_token())
203            }
204        }
205    }
206
207    #[derive(Clone)]
208    struct ExtensionCheckingService;
209
210    impl Service<Request<()>> for ExtensionCheckingService {
211        type Response = Response<()>;
212        type Error = Infallible;
213        type Future = Ready<Result<Self::Response, Self::Error>>;
214
215        fn poll_ready(
216            &mut self,
217            _cx: &mut std::task::Context<'_>,
218        ) -> std::task::Poll<Result<(), Self::Error>> {
219            std::task::Poll::Ready(Ok(()))
220        }
221
222        fn call(&mut self, request: Request<()>) -> Self::Future {
223            let status = if request.extensions().get::<Key>().is_some()
224                && request.extensions().get::<AuthOutcome<Key>>().is_some()
225            {
226                StatusCode::OK
227            } else if matches!(
228                request.extensions().get::<AuthOutcome<Key>>(),
229                Some(AuthOutcome::Missing)
230            ) {
231                StatusCode::NO_CONTENT
232            } else {
233                StatusCode::IM_A_TEAPOT
234            };
235            std::future::ready(Ok(Response::builder().status(status).body(()).unwrap()))
236        }
237    }
238
239    #[test]
240    fn api_key_extraction_requires_single_non_empty_header() {
241        let header = http::header::HeaderName::from_static("x-api-key");
242        let request = http::Request::builder()
243            .header(&header, "pk.secret")
244            .body(())
245            .unwrap();
246        assert!(matches!(
247            extract_api_key(&request, &header),
248            CredentialExtraction::Present("pk.secret")
249        ));
250
251        let request = http::Request::builder()
252            .header(&header, "one")
253            .header(&header, "two")
254            .body(())
255            .unwrap();
256        assert!(matches!(
257            extract_api_key(&request, &header),
258            CredentialExtraction::Invalid
259        ));
260    }
261
262    #[test]
263    fn api_key_extraction_rejects_blank_or_whitespace_values() {
264        let header = http::header::HeaderName::from_static("x-api-key");
265        let missing = http::Request::builder().body(()).unwrap();
266        assert!(matches!(
267            extract_api_key(&missing, &header),
268            CredentialExtraction::Missing
269        ));
270
271        for value in ["", "   ", "pk.secret with-space"] {
272            let request = http::Request::builder()
273                .header(&header, value)
274                .body(())
275                .unwrap();
276            assert!(matches!(
277                extract_api_key(&request, &header),
278                CredentialExtraction::Invalid
279            ));
280        }
281    }
282
283    #[tokio::test]
284    async fn api_key_layer_rejects_missing_by_default_and_accepts_valid_keys() {
285        let mut service = ApiKeyLayer::new(Validator).layer(ExtensionCheckingService);
286
287        let missing = service
288            .ready()
289            .await
290            .unwrap()
291            .call(Request::builder().body(()).unwrap())
292            .await
293            .unwrap();
294        assert_eq!(missing.status(), StatusCode::UNAUTHORIZED);
295
296        let valid = service
297            .ready()
298            .await
299            .unwrap()
300            .call(
301                Request::builder()
302                    .header("x-api-key", "pk.secret")
303                    .body(())
304                    .unwrap(),
305            )
306            .await
307            .unwrap();
308        assert_eq!(valid.status(), StatusCode::OK);
309    }
310
311    #[tokio::test]
312    async fn api_key_layer_uses_configured_header_name() {
313        let mut service = ApiKeyLayer::new(Validator)
314            .with_header(http::header::HeaderName::from_static("x-service-key"))
315            .layer(ExtensionCheckingService);
316
317        let default_header = service
318            .ready()
319            .await
320            .unwrap()
321            .call(
322                Request::builder()
323                    .header("x-api-key", "pk.secret")
324                    .body(())
325                    .unwrap(),
326            )
327            .await
328            .unwrap();
329        assert_eq!(default_header.status(), StatusCode::UNAUTHORIZED);
330
331        let custom_header = service
332            .ready()
333            .await
334            .unwrap()
335            .call(
336                Request::builder()
337                    .header("x-service-key", "pk.secret")
338                    .body(())
339                    .unwrap(),
340            )
341            .await
342            .unwrap();
343        assert_eq!(custom_header.status(), StatusCode::OK);
344    }
345
346    #[tokio::test]
347    async fn api_key_layer_accept_missing_does_not_accept_invalid_keys() {
348        let mut service = ApiKeyLayer::new(Validator)
349            .accept_missing()
350            .layer(ExtensionCheckingService);
351
352        let missing = service
353            .call(Request::builder().body(()).unwrap())
354            .await
355            .unwrap();
356        assert_eq!(missing.status(), StatusCode::NO_CONTENT);
357
358        let invalid = service
359            .call(
360                Request::builder()
361                    .header("x-api-key", "pk.bad")
362                    .body(())
363                    .unwrap(),
364            )
365            .await
366            .unwrap();
367        assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
368    }
369
370    #[tokio::test]
371    async fn api_key_layer_accept_missing_clears_stale_key() {
372        let mut service = ApiKeyLayer::new(Validator)
373            .accept_missing()
374            .layer(ExtensionCheckingService);
375        let mut request = Request::builder().body(()).unwrap();
376        request.extensions_mut().insert(Key {
377            id: "stale-key".into(),
378            owner_id: "user-1".into(),
379            name: "stale".into(),
380            key_prefix: "pk".into(),
381            key_digest: "digest".into(),
382            scopes: vec!["read".into()],
383            is_active: true,
384            expires_at: None,
385            grace_ends_at: None,
386            rotated_by_id: None,
387            last_used_at: None,
388            created_at: Utc::now(),
389        });
390
391        let response = service.call(request).await.unwrap();
392
393        assert_eq!(response.status(), StatusCode::NO_CONTENT);
394    }
395}