Skip to content

Commit 5f2453b

Browse files
committed
feat: Implement CLI session management
- Add functionality to list, revoke, and manage CLI sessions. - Introduce new subcommand in the CLI for session actions. - Update authentication flow to support session management. - Modify device flow and token refresh endpoints for improved session handling. - Enhance error handling and user prompts for session actions.
1 parent e4d4ddd commit 5f2453b

File tree

8 files changed

+495
-192
lines changed

8 files changed

+495
-192
lines changed

install.sh

Lines changed: 138 additions & 180 deletions
Large diffs are not rendered by default.

src/auth/client.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ impl AuthClient {
5959
}
6060

6161
pub async fn refresh_token(&self, request: RefreshRequest) -> Result<RefreshResponse> {
62-
let url = format!("{}/auth/refresh", self.base_url);
62+
let url = format!("{}/auth/cli/refresh", self.base_url);
6363

6464
let response = self
6565
.client

src/auth/device_flow.rs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ use reqwest::Client;
1111

1212
#[derive(Debug, Serialize)]
1313
pub struct DeviceFlowRequest {
14-
pub client_id: String,
15-
pub scope: String,
14+
pub client_id: Option<String>,
15+
pub scopes: Option<Vec<String>>,
1616
}
1717

1818
#[derive(Debug, Deserialize)]
@@ -37,7 +37,7 @@ pub struct DeviceTokenResponse {
3737
pub refresh_token: String,
3838
pub token_type: String,
3939
pub expires_in: i64,
40-
pub scope: String,
40+
pub scope: Vec<String>,
4141
}
4242

4343
#[derive(Debug, Deserialize)]
@@ -120,11 +120,18 @@ impl DeviceAuth {
120120

121121
async fn initiate_device_flow(&self) -> Result<DeviceFlowResponse> {
122122
let request = DeviceFlowRequest {
123-
client_id: "kanuni-cli".to_string(),
124-
scope: "full_access".to_string(),
123+
client_id: Some("kanuni-cli".to_string()),
124+
scopes: Some(vec![
125+
"read_documents".to_string(),
126+
"write_documents".to_string(),
127+
"read_cases".to_string(),
128+
"write_cases".to_string(),
129+
"read_chat".to_string(),
130+
"write_chat".to_string(),
131+
]),
125132
};
126133

127-
let url = format!("{}/api/v1/auth/device/code", self.base_url);
134+
let url = format!("{}/auth/device/code", self.base_url);
128135
let response = self
129136
.client
130137
.post(&url)
@@ -154,7 +161,7 @@ impl DeviceAuth {
154161
client_id: "kanuni-cli".to_string(),
155162
};
156163

157-
let url = format!("{}/api/v1/auth/device/token", self.base_url);
164+
let url = format!("{}/auth/device/token", self.base_url);
158165
let response = self.client.post(&url).json(&request).send().await?;
159166

160167
if response.status().is_success() {

src/auth/mod.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod api_key;
22
pub mod client;
33
pub mod device_flow;
44
pub mod models;
5+
pub mod sessions;
56
pub mod token_store;
67

78
use anyhow::{Context, Result};
@@ -13,7 +14,8 @@ use self::{
1314
api_key::ApiKeyManager,
1415
client::AuthClient,
1516
device_flow::DeviceAuth,
16-
models::{AuthTokens, RefreshRequest, UserInfo},
17+
models::{RefreshRequest, CliSessionResponse},
18+
sessions::SessionsClient,
1719
token_store::{AuthType, StoredCredentials, TokenStore},
1820
};
1921
use crate::config::Config;
@@ -219,4 +221,33 @@ impl AuthManager {
219221
Ok("Not authenticated".to_string())
220222
}
221223
}
224+
225+
/// List all CLI sessions
226+
pub async fn list_sessions(&self) -> Result<Vec<CliSessionResponse>> {
227+
let access_token = self.get_access_token().await?;
228+
let config = self.config.read().await;
229+
let client = SessionsClient::new(config.api_endpoint.clone());
230+
client.list_sessions(&access_token).await
231+
}
232+
233+
/// Revoke a specific CLI session
234+
pub async fn revoke_session(&self, session_id: &str) -> Result<()> {
235+
let access_token = self.get_access_token().await?;
236+
let config = self.config.read().await;
237+
let client = SessionsClient::new(config.api_endpoint.clone());
238+
client.revoke_session(&access_token, session_id).await
239+
}
240+
241+
/// Revoke all CLI sessions
242+
pub async fn revoke_all_sessions(&self) -> Result<()> {
243+
let access_token = self.get_access_token().await?;
244+
let config = self.config.read().await;
245+
let client = SessionsClient::new(config.api_endpoint.clone());
246+
client.revoke_all_sessions(&access_token).await?;
247+
248+
// After revoking all sessions, we need to logout locally as well
249+
self.logout().await?;
250+
251+
Ok(())
252+
}
222253
}

src/auth/models.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,24 @@ pub struct ErrorResponse {
5555
pub error: String,
5656
pub message: String,
5757
}
58+
59+
// CLI Session Management Models
60+
61+
#[derive(Debug, Clone, Deserialize)]
62+
pub struct CliSessionResponse {
63+
pub id: String, // Using String for UUID compatibility
64+
pub device_name: Option<String>,
65+
pub platform: Option<String>,
66+
pub hostname: Option<String>,
67+
pub ip_address: Option<String>,
68+
pub last_used_at: DateTime<Utc>,
69+
pub scopes: Vec<String>,
70+
pub is_current: bool,
71+
pub is_active: bool,
72+
pub created_at: DateTime<Utc>,
73+
}
74+
75+
#[derive(Debug, Serialize)]
76+
pub struct RevokeSessionRequest {
77+
pub reason: Option<String>,
78+
}

src/auth/sessions.rs

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
use anyhow::Result;
2+
use reqwest::{Client, StatusCode};
3+
use colored::*;
4+
use chrono::{DateTime, Utc};
5+
6+
use super::models::{CliSessionResponse, RevokeSessionRequest, ErrorResponse};
7+
8+
pub struct SessionsClient {
9+
client: Client,
10+
base_url: String,
11+
}
12+
13+
impl SessionsClient {
14+
pub fn new(base_url: String) -> Self {
15+
let client = Client::builder()
16+
.timeout(std::time::Duration::from_secs(30))
17+
.build()
18+
.expect("Failed to create HTTP client");
19+
20+
Self { client, base_url }
21+
}
22+
23+
/// List all CLI sessions
24+
pub async fn list_sessions(&self, access_token: &str) -> Result<Vec<CliSessionResponse>> {
25+
let url = format!("{}/auth/cli/sessions", self.base_url);
26+
27+
let response = self
28+
.client
29+
.get(&url)
30+
.header("Authorization", format!("Bearer {}", access_token))
31+
.send()
32+
.await?;
33+
34+
match response.status() {
35+
StatusCode::OK => {
36+
let sessions = response
37+
.json::<Vec<CliSessionResponse>>()
38+
.await?;
39+
Ok(sessions)
40+
}
41+
StatusCode::UNAUTHORIZED => {
42+
anyhow::bail!("Authentication token expired. Please login again.")
43+
}
44+
status => {
45+
if let Ok(error) = response.json::<ErrorResponse>().await {
46+
anyhow::bail!("{}: {}", status, error.message)
47+
} else {
48+
anyhow::bail!("Failed to list sessions with status: {}", status)
49+
}
50+
}
51+
}
52+
}
53+
54+
/// Revoke a specific CLI session
55+
pub async fn revoke_session(&self, access_token: &str, session_id: &str) -> Result<()> {
56+
let url = format!("{}/auth/cli/sessions/{}", self.base_url, session_id);
57+
58+
let request = RevokeSessionRequest {
59+
reason: Some("User revoked from CLI".to_string()),
60+
};
61+
62+
let response = self
63+
.client
64+
.delete(&url)
65+
.header("Authorization", format!("Bearer {}", access_token))
66+
.json(&request)
67+
.send()
68+
.await?;
69+
70+
match response.status() {
71+
StatusCode::OK => Ok(()),
72+
StatusCode::NOT_FOUND => {
73+
anyhow::bail!("Session not found or already revoked")
74+
}
75+
StatusCode::UNAUTHORIZED => {
76+
anyhow::bail!("Authentication token expired. Please login again.")
77+
}
78+
status => {
79+
if let Ok(error) = response.json::<ErrorResponse>().await {
80+
anyhow::bail!("{}: {}", status, error.message)
81+
} else {
82+
anyhow::bail!("Failed to revoke session with status: {}", status)
83+
}
84+
}
85+
}
86+
}
87+
88+
/// Revoke all CLI sessions
89+
pub async fn revoke_all_sessions(&self, access_token: &str) -> Result<()> {
90+
let url = format!("{}/auth/cli/sessions/revoke-all", self.base_url);
91+
92+
let response = self
93+
.client
94+
.post(&url)
95+
.header("Authorization", format!("Bearer {}", access_token))
96+
.send()
97+
.await?;
98+
99+
match response.status() {
100+
StatusCode::OK => Ok(()),
101+
StatusCode::UNAUTHORIZED => {
102+
anyhow::bail!("Authentication token expired. Please login again.")
103+
}
104+
status => {
105+
if let Ok(error) = response.json::<ErrorResponse>().await {
106+
anyhow::bail!("{}: {}", status, error.message)
107+
} else {
108+
anyhow::bail!("Failed to revoke all sessions with status: {}", status)
109+
}
110+
}
111+
}
112+
}
113+
}
114+
115+
// Helper functions for formatting sessions
116+
117+
pub fn format_session_display(sessions: &[CliSessionResponse]) {
118+
if sessions.is_empty() {
119+
println!("{} No active CLI sessions found", "ℹ".blue());
120+
return;
121+
}
122+
123+
println!("\n{} Active CLI Sessions\n", "🔐".green());
124+
125+
// Header
126+
println!(
127+
"{:<12} {:<20} {:<15} {:<20} {:<15} {}",
128+
"ID".bold(),
129+
"Device".bold(),
130+
"Platform".bold(),
131+
"Hostname".bold(),
132+
"Last Active".bold(),
133+
"Status".bold()
134+
);
135+
136+
println!("{}", "─".repeat(100));
137+
138+
for session in sessions {
139+
let id_short = &session.id[..8.min(session.id.len())];
140+
let device_name = session.device_name.as_deref().unwrap_or("Unknown");
141+
let platform = format_platform(session.platform.as_deref());
142+
let hostname = session.hostname.as_deref().unwrap_or("-");
143+
let last_active = format_relative_time(&session.last_used_at);
144+
145+
let status = if session.is_current {
146+
"CURRENT".green().bold()
147+
} else if session.is_active {
148+
"Active".green()
149+
} else {
150+
"Inactive".red()
151+
};
152+
153+
println!(
154+
"{:<12} {:<20} {:<15} {:<20} {:<15} {}",
155+
id_short,
156+
truncate_string(device_name, 20),
157+
platform,
158+
truncate_string(hostname, 20),
159+
last_active,
160+
status
161+
);
162+
}
163+
164+
println!("\n{} Total: {} active session(s)", "📊".blue(), sessions.len());
165+
}
166+
167+
fn format_platform(platform: Option<&str>) -> String {
168+
match platform {
169+
Some(p) if p.to_lowercase().contains("darwin") => "macOS",
170+
Some(p) if p.to_lowercase().contains("linux") => "Linux",
171+
Some(p) if p.to_lowercase().contains("win") => "Windows",
172+
Some(p) if p == "cli" => "CLI",
173+
Some(p) => p,
174+
None => "Unknown",
175+
}.to_string()
176+
}
177+
178+
fn truncate_string(s: &str, max_len: usize) -> String {
179+
if s.len() > max_len {
180+
format!("{}...", &s[..max_len - 3])
181+
} else {
182+
s.to_string()
183+
}
184+
}
185+
186+
fn format_relative_time(dt: &DateTime<Utc>) -> String {
187+
let now = Utc::now();
188+
let duration = now.signed_duration_since(*dt);
189+
190+
if duration.num_days() > 30 {
191+
format!("{} months ago", duration.num_days() / 30)
192+
} else if duration.num_days() > 0 {
193+
format!("{} days ago", duration.num_days())
194+
} else if duration.num_hours() > 0 {
195+
format!("{} hours ago", duration.num_hours())
196+
} else if duration.num_minutes() > 0 {
197+
format!("{} mins ago", duration.num_minutes())
198+
} else {
199+
"Just now".to_string()
200+
}
201+
}

src/cli.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,25 @@ pub enum AuthAction {
153153
/// List all API keys
154154
#[command(name = "list-keys")]
155155
ListKeys,
156+
/// Manage CLI sessions
157+
Sessions {
158+
#[command(subcommand)]
159+
action: SessionAction,
160+
},
161+
}
162+
163+
#[derive(Subcommand)]
164+
pub enum SessionAction {
165+
/// List all active CLI sessions
166+
List,
167+
/// Revoke a specific CLI session
168+
Revoke {
169+
/// Session ID to revoke
170+
id: String,
171+
},
172+
/// Revoke all CLI sessions (will log you out)
173+
#[command(name = "revoke-all")]
174+
RevokeAll,
156175
}
157176

158177
#[derive(Subcommand)]

0 commit comments

Comments
 (0)