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