trusty-common 0.49.0

Shared utilities and provider-agnostic streaming chat (ChatProvider, OllamaProvider, OpenRouter, tool-use) for trusty-* projects
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
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
//! Router-wide bearer authentication for a loopback daemon's HTTP/SSE
//! surface (#5439).
//!
//! Why: [`crate::server::origin_guard`] stops a browser page on a foreign origin from
//! issuing a WRITE and (with `same_origin_cors`) from READING a response, but
//! it is deliberately not authentication: it passes every request that sends
//! no `Origin` at all — `curl`, a reverse proxy, any local process. That is
//! the whole of #5439: a loopback bind plus an origin guard still leaves every
//! session, transcript, and mutation route open to any program running on the
//! machine. This layer adds the missing caller check, and lives beside the
//! origin guard rather than inside one daemon because the two are the same
//! defence stack applied to the same daemon family — a second copy in a leaf
//! crate is what the common-entry-point rule forbids.
//!
//! The credential itself is [`crate::daemon_token`]; read its honesty clause
//! before describing the boundary this establishes.
//!
//! What: [`DaemonAuth`] holds the expected token, the set of paths that stay
//! public, and the short-lived SSE ticket table. [`require_bearer`] is the
//! `axum::middleware::from_fn_with_state` guard: a valid
//! `Authorization: Bearer <token>` (or a valid single-use `?ticket=`) passes
//! and marks the request [`Authenticated`]; a public path passes UNMARKED;
//! everything else is `401` with an empty body.
//!
//! Two design points a reviewer should not have to reconstruct.
//!
//! **Tickets exist because `EventSource` cannot send a header.** A browser
//! opening `GET /sessions/{id}/events` has no way to attach `Authorization`,
//! and putting the durable token in the query string would write it into every
//! access log and tracing span. [`DaemonAuth::issue_ticket`] mints a
//! single-use value that expires in [`crate::server::bearer_auth::TICKET_TTL`], obtained over the
//! header-authenticated surface; a ticket in a log is spent and stale.
//!
//! **A public path passes unmarked, not authenticated.** A handler that serves
//! both audiences (`/health`) reads `Option<Extension<Authenticated>>` and
//! decides what to disclose, so the unauthenticated shape is a deliberate
//! choice at the handler rather than an exemption the middleware guesses at.
//!
//! Test: `bearer_auth_tests::*` drive a two-route router via
//! `tower::util::ServiceExt::oneshot` — missing, malformed, wrong, and correct
//! credentials; the public-path carve-out; ticket single-use and expiry.

use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use axum::extract::{Request, State};
use axum::http::{StatusCode, header::AUTHORIZATION};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};

use crate::daemon_token::{credentials_match, mint_token};

/// Query-string parameter carrying a single-use SSE ticket.
pub const TICKET_QUERY_PARAM: &str = "ticket";

/// How long an issued SSE ticket stays redeemable.
///
/// Why: long enough for a browser to mint one and open the stream in the same
/// user gesture, short enough that a ticket captured from a log or a shoulder
/// is worthless. Redemption is single-use, so this bounds only the window
/// before the intended client uses it.
pub const TICKET_TTL: Duration = Duration::from_secs(30);

/// Marker inserted into a request's extensions when it presented a valid
/// credential.
///
/// Why: lets a public-path handler tell an anonymous caller from an
/// authenticated one without re-reading the header or knowing the token.
#[derive(Clone, Copy, Debug)]
pub struct Authenticated;

/// The daemon's expected credential, its public-path carve-out, and its
/// live SSE ticket table.
///
/// Why: `Clone` is cheap (one `Arc`) because axum requires middleware state to
/// be `Clone`, and every clone must see the SAME ticket table — a ticket
/// issued against one clone has to be redeemable against another.
#[derive(Clone)]
pub struct DaemonAuth(Arc<Inner>);

struct Inner {
    token: String,
    public_paths: HashSet<String>,
    tickets: Mutex<HashMap<String, Ticket>>,
}

/// One outstanding ticket: what it may open, and when it stops being valid.
///
/// Why `path` is stored rather than the ticket standing alone: a ticket rides
/// in a URL and therefore lands in access logs and tracing spans. Unbound, a
/// ticket read from a log within [`TICKET_TTL`] buys one arbitrary
/// authenticated request — `POST /rpc` included, which is the whole method
/// surface. Bound, it buys exactly the one stream its holder already had the
/// credential to open.
struct Ticket {
    path: String,
    issued: Instant,
}

/// A credential too weak to guard anything.
///
/// Why an error rather than a silent acceptance: a daemon constructed around an
/// empty or truncated token would answer `401` to every real client while
/// authenticating a bare `Authorization: Bearer `. Refusing at construction
/// makes that a startup failure the operator sees, not a runtime hole nobody
/// does.
#[derive(Debug, thiserror::Error)]
#[error(
    "daemon credential is {got} characters; at least {} are required",
    crate::daemon_token::MIN_TOKEN_LEN
)]
pub struct WeakCredential {
    /// Length of the rejected value. The VALUE is never included — an error
    /// string reaches logs.
    pub got: usize,
}

impl DaemonAuth {
    /// Guard every path with `token`, except the exact paths in
    /// `public_paths`.
    ///
    /// Why: one constructor rather than a builder, so the guarded set is
    /// fixed before any ticket can be issued — a builder step that rebuilt the
    /// state would silently discard tickets minted against the earlier value.
    /// Fail-closed by construction: a route merged in later is guarded without
    /// anyone remembering to add it, and a daemon opts a path OUT here, once.
    /// What: rejects a `token` shorter than
    /// [`crate::daemon_token::MIN_TOKEN_LEN`] with [`WeakCredential`], so a
    /// daemon cannot start around a credential that guards nothing.
    /// `public_paths` is matched by exact string equality against
    /// `req.uri().path()` — never a prefix match, so `/health` cannot be
    /// widened into `/healthz-secrets` by a future route name.
    /// Test: `bearer_auth_tests::public_path_passes_without_a_credential`,
    /// `bearer_auth_tests::public_path_match_is_exact_not_prefix`,
    /// `bearer_auth_tests::a_weak_token_is_refused_at_construction`.
    pub fn new<I, S>(token: impl Into<String>, public_paths: I) -> Result<Self, WeakCredential>
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        let token = token.into();
        if token.len() < crate::daemon_token::MIN_TOKEN_LEN {
            return Err(WeakCredential { got: token.len() });
        }
        Ok(Self(Arc::new(Inner {
            token,
            public_paths: public_paths.into_iter().map(Into::into).collect(),
            tickets: Mutex::new(HashMap::new()),
        })))
    }

    /// Mint a single-use ticket that opens `path`, and only `path`, by `GET`,
    /// for [`TICKET_TTL`].
    ///
    /// Why the binding: see [`Ticket`]. The caller decides which paths are
    /// ticketable — this type deliberately does not know the daemon's routes,
    /// so a daemon validates the requested path against its own SSE shapes
    /// before calling here.
    /// What: mints, sweeps expired entries (here rather than on a timer, so the
    /// table cannot grow without an authenticated caller driving it), and
    /// records the bound path.
    /// Test: `bearer_auth_tests::ticket_authenticates_once_then_is_spent`,
    /// `bearer_auth_tests::a_ticket_opens_only_the_path_it_was_issued_for`.
    pub fn issue_ticket(&self, path: impl Into<String>) -> String {
        let ticket = mint_token();
        if let Ok(mut tickets) = self.0.tickets.lock() {
            let now = Instant::now();
            tickets.retain(|_, t| now.duration_since(t.issued) < TICKET_TTL);
            tickets.insert(
                ticket.clone(),
                Ticket {
                    path: path.into(),
                    issued: now,
                },
            );
        }
        ticket
    }

    /// Redeem `ticket` for a `GET` of `path`, consuming it.
    ///
    /// What: `false` for unknown, spent, expired, a non-`GET` method, or a path
    /// other than the one the ticket was issued for. The entry is removed
    /// whichever way the checks land — a ticket presented on the wrong route is
    /// spent, not left available for a retry on the right one.
    fn consume_ticket(&self, ticket: &str, method: &axum::http::Method, path: &str) -> bool {
        let Ok(mut tickets) = self.0.tickets.lock() else {
            // A poisoned table means a panic already happened while holding it;
            // refusing the ticket is the fail-closed answer.
            return false;
        };
        match tickets.remove(ticket) {
            Some(t) => {
                method == axum::http::Method::GET
                    && t.path == path
                    && Instant::now().duration_since(t.issued) < TICKET_TTL
            }
            None => false,
        }
    }

    /// Does `header` carry `Bearer <expected token>`?
    ///
    /// What: requires the `Bearer ` scheme prefix (ASCII-case-insensitive, per
    /// RFC 7235) and compares the remainder in constant time. A malformed
    /// header — no scheme, wrong scheme, non-ASCII bytes — is simply invalid,
    /// never a distinguishable error.
    fn header_is_valid(&self, header: Option<&axum::http::HeaderValue>) -> bool {
        let Some(value) = header.and_then(|h| h.to_str().ok()) else {
            return false;
        };
        let Some((scheme, presented)) = value.split_once(' ') else {
            return false;
        };
        scheme.eq_ignore_ascii_case("Bearer") && credentials_match(&self.0.token, presented.trim())
    }

    /// The ticket value in `query`, if any — a hand-rolled scan rather than a
    /// query-string crate, since exactly one parameter is read here.
    fn ticket_in_query(query: Option<&str>) -> Option<&str> {
        query?.split('&').find_map(|pair| {
            let (key, value) = pair.split_once('=')?;
            (key == TICKET_QUERY_PARAM).then_some(value)
        })
    }
}

/// Router-wide credential guard — apply with `Router::layer`, never
/// `route_layer`.
///
/// Why: `route_layer` covers only the routes registered before it in the same
/// chain, which is how #3268 left routes unguarded on this crate's sibling
/// guard. A single `.layer()` on the fully-merged router covers every route,
/// which for an authentication layer is the difference between a hardened
/// surface and one hole.
/// What: valid `Authorization: Bearer` → mark [`Authenticated`], continue;
/// else valid `?ticket=` → mark [`Authenticated`], continue; else a path in
/// [`DaemonAuth::new`]'s `public_paths` → continue UNMARKED; else `401` with an
/// empty body and a bare `WWW-Authenticate: Bearer`, disclosing nothing about
/// whether the path exists, why the credential failed, or what the daemon is.
/// Test: `bearer_auth_tests::*`.
pub async fn require_bearer(
    State(auth): State<DaemonAuth>,
    mut req: Request,
    next: Next,
) -> Response {
    let authenticated = auth.header_is_valid(req.headers().get(AUTHORIZATION)) || {
        // #5439: a ticket is redeemable only for the GET of the exact path it
        // was issued for, so one read from a log buys that stream and nothing
        // else. Bound before the borrow so `req` stays available below.
        let (method, path) = (req.method().clone(), req.uri().path().to_string());
        DaemonAuth::ticket_in_query(req.uri().query())
            .is_some_and(|ticket| auth.consume_ticket(ticket, &method, &path))
    };

    if authenticated {
        req.extensions_mut().insert(Authenticated);
        return next.run(req).await;
    }
    if auth.0.public_paths.contains(req.uri().path()) {
        return next.run(req).await;
    }
    // #5439: status only — no body, no reason, no route existence signal.
    (
        StatusCode::UNAUTHORIZED,
        [(axum::http::header::WWW_AUTHENTICATE, "Bearer")],
    )
        .into_response()
}

#[cfg(test)]
mod bearer_auth_tests {
    use super::*;
    use axum::{Extension, Router, body::Body, routing::get};
    use tower::util::ServiceExt;

    /// `/health`-shaped handler: says whether the caller was authenticated, so
    /// the tests can assert the marker as well as the status.
    async fn marker_handler(auth: Option<Extension<Authenticated>>) -> &'static str {
        if auth.is_some() { "authed" } else { "anon" }
    }

    fn router(auth: DaemonAuth) -> Router {
        Router::new()
            .route("/private", get(marker_handler))
            .route("/health", get(marker_handler))
            .layer(axum::middleware::from_fn_with_state(auth, require_bearer))
    }

    fn guarded(token: &str) -> Router {
        router(DaemonAuth::new(token, ["/health"]).expect("test token clears the floor"))
    }

    async fn get_with(app: Router, uri: &str, header: Option<&str>) -> (StatusCode, String) {
        let mut req = Request::builder().uri(uri);
        if let Some(value) = header {
            req = req.header(AUTHORIZATION, value);
        }
        let resp = app
            .oneshot(req.body(Body::empty()).expect("build request"))
            .await
            .expect("router response");
        let status = resp.status();
        let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
            .await
            .expect("read body");
        (status, String::from_utf8_lossy(&bytes).to_string())
    }

    /// The #5439 regression: no credential must not reach the handler.
    #[tokio::test]
    async fn missing_credential_is_rejected() {
        let (status, body) = get_with(guarded(&mint_token()), "/private", None).await;
        assert_eq!(status, StatusCode::UNAUTHORIZED);
        assert!(body.is_empty(), "401 body must disclose nothing: {body:?}");
    }

    /// A correct credential must reach the handler and be MARKED.
    #[tokio::test]
    async fn correct_credential_is_accepted_and_marked() {
        let token = mint_token();
        let (status, body) = get_with(
            guarded(&token),
            "/private",
            Some(&format!("Bearer {token}")),
        )
        .await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body, "authed");
    }

    /// A wrong token, a missing scheme, the wrong scheme, and a bare token
    /// must all fail identically — no branch may leak which part was wrong.
    #[tokio::test]
    async fn malformed_and_wrong_credentials_are_rejected() {
        let token = mint_token();
        for header in [
            format!("Bearer {}", mint_token()),
            token.clone(),
            format!("Basic {token}"),
            "Bearer".to_string(),
            format!("Bearer  {token} extra"),
            String::new(),
        ] {
            let (status, _) = get_with(guarded(&token), "/private", Some(&header)).await;
            assert_eq!(
                status,
                StatusCode::UNAUTHORIZED,
                "header {header:?} must not authenticate"
            );
        }
    }

    /// The scheme is case-insensitive per RFC 7235, so a client sending
    /// `bearer` must not be locked out.
    #[tokio::test]
    async fn bearer_scheme_is_case_insensitive() {
        let token = mint_token();
        let (status, _) = get_with(
            guarded(&token),
            "/private",
            Some(&format!("bearer {token}")),
        )
        .await;
        assert_eq!(status, StatusCode::OK);
    }

    /// A public path serves an anonymous caller — UNMARKED, so its handler
    /// can withhold what only an authenticated caller may see (#6472).
    #[tokio::test]
    async fn public_path_passes_without_a_credential() {
        let (status, body) = get_with(guarded(&mint_token()), "/health", None).await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body, "anon");
    }

    /// The same public path WITH a valid credential must be marked, which is
    /// what lets `/health` serve two payloads from one route.
    #[tokio::test]
    async fn public_path_with_a_credential_is_marked() {
        let token = mint_token();
        let (status, body) =
            get_with(guarded(&token), "/health", Some(&format!("Bearer {token}"))).await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body, "authed");
    }

    /// The carve-out is exact-match: a path that merely STARTS with a public
    /// path must stay guarded.
    #[tokio::test]
    async fn public_path_match_is_exact_not_prefix() {
        let auth = DaemonAuth::new(mint_token(), ["/priv"]).expect("mint_token clears the floor");
        let (status, _) = get_with(router(auth), "/private", None).await;
        assert_eq!(status, StatusCode::UNAUTHORIZED);
    }

    /// A query string must not smuggle a request past the guard just by being
    /// present — only a redeemable ticket does that.
    #[tokio::test]
    async fn unknown_ticket_is_rejected() {
        let app = guarded(&mint_token());
        let (status, _) = get_with(app, "/private?ticket=nope", None).await;
        assert_eq!(status, StatusCode::UNAUTHORIZED);
    }

    /// An issued ticket authenticates exactly ONE request; the replay must
    /// fail, which is what makes a ticket safe to place in a URL.
    #[tokio::test]
    async fn ticket_authenticates_once_then_is_spent() {
        let auth = DaemonAuth::new(mint_token(), ["/health"]).expect("mint_token clears the floor");
        let ticket = auth.issue_ticket("/private");
        let uri = format!("/private?{TICKET_QUERY_PARAM}={ticket}");

        let (status, body) = get_with(router(auth.clone()), &uri, None).await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body, "authed");

        let (status, _) = get_with(router(auth), &uri, None).await;
        assert_eq!(status, StatusCode::UNAUTHORIZED, "replay must fail");
    }

    /// A ticket rides in a URL and therefore reaches access logs. Bound to one
    /// path, a ticket read from a log within the TTL buys that stream and
    /// nothing else; unbound, it bought one arbitrary authenticated request —
    /// `POST /rpc` included.
    #[tokio::test]
    async fn a_ticket_opens_only_the_path_it_was_issued_for() {
        let auth = DaemonAuth::new(mint_token(), ["/health"]).expect("mint_token clears the floor");
        let ticket = auth.issue_ticket("/health");

        let (status, _) = get_with(
            router(auth.clone()),
            &format!("/private?{TICKET_QUERY_PARAM}={ticket}"),
            None,
        )
        .await;
        assert_eq!(
            status,
            StatusCode::UNAUTHORIZED,
            "a ticket for /health must not authenticate /private"
        );

        // And it is SPENT by that attempt, not left available for the route it
        // was actually issued for.
        assert!(
            !auth.consume_ticket(&ticket, &axum::http::Method::GET, "/health"),
            "a ticket presented on the wrong route must not survive it"
        );
    }

    /// A ticket authenticates a `GET` only. The SSE routes it exists for are
    /// `GET`; letting it carry a `POST` would hand a log reader the mutation
    /// surface.
    #[test]
    fn a_ticket_does_not_authenticate_a_non_get_method() {
        let auth = DaemonAuth::new(mint_token(), Vec::<String>::new())
            .expect("mint_token clears the floor");
        let ticket = auth.issue_ticket("/rpc");
        assert!(!auth.consume_ticket(&ticket, &axum::http::Method::POST, "/rpc"));
    }

    /// A ticket issued against one clone must be redeemable against another —
    /// axum clones the state per request, and a per-clone table would reject
    /// every ticket.
    #[tokio::test]
    async fn ticket_table_is_shared_across_clones() {
        let auth = DaemonAuth::new(mint_token(), Vec::<String>::new())
            .expect("mint_token clears the floor");
        let ticket = auth.clone().issue_ticket("/events");
        assert!(auth.consume_ticket(&ticket, &axum::http::Method::GET, "/events"));
    }

    /// A ticket past [`TICKET_TTL`] must be refused even though the table
    /// still holds it — expiry is enforced at redemption, not only by the
    /// sweep in `issue_ticket`.
    #[test]
    fn expired_ticket_is_rejected() {
        let auth = DaemonAuth::new(mint_token(), Vec::<String>::new())
            .expect("mint_token clears the floor");
        let stale = mint_token();
        // A machine up for less than TICKET_TTL cannot represent the earlier
        // instant; skip rather than panic on `Instant - Duration`.
        let Some(issued) = Instant::now().checked_sub(TICKET_TTL + Duration::from_secs(1)) else {
            return;
        };
        if let Ok(mut tickets) = auth.0.tickets.lock() {
            tickets.insert(
                stale.clone(),
                Ticket {
                    path: "/events".to_string(),
                    issued,
                },
            );
        }
        assert!(
            !auth.consume_ticket(&stale, &axum::http::Method::GET, "/events"),
            "an expired ticket must fail"
        );
    }

    /// A daemon must not start around a credential that guards nothing.
    ///
    /// Paired with `daemon_token`'s `MIN_TOKEN_LEN` floor in
    /// `credentials_match`: that stops an empty token from VERIFYING, this
    /// stops one from being installed at all, so the failure is a visible
    /// startup error rather than a daemon that `401`s every real client.
    #[test]
    fn a_weak_token_is_refused_at_construction() {
        for weak in ["", " ", "short", &"a".repeat(31)] {
            let err = DaemonAuth::new(weak, ["/health"])
                .err()
                .unwrap_or_else(|| panic!("{weak:?} must be refused"));
            assert_eq!(err.got, weak.len());
        }
        // The error reaches logs, so it reports the LENGTH and never the value.
        let err = DaemonAuth::new("sekrit-and-distinctive", ["/health"])
            .err()
            .expect("refused");
        assert!(
            !err.to_string().contains("sekrit"),
            "the error must not echo the credential: {err}"
        );
        assert!(DaemonAuth::new("a".repeat(32), ["/health"]).is_ok());
    }

    /// Only the `ticket` parameter is read, and only when it is spelled
    /// exactly — a lookalike key must not be mistaken for it.
    #[test]
    fn ticket_in_query_reads_only_the_named_parameter() {
        assert_eq!(DaemonAuth::ticket_in_query(None), None);
        assert_eq!(DaemonAuth::ticket_in_query(Some("")), None);
        assert_eq!(DaemonAuth::ticket_in_query(Some("other=1")), None);
        assert_eq!(DaemonAuth::ticket_in_query(Some("myticket=1")), None);
        assert_eq!(DaemonAuth::ticket_in_query(Some("ticket=abc")), Some("abc"));
        assert_eq!(
            DaemonAuth::ticket_in_query(Some("a=1&ticket=abc&b=2")),
            Some("abc")
        );
    }
}