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