Skip to main content

auths_cli/commands/id/
claim.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use anyhow::{Context, Result};
5use auths_infra_http::{
6    HttpGistPublisher, HttpGitHubOAuthProvider, HttpNpmAuthProvider, HttpRegistryClaimClient,
7};
8use auths_sdk::core_config::EnvironmentConfig;
9use auths_sdk::signing::PassphraseProvider;
10use auths_sdk::workflows::platform::{
11    GitHubClaimConfig, NpmClaimConfig, PypiClaimConfig, claim_github_identity, claim_npm_identity,
12    claim_pypi_identity,
13};
14use clap::{Parser, Subcommand};
15use console::style;
16
17use crate::factories::storage::build_auths_context;
18use crate::ux::format::{JsonResponse, is_json_mode};
19
20use super::register::DEFAULT_REGISTRY_URL;
21
22const DEFAULT_GITHUB_CLIENT_ID: &str = "Ov23lio2CiTHBjM2uIL4";
23
24#[allow(clippy::disallowed_methods)]
25fn github_client_id() -> String {
26    std::env::var("AUTHS_GITHUB_CLIENT_ID").unwrap_or_else(|_| DEFAULT_GITHUB_CLIENT_ID.to_string())
27}
28
29#[derive(Parser, Debug, Clone)]
30#[command(about = "Add a platform claim to an already-registered identity.")]
31pub struct ClaimCommand {
32    #[command(subcommand)]
33    pub platform: ClaimPlatform,
34}
35
36#[derive(Subcommand, Debug, Clone)]
37pub enum ClaimPlatform {
38    /// Link your GitHub account to your identity via OAuth device flow.
39    Github {
40        /// Registry URL to publish the claim to.
41        #[arg(long, env = "AUTHS_REGISTRY_URL", default_value = DEFAULT_REGISTRY_URL)]
42        registry: String,
43    },
44    /// Link your npm account to your identity via access token.
45    Npm {
46        /// Registry URL to publish the claim to.
47        #[arg(long, env = "AUTHS_REGISTRY_URL", default_value = DEFAULT_REGISTRY_URL)]
48        registry: String,
49    },
50    /// Link your PyPI account to your identity via API token.
51    Pypi {
52        /// Registry URL to publish the claim to.
53        #[arg(long, env = "AUTHS_REGISTRY_URL", default_value = DEFAULT_REGISTRY_URL)]
54        registry: String,
55    },
56}
57
58pub fn handle_claim(
59    cmd: &ClaimCommand,
60    repo_path: &Path,
61    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
62    env_config: &EnvironmentConfig,
63    now: chrono::DateTime<chrono::Utc>,
64) -> Result<()> {
65    let registry_url = match &cmd.platform {
66        ClaimPlatform::Github { registry }
67        | ClaimPlatform::Npm { registry }
68        | ClaimPlatform::Pypi { registry } => registry.clone(),
69    };
70
71    let ctx = build_auths_context(repo_path, env_config, Some(passphrase_provider))
72        .context("Failed to build auths context")?;
73
74    let registry_client = HttpRegistryClaimClient::new();
75
76    match &cmd.platform {
77        ClaimPlatform::Github { .. } => {
78            let oauth = HttpGitHubOAuthProvider::new();
79            let publisher = HttpGistPublisher::new();
80
81            let config = GitHubClaimConfig {
82                client_id: github_client_id(),
83                registry_url,
84                scopes: "read:user gist".to_string(),
85            };
86
87            let on_device_code = |code: &auths_sdk::ports::platform::DeviceCodeResponse| {
88                println!();
89                println!("  Copy this code: {}", style(&code.user_code).bold().cyan());
90                println!("  At: {}", style(&code.verification_uri).cyan());
91                println!();
92                println!(
93                    "  {}",
94                    style("Press 'enter' to open GitHub after copying the code above").blue()
95                );
96                let _ = std::io::stdin().read_line(&mut String::new());
97                println!();
98                if let Err(e) = open::that(&code.verification_uri) {
99                    println!(
100                        "  {}",
101                        style(format!("Could not open browser: {e}")).yellow()
102                    );
103                    println!("  Please open the URL above manually.");
104                } else {
105                    println!("  Browser opened — waiting for authorization...");
106                }
107            };
108
109            let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
110            let response = rt
111                .block_on(claim_github_identity(
112                    &oauth,
113                    &publisher,
114                    &registry_client,
115                    &ctx,
116                    config,
117                    now,
118                    &on_device_code,
119                ))
120                .map_err(anyhow::Error::from)?;
121
122            print_response(&response.message)?;
123        }
124
125        ClaimPlatform::Npm { .. } => {
126            println!();
127            println!("  {}", style("npm platform claim").bold());
128            println!();
129            println!(
130                "  Create a read-only access token at:\n  {}",
131                style("https://www.npmjs.com/settings/~/tokens").cyan()
132            );
133            println!();
134
135            let npm_token = rpassword::prompt_password("  Enter your npm access token: ")
136                .context("Failed to read npm token")?;
137
138            if npm_token.trim().is_empty() {
139                return Err(anyhow::anyhow!("npm access token cannot be empty"));
140            }
141
142            println!("  Verifying token...");
143
144            let npm_provider = HttpNpmAuthProvider::new();
145            let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
146
147            let profile = rt
148                .block_on(npm_provider.verify_token(npm_token.trim()))
149                .map_err(anyhow::Error::from)?;
150
151            println!(
152                "  {} Authenticated as {}",
153                style("✓").green(),
154                style(&profile.login).bold()
155            );
156
157            let config = NpmClaimConfig { registry_url };
158
159            let response = rt
160                .block_on(claim_npm_identity(
161                    &profile.login,
162                    npm_token.trim(),
163                    &registry_client,
164                    &ctx,
165                    config,
166                    now,
167                ))
168                .map_err(anyhow::Error::from)?;
169
170            print_response(&response.message)?;
171        }
172
173        ClaimPlatform::Pypi { .. } => {
174            println!();
175            println!("  {}", style("PyPI platform claim").bold());
176            println!();
177            println!(
178                "  Enter your PyPI username (visible at {}):",
179                style("https://pypi.org/account/").cyan()
180            );
181            println!();
182            println!(
183                "  {}",
184                style("Note: ownership is verified when you claim a package namespace,").dim()
185            );
186            println!(
187                "  {}",
188                style("not at this step. The PyPI JSON API confirms you are a maintainer.").dim()
189            );
190            println!();
191
192            print!("  PyPI username: ");
193            std::io::Write::flush(&mut std::io::stdout()).context("flush")?;
194            let mut pypi_username = String::new();
195            std::io::stdin()
196                .read_line(&mut pypi_username)
197                .context("Failed to read PyPI username")?;
198
199            let trimmed = pypi_username.trim();
200            if trimmed.is_empty() {
201                return Err(anyhow::anyhow!("PyPI username cannot be empty"));
202            }
203
204            println!("  Claiming PyPI identity as {}...", style(trimmed).bold());
205
206            let config = PypiClaimConfig { registry_url };
207
208            let rt = tokio::runtime::Runtime::new().context("failed to create async runtime")?;
209            let response = rt
210                .block_on(claim_pypi_identity(
211                    trimmed,
212                    &registry_client,
213                    &ctx,
214                    config,
215                    now,
216                ))
217                .map_err(anyhow::Error::from)?;
218
219            print_response(&response.message)?;
220        }
221    }
222
223    Ok(())
224}
225
226fn print_response(message: &str) -> Result<()> {
227    if is_json_mode() {
228        let json_resp = JsonResponse::success("id claim", message);
229        json_resp.print()?;
230    } else {
231        println!("  {}", style(message).green());
232    }
233    Ok(())
234}