auths_sdk/workflows/platform.rs
1//! Platform identity claim workflow orchestration.
2//!
3//! Orchestrates OAuth device flow, proof publishing, and registry submission
4//! for linking platform identities (e.g. GitHub) to a controller DID.
5
6use std::time::Duration;
7
8use base64::Engine;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use auths_core::ports::platform::{
14 ClaimResponse, DeviceCodeResponse, OAuthDeviceFlowProvider, PlatformError,
15 PlatformProofPublisher, PlatformUserProfile, RegistryClaimClient, SshSigningKeyUploader,
16};
17use auths_core::signing::{SecureSigner, StorageSigner};
18use auths_core::storage::keychain::{IdentityDID, KeyAlias};
19use auths_id::storage::identity::IdentityStorage;
20
21use crate::context::AuthsContext;
22use crate::pairing::PairingError;
23
24/// Signed platform claim linking a controller DID to a platform identity.
25///
26/// Canonicalized (RFC 8785) before signing so that the Ed25519 signature
27/// can be verified by anyone using only the DID's public key.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PlatformClaim {
30 /// Claim type discriminant; always `"platform_claim"`.
31 #[serde(rename = "type")]
32 pub claim_type: String,
33 /// Platform identifier (e.g. `"github"`).
34 pub platform: String,
35 /// Username on the platform.
36 pub namespace: String,
37 /// Controller DID being linked.
38 pub did: String,
39 /// RFC 3339 timestamp of claim creation.
40 pub timestamp: String,
41 /// Base64url-encoded Ed25519 signature over the canonical unsigned JSON.
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub signature: Option<String>,
44}
45
46/// Configuration for GitHub identity claim workflow.
47///
48/// Args:
49/// * `client_id`: GitHub OAuth application client ID.
50/// * `registry_url`: Base URL of the auths registry.
51/// * `scopes`: OAuth scopes to request (e.g. `"read:user gist"`).
52pub struct GitHubClaimConfig {
53 /// GitHub OAuth application client ID.
54 pub client_id: String,
55 /// Base URL of the auths registry.
56 pub registry_url: String,
57 /// OAuth scopes to request.
58 pub scopes: String,
59}
60
61/// Create and sign a platform claim JSON string.
62///
63/// Builds the claim, canonicalizes (RFC 8785), signs with the identity key,
64/// and returns the pretty-printed signed JSON.
65///
66/// Args:
67/// * `platform`: Platform name (e.g. `"github"`).
68/// * `namespace`: Username on the platform.
69/// * `did`: Controller DID.
70/// * `key_alias`: Keychain alias for the signing key.
71/// * `ctx`: Runtime context supplying `key_storage` and `passphrase_provider`.
72/// * `now`: Current time (injected by caller — no `Utc::now()` in SDK).
73///
74/// Usage:
75/// ```ignore
76/// let claim_json = create_signed_platform_claim("github", "octocat", &did, &alias, &ctx, now)?;
77/// ```
78pub fn create_signed_platform_claim(
79 platform: &str,
80 namespace: &str,
81 did: &str,
82 key_alias: &KeyAlias,
83 ctx: &AuthsContext,
84 now: DateTime<Utc>,
85) -> Result<String, PairingError> {
86 let mut claim = PlatformClaim {
87 claim_type: "platform_claim".to_string(),
88 platform: platform.to_string(),
89 namespace: namespace.to_string(),
90 did: did.to_string(),
91 timestamp: now.to_rfc3339(),
92 signature: None,
93 };
94
95 let unsigned_json = serde_json::to_value(&claim)
96 .map_err(|e| PairingError::AttestationFailed(format!("failed to serialize claim: {e}")))?;
97 let canonical = json_canon::to_string(&unsigned_json).map_err(|e| {
98 PairingError::AttestationFailed(format!("failed to canonicalize claim: {e}"))
99 })?;
100
101 let signer = StorageSigner::new(std::sync::Arc::clone(&ctx.key_storage));
102 let signature_bytes = signer
103 .sign_with_alias(
104 key_alias,
105 ctx.passphrase_provider.as_ref(),
106 canonical.as_bytes(),
107 )
108 .map_err(|e| {
109 PairingError::AttestationFailed(format!("failed to sign platform claim: {e}"))
110 })?;
111
112 claim.signature = Some(URL_SAFE_NO_PAD.encode(&signature_bytes));
113
114 serde_json::to_string_pretty(&claim).map_err(|e| {
115 PairingError::AttestationFailed(format!("failed to serialize signed claim: {e}"))
116 })
117}
118
119/// Orchestrate GitHub identity claiming end-to-end.
120///
121/// Steps:
122/// 1. Request OAuth device code.
123/// 2. Fire `on_device_code` callback (CLI displays `user_code`, opens browser).
124/// 3. Poll for access token (RFC 8628 device flow).
125/// 4. Fetch GitHub user profile.
126/// 5. Create signed platform claim (injected `now`, no `Utc::now()` in SDK).
127/// 6. Publish claim as a GitHub Gist proof.
128/// 7. Submit claim to registry.
129///
130/// Args:
131/// * `oauth`: OAuth device flow provider.
132/// * `publisher`: Proof publisher (publishes Gist).
133/// * `registry_claim`: Registry claim client.
134/// * `ctx`: Runtime context (identity, key storage, passphrase provider).
135/// * `config`: GitHub client ID, registry URL, and OAuth scopes.
136/// * `now`: Current time (injected by caller).
137/// * `on_device_code`: Callback fired after device code is obtained; CLI shows
138/// `user_code`, opens browser, displays instructions.
139///
140/// Usage:
141/// ```ignore
142/// let response = claim_github_identity(
143/// &oauth_provider,
144/// &gist_publisher,
145/// ®istry_client,
146/// &ctx,
147/// GitHubClaimConfig { client_id: "...".into(), registry_url: "...".into(), scopes: "read:user gist".into() },
148/// Utc::now(),
149/// &|code| { open::that(&code.verification_uri).ok(); },
150/// ).await?;
151/// ```
152pub async fn claim_github_identity<
153 O: OAuthDeviceFlowProvider,
154 P: PlatformProofPublisher,
155 C: RegistryClaimClient,
156>(
157 oauth: &O,
158 publisher: &P,
159 registry_claim: &C,
160 ctx: &AuthsContext,
161 config: GitHubClaimConfig,
162 now: DateTime<Utc>,
163 on_device_code: &(dyn Fn(&DeviceCodeResponse) + Send + Sync),
164) -> Result<ClaimResponse, PlatformError> {
165 let device_code = oauth
166 .request_device_code(&config.client_id, &config.scopes)
167 .await?;
168
169 on_device_code(&device_code);
170
171 let expires_in = Duration::from_secs(device_code.expires_in);
172 let interval = Duration::from_secs(device_code.interval);
173
174 let access_token = oauth
175 .poll_for_token(
176 &config.client_id,
177 &device_code.device_code,
178 interval,
179 expires_in,
180 )
181 .await?;
182
183 let profile = oauth.fetch_user_profile(&access_token).await?;
184
185 let controller_did = crate::pairing::load_controller_did(ctx.identity_storage.as_ref())
186 .map_err(|e| PlatformError::Platform {
187 message: e.to_string(),
188 })?;
189
190 let key_alias = resolve_signing_key_alias(ctx, &controller_did)?;
191
192 let claim_json = create_signed_platform_claim(
193 "github",
194 &profile.login,
195 &controller_did,
196 &key_alias,
197 ctx,
198 now,
199 )
200 .map_err(|e| PlatformError::Platform {
201 message: e.to_string(),
202 })?;
203
204 let proof_url = publisher.publish_proof(&access_token, &claim_json).await?;
205
206 registry_claim
207 .submit_claim(&config.registry_url, &controller_did, &proof_url)
208 .await
209}
210
211/// Configuration for claiming an npm platform identity.
212pub struct NpmClaimConfig {
213 /// Registry URL to submit the claim to.
214 pub registry_url: String,
215}
216
217/// Claims an npm platform identity by verifying an npm access token.
218///
219/// Args:
220/// * `npm_username`: The verified npm username (from `HttpNpmAuthProvider::verify_token`).
221/// * `registry_claim`: Client for submitting the claim to the auths registry.
222/// * `ctx`: Auths context with identity storage and signing keys.
223/// * `config`: npm claim configuration (registry URL).
224/// * `now`: Current time for timestamp in the claim.
225///
226/// Usage:
227/// ```ignore
228/// let response = claim_npm_identity("bordumb", ®istry_client, &ctx, config, now).await?;
229/// ```
230pub async fn claim_npm_identity<C: RegistryClaimClient>(
231 npm_username: &str,
232 npm_token: &str,
233 registry_claim: &C,
234 ctx: &AuthsContext,
235 config: NpmClaimConfig,
236 now: DateTime<Utc>,
237) -> Result<ClaimResponse, PlatformError> {
238 let controller_did = crate::pairing::load_controller_did(ctx.identity_storage.as_ref())
239 .map_err(|e| PlatformError::Platform {
240 message: e.to_string(),
241 })?;
242
243 let key_alias = resolve_signing_key_alias(ctx, &controller_did)?;
244
245 let claim_json =
246 create_signed_platform_claim("npm", npm_username, &controller_did, &key_alias, ctx, now)
247 .map_err(|e| PlatformError::Platform {
248 message: e.to_string(),
249 })?;
250
251 // npm has no Gist equivalent. Encode both the npm token (for server-side
252 // verification via npm whoami) and the signed claim (for signature verification).
253 // The server detects the "npm-token:" prefix, verifies the token, then discards it.
254 let encoded_claim = URL_SAFE_NO_PAD.encode(claim_json.as_bytes());
255 let encoded_token = URL_SAFE_NO_PAD.encode(npm_token.as_bytes());
256 let proof_url = format!("npm-token:{encoded_token}:{encoded_claim}");
257
258 registry_claim
259 .submit_claim(&config.registry_url, &controller_did, &proof_url)
260 .await
261}
262
263/// Configuration for claiming a PyPI platform identity.
264pub struct PypiClaimConfig {
265 /// Registry URL to submit the claim to.
266 pub registry_url: String,
267}
268
269/// Claims a PyPI platform identity via self-reported username + signed claim.
270///
271/// SECURITY: PyPI's token verification API (/danger-api/echo) is unreliable,
272/// so we don't verify tokens. Instead, the platform claim is a self-reported
273/// username backed by a DID-signed proof. The real security check happens at
274/// namespace claim time, when the PyPI verifier checks the public pypi.org
275/// JSON API to confirm the username is a maintainer of the target package.
276///
277/// This is equivalent to the GitHub flow's trust model: the claim is signed
278/// with the device key (stored in platform keychain, not in CI), so a stolen
279/// PyPI token alone cannot produce a valid claim.
280///
281/// Args:
282/// * `pypi_username`: The user's self-reported PyPI username.
283/// * `registry_claim`: Client for submitting the claim to the auths registry.
284/// * `ctx`: Auths context with identity storage and signing keys.
285/// * `config`: PyPI claim configuration (registry URL).
286/// * `now`: Current time for timestamp in the claim.
287///
288/// Usage:
289/// ```ignore
290/// let response = claim_pypi_identity("bordumb", ®istry_client, &ctx, config, now).await?;
291/// ```
292pub async fn claim_pypi_identity<C: RegistryClaimClient>(
293 pypi_username: &str,
294 registry_claim: &C,
295 ctx: &AuthsContext,
296 config: PypiClaimConfig,
297 now: DateTime<Utc>,
298) -> Result<ClaimResponse, PlatformError> {
299 let controller_did = crate::pairing::load_controller_did(ctx.identity_storage.as_ref())
300 .map_err(|e| PlatformError::Platform {
301 message: e.to_string(),
302 })?;
303
304 let key_alias = resolve_signing_key_alias(ctx, &controller_did)?;
305
306 let claim_json =
307 create_signed_platform_claim("pypi", pypi_username, &controller_did, &key_alias, ctx, now)
308 .map_err(|e| PlatformError::Platform {
309 message: e.to_string(),
310 })?;
311
312 // PyPI's token verification API is unreliable. Submit the signed claim
313 // directly. The server verifies the Ed25519 signature but does not
314 // independently verify the username via PyPI. The real ownership check
315 // happens at namespace claim time via the public PyPI JSON API.
316 let encoded_claim = URL_SAFE_NO_PAD.encode(claim_json.as_bytes());
317 let proof_url = format!("pypi-claim:{encoded_claim}");
318
319 registry_claim
320 .submit_claim(&config.registry_url, &controller_did, &proof_url)
321 .await
322}
323
324fn resolve_signing_key_alias(
325 ctx: &AuthsContext,
326 controller_did: &str,
327) -> Result<KeyAlias, PlatformError> {
328 #[allow(clippy::disallowed_methods)]
329 // INVARIANT: controller_did comes from load_controller_did() which returns into_inner() of a validated IdentityDID from storage
330 let identity_did = IdentityDID::new_unchecked(controller_did.to_string());
331 let aliases = ctx
332 .key_storage
333 .list_aliases_for_identity(&identity_did)
334 .map_err(|e| PlatformError::Platform {
335 message: format!("failed to list key aliases: {e}"),
336 })?;
337
338 aliases
339 .into_iter()
340 .find(|a| !a.contains("--next-"))
341 .ok_or_else(|| PlatformError::Platform {
342 message: format!("no signing key found for identity {controller_did}"),
343 })
344}
345
346/// Upload the SSH signing key for the identity to GitHub.
347///
348/// Stores metadata about the uploaded key (key ID, GitHub username, timestamp)
349/// in the identity metadata for future reference and idempotency.
350///
351/// Args:
352/// * `uploader`: HTTP implementation of SSH key uploader.
353/// * `access_token`: GitHub OAuth access token with `write:ssh_signing_key` scope.
354/// * `public_key`: SSH public key in OpenSSH format (ssh-ed25519 AAAA...).
355/// * `key_alias`: Keychain alias for the device key.
356/// * `hostname`: Machine hostname for the key title.
357/// * `identity_storage`: Storage backend for persisting metadata.
358/// * `now`: Current time (injected by caller; SDK does not call Utc::now()).
359///
360/// Returns: Ok(()) on success, PlatformError on failure (non-fatal; init continues).
361///
362/// Usage:
363/// ```ignore
364/// upload_github_ssh_signing_key(
365/// &uploader,
366/// "ghu_token...",
367/// "ssh-ed25519 AAAA...",
368/// "main",
369/// "MacBook-Pro.local",
370/// &identity_storage,
371/// Utc::now(),
372/// ).await?;
373/// ```
374pub async fn upload_github_ssh_signing_key<U: SshSigningKeyUploader + ?Sized>(
375 uploader: &U,
376 access_token: &str,
377 public_key: &str,
378 key_alias: &str,
379 hostname: &str,
380 identity_storage: &(dyn IdentityStorage + Send + Sync),
381 now: DateTime<Utc>,
382) -> Result<(), PlatformError> {
383 let title = format!("auths/{key_alias} ({hostname})");
384
385 let key_id = uploader
386 .upload_signing_key(access_token, public_key, &title)
387 .await?;
388
389 // Load existing identity to get the controller DID
390 let existing = identity_storage
391 .load_identity()
392 .map_err(|e| PlatformError::Platform {
393 message: format!("failed to load identity: {e}"),
394 })?;
395
396 let metadata = serde_json::json!({
397 "github_ssh_key": {
398 "key_id": key_id,
399 "uploaded_at": now.to_rfc3339(),
400 }
401 });
402
403 identity_storage
404 .create_identity(existing.controller_did.as_ref(), Some(metadata))
405 .map_err(|e| PlatformError::Platform {
406 message: format!("failed to store SSH key metadata: {e}"),
407 })?;
408
409 Ok(())
410}
411
412/// Re-authorize with GitHub and optionally upload the SSH signing key.
413///
414/// Re-runs the OAuth device flow to obtain a fresh token with potentially
415/// new scopes, then attempts to upload the SSH signing key if provided.
416///
417/// Args:
418/// * `oauth`: OAuth device flow provider.
419/// * `uploader`: SSH key uploader.
420/// * `identity_storage`: Storage backend for identity and metadata.
421/// * `ctx`: Runtime context (key storage, passphrase provider).
422/// * `config`: GitHub OAuth client ID and registry URL.
423/// * `key_alias`: Keychain alias for the device key.
424/// * `hostname`: Machine hostname for the key title.
425/// * `public_key`: SSH public key in OpenSSH format (optional).
426/// * `now`: Current time (injected by caller).
427/// * `on_device_code`: Callback fired after device code is obtained.
428///
429/// Usage:
430/// ```ignore
431/// update_github_ssh_scopes(
432/// &oauth_provider,
433/// &uploader,
434/// &identity_storage,
435/// &ctx,
436/// &config,
437/// "main",
438/// "MacBook.local",
439/// Some("ssh-ed25519 AAAA..."),
440/// Utc::now(),
441/// &|code| { println!("Authorize at: {}", code.verification_uri); },
442/// ).await?;
443/// ```
444#[allow(clippy::too_many_arguments)]
445pub async fn update_github_ssh_scopes<
446 O: OAuthDeviceFlowProvider + ?Sized,
447 U: SshSigningKeyUploader + ?Sized,
448>(
449 oauth: &O,
450 uploader: &U,
451 identity_storage: &(dyn IdentityStorage + Send + Sync),
452 _ctx: &AuthsContext,
453 config: &GitHubClaimConfig,
454 key_alias: &str,
455 hostname: &str,
456 public_key: Option<&str>,
457 now: DateTime<Utc>,
458 on_device_code: &dyn Fn(&DeviceCodeResponse),
459) -> Result<PlatformUserProfile, PlatformError> {
460 let resp = oauth
461 .request_device_code(&config.client_id, &config.scopes)
462 .await?;
463 on_device_code(&resp);
464
465 let access_token = oauth
466 .poll_for_token(
467 &config.client_id,
468 &resp.device_code,
469 Duration::from_secs(resp.interval),
470 Duration::from_secs(resp.expires_in),
471 )
472 .await?;
473
474 let profile = oauth.fetch_user_profile(&access_token).await?;
475
476 if let Some(key) = public_key {
477 let _ = upload_github_ssh_signing_key(
478 uploader,
479 &access_token,
480 key,
481 key_alias,
482 hostname,
483 identity_storage,
484 now,
485 )
486 .await;
487 }
488
489 Ok(profile)
490}