Skip to main content

babelforce_manager_sdk/
auth.rs

1use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2use base64::Engine;
3use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
4use sha2::{Digest, Sha256};
5use uuid::Uuid;
6
7/// How the SDK authenticates. Configure once when building the client.
8///
9/// The [`Debug`](std::fmt::Debug) impl redacts the secret-bearing fields (`client_secret`, `pass`,
10/// `token`, `refresh_token`) so a stray `{:?}` in logs or an error report can't leak credentials.
11#[derive(Clone)]
12pub enum Auth {
13    /// Server-to-server OAuth2 `client_credentials` grant against `/oauth/token`, resolved once at
14    /// connect into a bearer token. Requires an OAuth2 application (client id/secret); see the
15    /// Authentication guide for the current availability caveat.
16    ClientCredentials {
17        /// OAuth2 application client id.
18        client_id: String,
19        /// OAuth2 application client secret.
20        client_secret: String,
21    },
22    /// A bearer token you already hold (e.g. the access token from a PKCE flow).
23    Bearer {
24        /// The bearer access token, sent as `Authorization: Bearer <token>`.
25        token: String,
26    },
27    /// OAuth2 password grant against `/oauth/token` (resolved once at connect).
28    Password {
29        /// Resource-owner username.
30        user: String,
31        /// Resource-owner password.
32        pass: String,
33    },
34    /// A refresh token from the Authorization Code + PKCE flow, exchanged **once** at connect into a
35    /// bearer token.
36    ///
37    /// Unlike the TypeScript/Go SDKs there is no transparent background refresh — the resolved
38    /// access token is used for the client's lifetime. Because refresh tokens are rotated on every
39    /// use, the token passed here is consumed at connect and the newly issued one is discarded;
40    /// callers that need long-lived rotation should drive it manually via
41    /// `mgr.auth.token("refresh_token", …)` and rebuild the client (or use [`Auth::Bearer`]).
42    RefreshToken {
43        /// The refresh token to exchange for an access token.
44        refresh_token: String,
45        /// OAuth2 application client id the refresh token was issued to.
46        client_id: String,
47    },
48}
49
50impl std::fmt::Debug for Auth {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        // Redact secrets; keep the non-sensitive `client_id`/`user` for debuggability.
53        match self {
54            Auth::ClientCredentials { client_id, .. } => f
55                .debug_struct("ClientCredentials")
56                .field("client_id", client_id)
57                .field("client_secret", &"***")
58                .finish(),
59            Auth::Bearer { .. } => f.debug_struct("Bearer").field("token", &"***").finish(),
60            Auth::Password { user, .. } => f
61                .debug_struct("Password")
62                .field("user", user)
63                .field("pass", &"***")
64                .finish(),
65            Auth::RefreshToken { client_id, .. } => f
66                .debug_struct("RefreshToken")
67                .field("refresh_token", &"***")
68                .field("client_id", client_id)
69                .finish(),
70        }
71    }
72}
73
74impl Auth {
75    /// Static default headers for `Bearer`. `Password`, `ClientCredentials` and `RefreshToken` are
76    /// resolved during `connect` (a token fetch), then applied as a bearer header.
77    pub(crate) fn static_headers(&self) -> HeaderMap {
78        let mut h = HeaderMap::new();
79        match self {
80            Auth::Bearer { token } => {
81                // Trim surrounding whitespace — a token read from a file/env commonly carries a
82                // trailing newline, which `HeaderValue::from_str` rejects (would otherwise silently
83                // send an empty Authorization header and 401 with no hint).
84                h.insert(
85                    AUTHORIZATION,
86                    header_value(&format!("Bearer {}", token.trim())),
87                );
88            }
89            Auth::Password { .. } => {}
90            Auth::ClientCredentials { .. } => {}
91            Auth::RefreshToken { .. } => {}
92        }
93        h
94    }
95}
96
97fn header_value(s: &str) -> HeaderValue {
98    HeaderValue::from_str(s).unwrap_or_else(|_| HeaderValue::from_static(""))
99}
100
101/// A PKCE code verifier + S256 challenge (RFC 7636).
102///
103/// The [`Debug`](std::fmt::Debug) impl redacts `code_verifier` (the PKCE secret).
104#[derive(Clone)]
105pub struct PkceChallenge {
106    /// The high-entropy secret to keep and send when exchanging the authorization code
107    /// (`mgr.auth.token("authorization_code", TokenRequest { code_verifier: Some(..), .. })`).
108    pub code_verifier: String,
109    /// The `base64url(SHA-256(code_verifier))` value to pass to [`build_authorize_url`].
110    pub code_challenge: String,
111    /// Always `"S256"`.
112    pub code_challenge_method: String,
113}
114
115impl std::fmt::Debug for PkceChallenge {
116    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117        f.debug_struct("PkceChallenge")
118            .field("code_verifier", &"***")
119            .field("code_challenge", &self.code_challenge)
120            .field("code_challenge_method", &self.code_challenge_method)
121            .finish()
122    }
123}
124
125/// Generate a fresh PKCE verifier + S256 challenge (RFC 7636). Pass `code_challenge` to
126/// [`build_authorize_url`] and keep `code_verifier` to exchange the returned code.
127pub fn pkce_challenge() -> PkceChallenge {
128    // 32 bytes (~244 bits) of entropy from two getrandom-backed v4 UUIDs (avoids a `rand` dep).
129    let mut bytes = Vec::with_capacity(32);
130    bytes.extend_from_slice(Uuid::new_v4().as_bytes());
131    bytes.extend_from_slice(Uuid::new_v4().as_bytes());
132    let code_verifier = URL_SAFE_NO_PAD.encode(&bytes);
133    let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
134    PkceChallenge {
135        code_verifier,
136        code_challenge,
137        code_challenge_method: "S256".to_string(),
138    }
139}
140
141/// Build the `GET {base_url}/oauth/authorize` URL that starts the Authorization Code + PKCE flow.
142/// Redirect the user to it; babelforce redirects back to `redirect_uri` with a short-lived code.
143/// `code_challenge_method` defaults to `S256` when `None`. Pass a full `base_url` including the
144/// scheme (e.g. `https://services.babelforce.com`).
145pub fn build_authorize_url(
146    base_url: &str,
147    client_id: &str,
148    redirect_uri: &str,
149    scope: &str,
150    code_challenge: &str,
151    state: Option<&str>,
152    code_challenge_method: Option<&str>,
153) -> String {
154    let base = base_url.trim_end_matches('/');
155    // Build the query with a form-urlencoding serializer rather than `Url::parse(base)`, which
156    // would panic on a scheme-less/invalid `base_url` (this fn is infallible by contract).
157    let mut q = url::form_urlencoded::Serializer::new(String::new());
158    q.append_pair("response_type", "code");
159    q.append_pair("client_id", client_id);
160    q.append_pair("redirect_uri", redirect_uri);
161    q.append_pair("scope", scope);
162    q.append_pair("code_challenge", code_challenge);
163    q.append_pair(
164        "code_challenge_method",
165        code_challenge_method.unwrap_or("S256"),
166    );
167    if let Some(s) = state {
168        q.append_pair("state", s);
169    }
170    format!("{base}/oauth/authorize?{}", q.finish())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn pkce_challenge_is_s256_of_verifier() {
179        let c = pkce_challenge();
180        assert_eq!(c.code_challenge_method, "S256");
181        // Verifier is 43-128 chars from the unreserved set, base64url with no padding (RFC 7636).
182        assert!(c.code_verifier.len() >= 43 && c.code_verifier.len() <= 128);
183        assert!(c
184            .code_verifier
185            .bytes()
186            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'));
187        assert!(!c.code_verifier.contains('=') && !c.code_challenge.contains('='));
188        // challenge == base64url-no-pad(SHA-256(ASCII(verifier)))
189        let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(c.code_verifier.as_bytes()));
190        assert_eq!(c.code_challenge, expected);
191        assert_ne!(pkce_challenge().code_verifier, c.code_verifier);
192    }
193
194    #[test]
195    fn build_authorize_url_has_pkce_params() {
196        let u = build_authorize_url(
197            "https://acme.babelforce.com/",
198            "spa",
199            "https://app.example.com/cb",
200            "*",
201            "CHAL",
202            Some("xyz"),
203            None,
204        );
205        let parsed = url::Url::parse(&u).unwrap();
206        assert_eq!(parsed.path(), "/oauth/authorize");
207        let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
208        assert_eq!(q["response_type"], "code");
209        assert_eq!(q["client_id"], "spa");
210        assert_eq!(q["redirect_uri"], "https://app.example.com/cb");
211        assert_eq!(q["scope"], "*");
212        assert_eq!(q["code_challenge"], "CHAL");
213        assert_eq!(q["code_challenge_method"], "S256");
214        assert_eq!(q["state"], "xyz");
215    }
216}