Skip to main content

auths_cli/commands/device/
authorization.rs

1use anyhow::{Context, Result, anyhow};
2use clap::{Args, Subcommand};
3use log::warn;
4use serde::Serialize;
5use serde_json::Value;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use auths_core::config::EnvironmentConfig;
11use auths_core::signing::{PassphraseProvider, UnifiedPassphraseProvider};
12use auths_core::storage::keychain::KeyAlias;
13use auths_id::attestation::group::AttestationGroup;
14use auths_id::identity::helpers::ManagedIdentity;
15use auths_id::storage::attestation::AttestationSource;
16use auths_id::storage::identity::IdentityStorage;
17use auths_id::storage::layout::{self, StorageLayoutConfig};
18use auths_storage::git::{
19    GitRegistryBackend, RegistryAttestationStorage, RegistryConfig, RegistryIdentityStorage,
20};
21use chrono::Utc;
22
23use crate::commands::registry_overrides::RegistryOverrides;
24use crate::factories::storage::build_auths_context;
25use crate::ux::format::{JsonResponse, is_json_mode};
26
27#[derive(Serialize)]
28struct DeviceEntry {
29    id: String,
30    status: String,
31    public_key: String,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    created_at: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    expires_at: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    note: Option<String>,
38}
39
40#[derive(Args, Debug, Clone)]
41#[command(about = "Manage device authorizations within an identity repository.")]
42pub struct DeviceCommand {
43    #[command(subcommand)]
44    pub command: DeviceSubcommand,
45
46    #[command(flatten)]
47    pub overrides: RegistryOverrides,
48}
49
50#[derive(Subcommand, Debug, Clone)]
51pub enum DeviceSubcommand {
52    /// List all authorized devices for the current identity.
53    List {
54        /// Include devices with revoked or expired authorizations in the output.
55        #[arg(
56            long,
57            help = "Include devices with revoked or expired authorizations in the output."
58        )]
59        include_revoked: bool,
60    },
61
62    /// Authorize a new device to act on behalf of the identity.
63    #[command(visible_alias = "add")]
64    Link {
65        #[arg(
66            long,
67            visible_alias = "ika",
68            help = "Local alias of the *identity's* key (used for signing)."
69        )]
70        identity_key_alias: String,
71
72        #[arg(
73            long,
74            visible_alias = "dka",
75            help = "Local alias of the *new device's* key (must be imported first)."
76        )]
77        device_key_alias: String,
78
79        #[arg(
80            long,
81            visible_alias = "device",
82            help = "Identity ID of the new device being authorized (must match device-key-alias)."
83        )]
84        device_did: String,
85
86        #[arg(
87            long,
88            value_name = "PAYLOAD_PATH",
89            help = "Optional path to a JSON file containing arbitrary payload data for the authorization."
90        )]
91        payload: Option<PathBuf>,
92
93        #[arg(
94            long,
95            value_name = "SCHEMA_PATH",
96            help = "Optional path to a JSON schema for validating the payload (experimental)."
97        )]
98        schema: Option<PathBuf>,
99
100        #[arg(
101            long,
102            visible_alias = "days",
103            value_name = "DAYS",
104            help = "Optional number of days until this device authorization expires."
105        )]
106        expires_in_days: Option<i64>,
107
108        #[arg(
109            long,
110            help = "Optional description/note for this device authorization."
111        )]
112        note: Option<String>,
113
114        #[arg(
115            long,
116            value_delimiter = ',',
117            help = "Permissions to grant this device (comma-separated)"
118        )]
119        capabilities: Option<Vec<String>>,
120    },
121
122    /// Revoke an existing device authorization using the identity key.
123    Revoke {
124        #[arg(
125            long,
126            visible_alias = "device",
127            help = "Identity ID of the device authorization to revoke."
128        )]
129        device_did: String,
130
131        #[arg(
132            long,
133            help = "Local alias of the *identity's* key (required to authorize revocation)."
134        )]
135        identity_key_alias: String,
136
137        #[arg(long, help = "Optional note explaining the revocation.")]
138        note: Option<String>,
139
140        #[arg(long, help = "Preview actions without making changes.")]
141        dry_run: bool,
142    },
143
144    /// Resolve a device DID to its controller identity DID.
145    Resolve {
146        #[arg(
147            long,
148            visible_alias = "device",
149            help = "The device DID to resolve (e.g. did:key:z6Mk...)."
150        )]
151        device_did: String,
152    },
153
154    /// Link devices to your identity via QR code or short code.
155    Pair(super::pair::PairCommand),
156
157    /// Verify device authorization signatures (attestation).
158    #[command(name = "verify")]
159    VerifyAttestation(super::verify_attestation::VerifyCommand),
160
161    /// Extend the expiration date of an existing device authorization.
162    Extend {
163        #[arg(
164            long,
165            visible_alias = "device",
166            help = "Identity ID of the device authorization to extend."
167        )]
168        device_did: String,
169
170        #[arg(
171            long = "expires-in-days",
172            visible_alias = "days",
173            value_name = "DAYS",
174            help = "Number of days to extend the expiration by (from now)."
175        )]
176        expires_in_days: i64,
177
178        #[arg(
179            long = "identity-key-alias",
180            visible_alias = "ika",
181            help = "Local alias of the *identity's* key (required for re-signing)."
182        )]
183        identity_key_alias: String,
184
185        #[arg(
186            long = "device-key-alias",
187            visible_alias = "dka",
188            help = "Local alias of the *device's* key (required for re-signing)."
189        )]
190        device_key_alias: String,
191    },
192}
193
194#[allow(clippy::too_many_arguments)]
195pub fn handle_device(
196    cmd: DeviceCommand,
197    repo_opt: Option<PathBuf>,
198    identity_ref_override: Option<String>,
199    identity_blob_name_override: Option<String>,
200    attestation_prefix_override: Option<String>,
201    attestation_blob_name_override: Option<String>,
202    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
203    env_config: &EnvironmentConfig,
204) -> Result<()> {
205    let repo_path = layout::resolve_repo_path(repo_opt)?;
206
207    let mut config = StorageLayoutConfig::default();
208    if let Some(identity_ref) = identity_ref_override {
209        config.identity_ref = identity_ref.into();
210    }
211    if let Some(blob_name) = identity_blob_name_override {
212        config.identity_blob_name = blob_name.into();
213    }
214    if let Some(prefix) = attestation_prefix_override {
215        config.device_attestation_prefix = prefix.into();
216    }
217    if let Some(blob_name) = attestation_blob_name_override {
218        config.attestation_blob_name = blob_name.into();
219    }
220
221    match cmd.command {
222        DeviceSubcommand::List { include_revoked } => {
223            list_devices(&repo_path, &config, include_revoked)
224        }
225        DeviceSubcommand::Resolve { device_did } => resolve_device(&repo_path, &device_did),
226        DeviceSubcommand::Pair(pair_cmd) => super::pair::handle_pair(pair_cmd, env_config),
227        DeviceSubcommand::VerifyAttestation(verify_cmd) => {
228            let rt = tokio::runtime::Runtime::new()?;
229            rt.block_on(super::verify_attestation::handle_verify(verify_cmd))
230        }
231        DeviceSubcommand::Link {
232            identity_key_alias,
233            device_key_alias,
234            device_did,
235            payload: payload_path_opt,
236            schema: schema_path_opt,
237            expires_in_days,
238            note,
239            capabilities,
240        } => {
241            let payload = read_payload_file(payload_path_opt.as_deref())?;
242            validate_payload_schema(schema_path_opt.as_deref(), &payload)?;
243
244            let caps: Vec<auths_verifier::Capability> = capabilities
245                .unwrap_or_default()
246                .into_iter()
247                .filter_map(|s| auths_verifier::Capability::parse(&s).ok())
248                .collect();
249
250            let link_config = auths_sdk::types::DeviceLinkConfig {
251                identity_key_alias: KeyAlias::new_unchecked(identity_key_alias),
252                device_key_alias: Some(KeyAlias::new_unchecked(device_key_alias)),
253                device_did: Some(device_did.clone()),
254                capabilities: caps,
255                expires_in_days: expires_in_days.map(|d| d as u32),
256                note,
257                payload,
258            };
259
260            let passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync> =
261                Arc::new(UnifiedPassphraseProvider::new(passphrase_provider));
262            let ctx = build_auths_context(
263                &repo_path,
264                env_config,
265                Some(Arc::clone(&passphrase_provider)),
266            )?;
267
268            let result = auths_sdk::device::link_device(
269                link_config,
270                &ctx,
271                &auths_core::ports::clock::SystemClock,
272            )
273            .map_err(|e| anyhow!("{e}"))?;
274
275            display_link_result(&result, &device_did)
276        }
277
278        DeviceSubcommand::Revoke {
279            device_did,
280            identity_key_alias,
281            note,
282            dry_run,
283        } => {
284            if dry_run {
285                return display_dry_run_revoke(&device_did, &identity_key_alias);
286            }
287
288            let ctx = build_auths_context(
289                &repo_path,
290                env_config,
291                Some(Arc::clone(&passphrase_provider)),
292            )?;
293
294            let identity_key_alias = KeyAlias::new_unchecked(identity_key_alias);
295            auths_sdk::device::revoke_device(
296                &device_did,
297                &identity_key_alias,
298                &ctx,
299                note,
300                &auths_core::ports::clock::SystemClock,
301            )
302            .map_err(|e| anyhow!("{e}"))?;
303
304            display_revoke_result(&device_did, &repo_path)
305        }
306
307        DeviceSubcommand::Extend {
308            device_did,
309            expires_in_days,
310            identity_key_alias,
311            device_key_alias,
312        } => handle_extend(
313            &repo_path,
314            &config,
315            &device_did,
316            expires_in_days,
317            &identity_key_alias,
318            &device_key_alias,
319            passphrase_provider,
320            env_config,
321        ),
322    }
323}
324
325fn display_link_result(
326    result: &auths_sdk::result::DeviceLinkResult,
327    device_did: &str,
328) -> Result<()> {
329    println!(
330        "\nāœ… Successfully linked device {} (attestation: {})",
331        device_did, result.attestation_id
332    );
333    Ok(())
334}
335
336fn display_dry_run_revoke(device_did: &str, identity_key_alias: &str) -> Result<()> {
337    if is_json_mode() {
338        JsonResponse::success(
339            "device revoke",
340            &serde_json::json!({
341                "dry_run": true,
342                "device_did": device_did,
343                "identity_key_alias": identity_key_alias,
344                "actions": [
345                    "Revoke device authorization",
346                    "Create signed revocation attestation",
347                    "Store revocation in Git repository"
348                ]
349            }),
350        )
351        .print()
352        .map_err(|e| anyhow!("{e}"))
353    } else {
354        let out = crate::ux::format::Output::new();
355        out.print_info("Dry run mode — no changes will be made");
356        out.newline();
357        out.println("Would perform the following actions:");
358        out.println(&format!(
359            "  1. Revoke device authorization for {}",
360            device_did
361        ));
362        out.println("  2. Create signed revocation attestation");
363        out.println("  3. Store revocation in Git repository");
364        Ok(())
365    }
366}
367
368fn display_revoke_result(device_did: &str, repo_path: &Path) -> Result<()> {
369    let identity_storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
370    let identity: ManagedIdentity = identity_storage
371        .load_identity()
372        .context("Failed to load identity")?;
373
374    println!(
375        "\nāœ… Successfully revoked device {} for identity {}",
376        device_did, identity.controller_did
377    );
378    Ok(())
379}
380
381fn read_payload_file(path: Option<&Path>) -> Result<Option<Value>> {
382    match path {
383        Some(p) => {
384            let content = fs::read_to_string(p)
385                .with_context(|| format!("Failed to read payload file {:?}", p))?;
386            let value: Value = serde_json::from_str(&content)
387                .with_context(|| format!("Failed to parse JSON from payload file {:?}", p))?;
388            Ok(Some(value))
389        }
390        None => Ok(None),
391    }
392}
393
394fn validate_payload_schema(schema_path: Option<&Path>, payload: &Option<Value>) -> Result<()> {
395    match (schema_path, payload) {
396        (Some(schema_path), Some(payload_val)) => {
397            let schema_content = fs::read_to_string(schema_path)
398                .with_context(|| format!("Failed to read schema file {:?}", schema_path))?;
399            let schema_json: serde_json::Value = serde_json::from_str(&schema_content)
400                .with_context(|| format!("Failed to parse JSON schema from {:?}", schema_path))?;
401            let validator = jsonschema::validator_for(&schema_json)
402                .map_err(|e| anyhow!("Invalid JSON schema in {:?}: {}", schema_path, e))?;
403            let errors: Vec<String> = validator
404                .iter_errors(payload_val)
405                .map(|e| format!("  - {}", e))
406                .collect();
407            if !errors.is_empty() {
408                return Err(anyhow!(
409                    "Payload does not conform to schema:\n{}",
410                    errors.join("\n")
411                ));
412            }
413            Ok(())
414        }
415        (Some(_), None) => {
416            warn!("--schema specified but no --payload provided; skipping validation.");
417            Ok(())
418        }
419        _ => Ok(()),
420    }
421}
422
423#[allow(clippy::too_many_arguments)]
424fn handle_extend(
425    repo_path: &Path,
426    _config: &StorageLayoutConfig,
427    device_did: &str,
428    days: i64,
429    identity_key_alias: &str,
430    device_key_alias: &str,
431    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
432    env_config: &EnvironmentConfig,
433) -> Result<()> {
434    let config = auths_sdk::types::DeviceExtensionConfig {
435        repo_path: repo_path.to_path_buf(),
436        device_did: device_did.to_string(),
437        days: days as u32,
438        identity_key_alias: KeyAlias::new_unchecked(identity_key_alias),
439        device_key_alias: Some(KeyAlias::new_unchecked(device_key_alias)),
440    };
441    let ctx = build_auths_context(repo_path, env_config, Some(passphrase_provider))?;
442
443    let result =
444        auths_sdk::device::extend_device(config, &ctx, &auths_core::ports::clock::SystemClock)
445            .with_context(|| {
446                format!("Failed to extend device authorization for '{}'", device_did)
447            })?;
448
449    println!(
450        "Successfully extended expiration for {} to {}",
451        result.device_did,
452        result.new_expires_at.date_naive()
453    );
454    Ok(())
455}
456
457fn resolve_device(repo_path: &Path, device_did_str: &str) -> Result<()> {
458    let attestation_storage = RegistryAttestationStorage::new(repo_path.to_path_buf());
459    let device_did = auths_verifier::types::DeviceDID::new(device_did_str);
460    let attestations = attestation_storage
461        .load_attestations_for_device(&device_did)
462        .with_context(|| format!("Failed to load attestations for device {device_did_str}"))?;
463
464    let latest = attestations
465        .last()
466        .ok_or_else(|| anyhow!("No attestation found for device {device_did_str}"))?;
467
468    println!("{}", latest.issuer);
469    Ok(())
470}
471
472fn list_devices(
473    repo_path: &Path,
474    _config: &StorageLayoutConfig,
475    include_revoked: bool,
476) -> Result<()> {
477    let identity_storage = RegistryIdentityStorage::new(repo_path.to_path_buf());
478    let attestation_storage = RegistryAttestationStorage::new(repo_path.to_path_buf());
479    let backend = Arc::new(GitRegistryBackend::from_config_unchecked(
480        RegistryConfig::single_tenant(repo_path),
481    )) as Arc<dyn auths_id::ports::registry::RegistryBackend + Send + Sync>;
482    let resolver = auths_id::identity::resolve::RegistryDidResolver::new(backend);
483
484    let identity: ManagedIdentity = identity_storage
485        .load_identity()
486        .with_context(|| format!("Failed to load identity from {:?}", repo_path))?;
487
488    let attestations = attestation_storage
489        .load_all_attestations()
490        .context("Could not load device attestations")?;
491
492    let grouped = AttestationGroup::from_list(attestations);
493
494    let mut entries: Vec<DeviceEntry> = Vec::new();
495    for (device_did_str, att_entries) in grouped.by_device.iter() {
496        let latest = att_entries
497            .last()
498            .expect("Grouped attestations should not be empty");
499
500        let verification_result = auths_id::attestation::verify::verify_with_resolver(
501            chrono::Utc::now(),
502            &resolver,
503            latest,
504            None,
505        );
506
507        let status_string = match verification_result {
508            Ok(()) => {
509                if latest.is_revoked() {
510                    "revoked".to_string()
511                } else if let Some(expiry) = latest.expires_at {
512                    if Utc::now() > expiry {
513                        "expired".to_string()
514                    } else {
515                        format!("active (expires {})", expiry.date_naive())
516                    }
517                } else {
518                    "active".to_string()
519                }
520            }
521            Err(err) => {
522                let err_msg = err.to_string().to_lowercase();
523                if err_msg.contains("revoked") {
524                    format!(
525                        "revoked{}",
526                        latest
527                            .timestamp
528                            .map(|ts| format!(" ({})", ts.date_naive()))
529                            .unwrap_or_default()
530                    )
531                } else if err_msg.contains("expired") {
532                    format!(
533                        "expired{}",
534                        latest
535                            .expires_at
536                            .map(|ts| format!(" ({})", ts.date_naive()))
537                            .unwrap_or_default()
538                    )
539                } else {
540                    format!("invalid ({})", err)
541                }
542            }
543        };
544
545        let is_inactive = latest.is_revoked() || latest.expires_at.is_some_and(|e| Utc::now() > e);
546        if !include_revoked && is_inactive {
547            continue;
548        }
549
550        entries.push(DeviceEntry {
551            id: device_did_str.clone(),
552            status: status_string,
553            public_key: hex::encode(latest.device_public_key.as_bytes()),
554            created_at: latest.timestamp.map(|ts| ts.to_rfc3339()),
555            expires_at: latest.expires_at.map(|ts| ts.to_rfc3339()),
556            note: latest.note.clone().filter(|n| !n.is_empty()),
557        });
558    }
559
560    if is_json_mode() {
561        return JsonResponse::success(
562            "device list",
563            &serde_json::json!({
564                "identity": identity.controller_did.to_string(),
565                "devices": entries,
566            }),
567        )
568        .print()
569        .map_err(|e| anyhow!("{e}"));
570    }
571
572    println!("Devices for identity: {}", identity.controller_did);
573    if entries.is_empty() {
574        if include_revoked {
575            println!("  No authorized devices found.");
576        } else {
577            println!("  (No active devices. Use --include-revoked to see all.)");
578        }
579        return Ok(());
580    }
581    for (i, entry) in entries.iter().enumerate() {
582        println!("{:>2}. {}   {}", i + 1, entry.id, entry.status);
583        if let Some(note) = &entry.note {
584            println!("    Note: {}", note);
585        }
586    }
587    Ok(())
588}