bitwarden_access_connector/auth/
identity.rs1use 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
17pub(crate) struct AuthSuccess {
20 pub(crate) access_token: SensitiveString,
21 pub(crate) expires_in: u64,
23 pub(crate) encrypted_payload: String,
25 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#[derive(Debug, Error)]
39pub(crate) enum AuthError {
40 #[error("credential rejected by identity server")]
43 Rejected,
44
45 #[error("transient error contacting identity server: {0}")]
47 Transient(String),
48
49 #[error("identity server returned an unexpected response")]
51 Protocol,
52}
53
54pub(crate) struct IdentityClient {
56 http: reqwest::Client,
57 identity_url: String,
58}
59
60impl IdentityClient {
61 pub(crate) fn new(identity_url: String) -> Result<Self, reqwest::Error> {
64 let http = new_http_client_builder()
65 .timeout(REQUEST_TIMEOUT)
66 .redirect(reqwest::redirect::Policy::none())
69 .build()?;
70 Ok(Self { http, identity_url })
71 }
72
73 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 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 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
151fn 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 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 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 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 #[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}