Skip to main content

auths_core/ports/
platform.rs

1//! Platform claim port traits for OAuth device flow, proof publishing, and registry submission.
2
3use std::future::Future;
4use std::time::Duration;
5
6use auths_verifier::AssuranceLevel;
7use thiserror::Error;
8
9use crate::ports::network::NetworkError;
10
11/// Derives the cryptographic assurance level for a platform identity claim.
12///
13/// Args:
14/// * `platform`: Platform identifier (e.g., `"github"`, `"npm"`, `"pypi"`, `"auths"`).
15/// * `cross_verified`: Whether the claim has been strengthened by cross-platform verification
16///   (e.g., PyPI namespace verified via GitHub repo ownership).
17///
18/// Usage:
19/// ```rust
20/// # use auths_core::ports::platform::derive_assurance_level;
21/// # use auths_verifier::AssuranceLevel;
22/// assert_eq!(derive_assurance_level("github", false), AssuranceLevel::Authenticated);
23/// assert_eq!(derive_assurance_level("pypi", true), AssuranceLevel::TokenVerified);
24/// ```
25pub fn derive_assurance_level(platform: &str, cross_verified: bool) -> AssuranceLevel {
26    match platform {
27        "auths" => AssuranceLevel::Sovereign,
28        "github" => AssuranceLevel::Authenticated,
29        "npm" => AssuranceLevel::TokenVerified,
30        "pypi" if cross_verified => AssuranceLevel::TokenVerified,
31        "pypi" => AssuranceLevel::SelfAsserted,
32        _ => AssuranceLevel::SelfAsserted,
33    }
34}
35
36/// Errors from platform identity claim operations (OAuth, proof publishing, registry submission).
37///
38/// Usage:
39/// ```ignore
40/// match result {
41///     Err(PlatformError::AccessDenied) => { /* user denied */ }
42///     Err(PlatformError::ExpiredToken) => { /* device code expired, restart flow */ }
43///     Err(PlatformError::Network(e)) => { /* retry */ }
44///     Err(e) => return Err(e.into()),
45///     Ok(response) => { /* proceed */ }
46/// }
47/// ```
48#[derive(Debug, Error)]
49#[non_exhaustive]
50pub enum PlatformError {
51    /// OAuth authorization is pending — the user has not yet approved.
52    #[error("OAuth authorization pending")]
53    AuthorizationPending,
54    /// OAuth server requested slower polling.
55    #[error("OAuth slow down")]
56    SlowDown,
57    /// The user explicitly denied the OAuth authorization request.
58    #[error("OAuth access denied")]
59    AccessDenied,
60    /// The device code has expired; the flow must be restarted.
61    #[error("device code expired")]
62    ExpiredToken,
63    /// A network-level error occurred.
64    #[error("network error: {0}")]
65    Network(#[source] NetworkError),
66    /// A platform-specific error with a human-readable message.
67    #[error("platform error: {message}")]
68    Platform {
69        /// Human-readable error detail from the platform API.
70        message: String,
71    },
72}
73
74impl auths_crypto::AuthsErrorInfo for PlatformError {
75    fn error_code(&self) -> &'static str {
76        match self {
77            Self::AuthorizationPending => "AUTHS-E3801",
78            Self::SlowDown => "AUTHS-E3802",
79            Self::AccessDenied => "AUTHS-E3803",
80            Self::ExpiredToken => "AUTHS-E3804",
81            Self::Network(_) => "AUTHS-E3805",
82            Self::Platform { .. } => "AUTHS-E3806",
83        }
84    }
85
86    fn suggestion(&self) -> Option<&'static str> {
87        match self {
88            Self::AccessDenied => Some("Re-run the command and approve the authorization request"),
89            Self::ExpiredToken => Some("The device code expired — restart the flow"),
90            Self::Network(_) => Some("Check your internet connection"),
91            Self::AuthorizationPending => Some(
92                "Complete the authorization on the linked device, then the CLI will continue automatically",
93            ),
94            Self::SlowDown => {
95                Some("The authorization server is rate-limiting; the CLI will retry automatically")
96            }
97            Self::Platform { .. } => {
98                Some("A platform-specific error occurred; run `auths doctor` to diagnose")
99            }
100        }
101    }
102}
103
104/// OAuth 2.0 device authorization grant response (RFC 8628 §3.2).
105///
106/// Returned by [`OAuthDeviceFlowProvider::request_device_code`].
107/// The CLI displays `user_code` + `verification_uri` to the user,
108/// then polls with `device_code`.
109pub struct DeviceCodeResponse {
110    /// Opaque device verification code used to poll for the token.
111    pub device_code: String,
112    /// Short user-facing code to enter at `verification_uri`.
113    pub user_code: String,
114    /// URL where the user enters `user_code`.
115    pub verification_uri: String,
116    /// Duration in seconds until expiration (per RFC 6749).
117    pub expires_in: u64,
118    /// Minimum polling interval in seconds.
119    pub interval: u64,
120}
121
122/// Authenticated platform user profile returned after token exchange.
123///
124/// Returned by [`OAuthDeviceFlowProvider::fetch_user_profile`].
125pub struct PlatformUserProfile {
126    /// Platform login / username.
127    pub login: String,
128    /// Display name (optional).
129    pub name: Option<String>,
130}
131
132/// Response from the registry after submitting a platform claim.
133///
134/// Returned by [`RegistryClaimClient::submit_claim`].
135pub struct ClaimResponse {
136    /// Human-readable confirmation message from the registry.
137    pub message: String,
138}
139
140/// Two-phase OAuth 2.0 device authorization flow (RFC 8628).
141///
142/// The CLI calls [`request_device_code`](Self::request_device_code), displays the
143/// `user_code` and `verification_uri`, optionally opens a browser, then calls
144/// [`poll_for_token`](Self::poll_for_token). All presentation logic stays in the CLI.
145///
146/// Usage:
147/// ```ignore
148/// let resp = provider.request_device_code(CLIENT_ID, "read:user gist").await?;
149/// // CLI: display resp.user_code, open resp.verification_uri
150/// let token = provider.poll_for_token(CLIENT_ID, &resp.device_code, Duration::from_secs(resp.interval), expires_at).await?;
151/// let profile = provider.fetch_user_profile(&token).await?;
152/// ```
153pub trait OAuthDeviceFlowProvider: Send + Sync {
154    /// Request a device code to begin the device authorization flow.
155    ///
156    /// Args:
157    /// * `client_id`: OAuth application client ID.
158    /// * `scopes`: Space-separated OAuth scopes to request.
159    fn request_device_code(
160        &self,
161        client_id: &str,
162        scopes: &str,
163    ) -> impl Future<Output = Result<DeviceCodeResponse, PlatformError>> + Send;
164
165    /// Poll for the access token until granted, denied, or the lifetime elapses.
166    ///
167    /// Args:
168    /// * `client_id`: OAuth application client ID.
169    /// * `device_code`: Device code from [`request_device_code`](Self::request_device_code).
170    /// * `interval`: Minimum time between poll attempts.
171    /// * `expires_in`: Total lifetime of the device code (stop polling after this elapses).
172    fn poll_for_token(
173        &self,
174        client_id: &str,
175        device_code: &str,
176        interval: Duration,
177        expires_in: Duration,
178    ) -> impl Future<Output = Result<String, PlatformError>> + Send;
179
180    /// Fetch the authenticated user's profile using the access token.
181    ///
182    /// Args:
183    /// * `access_token`: OAuth access token from [`poll_for_token`](Self::poll_for_token).
184    fn fetch_user_profile(
185        &self,
186        access_token: &str,
187    ) -> impl Future<Output = Result<PlatformUserProfile, PlatformError>> + Send;
188}
189
190/// Publish a signed platform claim as a publicly readable proof artifact.
191///
192/// For GitHub: publishes a Gist. Returns the URL of the published proof.
193///
194/// Usage:
195/// ```ignore
196/// let proof_url = publisher.publish_proof(&access_token, &claim_json).await?;
197/// registry_client.submit_claim(registry, &did, &proof_url).await?;
198/// ```
199pub trait PlatformProofPublisher: Send + Sync {
200    /// Publish the claim JSON as a proof artifact and return its public URL.
201    ///
202    /// Args:
203    /// * `access_token`: OAuth access token with write permission to publish.
204    /// * `claim_json`: Canonicalized, signed JSON claim to publish.
205    fn publish_proof(
206        &self,
207        access_token: &str,
208        claim_json: &str,
209    ) -> impl Future<Output = Result<String, PlatformError>> + Send;
210}
211
212/// Submit a published platform claim to the auths registry for verification.
213///
214/// Usage:
215/// ```ignore
216/// let response = registry_client.submit_claim(registry_url, &did, &proof_url).await?;
217/// println!("{}", response.message);
218/// ```
219pub trait RegistryClaimClient: Send + Sync {
220    /// Submit a platform identity claim to the registry.
221    ///
222    /// Args:
223    /// * `registry_url`: Base URL of the auths registry.
224    /// * `did`: DID of the identity making the claim.
225    /// * `proof_url`: Public URL of the published proof artifact.
226    fn submit_claim(
227        &self,
228        registry_url: &str,
229        did: &str,
230        proof_url: &str,
231    ) -> impl Future<Output = Result<ClaimResponse, PlatformError>> + Send;
232}
233
234/// Upload an SSH signing key to a platform (e.g., GitHub) for commit verification.
235///
236/// Usage:
237/// ```ignore
238/// let key_id = uploader.upload_signing_key(
239///     &access_token,
240///     "ssh-ed25519 AAAA...",
241///     "auths/main (device-abc123 MacBook)"
242/// ).await?;
243/// println!("Uploaded key: {}", key_id);
244/// ```
245pub trait SshSigningKeyUploader: Send + Sync {
246    /// Upload an SSH signing key to GitHub.
247    ///
248    /// Args:
249    /// * `access_token`: OAuth token with `write:ssh_signing_key` scope
250    /// * `public_key`: SSH public key in OpenSSH format (ssh-ed25519 AAAA...)
251    /// * `title`: Human-readable title for the key in GitHub UI (e.g., "auths/main (MacBook)")
252    ///
253    /// Returns: Key ID from GitHub on success, or PlatformError on failure
254    fn upload_signing_key(
255        &self,
256        access_token: &str,
257        public_key: &str,
258        title: &str,
259    ) -> impl Future<Output = Result<String, PlatformError>> + Send;
260}