brazen 0.0.4

A stateless, swiss-army-knife adapter for every LLM provider and protocol.
Documentation
//! Silent refresh — the only stateful thing in a normal run (auth §6). `OAuth2Auth`
//! is the sole `Auth` impl that uses `clock` and `transport`: it detects staleness
//! with a pure clock comparison, refreshes OWNED state over the SAME `Transport`
//! seam, persists the new token (persist-then-use), and writes the bearer header
//! plus auth-mode-dependent beta headers (§4). Borrowed ambient state is read-only:
//! stale returns 77 before transport or persistence.

use super::oauth::{is_expired, parse_token_response, Grant};
use super::wire::build_token_exchange_request;
use super::{auth_error, fetch_cred, require_header, set_auth_header, Auth, AuthCtx, CredSource};
use crate::canonical::{CanonicalError, ErrorKind};
use crate::protocol::{ProviderCtx, WireRequest};
use crate::store::{Clock, Cred, CredStore};
use crate::transport::{Transport, TransportResponse};

/// The OAuth2 data-plane auth (auth §6). OAuth knowledge — refresh, the bearer
/// header, AND the `anthropic-beta: oauth-…` header — is fully contained here; the
/// registry shares one `&OAuth2Auth` across every OAuth row (it reads endpoints /
/// `client_id` / `scope` / `beta_headers` from the `OAuthConfig` on `AuthCtx`).
pub struct OAuth2Auth;

impl Auth for OAuth2Auth {
    fn apply(
        &self,
        wire: &mut WireRequest,
        _ctx: &ProviderCtx,
        auth: &AuthCtx,
        store: &dyn CredStore,
        clock: &dyn Clock,
        transport: &dyn Transport,
    ) -> Result<(), CanonicalError> {
        // Defensive, not a live branch: resolution pairs an `oauth2` row with a
        // present `OAuthConfig` or fails at resolve (§1.3); exercised by a direct
        // unit test handing `oauth: None`, proving the no-panic contract → 78.
        let cfg = auth.oauth.ok_or_else(oauth_row_misconfigured)?;
        let Some(fetched) = fetch_cred(store, auth) else {
            return Err(auth_error(
                "not logged in for this provider: run `bz --login --provider <id>` (or \
                 sign in to a tool whose ambient credential this row discovers)",
            ));
        };
        let borrowed = fetched.source == CredSource::Borrowed;
        let Cred::OAuth2 {
            access_token,
            refresh_token,
            expires_at,
            scope,
            account_id,
        } = fetched.cred
        else {
            return Err(auth_error(
                "not logged in for this provider: run `bz --login --provider <id>` (or \
                 sign in to a tool whose ambient credential this row discovers)",
            ));
        };

        let token = if is_expired(expires_at, clock.now()) {
            // Ambient state belongs to another tool. A stale borrowed token must be
            // refreshed there; adopting its rotation into brazen's store would make
            // two owners for one credential and shadow future discovery (auth §5.5).
            if borrowed {
                return Err(auth_error(
                    "borrowed OAuth credential is expired; refresh it with the tool that owns it",
                ));
            }
            let mut req = build_token_exchange_request(
                cfg,
                Grant::Refresh {
                    refresh_token: &refresh_token,
                },
            );
            // The refresh POST shares the data request's hang risk AND its wire
            // identity, so it inherits the whole transport policy `run` stamped on
            // `wire` (config §4, transport §4.3): the bounds, and the operator's
            // transport delegate when the row selects one — the auth control request
            // must not fall back to a different HTTP stack than the data request.
            req.timeouts = wire.timeouts;
            req.exec = wire.exec.clone();
            let bytes = collect_body(transport.send(req)?)?;
            let fresh = parse_token_response(&bytes, clock.now()).map_err(|_| {
                // Single-path 77: ANY unparseable refresh response — a permanent
                // invalid_grant OR a transient token-endpoint 503/429 that still
                // returns a body — lands here. `apply` does NOT peek `resp.status`
                // to split codes (auth §6.2); the operator action is the same
                // either way. The message is therefore non-alarming and does not
                // assert the cred is revoked/expired (the fault may be retryable —
                // retry is the caller's job, the data plane does not loop).
                auth_error(
                    "token refresh failed; re-run `bz --login --provider <id>` if this persists",
                )
            })?;
            store
                .put(
                    auth.store_key,
                    &fresh.as_cred(&refresh_token, &scope, &account_id),
                )
                .map_err(persist_failed)?;
            fresh.access_token
        } else {
            access_token
        };

        set_auth_header(wire, require_header(auth)?, &token);
        for (name, value) in &cfg.beta_headers {
            wire.set_header(name, value);
        }
        // The auth-mode-dependent header whose VALUE is the credential's account id
        // (auth §10.4): NAME is row data, value is the cred fact. Both absent ⇒ no
        // header (Anthropic). The account does not change on refresh, so the stored
        // value is correct regardless of which branch produced `token`.
        if let (Some(name), Some(id)) = (cfg.account_header.as_deref(), account_id.as_deref()) {
            wire.set_header(name, id);
        }
        Ok(())
    }
}

/// Drain a transport response body to a `Vec` (auth §6): the refresh `POST` answers
/// on the SAME seam as the data request, so a mid-stream read failure is the
/// transport's own `Transport` error (→69), distinct from a parsed `invalid_grant`.
pub(crate) fn collect_body(resp: TransportResponse) -> Result<Vec<u8>, CanonicalError> {
    let mut out = Vec::new();
    for chunk in resp.body {
        let bytes = chunk.map_err(|e| CanonicalError {
            kind: ErrorKind::Transport,
            message: format!("transport error reading token response: {e}"),
            provider_detail: None,
            retry_after_seconds: None,
        })?;
        out.extend_from_slice(&bytes);
    }
    Ok(out)
}

/// A resolved run never reaches `apply` with `oauth: None` (§1.3); a `Config` error
/// (→78) is the defensive surface if it somehow does.
fn oauth_row_misconfigured() -> CanonicalError {
    CanonicalError {
        kind: ErrorKind::Config,
        message: "oauth2 provider row has no oauth config (should be caught at resolve)".to_owned(),
        provider_detail: None,
        retry_after_seconds: None,
    }
}

/// A failure to persist the refreshed token (auth §6.2): surfaced as an auth error
/// (→77) — the credential subsystem could not record the new token.
fn persist_failed(e: std::io::Error) -> CanonicalError {
    CanonicalError {
        kind: ErrorKind::Auth,
        message: format!("could not persist refreshed credential: {e}"),
        provider_detail: None,
        retry_after_seconds: None,
    }
}