1use std::collections::HashMap;
5use std::sync::Arc;
6
7use axum::extract::{Path, Query, State};
8use axum::http::StatusCode;
9use axum::response::IntoResponse;
10use axum::routing::get;
11use axum::{Json, Router};
12
13use crate::state::SharedState;
14
15pub fn router() -> Router<Arc<SharedState>> {
16 Router::new()
17 .route(
18 "/api/sessions",
19 get(list_sessions).delete(delete_by_subject),
20 )
21 .route(
22 "/api/sessions/{store}/{id}",
23 axum::routing::delete(delete_session),
24 )
25}
26
27#[cfg(feature = "redis-store")]
28async fn list_sessions(
29 State(state): State<Arc<SharedState>>,
30 Query(params): Query<HashMap<String, String>>,
31) -> impl IntoResponse {
32 let Some(name) = params.get("store") else {
33 return (
34 StatusCode::BAD_REQUEST,
35 Json(serde_json::json!({"error": "store is required"})),
36 )
37 .into_response();
38 };
39 let store = match state.resources.stores.load().session_store(name) {
40 Ok(s) => s,
41 Err(_) => {
42 return (
43 StatusCode::NOT_FOUND,
44 Json(serde_json::json!({"error": "not_found"})),
45 )
46 .into_response()
47 }
48 };
49 let filter = crate::sessions::SessionFilter {
50 subject: params.get("subject").cloned(),
51 plugin: params.get("plugin").cloned(),
52 limit: params
53 .get("limit")
54 .and_then(|v| v.parse::<usize>().ok())
55 .unwrap_or(50),
56 cursor: params.get("cursor").cloned(),
57 };
58 match store.list(&filter).await {
59 Ok(page) => Json(serde_json::json!({
60 "sessions": page.sessions,
61 "next_cursor": page.next_cursor,
62 }))
63 .into_response(),
64 Err(e) => (
65 StatusCode::BAD_GATEWAY,
66 Json(serde_json::json!({"error": e.to_string()})),
67 )
68 .into_response(),
69 }
70}
71
72#[cfg(not(feature = "redis-store"))]
73async fn list_sessions(
74 State(_state): State<Arc<SharedState>>,
75 Query(_params): Query<HashMap<String, String>>,
76) -> impl IntoResponse {
77 (
78 StatusCode::NOT_IMPLEMENTED,
79 Json(serde_json::json!({
80 "error": "this binary was built without the redis-store feature"
81 })),
82 )
83 .into_response()
84}
85
86#[cfg(feature = "redis-store")]
87async fn delete_session(
88 State(state): State<Arc<SharedState>>,
89 Path((store_name, id)): Path<(String, String)>,
90) -> impl IntoResponse {
91 let Some(session_id) = crate::sessions::SessionId::parse(&id) else {
92 return (
93 StatusCode::BAD_REQUEST,
94 Json(serde_json::json!({"error": "invalid session id"})),
95 )
96 .into_response();
97 };
98 let store = match state.resources.stores.load().session_store(&store_name) {
99 Ok(s) => s,
100 Err(_) => {
101 return (
102 StatusCode::NOT_FOUND,
103 Json(serde_json::json!({"error": "not_found"})),
104 )
105 .into_response()
106 }
107 };
108 match store.delete(&session_id).await {
109 Ok(()) => Json(serde_json::json!({"status": "deleted"})).into_response(),
110 Err(e) => (
111 StatusCode::BAD_GATEWAY,
112 Json(serde_json::json!({"error": e.to_string()})),
113 )
114 .into_response(),
115 }
116}
117
118#[cfg(not(feature = "redis-store"))]
119async fn delete_session(
120 State(_state): State<Arc<SharedState>>,
121 Path((_store_name, _id)): Path<(String, String)>,
122) -> impl IntoResponse {
123 (
124 StatusCode::NOT_IMPLEMENTED,
125 Json(serde_json::json!({
126 "error": "this binary was built without the redis-store feature"
127 })),
128 )
129 .into_response()
130}
131
132#[cfg(feature = "redis-store")]
133async fn delete_by_subject(
134 State(state): State<Arc<SharedState>>,
135 Query(params): Query<HashMap<String, String>>,
136) -> impl IntoResponse {
137 let Some(name) = params.get("store") else {
138 return (
139 StatusCode::BAD_REQUEST,
140 Json(serde_json::json!({"error": "store is required"})),
141 )
142 .into_response();
143 };
144 let Some(subject) = params.get("subject") else {
145 return (
146 StatusCode::BAD_REQUEST,
147 Json(serde_json::json!({"error": "subject is required"})),
148 )
149 .into_response();
150 };
151 let store = match state.resources.stores.load().session_store(name) {
152 Ok(s) => s,
153 Err(_) => {
154 return (
155 StatusCode::NOT_FOUND,
156 Json(serde_json::json!({"error": "not_found"})),
157 )
158 .into_response()
159 }
160 };
161 match store.delete_subject(subject).await {
162 Ok(n) => Json(serde_json::json!({"revoked": n})).into_response(),
163 Err(e) => (
164 StatusCode::BAD_GATEWAY,
165 Json(serde_json::json!({"error": e.to_string()})),
166 )
167 .into_response(),
168 }
169}
170
171#[cfg(not(feature = "redis-store"))]
172async fn delete_by_subject(
173 State(_state): State<Arc<SharedState>>,
174 Query(_params): Query<HashMap<String, String>>,
175) -> impl IntoResponse {
176 (
177 StatusCode::NOT_IMPLEMENTED,
178 Json(serde_json::json!({
179 "error": "this binary was built without the redis-store feature"
180 })),
181 )
182 .into_response()
183}
184
185#[cfg(all(test, feature = "redis-store"))]
186mod tests {
187 use super::*;
188 use crate::config::{GatewayConfig, SystemConfig};
189 use crate::config_store::FileConfigStore;
190 use axum::body::Body;
191 use axum::http::{Request, StatusCode};
192 use tower::ServiceExt;
193
194 fn test_state(gateway_yaml: &str) -> Arc<SharedState> {
195 let system: SystemConfig = serde_yaml::from_str("{}").unwrap();
196 let gateway: GatewayConfig = serde_yaml::from_str(gateway_yaml).unwrap();
197 Arc::new(
198 SharedState::new(
199 system,
200 gateway,
201 None,
202 Arc::new(FileConfigStore::new(std::path::PathBuf::from(
203 "gateway.yaml",
204 ))),
205 )
206 .unwrap(),
207 )
208 }
209
210 fn app(state: Arc<SharedState>) -> Router {
211 router().with_state(state)
212 }
213
214 async fn send(state: &Arc<SharedState>, req: Request<Body>) -> (StatusCode, serde_json::Value) {
215 let resp = app(state.clone()).oneshot(req).await.unwrap();
216 let status = resp.status();
217 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
218 .await
219 .unwrap();
220 let v = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
221 (status, v)
222 }
223
224 const GATEWAY_WITH_S1: &str = r#"
225stores:
226 - name: s1
227 type: redis
228 url: redis://127.0.0.1:6379
229"#;
230
231 #[cfg(feature = "redis-store")]
232 fn state_with_fake_store(fake: Arc<crate::sessions::FakeSessionStore>) -> Arc<SharedState> {
233 let state = test_state(GATEWAY_WITH_S1);
234 state.resources.stores.store(Arc::new(
235 crate::stores::StoreRegistry::with_fake_session_store("s1", fake.clone()),
236 ));
237 state
238 }
239
240 #[cfg(feature = "redis-store")]
241 #[tokio::test]
242 async fn test_sessions_list_revoke_roundtrip() {
243 use crate::sessions::SessionStore as _;
244
245 let fake = Arc::new(crate::sessions::FakeSessionStore::default());
246 let state = state_with_fake_store(fake.clone());
247
248 let alice_id = crate::sessions::SessionId::random();
249 let alice_meta = crate::sessions::SessionMeta {
250 id: String::new(),
251 subject: "alice".to_string(),
252 plugin: "openid-connect".to_string(),
253 policy: "p1".to_string(),
254 route: "r1".to_string(),
255 created_at: 1,
256 expires_at: 2,
257 };
258 fake.put(
259 &alice_id,
260 b"sealed-alice",
261 std::time::Duration::from_secs(60),
262 &alice_meta,
263 )
264 .await
265 .unwrap();
266
267 let bob_id = crate::sessions::SessionId::random();
268 let bob_meta = crate::sessions::SessionMeta {
269 id: String::new(),
270 subject: "bob".to_string(),
271 plugin: "openid-connect".to_string(),
272 policy: "p1".to_string(),
273 route: "r1".to_string(),
274 created_at: 1,
275 expires_at: 2,
276 };
277 fake.put(
278 &bob_id,
279 b"sealed-bob",
280 std::time::Duration::from_secs(60),
281 &bob_meta,
282 )
283 .await
284 .unwrap();
285
286 let (status, body) = send(
289 &state,
290 Request::get("/api/sessions?store=s1")
291 .body(Body::empty())
292 .unwrap(),
293 )
294 .await;
295 assert_eq!(status, StatusCode::OK);
296 let sessions = body["sessions"].as_array().unwrap();
297 assert_eq!(sessions.len(), 2);
298 for s in sessions {
299 assert!(s.get("id").is_some());
300 assert!(s.get("subject").is_some());
301 assert!(s.get("plugin").is_some());
302 assert!(s.get("created_at").is_some());
303 assert!(s.get("expires_at").is_some());
304 assert!(s.get("sealed").is_none());
305 assert!(s.get("payload").is_none());
306 }
307
308 let (status, body) = send(
310 &state,
311 Request::get("/api/sessions?store=s1&subject=alice")
312 .body(Body::empty())
313 .unwrap(),
314 )
315 .await;
316 assert_eq!(status, StatusCode::OK);
317 let sessions = body["sessions"].as_array().unwrap();
318 assert_eq!(sessions.len(), 1);
319 assert_eq!(sessions[0]["subject"], "alice");
320
321 let (status, body) = send(
323 &state,
324 Request::delete(format!("/api/sessions/s1/{}", alice_id.as_str()))
325 .body(Body::empty())
326 .unwrap(),
327 )
328 .await;
329 assert_eq!(status, StatusCode::OK);
330 assert_eq!(body["status"], "deleted");
331
332 let (status, body) = send(
334 &state,
335 Request::delete("/api/sessions?store=s1&subject=bob")
336 .body(Body::empty())
337 .unwrap(),
338 )
339 .await;
340 assert_eq!(status, StatusCode::OK);
341 assert_eq!(body["revoked"], 1);
342
343 let (status, body) = send(
345 &state,
346 Request::get("/api/sessions?store=s1")
347 .body(Body::empty())
348 .unwrap(),
349 )
350 .await;
351 assert_eq!(status, StatusCode::OK);
352 assert_eq!(body["sessions"].as_array().unwrap().len(), 0);
353 }
354
355 #[cfg(feature = "redis-store")]
356 #[tokio::test]
357 async fn test_sessions_param_validation() {
358 let fake = Arc::new(crate::sessions::FakeSessionStore::default());
359 let state = state_with_fake_store(fake);
360
361 let (status, _) = send(
363 &state,
364 Request::get("/api/sessions").body(Body::empty()).unwrap(),
365 )
366 .await;
367 assert_eq!(status, StatusCode::BAD_REQUEST);
368
369 let (status, _) = send(
371 &state,
372 Request::get("/api/sessions?store=unknown")
373 .body(Body::empty())
374 .unwrap(),
375 )
376 .await;
377 assert_eq!(status, StatusCode::NOT_FOUND);
378
379 let (status, _) = send(
381 &state,
382 Request::delete("/api/sessions/s1/not-a-valid-id")
383 .body(Body::empty())
384 .unwrap(),
385 )
386 .await;
387 assert_eq!(status, StatusCode::BAD_REQUEST);
388
389 let (status, _) = send(
391 &state,
392 Request::delete("/api/sessions?store=s1")
393 .body(Body::empty())
394 .unwrap(),
395 )
396 .await;
397 assert_eq!(status, StatusCode::BAD_REQUEST);
398 }
399}