Skip to main content

stack_auth/
device_client.rs

1//! Post-login device client provisioning.
2//!
3//! After a device-code login, the caller must create a client in ZeroKMS and
4//! persist the resulting secret key to disk. This module provides the
5//! orchestration logic so that any consumer (not just the CLI) can perform
6//! this step.
7
8use 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
23// ---------------------------------------------------------------------------
24// Secret key file (output)
25// ---------------------------------------------------------------------------
26
27const SECRET_KEY_FILENAME: &str = "secretkey.json";
28const SECRET_KEY_MODE: u32 = 0o600;
29
30/// The on-disk shape of `secretkey.json`.
31///
32/// Must stay in sync with `cipherstash_client::zerokms::SecretKey` which
33/// deserializes this file. If that type moves to a shared crate, replace
34/// this with a re-export.
35#[derive(serde::Serialize)]
36struct SecretKeyFile {
37    client_id: Uuid,
38    client_key: ViturKeyMaterial,
39}
40
41// ---------------------------------------------------------------------------
42// Error type
43// ---------------------------------------------------------------------------
44
45/// Errors that can occur during device client provisioning.
46#[derive(Debug, thiserror::Error)]
47pub enum DeviceClientError {
48    /// The profile store could not load or create required data.
49    #[error("Profile error: {0}")]
50    Profile(#[from] stack_profile::ProfileError),
51
52    /// Authentication token could not be loaded or decoded.
53    #[error("Auth error: {0}")]
54    Auth(#[from] crate::AuthError),
55
56    /// The HTTP request to ZeroKMS failed.
57    #[error("ZeroKMS request failed: {0}")]
58    Request(#[from] reqwest::Error),
59
60    /// ZeroKMS returned a non-success, non-conflict status.
61    #[error("ZeroKMS returned {status}: {body}")]
62    Server { status: u16, body: String },
63
64    /// Failed to construct the ZeroKMS endpoint URL.
65    #[error("Invalid ZeroKMS URL: {0}")]
66    InvalidUrl(#[from] url::ParseError),
67}
68
69// ---------------------------------------------------------------------------
70// Public API
71// ---------------------------------------------------------------------------
72
73/// Provision a device client after login.
74///
75/// Loads the auth token and device identity from disk, creates a client in
76/// ZeroKMS (on the workspace's default keyset), and persists the resulting
77/// secret key to the profile store.
78///
79/// If the secret key already exists on disk, or the server returns 409
80/// (conflict), this is a no-op.
81pub 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    // DeviceIdentity is NOT workspace-scoped, so this reads from the root.
94    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        // Another client was already provisioned server-side.
116        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// ---------------------------------------------------------------------------
141// Tests
142// ---------------------------------------------------------------------------
143
144#[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        // Pre-populate secretkey.json in the workspace directory
252        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        // No mock server needed — the HTTP call should never happen.
262        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}