1use std::net::SocketAddr;
4use std::path::PathBuf;
5
6use anyhow::{Context, Result};
7use clap::{Parser, Subcommand};
8
9use crate::commands::executable::ExecutableCommand;
10use crate::config::CliConfig;
11
12#[derive(Parser, Debug, Clone)]
14#[command(name = "scim", about = "SCIM 2.0 provisioning for agent identities")]
15pub struct ScimCommand {
16 #[command(subcommand)]
17 pub command: ScimSubcommand,
18}
19
20#[derive(Subcommand, Debug, Clone)]
21pub enum ScimSubcommand {
22 Serve(ScimServeCommand),
24 Quickstart(ScimQuickstartCommand),
26 TestConnection(ScimTestConnectionCommand),
28 Tenants(ScimTenantsCommand),
30 AddTenant(ScimAddTenantCommand),
32 RotateToken(ScimRotateTokenCommand),
34 Status(ScimStatusCommand),
36}
37
38#[derive(Parser, Debug, Clone)]
40pub struct ScimServeCommand {
41 #[arg(long, default_value = "0.0.0.0:3301")]
43 pub bind: SocketAddr,
44 #[arg(long)]
46 pub database_url: String,
47 #[arg(long)]
49 pub registry_path: Option<PathBuf>,
50 #[arg(long, default_value = "info")]
52 pub log_level: String,
53 #[arg(long)]
55 pub test_mode: bool,
56}
57
58#[derive(Parser, Debug, Clone)]
60pub struct ScimQuickstartCommand {
61 #[arg(long, default_value = "0.0.0.0:3301")]
63 pub bind: SocketAddr,
64}
65
66#[derive(Parser, Debug, Clone)]
68pub struct ScimTestConnectionCommand {
69 #[arg(long, default_value = "http://localhost:3301")]
71 pub url: String,
72 #[arg(long)]
74 pub token: String,
75}
76
77#[derive(Parser, Debug, Clone)]
79pub struct ScimTenantsCommand {
80 #[arg(long)]
82 pub database_url: String,
83 #[arg(long)]
85 pub json: bool,
86}
87
88#[derive(Parser, Debug, Clone)]
90pub struct ScimAddTenantCommand {
91 #[arg(long)]
93 pub name: String,
94 #[arg(long)]
96 pub database_url: String,
97 #[arg(long)]
99 pub expires_in: Option<String>,
100}
101
102#[derive(Parser, Debug, Clone)]
104pub struct ScimRotateTokenCommand {
105 #[arg(long)]
107 pub name: String,
108 #[arg(long)]
110 pub database_url: String,
111 #[arg(long)]
113 pub expires_in: Option<String>,
114}
115
116#[derive(Parser, Debug, Clone)]
118pub struct ScimStatusCommand {
119 #[arg(long)]
121 pub database_url: String,
122 #[arg(long)]
124 pub json: bool,
125}
126
127fn handle_scim(cmd: ScimCommand) -> Result<()> {
128 match cmd.command {
129 ScimSubcommand::Serve(serve) => handle_serve(serve),
130 ScimSubcommand::Quickstart(qs) => handle_quickstart(qs),
131 ScimSubcommand::TestConnection(tc) => handle_test_connection(tc),
132 ScimSubcommand::Tenants(_) => {
133 println!("SCIM tenant listing requires database connection.");
134 println!("Run: auths-scim-server with DATABASE_URL set.");
135 Ok(())
136 }
137 ScimSubcommand::AddTenant(_) => {
138 println!("Tenant management requires database connection.");
139 println!("Run: auths-scim-server with DATABASE_URL set.");
140 Ok(())
141 }
142 ScimSubcommand::RotateToken(_) => {
143 println!("Token rotation requires database connection.");
144 println!("Run: auths-scim-server with DATABASE_URL set.");
145 Ok(())
146 }
147 ScimSubcommand::Status(_) => {
148 println!("SCIM status requires database connection.");
149 println!("Run: auths-scim-server with DATABASE_URL set.");
150 Ok(())
151 }
152 }
153}
154
155fn handle_serve(cmd: ScimServeCommand) -> Result<()> {
156 println!("Starting SCIM server...");
157 println!(" Bind: {}", cmd.bind);
158 println!(" Database: {}", mask_url(&cmd.database_url));
159 if let Some(ref path) = cmd.registry_path {
160 println!(" Registry: {}", path.display());
161 }
162 println!(" Test mode: {}", cmd.test_mode);
163 println!();
164
165 let mut child = std::process::Command::new("auths-scim-server")
166 .env("SCIM_LISTEN_ADDR", cmd.bind.to_string())
167 .env("DATABASE_URL", &cmd.database_url)
168 .env("RUST_LOG", &cmd.log_level)
169 .env("AUTHS_SCIM_TEST", if cmd.test_mode { "1" } else { "0" })
170 .spawn()
171 .context("Failed to start auths-scim-server. Is it installed?")?;
172
173 child.wait().context("Server exited with error")?;
174 Ok(())
175}
176
177fn handle_quickstart(cmd: ScimQuickstartCommand) -> Result<()> {
178 let token = format!("scim_test_{}", generate_token_b64());
179
180 println!();
181 println!(" Auths SCIM Quickstart");
182 println!();
183 println!(" Server: http://{}", cmd.bind);
184 println!(" Tenant: quickstart");
185 println!(" Token: {}", token);
186 println!();
187 println!(" Try it now:");
188 println!(" # List agents (empty)");
189 println!(" curl -s -H \"Authorization: Bearer {}\" \\", token);
190 println!(" http://{}/Users | jq", cmd.bind);
191 println!();
192 println!(" # Create an agent");
193 println!(
194 " curl -s -X POST -H \"Authorization: Bearer {}\" \\",
195 token
196 );
197 println!(" -H \"Content-Type: application/scim+json\" \\");
198 println!(
199 " -d '{{\"schemas\":[\"urn:ietf:params:scim:schemas:core:2.0:User\"],\"userName\":\"my-agent\",\"displayName\":\"My First Agent\"}}' \\"
200 );
201 println!(" http://{}/Users | jq", cmd.bind);
202 println!();
203 println!(" Docs: https://docs.auths.dev/scim/quickstart");
204 println!(" Press Ctrl+C to stop.");
205 println!();
206
207 let serve = ScimServeCommand {
209 bind: cmd.bind,
210 database_url: String::new(), registry_path: None,
212 log_level: "info".into(),
213 test_mode: true,
214 };
215
216 if serve.database_url.is_empty() {
218 println!(" Note: Quickstart requires DATABASE_URL to be set.");
219 println!(" Set DATABASE_URL env var or use `auths scim serve --database-url <url>`");
220 }
221
222 Ok(())
223}
224
225fn handle_test_connection(cmd: ScimTestConnectionCommand) -> Result<()> {
226 println!();
227 println!(" Testing SCIM connection to {}...", cmd.url);
228 println!();
229
230 let rt = tokio::runtime::Handle::try_current()
231 .ok()
232 .map(|_| None)
233 .unwrap_or_else(|| Some(tokio::runtime::Runtime::new().expect("tokio runtime")));
234
235 let result = if let Some(ref rt) = rt {
236 rt.block_on(run_test_connection(&cmd.url, &cmd.token))
237 } else {
238 tokio::task::block_in_place(|| {
239 tokio::runtime::Handle::current().block_on(run_test_connection(&cmd.url, &cmd.token))
240 })
241 };
242
243 match result {
244 Ok(()) => {
245 println!(" All checks passed. Your SCIM server is ready.");
246 println!();
247 }
248 Err(e) => {
249 println!(" Connection test failed: {}", e);
250 println!();
251 }
252 }
253
254 Ok(())
255}
256
257#[allow(clippy::disallowed_methods)] async fn run_test_connection(base_url: &str, token: &str) -> Result<()> {
259 #[allow(clippy::expect_used)]
260 let client = reqwest::Client::builder()
261 .connect_timeout(std::time::Duration::from_secs(10))
262 .timeout(std::time::Duration::from_secs(30))
263 .user_agent(concat!("auths/", env!("CARGO_PKG_VERSION")))
264 .min_tls_version(reqwest::tls::Version::TLS_1_2)
265 .build()
266 .expect("failed to build HTTP client");
267 let auth = format!("Bearer {}", token);
268
269 let start = std::time::Instant::now();
271 let resp = client
272 .post(format!("{}/Users", base_url))
273 .header("Authorization", &auth)
274 .header("Content-Type", "application/scim+json")
275 .json(&serde_json::json!({
276 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
277 "userName": format!("test-agent-{}", chrono::Utc::now().timestamp()),
278 "displayName": "SCIM Test Agent"
279 }))
280 .send()
281 .await
282 .context("POST /Users failed")?;
283 let elapsed = start.elapsed();
284
285 if resp.status().as_u16() == 201 {
286 println!(" [PASS] POST /Users -> 201 Created ({:.0?})", elapsed);
287 } else {
288 println!(
289 " [FAIL] POST /Users -> {} ({:.0?})",
290 resp.status(),
291 elapsed
292 );
293 return Ok(());
294 }
295
296 let body: serde_json::Value = resp.json().await?;
297 let id = body["id"].as_str().unwrap_or("unknown");
298 let did = body
299 .get("urn:ietf:params:scim:schemas:extension:auths:2.0:Agent")
300 .and_then(|ext| ext["identityDid"].as_str())
301 .unwrap_or("unknown");
302 println!(" Agent: {} (userName: {})", did, body["userName"]);
303
304 let start = std::time::Instant::now();
306 let resp = client
307 .get(format!("{}/Users/{}", base_url, id))
308 .header("Authorization", &auth)
309 .send()
310 .await?;
311 let elapsed = start.elapsed();
312 println!(
313 " [{}] GET /Users/{{id}} -> {} ({:.0?})",
314 if resp.status().is_success() {
315 "PASS"
316 } else {
317 "FAIL"
318 },
319 resp.status(),
320 elapsed
321 );
322
323 let start = std::time::Instant::now();
325 let resp = client
326 .patch(format!("{}/Users/{}", base_url, id))
327 .header("Authorization", &auth)
328 .header("Content-Type", "application/scim+json")
329 .json(&serde_json::json!({
330 "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
331 "Operations": [{"op": "Replace", "value": {"active": false}}]
332 }))
333 .send()
334 .await?;
335 let elapsed = start.elapsed();
336 println!(
337 " [{}] PATCH active=false -> {} ({:.0?})",
338 if resp.status().is_success() {
339 "PASS"
340 } else {
341 "FAIL"
342 },
343 resp.status(),
344 elapsed
345 );
346
347 let start = std::time::Instant::now();
349 let resp = client
350 .delete(format!("{}/Users/{}", base_url, id))
351 .header("Authorization", &auth)
352 .send()
353 .await?;
354 let elapsed = start.elapsed();
355 println!(
356 " [{}] DELETE /Users/{{id}} -> {} ({:.0?})",
357 if resp.status().as_u16() == 204 {
358 "PASS"
359 } else {
360 "FAIL"
361 },
362 resp.status(),
363 elapsed
364 );
365
366 let start = std::time::Instant::now();
368 let resp = client
369 .get(format!("{}/Users/{}", base_url, id))
370 .header("Authorization", &auth)
371 .send()
372 .await?;
373 let elapsed = start.elapsed();
374 println!(
375 " [{}] GET /Users/{{id}} -> {} ({:.0?})",
376 if resp.status().as_u16() == 404 {
377 "PASS"
378 } else {
379 "FAIL"
380 },
381 resp.status(),
382 elapsed
383 );
384
385 println!();
386 Ok(())
387}
388
389fn generate_token_b64() -> String {
390 use base64::Engine;
391 let mut bytes = [0u8; 32];
392 ring::rand::SystemRandom::new()
393 .fill(&mut bytes)
394 .expect("random bytes");
395 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
396}
397
398fn mask_url(url: &str) -> String {
399 if let Some(at_pos) = url.find('@')
400 && let Some(scheme_end) = url.find("://")
401 {
402 return format!("{}://***@{}", &url[..scheme_end], &url[at_pos + 1..]);
403 }
404 url.to_string()
405}
406
407impl ExecutableCommand for ScimCommand {
408 fn execute(&self, _ctx: &CliConfig) -> Result<()> {
409 handle_scim(self.clone())
410 }
411}
412
413use ring::rand::SecureRandom;