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::api_key_store::ApiKeyStore;
19use ironflow_store::entities::ApiKeyScope;
20use ironflow_store::user_store::UserStore;
21use serde_json::json;
22use uuid::Uuid;
23
24use crate::cookies::AUTH_COOKIE_NAME;
25use crate::jwt::{AccessToken, 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>` to be extractable from state via `FromRef`.
36///
37/// # Examples
38///
39/// ```no_run
40/// use ironflow_auth::extractor::AuthenticatedUser;
41///
42/// async fn protected(user: AuthenticatedUser) -> String {
43///     format!("Hello, {}!", user.username)
44/// }
45/// ```
46#[derive(Debug, Clone)]
47pub struct AuthenticatedUser {
48    /// The user's unique identifier.
49    pub user_id: Uuid,
50    /// The user's username.
51    pub username: String,
52    /// Whether the user is an administrator.
53    pub is_admin: bool,
54}
55
56impl<S> FromRequestParts<S> for AuthenticatedUser
57where
58    S: Send + Sync,
59    Arc<JwtConfig>: FromRef<S>,
60{
61    type Rejection = AuthRejection;
62
63    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
64        let jwt_config = Arc::<JwtConfig>::from_ref(state);
65
66        let jar = CookieJar::from_headers(&parts.headers);
67        let token = jar
68            .get(AUTH_COOKIE_NAME)
69            .map(|c| c.value().to_string())
70            .or_else(|| {
71                parts
72                    .headers
73                    .get("authorization")
74                    .and_then(|v| v.to_str().ok())
75                    .and_then(|v| v.strip_prefix("Bearer "))
76                    .map(|t| t.to_string())
77            });
78
79        let token = token.ok_or(AuthRejection {
80            status: StatusCode::UNAUTHORIZED,
81            code: "MISSING_TOKEN",
82            message: "No authentication token provided",
83        })?;
84
85        let claims = AccessToken::decode(&token, &jwt_config).map_err(|_| AuthRejection {
86            status: StatusCode::UNAUTHORIZED,
87            code: "INVALID_TOKEN",
88            message: "Invalid or expired authentication token",
89        })?;
90
91        Ok(AuthenticatedUser {
92            user_id: claims.user_id,
93            username: claims.username,
94            is_admin: claims.is_admin,
95        })
96    }
97}
98
99/// Rejection type when JWT authentication fails.
100pub struct AuthRejection {
101    status: StatusCode,
102    code: &'static str,
103    message: &'static str,
104}
105
106impl IntoResponse for AuthRejection {
107    fn into_response(self) -> Response {
108        let body = json!({
109            "error": {
110                "code": self.code,
111                "message": self.message,
112            }
113        });
114        (self.status, Json(body)).into_response()
115    }
116}
117
118// ---------------------------------------------------------------------------
119// ApiKeyAuth (API key only)
120// ---------------------------------------------------------------------------
121
122/// API key prefix used to distinguish API keys from JWT tokens.
123pub const API_KEY_PREFIX: &str = "irfl_";
124
125/// An authenticated caller via API key.
126///
127/// Use as an Axum handler parameter to enforce API key authentication.
128///
129/// # Examples
130///
131/// ```no_run
132/// use ironflow_auth::extractor::ApiKeyAuth;
133///
134/// async fn protected(key: ApiKeyAuth) -> String {
135///     format!("Key {} (user {})", key.key_name, key.user_id)
136/// }
137/// ```
138#[derive(Debug, Clone)]
139pub struct ApiKeyAuth {
140    /// The API key ID.
141    pub key_id: Uuid,
142    /// The owner user ID.
143    pub user_id: Uuid,
144    /// The API key name.
145    pub key_name: String,
146    /// Scopes granted to this key.
147    pub scopes: Vec<ApiKeyScope>,
148    /// Whether the key owner is an admin (checked at request time).
149    pub owner_is_admin: bool,
150}
151
152impl ApiKeyAuth {
153    /// Check if the API key has a specific scope.
154    pub fn has_scope(&self, required: &ApiKeyScope) -> bool {
155        ApiKeyScope::has_permission(&self.scopes, required)
156    }
157}
158
159/// Rejection type when API key authentication fails.
160pub struct ApiKeyRejection {
161    status: StatusCode,
162    code: &'static str,
163    message: &'static str,
164}
165
166impl IntoResponse for ApiKeyRejection {
167    fn into_response(self) -> Response {
168        let body = json!({
169            "error": {
170                "code": self.code,
171                "message": self.message,
172            }
173        });
174        (self.status, Json(body)).into_response()
175    }
176}
177
178impl<S> FromRequestParts<S> for ApiKeyAuth
179where
180    S: Send + Sync,
181    Arc<dyn ApiKeyStore>: FromRef<S>,
182    Arc<dyn UserStore>: FromRef<S>,
183{
184    type Rejection = ApiKeyRejection;
185
186    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
187        let api_key_store = Arc::<dyn ApiKeyStore>::from_ref(state);
188        let user_store = Arc::<dyn UserStore>::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(8);
210        let prefix = &token[..API_KEY_PREFIX.len() + suffix_len];
211
212        let api_key = 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 _ = api_key_store.touch_api_key(api_key.id).await;
259
260        let owner = user_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 ApiKeyStore>: FromRef<S>,
348    Arc<dyn UserStore>: FromRef<S>,
349{
350    type Rejection = AuthRejection;
351
352    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
353        let jar = CookieJar::from_headers(&parts.headers);
354        let cookie_token = jar.get(AUTH_COOKIE_NAME).map(|c| c.value().to_string());
355
356        let header_token = parts
357            .headers
358            .get("authorization")
359            .and_then(|v| v.to_str().ok())
360            .and_then(|v| v.strip_prefix("Bearer "))
361            .map(|t| t.to_string());
362
363        // If the Bearer token is an API key, use API key auth
364        if let Some(ref token) = header_token
365            && token.starts_with(API_KEY_PREFIX)
366        {
367            let api_key_auth =
368                ApiKeyAuth::from_request_parts(parts, state)
369                    .await
370                    .map_err(|_| AuthRejection {
371                        status: StatusCode::UNAUTHORIZED,
372                        code: "INVALID_TOKEN",
373                        message: "Invalid or expired authentication token",
374                    })?;
375            return Ok(Authenticated {
376                user_id: api_key_auth.user_id,
377                method: AuthMethod::ApiKey {
378                    key_id: api_key_auth.key_id,
379                    key_name: api_key_auth.key_name,
380                    scopes: api_key_auth.scopes,
381                    owner_is_admin: api_key_auth.owner_is_admin,
382                },
383            });
384        }
385
386        // Otherwise, try JWT (cookie first, then header)
387        let token = cookie_token.or(header_token).ok_or(AuthRejection {
388            status: StatusCode::UNAUTHORIZED,
389            code: "MISSING_TOKEN",
390            message: "No authentication token provided",
391        })?;
392
393        let jwt_config = Arc::<JwtConfig>::from_ref(state);
394        let claims = AccessToken::decode(&token, &jwt_config).map_err(|_| AuthRejection {
395            status: StatusCode::UNAUTHORIZED,
396            code: "INVALID_TOKEN",
397            message: "Invalid or expired authentication token",
398        })?;
399
400        Ok(Authenticated {
401            user_id: claims.user_id,
402            method: AuthMethod::Jwt {
403                username: claims.username,
404                is_admin: claims.is_admin,
405            },
406        })
407    }
408}