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}