Skip to main content

auths_cli/commands/device/
authorization.rs

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