Skip to main content

bitwarden_access_connector/auth/
identity.rs

1//! OAuth2 client-credentials grant against `{identity_url}/connect/token`, requesting
2//! `scope=api.pam.rotation`.
3
4use std::time::Duration;
5
6use bitwarden_access_token::AccessToken;
7use bitwarden_api_base::new_http_client_builder;
8use bitwarden_core::{OrganizationId, auth::JwtToken};
9use bitwarden_sensitive_value::SensitiveString;
10use serde::Deserialize;
11use thiserror::Error;
12
13const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
14
15const SCOPE: &str = "api.pam.rotation";
16
17/// A successful authentication response from the identity server. `Debug` is hand-written to
18/// redact `access_token`.
19pub(crate) struct AuthSuccess {
20    pub(crate) access_token: SensitiveString,
21    /// Token lifetime in seconds.
22    pub(crate) expires_in: u64,
23    /// The organisation key wrapped under the connector's encryption key (EncString).
24    pub(crate) encrypted_payload: String,
25    /// The organisation the connector belongs to, from the access token's `organization` claim.
26    pub(crate) organization_id: OrganizationId,
27}
28
29impl std::fmt::Debug for AuthSuccess {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        f.debug_struct("AuthSuccess")
32            .field("expires_in", &self.expires_in)
33            .finish_non_exhaustive()
34    }
35}
36
37/// Errors returned by [`IdentityClient::authenticate`].
38#[derive(Debug, Error)]
39pub(crate) enum AuthError {
40    /// The identity server rejected the credential (`invalid_client`, `invalid_grant`,
41    /// `unauthorized_client`). Terminal: do not retry with the same credential.
42    #[error("credential rejected by identity server")]
43    Rejected,
44
45    /// A connection failure, 429, 5xx or other unexpected status; retryable after a delay.
46    #[error("transient error contacting identity server: {0}")]
47    Transient(String),
48
49    /// The response was not the expected JSON, or carried an unrecognised OAuth error.
50    #[error("identity server returned an unexpected response")]
51    Protocol,
52}
53
54/// HTTP client for the `POST /connect/token` identity endpoint.
55pub(crate) struct IdentityClient {
56    http: reqwest::Client,
57    identity_url: String,
58}
59
60impl IdentityClient {
61    /// Uses [`new_http_client_builder`] because the workspace `reqwest` has no TLS backend of its
62    /// own.
63    pub(crate) fn new(identity_url: String) -> Result<Self, reqwest::Error> {
64        let http = new_http_client_builder()
65            .timeout(REQUEST_TIMEOUT)
66            // Never follow redirects: a redirected credential post would send the client_secret to
67            // an unexpected endpoint.
68            .redirect(reqwest::redirect::Policy::none())
69            .build()?;
70        Ok(Self { http, identity_url })
71    }
72
73    /// POST `{identity_url}/connect/token` with a `client_credentials` grant.
74    pub(crate) async fn authenticate(&self, token: &AccessToken) -> Result<AuthSuccess, AuthError> {
75        let url = format!("{}/connect/token", self.identity_url.trim_end_matches('/'));
76
77        use bitwarden_sensitive_value::ExposeSensitive as _;
78        let secret_value = token.client_secret().expose().to_owned();
79        let client_id = token.client_id();
80
81        let form: Vec<(&str, &str)> = vec![
82            ("grant_type", "client_credentials"),
83            ("client_id", &client_id),
84            ("client_secret", &secret_value),
85            ("scope", SCOPE),
86        ];
87
88        let response = self
89            .http
90            .post(&url)
91            .form(&form)
92            .send()
93            .await
94            .map_err(|e| AuthError::Transient(e.to_string()))?;
95
96        let status = response.status();
97
98        if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
99            return Err(AuthError::Transient(format!("HTTP {}", status.as_u16())));
100        }
101
102        if status == reqwest::StatusCode::BAD_REQUEST || status == reqwest::StatusCode::UNAUTHORIZED
103        {
104            // The OAuth `error` field separates Rejected from Protocol.
105            let body_bytes = response.bytes().await.map_err(|_| AuthError::Protocol)?;
106
107            #[derive(Deserialize)]
108            struct OAuthError {
109                error: Option<String>,
110            }
111
112            let parsed: OAuthError =
113                serde_json::from_slice(&body_bytes).map_err(|_| AuthError::Protocol)?;
114
115            match parsed.error.as_deref() {
116                Some("invalid_client" | "invalid_grant" | "unauthorized_client") => {
117                    return Err(AuthError::Rejected);
118                }
119                _ => return Err(AuthError::Protocol),
120            }
121        }
122
123        if !status.is_success() {
124            return Err(AuthError::Transient(format!("HTTP {}", status.as_u16())));
125        }
126
127        let body_bytes = response.bytes().await.map_err(|_| AuthError::Protocol)?;
128
129        #[derive(Deserialize)]
130        struct TokenResponse {
131            access_token: String,
132            expires_in: u64,
133            // The identity server emits this field as snake_case `encrypted_payload`.
134            encrypted_payload: String,
135        }
136
137        let parsed: TokenResponse =
138            serde_json::from_slice(&body_bytes).map_err(|_| AuthError::Protocol)?;
139
140        let organization_id = organization_claim(&parsed.access_token)?;
141
142        Ok(AuthSuccess {
143            access_token: SensitiveString::from(parsed.access_token),
144            expires_in: parsed.expires_in,
145            encrypted_payload: parsed.encrypted_payload,
146            organization_id,
147        })
148    }
149}
150
151/// Reads the `organization` claim, which the server emits unprefixed. Its absence is a server
152/// contract violation: without an organisation there is no org slot to key.
153fn organization_claim(access_token: &str) -> Result<OrganizationId, AuthError> {
154    let jwt: JwtToken = access_token.parse().map_err(|_| AuthError::Protocol)?;
155
156    jwt.organization
157        .as_deref()
158        .and_then(|o| o.parse().ok())
159        .ok_or(AuthError::Protocol)
160}
161
162#[cfg(test)]
163mod tests {
164    use bitwarden_access_token::AccessTokenKind;
165    use bitwarden_sensitive_value::ExposeSensitive as _;
166    use wiremock::{
167        Mock, MockServer, ResponseTemplate,
168        matchers::{body_string_contains, method, path},
169    };
170
171    use super::{AccessToken, AuthError, IdentityClient};
172    use crate::test_support::{access_token_tagged, test_org_id};
173
174    const VALID_TOKEN_STR: &str = "0.access-connector.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==";
175
176    fn test_token() -> AccessToken {
177        AccessToken::parse(VALID_TOKEN_STR, AccessTokenKind::AccessConnector).expect("valid token")
178    }
179
180    fn client(server: &MockServer) -> IdentityClient {
181        IdentityClient::new(server.uri()).expect("client build")
182    }
183
184    /// `tag` is expanded to a real JWT so the `organization` claim parses.
185    fn success_body(tag: &str, expires_in: u64) -> String {
186        let access_token = access_token_tagged(test_org_id(), tag);
187        format!(
188            r#"{{"access_token":"{access_token}","expires_in":{expires_in},"encrypted_payload":"2.abc==|def==|ghi=="}}"#
189        )
190    }
191
192    #[tokio::test]
193    async fn successful_auth_returns_token_and_payload() {
194        let server = MockServer::start().await;
195        Mock::given(method("POST"))
196            .and(path("/connect/token"))
197            .respond_with(
198                ResponseTemplate::new(200)
199                    .set_body_string(success_body("test-bearer", 3600))
200                    .insert_header("content-type", "application/json"),
201            )
202            .mount(&server)
203            .await;
204
205        let result = client(&server)
206            .authenticate(&test_token())
207            .await
208            .expect("authenticate");
209
210        assert_eq!(
211            result.access_token.expose(),
212            &access_token_tagged(test_org_id(), "test-bearer")
213        );
214        assert_eq!(result.organization_id, test_org_id());
215        assert_eq!(result.expires_in, 3600);
216        assert_eq!(result.encrypted_payload, "2.abc==|def==|ghi==");
217    }
218
219    #[tokio::test]
220    async fn sends_correct_form_fields() {
221        let server = MockServer::start().await;
222        Mock::given(method("POST"))
223            .and(path("/connect/token"))
224            .and(body_string_contains("grant_type=client_credentials"))
225            .and(body_string_contains("scope=api.pam.rotation"))
226            .and(body_string_contains("client_id=access-connector."))
227            .respond_with(
228                ResponseTemplate::new(200)
229                    .set_body_string(success_body("tok", 3600))
230                    .insert_header("content-type", "application/json"),
231            )
232            .mount(&server)
233            .await;
234
235        client(&server)
236            .authenticate(&test_token())
237            .await
238            .expect("authenticate");
239
240        assert_eq!(server.received_requests().await.unwrap().len(), 1);
241    }
242
243    #[tokio::test]
244    async fn invalid_client_gives_rejected() {
245        let server = MockServer::start().await;
246        Mock::given(method("POST"))
247            .and(path("/connect/token"))
248            .respond_with(
249                ResponseTemplate::new(400)
250                    .set_body_string(r#"{"error":"invalid_client"}"#)
251                    .insert_header("content-type", "application/json"),
252            )
253            .mount(&server)
254            .await;
255
256        let err = client(&server)
257            .authenticate(&test_token())
258            .await
259            .expect_err("should fail");
260
261        assert!(
262            matches!(err, AuthError::Rejected),
263            "expected Rejected, got {err:?}"
264        );
265    }
266
267    /// A token whose claims segment is `claims`, so a test can omit or corrupt the organisation.
268    fn token_with_claims(claims: serde_json::Value) -> String {
269        let payload = bitwarden_encoding::B64Url::from(claims.to_string().as_bytes());
270        format!("header.{payload}.signature")
271    }
272
273    #[tokio::test]
274    async fn access_token_without_organization_claim_is_protocol_error() {
275        let token = token_with_claims(serde_json::json!({
276            "exp": 1_900_000_000u64,
277            "sub": "access-connector",
278            "scope": ["api.pam.rotation"],
279        }));
280
281        let err = authenticate_against(&token).await;
282        assert!(
283            matches!(err, AuthError::Protocol),
284            "expected Protocol, got {err:?}"
285        );
286    }
287
288    #[tokio::test]
289    async fn unparseable_organization_claim_is_protocol_error() {
290        let token = token_with_claims(serde_json::json!({
291            "exp": 1_900_000_000u64,
292            "sub": "access-connector",
293            "scope": ["api.pam.rotation"],
294            "organization": "not-a-uuid",
295        }));
296
297        let err = authenticate_against(&token).await;
298        assert!(
299            matches!(err, AuthError::Protocol),
300            "expected Protocol, got {err:?}"
301        );
302    }
303
304    #[tokio::test]
305    async fn access_token_that_is_not_a_jwt_is_protocol_error() {
306        let err = authenticate_against("opaque-token").await;
307        assert!(
308            matches!(err, AuthError::Protocol),
309            "expected Protocol, got {err:?}"
310        );
311    }
312
313    /// Serve `access_token` from a 200 identity response and return the resulting error.
314    async fn authenticate_against(access_token: &str) -> AuthError {
315        let server = MockServer::start().await;
316        let body = format!(
317            r#"{{"access_token":"{access_token}","expires_in":3600,"encrypted_payload":"2.abc==|def==|ghi=="}}"#
318        );
319        Mock::given(method("POST"))
320            .and(path("/connect/token"))
321            .respond_with(
322                ResponseTemplate::new(200)
323                    .set_body_string(body)
324                    .insert_header("content-type", "application/json"),
325            )
326            .mount(&server)
327            .await;
328
329        client(&server)
330            .authenticate(&test_token())
331            .await
332            .expect_err("authentication must fail")
333    }
334
335    #[tokio::test]
336    async fn invalid_grant_gives_rejected() {
337        let server = MockServer::start().await;
338        Mock::given(method("POST"))
339            .and(path("/connect/token"))
340            .respond_with(
341                ResponseTemplate::new(401)
342                    .set_body_string(r#"{"error":"invalid_grant"}"#)
343                    .insert_header("content-type", "application/json"),
344            )
345            .mount(&server)
346            .await;
347
348        let err = client(&server)
349            .authenticate(&test_token())
350            .await
351            .expect_err("should fail");
352
353        assert!(matches!(err, AuthError::Rejected));
354    }
355
356    #[tokio::test]
357    async fn unauthorized_client_gives_rejected() {
358        let server = MockServer::start().await;
359        Mock::given(method("POST"))
360            .and(path("/connect/token"))
361            .respond_with(
362                ResponseTemplate::new(401)
363                    .set_body_string(r#"{"error":"unauthorized_client"}"#)
364                    .insert_header("content-type", "application/json"),
365            )
366            .mount(&server)
367            .await;
368
369        let err = client(&server)
370            .authenticate(&test_token())
371            .await
372            .expect_err("should fail");
373
374        assert!(matches!(err, AuthError::Rejected));
375    }
376
377    #[tokio::test]
378    async fn server_error_gives_transient() {
379        let server = MockServer::start().await;
380        Mock::given(method("POST"))
381            .and(path("/connect/token"))
382            .respond_with(ResponseTemplate::new(500))
383            .mount(&server)
384            .await;
385
386        let err = client(&server)
387            .authenticate(&test_token())
388            .await
389            .expect_err("should fail");
390
391        assert!(
392            matches!(err, AuthError::Transient(_)),
393            "expected Transient, got {err:?}"
394        );
395    }
396
397    #[tokio::test]
398    async fn too_many_requests_gives_transient() {
399        let server = MockServer::start().await;
400        Mock::given(method("POST"))
401            .and(path("/connect/token"))
402            .respond_with(ResponseTemplate::new(429))
403            .mount(&server)
404            .await;
405
406        let err = client(&server)
407            .authenticate(&test_token())
408            .await
409            .expect_err("should fail");
410
411        assert!(matches!(err, AuthError::Transient(_)));
412    }
413
414    #[tokio::test]
415    async fn malformed_success_body_gives_protocol() {
416        let server = MockServer::start().await;
417        Mock::given(method("POST"))
418            .and(path("/connect/token"))
419            .respond_with(
420                ResponseTemplate::new(200)
421                    .set_body_string("not json at all")
422                    .insert_header("content-type", "application/json"),
423            )
424            .mount(&server)
425            .await;
426
427        let err = client(&server)
428            .authenticate(&test_token())
429            .await
430            .expect_err("should fail");
431
432        assert!(
433            matches!(err, AuthError::Protocol),
434            "expected Protocol, got {err:?}"
435        );
436    }
437
438    /// The identity server emits snake_case `encrypted_payload`; a camelCase field alone must not
439    /// parse.
440    #[tokio::test]
441    async fn camel_case_encrypted_payload_gives_protocol() {
442        let server = MockServer::start().await;
443        Mock::given(method("POST"))
444            .and(path("/connect/token"))
445            .respond_with(
446                ResponseTemplate::new(200)
447                    .set_body_string(
448                        r#"{"access_token":"tok","expires_in":3600,"encryptedPayload":"2.abc==|def==|ghi=="}"#,
449                    )
450                    .insert_header("content-type", "application/json"),
451            )
452            .mount(&server)
453            .await;
454
455        let err = client(&server)
456            .authenticate(&test_token())
457            .await
458            .expect_err("should fail");
459
460        assert!(
461            matches!(err, AuthError::Protocol),
462            "expected Protocol for camelCase field, got {err:?}"
463        );
464    }
465
466    #[tokio::test]
467    async fn unknown_oauth_error_gives_protocol() {
468        let server = MockServer::start().await;
469        Mock::given(method("POST"))
470            .and(path("/connect/token"))
471            .respond_with(
472                ResponseTemplate::new(400)
473                    .set_body_string(r#"{"error":"some_other_error"}"#)
474                    .insert_header("content-type", "application/json"),
475            )
476            .mount(&server)
477            .await;
478
479        let err = client(&server)
480            .authenticate(&test_token())
481            .await
482            .expect_err("should fail");
483
484        assert!(
485            matches!(err, AuthError::Protocol),
486            "expected Protocol, got {err:?}"
487        );
488    }
489
490    #[tokio::test]
491    async fn secret_not_visible_in_error_messages() {
492        let server = MockServer::start().await;
493        Mock::given(method("POST"))
494            .and(path("/connect/token"))
495            .respond_with(ResponseTemplate::new(500))
496            .mount(&server)
497            .await;
498
499        let err = client(&server)
500            .authenticate(&test_token())
501            .await
502            .expect_err("should fail");
503
504        let err_str = format!("{err:?}");
505        assert!(
506            !err_str.contains("C2IgxjjLF7qSshsbwe8JGcbM075YXw"),
507            "error message must not contain client secret: {err_str}"
508        );
509    }
510}