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 serde_json::json;
21use uuid::Uuid;
22
23use crate::cookies::AUTH_COOKIE_NAME;
24use crate::jwt::{AccessToken, JwtConfig};
25use crate::password;
26
27// ---------------------------------------------------------------------------
28// AuthenticatedUser (JWT only)
29// ---------------------------------------------------------------------------
30
31/// An authenticated user extracted from a JWT.
32///
33/// Use as an Axum handler parameter to enforce JWT authentication.
34/// Requires `Arc<JwtConfig>` to be extractable from state via `FromRef`.
35///
36/// # Examples
37///
38/// ```no_run
39/// use ironflow_auth::extractor::AuthenticatedUser;
40///
41/// async fn protected(user: AuthenticatedUser) -> String {
42///     format!("Hello, {}!", user.username)
43/// }
44/// ```
45#[derive(Debug, Clone)]
46pub struct AuthenticatedUser {
47    /// The user's unique identifier.
48    pub user_id: Uuid,
49    /// The user's username.
50    pub username: String,
51    /// Whether the user is an administrator.
52    pub is_admin: bool,
53}
54
55impl<S> FromRequestParts<S> for AuthenticatedUser
56where
57    S: Send + Sync,
58    Arc<JwtConfig>: FromRef<S>,
59{
60    type Rejection = AuthRejection;
61
62    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
63        let jwt_config = Arc::<JwtConfig>::from_ref(state);
64
65        let jar = CookieJar::from_headers(&parts.headers);
66        let token = jar
67            .get(AUTH_COOKIE_NAME)
68            .map(|c| c.value().to_string())
69            .or_else(|| {
70                parts
71                    .headers
72                    .get("authorization")
73                    .and_then(|v| v.to_str().ok())
74                    .and_then(|v| v.strip_prefix("Bearer "))
75                    .map(|t| t.to_string())
76            });
77
78        let token = token.ok_or(AuthRejection {
79            status: StatusCode::UNAUTHORIZED,
80            code: "MISSING_TOKEN",
81            message: "No authentication token provided",
82        })?;
83
84        let claims = AccessToken::decode(&token, &jwt_config).map_err(|_| AuthRejection {
85            status: StatusCode::UNAUTHORIZED,
86            code: "INVALID_TOKEN",
87            message: "Invalid or expired authentication token",
88        })?;
89
90        Ok(AuthenticatedUser {
91            user_id: claims.user_id,
92            username: claims.username,
93            is_admin: claims.is_admin,
94        })
95    }
96}
97
98/// Rejection type when JWT authentication fails.
99pub struct AuthRejection {
100    status: StatusCode,
101    code: &'static str,
102    message: &'static str,
103}
104
105impl IntoResponse for AuthRejection {
106    fn into_response(self) -> Response {
107        let body = json!({
108            "error": {
109                "code": self.code,
110                "message": self.message,
111            }
112        });
113        (self.status, Json(body)).into_response()
114    }
115}
116
117// ---------------------------------------------------------------------------
118// ApiKeyAuth (API key only)
119// ---------------------------------------------------------------------------
120
121/// API key prefix used to distinguish API keys from JWT tokens.
122pub const API_KEY_PREFIX: &str = "irfl_";
123
124/// An authenticated caller via API key.
125///
126/// Use as an Axum handler parameter to enforce API key authentication.
127///
128/// # Examples
129///
130/// ```no_run
131/// use ironflow_auth::extractor::ApiKeyAuth;
132///
133/// async fn protected(key: ApiKeyAuth) -> String {
134///     format!("Key {} (user {})", key.key_name, key.user_id)
135/// }
136/// ```
137#[derive(Debug, Clone)]
138pub struct ApiKeyAuth {
139    /// The API key ID.
140    pub key_id: Uuid,
141    /// The owner user ID.
142    pub user_id: Uuid,
143    /// The API key name.
144    pub key_name: String,
145    /// Scopes granted to this key.
146    pub scopes: Vec<ApiKeyScope>,
147}
148
149impl ApiKeyAuth {
150    /// Check if the API key has a specific scope.
151    pub fn has_scope(&self, required: &ApiKeyScope) -> bool {
152        ApiKeyScope::has_permission(&self.scopes, required)
153    }
154}
155
156/// Rejection type when API key authentication fails.
157pub struct ApiKeyRejection {
158    status: StatusCode,
159    code: &'static str,
160    message: &'static str,
161}
162
163impl IntoResponse for ApiKeyRejection {
164    fn into_response(self) -> Response {
165        let body = json!({
166            "error": {
167                "code": self.code,
168                "message": self.message,
169            }
170        });
171        (self.status, Json(body)).into_response()
172    }
173}
174
175impl<S> FromRequestParts<S> for ApiKeyAuth
176where
177    S: Send + Sync,
178    Arc<dyn ApiKeyStore>: FromRef<S>,
179{
180    type Rejection = ApiKeyRejection;
181
182    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
183        let api_key_store = Arc::<dyn ApiKeyStore>::from_ref(state);
184
185        let token = parts
186            .headers
187            .get("authorization")
188            .and_then(|v| v.to_str().ok())
189            .and_then(|v| v.strip_prefix("Bearer "))
190            .ok_or(ApiKeyRejection {
191                status: StatusCode::UNAUTHORIZED,
192                code: "MISSING_TOKEN",
193                message: "No authentication token provided",
194            })?;
195
196        if !token.starts_with(API_KEY_PREFIX) {
197            return Err(ApiKeyRejection {
198                status: StatusCode::UNAUTHORIZED,
199                code: "INVALID_TOKEN",
200                message: "Expected API key (irfl_...) in Authorization header",
201            });
202        }
203
204        let suffix_len = (token.len() - API_KEY_PREFIX.len()).min(8);
205        let prefix = &token[..API_KEY_PREFIX.len() + suffix_len];
206
207        let api_key = api_key_store
208            .find_api_key_by_prefix(prefix)
209            .await
210            .map_err(|_| ApiKeyRejection {
211                status: StatusCode::INTERNAL_SERVER_ERROR,
212                code: "INTERNAL_ERROR",
213                message: "Failed to look up API key",
214            })?
215            .ok_or(ApiKeyRejection {
216                status: StatusCode::UNAUTHORIZED,
217                code: "INVALID_TOKEN",
218                message: "Invalid API key",
219            })?;
220
221        if !api_key.is_active {
222            return Err(ApiKeyRejection {
223                status: StatusCode::UNAUTHORIZED,
224                code: "KEY_DISABLED",
225                message: "API key is disabled",
226            });
227        }
228
229        if let Some(expires_at) = api_key.expires_at
230            && expires_at < Utc::now()
231        {
232            return Err(ApiKeyRejection {
233                status: StatusCode::UNAUTHORIZED,
234                code: "KEY_EXPIRED",
235                message: "API key has expired",
236            });
237        }
238
239        let valid = password::verify(token, &api_key.key_hash).map_err(|_| ApiKeyRejection {
240            status: StatusCode::INTERNAL_SERVER_ERROR,
241            code: "INTERNAL_ERROR",
242            message: "Failed to verify API key",
243        })?;
244
245        if !valid {
246            return Err(ApiKeyRejection {
247                status: StatusCode::UNAUTHORIZED,
248                code: "INVALID_TOKEN",
249                message: "Invalid API key",
250            });
251        }
252
253        let _ = api_key_store.touch_api_key(api_key.id).await;
254
255        Ok(ApiKeyAuth {
256            key_id: api_key.id,
257            user_id: api_key.user_id,
258            key_name: api_key.name,
259            scopes: api_key.scopes,
260        })
261    }
262}
263
264// ---------------------------------------------------------------------------
265// Authenticated (dual: API key OR JWT)
266// ---------------------------------------------------------------------------
267
268/// An authenticated caller, either via API key or JWT.
269///
270/// If the `Authorization: Bearer` token starts with `irfl_`, API key auth is used.
271/// Otherwise, JWT auth is attempted (cookie first, then header).
272///
273/// # Examples
274///
275/// ```no_run
276/// use ironflow_auth::extractor::Authenticated;
277///
278/// async fn protected(auth: Authenticated) -> String {
279///     format!("User {}", auth.user_id)
280/// }
281/// ```
282#[derive(Debug, Clone)]
283pub struct Authenticated {
284    /// The authenticated user's ID.
285    pub user_id: Uuid,
286    /// The authentication method used.
287    pub method: AuthMethod,
288}
289
290/// How the caller was authenticated.
291#[derive(Debug, Clone)]
292pub enum AuthMethod {
293    /// Authenticated via JWT (cookie or Bearer header).
294    Jwt {
295        /// The user's username.
296        username: String,
297        /// Whether the user is an admin.
298        is_admin: bool,
299    },
300    /// Authenticated via API key.
301    ApiKey {
302        /// The API key ID.
303        key_id: Uuid,
304        /// The API key name.
305        key_name: String,
306        /// Scopes granted to this key.
307        scopes: Vec<ApiKeyScope>,
308    },
309}
310
311impl<S> FromRequestParts<S> for Authenticated
312where
313    S: Send + Sync,
314    Arc<JwtConfig>: FromRef<S>,
315    Arc<dyn ApiKeyStore>: FromRef<S>,
316{
317    type Rejection = AuthRejection;
318
319    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
320        let jar = CookieJar::from_headers(&parts.headers);
321        let cookie_token = jar.get(AUTH_COOKIE_NAME).map(|c| c.value().to_string());
322
323        let header_token = parts
324            .headers
325            .get("authorization")
326            .and_then(|v| v.to_str().ok())
327            .and_then(|v| v.strip_prefix("Bearer "))
328            .map(|t| t.to_string());
329
330        // If the Bearer token is an API key, use API key auth
331        if let Some(ref token) = header_token
332            && token.starts_with(API_KEY_PREFIX)
333        {
334            let api_key_auth =
335                ApiKeyAuth::from_request_parts(parts, state)
336                    .await
337                    .map_err(|_| AuthRejection {
338                        status: StatusCode::UNAUTHORIZED,
339                        code: "INVALID_TOKEN",
340                        message: "Invalid or expired authentication token",
341                    })?;
342            return Ok(Authenticated {
343                user_id: api_key_auth.user_id,
344                method: AuthMethod::ApiKey {
345                    key_id: api_key_auth.key_id,
346                    key_name: api_key_auth.key_name,
347                    scopes: api_key_auth.scopes,
348                },
349            });
350        }
351
352        // Otherwise, try JWT (cookie first, then header)
353        let token = cookie_token.or(header_token).ok_or(AuthRejection {
354            status: StatusCode::UNAUTHORIZED,
355            code: "MISSING_TOKEN",
356            message: "No authentication token provided",
357        })?;
358
359        let jwt_config = Arc::<JwtConfig>::from_ref(state);
360        let claims = AccessToken::decode(&token, &jwt_config).map_err(|_| AuthRejection {
361            status: StatusCode::UNAUTHORIZED,
362            code: "INVALID_TOKEN",
363            message: "Invalid or expired authentication token",
364        })?;
365
366        Ok(Authenticated {
367            user_id: claims.user_id,
368            method: AuthMethod::Jwt {
369                username: claims.username,
370                is_admin: claims.is_admin,
371            },
372        })
373    }
374}