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