Skip to main content

onshape_client_core/
auth.rs

1//! Authentication types and logic for the Onshape API.
2//!
3//! Provides pure functions for generating authorization headers from API credentials.
4//! Supports Basic authentication and OAuth 2.0 bearer tokens.
5//! HMAC-SHA256 request signing is planned as a future enhancement.
6
7use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
8use oauth2::AccessToken;
9use schemars::JsonSchema;
10use secrecy::{ExposeSecret, SecretString};
11use serde::{Deserialize, Serialize};
12
13// ============================================================================
14// Types
15// ============================================================================
16
17/// Supported authentication methods for the Onshape API.
18///
19/// See the [Onshape API key docs](https://onshape-public.github.io/docs/auth/apikeys/)
20/// for details on each method.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
22#[serde(rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum AuthMethod {
25    /// Automatic detection: try available credentials in priority order.
26    ///
27    /// Resolution order:
28    /// 1. OAuth with tokens (client credentials + token file present)
29    /// 2. Basic auth (API key pair present)
30    /// 3. OAuth pending (client credentials but no tokens yet)
31    /// 4. Not configured
32    Auto,
33    /// HTTP Basic authentication: base64-encoded `access_key:secret_key`.
34    ///
35    /// Simplest method. Relies on HTTPS for transport security.
36    /// Onshape documents this as suitable for local testing and personal use.
37    Basic,
38    /// OAuth 2.0 bearer token authentication.
39    ///
40    /// Uses access tokens obtained through the OAuth 2.0 authorization code flow.
41    /// Tokens are stored in a local file and refreshed when expired.
42    /// Suitable for multi-user apps and team access.
43    #[serde(rename = "oauth")]
44    OAuth,
45    // Future: HMAC-SHA256 request signing.
46    // Each request is signed with a nonce and timestamp, providing replay
47    // protection and avoiding sending the secret key over the wire.
48    // See: docs/src/project/open-questions.md
49}
50
51/// API key credentials for authenticating with the Onshape API.
52pub struct Credentials {
53    /// The API access key (acts as a username/identifier).
54    pub access_key: SecretString,
55    /// The API secret key (acts as a password/signing key).
56    pub secret_key: SecretString,
57}
58
59// ============================================================================
60// Authorization Header Generation
61// ============================================================================
62
63/// Generates the value for the HTTP `Authorization` header for API-key
64/// based authentication methods (currently Basic only).
65///
66/// The returned string is wrapped in [`SecretString`] because it contains
67/// encoded credentials that should not be logged.
68///
69/// # Arguments
70///
71/// * `credentials` — The API key pair to authenticate with.
72///
73/// # Examples
74///
75/// ```
76/// use onshape_client_core::auth::{Credentials, basic_authorization_header_value};
77/// use secrecy::{ExposeSecret, SecretString};
78///
79/// let creds = Credentials {
80///     access_key: SecretString::from("my_access_key"),
81///     secret_key: SecretString::from("my_secret_key"),
82/// };
83///
84/// let header = basic_authorization_header_value(&creds);
85/// assert!(header.expose_secret().starts_with("Basic "));
86/// ```
87#[must_use]
88pub fn basic_authorization_header_value(credentials: &Credentials) -> SecretString {
89    let access = credentials.access_key.expose_secret();
90    let secret = credentials.secret_key.expose_secret();
91    let encoded = BASE64.encode(format!("{access}:{secret}"));
92    SecretString::from(format!("Basic {encoded}"))
93}
94
95/// Generates a Bearer token `Authorization` header value for OAuth 2.0.
96///
97/// Format: `Bearer <access_token>`
98///
99/// The returned string is wrapped in [`SecretString`] because it contains
100/// the access token that should not be logged.
101///
102/// # Arguments
103///
104/// * `access_token` — The OAuth 2.0 access token.
105#[must_use]
106pub fn bearer_authorization_header_value(access_token: &AccessToken) -> SecretString {
107    SecretString::from(format!("Bearer {}", access_token.secret()))
108}
109
110// ============================================================================
111// Tests
112// ============================================================================
113
114#[cfg(test)]
115#[allow(clippy::expect_used)]
116mod tests {
117    use super::*;
118
119    fn test_credentials() -> Credentials {
120        Credentials {
121            access_key: SecretString::from("my_access_key"),
122            secret_key: SecretString::from("my_secret_key"),
123        }
124    }
125
126    #[test]
127    fn basic_auth_starts_with_basic_prefix() {
128        let creds = test_credentials();
129        let header = basic_authorization_header_value(&creds);
130        assert!(header.expose_secret().starts_with("Basic "));
131    }
132
133    #[test]
134    fn basic_auth_encodes_correctly() {
135        let creds = test_credentials();
136        let header = basic_authorization_header_value(&creds);
137        let value = header.expose_secret();
138
139        // Strip "Basic " prefix and decode
140        let encoded = value
141            .strip_prefix("Basic ")
142            .expect("should have Basic prefix");
143        let decoded_bytes = BASE64.decode(encoded).expect("should be valid base64");
144        let decoded = String::from_utf8(decoded_bytes).expect("should be valid UTF-8");
145
146        assert_eq!(decoded, "my_access_key:my_secret_key");
147    }
148
149    #[test]
150    fn basic_auth_matches_known_value() {
151        // Verify against a value computed independently:
152        // echo -n "access:secret" | base64 => "YWNjZXNzOnNlY3JldA=="
153        let creds = Credentials {
154            access_key: SecretString::from("access"),
155            secret_key: SecretString::from("secret"),
156        };
157        let header = basic_authorization_header_value(&creds);
158        assert_eq!(header.expose_secret(), "Basic YWNjZXNzOnNlY3JldA==");
159    }
160
161    #[test]
162    fn basic_auth_handles_empty_keys() {
163        let creds = Credentials {
164            access_key: SecretString::from(""),
165            secret_key: SecretString::from(""),
166        };
167        let header = basic_authorization_header_value(&creds);
168        let value = header.expose_secret();
169
170        let encoded = value
171            .strip_prefix("Basic ")
172            .expect("should have Basic prefix");
173        let decoded_bytes = BASE64.decode(encoded).expect("should be valid base64");
174        let decoded = String::from_utf8(decoded_bytes).expect("should be valid UTF-8");
175
176        assert_eq!(decoded, ":");
177    }
178
179    #[test]
180    fn basic_auth_handles_special_characters() {
181        let creds = Credentials {
182            access_key: SecretString::from("key+with/special=chars"),
183            secret_key: SecretString::from("s3cr3t!@#$%^&*()"),
184        };
185        let header = basic_authorization_header_value(&creds);
186        let value = header.expose_secret();
187
188        let encoded = value
189            .strip_prefix("Basic ")
190            .expect("should have Basic prefix");
191        let decoded_bytes = BASE64.decode(encoded).expect("should be valid base64");
192        let decoded = String::from_utf8(decoded_bytes).expect("should be valid UTF-8");
193
194        assert_eq!(decoded, "key+with/special=chars:s3cr3t!@#$%^&*()");
195    }
196
197    #[test]
198    fn basic_auth_handles_colon_in_keys() {
199        // Colons in keys are allowed — the first colon separates access from secret
200        // when decoding, but for encoding it's just part of the concatenated string.
201        let creds = Credentials {
202            access_key: SecretString::from("key:with:colons"),
203            secret_key: SecretString::from("secret:too"),
204        };
205        let header = basic_authorization_header_value(&creds);
206        let value = header.expose_secret();
207
208        let encoded = value
209            .strip_prefix("Basic ")
210            .expect("should have Basic prefix");
211        let decoded_bytes = BASE64.decode(encoded).expect("should be valid base64");
212        let decoded = String::from_utf8(decoded_bytes).expect("should be valid UTF-8");
213
214        assert_eq!(decoded, "key:with:colons:secret:too");
215    }
216
217    #[test]
218    fn auth_method_serializes_to_snake_case() {
219        let json = serde_json::to_string(&AuthMethod::Basic).expect("should serialize");
220        assert_eq!(json, "\"basic\"");
221    }
222
223    #[test]
224    fn auth_method_deserializes_from_snake_case() {
225        let method: AuthMethod = serde_json::from_str("\"basic\"").expect("should deserialize");
226        assert_eq!(method, AuthMethod::Basic);
227    }
228
229    #[test]
230    fn auth_method_oauth_serializes_to_snake_case() {
231        let json = serde_json::to_string(&AuthMethod::OAuth).expect("should serialize");
232        assert_eq!(json, "\"oauth\"");
233    }
234
235    #[test]
236    fn auth_method_oauth_deserializes_from_snake_case() {
237        let method: AuthMethod = serde_json::from_str("\"oauth\"").expect("should deserialize");
238        assert_eq!(method, AuthMethod::OAuth);
239    }
240
241    #[test]
242    fn auth_method_auto_serializes_to_snake_case() {
243        let json = serde_json::to_string(&AuthMethod::Auto).expect("should serialize");
244        assert_eq!(json, "\"auto\"");
245    }
246
247    #[test]
248    fn auth_method_auto_deserializes_from_snake_case() {
249        let method: AuthMethod = serde_json::from_str("\"auto\"").expect("should deserialize");
250        assert_eq!(method, AuthMethod::Auto);
251    }
252
253    #[test]
254    fn bearer_auth_starts_with_bearer_prefix() {
255        let token = AccessToken::new("test-access-token".to_string());
256        let header = bearer_authorization_header_value(&token);
257        assert!(header.expose_secret().starts_with("Bearer "));
258    }
259
260    #[test]
261    fn bearer_auth_contains_token() {
262        let token = AccessToken::new("my-oauth-token-12345".to_string());
263        let header = bearer_authorization_header_value(&token);
264        assert_eq!(header.expose_secret(), "Bearer my-oauth-token-12345");
265    }
266
267    #[test]
268    fn bearer_auth_handles_empty_token() {
269        let token = AccessToken::new(String::new());
270        let header = bearer_authorization_header_value(&token);
271        assert_eq!(header.expose_secret(), "Bearer ");
272    }
273}