Skip to main content

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///     &registry_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", &registry_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", &registry_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}