Skip to main content

featherbit/admin/
sessions.rs

1//! Admin surface for server-side sessions: list and revoke. Meta only —
2//! session payloads are sealed and never leave the store through this API.
3
4use 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        // GET /api/sessions?store=s1 -> 200, 2 sessions, meta fields present,
287        // no payload-like field.
288        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        // filter subject=alice -> 1
309        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        // DELETE /api/sessions/s1/{alice's id} -> 200 deleted
322        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        // DELETE /api/sessions?store=s1&subject=bob -> 200 {"revoked": 1}
333        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        // GET -> 0 sessions
344        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        // GET /api/sessions (no store) -> 400
362        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        // GET /api/sessions?store=unknown -> 404
370        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        // DELETE /api/sessions/s1/not-a-valid-id -> 400
380        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        // DELETE /api/sessions?store=s1 (no subject) -> 400
390        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}