1use std::net::SocketAddr;
10use std::path::PathBuf;
11
12use anyhow::{Context, Result};
13use clap::{Parser, Subcommand};
14
15use auths_scim_server::{ServeConfig, TenantBootstrap, run};
16
17use crate::commands::executable::ExecutableCommand;
18use crate::config::CliConfig;
19
20#[derive(Parser, Debug, Clone)]
22#[command(name = "scim", about = "SCIM 2.0 provisioning for agent identities")]
23pub struct ScimCommand {
24 #[command(subcommand)]
25 pub command: ScimSubcommand,
26}
27
28#[derive(Subcommand, Debug, Clone)]
29pub enum ScimSubcommand {
30 Serve(ScimServeCommand),
32 Quickstart(ScimQuickstartCommand),
34 TestConnection(ScimTestConnectionCommand),
36 Tenants(ScimTenantsCommand),
38 AddTenant(ScimAddTenantCommand),
40 RotateToken(ScimRotateTokenCommand),
42 Status(ScimStatusCommand),
44}
45
46#[derive(Parser, Debug, Clone)]
48pub struct ScimServeCommand {
49 #[arg(long, default_value = "0.0.0.0:8787")]
51 pub bind: SocketAddr,
52 #[arg(long)]
54 pub tenant: Option<String>,
55 #[arg(long)]
57 pub org_prefix: Option<String>,
58 #[arg(long)]
60 pub token: Option<String>,
61 #[arg(long)]
63 pub org_key: Option<String>,
64 #[arg(long)]
66 pub base_url: Option<String>,
67 #[arg(long)]
69 pub passphrase: Option<String>,
70 #[arg(long)]
72 pub registry_path: Option<PathBuf>,
73}
74
75#[derive(Parser, Debug, Clone)]
77pub struct ScimQuickstartCommand {
78 #[arg(long, default_value = "0.0.0.0:8787")]
80 pub bind: SocketAddr,
81 #[arg(long)]
83 pub org_prefix: String,
84 #[arg(long, default_value = "quickstart")]
86 pub tenant: String,
87 #[arg(long)]
89 pub passphrase: Option<String>,
90 #[arg(long)]
92 pub registry_path: Option<PathBuf>,
93}
94
95#[derive(Parser, Debug, Clone)]
97pub struct ScimTestConnectionCommand {
98 #[arg(long, default_value = "http://localhost:8787")]
100 pub url: String,
101 #[arg(long)]
103 pub token: String,
104}
105
106#[derive(Parser, Debug, Clone)]
108pub struct ScimTenantsCommand {
109 #[arg(long)]
111 pub json: bool,
112}
113
114#[derive(Parser, Debug, Clone)]
116pub struct ScimAddTenantCommand {
117 #[arg(long)]
119 pub name: String,
120 #[arg(long)]
122 pub org_prefix: String,
123}
124
125#[derive(Parser, Debug, Clone)]
127pub struct ScimRotateTokenCommand {
128 #[arg(long)]
130 pub name: String,
131}
132
133#[derive(Parser, Debug, Clone)]
135pub struct ScimStatusCommand {
136 #[arg(long, default_value = "http://localhost:8787")]
138 pub url: String,
139 #[arg(long)]
141 pub json: bool,
142}
143
144fn handle_scim(cmd: ScimCommand, ctx: &CliConfig) -> Result<()> {
145 match cmd.command {
146 ScimSubcommand::Serve(serve) => handle_serve(serve, ctx),
147 ScimSubcommand::Quickstart(qs) => handle_quickstart(qs, ctx),
148 ScimSubcommand::TestConnection(tc) => handle_test_connection(tc),
149 ScimSubcommand::Tenants(_) => handle_tenants(),
150 ScimSubcommand::AddTenant(at) => handle_add_tenant(&at.name, &at.org_prefix),
151 ScimSubcommand::RotateToken(rt) => handle_rotate_token(&rt.name),
152 ScimSubcommand::Status(st) => handle_status(st),
153 }
154}
155
156fn resolve_home(registry_path: Option<PathBuf>, ctx: &CliConfig) -> Option<PathBuf> {
159 registry_path.or_else(|| ctx.repo_path.clone())
160}
161
162fn handle_serve(cmd: ScimServeCommand, ctx: &CliConfig) -> Result<()> {
163 let tenant = match (cmd.tenant, cmd.org_prefix, cmd.token) {
164 (Some(tenant_id), Some(org_prefix), Some(bearer_token)) => Some(TenantBootstrap {
165 tenant_id,
166 org_prefix,
167 bearer_token,
168 org_key_alias: cmd.org_key,
169 base_url: cmd.base_url,
170 }),
171 (None, None, None) => {
172 println!("No tenant configured ā running discovery-only; /Users rejects all callers.");
173 println!("Configure with --tenant <id> --org-prefix <Eā¦> --token <bearer>.");
174 None
175 }
176 _ => anyhow::bail!(
177 "--tenant, --org-prefix, and --token must be set together (or all omitted for discovery-only)"
178 ),
179 };
180
181 println!("Starting SCIM server on {}...", cmd.bind);
182 serve(ServeConfig {
183 bind: cmd.bind.to_string(),
184 tenant,
185 home: resolve_home(cmd.registry_path, ctx),
186 passphrase: cmd.passphrase.unwrap_or_default(),
187 })
188}
189
190fn handle_quickstart(cmd: ScimQuickstartCommand, ctx: &CliConfig) -> Result<()> {
191 let token = format!("scim_test_{}", generate_token_b64());
192
193 println!();
194 println!(" Auths SCIM Quickstart");
195 println!();
196 println!(" Server: http://{}", cmd.bind);
197 println!(" Tenant: {}", cmd.tenant);
198 println!(" Org: {}", cmd.org_prefix);
199 println!(" Token: {}", token);
200 println!();
201 println!(" Try it (in another shell):");
202 println!(
203 " curl -s -H \"Authorization: Bearer {token}\" http://{}/scim/v2/Users | jq",
204 cmd.bind
205 );
206 println!();
207 println!(" curl -s -X POST -H \"Authorization: Bearer {token}\" \\");
208 println!(" -H \"Content-Type: application/scim+json\" \\");
209 println!(
210 " -d '{{\"schemas\":[\"urn:ietf:params:scim:schemas:core:2.0:User\"],\"userName\":\"deploy-bot\",\"externalId\":\"okta-1\"}}' \\"
211 );
212 println!(" http://{}/scim/v2/Users | jq", cmd.bind);
213 println!();
214 println!(" Press Ctrl+C to stop.");
215 println!();
216
217 serve(ServeConfig {
218 bind: cmd.bind.to_string(),
219 tenant: Some(TenantBootstrap {
220 tenant_id: cmd.tenant,
221 org_prefix: cmd.org_prefix,
222 bearer_token: token,
223 org_key_alias: None,
224 base_url: Some(format!("http://{}/scim/v2", cmd.bind)),
225 }),
226 home: resolve_home(cmd.registry_path, ctx),
227 passphrase: cmd.passphrase.unwrap_or_default(),
228 })
229}
230
231fn handle_tenants() -> Result<()> {
232 anyhow::bail!(
233 "SCIM tenants are process-configured (via `auths scim serve --tenant/--org-prefix/--token` \
234 or the SCIM_* env), not stored in a tenant database ā the KEL is the source of truth, so \
235 there is no tenant registry to list. Use `auths scim status --url <server>` to probe a \
236 running server."
237 )
238}
239
240fn handle_add_tenant(name: &str, org_prefix: &str) -> Result<()> {
241 let token = format!("scim_{}", generate_token_b64());
242 println!("Minted a SCIM channel bearer token for tenant '{name}':");
243 println!();
244 println!(" {token}");
245 println!();
246 println!("Run the server with it:");
247 println!(" auths scim serve --tenant {name} --org-prefix {org_prefix} --token {token}");
248 println!();
249 println!("Configure your IdP (Okta/Entra) with this token as the SCIM bearer secret.");
250 println!("The token is hashed at rest by the server; store this plaintext securely now.");
251 Ok(())
252}
253
254fn handle_rotate_token(name: &str) -> Result<()> {
255 let token = format!("scim_{}", generate_token_b64());
256 println!("Minted a replacement SCIM bearer token for tenant '{name}':");
257 println!();
258 println!(" {token}");
259 println!();
260 println!("Swap it in by restarting the server with --token {token} and updating the IdP.");
261 println!("The previous token stops authenticating as soon as the server restarts.");
262 Ok(())
263}
264
265fn handle_status(cmd: ScimStatusCommand) -> Result<()> {
266 let result = block_on(probe_status(&cmd.url));
267 match result {
268 Ok(report) => {
269 if cmd.json {
270 println!("{}", serde_json::to_string_pretty(&report)?);
271 } else {
272 println!("SCIM server: {}", cmd.url);
273 println!(
274 " health: {}",
275 if report.healthy { "ok" } else { "unreachable" }
276 );
277 println!(" patch: {}", report.patch_supported);
278 println!(" filter: {}", report.filter_supported);
279 }
280 Ok(())
281 }
282 Err(e) => anyhow::bail!("could not reach SCIM server at {}: {e}", cmd.url),
283 }
284}
285
286#[derive(Debug, serde::Serialize)]
288struct StatusReport {
289 healthy: bool,
290 patch_supported: bool,
291 filter_supported: bool,
292}
293
294async fn probe_status(base_url: &str) -> Result<StatusReport> {
295 let client = build_http_client()?;
296 let healthy = client
297 .get(format!("{base_url}/health"))
298 .send()
299 .await
300 .map(|r| r.status().is_success())
301 .unwrap_or(false);
302
303 let spc: serde_json::Value = client
304 .get(format!("{base_url}/scim/v2/ServiceProviderConfig"))
305 .send()
306 .await
307 .context("ServiceProviderConfig request failed")?
308 .json()
309 .await
310 .context("ServiceProviderConfig was not valid JSON")?;
311
312 Ok(StatusReport {
313 healthy,
314 patch_supported: spc["patch"]["supported"].as_bool().unwrap_or(false),
315 filter_supported: spc["filter"]["supported"].as_bool().unwrap_or(false),
316 })
317}
318
319fn handle_test_connection(cmd: ScimTestConnectionCommand) -> Result<()> {
320 println!();
321 println!(" Testing SCIM connection to {}...", cmd.url);
322 println!();
323
324 match block_on(run_test_connection(&cmd.url, &cmd.token)) {
325 Ok(()) => {
326 println!(" All checks passed. Your SCIM server is ready.");
327 println!();
328 }
329 Err(e) => {
330 println!(" Connection test failed: {e}");
331 println!();
332 }
333 }
334 Ok(())
335}
336
337#[allow(clippy::disallowed_methods)] async fn run_test_connection(base_url: &str, token: &str) -> Result<()> {
339 let client = build_http_client()?;
340 let auth = format!("Bearer {token}");
341
342 let start = std::time::Instant::now();
343 let resp = client
344 .post(format!("{base_url}/scim/v2/Users"))
345 .header("Authorization", &auth)
346 .header("Content-Type", "application/scim+json")
347 .json(&serde_json::json!({
348 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
349 "userName": format!("test-agent-{}", chrono::Utc::now().timestamp()),
350 "externalId": format!("scim-test-{}", chrono::Utc::now().timestamp()),
351 "displayName": "SCIM Test Agent"
352 }))
353 .send()
354 .await
355 .context("POST /Users failed")?;
356 let elapsed = start.elapsed();
357
358 if resp.status().as_u16() == 201 {
359 println!(" [PASS] POST /Users -> 201 Created ({elapsed:.0?})");
360 } else {
361 println!(" [FAIL] POST /Users -> {} ({elapsed:.0?})", resp.status());
362 return Ok(());
363 }
364
365 let body: serde_json::Value = resp.json().await?;
366 let id = body["id"].as_str().unwrap_or("unknown").to_string();
367 let did = body
368 .get("urn:ietf:params:scim:schemas:extension:auths:2.0:Agent")
369 .and_then(|ext| ext["identityDid"].as_str())
370 .unwrap_or("unknown");
371 println!(" Agent: {} (userName: {})", did, body["userName"]);
372
373 probe_step(&client, &auth, "GET /Users/{id}", |c| {
374 c.get(format!("{base_url}/scim/v2/Users/{id}"))
375 })
376 .await;
377
378 let start = std::time::Instant::now();
380 let resp = client
381 .patch(format!("{base_url}/scim/v2/Users/{id}"))
382 .header("Authorization", &auth)
383 .header("Content-Type", "application/scim+json")
384 .json(&serde_json::json!({
385 "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
386 "Operations": [{"op": "replace", "path": "active", "value": false}]
387 }))
388 .send()
389 .await?;
390 println!(
391 " [{}] PATCH active=false -> {} ({:.0?})",
392 pass_fail(resp.status().is_success()),
393 resp.status(),
394 start.elapsed()
395 );
396
397 let start = std::time::Instant::now();
399 let resp = client
400 .delete(format!("{base_url}/scim/v2/Users/{id}"))
401 .header("Authorization", &auth)
402 .send()
403 .await?;
404 println!(
405 " [{}] DELETE /Users/{{id}} -> {} ({:.0?})",
406 pass_fail(resp.status().as_u16() == 204),
407 resp.status(),
408 start.elapsed()
409 );
410
411 let start = std::time::Instant::now();
413 let resp = client
414 .get(format!("{base_url}/scim/v2/Users/{id}"))
415 .header("Authorization", &auth)
416 .send()
417 .await?;
418 println!(
419 " [{}] GET /Users/{{id}} -> {} ({:.0?})",
420 pass_fail(resp.status().as_u16() == 404),
421 resp.status(),
422 start.elapsed()
423 );
424
425 println!();
426 Ok(())
427}
428
429async fn probe_step<F>(client: &reqwest::Client, auth: &str, label: &str, build: F)
430where
431 F: FnOnce(&reqwest::Client) -> reqwest::RequestBuilder,
432{
433 let start = std::time::Instant::now();
434 let outcome = build(client).header("Authorization", auth).send().await;
435 match outcome {
436 Ok(resp) => println!(
437 " [{}] {label} -> {} ({:.0?})",
438 pass_fail(resp.status().is_success()),
439 resp.status(),
440 start.elapsed()
441 ),
442 Err(e) => println!(" [FAIL] {label} -> {e}"),
443 }
444}
445
446fn pass_fail(ok: bool) -> &'static str {
447 if ok { "PASS" } else { "FAIL" }
448}
449
450fn build_http_client() -> Result<reqwest::Client> {
451 reqwest::Client::builder()
452 .connect_timeout(std::time::Duration::from_secs(10))
453 .timeout(std::time::Duration::from_secs(30))
454 .user_agent(concat!("auths/", env!("CARGO_PKG_VERSION")))
455 .min_tls_version(reqwest::tls::Version::TLS_1_2)
456 .build()
457 .context("failed to build HTTP client")
458}
459
460fn block_on<F: std::future::Future>(fut: F) -> F::Output {
462 #[allow(clippy::expect_used)] let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
464 rt.block_on(fut)
465}
466
467fn serve(config: ServeConfig) -> Result<()> {
469 block_on(run(config))
470}
471
472fn generate_token_b64() -> String {
473 use base64::Engine;
474 let mut bytes = [0u8; 32];
475 #[allow(clippy::expect_used)] ring::rand::SystemRandom::new()
477 .fill(&mut bytes)
478 .expect("random bytes");
479 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
480}
481
482impl ExecutableCommand for ScimCommand {
483 fn execute(&self, ctx: &CliConfig) -> Result<()> {
484 handle_scim(self.clone(), ctx)
485 }
486}
487
488use ring::rand::SecureRandom;