stack_auth/
device_client.rs1use stack_profile::{DeviceIdentity, ProfileStore};
9use uuid::Uuid;
10use zerokms_protocol::{CreateClientRequest, CreateClientResponse, ViturKeyMaterial, ViturRequest};
11
12use crate::{ensure_trailing_slash, http_client, ServiceToken, Token};
13
14fn user_agent() -> String {
15 format!(
16 "stack-auth/{} ({} {})",
17 env!("CARGO_PKG_VERSION"),
18 std::env::consts::OS,
19 std::env::consts::ARCH,
20 )
21}
22
23const SECRET_KEY_FILENAME: &str = "secretkey.json";
28const SECRET_KEY_MODE: u32 = 0o600;
29
30#[derive(serde::Serialize)]
36struct SecretKeyFile {
37 client_id: Uuid,
38 client_key: ViturKeyMaterial,
39}
40
41#[derive(Debug, thiserror::Error)]
47pub enum DeviceClientError {
48 #[error("Profile error: {0}")]
50 Profile(#[from] stack_profile::ProfileError),
51
52 #[error("Auth error: {0}")]
54 Auth(#[from] crate::AuthError),
55
56 #[error("ZeroKMS request failed: {0}")]
58 Request(#[from] reqwest::Error),
59
60 #[error("ZeroKMS returned {status}: {body}")]
62 Server { status: u16, body: String },
63
64 #[error("Invalid ZeroKMS URL: {0}")]
66 InvalidUrl(#[from] url::ParseError),
67}
68
69pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClientError> {
82 let ws_store = store.current_workspace_store()?;
83
84 if ws_store.exists(SECRET_KEY_FILENAME) {
85 tracing::debug!("secret key already exists, skipping provisioning");
86 return Ok(());
87 }
88
89 let token: Token = ws_store.load_profile()?;
90 let service_token = ServiceToken::new(token.access_token().clone());
91 let zerokms_url = ensure_trailing_slash(service_token.zerokms_url()?);
92
93 let identity = DeviceIdentity::load_or_create(store)?;
95
96 let request = CreateClientRequest {
97 keyset_id: None,
98 name: (&identity.device_name).into(),
99 description: (&identity.device_name).into(),
100 };
101
102 let url = zerokms_url.join(CreateClientRequest::ENDPOINT)?;
103
104 let response = http_client()
105 .post(url)
106 .header(reqwest::header::USER_AGENT, user_agent())
107 .bearer_auth(service_token.as_str())
108 .json(&request)
109 .send()
110 .await?;
111
112 let status = response.status();
113
114 if status == reqwest::StatusCode::CONFLICT {
115 tracing::debug!("device client already exists, skipping");
117 return Ok(());
118 }
119
120 if !status.is_success() {
121 let body = response.text().await.unwrap_or_default();
122 return Err(DeviceClientError::Server {
123 status: status.as_u16(),
124 body,
125 });
126 }
127
128 let created: CreateClientResponse = response.json().await?;
129
130 let secret_key = SecretKeyFile {
131 client_id: created.id,
132 client_key: created.client_key,
133 };
134
135 ws_store.save_with_mode(SECRET_KEY_FILENAME, &secret_key, SECRET_KEY_MODE)?;
136
137 Ok(())
138}
139
140#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::SecretToken;
148 use mocktail::prelude::*;
149 use tempfile::TempDir;
150
151 fn make_test_jwt(zerokms_url: impl std::fmt::Display) -> String {
152 use jsonwebtoken::{encode, EncodingKey, Header};
153 use std::time::{SystemTime, UNIX_EPOCH};
154
155 let zerokms_url = zerokms_url.to_string();
156 let now = SystemTime::now()
157 .duration_since(UNIX_EPOCH)
158 .unwrap()
159 .as_secs();
160
161 let claims = serde_json::json!({
162 "iss": "https://cts.example.com/",
163 "sub": "CS|test-user",
164 "aud": "legacy-aud-value",
165 "iat": now,
166 "exp": now + 3600,
167 "workspace": "ZVATKW3VHMFG27DY",
168 "scope": "",
169 "services": {
170 "zerokms": zerokms_url,
171 },
172 });
173
174 encode(
175 &Header::default(),
176 &claims,
177 &EncodingKey::from_secret(b"test-secret"),
178 )
179 .unwrap()
180 }
181
182 const TEST_WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY";
183
184 fn save_test_token(store: &ProfileStore, access_token: &str) {
185 use std::time::{SystemTime, UNIX_EPOCH};
186
187 let now = SystemTime::now()
188 .duration_since(UNIX_EPOCH)
189 .unwrap()
190 .as_secs();
191
192 let token = Token {
193 access_token: SecretToken::new(access_token),
194 refresh_token: None,
195 token_type: "Bearer".into(),
196 expires_at: now + 3600,
197 region: None,
198 client_id: None,
199 device_instance_id: None,
200 };
201 store.init_workspace(TEST_WORKSPACE_ID).unwrap();
202 let ws_store = store.current_workspace_store().unwrap();
203 ws_store.save_profile(&token).unwrap();
204 }
205
206 fn client_response_json() -> serde_json::Value {
207 serde_json::json!({
208 "id": "00000000-0000-0000-0000-000000000001",
209 "dataset_id": "00000000-0000-0000-0000-000000000099",
210 "name": "test-device",
211 "description": "test-device",
212 "client_key": "dGVzdC1rZXktbWF0ZXJpYWw="
213 })
214 }
215
216 async fn start_server(mocks: MockSet) -> MockServer {
217 let server = MockServer::new_http("device-client-test").with_mocks(mocks);
218 server.start().await.unwrap();
219 server
220 }
221
222 #[tokio::test]
223 async fn provisions_and_saves_secret_key() {
224 let dir = TempDir::new().unwrap();
225 let store = ProfileStore::new(dir.path());
226
227 let mut mocks = MockSet::new();
228 mocks.mock(|when, then| {
229 when.post().path("/create-client");
230 then.json(client_response_json());
231 });
232 let server = start_server(mocks).await;
233
234 let jwt = make_test_jwt(server.url("/"));
235 save_test_token(&store, &jwt);
236
237 bind_client_device(&store).await.unwrap();
238
239 let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap();
240 let saved: serde_json::Value = ws_store.load(SECRET_KEY_FILENAME).unwrap();
241 assert_eq!(saved["client_id"], "00000000-0000-0000-0000-000000000001");
242 assert_eq!(saved["client_key"], "dGVzdC1rZXktbWF0ZXJpYWw=");
243 }
244
245 #[tokio::test]
246 async fn skips_when_secret_key_exists() {
247 let dir = TempDir::new().unwrap();
248 let store = ProfileStore::new(dir.path());
249 store.init_workspace(TEST_WORKSPACE_ID).unwrap();
250
251 let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap();
253 ws_store
254 .save_with_mode(
255 SECRET_KEY_FILENAME,
256 &serde_json::json!({"client_id": "old", "client_key": "old"}),
257 SECRET_KEY_MODE,
258 )
259 .unwrap();
260
261 bind_client_device(&store).await.unwrap();
263
264 let saved: serde_json::Value = ws_store.load(SECRET_KEY_FILENAME).unwrap();
265 assert_eq!(
266 saved["client_id"], "old",
267 "should not overwrite existing key"
268 );
269 }
270
271 #[tokio::test]
272 async fn no_op_on_conflict() {
273 let dir = TempDir::new().unwrap();
274 let store = ProfileStore::new(dir.path());
275
276 let mut mocks = MockSet::new();
277 mocks.mock(|when, then| {
278 when.post().path("/create-client");
279 then.status(reqwest::StatusCode::CONFLICT)
280 .json(serde_json::json!({"error": "conflict"}));
281 });
282 let server = start_server(mocks).await;
283
284 let jwt = make_test_jwt(server.url("/"));
285 save_test_token(&store, &jwt);
286
287 bind_client_device(&store).await.unwrap();
288
289 let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap();
290 assert!(
291 !ws_store.exists(SECRET_KEY_FILENAME),
292 "should not write secret key on conflict"
293 );
294 }
295
296 #[tokio::test]
297 async fn returns_error_on_server_failure() {
298 let dir = TempDir::new().unwrap();
299 let store = ProfileStore::new(dir.path());
300
301 let mut mocks = MockSet::new();
302 mocks.mock(|when, then| {
303 when.post().path("/create-client");
304 then.status(reqwest::StatusCode::INTERNAL_SERVER_ERROR)
305 .json(serde_json::json!({"error": "internal error"}));
306 });
307 let server = start_server(mocks).await;
308
309 let jwt = make_test_jwt(server.url("/"));
310 save_test_token(&store, &jwt);
311
312 let err = bind_client_device(&store).await.unwrap_err();
313 assert!(
314 matches!(err, DeviceClientError::Server { status: 500, .. }),
315 "expected Server error, got: {err:?}"
316 );
317 }
318}