1use std::convert::Infallible;
26use std::future::Future;
27use std::pin::Pin;
28use std::sync::Arc;
29use std::task::{Context, Poll};
30use std::time::{Duration, Instant};
31
32use axum::body::Body;
33use axum::http::{HeaderValue, Method, Request, header};
34use axum::response::{IntoResponse, Redirect, Response};
35use dashmap::DashMap;
36use subtle::ConstantTimeEq;
37use tower::{Layer, Service};
38
39use crate::auth_context::{AuthContext, www_authenticate_value};
40use crate::error::AuthError;
41use crate::metadata::canonical_resource_url;
42use crate::session;
43use crate::state::AuthState;
44
45pub type ActorKeyDeriver = dyn Fn(&str) -> Option<Arc<str>> + Send + Sync;
55
56const FAILED_AUTH_LOG_WINDOW: Duration = Duration::from_secs(60);
59
60const FAILED_AUTH_LOG_MAX_ENTRIES: usize = 1024;
63
64struct FailedAuthLog {
70 entries: DashMap<String, Instant>,
71}
72
73impl FailedAuthLog {
74 fn new() -> Self {
75 Self {
76 entries: DashMap::new(),
77 }
78 }
79
80 fn should_log(&self, fingerprint: &str) -> bool {
82 let now = Instant::now();
83 if let Some(last) = self.entries.get(fingerprint)
84 && now.duration_since(*last) < FAILED_AUTH_LOG_WINDOW
85 {
86 return false;
87 }
88 if self.entries.len() >= FAILED_AUTH_LOG_MAX_ENTRIES
89 && !self.entries.contains_key(fingerprint)
90 {
91 self.entries
92 .retain(|_, seen| now.duration_since(*seen) < FAILED_AUTH_LOG_WINDOW);
93 if self.entries.len() >= FAILED_AUTH_LOG_MAX_ENTRIES {
94 return false;
95 }
96 }
97 self.entries.insert(fingerprint.to_string(), now);
98 true
99 }
100}
101
102#[derive(Clone)]
108pub struct AuthLayer {
109 inner: Arc<AuthLayerInner>,
110}
111
112#[derive(Clone)]
113struct AuthLayerInner {
114 static_token: Option<Arc<str>>,
115 auth_state: Option<Arc<AuthState>>,
116 actor_key_deriver: Option<Arc<ActorKeyDeriver>>,
117 resource_url: Option<Arc<str>>,
118 allow_session_cookie: bool,
119 static_token_scopes: Vec<String>,
124 login_path: String,
128 session_cookie_name: String,
132 failed_auth_log: Arc<FailedAuthLog>,
135}
136
137impl AuthLayer {
138 #[must_use]
143 pub fn new() -> Self {
144 Self {
145 inner: Arc::new(AuthLayerInner {
146 static_token: None,
147 auth_state: None,
148 actor_key_deriver: None,
149 resource_url: None,
150 allow_session_cookie: false,
151 static_token_scopes: Vec::new(),
152 login_path: crate::config::DEFAULT_LOGIN_PATH.to_string(),
153 session_cookie_name: crate::config::DEFAULT_SESSION_COOKIE_NAME.to_string(),
154 failed_auth_log: Arc::new(FailedAuthLog::new()),
155 }),
156 }
157 }
158
159 #[must_use]
164 pub fn from_state(auth_state: Arc<AuthState>) -> Self {
165 let cfg = &auth_state.config;
166 let static_token_scopes = cfg.static_token_scopes.clone();
167 let login_path = cfg.login_path.clone();
168 let session_cookie_name = cfg.session_cookie_name.clone();
169 Self {
170 inner: Arc::new(AuthLayerInner {
171 static_token: None,
172 auth_state: Some(auth_state),
173 actor_key_deriver: None,
174 resource_url: None,
175 allow_session_cookie: false,
176 static_token_scopes,
177 login_path,
178 session_cookie_name,
179 failed_auth_log: Arc::new(FailedAuthLog::new()),
180 }),
181 }
182 }
183
184 fn with(mut self, mutate: impl FnOnce(&mut AuthLayerInner)) -> Self {
185 let inner = Arc::make_mut(&mut self.inner);
186 mutate(inner);
187 self
188 }
189
190 #[must_use]
191 pub fn with_static_token(self, token: Option<Arc<str>>) -> Self {
192 self.with(|inner| inner.static_token = token)
193 }
194
195 #[must_use]
196 pub fn with_auth_state(self, state: Option<Arc<AuthState>>) -> Self {
197 self.with(|inner| {
198 if let Some(state) = state.as_ref() {
199 let cfg = &state.config;
200 inner.static_token_scopes = cfg.static_token_scopes.clone();
201 inner.login_path = cfg.login_path.clone();
202 inner.session_cookie_name = cfg.session_cookie_name.clone();
203 }
204 inner.auth_state = state;
205 })
206 }
207
208 #[must_use]
209 pub fn with_actor_key_deriver(self, deriver: Option<Arc<ActorKeyDeriver>>) -> Self {
210 self.with(|inner| inner.actor_key_deriver = deriver)
211 }
212
213 #[must_use]
214 pub fn with_resource_url(self, resource_url: Option<Arc<str>>) -> Self {
215 self.with(|inner| inner.resource_url = resource_url)
216 }
217
218 #[must_use]
219 pub fn with_allow_session_cookie(self, allow: bool) -> Self {
220 self.with(|inner| inner.allow_session_cookie = allow)
221 }
222
223 #[must_use]
227 pub fn with_static_token_scopes(self, scopes: Vec<String>) -> Self {
228 self.with(|inner| inner.static_token_scopes = scopes)
229 }
230
231 #[must_use]
234 pub fn with_login_path(self, path: impl Into<String>) -> Self {
235 self.with(|inner| inner.login_path = path.into())
236 }
237
238 #[must_use]
240 pub fn with_session_cookie_name(self, name: impl Into<String>) -> Self {
241 self.with(|inner| inner.session_cookie_name = name.into())
242 }
243}
244
245impl Default for AuthLayer {
246 fn default() -> Self {
247 Self::new()
248 }
249}
250
251impl<S> Layer<S> for AuthLayer {
252 type Service = AuthService<S>;
253
254 fn layer(&self, inner: S) -> Self::Service {
255 AuthService {
256 inner,
257 layer: self.inner.clone(),
258 }
259 }
260}
261
262#[derive(Clone)]
265pub struct AuthService<S> {
266 inner: S,
267 layer: Arc<AuthLayerInner>,
268}
269
270impl<S> Service<Request<Body>> for AuthService<S>
271where
272 S: Service<Request<Body>, Response = Response, Error = Infallible> + Clone + Send + 'static,
273 S::Future: Send + 'static,
274{
275 type Response = Response;
276 type Error = Infallible;
277 type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
278
279 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Infallible>> {
280 self.inner.poll_ready(cx)
281 }
282
283 fn call(&mut self, request: Request<Body>) -> Self::Future {
284 let clone = self.inner.clone();
289 let inner = std::mem::replace(&mut self.inner, clone);
290 let layer = self.layer.clone();
291 Box::pin(authenticate_and_forward(layer, inner, request))
292 }
293}
294
295async fn authenticate_and_forward<S>(
296 layer: Arc<AuthLayerInner>,
297 mut inner: S,
298 request: Request<Body>,
299) -> Result<Response, Infallible>
300where
301 S: Service<Request<Body>, Response = Response, Error = Infallible> + Send,
302 S::Future: Send,
303{
304 match authenticate(&layer, request).await {
305 Ok(request) => inner.call(request).await,
306 Err(response) => Ok(response),
307 }
308}
309
310async fn authenticate(
314 layer: &AuthLayerInner,
315 mut request: Request<Body>,
316) -> Result<Request<Body>, Response> {
317 let auth_header = request
318 .headers()
319 .get(header::AUTHORIZATION)
320 .and_then(|v| v.to_str().ok())
321 .and_then(parse_bearer_token);
322
323 let scope = layer
328 .auth_state
329 .as_ref()
330 .map(|s| s.config.scopes_supported.join(" "));
331
332 if let Some(token) = auth_header {
333 let static_token_blocked = layer.auth_state.as_ref().is_some_and(|s| {
336 s.config.disable_static_token_with_oauth
337 && matches!(s.config.mode, crate::config::AuthMode::OAuth)
338 });
339 if !static_token_blocked
340 && let Some(ref expected) = layer.static_token
341 && tokens_equal(&token, expected.as_ref())
342 {
343 let sub = "static-bearer".to_string();
344 let actor_key = derive_actor_key(layer.actor_key_deriver.as_deref(), &sub);
345 request.extensions_mut().insert(AuthContext {
346 sub,
347 actor_key,
348 scopes: layer.static_token_scopes.clone(),
349 issuer: "local".to_string(),
350 via_session: false,
351 csrf_token: None,
352 email: None,
353 });
354 return Ok(request);
355 }
356
357 if let Some(ref auth_state) = layer.auth_state {
359 let Some(expected_issuer) = auth_state
360 .config
361 .public_url
362 .as_ref()
363 .map(|url| url.as_str().trim_end_matches('/').to_string())
364 else {
365 return Err(auth_error_response(
366 &format!(
367 "server misconfigured: {}_PUBLIC_URL required for JWT validation",
368 auth_state.config.env_prefix
369 ),
370 layer.resource_url.as_deref(),
371 scope.as_deref(),
372 ));
373 };
374 let expected_aud = canonical_resource_url(auth_state);
375 match auth_state.signing_keys.validate_access_token_with_issuer(
376 &token,
377 &expected_aud,
378 &expected_issuer,
379 ) {
380 Ok(claims) => {
381 let actor_key =
382 derive_actor_key(layer.actor_key_deriver.as_deref(), &claims.sub);
383 request.extensions_mut().insert(AuthContext {
384 actor_key,
385 sub: claims.sub,
386 scopes: claims
387 .scope
388 .split_whitespace()
389 .filter(|scope| !scope.is_empty())
390 .map(ToOwned::to_owned)
391 .collect(),
392 issuer: claims.iss,
393 via_session: false,
394 csrf_token: None,
395 email: None,
396 });
397 return Ok(request);
398 }
399 Err(error) => {
400 tracing::debug!(error = %error, "lab-auth JWT validation failed");
401 }
402 }
403 }
404
405 let token_fp = crate::util::fingerprint(&token);
410 if layer.failed_auth_log.should_log(&token_fp) {
411 let peer = request
412 .extensions()
413 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
414 .map(|info| info.0.to_string());
415 tracing::warn!(
416 token_fp = %token_fp,
417 peer = peer.as_deref().unwrap_or("unknown"),
418 "rejected bearer token"
419 );
420 }
421 return Err(auth_error_response(
422 "invalid bearer token",
423 layer.resource_url.as_deref(),
424 scope.as_deref(),
425 ));
426 }
427
428 if layer.allow_session_cookie
430 && let Some(auth_state) = layer.auth_state.as_ref()
431 && let Some(session_id) =
432 session::read_cookie(request.headers(), &layer.session_cookie_name)
433 {
434 match auth_state.store.find_browser_session(&session_id).await {
435 Ok(Some(session)) => {
436 if !matches!(
437 *request.method(),
438 Method::GET | Method::HEAD | Method::OPTIONS
439 ) {
440 let csrf_ok = request
441 .headers()
442 .get(session::BROWSER_CSRF_HEADER_NAME)
443 .and_then(|value| value.to_str().ok())
444 .is_some_and(|csrf| tokens_equal(csrf, &session.csrf_token));
445 if !csrf_ok {
446 return Err(csrf_error_response("missing or invalid csrf token"));
447 }
448 }
449
450 let actor_key =
451 derive_actor_key(layer.actor_key_deriver.as_deref(), &session.subject);
452 request.extensions_mut().insert(AuthContext {
453 actor_key,
454 sub: session.subject,
455 scopes: layer.static_token_scopes.clone(),
456 issuer: "browser-session".to_string(),
457 via_session: true,
458 csrf_token: Some(session.csrf_token),
459 email: session.email,
460 });
461 return Ok(request);
462 }
463 Ok(None) => {}
464 Err(error) => {
465 tracing::debug!(error = %error, "browser session lookup failed");
466 }
467 }
468 }
469
470 if layer.allow_session_cookie
472 && layer.auth_state.is_some()
473 && *request.method() == Method::GET
474 && request
475 .headers()
476 .get(header::ACCEPT)
477 .and_then(|v| v.to_str().ok())
478 .is_some_and(|accept| accept.contains("text/html"))
479 {
480 let return_to = request
481 .uri()
482 .path_and_query()
483 .map(|pq| pq.as_str())
484 .unwrap_or("/");
485 let encoded = percent_encode_path(return_to);
486 let login_url = format!("{}?return_to={encoded}", layer.login_path);
487 return Err(Redirect::to(&login_url).into_response());
488 }
489
490 Err(auth_error_response(
491 if layer.allow_session_cookie {
492 "missing bearer token or session cookie"
493 } else {
494 "missing bearer token"
495 },
496 layer.resource_url.as_deref(),
497 scope.as_deref(),
498 ))
499}
500
501#[must_use]
504pub fn tokens_equal(a: &str, b: &str) -> bool {
505 a.as_bytes().ct_eq(b.as_bytes()).into()
506}
507
508#[must_use]
511pub fn parse_bearer_token(header_value: &str) -> Option<String> {
512 let mut parts = header_value.split_whitespace();
513 let scheme = parts.next()?;
514 let token = parts.next()?;
515 if parts.next().is_some() || !scheme.eq_ignore_ascii_case("bearer") {
516 return None;
517 }
518 Some(token.to_string())
519}
520
521fn derive_actor_key(deriver: Option<&ActorKeyDeriver>, subject: &str) -> Option<Arc<str>> {
522 deriver.and_then(|deriver| deriver(subject))
523}
524
525fn auth_error_response(message: &str, resource_url: Option<&str>, scope: Option<&str>) -> Response {
530 let mut response = AuthError::AuthFailed(message.to_string()).into_response();
531 if let Some(url) = resource_url {
532 let www_auth = www_authenticate_value(url, scope);
533 if let Ok(value) = HeaderValue::from_str(&www_auth) {
534 response
535 .headers_mut()
536 .insert(header::WWW_AUTHENTICATE, value);
537 }
538 }
539 response
540}
541
542fn csrf_error_response(message: &str) -> Response {
543 AuthError::Validation(message.to_string()).into_response()
544}
545
546fn percent_encode_path(s: &str) -> String {
547 let mut out = String::with_capacity(s.len());
548 for b in s.bytes() {
549 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') {
553 out.push(b as char);
554 } else {
555 out.push('%');
556 out.push(
557 char::from_digit(u32::from(b >> 4), 16)
558 .unwrap()
559 .to_ascii_uppercase(),
560 );
561 out.push(
562 char::from_digit(u32::from(b & 0xf), 16)
563 .unwrap()
564 .to_ascii_uppercase(),
565 );
566 }
567 }
568 out
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use axum::Router;
575 use axum::http::{Request as HttpRequest, StatusCode};
576 use axum::routing::get;
577 use tower::ServiceExt;
578
579 use crate::authorize::tests::{test_auth_config, test_auth_state, test_auth_state_with_config};
580
581 fn echo_app(layer: AuthLayer) -> Router {
582 Router::new()
583 .route("/probe", get(|| async { "ok" }))
584 .route_layer(layer)
585 }
586
587 #[test]
588 fn parse_bearer_token_accepts_valid_header() {
589 assert_eq!(
590 parse_bearer_token("Bearer abc.def").as_deref(),
591 Some("abc.def")
592 );
593 assert_eq!(
594 parse_bearer_token("bearer abc.def").as_deref(),
595 Some("abc.def")
596 );
597 }
598
599 #[test]
600 fn parse_bearer_token_rejects_malformed() {
601 assert_eq!(parse_bearer_token("Basic abc.def"), None);
602 assert_eq!(parse_bearer_token("Bearer"), None);
603 assert_eq!(parse_bearer_token("Bearer one two"), None);
604 assert_eq!(parse_bearer_token(""), None);
605 }
606
607 #[test]
608 fn tokens_equal_distinguishes_unequal_strings() {
609 assert!(tokens_equal("abc", "abc"));
610 assert!(!tokens_equal("abc", "abd"));
611 assert!(!tokens_equal("abc", "abcd"));
612 }
613
614 #[test]
615 fn failed_auth_log_rate_limits_per_fingerprint() {
616 let log = FailedAuthLog::new();
617 assert!(log.should_log("aaaaaaaaaaaa"));
618 assert!(
619 !log.should_log("aaaaaaaaaaaa"),
620 "second rejection of the same fingerprint inside the window must be suppressed"
621 );
622 assert!(
623 log.should_log("bbbbbbbbbbbb"),
624 "a different fingerprint gets its own window"
625 );
626 }
627
628 #[tokio::test(flavor = "current_thread")]
629 async fn missing_bearer_token_returns_401_with_www_authenticate() {
630 let layer =
631 AuthLayer::new().with_resource_url(Some(Arc::<str>::from("https://lab.example.com")));
632 let app = echo_app(layer);
633 let response = app
634 .oneshot(
635 HttpRequest::builder()
636 .uri("/probe")
637 .body(Body::empty())
638 .unwrap(),
639 )
640 .await
641 .unwrap();
642 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
643 let www = response
644 .headers()
645 .get(header::WWW_AUTHENTICATE)
646 .and_then(|v| v.to_str().ok())
647 .unwrap_or_default();
648 assert!(
649 www.contains("resource_metadata="),
650 "missing resource_metadata in WWW-Authenticate: `{www}`"
651 );
652 assert!(
653 !www.contains("scope="),
654 "unexpected scope in WWW-Authenticate for a layer with no auth_state: `{www}`"
655 );
656 }
657
658 #[tokio::test(flavor = "current_thread")]
659 async fn missing_bearer_token_with_auth_state_includes_scope_in_www_authenticate() {
660 let state = Arc::new(test_auth_state().await);
661 let layer = AuthLayer::from_state(state)
662 .with_resource_url(Some(Arc::<str>::from("https://lab.example.com")));
663 let app = echo_app(layer);
664 let response = app
665 .oneshot(
666 HttpRequest::builder()
667 .uri("/probe")
668 .body(Body::empty())
669 .unwrap(),
670 )
671 .await
672 .unwrap();
673 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
674 let www = response
675 .headers()
676 .get(header::WWW_AUTHENTICATE)
677 .and_then(|v| v.to_str().ok())
678 .unwrap_or_default();
679 assert!(
680 www.contains("resource_metadata="),
681 "missing resource_metadata in WWW-Authenticate: `{www}`"
682 );
683 assert!(
684 www.contains("scope="),
685 "missing scope in WWW-Authenticate: `{www}`"
686 );
687 }
688
689 #[tokio::test(flavor = "current_thread")]
690 async fn static_bearer_match_grants_configured_scopes() {
691 let token: Arc<str> = Arc::<str>::from("super-secret");
692 let layer = AuthLayer::new()
693 .with_static_token(Some(token.clone()))
694 .with_static_token_scopes(vec!["syslog:read".to_string(), "syslog:admin".to_string()]);
695 let app = Router::new()
696 .route(
697 "/probe",
698 get(
699 |axum::Extension(ctx): axum::Extension<AuthContext>| async move {
700 ctx.scopes.join(",")
701 },
702 ),
703 )
704 .route_layer(layer);
705
706 let response = app
707 .oneshot(
708 HttpRequest::builder()
709 .uri("/probe")
710 .header(header::AUTHORIZATION, "Bearer super-secret")
711 .body(Body::empty())
712 .unwrap(),
713 )
714 .await
715 .unwrap();
716 assert_eq!(response.status(), StatusCode::OK);
717 let body = axum::body::to_bytes(response.into_body(), 1024)
718 .await
719 .unwrap();
720 assert_eq!(&body[..], b"syslog:read,syslog:admin");
721 }
722
723 #[tokio::test(flavor = "current_thread")]
724 async fn wrong_static_bearer_rejected() {
725 let layer = AuthLayer::new().with_static_token(Some(Arc::<str>::from("super-secret")));
726 let app = echo_app(layer);
727
728 let response = app
729 .oneshot(
730 HttpRequest::builder()
731 .uri("/probe")
732 .header(header::AUTHORIZATION, "Bearer wrong")
733 .body(Body::empty())
734 .unwrap(),
735 )
736 .await
737 .unwrap();
738 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
739 }
740
741 #[tokio::test(flavor = "current_thread")]
742 async fn jwt_validation_path_accepts_signed_token_and_writes_context() {
743 let state = Arc::new(test_auth_state().await);
744 let aud = canonical_resource_url(&state);
745 let iss = state
746 .config
747 .public_url
748 .as_ref()
749 .map(|url| url.as_str().trim_end_matches('/').to_string())
750 .unwrap();
751 let claims = crate::jwt::AccessClaims {
752 iss: iss.clone(),
753 sub: "user@example.com".to_string(),
754 aud: aud.clone(),
755 exp: (crate::util::now_unix() + 60) as usize,
756 iat: crate::util::now_unix() as usize,
757 jti: "j-1".to_string(),
758 scope: "syslog:read syslog:admin".to_string(),
759 azp: String::new(),
760 };
761 let token = state.signing_keys.issue_access_token(&claims).unwrap();
762 let layer = AuthLayer::from_state(state);
763 let app = Router::new()
764 .route(
765 "/probe",
766 get(
767 |axum::Extension(ctx): axum::Extension<AuthContext>| async move {
768 format!("{}|{}", ctx.sub, ctx.scopes.join(","))
769 },
770 ),
771 )
772 .route_layer(layer);
773
774 let response = app
775 .oneshot(
776 HttpRequest::builder()
777 .uri("/probe")
778 .header(header::AUTHORIZATION, format!("Bearer {token}"))
779 .body(Body::empty())
780 .unwrap(),
781 )
782 .await
783 .unwrap();
784 assert_eq!(response.status(), StatusCode::OK);
785 let body = axum::body::to_bytes(response.into_body(), 1024)
786 .await
787 .unwrap();
788 assert_eq!(&body[..], b"user@example.com|syslog:read,syslog:admin");
789 }
790
791 #[tokio::test(flavor = "current_thread")]
792 async fn jwt_with_wrong_issuer_rejected() {
793 let state = Arc::new(test_auth_state().await);
794 let aud = canonical_resource_url(&state);
795 let claims = crate::jwt::AccessClaims {
796 iss: "https://attacker.example.com".to_string(),
797 sub: "user@example.com".to_string(),
798 aud,
799 exp: (crate::util::now_unix() + 60) as usize,
800 iat: crate::util::now_unix() as usize,
801 jti: "j-1".to_string(),
802 scope: "syslog:read".to_string(),
803 azp: String::new(),
804 };
805 let token = state.signing_keys.issue_access_token(&claims).unwrap();
806 let layer = AuthLayer::from_state(state)
807 .with_resource_url(Some(Arc::<str>::from("https://lab.example.com")));
808 let app = echo_app(layer);
809
810 let response = app
811 .oneshot(
812 HttpRequest::builder()
813 .uri("/probe")
814 .header(header::AUTHORIZATION, format!("Bearer {token}"))
815 .body(Body::empty())
816 .unwrap(),
817 )
818 .await
819 .unwrap();
820 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
821 }
822
823 #[tokio::test(flavor = "current_thread")]
824 async fn jwt_with_wrong_audience_rejected() {
825 let state = Arc::new(test_auth_state().await);
826 let iss = state
827 .config
828 .public_url
829 .as_ref()
830 .map(|url| url.as_str().trim_end_matches('/').to_string())
831 .unwrap();
832 let claims = crate::jwt::AccessClaims {
833 iss,
834 sub: "user@example.com".to_string(),
835 aud: "https://other.example.com/mcp".to_string(),
836 exp: (crate::util::now_unix() + 60) as usize,
837 iat: crate::util::now_unix() as usize,
838 jti: "j-1".to_string(),
839 scope: "syslog:read".to_string(),
840 azp: String::new(),
841 };
842 let token = state.signing_keys.issue_access_token(&claims).unwrap();
843 let layer = AuthLayer::from_state(state);
844 let app = echo_app(layer);
845
846 let response = app
847 .oneshot(
848 HttpRequest::builder()
849 .uri("/probe")
850 .header(header::AUTHORIZATION, format!("Bearer {token}"))
851 .body(Body::empty())
852 .unwrap(),
853 )
854 .await
855 .unwrap();
856 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
857 }
858
859 #[tokio::test(flavor = "current_thread")]
860 async fn html_get_with_session_cookie_enabled_redirects_to_login_path() {
861 let state = Arc::new(test_auth_state().await);
862 let layer = AuthLayer::from_state(state)
863 .with_allow_session_cookie(true)
864 .with_login_path("/auth/login");
865 let app = echo_app(layer);
866
867 let response = app
868 .oneshot(
869 HttpRequest::builder()
870 .uri("/probe?x=1")
871 .header(header::ACCEPT, "text/html")
872 .body(Body::empty())
873 .unwrap(),
874 )
875 .await
876 .unwrap();
877 assert_eq!(response.status(), StatusCode::SEE_OTHER);
878 let location = response
879 .headers()
880 .get(header::LOCATION)
881 .and_then(|v| v.to_str().ok())
882 .unwrap_or_default();
883 assert!(
884 location.starts_with("/auth/login?return_to="),
885 "unexpected redirect Location: `{location}`"
886 );
887 }
888
889 #[tokio::test(flavor = "current_thread")]
890 async fn html_get_uses_configured_login_path_override() {
891 let state = Arc::new(test_auth_state().await);
892 let layer = AuthLayer::from_state(state)
893 .with_allow_session_cookie(true)
894 .with_login_path("/syslog/auth/login");
895 let app = echo_app(layer);
896
897 let response = app
898 .oneshot(
899 HttpRequest::builder()
900 .uri("/probe")
901 .header(header::ACCEPT, "text/html")
902 .body(Body::empty())
903 .unwrap(),
904 )
905 .await
906 .unwrap();
907 assert_eq!(response.status(), StatusCode::SEE_OTHER);
908 let location = response
909 .headers()
910 .get(header::LOCATION)
911 .and_then(|v| v.to_str().ok())
912 .unwrap_or_default();
913 assert!(
914 location.starts_with("/syslog/auth/login?return_to="),
915 "unexpected redirect Location: `{location}`"
916 );
917 }
918
919 #[tokio::test(flavor = "current_thread")]
920 async fn static_bearer_blocked_when_disable_static_token_with_oauth_is_set() {
921 let mut config = test_auth_config();
924 config.disable_static_token_with_oauth = true;
925 let state = Arc::new(test_auth_state_with_config(config).await);
926
927 let token: Arc<str> = Arc::from("super-secret");
928 let layer = AuthLayer::from_state(state).with_static_token(Some(token.clone()));
929 let app = echo_app(layer);
930
931 let response = app
932 .oneshot(
933 HttpRequest::builder()
934 .uri("/probe")
935 .header(header::AUTHORIZATION, "Bearer super-secret")
936 .body(Body::empty())
937 .unwrap(),
938 )
939 .await
940 .unwrap();
941 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
943 }
944
945 fn session_probe_app(layer: AuthLayer) -> Router {
947 Router::new()
948 .route(
949 "/probe",
950 get(
951 |axum::Extension(ctx): axum::Extension<AuthContext>| async move {
952 format!("{}:{}", ctx.sub, ctx.via_session)
953 },
954 )
955 .post(
956 |axum::Extension(ctx): axum::Extension<AuthContext>| async move {
957 format!("{}:{}", ctx.sub, ctx.via_session)
958 },
959 ),
960 )
961 .route_layer(layer)
962 }
963
964 async fn state_with_session() -> (Arc<AuthState>, crate::types::BrowserSessionRow) {
965 let state = Arc::new(test_auth_state().await);
966 let session = session::create_browser_session(&state, "cookie-user".to_string(), None)
967 .await
968 .expect("create session");
969 (state, session)
970 }
971
972 #[tokio::test(flavor = "current_thread")]
973 async fn valid_session_cookie_authenticates_get_and_marks_via_session() {
974 let (state, session) = state_with_session().await;
975 let cookie = format!(
976 "{}={}",
977 state.config.session_cookie_name, session.session_id
978 );
979 let app = session_probe_app(AuthLayer::from_state(state).with_allow_session_cookie(true));
980
981 let response = app
982 .oneshot(
983 HttpRequest::builder()
984 .uri("/probe")
985 .header(header::COOKIE, cookie)
986 .body(Body::empty())
987 .unwrap(),
988 )
989 .await
990 .unwrap();
991 assert_eq!(response.status(), StatusCode::OK);
992 let body = axum::body::to_bytes(response.into_body(), 1024)
993 .await
994 .unwrap();
995 assert_eq!(&body[..], b"cookie-user:true");
996 }
997
998 #[tokio::test(flavor = "current_thread")]
999 async fn session_cookie_post_without_csrf_header_is_rejected() {
1000 let (state, session) = state_with_session().await;
1001 let cookie = format!(
1002 "{}={}",
1003 state.config.session_cookie_name, session.session_id
1004 );
1005 let app = session_probe_app(AuthLayer::from_state(state).with_allow_session_cookie(true));
1006
1007 let response = app
1008 .oneshot(
1009 HttpRequest::builder()
1010 .method(Method::POST)
1011 .uri("/probe")
1012 .header(header::COOKIE, cookie)
1013 .body(Body::empty())
1014 .unwrap(),
1015 )
1016 .await
1017 .unwrap();
1018 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1019 }
1020
1021 #[tokio::test(flavor = "current_thread")]
1022 async fn session_cookie_post_with_wrong_csrf_header_is_rejected() {
1023 let (state, session) = state_with_session().await;
1024 let cookie = format!(
1025 "{}={}",
1026 state.config.session_cookie_name, session.session_id
1027 );
1028 let app = session_probe_app(AuthLayer::from_state(state).with_allow_session_cookie(true));
1029
1030 let response = app
1031 .oneshot(
1032 HttpRequest::builder()
1033 .method(Method::POST)
1034 .uri("/probe")
1035 .header(header::COOKIE, cookie)
1036 .header(session::BROWSER_CSRF_HEADER_NAME, "not-the-csrf-token")
1037 .body(Body::empty())
1038 .unwrap(),
1039 )
1040 .await
1041 .unwrap();
1042 assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
1043 }
1044
1045 #[tokio::test(flavor = "current_thread")]
1046 async fn session_cookie_post_with_matching_csrf_header_is_accepted() {
1047 let (state, session) = state_with_session().await;
1048 let cookie = format!(
1049 "{}={}",
1050 state.config.session_cookie_name, session.session_id
1051 );
1052 let app = session_probe_app(AuthLayer::from_state(state).with_allow_session_cookie(true));
1053
1054 let response = app
1055 .oneshot(
1056 HttpRequest::builder()
1057 .method(Method::POST)
1058 .uri("/probe")
1059 .header(header::COOKIE, cookie)
1060 .header(
1061 session::BROWSER_CSRF_HEADER_NAME,
1062 session.csrf_token.as_str(),
1063 )
1064 .body(Body::empty())
1065 .unwrap(),
1066 )
1067 .await
1068 .unwrap();
1069 assert_eq!(response.status(), StatusCode::OK);
1070 let body = axum::body::to_bytes(response.into_body(), 1024)
1071 .await
1072 .unwrap();
1073 assert_eq!(&body[..], b"cookie-user:true");
1074 }
1075}