auths_infra_http/namespace/
npm_verifier.rs1use 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
16pub struct NpmVerifier {
29 client: reqwest::Client,
30 base_url: Url,
31}
32
33impl NpmVerifier {
34 pub fn new() -> Self {
36 Self {
37 client: crate::default_http_client(),
38 #[allow(clippy::expect_used)]
40 base_url: Url::parse("https://registry.npmjs.org").expect("valid URL"),
41 }
42 }
43
44 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 #[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 #[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
223fn 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}