Skip to main content

ironflow_auth/
extractor.rs

1//! Axum extractors for authenticated callers.
2//!
3//! Provides three extractors:
4//!
5//! - [`AuthenticatedUser`] -- JWT only (cookie or `Authorization: Bearer` header)
6//! - [`ApiKeyAuth`] -- API key only (`irfl_...` prefix)
7//! - [`Authenticated`] -- Dual auth: API key OR JWT
8
9use std::sync::Arc;
10
11use axum::Json;
12use axum::extract::{FromRef, FromRequestParts};
13use axum::http::StatusCode;
14use axum::http::request::Parts;
15use axum::response::{IntoResponse, Response};
16use axum_extra::extract::cookie::CookieJar;
17use chrono::Utc;
18use ironflow_store::entities::ApiKeyScope;
19use ironflow_store::store::Store;
20use serde_json::json;
21use uuid::Uuid;
22
23use crate::cookies::AUTH_COOKIE_NAME;
24use crate::jwt::{AccessToken, JwtConfig};
25use crate::password;
26
27// ---------------------------------------------------------------------------
28// AuthenticatedUser (JWT only)
29// ---------------------------------------------------------------------------
30
31/// An authenticated user extracted from a JWT.
32///
33/// Use as an Axum handler parameter to enforce JWT authentication.
34/// Requires `Arc<JwtConfig>` to be extractable from state via `FromRef`.
35///
36/// # Examples
37///
38/// ```no_run
39/// use ironflow_auth::extractor::AuthenticatedUser;
40///
41/// async fn protected(user: AuthenticatedUser) -> String {
42///     format!("Hello, {}!", user.username)
43/// }
44/// ```
45#[derive(Debug, Clone)]
46pub struct AuthenticatedUser {
47    /// The user's unique identifier.
48    pub user_id: Uuid,
49    /// The user's username.
50    pub username: String,
51    /// Whether the user is an administrator.
52    pub is_admin: bool,
53}
54
55impl<S> FromRequestParts<S> for AuthenticatedUser
56where
57    S: Send + Sync,
58    Arc<JwtConfig>: FromRef<S>,
59{
60    type Rejection = AuthRejection;
61
62    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
63        let jwt_config = Arc::<JwtConfig>::from_ref(state);
64
65        let jar = CookieJar::from_headers(&parts.headers);
66        let token = jar
67            .get(AUTH_COOKIE_NAME)
68            .map(|c| c.value().to_string())
69            .or_else(|| {
70                parts
71                    .headers
72                    .get("authorization")
73                    .and_then(|v| v.to_str().ok())
74                    .and_then(|v| v.strip_prefix("Bearer "))
75                    .map(|t| t.to_string())
76            });
77
78        let token = token.ok_or(AuthRejection {
79            status: StatusCode::UNAUTHORIZED,
80            code: "MISSING_TOKEN",
81            message: "No authentication token provided",
82        })?;
83
84        let claims = AccessToken::decode(&token, &jwt_config).map_err(|_| AuthRejection {
85            status: StatusCode::UNAUTHORIZED,
86            code: "INVALID_TOKEN",
87            message: "Invalid or expired authentication token",
88        })?;
89
90        Ok(AuthenticatedUser {
91            user_id: claims.user_id,
92            username: claims.username,
93            is_admin: claims.is_admin,
94        })
95    }
96}
97
98/// Rejection type when JWT authentication fails.
99pub struct AuthRejection {
100    status: StatusCode,
101    code: &'static str,
102    message: &'static str,
103}
104
105impl IntoResponse for AuthRejection {
106    fn into_response(self) -> Response {
107        let body = json!({
108            "error": {
109                "code": self.code,
110                "message": self.message,
111            }
112        });
113        (self.status, Json(body)).into_response()
114    }
115}
116
117// ---------------------------------------------------------------------------
118// ApiKeyAuth (API key only)
119// ---------------------------------------------------------------------------
120
121/// API key prefix used to distinguish API keys from JWT tokens.
122pub const API_KEY_PREFIX: &str = "irfl_";
123
124/// Number of hex characters kept after [`API_KEY_PREFIX`] to form the stored prefix.
125pub const API_KEY_SUFFIX_LEN: usize = 8;
126
127/// An authenticated caller via API key.
128///
129/// Use as an Axum handler parameter to enforce API key authentication.
130///
131/// # Examples
132///
133/// ```no_run
134/// use ironflow_auth::extractor::ApiKeyAuth;
135///
136/// async fn protected(key: ApiKeyAuth) -> String {
137///     format!("Key {} (user {})", key.key_name, key.user_id)
138/// }
139/// ```
140#[derive(Debug, Clone)]
141pub struct ApiKeyAuth {
142    /// The API key ID.
143    pub key_id: Uuid,
144    /// The owner user ID.
145    pub user_id: Uuid,
146    /// The API key name.
147    pub key_name: String,
148    /// Scopes granted to this key.
149    pub scopes: Vec<ApiKeyScope>,
150    /// Whether the key owner is an admin (checked at request time).
151    pub owner_is_admin: bool,
152}
153
154impl ApiKeyAuth {
155    /// Check if the API key has a specific scope.
156    pub fn has_scope(&self, required: &ApiKeyScope) -> bool {
157        ApiKeyScope::has_permission(&self.scopes, required)
158    }
159}
160
161/// Rejection type when API key authentication fails.
162pub struct ApiKeyRejection {
163    status: StatusCode,
164    code: &'static str,
165    message: &'static str,
166}
167
168impl IntoResponse for ApiKeyRejection {
169    fn into_response(self) -> Response {
170        let body = json!({
171            "error": {
172                "code": self.code,
173                "message": self.message,
174            }
175        });
176        (self.status, Json(body)).into_response()
177    }
178}
179
180impl<S> FromRequestParts<S> for ApiKeyAuth
181where
182    S: Send + Sync,
183    Arc<dyn Store>: FromRef<S>,
184{
185    type Rejection = ApiKeyRejection;
186
187    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
188        let store = Arc::<dyn Store>::from_ref(state);
189
190        let token = parts
191            .headers
192            .get("authorization")
193            .and_then(|v| v.to_str().ok())
194            .and_then(|v| v.strip_prefix("Bearer "))
195            .ok_or(ApiKeyRejection {
196                status: StatusCode::UNAUTHORIZED,
197                code: "MISSING_TOKEN",
198                message: "No authentication token provided",
199            })?;
200
201        if !token.starts_with(API_KEY_PREFIX) {
202            return Err(ApiKeyRejection {
203                status: StatusCode::UNAUTHORIZED,
204                code: "INVALID_TOKEN",
205                message: "Expected API key (irfl_...) in Authorization header",
206            });
207        }
208
209        let suffix_len = (token.len() - API_KEY_PREFIX.len()).min(API_KEY_SUFFIX_LEN);
210        let prefix = &token[..API_KEY_PREFIX.len() + suffix_len];
211
212        let api_key = store
213            .find_api_key_by_prefix(prefix)
214            .await
215            .map_err(|_| ApiKeyRejection {
216                status: StatusCode::INTERNAL_SERVER_ERROR,
217                code: "INTERNAL_ERROR",
218                message: "Failed to look up API key",
219            })?
220            .ok_or(ApiKeyRejection {
221                status: StatusCode::UNAUTHORIZED,
222                code: "INVALID_TOKEN",
223                message: "Invalid API key",
224            })?;
225
226        if !api_key.is_active {
227            return Err(ApiKeyRejection {
228                status: StatusCode::UNAUTHORIZED,
229                code: "KEY_DISABLED",
230                message: "API key is disabled",
231            });
232        }
233
234        if let Some(expires_at) = api_key.expires_at
235            && expires_at < Utc::now()
236        {
237            return Err(ApiKeyRejection {
238                status: StatusCode::UNAUTHORIZED,
239                code: "KEY_EXPIRED",
240                message: "API key has expired",
241            });
242        }
243
244        let valid = password::verify(token, &api_key.key_hash).map_err(|_| ApiKeyRejection {
245            status: StatusCode::INTERNAL_SERVER_ERROR,
246            code: "INTERNAL_ERROR",
247            message: "Failed to verify API key",
248        })?;
249
250        if !valid {
251            return Err(ApiKeyRejection {
252                status: StatusCode::UNAUTHORIZED,
253                code: "INVALID_TOKEN",
254                message: "Invalid API key",
255            });
256        }
257
258        let _ = store.touch_api_key(api_key.id).await;
259
260        let owner = store
261            .find_user_by_id(api_key.user_id)
262            .await
263            .map_err(|_| ApiKeyRejection {
264                status: StatusCode::INTERNAL_SERVER_ERROR,
265                code: "INTERNAL_ERROR",
266                message: "Failed to look up API key owner",
267            })?;
268        let owner_is_admin = owner.map(|u| u.is_admin).unwrap_or(false);
269
270        Ok(ApiKeyAuth {
271            key_id: api_key.id,
272            user_id: api_key.user_id,
273            key_name: api_key.name,
274            scopes: api_key.scopes,
275            owner_is_admin,
276        })
277    }
278}
279
280// ---------------------------------------------------------------------------
281// Authenticated (dual: API key OR JWT)
282// ---------------------------------------------------------------------------
283
284/// An authenticated caller, either via API key or JWT.
285///
286/// If the `Authorization: Bearer` token starts with `irfl_`, API key auth is used.
287/// Otherwise, JWT auth is attempted (cookie first, then header).
288///
289/// # Examples
290///
291/// ```no_run
292/// use ironflow_auth::extractor::Authenticated;
293///
294/// async fn protected(auth: Authenticated) -> String {
295///     format!("User {}", auth.user_id)
296/// }
297/// ```
298#[derive(Debug, Clone)]
299pub struct Authenticated {
300    /// The authenticated user's ID.
301    pub user_id: Uuid,
302    /// The authentication method used.
303    pub method: AuthMethod,
304}
305
306/// How the caller was authenticated.
307#[derive(Debug, Clone)]
308pub enum AuthMethod {
309    /// Authenticated via JWT (cookie or Bearer header).
310    Jwt {
311        /// The user's username.
312        username: String,
313        /// Whether the user is an admin.
314        is_admin: bool,
315    },
316    /// Authenticated via API key.
317    ApiKey {
318        /// The API key ID.
319        key_id: Uuid,
320        /// The API key name.
321        key_name: String,
322        /// Scopes granted to this key.
323        scopes: Vec<ApiKeyScope>,
324        /// Whether the key owner is an admin (checked at request time).
325        owner_is_admin: bool,
326    },
327}
328
329impl Authenticated {
330    /// Whether the authenticated caller has admin privileges.
331    ///
332    /// For JWT users, checks the `is_admin` claim.
333    /// For API key users, checks the owner's current admin status
334    /// (fetched at request time, so demotions take effect immediately).
335    pub fn is_admin(&self) -> bool {
336        match &self.method {
337            AuthMethod::Jwt { is_admin, .. } => *is_admin,
338            AuthMethod::ApiKey { owner_is_admin, .. } => *owner_is_admin,
339        }
340    }
341}
342
343impl<S> FromRequestParts<S> for Authenticated
344where
345    S: Send + Sync,
346    Arc<JwtConfig>: FromRef<S>,
347    Arc<dyn Store>: FromRef<S>,
348{
349    type Rejection = AuthRejection;
350
351    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
352        let jar = CookieJar::from_headers(&parts.headers);
353        let cookie_token = jar.get(AUTH_COOKIE_NAME).map(|c| c.value().to_string());
354
355        let header_token = parts
356            .headers
357            .get("authorization")
358            .and_then(|v| v.to_str().ok())
359            .and_then(|v| v.strip_prefix("Bearer "))
360            .map(|t| t.to_string());
361
362        // If the Bearer token is an API key, use API key auth
363        if let Some(ref token) = header_token
364            && token.starts_with(API_KEY_PREFIX)
365        {
366            let api_key_auth =
367                ApiKeyAuth::from_request_parts(parts, state)
368                    .await
369                    .map_err(|_| AuthRejection {
370                        status: StatusCode::UNAUTHORIZED,
371                        code: "INVALID_TOKEN",
372                        message: "Invalid or expired authentication token",
373                    })?;
374            return Ok(Authenticated {
375                user_id: api_key_auth.user_id,
376                method: AuthMethod::ApiKey {
377                    key_id: api_key_auth.key_id,
378                    key_name: api_key_auth.key_name,
379                    scopes: api_key_auth.scopes,
380                    owner_is_admin: api_key_auth.owner_is_admin,
381                },
382            });
383        }
384
385        // Otherwise, try JWT (cookie first, then header)
386        let token = cookie_token.or(header_token).ok_or(AuthRejection {
387            status: StatusCode::UNAUTHORIZED,
388            code: "MISSING_TOKEN",
389            message: "No authentication token provided",
390        })?;
391
392        let jwt_config = Arc::<JwtConfig>::from_ref(state);
393        let claims = AccessToken::decode(&token, &jwt_config).map_err(|_| AuthRejection {
394            status: StatusCode::UNAUTHORIZED,
395            code: "INVALID_TOKEN",
396            message: "Invalid or expired authentication token",
397        })?;
398
399        Ok(Authenticated {
400            user_id: claims.user_id,
401            method: AuthMethod::Jwt {
402                username: claims.username,
403                is_admin: claims.is_admin,
404            },
405        })
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use std::sync::Arc;
412
413    use axum::body::Body;
414    use axum::extract::FromRef;
415    use axum::http::{Request, StatusCode};
416    use axum::routing::get;
417    use axum::{Json, Router};
418    use http_body_util::BodyExt;
419    use ironflow_store::entities::NewUser;
420    use ironflow_store::entities::{ApiKeyScope, NewApiKey};
421    use ironflow_store::memory::InMemoryStore;
422    use ironflow_store::store::Store;
423    use serde_json::Value;
424    use tower::ServiceExt;
425    use uuid::Uuid;
426
427    use crate::jwt::{AccessToken, JwtConfig};
428    use crate::password;
429
430    use super::*;
431
432    #[derive(Clone)]
433    struct TestState {
434        jwt_config: Arc<JwtConfig>,
435        store: Arc<dyn Store>,
436    }
437
438    impl FromRef<TestState> for Arc<JwtConfig> {
439        fn from_ref(state: &TestState) -> Self {
440            state.jwt_config.clone()
441        }
442    }
443
444    impl FromRef<TestState> for Arc<dyn Store> {
445        fn from_ref(state: &TestState) -> Self {
446            state.store.clone()
447        }
448    }
449
450    fn test_jwt_config() -> Arc<JwtConfig> {
451        Arc::new(JwtConfig {
452            secret: "test-secret-key-for-extractor-tests".to_string(),
453            access_token_ttl_secs: 900,
454            refresh_token_ttl_secs: 604800,
455            cookie_domain: None,
456            cookie_secure: false,
457        })
458    }
459
460    fn test_state() -> TestState {
461        TestState {
462            jwt_config: test_jwt_config(),
463            store: Arc::new(InMemoryStore::new()),
464        }
465    }
466
467    async fn response_json(resp: axum::http::Response<Body>) -> Value {
468        let body = resp.into_body().collect().await.unwrap().to_bytes();
469        serde_json::from_slice(&body).unwrap()
470    }
471
472    // ---------------------------------------------------------------
473    // AuthenticatedUser (JWT only)
474    // ---------------------------------------------------------------
475
476    #[tokio::test]
477    async fn jwt_extractor_from_bearer_header() {
478        let state = test_state();
479        let user_id = Uuid::now_v7();
480        let token = AccessToken::for_user(user_id, "alice", false, &state.jwt_config).unwrap();
481
482        let app = Router::new()
483            .route(
484                "/me",
485                get(|user: AuthenticatedUser| async move {
486                    Json(json!({
487                        "user_id": user.user_id,
488                        "username": user.username,
489                        "is_admin": user.is_admin
490                    }))
491                }),
492            )
493            .with_state(state);
494
495        let req = Request::builder()
496            .uri("/me")
497            .header("authorization", format!("Bearer {}", token.0))
498            .body(Body::empty())
499            .unwrap();
500
501        let resp = app.oneshot(req).await.unwrap();
502        assert_eq!(resp.status(), StatusCode::OK);
503
504        let json = response_json(resp).await;
505        assert_eq!(json["user_id"], user_id.to_string());
506        assert_eq!(json["username"], "alice");
507        assert_eq!(json["is_admin"], false);
508    }
509
510    #[tokio::test]
511    async fn jwt_extractor_from_cookie() {
512        let state = test_state();
513        let user_id = Uuid::now_v7();
514        let token = AccessToken::for_user(user_id, "bob", true, &state.jwt_config).unwrap();
515
516        let app = Router::new()
517            .route(
518                "/me",
519                get(|user: AuthenticatedUser| async move {
520                    Json(json!({ "username": user.username, "is_admin": user.is_admin }))
521                }),
522            )
523            .with_state(state);
524
525        let req = Request::builder()
526            .uri("/me")
527            .header("cookie", format!("{}={}", AUTH_COOKIE_NAME, token.0))
528            .body(Body::empty())
529            .unwrap();
530
531        let resp = app.oneshot(req).await.unwrap();
532        assert_eq!(resp.status(), StatusCode::OK);
533
534        let json = response_json(resp).await;
535        assert_eq!(json["username"], "bob");
536        assert_eq!(json["is_admin"], true);
537    }
538
539    #[tokio::test]
540    async fn jwt_extractor_rejects_missing_token() {
541        let app = Router::new()
542            .route("/me", get(|_user: AuthenticatedUser| async { "ok" }))
543            .with_state(test_state());
544
545        let req = Request::builder().uri("/me").body(Body::empty()).unwrap();
546
547        let resp = app.oneshot(req).await.unwrap();
548        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
549
550        let json = response_json(resp).await;
551        assert_eq!(json["error"]["code"], "MISSING_TOKEN");
552    }
553
554    #[tokio::test]
555    async fn jwt_extractor_rejects_invalid_token() {
556        let app = Router::new()
557            .route("/me", get(|_user: AuthenticatedUser| async { "ok" }))
558            .with_state(test_state());
559
560        let req = Request::builder()
561            .uri("/me")
562            .header("authorization", "Bearer invalid.token.here")
563            .body(Body::empty())
564            .unwrap();
565
566        let resp = app.oneshot(req).await.unwrap();
567        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
568
569        let json = response_json(resp).await;
570        assert_eq!(json["error"]["code"], "INVALID_TOKEN");
571    }
572
573    // ---------------------------------------------------------------
574    // ApiKeyAuth
575    // ---------------------------------------------------------------
576
577    async fn setup_api_key(store: &Arc<dyn Store>) -> (Uuid, String) {
578        let user = store
579            .create_user(NewUser {
580                email: "key-owner@test.com".to_string(),
581                username: "keyowner".to_string(),
582                password_hash: password::hash("pass123").unwrap(),
583                is_admin: Some(true),
584            })
585            .await
586            .unwrap();
587
588        let raw_key = "irfl_abcdef12rest-of-secret-key";
589        let key_hash = password::hash(raw_key).unwrap();
590        let prefix = &raw_key[..API_KEY_PREFIX.len() + API_KEY_SUFFIX_LEN];
591
592        store
593            .create_api_key(NewApiKey {
594                user_id: user.id,
595                name: "test-key".to_string(),
596                key_hash,
597                key_prefix: prefix.to_string(),
598                scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::WorkflowsRead],
599                expires_at: None,
600                rate_limit_override: None,
601            })
602            .await
603            .unwrap();
604
605        (user.id, raw_key.to_string())
606    }
607
608    #[tokio::test]
609    async fn api_key_extractor_valid_key() {
610        let state = test_state();
611        let (user_id, raw_key) = setup_api_key(&state.store).await;
612
613        let app = Router::new()
614            .route(
615                "/check",
616                get(|key: ApiKeyAuth| async move {
617                    Json(json!({
618                        "user_id": key.user_id,
619                        "key_name": key.key_name,
620                        "scopes": key.scopes,
621                        "owner_is_admin": key.owner_is_admin
622                    }))
623                }),
624            )
625            .with_state(state);
626
627        let req = Request::builder()
628            .uri("/check")
629            .header("authorization", format!("Bearer {raw_key}"))
630            .body(Body::empty())
631            .unwrap();
632
633        let resp = app.oneshot(req).await.unwrap();
634        assert_eq!(resp.status(), StatusCode::OK);
635
636        let json = response_json(resp).await;
637        assert_eq!(json["user_id"], user_id.to_string());
638        assert_eq!(json["key_name"], "test-key");
639        assert_eq!(json["owner_is_admin"], true);
640    }
641
642    #[tokio::test]
643    async fn api_key_extractor_rejects_missing_header() {
644        let app = Router::new()
645            .route("/check", get(|_key: ApiKeyAuth| async { "ok" }))
646            .with_state(test_state());
647
648        let req = Request::builder()
649            .uri("/check")
650            .body(Body::empty())
651            .unwrap();
652
653        let resp = app.oneshot(req).await.unwrap();
654        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
655
656        let json = response_json(resp).await;
657        assert_eq!(json["error"]["code"], "MISSING_TOKEN");
658    }
659
660    #[tokio::test]
661    async fn api_key_extractor_rejects_non_irfl_token() {
662        let app = Router::new()
663            .route("/check", get(|_key: ApiKeyAuth| async { "ok" }))
664            .with_state(test_state());
665
666        let req = Request::builder()
667            .uri("/check")
668            .header("authorization", "Bearer not-an-api-key")
669            .body(Body::empty())
670            .unwrap();
671
672        let resp = app.oneshot(req).await.unwrap();
673        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
674
675        let json = response_json(resp).await;
676        assert_eq!(json["error"]["code"], "INVALID_TOKEN");
677    }
678
679    #[tokio::test]
680    async fn api_key_extractor_rejects_unknown_key() {
681        let app = Router::new()
682            .route("/check", get(|_key: ApiKeyAuth| async { "ok" }))
683            .with_state(test_state());
684
685        let req = Request::builder()
686            .uri("/check")
687            .header("authorization", "Bearer irfl_unknown1rest-of-key")
688            .body(Body::empty())
689            .unwrap();
690
691        let resp = app.oneshot(req).await.unwrap();
692        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
693
694        let json = response_json(resp).await;
695        assert_eq!(json["error"]["code"], "INVALID_TOKEN");
696    }
697
698    // ---------------------------------------------------------------
699    // ApiKeyAuth::has_scope()
700    // ---------------------------------------------------------------
701
702    #[test]
703    fn has_scope_returns_true_for_granted_scope() {
704        let auth = ApiKeyAuth {
705            key_id: Uuid::now_v7(),
706            user_id: Uuid::now_v7(),
707            key_name: "k".to_string(),
708            scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::WorkflowsRead],
709            owner_is_admin: false,
710        };
711        assert!(auth.has_scope(&ApiKeyScope::RunsRead));
712        assert!(auth.has_scope(&ApiKeyScope::WorkflowsRead));
713    }
714
715    #[test]
716    fn has_scope_returns_false_for_missing_scope() {
717        let auth = ApiKeyAuth {
718            key_id: Uuid::now_v7(),
719            user_id: Uuid::now_v7(),
720            key_name: "k".to_string(),
721            scopes: vec![ApiKeyScope::RunsRead],
722            owner_is_admin: false,
723        };
724        assert!(!auth.has_scope(&ApiKeyScope::RunsWrite));
725        assert!(!auth.has_scope(&ApiKeyScope::Admin));
726    }
727
728    #[test]
729    fn has_scope_admin_grants_everything() {
730        let auth = ApiKeyAuth {
731            key_id: Uuid::now_v7(),
732            user_id: Uuid::now_v7(),
733            key_name: "k".to_string(),
734            scopes: vec![ApiKeyScope::Admin],
735            owner_is_admin: true,
736        };
737        assert!(auth.has_scope(&ApiKeyScope::RunsRead));
738        assert!(auth.has_scope(&ApiKeyScope::RunsWrite));
739        assert!(auth.has_scope(&ApiKeyScope::StatsRead));
740    }
741
742    // ---------------------------------------------------------------
743    // Authenticated (dual auth)
744    // ---------------------------------------------------------------
745
746    #[tokio::test]
747    async fn authenticated_via_jwt() {
748        let state = test_state();
749        let user_id = Uuid::now_v7();
750        let token = AccessToken::for_user(user_id, "alice", true, &state.jwt_config).unwrap();
751
752        let app = Router::new()
753            .route(
754                "/auth",
755                get(|auth: Authenticated| async move {
756                    Json(json!({
757                        "user_id": auth.user_id,
758                        "is_admin": auth.is_admin(),
759                        "method": match auth.method {
760                            AuthMethod::Jwt { .. } => "jwt",
761                            AuthMethod::ApiKey { .. } => "api_key",
762                        }
763                    }))
764                }),
765            )
766            .with_state(state);
767
768        let req = Request::builder()
769            .uri("/auth")
770            .header("authorization", format!("Bearer {}", token.0))
771            .body(Body::empty())
772            .unwrap();
773
774        let resp = app.oneshot(req).await.unwrap();
775        assert_eq!(resp.status(), StatusCode::OK);
776
777        let json = response_json(resp).await;
778        assert_eq!(json["user_id"], user_id.to_string());
779        assert_eq!(json["is_admin"], true);
780        assert_eq!(json["method"], "jwt");
781    }
782
783    #[tokio::test]
784    async fn authenticated_via_api_key() {
785        let state = test_state();
786        let (user_id, raw_key) = setup_api_key(&state.store).await;
787
788        let app = Router::new()
789            .route(
790                "/auth",
791                get(|auth: Authenticated| async move {
792                    Json(json!({
793                        "user_id": auth.user_id,
794                        "is_admin": auth.is_admin(),
795                        "method": match auth.method {
796                            AuthMethod::Jwt { .. } => "jwt",
797                            AuthMethod::ApiKey { .. } => "api_key",
798                        }
799                    }))
800                }),
801            )
802            .with_state(state);
803
804        let req = Request::builder()
805            .uri("/auth")
806            .header("authorization", format!("Bearer {raw_key}"))
807            .body(Body::empty())
808            .unwrap();
809
810        let resp = app.oneshot(req).await.unwrap();
811        assert_eq!(resp.status(), StatusCode::OK);
812
813        let json = response_json(resp).await;
814        assert_eq!(json["user_id"], user_id.to_string());
815        assert_eq!(json["is_admin"], true);
816        assert_eq!(json["method"], "api_key");
817    }
818
819    #[tokio::test]
820    async fn authenticated_rejects_missing_token() {
821        let app = Router::new()
822            .route("/auth", get(|_auth: Authenticated| async { "ok" }))
823            .with_state(test_state());
824
825        let req = Request::builder().uri("/auth").body(Body::empty()).unwrap();
826
827        let resp = app.oneshot(req).await.unwrap();
828        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
829
830        let json = response_json(resp).await;
831        assert_eq!(json["error"]["code"], "MISSING_TOKEN");
832    }
833
834    // ---------------------------------------------------------------
835    // Authenticated::is_admin()
836    // ---------------------------------------------------------------
837
838    #[test]
839    fn is_admin_jwt_true() {
840        let auth = Authenticated {
841            user_id: Uuid::now_v7(),
842            method: AuthMethod::Jwt {
843                username: "admin".to_string(),
844                is_admin: true,
845            },
846        };
847        assert!(auth.is_admin());
848    }
849
850    #[test]
851    fn is_admin_jwt_false() {
852        let auth = Authenticated {
853            user_id: Uuid::now_v7(),
854            method: AuthMethod::Jwt {
855                username: "user".to_string(),
856                is_admin: false,
857            },
858        };
859        assert!(!auth.is_admin());
860    }
861
862    #[test]
863    fn is_admin_api_key_true() {
864        let auth = Authenticated {
865            user_id: Uuid::now_v7(),
866            method: AuthMethod::ApiKey {
867                key_id: Uuid::now_v7(),
868                key_name: "k".to_string(),
869                scopes: vec![],
870                owner_is_admin: true,
871            },
872        };
873        assert!(auth.is_admin());
874    }
875
876    #[test]
877    fn is_admin_api_key_false() {
878        let auth = Authenticated {
879            user_id: Uuid::now_v7(),
880            method: AuthMethod::ApiKey {
881                key_id: Uuid::now_v7(),
882                key_name: "k".to_string(),
883                scopes: vec![],
884                owner_is_admin: false,
885            },
886        };
887        assert!(!auth.is_admin());
888    }
889
890    // ---------------------------------------------------------------
891    // AuthRejection / ApiKeyRejection IntoResponse
892    // ---------------------------------------------------------------
893
894    #[tokio::test]
895    async fn auth_rejection_into_response() {
896        let rejection = AuthRejection {
897            status: StatusCode::UNAUTHORIZED,
898            code: "TEST_CODE",
899            message: "test message",
900        };
901        let resp = rejection.into_response();
902        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
903
904        let body = resp.into_body().collect().await.unwrap().to_bytes();
905        let json: Value = serde_json::from_slice(&body).unwrap();
906        assert_eq!(json["error"]["code"], "TEST_CODE");
907        assert_eq!(json["error"]["message"], "test message");
908    }
909
910    #[tokio::test]
911    async fn api_key_rejection_into_response() {
912        let rejection = ApiKeyRejection {
913            status: StatusCode::FORBIDDEN,
914            code: "KEY_DISABLED",
915            message: "API key is disabled",
916        };
917        let resp = rejection.into_response();
918        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
919
920        let body = resp.into_body().collect().await.unwrap().to_bytes();
921        let json: Value = serde_json::from_slice(&body).unwrap();
922        assert_eq!(json["error"]["code"], "KEY_DISABLED");
923        assert_eq!(json["error"]["message"], "API key is disabled");
924    }
925}