babelforce-manager-sdk 0.44.1

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 at
    /// connect into a bearer token and transparently re-granted shortly before it expires.
    /// 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). Used as-is for
    /// the client's lifetime — the SDK cannot renew a token it didn't obtain itself.
    Bearer {
        /// The bearer access token, sent as `Authorization: Bearer <token>`.
        token: String,
    },
    /// OAuth2 password grant against `/oauth/token`, resolved at connect and transparently
    /// re-granted shortly before the token expires.
    Password {
        /// Resource-owner username.
        user: String,
        /// Resource-owner password.
        pass: String,
        /// OAuth2 client id to present with the grant. `None` uses the default `"manager"`
        /// client; set it to authenticate through a custom OAuth2 application — matching the
        /// TypeScript/Go SDKs.
        client_id: Option<String>,
    },
    /// A refresh token from the Authorization Code + PKCE flow, exchanged at connect into a
    /// bearer token and transparently re-exchanged shortly before that token expires — matching
    /// the TypeScript/Go SDKs. Refresh tokens are rotated on every use: the SDK captures the
    /// newly issued refresh token from each grant response and uses it for the next exchange.
    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,
        /// Client secret, for refresh tokens issued to a confidential client. `None` (public
        /// PKCE clients) omits it from the exchange — matching the TypeScript/Go SDKs.
        client_secret: Option<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, client_id, ..
            } => f
                .debug_struct("Password")
                .field("user", user)
                .field("pass", &"***")
                .field("client_id", client_id)
                .finish(),
            Auth::RefreshToken {
                client_id,
                client_secret,
                ..
            } => f
                .debug_struct("RefreshToken")
                .field("refresh_token", &"***")
                .field("client_id", client_id)
                .field("client_secret", &client_secret.as_ref().map(|_| "***"))
                .finish(),
        }
    }
}

impl Auth {
    /// Static default headers for `Bearer`. `Password`, `ClientCredentials` and `RefreshToken`
    /// carry no static header — their grant-resolved (and periodically re-granted) token travels
    /// per request via the per-spec configurations (see `crate::token`).
    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");
    }
}