logbrew-cli 0.1.27

Public command-line interface for LogBrew.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
//! Interactive browser login and loopback callback handling.

use crate::{CliEnvironment, LoginProvider, RuntimeError};
/// Loopback callback path accepted by the CLI listener.
const CALLBACK_PATH: &str = "/callback";
/// Maximum time the CLI waits for the provider round trip.
const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
/// Maximum HTTP request header accepted by the loopback listener.
const CALLBACK_REQUEST_LIMIT: usize = 8 * 1024;
/// Lowercase hexadecimal alphabet for OAuth state encoding.
const HEX: &[u8; 16] = b"0123456789abcdef";

/// Executes interactive login or a non-mutating handoff mode.
pub(super) async fn execute_login<W: std::io::Write>(
    env: &CliEnvironment,
    provider: LoginProvider,
    should_open_browser: bool,
    json: bool,
    output: &mut W,
) -> Result<(), RuntimeError> {
    if json || !should_open_browser {
        return write_auth_handoff(env, provider, json, output);
    }
    execute_with_opener(env, provider, output, open_browser).await
}

/// Writes the legacy agent/manual handoff without binding or persisting state.
fn write_auth_handoff<W: std::io::Write>(
    env: &CliEnvironment,
    provider: LoginProvider,
    json: bool,
    output: &mut W,
) -> Result<(), RuntimeError> {
    let origin = super::normalized_api_base(env.base_url.as_str())?;
    let mut auth_url = reqwest::Url::parse(format!("{origin}/api/auth/cli/login").as_str())
        .map_err(|_| login_unavailable("the configured API URL is invalid"))?;
    let _query = auth_url
        .query_pairs_mut()
        .append_pair("provider", provider.as_str());
    if json {
        let body = serde_json::json!({
            "ok": true,
            "auth_url": auth_url.as_str(),
            "provider": provider.as_str(),
            "browser_opened": false,
            "next": "open auth_url in a browser",
        });
        writeln!(output, "{body}")?;
    } else {
        writeln!(output, "Open this URL to log in: {auth_url}")?;
        writeln!(output, "Provider: {}", provider.as_str())?;
        writeln!(output, "Browser: not opened")?;
        writeln!(output, "Next: open the URL in a browser")?;
    }
    Ok(())
}

/// Opens a URL in the user's default browser.
fn open_browser(url: &str) -> bool {
    let command = if cfg!(target_os = "macos") {
        ("open", vec![url])
    } else if cfg!(target_os = "windows") {
        ("rundll32", vec!["url.dll,FileProtocolHandler", url])
    } else {
        ("xdg-open", vec![url])
    };

    std::process::Command::new(command.0)
        .args(command.1)
        .status()
        .is_ok_and(|status| status.success())
}

/// Executes interactive login with an injectable browser opener.
pub(super) async fn execute_with_opener<W, F>(
    env: &CliEnvironment,
    provider: LoginProvider,
    output: &mut W,
    open_browser: F,
) -> Result<(), RuntimeError>
where
    W: std::io::Write,
    F: FnOnce(&str) -> bool,
{
    let Some(home) = env.home.clone() else {
        return Err(login_unavailable("a home directory is required for login"));
    };
    let origin = super::normalized_api_base(env.base_url.as_str())?;
    let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
    let callback_address = listener.local_addr()?;
    let redirect_uri = format!("http://{callback_address}{CALLBACK_PATH}");
    let state = random_state()?;
    let auth_url = build_auth_url(
        origin.as_str(),
        provider,
        redirect_uri.as_str(),
        state.as_str(),
    )?;

    if !open_browser(auth_url.as_str()) {
        return Err(login_unavailable("could not open the browser for login"));
    }
    writeln!(output, "Waiting for browser login...")?;

    let code = wait_for_callback(&listener, provider, state.as_str()).await?;
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .connect_timeout(std::time::Duration::from_secs(10))
        .build()?;
    let exchange_url = format!(
        "{}/api/auth/{}",
        origin.trim_end_matches('/'),
        provider.as_str()
    );
    let response = client
        .post(exchange_url)
        .json(&serde_json::json!({ "code": code }))
        .send()
        .await?;
    if !response.status().is_success() {
        return Err(login_unavailable("browser login could not be completed"));
    }
    let value = response
        .json::<serde_json::Value>()
        .await
        .map_err(|_| login_unavailable("browser login returned an invalid response"))?;
    let access_token = token_field(&value, "access_token")?;
    let refresh_token = token_field(&value, "refresh_token")?;
    tokio::task::spawn_blocking(move || {
        let lock = super::store::CredentialStoreLock::exclusive(home.as_path())?;
        lock.persist(
            access_token.as_str(),
            refresh_token.as_str(),
            origin.as_str(),
        )
    })
    .await
    .map_err(|_| login_unavailable("local authentication storage failed"))??;

    writeln!(output, "Logged in to LogBrew.")?;
    writeln!(output, "Next: run logbrew status")?;
    Ok(())
}

/// Builds the provider-login URL without rendering state to CLI output.
fn build_auth_url(
    base_url: &str,
    provider: LoginProvider,
    redirect_uri: &str,
    state: &str,
) -> Result<reqwest::Url, RuntimeError> {
    let mut url = reqwest::Url::parse(
        format!("{}/api/auth/cli/login", base_url.trim_end_matches('/')).as_str(),
    )
    .map_err(|_| login_unavailable("the configured API URL is invalid"))?;
    let _query = url
        .query_pairs_mut()
        .append_pair("provider", provider.as_str())
        .append_pair("redirect_uri", redirect_uri)
        .append_pair("state", state);
    Ok(url)
}

/// Creates a cryptographically random OAuth state value.
fn random_state() -> Result<String, RuntimeError> {
    let mut bytes = [0_u8; 32];
    getrandom::fill(&mut bytes)
        .map_err(|_| login_unavailable("secure login state generation failed"))?;
    let mut state = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        state.push(char::from(HEX[usize::from(byte >> 4)]));
        state.push(char::from(HEX[usize::from(byte & 0x0f)]));
    }
    Ok(state)
}

/// Waits for one matching provider callback while rejecting unrelated local requests.
async fn wait_for_callback(
    listener: &tokio::net::TcpListener,
    provider: LoginProvider,
    expected_state: &str,
) -> Result<String, RuntimeError> {
    let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT;
    loop {
        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
        if remaining.is_zero() {
            return Err(login_unavailable("browser login timed out"));
        }
        let (mut stream, _) = tokio::time::timeout(remaining, listener.accept())
            .await
            .map_err(|_| login_unavailable("browser login timed out"))??;
        match read_callback(&mut stream, provider, expected_state).await {
            Callback::Authorized(code) => {
                write_browser_response(
                    &mut stream,
                    200,
                    "Authorization received. Return to the terminal to confirm login.",
                )
                .await?;
                return Ok(code);
            }
            Callback::ProviderError => {
                write_browser_response(
                    &mut stream,
                    400,
                    "LogBrew authorization was not completed. Return to the terminal.",
                )
                .await?;
                return Err(login_unavailable("browser authorization was not completed"));
            }
            Callback::Invalid => {
                write_browser_response(&mut stream, 400, "Invalid LogBrew login callback.").await?;
            }
        }
    }
}

/// Reads and validates one bounded loopback HTTP request.
async fn read_callback(
    stream: &mut tokio::net::TcpStream,
    provider: LoginProvider,
    expected_state: &str,
) -> Callback {
    use tokio::io::AsyncReadExt as _;

    let mut request = Vec::with_capacity(1024);
    let mut chunk = [0_u8; 1024];
    while request.len() < CALLBACK_REQUEST_LIMIT {
        let Ok(Ok(read)) =
            tokio::time::timeout(std::time::Duration::from_secs(2), stream.read(&mut chunk)).await
        else {
            return Callback::Invalid;
        };
        if read == 0 {
            break;
        }
        request.extend_from_slice(&chunk[..read]);
        if request.windows(4).any(|window| window == b"\r\n\r\n") {
            break;
        }
    }
    parse_callback(request.as_slice(), provider, expected_state)
}

/// Parses the callback request line and exact public query fields.
fn parse_callback(
    request: &[u8],
    expected_provider: LoginProvider,
    expected_state: &str,
) -> Callback {
    let Ok(request) = std::str::from_utf8(request) else {
        return Callback::Invalid;
    };
    let Some(request_line) = request.lines().next() else {
        return Callback::Invalid;
    };
    let mut parts = request_line.split_whitespace();
    let (Some("GET"), Some(target), Some(version), None) =
        (parts.next(), parts.next(), parts.next(), parts.next())
    else {
        return Callback::Invalid;
    };
    if !version.starts_with("HTTP/1.") {
        return Callback::Invalid;
    }
    let Ok(url) = reqwest::Url::parse(format!("http://localhost{target}").as_str()) else {
        return Callback::Invalid;
    };
    if url.path() != CALLBACK_PATH {
        return Callback::Invalid;
    }

    let mut provider = None;
    let mut state = None;
    let mut code = None;
    let mut provider_error = None;
    for (name, value) in url.query_pairs() {
        let slot = match name.as_ref() {
            "provider" => &mut provider,
            "state" => &mut state,
            "code" => &mut code,
            "error" => &mut provider_error,
            _ => continue,
        };
        if slot.replace(value.into_owned()).is_some() {
            return Callback::Invalid;
        }
    }
    if provider.as_deref() != Some(expected_provider.as_str())
        || !state
            .as_deref()
            .is_some_and(|state| constant_time_equal(state, expected_state))
    {
        return Callback::Invalid;
    }
    if provider_error
        .as_deref()
        .is_some_and(|value| !value.trim().is_empty())
    {
        return Callback::ProviderError;
    }
    code.filter(|value| !value.trim().is_empty())
        .map_or(Callback::Invalid, Callback::Authorized)
}

/// Compares the fixed-size OAuth state without a content-dependent early exit.
fn constant_time_equal(candidate: &str, expected: &str) -> bool {
    if candidate.len() != expected.len() {
        return false;
    }
    candidate
        .bytes()
        .zip(expected.bytes())
        .fold(0_u8, |difference, (left, right)| {
            difference | (left ^ right)
        })
        == 0
}

/// Writes a minimal secret-free HTTP response to the browser.
async fn write_browser_response(
    stream: &mut tokio::net::TcpStream,
    status: u16,
    body: &str,
) -> Result<(), RuntimeError> {
    use tokio::io::AsyncWriteExt as _;

    let reason = if status == 200 { "OK" } else { "Bad Request" };
    let response = format!(
        "HTTP/1.1 {status} {reason}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
        body.len()
    );
    stream.write_all(response.as_bytes()).await?;
    stream.shutdown().await?;
    Ok(())
}

/// Extracts one non-empty token without exposing its value in errors.
fn token_field(value: &serde_json::Value, field: &str) -> Result<String, RuntimeError> {
    value
        .get(field)
        .and_then(serde_json::Value::as_str)
        .map(str::trim)
        .filter(|token| !token.is_empty())
        .map(ToOwned::to_owned)
        .ok_or_else(|| login_unavailable("browser login returned an invalid response"))
}

/// Secret-free callback classification.
enum Callback {
    /// Provider returned a one-time exchange code.
    Authorized(String),
    /// Provider returned an explicit error for the matching state.
    ProviderError,
    /// Request was malformed, duplicated, unrelated, or had the wrong state.
    Invalid,
}

/// Builds stable interactive-login recovery without provider details or credentials.
const fn login_unavailable(message: &'static str) -> RuntimeError {
    RuntimeError::Unavailable {
        message,
        next: "retry logbrew login",
    }
}

#[cfg(test)]
mod tests {
    use super::execute_with_opener;
    use crate::{CliEnvironment, LoginProvider};
    use wiremock::matchers::{body_json, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    #[tokio::test]
    async fn loopback_login_exchanges_code_and_persists_redacted_token_pair()
    -> Result<(), Box<dyn std::error::Error>> {
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/api/auth/gitlab"))
            .and(body_json(serde_json::json!({ "code": "provider-code" })))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "access_token": "saved-access",
                "refresh_token": "saved-refresh"
            })))
            .expect(1)
            .mount(&server)
            .await;
        let home = login_home("loopback-success")?;
        let env = CliEnvironment {
            base_url: server.uri(),
            token: None,
            home: Some(home.clone()),
            cwd: None,
        };
        let mut output = Vec::new();

        execute_with_opener(&env, LoginProvider::GitLab, &mut output, |auth_url| {
            let auth_url = reqwest::Url::parse(auth_url).expect("valid auth URL");
            assert_eq!(auth_url.path(), "/api/auth/cli/login");
            let query = auth_url
                .query_pairs()
                .collect::<std::collections::HashMap<_, _>>();
            assert_eq!(
                query.get("provider").map(|value| value.as_ref()),
                Some("gitlab")
            );
            let redirect_uri = query.get("redirect_uri").expect("redirect URI").to_string();
            let state = query.get("state").expect("state").to_string();
            let callback = reqwest::Url::parse(redirect_uri.as_str()).expect("callback URL");
            let _callback = tokio::spawn(async move {
                let mut wrong_state = callback.clone();
                let _query = wrong_state
                    .query_pairs_mut()
                    .append_pair("provider", "gitlab")
                    .append_pair("code", "ignored-code")
                    .append_pair("state", "wrong-state");
                let response = reqwest::get(wrong_state)
                    .await
                    .expect("wrong-state response");
                assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);

                let mut valid = callback;
                let _query = valid
                    .query_pairs_mut()
                    .append_pair("provider", "gitlab")
                    .append_pair("code", "provider-code")
                    .append_pair("state", state.as_str());
                let _response = reqwest::get(valid).await;
            });
            true
        })
        .await?;

        let text = String::from_utf8(output)?;
        assert!(text.contains("Logged in to LogBrew."));
        for secret in ["provider-code", "saved-access", "saved-refresh"] {
            assert!(!text.contains(secret));
        }
        let saved: serde_json::Value = serde_json::from_str(
            std::fs::read_to_string(home.join(".logbrew/session.json"))?.as_str(),
        )?;
        assert_eq!(saved["access_token"], "saved-access");
        assert_eq!(saved["refresh_token"], "saved-refresh");
        assert_eq!(saved["origin"], server.uri());
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            assert_eq!(
                std::fs::metadata(home.join(".logbrew"))?
                    .permissions()
                    .mode()
                    & 0o777,
                0o700
            );
            for name in ["session.json", "credentials.lock"] {
                assert_eq!(
                    std::fs::metadata(home.join(".logbrew").join(name))?
                        .permissions()
                        .mode()
                        & 0o777,
                    0o600
                );
            }
        }
        Ok(())
    }

    #[test]
    fn callback_parser_rejects_duplicate_or_mismatched_security_fields() {
        let duplicate =
            b"GET /callback?provider=github&state=expected&state=expected&code=a HTTP/1.1\r\n\r\n";
        assert!(matches!(
            super::parse_callback(duplicate, LoginProvider::GitHub, "expected"),
            super::Callback::Invalid
        ));
        let wrong_provider =
            b"GET /callback?provider=gitlab&state=expected&code=a HTTP/1.1\r\n\r\n";
        assert!(matches!(
            super::parse_callback(wrong_provider, LoginProvider::GitHub, "expected"),
            super::Callback::Invalid
        ));
    }

    /// Creates one isolated home directory for login tests.
    fn login_home(name: &str) -> Result<std::path::PathBuf, std::io::Error> {
        let home =
            std::env::temp_dir().join(format!("logbrew-cli-login-{name}-{}", std::process::id()));
        match std::fs::remove_dir_all(home.as_path()) {
            Ok(()) => {}
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(error) => return Err(error),
        }
        std::fs::create_dir_all(home.as_path())?;
        Ok(home)
    }
}