Skip to main content

servers/http/
authorize.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use ::auth::UserProviderRef;
16use api::v1::Basic;
17use axum::extract::{Request, State};
18use axum::http::{self, StatusCode};
19use axum::middleware::Next;
20use axum::response::{IntoResponse, Response};
21use base64::Engine;
22use base64::prelude::BASE64_STANDARD;
23use common_base::secrets::{ExposeSecret, SecretString};
24use common_catalog::consts::DEFAULT_SCHEMA_NAME;
25use common_catalog::parse_catalog_and_schema_from_db_string;
26use common_error::ext::ErrorExt;
27use common_telemetry::warn;
28use common_time::Timezone;
29use common_time::timezone::parse_timezone;
30use headers::Header;
31use session::context::QueryContextBuilder;
32use snafu::{OptionExt, ResultExt, ensure};
33
34use crate::error::{
35    self, InvalidAuthHeaderInvisibleASCIISnafu, InvalidAuthHeaderSnafu, InvalidParameterSnafu,
36    NotFoundAuthHeaderSnafu, NotFoundInfluxAuthSnafu, Result, UnsupportedAuthSchemeSnafu,
37    UrlDecodeSnafu,
38};
39use crate::http::header::{GREPTIME_TIMEZONE_HEADER_NAME, GreptimeDbName};
40use crate::http::result::error_result::ErrorResponse;
41use crate::http::splunk::is_splunk_request;
42use crate::http::{AUTHORIZATION_HEADER, HTTP_API_PREFIX, PUBLIC_API_PREFIX};
43use crate::influxdb::{is_influxdb_request, is_influxdb_v2_request};
44
45/// AuthState is a holder state for [`UserProviderRef`]
46/// during [`check_http_auth`] function in axum's middleware
47#[derive(Clone)]
48pub struct AuthState {
49    user_provider: Option<UserProviderRef>,
50}
51
52impl AuthState {
53    pub fn new(user_provider: Option<UserProviderRef>) -> Self {
54        Self { user_provider }
55    }
56}
57
58pub async fn inner_auth<B>(
59    user_provider: Option<UserProviderRef>,
60    mut req: Request<B>,
61) -> std::result::Result<Request<B>, Response> {
62    // 1. prepare
63    let (catalog, schema) = extract_catalog_and_schema(&req);
64    // TODO(ruihang): move this out of auth module
65    let timezone = extract_timezone(&req);
66    let query_ctx_builder = QueryContextBuilder::default()
67        .current_catalog(catalog.clone())
68        .current_schema(schema.clone())
69        .timezone(timezone);
70
71    let query_ctx = query_ctx_builder.build();
72    let need_auth = need_auth(&req);
73
74    // 2. check if auth is needed
75    let user_provider = if let Some(user_provider) = user_provider.filter(|_| need_auth) {
76        user_provider
77    } else {
78        query_ctx.set_current_user(auth::userinfo_by_name(None));
79        let _ = req.extensions_mut().insert(query_ctx);
80        return Ok(req);
81    };
82
83    // 3. bearer token auth (JWT / OAuth2). When an `Authorization: Bearer
84    //    <token>` header is present, authenticate via
85    //    [`UserProvider::auth_bearer_token`]; otherwise fall through to the
86    //    username/password path (Basic / influxdb / splunk) below.
87    if let Some(token) = extract_bearer_token(&req) {
88        match user_provider
89            .auth_bearer_token(token, &catalog, &schema)
90            .await
91        {
92            Ok(userinfo) => {
93                query_ctx.set_current_user(userinfo);
94                let _ = req.extensions_mut().insert(query_ctx);
95                return Ok(req);
96            }
97            Err(e) => {
98                warn!(e; "bearer token authentication failed");
99                crate::metrics::METRIC_AUTH_FAILURE
100                    .with_label_values(&[e.status_code().as_ref()])
101                    .inc();
102                // Splunk HEC clients expect `{"text":"Invalid token","code":4}`
103                // (FORBIDDEN), not the generic 401 `ErrorResponse`.
104                if is_splunk_request(&req) {
105                    return Err(splunk_hec_err(StatusCode::FORBIDDEN, 4));
106                }
107                return Err(err_response(e));
108            }
109        }
110    }
111
112    // 4. get username and pwd
113    let (username, password) = match extract_username_and_password(&req) {
114        Ok((username, password)) => (username, password),
115        Err(e) => {
116            warn!(e; "extract username and password failed");
117            crate::metrics::METRIC_AUTH_FAILURE
118                .with_label_values(&[e.status_code().as_ref()])
119                .inc();
120            if is_splunk_request(&req) {
121                // HEC: missing header -> 2 ("token is required"), else 4 ("invalid token").
122                let (status, code) = match &e {
123                    error::Error::NotFoundAuthHeader { .. } => (StatusCode::UNAUTHORIZED, 2),
124                    _ => (StatusCode::FORBIDDEN, 4),
125                };
126                return Err(splunk_hec_err(status, code));
127            }
128            return Err(err_response(e));
129        }
130    };
131
132    // 5. auth
133    match user_provider
134        .auth(
135            auth::Identity::UserId(&username, None),
136            auth::Password::PlainText(password),
137            &catalog,
138            &schema,
139        )
140        .await
141    {
142        Ok(userinfo) => {
143            query_ctx.set_current_user(userinfo);
144            let _ = req.extensions_mut().insert(query_ctx);
145            Ok(req)
146        }
147        Err(e) => {
148            warn!(e; "authenticate failed");
149            crate::metrics::METRIC_AUTH_FAILURE
150                .with_label_values(&[e.status_code().as_ref()])
151                .inc();
152            // HEC: bad credentials -> 4 ("invalid token", 403).
153            if is_splunk_request(&req) {
154                return Err(splunk_hec_err(StatusCode::FORBIDDEN, 4));
155            }
156            Err(err_response(e))
157        }
158    }
159}
160
161pub async fn check_http_auth(
162    State(auth_state): State<AuthState>,
163    req: Request,
164    next: Next,
165) -> Response {
166    match inner_auth(auth_state.user_provider, req).await {
167        Ok(req) => next.run(req).await,
168        Err(resp) => resp,
169    }
170}
171
172/// HEC-shaped auth error (`{"text","code"}`) so Splunk clients can branch on `code`.
173fn splunk_hec_err(status: StatusCode, code: u32) -> Response {
174    let text = match code {
175        2 => "Token is required",
176        4 => "Invalid token",
177        _ => "Unauthorized",
178    };
179    (
180        status,
181        axum::Json(serde_json::json!({ "text": text, "code": code })),
182    )
183        .into_response()
184}
185
186fn err_response(err: impl ErrorExt) -> Response {
187    (StatusCode::UNAUTHORIZED, ErrorResponse::from_error(err)).into_response()
188}
189
190pub fn extract_catalog_and_schema<B>(request: &Request<B>) -> (String, String) {
191    // parse database from header
192    let dbname = request
193        .headers()
194        .get(GreptimeDbName::name())
195        // eat this invalid ascii error and give user the final IllegalParam error
196        .and_then(|header| header.to_str().ok())
197        .or_else(|| {
198            let query = request.uri().query().unwrap_or_default();
199            if is_influxdb_v2_request(request) {
200                extract_db_from_query(query).or_else(|| extract_bucket_from_query(query))
201            } else {
202                extract_db_from_query(query)
203            }
204        })
205        .unwrap_or(DEFAULT_SCHEMA_NAME);
206
207    parse_catalog_and_schema_from_db_string(dbname)
208}
209
210fn extract_timezone<B>(request: &Request<B>) -> Timezone {
211    // parse timezone from header
212    let timezone = request
213        .headers()
214        .get(&GREPTIME_TIMEZONE_HEADER_NAME)
215        // eat this invalid ascii error and give user the final IllegalParam error
216        .and_then(|header| header.to_str().ok())
217        .unwrap_or("");
218    parse_timezone(Some(timezone))
219}
220
221fn get_influxdb_credentials<B>(request: &Request<B>) -> Result<Option<(Username, Password)>> {
222    // compat with influxdb v2 and v1
223    if let Some(header) = request.headers().get(http::header::AUTHORIZATION) {
224        // try header
225        let (auth_scheme, credential) = header
226            .to_str()
227            .context(InvalidAuthHeaderInvisibleASCIISnafu)?
228            .split_once(' ')
229            .context(InvalidAuthHeaderSnafu)?;
230
231        let (username, password) = match auth_scheme.to_lowercase().as_str() {
232            "token" => {
233                let (u, p) = credential.split_once(':').context(InvalidAuthHeaderSnafu)?;
234                (u.to_string(), p.to_string().into())
235            }
236            "basic" => decode_basic(credential)?,
237            _ => UnsupportedAuthSchemeSnafu { name: auth_scheme }.fail()?,
238        };
239
240        Ok(Some((username, password)))
241    } else {
242        // try u and p in query
243        let Some(query_str) = request.uri().query() else {
244            return Ok(None);
245        };
246
247        let query_str = urlencoding::decode(query_str).context(UrlDecodeSnafu)?;
248
249        match extract_influxdb_user_from_query(&query_str) {
250            (None, None) => Ok(None),
251            (Some(username), Some(password)) => {
252                Ok(Some((username.to_string(), password.to_string().into())))
253            }
254            _ => InvalidParameterSnafu {
255                reason: "influxdb auth: username and password must be provided together"
256                    .to_string(),
257            }
258            .fail(),
259        }
260    }
261}
262
263fn get_splunk_credentials<B>(request: &Request<B>) -> Result<Option<(Username, Password)>> {
264    let Some(header) = request.headers().get(http::header::AUTHORIZATION) else {
265        return Ok(None);
266    };
267    let (auth_scheme, credential) = header
268        .to_str()
269        .context(InvalidAuthHeaderInvisibleASCIISnafu)?
270        .split_once(' ')
271        .context(InvalidAuthHeaderSnafu)?;
272
273    let (username, password) = match auth_scheme.to_lowercase().as_str() {
274        "splunk" => {
275            let (u, p) = credential.split_once(':').context(InvalidAuthHeaderSnafu)?;
276            (u.to_string(), p.to_string().into())
277        }
278        "basic" => decode_basic(credential)?,
279        _ => UnsupportedAuthSchemeSnafu { name: auth_scheme }.fail()?,
280    };
281    Ok(Some((username, password)))
282}
283
284pub fn extract_username_and_password<B>(request: &Request<B>) -> Result<(Username, Password)> {
285    Ok(if is_influxdb_request(request) {
286        // compatible with influxdb auth
287        get_influxdb_credentials(request)?.context(NotFoundInfluxAuthSnafu)?
288    } else if is_splunk_request(request) {
289        get_splunk_credentials(request)?.context(NotFoundAuthHeaderSnafu)?
290    } else {
291        // normal http auth
292        let scheme = auth_header(request)?;
293        match scheme {
294            AuthScheme::Basic(username, password) => (username, password),
295        }
296    })
297}
298
299#[derive(Debug)]
300pub enum AuthScheme {
301    Basic(Username, Password),
302}
303
304type Username = String;
305type Password = SecretString;
306
307impl TryFrom<&str> for AuthScheme {
308    type Error = error::Error;
309
310    fn try_from(value: &str) -> Result<Self> {
311        let (scheme, encoded_credentials) =
312            value.split_once(' ').context(InvalidAuthHeaderSnafu)?;
313
314        ensure!(!encoded_credentials.contains(' '), InvalidAuthHeaderSnafu);
315
316        match scheme.to_lowercase().as_str() {
317            "basic" => decode_basic(encoded_credentials)
318                .map(|(username, password)| AuthScheme::Basic(username, password)),
319            other => UnsupportedAuthSchemeSnafu { name: other }.fail(),
320        }
321    }
322}
323
324impl From<AuthScheme> for api::v1::auth_header::AuthScheme {
325    fn from(value: AuthScheme) -> Self {
326        match value {
327            AuthScheme::Basic(username, password) => {
328                api::v1::auth_header::AuthScheme::Basic(Basic {
329                    username,
330                    password: password.expose_secret().clone(),
331                })
332            }
333        }
334    }
335}
336
337type Credential<'a> = &'a str;
338
339/// Extracts an opaque bearer token from an `Authorization: Bearer <token>`
340/// header (the standard or `x-greptime-auth` header).
341///
342/// Returns `None` for any other scheme (`Basic`, influxdb `Token`, splunk
343/// `Splunk`, …) so the caller can fall through to the username/password path.
344fn extract_bearer_token<B>(req: &Request<B>) -> Option<&str> {
345    let header = req
346        .headers()
347        .get(AUTHORIZATION_HEADER)
348        .or_else(|| req.headers().get(http::header::AUTHORIZATION))?;
349    let value = header.to_str().ok()?;
350    // HTTP authentication schemes are case-insensitive (RFC 9110 §11.1), so
351    // match the scheme with `eq_ignore_ascii_case` — but never lowercase the
352    // *token* itself, which is opaque and case-sensitive. Returns a borrow
353    // into the request's headers, so there is no allocation.
354    let (scheme, token) = value.split_once(' ')?;
355    if !scheme.eq_ignore_ascii_case("bearer") {
356        return None;
357    }
358    let token = token.trim_start();
359    (!token.is_empty()).then_some(token)
360}
361
362fn auth_header<B>(req: &Request<B>) -> Result<AuthScheme> {
363    let auth_header = req
364        .headers()
365        .get(AUTHORIZATION_HEADER)
366        .or_else(|| req.headers().get(http::header::AUTHORIZATION))
367        .context(error::NotFoundAuthHeaderSnafu)?
368        .to_str()
369        .context(InvalidAuthHeaderInvisibleASCIISnafu)?;
370
371    auth_header.try_into()
372}
373
374fn decode_basic(credential: Credential) -> Result<(Username, Password)> {
375    let decoded = BASE64_STANDARD
376        .decode(credential)
377        .context(error::InvalidBase64ValueSnafu)?;
378    let as_utf8 =
379        String::from_utf8(decoded).context(error::InvalidAuthHeaderInvalidUtf8ValueSnafu)?;
380
381    if let Some((user_id, password)) = as_utf8.split_once(':') {
382        return Ok((user_id.to_string(), password.to_string().into()));
383    }
384
385    InvalidAuthHeaderSnafu {}.fail()
386}
387
388fn need_auth<B>(req: &Request<B>) -> bool {
389    let path = req.uri().path();
390
391    for api in PUBLIC_API_PREFIX {
392        if path.starts_with(api) {
393            return false;
394        }
395    }
396
397    path.starts_with(HTTP_API_PREFIX)
398}
399
400fn extract_param_from_query<'a>(query: &'a str, param: &'a str) -> Option<&'a str> {
401    let prefix = format!("{}=", param);
402    for pair in query.split('&') {
403        if let Some(param) = pair.strip_prefix(&prefix) {
404            return if param.is_empty() { None } else { Some(param) };
405        }
406    }
407    None
408}
409
410fn extract_db_from_query(query: &str) -> Option<&str> {
411    extract_param_from_query(query, "db")
412}
413
414/// InfluxDB v2 uses "bucket" instead of "db"
415/// https://docs.influxdata.com/influxdb/v1/tools/api/#apiv2write-http-endpoint
416fn extract_bucket_from_query(query: &str) -> Option<&str> {
417    extract_param_from_query(query, "bucket")
418}
419
420fn extract_influxdb_user_from_query(query: &str) -> (Option<&str>, Option<&str>) {
421    let mut username = None;
422    let mut password = None;
423
424    for pair in query.split('&') {
425        if pair.starts_with("u=") && pair.len() > 2 {
426            username = Some(&pair[2..]);
427        } else if pair.starts_with("p=") && pair.len() > 2 {
428            password = Some(&pair[2..]);
429        }
430    }
431    (username, password)
432}
433
434#[cfg(test)]
435mod tests {
436    use std::assert_matches;
437
438    use common_base::secrets::ExposeSecret;
439
440    use super::*;
441
442    #[test]
443    fn test_need_auth() {
444        let req = Request::builder()
445            .uri("http://127.0.0.1/v1/influxdb/ping")
446            .body(())
447            .unwrap();
448
449        assert!(!need_auth(&req));
450
451        let req = Request::builder()
452            .uri("http://127.0.0.1/v1/influxdb/health")
453            .body(())
454            .unwrap();
455
456        assert!(!need_auth(&req));
457
458        let req = Request::builder()
459            .uri("http://127.0.0.1/v1/influxdb/write")
460            .body(())
461            .unwrap();
462
463        assert!(need_auth(&req));
464    }
465
466    #[test]
467    fn test_splunk_auth() {
468        let splunk_uri = "http://127.0.0.1/v1/splunk/services/collector/event";
469        let splunk_req = |auth: Option<&str>| {
470            let mut req = Request::builder().uri(splunk_uri);
471            if let Some(auth) = auth {
472                req = req.header(http::header::AUTHORIZATION, auth);
473            }
474            req.body(()).unwrap()
475        };
476
477        // is_splunk_request matches our mount, not other endpoints.
478        assert!(is_splunk_request(&splunk_req(None)));
479        assert!(!is_splunk_request(
480            &Request::builder()
481                .uri("http://127.0.0.1/v1/influxdb/write")
482                .body(())
483                .unwrap()
484        ));
485        assert!(!is_splunk_request(
486            &Request::builder()
487                .uri("http://127.0.0.1/v1/sql")
488                .body(())
489                .unwrap()
490        ));
491
492        // `Splunk <user:pass>` -> (user, pass).
493        let (username, password) =
494            get_splunk_credentials(&splunk_req(Some("Splunk teamA:secretA")))
495                .unwrap()
496                .unwrap();
497        assert_eq!(username, "teamA");
498        assert_eq!(password.expose_secret(), "secretA");
499
500        // standard Basic is also accepted (parity with influxdb).
501        let basic = basic_auth("u", "p");
502        let (username, password) = get_splunk_credentials(&splunk_req(Some(&basic)))
503            .unwrap()
504            .unwrap();
505        assert_eq!(username, "u");
506        assert_eq!(password.expose_secret(), "p");
507
508        // missing header -> None; token without ':' -> error.
509        assert!(get_splunk_credentials(&splunk_req(None)).unwrap().is_none());
510        assert!(get_splunk_credentials(&splunk_req(Some("Splunk no_colon_token"))).is_err());
511
512        // full dispatch routes a splunk request through the splunk scheme.
513        let (username, password) =
514            extract_username_and_password(&splunk_req(Some("Splunk teamA:secretA"))).unwrap();
515        assert_eq!(username, "teamA");
516        assert_eq!(password.expose_secret(), "secretA");
517    }
518
519    #[test]
520    fn test_decode_basic() {
521        let credential = basic_auth_credentials("username", "password");
522        let (username, pwd) = decode_basic(&credential).unwrap();
523        assert_eq!("username", username);
524        assert_eq!("password", pwd.expose_secret());
525
526        let wrong_credential = credential.replacen('c', "c ", 1);
527        let result = decode_basic(&wrong_credential);
528        assert_matches!(result.err(), Some(error::Error::InvalidBase64Value { .. }));
529    }
530
531    #[test]
532    fn test_try_into_auth_scheme() {
533        let auth_scheme_str = "basic";
534        let re: Result<AuthScheme> = auth_scheme_str.try_into();
535        assert!(re.is_err());
536
537        let auth_scheme_str = basic_auth("test", "test");
538        let scheme: AuthScheme = auth_scheme_str.as_str().try_into().unwrap();
539        assert_matches!(scheme, AuthScheme::Basic(username, pwd) if username == "test" && pwd.expose_secret() == "test");
540
541        let unsupported = "digest";
542        let auth_scheme: Result<AuthScheme> = unsupported.try_into();
543        assert!(auth_scheme.is_err());
544    }
545
546    #[test]
547    fn test_inner_auth_assigns_remote_query_id() {
548        let req =
549            mock_http_request(None, Some("http://127.0.0.1/v1/sql?db=greptime-public")).unwrap();
550        let req = futures::executor::block_on(inner_auth::<()>(None, req)).unwrap();
551        let query_ctx = req
552            .extensions()
553            .get::<session::context::QueryContext>()
554            .unwrap();
555
556        assert!(query_ctx.remote_query_id().is_some());
557    }
558
559    #[test]
560    fn test_auth_header() {
561        let header_value = basic_auth("username", "password");
562        let req = mock_http_request(Some(&header_value), None).unwrap();
563
564        let auth_scheme = auth_header(&req).unwrap();
565        assert_matches!(auth_scheme, AuthScheme::Basic(username, pwd) if username == "username" && pwd.expose_secret() == "password");
566
567        let wrong_auth_header = header_value.replacen('c', "c ", 1);
568        let wrong_req = mock_http_request(Some(&wrong_auth_header), None).unwrap();
569        let res = auth_header(&wrong_req);
570        assert_matches!(res.err(), Some(error::Error::InvalidAuthHeader { .. }));
571
572        let wrong_req = mock_http_request(
573            Some(&format!(
574                "Digest {}",
575                basic_auth_credentials("username", "password")
576            )),
577            None,
578        )
579        .unwrap();
580        let res = auth_header(&wrong_req);
581        assert_matches!(res.err(), Some(error::Error::UnsupportedAuthScheme { .. }));
582    }
583
584    fn basic_auth(username: &str, password: &str) -> String {
585        format!("Basic {}", basic_auth_credentials(username, password))
586    }
587
588    fn basic_auth_credentials(username: &str, password: &str) -> String {
589        BASE64_STANDARD.encode(format!("{username}:{password}"))
590    }
591
592    fn mock_http_request(auth_header: Option<&str>, uri: Option<&str>) -> Result<Request<()>> {
593        let http_api_version = crate::http::HTTP_API_VERSION;
594        let mut req = Request::builder()
595            .uri(uri.unwrap_or(format!("http://localhost/{http_api_version}/sql").as_str()));
596        if let Some(auth_header) = auth_header {
597            req = req.header(http::header::AUTHORIZATION, auth_header);
598        }
599
600        Ok(req.body(()).unwrap())
601    }
602
603    #[test]
604    fn test_db_name_header() {
605        let http_api_version = crate::http::HTTP_API_VERSION;
606        let req = Request::builder()
607            .uri(format!("http://localhost/{http_api_version}/sql").as_str())
608            .header(GreptimeDbName::name(), "greptime-tomcat")
609            .body(())
610            .unwrap();
611
612        let db = extract_catalog_and_schema(&req);
613        assert_eq!(db, ("greptime".to_string(), "tomcat".to_string()));
614    }
615
616    #[test]
617    fn test_extract_db() {
618        assert_matches!(extract_db_from_query(""), None);
619        assert_matches!(extract_db_from_query("&"), None);
620        assert_matches!(extract_db_from_query("db="), None);
621        assert_matches!(extract_bucket_from_query("bucket="), None);
622        assert_matches!(extract_bucket_from_query("db=foo"), None);
623        assert_matches!(extract_db_from_query("db=foo"), Some("foo"));
624        assert_matches!(extract_bucket_from_query("bucket=foo"), Some("foo"));
625        assert_matches!(extract_db_from_query("name=bar"), None);
626        assert_matches!(extract_db_from_query("db=&name=bar"), None);
627        assert_matches!(extract_db_from_query("db=foo&name=bar"), Some("foo"));
628        assert_matches!(extract_bucket_from_query("db=foo&bucket=bar"), Some("bar"));
629        assert_matches!(extract_db_from_query("name=bar&db="), None);
630        assert_matches!(extract_db_from_query("name=bar&db=foo"), Some("foo"));
631        assert_matches!(extract_db_from_query("name=bar&db=&name=bar"), None);
632        assert_matches!(
633            extract_db_from_query("name=bar&db=foo&name=bar"),
634            Some("foo")
635        );
636    }
637
638    #[test]
639    fn test_extract_user() {
640        assert_matches!(extract_influxdb_user_from_query(""), (None, None));
641        assert_matches!(extract_influxdb_user_from_query("u="), (None, None));
642        assert_matches!(
643            extract_influxdb_user_from_query("u=123"),
644            (Some("123"), None)
645        );
646        assert_matches!(
647            extract_influxdb_user_from_query("u=123&p="),
648            (Some("123"), None)
649        );
650        assert_matches!(
651            extract_influxdb_user_from_query("u=123&p=4"),
652            (Some("123"), Some("4"))
653        );
654        assert_matches!(extract_influxdb_user_from_query("p="), (None, None));
655        assert_matches!(extract_influxdb_user_from_query("p=4"), (None, Some("4")));
656        assert_matches!(
657            extract_influxdb_user_from_query("p=4&u="),
658            (None, Some("4"))
659        );
660        assert_matches!(
661            extract_influxdb_user_from_query("p=4&u=123"),
662            (Some("123"), Some("4"))
663        );
664    }
665
666    #[test]
667    fn test_extract_bearer_token() {
668        let bearer = |scheme: &str, val: &str| {
669            mock_http_request(Some(&format!("{scheme} {val}")), None).unwrap()
670        };
671
672        // Standard bearer scheme, on either header.
673        assert_eq!(
674            extract_bearer_token(&bearer("Bearer", "abc.def.ghi")),
675            Some("abc.def.ghi")
676        );
677        assert_eq!(extract_bearer_token(&bearer("bearer", "tok")), Some("tok"));
678        // HTTP schemes are case-insensitive (RFC 9110 §11.1); the token
679        // itself is opaque and must NOT be lowercased.
680        assert_eq!(
681            extract_bearer_token(&bearer("BEARER", "ABC.DEF.GHI")),
682            Some("ABC.DEF.GHI")
683        );
684        assert_eq!(extract_bearer_token(&bearer("BeArEr", "tok")), Some("tok"));
685        let mut req = mock_http_request(Some("Bearer xyz"), None).unwrap();
686        req.headers_mut().insert(
687            AUTHORIZATION_HEADER,
688            "Bearer from-x-greptime".parse().unwrap(),
689        );
690        assert_eq!(extract_bearer_token(&req), Some("from-x-greptime"));
691
692        // Non-bearer schemes are ignored so the caller falls through to Basic.
693        assert_eq!(extract_bearer_token(&bearer("Basic", "dXNlcjpwYXNz")), None);
694        assert_eq!(extract_bearer_token(&bearer("Token", "u:p")), None);
695        assert_eq!(extract_bearer_token(&bearer("Splunk", "u:p")), None);
696
697        // No header, empty token.
698        assert_eq!(
699            extract_bearer_token(&mock_http_request(None, None).unwrap()),
700            None
701        );
702        assert_eq!(extract_bearer_token(&bearer("Bearer", "")), None);
703    }
704
705    /// A `UserProvider` that resolves exactly one bearer token to a known user
706    /// and rejects everything else (including password auth).
707    struct TokenUserProvider {
708        token: String,
709        user: auth::UserInfoRef,
710    }
711
712    #[async_trait::async_trait]
713    impl auth::UserProvider for TokenUserProvider {
714        fn name(&self) -> &str {
715            "token-test"
716        }
717
718        async fn authenticate(
719            &self,
720            _: auth::Identity<'_>,
721            _: auth::Password<'_>,
722        ) -> auth::error::Result<auth::UserInfoRef> {
723            unreachable!("password auth should not be reached for a bearer request")
724        }
725
726        async fn authorize(
727            &self,
728            _: &str,
729            _: &str,
730            _: &auth::UserInfoRef,
731        ) -> auth::error::Result<()> {
732            Ok(())
733        }
734
735        async fn auth_bearer_token(
736            &self,
737            token: &str,
738            _: &str,
739            _: &str,
740        ) -> auth::error::Result<auth::UserInfoRef> {
741            if token == self.token {
742                Ok(self.user.clone())
743            } else {
744                auth::error::UnsupportedAuthMethodSnafu {
745                    method: "bearer token",
746                }
747                .fail()
748            }
749        }
750    }
751
752    #[tokio::test]
753    async fn test_bearer_token_dispatches_to_auth_bearer_token() {
754        let provider = TokenUserProvider {
755            token: "good-token".to_string(),
756            user: auth::userinfo_by_name(Some("alice".into())),
757        };
758        let req = mock_http_request(Some("Bearer good-token"), None).unwrap();
759
760        let req = inner_auth::<()>(
761            Some(std::sync::Arc::new(provider) as auth::UserProviderRef),
762            req,
763        )
764        .await
765        .expect("valid bearer token authenticates");
766
767        let user = req
768            .extensions()
769            .get::<session::context::QueryContext>()
770            .expect("query context is populated")
771            .current_user();
772        assert_eq!(user.username(), "alice");
773    }
774
775    #[tokio::test]
776    async fn test_bearer_token_failure_rejects() {
777        let provider = TokenUserProvider {
778            token: "good-token".to_string(),
779            user: auth::userinfo_by_name(Some("alice".into())),
780        };
781        let req = mock_http_request(Some("Bearer bad-token"), None).unwrap();
782
783        let result = inner_auth::<()>(
784            Some(std::sync::Arc::new(provider) as auth::UserProviderRef),
785            req,
786        )
787        .await;
788        assert!(result.is_err(), "an invalid bearer token is rejected");
789    }
790
791    #[tokio::test]
792    async fn test_default_provider_rejects_bearer() {
793        // A password-only provider uses the default `auth_bearer_token`, which rejects.
794        let provider =
795            auth::user_provider_from_option("static_user_provider:cmd:alice=s3cret").unwrap();
796        let req = mock_http_request(Some("Bearer some-jwt"), None).unwrap();
797        let result = inner_auth::<()>(Some(provider), req).await;
798        assert!(
799            result.is_err(),
800            "password-only providers reject bearer tokens"
801        );
802    }
803
804    /// A bearer-token failure on a Splunk HEC request must keep the HEC
805    /// contract — `{"text":"Invalid token","code":4}` with FORBIDDEN —
806    /// instead of falling through to the generic 401 `ErrorResponse`.
807    /// Regression for the bearer/splunk routing gap.
808    #[tokio::test]
809    async fn test_bearer_failure_on_splunk_keeps_hec_contract() {
810        let provider = TokenUserProvider {
811            token: "good-token".to_string(),
812            user: auth::userinfo_by_name(Some("alice".into())),
813        };
814        let req = mock_http_request(
815            Some("Bearer bad-token"),
816            Some("http://127.0.0.1/v1/splunk/services/collector/event"),
817        )
818        .unwrap();
819
820        let resp = inner_auth::<()>(
821            Some(std::sync::Arc::new(provider) as auth::UserProviderRef),
822            req,
823        )
824        .await
825        .expect_err("an invalid bearer token is rejected");
826
827        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
828        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
829            .await
830            .unwrap();
831        let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
832        assert_eq!(payload["code"], 4);
833        assert_eq!(payload["text"], "Invalid token");
834    }
835}