Skip to content

Commit d1dd9a4

Browse files
authored
Track own sessions (#15)
* Track own sessions * Review fixes * Review fixes * Review fixes * Logout current session
1 parent 1b89a25 commit d1dd9a4

8 files changed

Lines changed: 456 additions & 9 deletions

File tree

src/openapi.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,9 @@ requests_per_minute = 120
401401
admin::me_api_keys::create,
402402
admin::me_api_keys::revoke,
403403
admin::me_api_keys::rotate,
404+
// Self-service endpoints - Sessions
405+
admin::me_sessions::list,
406+
admin::me_sessions::delete_one,
404407
// Admin routes - Organizations
405408
admin::organizations::create,
406409
admin::organizations::get,

src/routes/admin/me_sessions.rs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
//! Self-service session management endpoints.
2+
//!
3+
//! Allows users to view and revoke their own sessions at `/admin/v1/me/sessions`.
4+
5+
use axum::{
6+
Extension, Json,
7+
extract::{Path, State},
8+
};
9+
use serde_json::json;
10+
use tower_cookies::Cookies;
11+
use uuid::Uuid;
12+
13+
use super::{AuditActor, error::AdminError, sessions::get_session_store};
14+
use crate::{
15+
AppState,
16+
middleware::{AdminAuth, AuthzContext, ClientInfo},
17+
models::CreateAuditLog,
18+
routes::admin::sessions::{SessionInfo, SessionListResponse, SessionsRevokedResponse},
19+
services::Services,
20+
};
21+
22+
fn get_services(state: &AppState) -> Result<&Services, AdminError> {
23+
state.services.as_ref().ok_or(AdminError::ServicesRequired)
24+
}
25+
26+
/// List current user's active sessions.
27+
///
28+
/// Returns an empty list if enhanced session management is not enabled.
29+
/// The `enhanced_enabled` field indicates whether session tracking is active.
30+
#[cfg_attr(feature = "utoipa", utoipa::path(
31+
get,
32+
path = "/admin/v1/me/sessions",
33+
tag = "me",
34+
operation_id = "me_sessions_list",
35+
responses(
36+
(status = 200, description = "List of current user's sessions", body = SessionListResponse),
37+
(status = 401, description = "Not authenticated", body = crate::openapi::ErrorResponse),
38+
)
39+
))]
40+
#[tracing::instrument(
41+
name = "admin.me.sessions.list",
42+
skip(state, admin_auth, authz, cookies)
43+
)]
44+
pub async fn list(
45+
State(state): State<AppState>,
46+
Extension(admin_auth): Extension<AdminAuth>,
47+
Extension(authz): Extension<AuthzContext>,
48+
cookies: Cookies,
49+
) -> Result<Json<SessionListResponse>, AdminError> {
50+
authz.require("me", "read", None, None, None, None)?;
51+
52+
let external_id = &admin_auth.identity.external_id;
53+
if external_id.is_empty() {
54+
return Ok(Json(SessionListResponse {
55+
data: vec![],
56+
enhanced_enabled: false,
57+
current_session_id: None,
58+
}));
59+
}
60+
61+
let session_store = get_session_store(&state)?;
62+
let enhanced_enabled = session_store.is_enhanced_enabled();
63+
64+
// Extract current session ID from cookie so the UI can highlight it.
65+
// Only include when enhanced sessions are enabled to avoid leaking the session UUID.
66+
let current_session_id = if enhanced_enabled {
67+
let session_config = state.config.auth.session_config_or_default();
68+
cookies
69+
.get(&session_config.cookie_name)
70+
.and_then(|c| c.value().parse::<Uuid>().ok())
71+
} else {
72+
None
73+
};
74+
75+
let sessions = session_store
76+
.list_user_sessions(external_id)
77+
.await
78+
.map_err(|e| AdminError::Internal(format!("Failed to list sessions: {}", e)))?;
79+
80+
let data = sessions
81+
.into_iter()
82+
.map(|s| SessionInfo {
83+
id: s.id,
84+
device: s.device,
85+
created_at: s.created_at,
86+
last_activity: s.last_activity,
87+
expires_at: s.expires_at,
88+
})
89+
.collect();
90+
91+
Ok(Json(SessionListResponse {
92+
data,
93+
enhanced_enabled,
94+
current_session_id,
95+
}))
96+
}
97+
98+
/// Revoke a specific session belonging to the current user.
99+
///
100+
/// Returns success even if the session doesn't exist (idempotent).
101+
/// Returns 400 if the session belongs to a different user.
102+
#[cfg_attr(feature = "utoipa", utoipa::path(
103+
delete,
104+
path = "/admin/v1/me/sessions/{session_id}",
105+
tag = "me",
106+
operation_id = "me_sessions_delete_one",
107+
params(("session_id" = Uuid, Path, description = "Session ID to revoke")),
108+
responses(
109+
(status = 200, description = "Session revoked", body = SessionsRevokedResponse),
110+
(status = 400, description = "Session does not belong to current user", body = crate::openapi::ErrorResponse),
111+
(status = 401, description = "Not authenticated", body = crate::openapi::ErrorResponse),
112+
)
113+
))]
114+
#[tracing::instrument(name = "admin.me.sessions.delete_one", skip(state, admin_auth, authz), fields(%session_id))]
115+
pub async fn delete_one(
116+
State(state): State<AppState>,
117+
Extension(admin_auth): Extension<AdminAuth>,
118+
Extension(authz): Extension<AuthzContext>,
119+
Extension(client_info): Extension<ClientInfo>,
120+
Path(session_id): Path<Uuid>,
121+
) -> Result<Json<SessionsRevokedResponse>, AdminError> {
122+
authz.require("me", "delete", None, None, None, None)?;
123+
124+
let external_id = &admin_auth.identity.external_id;
125+
let services = get_services(&state)?;
126+
let actor = AuditActor::from(&admin_auth);
127+
128+
let session_store = get_session_store(&state)?;
129+
130+
// Verify session belongs to the current user
131+
let session_existed = match session_store.get_session(session_id).await {
132+
Ok(Some(session)) => {
133+
if session.external_id != *external_id {
134+
return Err(AdminError::BadRequest(
135+
"Session does not belong to current user".to_string(),
136+
));
137+
}
138+
true
139+
}
140+
Ok(None) => false,
141+
Err(e) => {
142+
return Err(AdminError::Internal(format!(
143+
"Failed to look up session: {e}"
144+
)));
145+
}
146+
};
147+
148+
let sessions_revoked = if !session_existed {
149+
0
150+
} else {
151+
session_store
152+
.delete_session(session_id)
153+
.await
154+
.map_err(|e| AdminError::Internal(format!("Failed to delete session: {e}")))?;
155+
1
156+
};
157+
158+
if session_existed {
159+
let _ = services
160+
.audit_logs
161+
.create(CreateAuditLog {
162+
actor_type: actor.actor_type,
163+
actor_id: actor.actor_id,
164+
action: "session.self_delete_one".to_string(),
165+
resource_type: "session".to_string(),
166+
resource_id: session_id,
167+
org_id: None,
168+
project_id: None,
169+
details: json!({
170+
"session_id": session_id,
171+
"sessions_revoked": sessions_revoked,
172+
}),
173+
ip_address: client_info.ip_address,
174+
user_agent: client_info.user_agent,
175+
})
176+
.await;
177+
}
178+
179+
Ok(Json(SessionsRevokedResponse { sessions_revoked }))
180+
}

src/routes/admin/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ mod error;
1313
pub mod me;
1414
pub mod me_api_keys;
1515
pub mod me_providers;
16+
#[cfg(feature = "sso")]
17+
pub mod me_sessions;
1618
pub mod model_pricing;
1719
pub mod org_rbac_policies;
1820
#[cfg(feature = "sso")]
@@ -689,7 +691,10 @@ pub(crate) fn admin_v1_routes() -> Router<AppState> {
689691
// SSO routes (only available when sso feature is enabled)
690692
#[cfg(feature = "sso")]
691693
let router = router
692-
// User Sessions
694+
// Self-service sessions (current user)
695+
.route("/me/sessions", get(me_sessions::list))
696+
.route("/me/sessions/{session_id}", delete(me_sessions::delete_one))
697+
// User Sessions (admin)
693698
.route(
694699
"/users/{user_id}/sessions",
695700
get(sessions::list).delete(sessions::delete_all),

src/routes/admin/sessions.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ pub struct SessionListResponse {
4848
/// Whether enhanced session management is enabled.
4949
/// If false, the UI should show a message that session tracking is not enabled.
5050
pub enhanced_enabled: bool,
51+
/// The session ID of the current request (if authenticated via session cookie).
52+
/// Allows the UI to highlight the current session and warn before self-revocation.
53+
#[serde(skip_serializing_if = "Option::is_none")]
54+
pub current_session_id: Option<Uuid>,
5155
}
5256

5357
/// Response after revoking sessions.
@@ -153,6 +157,7 @@ pub async fn list(
153157
Ok(Json(SessionListResponse {
154158
data,
155159
enhanced_enabled,
160+
current_session_id: None,
156161
}))
157162
}
158163

@@ -386,6 +391,7 @@ mod tests {
386391
let response = SessionListResponse {
387392
data: vec![],
388393
enhanced_enabled: false,
394+
current_session_id: None,
389395
};
390396

391397
let json = serde_json::to_string(&response).unwrap();

ui/src/api/openapi.json

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2962,6 +2962,92 @@
29622962
}
29632963
}
29642964
},
2965+
"/admin/v1/me/sessions": {
2966+
"get": {
2967+
"tags": [
2968+
"me"
2969+
],
2970+
"summary": "List current user's active sessions.",
2971+
"description": "Returns an empty list if enhanced session management is not enabled.\nThe `enhanced_enabled` field indicates whether session tracking is active.",
2972+
"operationId": "me_sessions_list",
2973+
"responses": {
2974+
"200": {
2975+
"description": "List of current user's sessions",
2976+
"content": {
2977+
"application/json": {
2978+
"schema": {
2979+
"$ref": "#/components/schemas/SessionListResponse"
2980+
}
2981+
}
2982+
}
2983+
},
2984+
"401": {
2985+
"description": "Not authenticated",
2986+
"content": {
2987+
"application/json": {
2988+
"schema": {
2989+
"$ref": "#/components/schemas/ErrorResponse"
2990+
}
2991+
}
2992+
}
2993+
}
2994+
}
2995+
}
2996+
},
2997+
"/admin/v1/me/sessions/{session_id}": {
2998+
"delete": {
2999+
"tags": [
3000+
"me"
3001+
],
3002+
"summary": "Revoke a specific session belonging to the current user.",
3003+
"description": "Returns success even if the session doesn't exist (idempotent).\nReturns 400 if the session belongs to a different user.",
3004+
"operationId": "me_sessions_delete_one",
3005+
"parameters": [
3006+
{
3007+
"name": "session_id",
3008+
"in": "path",
3009+
"description": "Session ID to revoke",
3010+
"required": true,
3011+
"schema": {
3012+
"type": "string",
3013+
"format": "uuid"
3014+
}
3015+
}
3016+
],
3017+
"responses": {
3018+
"200": {
3019+
"description": "Session revoked",
3020+
"content": {
3021+
"application/json": {
3022+
"schema": {
3023+
"$ref": "#/components/schemas/SessionsRevokedResponse"
3024+
}
3025+
}
3026+
}
3027+
},
3028+
"400": {
3029+
"description": "Session does not belong to current user",
3030+
"content": {
3031+
"application/json": {
3032+
"schema": {
3033+
"$ref": "#/components/schemas/ErrorResponse"
3034+
}
3035+
}
3036+
}
3037+
},
3038+
"401": {
3039+
"description": "Not authenticated",
3040+
"content": {
3041+
"application/json": {
3042+
"schema": {
3043+
"$ref": "#/components/schemas/ErrorResponse"
3044+
}
3045+
}
3046+
}
3047+
}
3048+
}
3049+
}
3050+
},
29653051
"/admin/v1/me/usage": {
29663052
"get": {
29673053
"tags": [
@@ -27792,6 +27878,12 @@
2779227878
"enhanced_enabled"
2779327879
],
2779427880
"properties": {
27881+
"current_session_id": {
27882+
"type": "string",
27883+
"format": "uuid",
27884+
"description": "The session ID of the current request (if authenticated via session cookie).\nAllows the UI to highlight the current session and warn before self-revocation.",
27885+
"nullable": true
27886+
},
2779527887
"data": {
2779627888
"type": "array",
2779727889
"items": {

ui/src/components/Admin/SessionsPanel/SessionCard.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export interface SessionCardProps {
55
session: SessionInfo;
66
onRevoke?: (sessionId: string) => void;
77
isRevoking?: boolean;
8+
isCurrent?: boolean;
89
}
910

1011
function formatRelativeTime(date: Date): string {
@@ -37,23 +38,37 @@ function formatDateTime(dateStr: string): string {
3738
return date.toLocaleString();
3839
}
3940

40-
export function SessionCard({ session, onRevoke, isRevoking = false }: SessionCardProps) {
41+
export function SessionCard({
42+
session,
43+
onRevoke,
44+
isRevoking = false,
45+
isCurrent = false,
46+
}: SessionCardProps) {
4147
const createdAt = new Date(session.created_at);
4248
const expiresAt = new Date(session.expires_at);
4349
const lastActivity = session.last_activity ? new Date(session.last_activity) : null;
4450

4551
return (
46-
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
52+
<div
53+
className={`bg-card border rounded-lg p-4 space-y-3 ${isCurrent ? "border-primary/50" : "border-border"}`}
54+
>
4755
{/* Device Info */}
4856
<div className="flex items-start justify-between gap-3">
4957
<div className="flex items-start gap-3 min-w-0 flex-1">
5058
<div className="flex-shrink-0 w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
5159
<Monitor className="w-5 h-5 text-primary" />
5260
</div>
5361
<div className="min-w-0 flex-1">
54-
<p className="text-sm font-medium text-foreground truncate">
55-
{session.device?.device_description || "Unknown Device"}
56-
</p>
62+
<div className="flex items-center gap-2">
63+
<p className="text-sm font-medium text-foreground truncate">
64+
{session.device?.device_description || "Unknown Device"}
65+
</p>
66+
{isCurrent && (
67+
<span className="flex-shrink-0 text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded">
68+
This device
69+
</span>
70+
)}
71+
</div>
5772
{session.device?.ip_address && (
5873
<div className="flex items-center gap-1.5 mt-1">
5974
<Globe className="w-3.5 h-3.5 text-muted-foreground" />

0 commit comments

Comments
 (0)