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 tracing::error;
22use uuid::Uuid;
23
24use crate::cookies::AUTH_COOKIE_NAME;
25use crate::jwt::{AccessToken, AccessTokenClaims, JwtConfig};
26use crate::password;
27
28// ---------------------------------------------------------------------------
29// AuthenticatedUser (JWT only)
30// ---------------------------------------------------------------------------
31
32/// An authenticated user extracted from a JWT.
33///
34/// Use as an Axum handler parameter to enforce JWT authentication.
35/// Requires `Arc<JwtConfig>` and `Arc<dyn Store>` to be extractable from state
36/// via `FromRef`: the token is checked against the stored user on every request.
37///
38/// # Examples
39///
40/// ```no_run
41/// use ironflow_auth::extractor::AuthenticatedUser;
42///
43/// async fn protected(user: AuthenticatedUser) -> String {
44///     format!("Hello, {}!", user.username)
45/// }
46/// ```
47#[derive(Debug, Clone)]
48pub struct AuthenticatedUser {
49    /// The user's unique identifier.
50    pub user_id: Uuid,
51    /// The user's username.
52    pub username: String,
53    /// Whether the user is an administrator.
54    pub is_admin: bool,
55}
56
57impl<S> FromRequestParts<S> for AuthenticatedUser
58where
59    S: Send + Sync,
60    Arc<JwtConfig>: FromRef<S>,
61    Arc<dyn Store>: FromRef<S>,
62{
63    type Rejection = AuthRejection;
64
65    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
66        let jwt_config = Arc::<JwtConfig>::from_ref(state);
67        let store = Arc::<dyn Store>::from_ref(state);
68
69        let jar = CookieJar::from_headers(&parts.headers);
70        let token = jar
71            .get(AUTH_COOKIE_NAME)
72            .map(|c| c.value().to_string())
73            .or_else(|| {
74                parts
75                    .headers
76                    .get("authorization")
77                    .and_then(|v| v.to_str().ok())
78                    .and_then(|v| v.strip_prefix("Bearer "))
79                    .map(|t| t.to_string())
80            });
81
82        let token = token.ok_or(AuthRejection {
83            status: StatusCode::UNAUTHORIZED,
84            code: "MISSING_TOKEN",
85            message: "No authentication token provided",
86        })?;
87
88        let claims = verify_access_token(&token, &jwt_config, store.as_ref()).await?;
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/// Decode an access token and check it against the stored user.
99///
100/// A token whose `ver` no longer matches the user's `token_version` has been
101/// revoked (sign-out, password or role change). A token whose user no longer
102/// exists in the store (deleted account) is rejected as revoked too, so a
103/// client cannot tell a deletion from a sign-out. The admin flag and username
104/// come from the store, never from the claims, so a demotion takes effect on
105/// the next request.
106pub(crate) async fn verify_access_token(
107    token: &str,
108    jwt_config: &JwtConfig,
109    store: &dyn Store,
110) -> Result<AccessTokenClaims, AuthRejection> {
111    let mut claims = AccessToken::decode(token, jwt_config).map_err(|_| AuthRejection {
112        status: StatusCode::UNAUTHORIZED,
113        code: "INVALID_TOKEN",
114        message: "Invalid or expired authentication token",
115    })?;
116
117    let user = store.find_user_by_id(claims.user_id).await.map_err(|e| {
118        error!(user_id = %claims.user_id, error = %e, "failed to look up user for token");
119        AuthRejection {
120            status: StatusCode::INTERNAL_SERVER_ERROR,
121            code: "INTERNAL_ERROR",
122            message: "Failed to look up user",
123        }
124    })?;
125
126    let user = user
127        .filter(|user| user.token_version == claims.ver)
128        .ok_or(AuthRejection {
129            status: StatusCode::UNAUTHORIZED,
130            code: "TOKEN_REVOKED",
131            message: "Authentication token has been revoked",
132        })?;
133    claims.is_admin = user.is_admin;
134    claims.username = user.username;
135
136    Ok(claims)
137}
138
139/// Rejection type when JWT authentication fails.
140pub struct AuthRejection {
141    status: StatusCode,
142    code: &'static str,
143    message: &'static str,
144}
145
146impl IntoResponse for AuthRejection {
147    fn into_response(self) -> Response {
148        let body = json!({
149            "error": {
150                "code": self.code,
151                "message": self.message,
152            }
153        });
154        (self.status, Json(body)).into_response()
155    }
156}
157
158// ---------------------------------------------------------------------------
159// ApiKeyAuth (API key only)
160// ---------------------------------------------------------------------------
161
162/// API key prefix used to distinguish API keys from JWT tokens.
163pub const API_KEY_PREFIX: &str = "irfl_";
164
165/// Number of hex characters kept after [`API_KEY_PREFIX`] to form the stored prefix.
166pub const API_KEY_SUFFIX_LEN: usize = 8;
167
168/// An authenticated caller via API key.
169///
170/// Use as an Axum handler parameter to enforce API key authentication.
171///
172/// # Examples
173///
174/// ```no_run
175/// use ironflow_auth::extractor::ApiKeyAuth;
176///
177/// async fn protected(key: ApiKeyAuth) -> String {
178///     format!("Key {} (user {})", key.key_name, key.user_id)
179/// }
180/// ```
181#[derive(Debug, Clone)]
182pub struct ApiKeyAuth {
183    /// The API key ID.
184    pub key_id: Uuid,
185    /// The owner user ID.
186    pub user_id: Uuid,
187    /// The API key name.
188    pub key_name: String,
189    /// Scopes granted to this key.
190    pub scopes: Vec<ApiKeyScope>,
191    /// Whether the key owner is an admin (checked at request time).
192    pub owner_is_admin: bool,
193}
194
195impl ApiKeyAuth {
196    /// Check if the API key has a specific scope.
197    pub fn has_scope(&self, required: &ApiKeyScope) -> bool {
198        ApiKeyScope::has_permission(&self.scopes, required)
199    }
200}
201
202/// Rejection type when API key authentication fails.
203pub struct ApiKeyRejection {
204    status: StatusCode,
205    code: &'static str,
206    message: &'static str,
207}
208
209impl IntoResponse for ApiKeyRejection {
210    fn into_response(self) -> Response {
211        let body = json!({
212            "error": {
213                "code": self.code,
214                "message": self.message,
215            }
216        });
217        (self.status, Json(body)).into_response()
218    }
219}
220
221impl<S> FromRequestParts<S> for ApiKeyAuth
222where
223    S: Send + Sync,
224    Arc<dyn Store>: FromRef<S>,
225{
226    type Rejection = ApiKeyRejection;
227
228    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
229        let store = Arc::<dyn Store>::from_ref(state);
230
231        let token = parts
232            .headers
233            .get("authorization")
234            .and_then(|v| v.to_str().ok())
235            .and_then(|v| v.strip_prefix("Bearer "))
236            .ok_or(ApiKeyRejection {
237                status: StatusCode::UNAUTHORIZED,
238                code: "MISSING_TOKEN",
239                message: "No authentication token provided",
240            })?;
241
242        if !token.starts_with(API_KEY_PREFIX) {
243            return Err(ApiKeyRejection {
244                status: StatusCode::UNAUTHORIZED,
245                code: "INVALID_TOKEN",
246                message: "Expected API key (irfl_...) in Authorization header",
247            });
248        }
249
250        let suffix_len = (token.len() - API_KEY_PREFIX.len()).min(API_KEY_SUFFIX_LEN);
251        let prefix = &token[..API_KEY_PREFIX.len() + suffix_len];
252
253        let api_key = store
254            .find_api_key_by_prefix(prefix)
255            .await
256            .map_err(|_| ApiKeyRejection {
257                status: StatusCode::INTERNAL_SERVER_ERROR,
258                code: "INTERNAL_ERROR",
259                message: "Failed to look up API key",
260            })?
261            .ok_or(ApiKeyRejection {
262                status: StatusCode::UNAUTHORIZED,
263                code: "INVALID_TOKEN",
264                message: "Invalid API key",
265            })?;
266
267        if !api_key.is_active {
268            return Err(ApiKeyRejection {
269                status: StatusCode::UNAUTHORIZED,
270                code: "KEY_DISABLED",
271                message: "API key is disabled",
272            });
273        }
274
275        if let Some(expires_at) = api_key.expires_at
276            && expires_at < Utc::now()
277        {
278            return Err(ApiKeyRejection {
279                status: StatusCode::UNAUTHORIZED,
280                code: "KEY_EXPIRED",
281                message: "API key has expired",
282            });
283        }
284
285        let valid = password::verify(token, &api_key.key_hash).map_err(|_| ApiKeyRejection {
286            status: StatusCode::INTERNAL_SERVER_ERROR,
287            code: "INTERNAL_ERROR",
288            message: "Failed to verify API key",
289        })?;
290
291        if !valid {
292            return Err(ApiKeyRejection {
293                status: StatusCode::UNAUTHORIZED,
294                code: "INVALID_TOKEN",
295                message: "Invalid API key",
296            });
297        }
298
299        let _ = store.touch_api_key(api_key.id).await;
300
301        let owner = store
302            .find_user_by_id(api_key.user_id)
303            .await
304            .map_err(|_| ApiKeyRejection {
305                status: StatusCode::INTERNAL_SERVER_ERROR,
306                code: "INTERNAL_ERROR",
307                message: "Failed to look up API key owner",
308            })?;
309        let owner_is_admin = owner.map(|u| u.is_admin).unwrap_or(false);
310
311        Ok(ApiKeyAuth {
312            key_id: api_key.id,
313            user_id: api_key.user_id,
314            key_name: api_key.name,
315            scopes: api_key.scopes,
316            owner_is_admin,
317        })
318    }
319}
320
321// ---------------------------------------------------------------------------
322// Authenticated (dual: API key OR JWT)
323// ---------------------------------------------------------------------------
324
325/// An authenticated caller, either via API key or JWT.
326///
327/// If the `Authorization: Bearer` token starts with `irfl_`, API key auth is used.
328/// Otherwise, JWT auth is attempted (cookie first, then header).
329///
330/// # Examples
331///
332/// ```no_run
333/// use ironflow_auth::extractor::Authenticated;
334///
335/// async fn protected(auth: Authenticated) -> String {
336///     format!("User {}", auth.user_id)
337/// }
338/// ```
339#[derive(Debug, Clone)]
340pub struct Authenticated {
341    /// The authenticated user's ID.
342    pub user_id: Uuid,
343    /// The authentication method used.
344    pub method: AuthMethod,
345}
346
347/// How the caller was authenticated.
348#[derive(Debug, Clone)]
349pub enum AuthMethod {
350    /// Authenticated via JWT (cookie or Bearer header).
351    Jwt {
352        /// The user's username.
353        username: String,
354        /// Whether the user is an admin.
355        is_admin: bool,
356    },
357    /// Authenticated via API key.
358    ApiKey {
359        /// The API key ID.
360        key_id: Uuid,
361        /// The API key name.
362        key_name: String,
363        /// Scopes granted to this key.
364        scopes: Vec<ApiKeyScope>,
365        /// Whether the key owner is an admin (checked at request time).
366        owner_is_admin: bool,
367    },
368}
369
370impl Authenticated {
371    /// Whether the authenticated caller has admin privileges.
372    ///
373    /// For JWT users, checks the user's current admin status, read from the
374    /// store at request time (the token's `is_admin` claim is not trusted
375    /// for a stored user, and a role change revokes the token).
376    /// For API key users, checks the owner's current admin status
377    /// (fetched at request time, so demotions take effect immediately).
378    pub fn is_admin(&self) -> bool {
379        match &self.method {
380            AuthMethod::Jwt { is_admin, .. } => *is_admin,
381            AuthMethod::ApiKey { owner_is_admin, .. } => *owner_is_admin,
382        }
383    }
384}
385
386impl<S> FromRequestParts<S> for Authenticated
387where
388    S: Send + Sync,
389    Arc<JwtConfig>: FromRef<S>,
390    Arc<dyn Store>: FromRef<S>,
391{
392    type Rejection = AuthRejection;
393
394    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
395        let jar = CookieJar::from_headers(&parts.headers);
396        let cookie_token = jar.get(AUTH_COOKIE_NAME).map(|c| c.value().to_string());
397
398        let header_token = parts
399            .headers
400            .get("authorization")
401            .and_then(|v| v.to_str().ok())
402            .and_then(|v| v.strip_prefix("Bearer "))
403            .map(|t| t.to_string());
404
405        // If the Bearer token is an API key, use API key auth
406        if let Some(ref token) = header_token
407            && token.starts_with(API_KEY_PREFIX)
408        {
409            let api_key_auth =
410                ApiKeyAuth::from_request_parts(parts, state)
411                    .await
412                    .map_err(|_| AuthRejection {
413                        status: StatusCode::UNAUTHORIZED,
414                        code: "INVALID_TOKEN",
415                        message: "Invalid or expired authentication token",
416                    })?;
417            return Ok(Authenticated {
418                user_id: api_key_auth.user_id,
419                method: AuthMethod::ApiKey {
420                    key_id: api_key_auth.key_id,
421                    key_name: api_key_auth.key_name,
422                    scopes: api_key_auth.scopes,
423                    owner_is_admin: api_key_auth.owner_is_admin,
424                },
425            });
426        }
427
428        // Otherwise, try JWT (cookie first, then header)
429        let token = cookie_token.or(header_token).ok_or(AuthRejection {
430            status: StatusCode::UNAUTHORIZED,
431            code: "MISSING_TOKEN",
432            message: "No authentication token provided",
433        })?;
434
435        let jwt_config = Arc::<JwtConfig>::from_ref(state);
436        let store = Arc::<dyn Store>::from_ref(state);
437        let claims = verify_access_token(&token, &jwt_config, store.as_ref()).await?;
438
439        Ok(Authenticated {
440            user_id: claims.user_id,
441            method: AuthMethod::Jwt {
442                username: claims.username,
443                is_admin: claims.is_admin,
444            },
445        })
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use std::sync::Arc;
452
453    use axum::body::Body;
454    use axum::extract::FromRef;
455    use axum::http::{Request, StatusCode};
456    use axum::routing::get;
457    use axum::{Json, Router};
458    use http_body_util::BodyExt;
459    use ironflow_store::entities::NewUser;
460    use ironflow_store::entities::{ApiKeyScope, NewApiKey};
461    use ironflow_store::memory::InMemoryStore;
462    use ironflow_store::store::Store;
463    use serde_json::Value;
464    use tower::ServiceExt;
465    use uuid::Uuid;
466
467    use crate::jwt::{AccessToken, JwtConfig};
468    use crate::password;
469
470    use super::*;
471
472    #[derive(Clone)]
473    struct TestState {
474        jwt_config: Arc<JwtConfig>,
475        store: Arc<dyn Store>,
476    }
477
478    impl FromRef<TestState> for Arc<JwtConfig> {
479        fn from_ref(state: &TestState) -> Self {
480            state.jwt_config.clone()
481        }
482    }
483
484    impl FromRef<TestState> for Arc<dyn Store> {
485        fn from_ref(state: &TestState) -> Self {
486            state.store.clone()
487        }
488    }
489
490    fn test_jwt_config() -> Arc<JwtConfig> {
491        Arc::new(JwtConfig {
492            secret: "test-secret-key-for-extractor-tests".to_string(),
493            access_token_ttl_secs: 900,
494            refresh_token_ttl_secs: 604800,
495            cookie_domain: None,
496            cookie_secure: false,
497        })
498    }
499
500    fn test_state() -> TestState {
501        TestState {
502            jwt_config: test_jwt_config(),
503            store: Arc::new(InMemoryStore::new()),
504        }
505    }
506
507    async fn response_json(resp: axum::http::Response<Body>) -> Value {
508        let body = resp.into_body().collect().await.unwrap().to_bytes();
509        serde_json::from_slice(&body).unwrap()
510    }
511
512    // ---------------------------------------------------------------
513    // AuthenticatedUser (JWT only)
514    // ---------------------------------------------------------------
515
516    #[tokio::test]
517    async fn jwt_extractor_from_bearer_header() {
518        let state = test_state();
519        let user_id = stored_user(&state.store, "alice", false).await;
520        let token = AccessToken::for_user(user_id, "alice", false, &state.jwt_config).unwrap();
521
522        let app = Router::new()
523            .route(
524                "/me",
525                get(|user: AuthenticatedUser| async move {
526                    Json(json!({
527                        "user_id": user.user_id,
528                        "username": user.username,
529                        "is_admin": user.is_admin
530                    }))
531                }),
532            )
533            .with_state(state);
534
535        let req = Request::builder()
536            .uri("/me")
537            .header("authorization", format!("Bearer {}", token.0))
538            .body(Body::empty())
539            .unwrap();
540
541        let resp = app.oneshot(req).await.unwrap();
542        assert_eq!(resp.status(), StatusCode::OK);
543
544        let json = response_json(resp).await;
545        assert_eq!(json["user_id"], user_id.to_string());
546        assert_eq!(json["username"], "alice");
547        assert_eq!(json["is_admin"], false);
548    }
549
550    #[tokio::test]
551    async fn jwt_extractor_from_cookie() {
552        let state = test_state();
553        let user_id = stored_user(&state.store, "bob", true).await;
554        let token = AccessToken::for_user(user_id, "bob", true, &state.jwt_config).unwrap();
555
556        let app = Router::new()
557            .route(
558                "/me",
559                get(|user: AuthenticatedUser| async move {
560                    Json(json!({ "username": user.username, "is_admin": user.is_admin }))
561                }),
562            )
563            .with_state(state);
564
565        let req = Request::builder()
566            .uri("/me")
567            .header("cookie", format!("{}={}", AUTH_COOKIE_NAME, token.0))
568            .body(Body::empty())
569            .unwrap();
570
571        let resp = app.oneshot(req).await.unwrap();
572        assert_eq!(resp.status(), StatusCode::OK);
573
574        let json = response_json(resp).await;
575        assert_eq!(json["username"], "bob");
576        assert_eq!(json["is_admin"], true);
577    }
578
579    #[tokio::test]
580    async fn jwt_extractor_rejects_missing_token() {
581        let app = Router::new()
582            .route("/me", get(|_user: AuthenticatedUser| async { "ok" }))
583            .with_state(test_state());
584
585        let req = Request::builder().uri("/me").body(Body::empty()).unwrap();
586
587        let resp = app.oneshot(req).await.unwrap();
588        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
589
590        let json = response_json(resp).await;
591        assert_eq!(json["error"]["code"], "MISSING_TOKEN");
592    }
593
594    #[tokio::test]
595    async fn jwt_extractor_rejects_invalid_token() {
596        let app = Router::new()
597            .route("/me", get(|_user: AuthenticatedUser| async { "ok" }))
598            .with_state(test_state());
599
600        let req = Request::builder()
601            .uri("/me")
602            .header("authorization", "Bearer invalid.token.here")
603            .body(Body::empty())
604            .unwrap();
605
606        let resp = app.oneshot(req).await.unwrap();
607        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
608
609        let json = response_json(resp).await;
610        assert_eq!(json["error"]["code"], "INVALID_TOKEN");
611    }
612
613    // ---------------------------------------------------------------
614    // ApiKeyAuth
615    // ---------------------------------------------------------------
616
617    async fn setup_api_key(store: &Arc<dyn Store>) -> (Uuid, String) {
618        let user = store
619            .create_user(NewUser {
620                email: "key-owner@test.com".to_string(),
621                username: "keyowner".to_string(),
622                password_hash: password::hash("pass123").unwrap(),
623                is_admin: Some(true),
624            })
625            .await
626            .unwrap();
627
628        let raw_key = "irfl_abcdef12rest-of-secret-key";
629        let key_hash = password::hash(raw_key).unwrap();
630        let prefix = &raw_key[..API_KEY_PREFIX.len() + API_KEY_SUFFIX_LEN];
631
632        store
633            .create_api_key(NewApiKey {
634                user_id: user.id,
635                name: "test-key".to_string(),
636                key_hash,
637                key_prefix: prefix.to_string(),
638                scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::WorkflowsRead],
639                expires_at: None,
640                rate_limit_override: None,
641            })
642            .await
643            .unwrap();
644
645        (user.id, raw_key.to_string())
646    }
647
648    #[tokio::test]
649    async fn api_key_extractor_valid_key() {
650        let state = test_state();
651        let (user_id, raw_key) = setup_api_key(&state.store).await;
652
653        let app = Router::new()
654            .route(
655                "/check",
656                get(|key: ApiKeyAuth| async move {
657                    Json(json!({
658                        "user_id": key.user_id,
659                        "key_name": key.key_name,
660                        "scopes": key.scopes,
661                        "owner_is_admin": key.owner_is_admin
662                    }))
663                }),
664            )
665            .with_state(state);
666
667        let req = Request::builder()
668            .uri("/check")
669            .header("authorization", format!("Bearer {raw_key}"))
670            .body(Body::empty())
671            .unwrap();
672
673        let resp = app.oneshot(req).await.unwrap();
674        assert_eq!(resp.status(), StatusCode::OK);
675
676        let json = response_json(resp).await;
677        assert_eq!(json["user_id"], user_id.to_string());
678        assert_eq!(json["key_name"], "test-key");
679        assert_eq!(json["owner_is_admin"], true);
680    }
681
682    #[tokio::test]
683    async fn api_key_extractor_rejects_missing_header() {
684        let app = Router::new()
685            .route("/check", get(|_key: ApiKeyAuth| async { "ok" }))
686            .with_state(test_state());
687
688        let req = Request::builder()
689            .uri("/check")
690            .body(Body::empty())
691            .unwrap();
692
693        let resp = app.oneshot(req).await.unwrap();
694        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
695
696        let json = response_json(resp).await;
697        assert_eq!(json["error"]["code"], "MISSING_TOKEN");
698    }
699
700    #[tokio::test]
701    async fn api_key_extractor_rejects_non_irfl_token() {
702        let app = Router::new()
703            .route("/check", get(|_key: ApiKeyAuth| async { "ok" }))
704            .with_state(test_state());
705
706        let req = Request::builder()
707            .uri("/check")
708            .header("authorization", "Bearer not-an-api-key")
709            .body(Body::empty())
710            .unwrap();
711
712        let resp = app.oneshot(req).await.unwrap();
713        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
714
715        let json = response_json(resp).await;
716        assert_eq!(json["error"]["code"], "INVALID_TOKEN");
717    }
718
719    #[tokio::test]
720    async fn api_key_extractor_rejects_unknown_key() {
721        let app = Router::new()
722            .route("/check", get(|_key: ApiKeyAuth| async { "ok" }))
723            .with_state(test_state());
724
725        let req = Request::builder()
726            .uri("/check")
727            .header("authorization", "Bearer irfl_unknown1rest-of-key")
728            .body(Body::empty())
729            .unwrap();
730
731        let resp = app.oneshot(req).await.unwrap();
732        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
733
734        let json = response_json(resp).await;
735        assert_eq!(json["error"]["code"], "INVALID_TOKEN");
736    }
737
738    // ---------------------------------------------------------------
739    // ApiKeyAuth::has_scope()
740    // ---------------------------------------------------------------
741
742    #[test]
743    fn has_scope_returns_true_for_granted_scope() {
744        let auth = ApiKeyAuth {
745            key_id: Uuid::now_v7(),
746            user_id: Uuid::now_v7(),
747            key_name: "k".to_string(),
748            scopes: vec![ApiKeyScope::RunsRead, ApiKeyScope::WorkflowsRead],
749            owner_is_admin: false,
750        };
751        assert!(auth.has_scope(&ApiKeyScope::RunsRead));
752        assert!(auth.has_scope(&ApiKeyScope::WorkflowsRead));
753    }
754
755    #[test]
756    fn has_scope_returns_false_for_missing_scope() {
757        let auth = ApiKeyAuth {
758            key_id: Uuid::now_v7(),
759            user_id: Uuid::now_v7(),
760            key_name: "k".to_string(),
761            scopes: vec![ApiKeyScope::RunsRead],
762            owner_is_admin: false,
763        };
764        assert!(!auth.has_scope(&ApiKeyScope::RunsWrite));
765        assert!(!auth.has_scope(&ApiKeyScope::Admin));
766    }
767
768    #[test]
769    fn has_scope_admin_grants_everything() {
770        let auth = ApiKeyAuth {
771            key_id: Uuid::now_v7(),
772            user_id: Uuid::now_v7(),
773            key_name: "k".to_string(),
774            scopes: vec![ApiKeyScope::Admin],
775            owner_is_admin: true,
776        };
777        assert!(auth.has_scope(&ApiKeyScope::RunsRead));
778        assert!(auth.has_scope(&ApiKeyScope::RunsWrite));
779        assert!(auth.has_scope(&ApiKeyScope::StatsRead));
780    }
781
782    // ---------------------------------------------------------------
783    // Authenticated (dual auth)
784    // ---------------------------------------------------------------
785
786    #[tokio::test]
787    async fn authenticated_via_jwt() {
788        let state = test_state();
789        let user_id = stored_user(&state.store, "alice", true).await;
790        let token = AccessToken::for_user(user_id, "alice", true, &state.jwt_config).unwrap();
791
792        let app = Router::new()
793            .route(
794                "/auth",
795                get(|auth: Authenticated| async move {
796                    Json(json!({
797                        "user_id": auth.user_id,
798                        "is_admin": auth.is_admin(),
799                        "method": match auth.method {
800                            AuthMethod::Jwt { .. } => "jwt",
801                            AuthMethod::ApiKey { .. } => "api_key",
802                        }
803                    }))
804                }),
805            )
806            .with_state(state);
807
808        let req = Request::builder()
809            .uri("/auth")
810            .header("authorization", format!("Bearer {}", token.0))
811            .body(Body::empty())
812            .unwrap();
813
814        let resp = app.oneshot(req).await.unwrap();
815        assert_eq!(resp.status(), StatusCode::OK);
816
817        let json = response_json(resp).await;
818        assert_eq!(json["user_id"], user_id.to_string());
819        assert_eq!(json["is_admin"], true);
820        assert_eq!(json["method"], "jwt");
821    }
822
823    #[tokio::test]
824    async fn authenticated_via_api_key() {
825        let state = test_state();
826        let (user_id, raw_key) = setup_api_key(&state.store).await;
827
828        let app = Router::new()
829            .route(
830                "/auth",
831                get(|auth: Authenticated| async move {
832                    Json(json!({
833                        "user_id": auth.user_id,
834                        "is_admin": auth.is_admin(),
835                        "method": match auth.method {
836                            AuthMethod::Jwt { .. } => "jwt",
837                            AuthMethod::ApiKey { .. } => "api_key",
838                        }
839                    }))
840                }),
841            )
842            .with_state(state);
843
844        let req = Request::builder()
845            .uri("/auth")
846            .header("authorization", format!("Bearer {raw_key}"))
847            .body(Body::empty())
848            .unwrap();
849
850        let resp = app.oneshot(req).await.unwrap();
851        assert_eq!(resp.status(), StatusCode::OK);
852
853        let json = response_json(resp).await;
854        assert_eq!(json["user_id"], user_id.to_string());
855        assert_eq!(json["is_admin"], true);
856        assert_eq!(json["method"], "api_key");
857    }
858
859    #[tokio::test]
860    async fn authenticated_rejects_missing_token() {
861        let app = Router::new()
862            .route("/auth", get(|_auth: Authenticated| async { "ok" }))
863            .with_state(test_state());
864
865        let req = Request::builder().uri("/auth").body(Body::empty()).unwrap();
866
867        let resp = app.oneshot(req).await.unwrap();
868        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
869
870        let json = response_json(resp).await;
871        assert_eq!(json["error"]["code"], "MISSING_TOKEN");
872    }
873
874    // ---------------------------------------------------------------
875    // Authenticated::is_admin()
876    // ---------------------------------------------------------------
877
878    #[test]
879    fn is_admin_jwt_true() {
880        let auth = Authenticated {
881            user_id: Uuid::now_v7(),
882            method: AuthMethod::Jwt {
883                username: "admin".to_string(),
884                is_admin: true,
885            },
886        };
887        assert!(auth.is_admin());
888    }
889
890    #[test]
891    fn is_admin_jwt_false() {
892        let auth = Authenticated {
893            user_id: Uuid::now_v7(),
894            method: AuthMethod::Jwt {
895                username: "user".to_string(),
896                is_admin: false,
897            },
898        };
899        assert!(!auth.is_admin());
900    }
901
902    #[test]
903    fn is_admin_api_key_true() {
904        let auth = Authenticated {
905            user_id: Uuid::now_v7(),
906            method: AuthMethod::ApiKey {
907                key_id: Uuid::now_v7(),
908                key_name: "k".to_string(),
909                scopes: vec![],
910                owner_is_admin: true,
911            },
912        };
913        assert!(auth.is_admin());
914    }
915
916    #[test]
917    fn is_admin_api_key_false() {
918        let auth = Authenticated {
919            user_id: Uuid::now_v7(),
920            method: AuthMethod::ApiKey {
921                key_id: Uuid::now_v7(),
922                key_name: "k".to_string(),
923                scopes: vec![],
924                owner_is_admin: false,
925            },
926        };
927        assert!(!auth.is_admin());
928    }
929
930    // ---------------------------------------------------------------
931    // AuthRejection / ApiKeyRejection IntoResponse
932    // ---------------------------------------------------------------
933
934    #[tokio::test]
935    async fn auth_rejection_into_response() {
936        let rejection = AuthRejection {
937            status: StatusCode::UNAUTHORIZED,
938            code: "TEST_CODE",
939            message: "test message",
940        };
941        let resp = rejection.into_response();
942        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
943
944        let body = resp.into_body().collect().await.unwrap().to_bytes();
945        let json: Value = serde_json::from_slice(&body).unwrap();
946        assert_eq!(json["error"]["code"], "TEST_CODE");
947        assert_eq!(json["error"]["message"], "test message");
948    }
949
950    #[tokio::test]
951    async fn api_key_rejection_into_response() {
952        let rejection = ApiKeyRejection {
953            status: StatusCode::FORBIDDEN,
954            code: "KEY_DISABLED",
955            message: "API key is disabled",
956        };
957        let resp = rejection.into_response();
958        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
959
960        let body = resp.into_body().collect().await.unwrap().to_bytes();
961        let json: Value = serde_json::from_slice(&body).unwrap();
962        assert_eq!(json["error"]["code"], "KEY_DISABLED");
963        assert_eq!(json["error"]["message"], "API key is disabled");
964    }
965
966    // ---------------------------------------------------------------
967    // Server-side revocation
968    // ---------------------------------------------------------------
969
970    async fn stored_user(store: &Arc<dyn Store>, username: &str, is_admin: bool) -> Uuid {
971        store
972            .create_user(NewUser {
973                email: format!("{username}@test.com"),
974                username: username.to_string(),
975                password_hash: "argon2hash".to_string(),
976                is_admin: Some(is_admin),
977            })
978            .await
979            .unwrap()
980            .id
981    }
982
983    fn bearer(uri: &str, token: &str) -> Request<Body> {
984        Request::builder()
985            .uri(uri)
986            .header("authorization", format!("Bearer {token}"))
987            .body(Body::empty())
988            .unwrap()
989    }
990
991    #[tokio::test]
992    async fn jwt_extractor_accepts_token_at_current_version() {
993        let state = test_state();
994        let user_id = stored_user(&state.store, "alice", false).await;
995        let token = AccessToken::for_user(user_id, "alice", false, &state.jwt_config).unwrap();
996        let app = Router::new()
997            .route("/me", get(|_user: AuthenticatedUser| async { "ok" }))
998            .with_state(state);
999
1000        let resp = app.oneshot(bearer("/me", &token.0)).await.unwrap();
1001        assert_eq!(resp.status(), StatusCode::OK);
1002    }
1003
1004    #[tokio::test]
1005    async fn jwt_extractor_rejects_token_after_session_revoked() {
1006        let state = test_state();
1007        let user_id = stored_user(&state.store, "alice", false).await;
1008        let token = AccessToken::for_user(user_id, "alice", false, &state.jwt_config).unwrap();
1009        state.store.revoke_user_sessions(user_id).await.unwrap();
1010
1011        let app = Router::new()
1012            .route("/me", get(|_user: AuthenticatedUser| async { "ok" }))
1013            .with_state(state);
1014
1015        let resp = app.oneshot(bearer("/me", &token.0)).await.unwrap();
1016        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1017        let json = response_json(resp).await;
1018        assert_eq!(json["error"]["code"], "TOKEN_REVOKED");
1019    }
1020
1021    #[tokio::test]
1022    async fn jwt_extractor_accepts_token_issued_at_new_version() {
1023        let state = test_state();
1024        let user_id = stored_user(&state.store, "alice", false).await;
1025        let v = state.store.revoke_user_sessions(user_id).await.unwrap();
1026        let jwt = state.jwt_config.clone();
1027        let token = AccessToken::for_user_with_version(user_id, "alice", false, v, &jwt).unwrap();
1028
1029        let app = Router::new()
1030            .route("/me", get(|_user: AuthenticatedUser| async { "ok" }))
1031            .with_state(state);
1032
1033        let resp = app.oneshot(bearer("/me", &token.0)).await.unwrap();
1034        assert_eq!(resp.status(), StatusCode::OK);
1035    }
1036
1037    #[tokio::test]
1038    async fn dual_auth_rejects_jwt_after_session_revoked() {
1039        let state = test_state();
1040        let user_id = stored_user(&state.store, "alice", false).await;
1041        let token = AccessToken::for_user(user_id, "alice", false, &state.jwt_config).unwrap();
1042        state.store.revoke_user_sessions(user_id).await.unwrap();
1043
1044        let app = Router::new()
1045            .route("/auth", get(|_auth: Authenticated| async { "ok" }))
1046            .with_state(state);
1047
1048        let resp = app.oneshot(bearer("/auth", &token.0)).await.unwrap();
1049        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1050        let json = response_json(resp).await;
1051        assert_eq!(json["error"]["code"], "TOKEN_REVOKED");
1052    }
1053
1054    #[tokio::test]
1055    async fn jwt_extractor_rejects_token_of_deleted_user() {
1056        let state = test_state();
1057        let user_id = stored_user(&state.store, "alice", false).await;
1058        let token = AccessToken::for_user(user_id, "alice", false, &state.jwt_config).unwrap();
1059        state.store.delete_user(user_id).await.unwrap();
1060
1061        let app = Router::new()
1062            .route("/me", get(|_user: AuthenticatedUser| async { "ok" }))
1063            .with_state(state);
1064
1065        let resp = app.oneshot(bearer("/me", &token.0)).await.unwrap();
1066        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1067        let json = response_json(resp).await;
1068        assert_eq!(json["error"]["code"], "TOKEN_REVOKED");
1069    }
1070
1071    #[tokio::test]
1072    async fn jwt_extractor_rejects_token_of_unknown_user() {
1073        let state = test_state();
1074        let token =
1075            AccessToken::for_user(Uuid::now_v7(), "ghost", true, &state.jwt_config).unwrap();
1076
1077        let app = Router::new()
1078            .route("/me", get(|_user: AuthenticatedUser| async { "ok" }))
1079            .with_state(state);
1080
1081        let resp = app.oneshot(bearer("/me", &token.0)).await.unwrap();
1082        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1083        let json = response_json(resp).await;
1084        assert_eq!(json["error"]["code"], "TOKEN_REVOKED");
1085    }
1086
1087    #[tokio::test]
1088    async fn dual_auth_rejects_jwt_of_deleted_user() {
1089        let state = test_state();
1090        let user_id = stored_user(&state.store, "alice", false).await;
1091        let token = AccessToken::for_user(user_id, "alice", false, &state.jwt_config).unwrap();
1092        state.store.delete_user(user_id).await.unwrap();
1093
1094        let app = Router::new()
1095            .route("/auth", get(|_auth: Authenticated| async { "ok" }))
1096            .with_state(state);
1097
1098        let resp = app.oneshot(bearer("/auth", &token.0)).await.unwrap();
1099        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1100        let json = response_json(resp).await;
1101        assert_eq!(json["error"]["code"], "TOKEN_REVOKED");
1102    }
1103
1104    #[tokio::test]
1105    async fn demoted_admin_token_is_rejected() {
1106        let state = test_state();
1107        let admin_id = stored_user(&state.store, "root", true).await;
1108        let token = AccessToken::for_user(admin_id, "root", true, &state.jwt_config).unwrap();
1109        state.store.update_user_role(admin_id, false).await.unwrap();
1110
1111        let app = Router::new()
1112            .route("/admin", get(|_auth: Authenticated| async { "ok" }))
1113            .with_state(state);
1114
1115        let resp = app.oneshot(bearer("/admin", &token.0)).await.unwrap();
1116        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1117        let json = response_json(resp).await;
1118        assert_eq!(json["error"]["code"], "TOKEN_REVOKED");
1119    }
1120
1121    #[tokio::test]
1122    async fn jwt_is_admin_comes_from_store() {
1123        let state = test_state();
1124        let user_id = stored_user(&state.store, "member", false).await;
1125        // The claim says admin, the store says member: the store wins.
1126        let token = AccessToken::for_user(user_id, "forged", true, &state.jwt_config).unwrap();
1127
1128        let app = Router::new()
1129            .route(
1130                "/auth",
1131                get(|auth: Authenticated| async move {
1132                    let username = match &auth.method {
1133                        AuthMethod::Jwt { username, .. } => username.clone(),
1134                        AuthMethod::ApiKey { .. } => String::new(),
1135                    };
1136                    Json(json!({ "is_admin": auth.is_admin(), "username": username }))
1137                }),
1138            )
1139            .route(
1140                "/me",
1141                get(|user: AuthenticatedUser| async move {
1142                    Json(json!({ "is_admin": user.is_admin, "username": user.username }))
1143                }),
1144            )
1145            .with_state(state);
1146
1147        let resp = app
1148            .clone()
1149            .oneshot(bearer("/auth", &token.0))
1150            .await
1151            .unwrap();
1152        assert_eq!(resp.status(), StatusCode::OK);
1153        let json = response_json(resp).await;
1154        assert_eq!(json["is_admin"], false);
1155        assert_eq!(json["username"], "member");
1156
1157        let resp = app.oneshot(bearer("/me", &token.0)).await.unwrap();
1158        assert_eq!(resp.status(), StatusCode::OK);
1159        let json = response_json(resp).await;
1160        assert_eq!(json["is_admin"], false);
1161        assert_eq!(json["username"], "member");
1162    }
1163}