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}