Skip to main content

harmont_cloud/
auth.rs

1//! CLI authentication flows.
2//!
3//! Two paths produce a bearer token: paste-code (headless/SSH) and loopback
4//! (browser on the same machine). Both are anonymous endpoints; construct the
5//! client with an empty token for these calls. `create_api_token` requires an
6//! already-authenticated client.
7
8use crate::{HarmontClient, Result};
9
10impl HarmontClient {
11    /// Redeem a paste code for a bearer token (headless login).
12    ///
13    /// The user obtains the paste code from the SPA (which calls the authed
14    /// `GET /api/v0/auth/cli/code` endpoint while logged in) and types it into
15    /// the CLI. Single-use, expires in 5 minutes.
16    pub async fn redeem_code(&self, code: &str) -> Result<String> {
17        let url = format!("{}/api/v0/auth/cli/redeem", self.base);
18        let resp = self.http.post(&url)
19            .json(&serde_json::json!({ "code": code }))
20            .send().await?;
21        #[derive(serde::Deserialize)]
22        struct R { token: String }
23        let r: R = self.parse_json(resp).await?;
24        Ok(r.token)
25    }
26
27    /// Claim a loopback-parked token by nonce (browser login).
28    ///
29    /// The CLI generates a random nonce, opens the browser to the SPA, and
30    /// polls this endpoint until the token appears (the SPA calls the authed
31    /// `POST /api/v0/auth/cli/transfer` endpoint). Single-use, expires in 60
32    /// seconds. Callers should retry on [`crate::HarmontError::Api`] with code
33    /// `cli_code_invalid` until the token is claimed or the window closes.
34    pub async fn claim_token(&self, nonce: &str) -> Result<String> {
35        let url = format!("{}/api/v0/auth/cli/claim", self.base);
36        let resp = self.http.post(&url)
37            .json(&serde_json::json!({ "nonce": nonce }))
38            .send().await?;
39        #[derive(serde::Deserialize)]
40        struct R { token: String }
41        let r: R = self.parse_json(resp).await?;
42        Ok(r.token)
43    }
44
45    /// Mint a personal API token (requires an authenticated client).
46    ///
47    /// The raw token is returned once and never retrievable again. Store it
48    /// securely (e.g. in the system keychain or `~/.config/harmont/token`).
49    pub async fn create_api_token(&self, description: &str) -> Result<String> {
50        let url = format!("{}/api/v0/user/api-tokens", self.base);
51        let resp = self.http.post(&url)
52            .json(&serde_json::json!({ "description": description }))
53            .send().await?;
54        #[derive(serde::Deserialize)]
55        struct R { token: String }
56        let r: R = self.parse_json(resp).await?;
57        Ok(r.token)
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use crate::HarmontClient;
64    use wiremock::matchers::{body_partial_json, method, path};
65    use wiremock::{Mock, MockServer, ResponseTemplate};
66    use serde_json::json;
67
68    #[tokio::test]
69    async fn redeem_returns_token() {
70        let server = MockServer::start().await;
71        Mock::given(method("POST"))
72            .and(path("/api/v0/auth/cli/redeem"))
73            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"token": "hm_live"})))
74            .mount(&server).await;
75        let client = HarmontClient::anonymous(server.uri());
76        let tok = client.redeem_code("ABCD-1234").await.expect("ok");
77        assert_eq!(tok, "hm_live");
78    }
79
80    #[tokio::test]
81    async fn redeem_sends_code_field() {
82        let server = MockServer::start().await;
83        Mock::given(method("POST"))
84            .and(path("/api/v0/auth/cli/redeem"))
85            .and(body_partial_json(json!({"code": "ZZZZ-9999"})))
86            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"token": "hm_abc"})))
87            .mount(&server).await;
88        let client = HarmontClient::anonymous(server.uri());
89        client.redeem_code("ZZZZ-9999").await.expect("ok");
90    }
91
92    #[tokio::test]
93    async fn redeem_invalid_code_maps_to_api_error() {
94        let server = MockServer::start().await;
95        Mock::given(method("POST"))
96            .and(path("/api/v0/auth/cli/redeem"))
97            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
98                "error": {"code": "cli_code_invalid", "message": "This CLI code is invalid, expired, or already used."}
99            })))
100            .mount(&server).await;
101        let client = HarmontClient::anonymous(server.uri());
102        let err = client.redeem_code("BAD-CODE").await.unwrap_err();
103        match err {
104            crate::HarmontError::Api { status, code, .. } => {
105                assert_eq!(status, 400);
106                assert_eq!(code, "cli_code_invalid");
107            }
108            other => panic!("expected Api error, got {other:?}"),
109        }
110    }
111
112    #[tokio::test]
113    async fn claim_returns_token() {
114        let server = MockServer::start().await;
115        Mock::given(method("POST"))
116            .and(path("/api/v0/auth/cli/claim"))
117            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"token": "hm_session"})))
118            .mount(&server).await;
119        let client = HarmontClient::anonymous(server.uri());
120        let tok = client.claim_token("my-random-nonce-xyz").await.expect("ok");
121        assert_eq!(tok, "hm_session");
122    }
123
124    #[tokio::test]
125    async fn claim_sends_nonce_field() {
126        let server = MockServer::start().await;
127        Mock::given(method("POST"))
128            .and(path("/api/v0/auth/cli/claim"))
129            .and(body_partial_json(json!({"nonce": "abc-nonce-123"})))
130            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"token": "hm_tok"})))
131            .mount(&server).await;
132        let client = HarmontClient::anonymous(server.uri());
133        client.claim_token("abc-nonce-123").await.expect("ok");
134    }
135
136    #[tokio::test]
137    async fn claim_not_yet_available_maps_to_api_error() {
138        let server = MockServer::start().await;
139        Mock::given(method("POST"))
140            .and(path("/api/v0/auth/cli/claim"))
141            .respond_with(ResponseTemplate::new(400).set_body_json(json!({
142                "error": {"code": "cli_code_invalid", "message": "No token parked for this nonce yet."}
143            })))
144            .mount(&server).await;
145        let client = HarmontClient::anonymous(server.uri());
146        let err = client.claim_token("no-match-nonce").await.unwrap_err();
147        match err {
148            crate::HarmontError::Api { status, code, .. } => {
149                assert_eq!(status, 400);
150                assert_eq!(code, "cli_code_invalid");
151            }
152            other => panic!("expected Api error, got {other:?}"),
153        }
154    }
155
156    #[tokio::test]
157    async fn create_api_token_returns_raw_token() {
158        let server = MockServer::start().await;
159        Mock::given(method("POST"))
160            .and(path("/api/v0/user/api-tokens"))
161            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
162                "token": "hm_personal_abc123",
163                "api_token": {
164                    "id": "00000000-0000-0000-0000-000000000001",
165                    "description": "my local machine",
166                    "created_at": "2026-06-04T00:00:00Z",
167                    "expires_at": null,
168                    "last_used_at": null
169                }
170            })))
171            .mount(&server).await;
172        let client = HarmontClient::with_base_url("hm_session_token", server.uri());
173        let raw = client.create_api_token("my local machine").await.expect("ok");
174        assert_eq!(raw, "hm_personal_abc123");
175    }
176
177    #[tokio::test]
178    async fn create_api_token_sends_description_field() {
179        let server = MockServer::start().await;
180        Mock::given(method("POST"))
181            .and(path("/api/v0/user/api-tokens"))
182            .and(body_partial_json(json!({"description": "laptop key"})))
183            .respond_with(ResponseTemplate::new(201).set_body_json(json!({
184                "token": "hm_xyz",
185                "api_token": {
186                    "id": "00000000-0000-0000-0000-000000000002",
187                    "description": "laptop key",
188                    "created_at": "2026-06-04T00:00:00Z",
189                    "expires_at": null,
190                    "last_used_at": null
191                }
192            })))
193            .mount(&server).await;
194        let client = HarmontClient::with_base_url("hm_session", server.uri());
195        client.create_api_token("laptop key").await.expect("ok");
196    }
197
198    #[tokio::test]
199    async fn create_api_token_unauthorized_maps_cleanly() {
200        let server = MockServer::start().await;
201        Mock::given(method("POST"))
202            .and(path("/api/v0/user/api-tokens"))
203            .respond_with(ResponseTemplate::new(401))
204            .mount(&server).await;
205        let client = HarmontClient::with_base_url("bad_token", server.uri());
206        let err = client.create_api_token("test").await.unwrap_err();
207        assert!(matches!(err, crate::HarmontError::Unauthorized));
208    }
209}