Skip to main content

objectstore_server/extractors/
service.rs

1use std::time::SystemTime;
2
3use axum::extract::{FromRequestParts, OriginalUri, Query};
4use axum::http::{Method, header, request::Parts};
5use objectstore_types::presign::PARAM_SIG;
6use serde::Deserialize;
7
8use crate::auth::{AuthAwareService, AuthContext, AuthError, PresignParams};
9use crate::endpoints::common::ApiError;
10use crate::state::ServiceState;
11
12const BEARER_PREFIX: &str = "Bearer ";
13
14/// Custom header for Objectstore authentication. Checked before the standard
15/// `Authorization` header so that proxy setups (e.g. Django) can use
16/// `Authorization` for their own auth while forwarding an Objectstore token in
17/// this header.
18const HEADER_AUTH: &str = "x-os-auth";
19
20/// Query parameters carrying authentication, as an alternative to the
21/// `x-os-auth`/`Authorization` header. The header takes precedence when both
22/// are present.
23#[derive(Debug, Deserialize)]
24struct AuthParams {
25    /// A JWT, mirroring the `x-os-auth` header value (without the `Bearer `
26    /// prefix). Lets callers embed a token directly in a URL.
27    os_auth: Option<String>,
28}
29
30impl AuthAwareService {
31    fn from_token(parts: &mut Parts, state: &ServiceState) -> Result<AuthContext, AuthError> {
32        let header_token = parts
33            .headers
34            .get(HEADER_AUTH)
35            .or_else(|| parts.headers.get(header::AUTHORIZATION))
36            .and_then(|v| v.to_str().ok())
37            .and_then(strip_bearer);
38
39        let query_token = match header_token {
40            Some(_) => None,
41            None => {
42                Query::<AuthParams>::try_from_uri(&parts.uri)
43                    .map_err(|_| AuthError::BadRequest("invalid query string"))?
44                    .0
45                    .os_auth
46            }
47        };
48
49        let token = header_token.or(query_token.as_deref());
50
51        AuthContext::from_encoded_jwt(token, &state.key_directory)
52    }
53
54    async fn from_presigned_request(
55        parts: &mut Parts,
56        state: &ServiceState,
57    ) -> Result<AuthContext, AuthError> {
58        if !matches!(&parts.method, &Method::GET | &Method::HEAD) {
59            return Err(AuthError::UnsupportedPresignedMethod);
60        }
61
62        let Query(params) = Query::<PresignParams>::from_request_parts(parts, state)
63            .await
64            .map_err(|_| {
65                AuthError::BadRequest("presigned URL has missing or invalid parameters")
66            })?;
67
68        // The client signs the full public path, but `Router::nest` strips the `/v1`
69        // prefix from `parts.uri`. Recover the original path from `OriginalUri`.
70        let path = parts
71            .extensions
72            .get::<OriginalUri>()
73            .ok_or(AuthError::InternalError(
74                "OriginalUri extension missing".into(),
75            ))?
76            .0
77            .path();
78
79        AuthContext::from_presigned_request(
80            &parts.method,
81            path,
82            parts.uri.query(),
83            &params,
84            &state.key_directory,
85            SystemTime::now(),
86        )
87    }
88}
89
90impl FromRequestParts<ServiceState> for AuthAwareService {
91    type Rejection = ApiError;
92
93    async fn from_request_parts(
94        parts: &mut Parts,
95        state: &ServiceState,
96    ) -> Result<Self, Self::Rejection> {
97        let enforce = state.config.auth.enforce;
98        if !state.config.auth.is_active() {
99            return Ok(AuthAwareService::new(
100                state.service.clone(),
101                AuthContext::Disabled,
102                enforce,
103            ));
104        }
105
106        let auth_result = if has_signature(parts.uri.query()) {
107            AuthAwareService::from_presigned_request(parts, state).await
108        } else {
109            AuthAwareService::from_token(parts, state)
110        }
111        .inspect_err(|e| e.log(!enforce));
112
113        // If enforcement is disabled, proceed without an auth context even on failure
114        let auth = match auth_result {
115            Ok(auth) => auth,
116            Err(error) if enforce => return Err(ApiError::Auth(error)),
117            Err(_) => AuthContext::Disabled,
118        };
119
120        Ok(AuthAwareService::new(state.service.clone(), auth, enforce))
121    }
122}
123
124/// Returns whether the query string carries a pre-signed URL signature (`os-sig`).
125fn has_signature(query: Option<&str>) -> bool {
126    query.is_some_and(|query| {
127        query
128            .split('&')
129            .any(|pair| pair.split_once('=').map_or(pair, |(key, _)| key) == PARAM_SIG)
130    })
131}
132
133fn strip_bearer(header_value: &str) -> Option<&str> {
134    let (prefix, tail) = header_value.split_at_checked(BEARER_PREFIX.len())?;
135    if prefix.eq_ignore_ascii_case(BEARER_PREFIX) {
136        Some(tail)
137    } else {
138        None
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_strip_bearer() {
148        // Prefix matches
149        assert_eq!(strip_bearer("Bearer tokenvalue"), Some("tokenvalue"));
150        assert_eq!(strip_bearer("bearer tokenvalue"), Some("tokenvalue"));
151        assert_eq!(strip_bearer("BEARER tokenvalue"), Some("tokenvalue"));
152
153        // Prefix doesn't match
154        assert_eq!(strip_bearer("Token tokenvalue"), None);
155        assert_eq!(strip_bearer("Bearer"), None);
156
157        // No character boundary at end of expected prefix
158        assert_eq!(strip_bearer("Bearer⚠️tokenvalue"), None);
159    }
160
161    #[test]
162    fn test_has_presign_signature() {
163        assert!(has_signature(Some("os_sig=abc")));
164        assert!(has_signature(Some("os_kid=relay&os_sig=abc")));
165
166        assert!(!has_signature(Some("OS_SIG=abc")));
167        assert!(!has_signature(None));
168        assert!(!has_signature(Some("os_kid=relay")));
169    }
170
171    #[test]
172    fn test_auth_params_from_query() {
173        fn parse(query: &str) -> Option<String> {
174            let uri = format!("http://localhost/?{query}").parse().unwrap();
175            Query::<AuthParams>::try_from_uri(&uri).unwrap().0.os_auth
176        }
177
178        let jwt = "header.payload.signature";
179
180        // Present, and correctly URL-decoded (a proxy may percent-encode `.`).
181        assert_eq!(parse(&format!("os_auth={jwt}")), Some(jwt.to_owned()));
182        assert_eq!(parse("os_auth=a%2Eb"), Some("a.b".to_owned()));
183        assert_eq!(
184            parse(&format!("foo=bar&os_auth={jwt}")),
185            Some(jwt.to_owned())
186        );
187
188        // Absent: gracefully `None`, not an error.
189        assert_eq!(parse(""), None);
190        assert_eq!(parse("foo=bar"), None);
191    }
192}