Skip to main content

auths_infra_http/namespace/
npm_verifier.rs

1//! npm registry namespace verification adapter.
2
3use async_trait::async_trait;
4use chrono::{DateTime, Duration, Utc};
5use serde::Deserialize;
6use url::Url;
7
8use auths_core::ports::namespace::{
9    Ecosystem, NamespaceOwnershipProof, NamespaceVerifier, NamespaceVerifyError, PackageName,
10    PlatformContext, VerificationChallenge, VerificationMethod,
11};
12use auths_verifier::CanonicalDid;
13
14use super::generate_verification_token;
15
16/// npm registry namespace ownership verifier.
17///
18/// Verifies package ownership by checking the `maintainers` field in the
19/// public package metadata endpoint. Falls back to matching the `repository.url`
20/// against the user's verified GitHub claim.
21///
22/// Usage:
23/// ```ignore
24/// let verifier = NpmVerifier::new();
25/// let challenge = verifier.initiate(&package, &did, &platform).await?;
26/// let proof = verifier.verify(&package, &did, &platform, &challenge).await?;
27/// ```
28pub struct NpmVerifier {
29    client: reqwest::Client,
30    base_url: Url,
31}
32
33impl NpmVerifier {
34    /// Create a new verifier targeting the production npm registry.
35    pub fn new() -> Self {
36        Self {
37            client: crate::default_http_client(),
38            // INVARIANT: hardcoded valid URL
39            #[allow(clippy::expect_used)]
40            base_url: Url::parse("https://registry.npmjs.org").expect("valid URL"),
41        }
42    }
43
44    /// Create a verifier with a custom base URL (for testing).
45    ///
46    /// Args:
47    /// * `base_url`: The base URL to use instead of `https://registry.npmjs.org`.
48    pub fn with_base_url(base_url: Url) -> Self {
49        Self {
50            client: crate::default_http_client(),
51            base_url,
52        }
53    }
54
55    fn package_url(&self, package_name: &str) -> String {
56        let encoded = urlencoding::encode(package_name);
57        format!("{}/{}", self.base_url, encoded)
58    }
59
60    async fn fetch_metadata(
61        &self,
62        package_name: &str,
63    ) -> Result<NpmPackageMetadata, NamespaceVerifyError> {
64        let url = self.package_url(package_name);
65        let resp = self
66            .client
67            .get(&url)
68            .header("Accept", "application/json")
69            .send()
70            .await
71            .map_err(|e| NamespaceVerifyError::NetworkError {
72                message: e.to_string(),
73            })?;
74
75        match resp.status().as_u16() {
76            200 => {}
77            404 => {
78                return Err(NamespaceVerifyError::PackageNotFound {
79                    ecosystem: Ecosystem::Npm,
80                    package_name: package_name.to_string(),
81                });
82            }
83            429 => {
84                return Err(NamespaceVerifyError::RateLimited {
85                    ecosystem: Ecosystem::Npm,
86                });
87            }
88            status => {
89                return Err(NamespaceVerifyError::NetworkError {
90                    message: format!("npm registry returned HTTP {status}"),
91                });
92            }
93        }
94
95        resp.json()
96            .await
97            .map_err(|e| NamespaceVerifyError::NetworkError {
98                message: format!("failed to parse npm metadata: {e}"),
99            })
100    }
101}
102
103impl Default for NpmVerifier {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109#[async_trait]
110impl NamespaceVerifier for NpmVerifier {
111    fn ecosystem(&self) -> Ecosystem {
112        Ecosystem::Npm
113    }
114
115    async fn initiate(
116        &self,
117        now: DateTime<Utc>,
118        package_name: &PackageName,
119        did: &CanonicalDid,
120        platform: &PlatformContext,
121    ) -> Result<VerificationChallenge, NamespaceVerifyError> {
122        self.fetch_metadata(package_name.as_str()).await?;
123
124        if platform.npm_username.is_none() && platform.github_username.is_none() {
125            return Err(NamespaceVerifyError::OwnershipNotConfirmed {
126                ecosystem: Ecosystem::Npm,
127                package_name: package_name.as_str().to_string(),
128            });
129        }
130
131        let token = generate_verification_token();
132        let expires_at = now + Duration::hours(1);
133
134        let identity_desc = platform
135            .npm_username
136            .as_deref()
137            .map(|u| format!("npm user '{u}'"))
138            .or_else(|| {
139                platform
140                    .github_username
141                    .as_deref()
142                    .map(|u| format!("GitHub user '{u}'"))
143            })
144            .unwrap_or_default();
145
146        Ok(VerificationChallenge {
147            ecosystem: Ecosystem::Npm,
148            package_name: package_name.clone(),
149            did: did.clone(),
150            token,
151            instructions: format!(
152                "Verify your identity ({identity_desc}) is listed as a maintainer \
153                 of npm package '{}'",
154                package_name.as_str()
155            ),
156            expires_at,
157        })
158    }
159
160    async fn verify(
161        &self,
162        now: DateTime<Utc>,
163        package_name: &PackageName,
164        _did: &CanonicalDid,
165        platform: &PlatformContext,
166        _challenge: &VerificationChallenge,
167    ) -> Result<NamespaceOwnershipProof, NamespaceVerifyError> {
168        let metadata = self.fetch_metadata(package_name.as_str()).await?;
169
170        if let Some(npm_username) = platform.npm_username.as_deref() {
171            let is_maintainer = metadata
172                .maintainers
173                .iter()
174                .any(|m| m.name.eq_ignore_ascii_case(npm_username));
175
176            if is_maintainer {
177                let package_url = self.package_url(package_name.as_str());
178                // INVARIANT: package_url is built from a valid base_url
179                #[allow(clippy::expect_used)]
180                let proof_url = Url::parse(&package_url).expect("package URL is valid");
181
182                return Ok(NamespaceOwnershipProof {
183                    ecosystem: Ecosystem::Npm,
184                    package_name: package_name.clone(),
185                    proof_url,
186                    method: VerificationMethod::ApiOwnership,
187                    verified_at: now,
188                });
189            }
190        }
191
192        if let Some(github_username) = platform.github_username.as_deref() {
193            let github_owner = metadata
194                .repository
195                .as_ref()
196                .and_then(|r| extract_github_owner(&r.url));
197
198            if let Some(owner) = github_owner
199                && owner.eq_ignore_ascii_case(github_username)
200            {
201                let package_url = self.package_url(package_name.as_str());
202                // INVARIANT: package_url is built from a valid base_url
203                #[allow(clippy::expect_used)]
204                let proof_url = Url::parse(&package_url).expect("package URL is valid");
205
206                return Ok(NamespaceOwnershipProof {
207                    ecosystem: Ecosystem::Npm,
208                    package_name: package_name.clone(),
209                    proof_url,
210                    method: VerificationMethod::ApiOwnership,
211                    verified_at: now,
212                });
213            }
214        }
215
216        Err(NamespaceVerifyError::OwnershipNotConfirmed {
217            ecosystem: Ecosystem::Npm,
218            package_name: package_name.as_str().to_string(),
219        })
220    }
221}
222
223/// Extract the GitHub owner from a repository URL.
224fn extract_github_owner(url: &str) -> Option<String> {
225    let url = url.strip_prefix("git+").unwrap_or(url);
226    let url = url.strip_suffix(".git").unwrap_or(url);
227    let parsed = Url::parse(url).ok()?;
228    if parsed.host_str() != Some("github.com") {
229        return None;
230    }
231    let segments: Vec<_> = parsed.path_segments()?.collect();
232    if segments.is_empty() || segments[0].is_empty() {
233        return None;
234    }
235    Some(segments[0].to_string())
236}
237
238#[derive(Debug, Deserialize)]
239struct NpmPackageMetadata {
240    #[serde(default)]
241    maintainers: Vec<NpmMaintainer>,
242    repository: Option<NpmRepository>,
243}
244
245#[derive(Debug, Deserialize)]
246struct NpmMaintainer {
247    name: String,
248}
249
250#[derive(Debug, Deserialize)]
251struct NpmRepository {
252    url: String,
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn extract_github_owner_standard_url() {
261        assert_eq!(
262            extract_github_owner("https://github.com/expressjs/express"),
263            Some("expressjs".to_string())
264        );
265    }
266
267    #[test]
268    fn extract_github_owner_git_plus_url() {
269        assert_eq!(
270            extract_github_owner("git+https://github.com/expressjs/express.git"),
271            Some("expressjs".to_string())
272        );
273    }
274
275    #[test]
276    fn extract_github_owner_non_github() {
277        assert_eq!(extract_github_owner("https://gitlab.com/user/repo"), None);
278    }
279
280    #[test]
281    fn extract_github_owner_empty_path() {
282        assert_eq!(extract_github_owner("https://github.com/"), None);
283    }
284
285    #[test]
286    fn parse_npm_metadata_response() {
287        let json = r#"{
288            "name": "express",
289            "maintainers": [
290                { "name": "dougwilson", "email": "doug@somethingdoug.com" },
291                { "name": "wesleytodd", "email": "wes@wesleytodd.com" }
292            ],
293            "repository": {
294                "type": "git",
295                "url": "git+https://github.com/expressjs/express.git"
296            }
297        }"#;
298        let meta: NpmPackageMetadata = serde_json::from_str(json).unwrap();
299        assert_eq!(meta.maintainers.len(), 2);
300        assert_eq!(meta.maintainers[0].name, "dougwilson");
301        assert!(meta.repository.is_some());
302    }
303
304    #[test]
305    fn parse_npm_metadata_empty_maintainers() {
306        let json = r#"{"name": "empty-pkg", "maintainers": []}"#;
307        let meta: NpmPackageMetadata = serde_json::from_str(json).unwrap();
308        assert!(meta.maintainers.is_empty());
309        assert!(meta.repository.is_none());
310    }
311
312    #[test]
313    fn parse_npm_metadata_no_maintainers_field() {
314        let json = r#"{"name": "bare-pkg"}"#;
315        let meta: NpmPackageMetadata = serde_json::from_str(json).unwrap();
316        assert!(meta.maintainers.is_empty());
317    }
318
319    #[test]
320    fn scoped_package_url_encoding() {
321        let verifier = NpmVerifier::new();
322        let url = verifier.package_url("@scope/package");
323        assert!(url.contains("%40scope%2Fpackage"));
324    }
325}