babelforce-manager-sdk 0.42.3

Rust SDK for the babelforce manager APIs — auth, user & agent management, call reporting, metrics, and task automations.
Documentation
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use sha2::{Digest, Sha256};
use uuid::Uuid;

/// How the SDK authenticates. Configure once when building the client.
///
/// The [`Debug`](std::fmt::Debug) impl redacts the secret-bearing fields (`client_secret`, `pass`,
/// `token`, `refresh_token`) so a stray `{:?}` in logs or an error report can't leak credentials.
#[derive(Clone)]
pub enum Auth {
    /// Server-to-server OAuth2 `client_credentials` grant against `/oauth/token`, resolved once at
    /// connect into a bearer token. Requires an OAuth2 application (client id/secret); see the
    /// Authentication guide for the current availability caveat.
    ClientCredentials {
        /// OAuth2 application client id.
        client_id: String,
        /// OAuth2 application client secret.
        client_secret: String,
    },
    /// A bearer token you already hold (e.g. the access token from a PKCE flow).
    Bearer {
        /// The bearer access token, sent as `Authorization: Bearer <token>`.
        token: String,
    },
    /// OAuth2 password grant against `/oauth/token` (resolved once at connect).
    Password {
        /// Resource-owner username.
        user: String,
        /// Resource-owner password.
        pass: String,
    },
    /// A refresh token from the Authorization Code + PKCE flow, exchanged **once** at connect into a
    /// bearer token.
    ///
    /// Unlike the TypeScript/Go SDKs there is no transparent background refresh — the resolved
    /// access token is used for the client's lifetime. Because refresh tokens are rotated on every
    /// use, the token passed here is consumed at connect and the newly issued one is discarded;
    /// callers that need long-lived rotation should drive it manually via
    /// `mgr.auth.token("refresh_token", …)` and rebuild the client (or use [`Auth::Bearer`]).
    RefreshToken {
        /// The refresh token to exchange for an access token.
        refresh_token: String,
        /// OAuth2 application client id the refresh token was issued to.
        client_id: String,
    },
}

impl std::fmt::Debug for Auth {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Redact secrets; keep the non-sensitive `client_id`/`user` for debuggability.
        match self {
            Auth::ClientCredentials { client_id, .. } => f
                .debug_struct("ClientCredentials")
                .field("client_id", client_id)
                .field("client_secret", &"***")
                .finish(),
            Auth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
            Auth::Password { user, .. } => f
                .debug_struct("Password")
                .field("user", user)
                .field("pass", &"***")
                .finish(),
            Auth::RefreshToken { client_id, .. } => f
                .debug_struct("RefreshToken")
                .field("refresh_token", &"***")
                .field("client_id", client_id)
                .finish(),
        }
    }
}

impl Auth {
    /// Static default headers for `Bearer`. `Password`, `ClientCredentials` and `RefreshToken` are
    /// resolved during `connect` (a token fetch), then applied as a bearer header.
    pub(crate) fn static_headers(&self) -> HeaderMap {
        let mut h = HeaderMap::new();
        match self {
            Auth::Bearer { token } => {
                // Trim surrounding whitespace — a token read from a file/env commonly carries a
                // trailing newline, which `HeaderValue::from_str` rejects (would otherwise silently
                // send an empty Authorization header and 401 with no hint).
                h.insert(
                    AUTHORIZATION,
                    header_value(&format!("Bearer {}", token.trim())),
                );
            }
            Auth::Password { .. } => {}
            Auth::ClientCredentials { .. } => {}
            Auth::RefreshToken { .. } => {}
        }
        h
    }
}

fn header_value(s: &str) -> HeaderValue {
    HeaderValue::from_str(s).unwrap_or_else(|_| HeaderValue::from_static(""))
}

/// A PKCE code verifier + S256 challenge (RFC 7636).
///
/// The [`Debug`](std::fmt::Debug) impl redacts `code_verifier` (the PKCE secret).
#[derive(Clone)]
pub struct PkceChallenge {
    /// The high-entropy secret to keep and send when exchanging the authorization code
    /// (`mgr.auth.token("authorization_code", TokenRequest { code_verifier: Some(..), .. })`).
    pub code_verifier: String,
    /// The `base64url(SHA-256(code_verifier))` value to pass to [`build_authorize_url`].
    pub code_challenge: String,
    /// Always `"S256"`.
    pub code_challenge_method: String,
}

impl std::fmt::Debug for PkceChallenge {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PkceChallenge")
            .field("code_verifier", &"***")
            .field("code_challenge", &self.code_challenge)
            .field("code_challenge_method", &self.code_challenge_method)
            .finish()
    }
}

/// Generate a fresh PKCE verifier + S256 challenge (RFC 7636). Pass `code_challenge` to
/// [`build_authorize_url`] and keep `code_verifier` to exchange the returned code.
pub fn pkce_challenge() -> PkceChallenge {
    // 32 bytes (~244 bits) of entropy from two getrandom-backed v4 UUIDs (avoids a `rand` dep).
    let mut bytes = Vec::with_capacity(32);
    bytes.extend_from_slice(Uuid::new_v4().as_bytes());
    bytes.extend_from_slice(Uuid::new_v4().as_bytes());
    let code_verifier = URL_SAFE_NO_PAD.encode(&bytes);
    let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
    PkceChallenge {
        code_verifier,
        code_challenge,
        code_challenge_method: "S256".to_string(),
    }
}

/// Build the `GET {base_url}/oauth/authorize` URL that starts the Authorization Code + PKCE flow.
/// Redirect the user to it; babelforce redirects back to `redirect_uri` with a short-lived code.
/// `code_challenge_method` defaults to `S256` when `None`. Pass a full `base_url` including the
/// scheme (e.g. `https://services.babelforce.com`).
pub fn build_authorize_url(
    base_url: &str,
    client_id: &str,
    redirect_uri: &str,
    scope: &str,
    code_challenge: &str,
    state: Option<&str>,
    code_challenge_method: Option<&str>,
) -> String {
    let base = base_url.trim_end_matches('/');
    // Build the query with a form-urlencoding serializer rather than `Url::parse(base)`, which
    // would panic on a scheme-less/invalid `base_url` (this fn is infallible by contract).
    let mut q = url::form_urlencoded::Serializer::new(String::new());
    q.append_pair("response_type", "code");
    q.append_pair("client_id", client_id);
    q.append_pair("redirect_uri", redirect_uri);
    q.append_pair("scope", scope);
    q.append_pair("code_challenge", code_challenge);
    q.append_pair(
        "code_challenge_method",
        code_challenge_method.unwrap_or("S256"),
    );
    if let Some(s) = state {
        q.append_pair("state", s);
    }
    format!("{base}/oauth/authorize?{}", q.finish())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pkce_challenge_is_s256_of_verifier() {
        let c = pkce_challenge();
        assert_eq!(c.code_challenge_method, "S256");
        // Verifier is 43-128 chars from the unreserved set, base64url with no padding (RFC 7636).
        assert!(c.code_verifier.len() >= 43 && c.code_verifier.len() <= 128);
        assert!(c
            .code_verifier
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'));
        assert!(!c.code_verifier.contains('=') && !c.code_challenge.contains('='));
        // challenge == base64url-no-pad(SHA-256(ASCII(verifier)))
        let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(c.code_verifier.as_bytes()));
        assert_eq!(c.code_challenge, expected);
        assert_ne!(pkce_challenge().code_verifier, c.code_verifier);
    }

    #[test]
    fn build_authorize_url_has_pkce_params() {
        let u = build_authorize_url(
            "https://acme.babelforce.com/",
            "spa",
            "https://app.example.com/cb",
            "*",
            "CHAL",
            Some("xyz"),
            None,
        );
        let parsed = url::Url::parse(&u).unwrap();
        assert_eq!(parsed.path(), "/oauth/authorize");
        let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
        assert_eq!(q["response_type"], "code");
        assert_eq!(q["client_id"], "spa");
        assert_eq!(q["redirect_uri"], "https://app.example.com/cb");
        assert_eq!(q["scope"], "*");
        assert_eq!(q["code_challenge"], "CHAL");
        assert_eq!(q["code_challenge_method"], "S256");
        assert_eq!(q["state"], "xyz");
    }
}