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