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