1use http::{Uri, header};
19use log::debug;
20use percent_encoding::{percent_decode_str, utf8_percent_encode};
21use rsa::pkcs1v15::SigningKey;
22use rsa::pkcs8::DecodePrivateKey;
23use rsa::rand_core::OsRng;
24use rsa::signature::RandomizedSigner;
25use serde::{Deserialize, Serialize};
26use std::time::Duration;
27
28use reqsign_core::{
29 Context, Result, SignRequest, SigningCredential, SigningRequest, hash::hex_sha256, time::*,
30};
31
32use crate::constants::{
33 DEFAULT_SCOPE, GOOG_QUERY_ENCODE_SET, GOOG_URI_ENCODE_SET, GOOGLE_SCOPE,
34 TOKEN_OPERATION_HEADROOM,
35};
36use crate::credential::{Credential, ServiceAccount, Token};
37
38#[derive(Debug, Serialize)]
40struct Claims {
41 iss: String,
42 scope: String,
43 aud: String,
44 exp: u64,
45 iat: u64,
46}
47
48impl Claims {
49 fn new(client_email: &str, scope: &str) -> Self {
50 let current = Timestamp::now().as_second() as u64;
51
52 Claims {
53 iss: client_email.to_string(),
54 scope: scope.to_string(),
55 aud: "https://oauth2.googleapis.com/token".to_string(),
56 exp: current + 3600,
57 iat: current,
58 }
59 }
60}
61
62#[derive(Debug, Serialize)]
64struct JwtHeader {
65 alg: &'static str,
66 typ: &'static str,
67}
68
69impl JwtHeader {
70 fn rs256() -> Self {
71 Self {
72 alg: "RS256",
73 typ: "JWT",
74 }
75 }
76}
77
78#[derive(Deserialize)]
80struct TokenResponse {
81 access_token: String,
82 #[serde(default)]
83 expires_in: Option<u64>,
84}
85
86#[derive(Debug)]
88pub struct RequestSigner {
89 service: String,
90 region: String,
91 scope: Option<String>,
92 signer_email: Option<String>,
93}
94
95impl Default for RequestSigner {
96 fn default() -> Self {
97 Self {
98 service: String::new(),
99 region: "auto".to_string(),
100 scope: None,
101 signer_email: None,
102 }
103 }
104}
105
106impl RequestSigner {
107 pub fn new(service: impl Into<String>) -> Self {
109 Self {
110 service: service.into(),
111 region: "auto".to_string(),
112 scope: None,
113 signer_email: None,
114 }
115 }
116
117 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
119 self.scope = Some(scope.into());
120 self
121 }
122
123 pub fn with_signer_email(mut self, signer_email: impl Into<String>) -> Self {
129 self.signer_email = Some(signer_email.into());
130 self
131 }
132
133 pub fn with_region(mut self, region: impl Into<String>) -> Self {
135 self.region = region.into();
136 self
137 }
138
139 fn token_required_until(&self) -> Timestamp {
140 Timestamp::now() + TOKEN_OPERATION_HEADROOM
141 }
142
143 async fn exchange_token(&self, ctx: &Context, sa: &ServiceAccount) -> Result<Token> {
148 let scope = self
149 .scope
150 .clone()
151 .or_else(|| ctx.env_var(GOOGLE_SCOPE))
152 .unwrap_or_else(|| DEFAULT_SCOPE.to_string());
153
154 debug!("exchanging service account for token with scope: {scope}");
155
156 let jwt = reqsign_core::jwt::encode_rs256_pem(
157 &JwtHeader::rs256(),
158 &Claims::new(&sa.client_email, &scope),
159 sa.private_key.as_bytes(),
160 )?;
161
162 let body =
164 format!("grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion={jwt}");
165 let req = http::Request::builder()
166 .method(http::Method::POST)
167 .uri("https://oauth2.googleapis.com/token")
168 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
169 .body(body.into_bytes().into())
170 .map_err(|e| {
171 reqsign_core::Error::unexpected("failed to build HTTP request").with_source(e)
172 })?;
173
174 let resp = ctx.http_send(req).await?;
175
176 if resp.status() != http::StatusCode::OK {
177 let body = String::from_utf8_lossy(resp.body());
178 return Err(reqsign_core::Error::unexpected(format!(
179 "exchange token failed: {body}"
180 )));
181 }
182
183 let token_resp: TokenResponse = serde_json::from_slice(resp.body()).map_err(|e| {
184 reqsign_core::Error::unexpected("failed to parse token response").with_source(e)
185 })?;
186
187 let expires_at = token_resp
188 .expires_in
189 .map(|expires_in| Timestamp::now() + Duration::from_secs(expires_in));
190
191 Ok(Token {
192 access_token: token_resp.access_token,
193 expires_at,
194 })
195 }
196
197 fn build_token_auth(
198 &self,
199 parts: &mut http::request::Parts,
200 token: &Token,
201 ) -> Result<SigningRequest> {
202 let mut req = SigningRequest::build(parts)?;
203
204 req.headers.insert(header::AUTHORIZATION, {
205 let mut value: http::HeaderValue = format!("Bearer {}", token.access_token)
206 .parse()
207 .map_err(|e| {
208 reqsign_core::Error::unexpected("failed to parse header value").with_source(e)
209 })?;
210 value.set_sensitive(true);
211 value
212 });
213
214 Ok(req)
215 }
216
217 fn build_string_to_sign(
218 &self,
219 req: &mut SigningRequest,
220 client_email: &str,
221 now: Timestamp,
222 expires_in: Duration,
223 ) -> Result<(String, Vec<(String, String)>)> {
224 canonicalize_header(req)?;
225
226 let authentication_query = authentication_query(
227 req,
228 client_email,
229 now,
230 expires_in,
231 &self.service,
232 &self.region,
233 );
234 let canonical_query = canonicalize_query(req, &authentication_query);
235
236 let creq = canonical_request_string(req, &canonical_query)?;
237 let encoded_req = hex_sha256(creq.as_bytes());
238
239 let scope = format!(
240 "{}/{}/{}/goog4_request",
241 now.format_date(),
242 self.region,
243 self.service
244 );
245 debug!("calculated scope: {scope}");
246
247 let string_to_sign = {
248 let mut f = String::new();
249 f.push_str("GOOG4-RSA-SHA256");
250 f.push('\n');
251 f.push_str(&now.format_iso8601());
252 f.push('\n');
253 f.push_str(&scope);
254 f.push('\n');
255 f.push_str(&encoded_req);
256 f
257 };
258 debug!("calculated string to sign: {string_to_sign}");
259
260 Ok((string_to_sign, authentication_query))
261 }
262
263 fn sign_with_service_account(private_key_pem: &str, string_to_sign: &str) -> Result<String> {
264 let mut rng = OsRng;
265 let private_key = rsa::RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(|e| {
266 reqsign_core::Error::unexpected("failed to parse private key").with_source(e)
267 })?;
268 let signing_key = SigningKey::<rsa::sha2::Sha256>::new(private_key);
269 let signature = signing_key.sign_with_rng(&mut rng, string_to_sign.as_bytes());
270
271 Ok(signature.to_string())
272 }
273
274 fn build_signed_query_with_service_account(
275 &self,
276 parts: &mut http::request::Parts,
277 service_account: &ServiceAccount,
278 expires_in: Duration,
279 ) -> Result<(SigningRequest, Uri)> {
280 let original_uri = parts.uri.clone();
281 let mut req = SigningRequest::build(parts)?;
282 let now = Timestamp::now();
283
284 let (string_to_sign, authentication_query) =
285 self.build_string_to_sign(&mut req, &service_account.client_email, now, expires_in)?;
286 let signature =
287 Self::sign_with_service_account(&service_account.private_key, &string_to_sign)?;
288
289 let unsigned_uri = append_query_pairs(&original_uri, &authentication_query)?;
290 let final_uri =
291 append_query_fragment(&unsigned_uri, &format!("X-Goog-Signature={signature}"))?;
292
293 Ok((req, final_uri))
294 }
295
296 async fn sign_via_iamcredentials(
297 &self,
298 ctx: &Context,
299 token: &Token,
300 signer_email: &str,
301 payload: &[u8],
302 ) -> Result<String> {
303 #[derive(Serialize)]
304 struct SignBlobRequest<'a> {
305 payload: &'a str,
306 }
307
308 #[derive(Deserialize)]
309 #[serde(rename_all = "camelCase")]
310 struct SignBlobResponse {
311 signed_blob: String,
312 }
313
314 let payload_b64 = reqsign_core::hash::base64_encode(payload);
315 let body = serde_json::to_vec(&SignBlobRequest {
316 payload: &payload_b64,
317 })
318 .map_err(|e| {
319 reqsign_core::Error::unexpected("failed to encode signBlob request").with_source(e)
320 })?;
321
322 let req = http::Request::builder()
323 .method(http::Method::POST)
324 .uri(format!(
325 "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{signer_email}:signBlob"
326 ))
327 .header(header::CONTENT_TYPE, "application/json")
328 .header(header::AUTHORIZATION, {
329 let mut value: http::HeaderValue = format!("Bearer {}", token.access_token)
330 .parse()
331 .map_err(|e| {
332 reqsign_core::Error::unexpected("failed to parse header value")
333 .with_source(e)
334 })?;
335 value.set_sensitive(true);
336 value
337 })
338 .body(body.into())
339 .map_err(|e| {
340 reqsign_core::Error::unexpected("failed to build HTTP request").with_source(e)
341 })?;
342
343 let resp = ctx.http_send(req).await?;
344
345 if resp.status() != http::StatusCode::OK {
346 let body = String::from_utf8_lossy(resp.body());
347 return Err(reqsign_core::Error::unexpected(format!(
348 "iamcredentials signBlob failed: {body}"
349 )));
350 }
351
352 let sign_resp: SignBlobResponse = serde_json::from_slice(resp.body()).map_err(|e| {
353 reqsign_core::Error::unexpected("failed to parse signBlob response").with_source(e)
354 })?;
355
356 let signed = reqsign_core::hash::base64_decode(&sign_resp.signed_blob)?;
357
358 Ok(hex_encode_upper(&signed))
359 }
360
361 async fn build_signed_query_via_iamcredentials(
362 &self,
363 ctx: &Context,
364 parts: &mut http::request::Parts,
365 token: &Token,
366 signer_email: &str,
367 expires_in: Duration,
368 ) -> Result<(SigningRequest, Uri)> {
369 let original_uri = parts.uri.clone();
370 let mut req = SigningRequest::build(parts)?;
371 let now = Timestamp::now();
372
373 let (string_to_sign, authentication_query) =
374 self.build_string_to_sign(&mut req, signer_email, now, expires_in)?;
375 let signature = self
376 .sign_via_iamcredentials(ctx, token, signer_email, string_to_sign.as_bytes())
377 .await?;
378
379 let unsigned_uri = append_query_pairs(&original_uri, &authentication_query)?;
380 let final_uri =
381 append_query_fragment(&unsigned_uri, &format!("X-Goog-Signature={signature}"))?;
382
383 Ok((req, final_uri))
384 }
385}
386impl SignRequest for RequestSigner {
387 type Credential = Credential;
388
389 fn required_valid_until(
390 &self,
391 credential: &Self::Credential,
392 _expires_in: Option<Duration>,
393 ) -> Timestamp {
394 if credential
395 .service_account
396 .as_ref()
397 .is_some_and(ServiceAccount::is_valid)
398 {
399 Timestamp::now()
400 } else {
401 self.token_required_until()
402 }
403 }
404
405 async fn sign_request(
406 &self,
407 ctx: &Context,
408 req: &mut http::request::Parts,
409 credential: Option<&Self::Credential>,
410 expires_in: Option<Duration>,
411 ) -> Result<()> {
412 let Some(cred) = credential else {
413 return Ok(());
414 };
415
416 let required_until = self.required_valid_until(cred, expires_in);
417 if !cred.is_valid_at(required_until) {
418 return Err(reqsign_core::Error::credential_invalid(
419 "credential expires before the requested signing operation deadline",
420 ));
421 }
422
423 let (signing_req, final_uri) = match expires_in {
424 Some(expires) => {
426 if let Some(sa) = cred
427 .service_account
428 .as_ref()
429 .filter(|service_account| service_account.is_valid())
430 {
431 let (signing_req, uri) =
432 self.build_signed_query_with_service_account(req, sa, expires)?;
433 (signing_req, Some(uri))
434 } else if let (Some(token), Some(signer_email)) = (
435 cred.token.as_ref(),
436 self.signer_email
437 .as_deref()
438 .or(cred.signer_email.as_deref()),
439 ) {
440 if !token.is_valid_at(required_until) {
441 return Err(reqsign_core::Error::credential_invalid(
442 "token required for iamcredentials signBlob query signing",
443 ));
444 }
445
446 let (signing_req, uri) = self
447 .build_signed_query_via_iamcredentials(
448 ctx,
449 req,
450 token,
451 signer_email,
452 expires,
453 )
454 .await?;
455 (signing_req, Some(uri))
456 } else {
457 return Err(reqsign_core::Error::credential_invalid(
458 "service account or token + signer identity required for query signing",
459 ));
460 }
461 }
462 None => {
464 let token_required_until = self.token_required_until();
465 if let Some(token) = &cred.token {
466 if token.is_valid_at(token_required_until) {
467 (self.build_token_auth(req, token)?, None)
468 } else if let Some(sa) = cred
469 .service_account
470 .as_ref()
471 .filter(|service_account| service_account.is_valid())
472 {
473 debug!("token expired, exchanging service account for new token");
475 let new_token = self.exchange_token(ctx, sa).await?;
476 if !new_token.is_valid_at(self.token_required_until()) {
477 return Err(reqsign_core::Error::credential_invalid(
478 "exchanged token is not valid long enough for header authentication",
479 ));
480 }
481 (self.build_token_auth(req, &new_token)?, None)
482 } else {
483 return Err(reqsign_core::Error::credential_invalid(
484 "token expired and no service account available",
485 ));
486 }
487 } else if let Some(sa) = cred
488 .service_account
489 .as_ref()
490 .filter(|service_account| service_account.is_valid())
491 {
492 debug!("no token available, exchanging service account for token");
494 let token = self.exchange_token(ctx, sa).await?;
495 if !token.is_valid_at(self.token_required_until()) {
496 return Err(reqsign_core::Error::credential_invalid(
497 "exchanged token is not valid long enough for header authentication",
498 ));
499 }
500 (self.build_token_auth(req, &token)?, None)
501 } else {
502 return Err(reqsign_core::Error::credential_invalid(
503 "no valid credential available",
504 ));
505 }
506 }
507 };
508
509 signing_req.apply(req).map_err(|e| {
510 reqsign_core::Error::unexpected("failed to apply signing request").with_source(e)
511 })?;
512 if let Some(uri) = final_uri {
513 req.uri = uri;
514 }
515 Ok(())
516 }
517}
518
519fn hex_encode_upper(bytes: &[u8]) -> String {
520 use std::fmt::Write;
521
522 let mut out = String::with_capacity(bytes.len() * 2);
523 for b in bytes {
524 write!(&mut out, "{:02X}", b).expect("writing to string must succeed");
525 }
526 out
527}
528
529fn canonical_request_string(
530 req: &SigningRequest,
531 canonical_query: &[(String, String)],
532) -> Result<String> {
533 let mut f = String::with_capacity(256);
535
536 f.push_str(req.method.as_str());
538 f.push('\n');
539
540 f.push_str(&canonical_uri(&req.path)?);
542 f.push('\n');
543
544 f.push_str(
546 &canonical_query
547 .iter()
548 .map(|(key, value)| format!("{key}={value}"))
549 .collect::<Vec<_>>()
550 .join("&"),
551 );
552 f.push('\n');
553
554 let signed_headers = req.header_name_to_vec_sorted();
556 for header in signed_headers.iter() {
557 let mut value = req.headers[*header].clone();
558 SigningRequest::header_value_normalize(&mut value);
559 f.push_str(header);
560 f.push(':');
561 f.push_str(value.to_str().map_err(|e| {
562 reqsign_core::Error::request_invalid("invalid signed header value").with_source(e)
563 })?);
564 f.push('\n');
565 }
566 f.push('\n');
567 f.push_str(&signed_headers.join(";"));
568 f.push('\n');
569 f.push_str("UNSIGNED-PAYLOAD");
570
571 debug!("canonical request string: {f}");
572 Ok(f)
573}
574
575fn canonicalize_header(req: &mut SigningRequest) -> Result<()> {
576 if req.headers.get(header::HOST).is_none() {
578 req.headers.insert(
579 header::HOST,
580 req.authority.as_str().parse().map_err(|e| {
581 reqsign_core::Error::unexpected("failed to parse host header").with_source(e)
582 })?,
583 );
584 }
585
586 Ok(())
587}
588
589fn authentication_query(
590 req: &SigningRequest,
591 client_email: &str,
592 now: Timestamp,
593 expires_in: Duration,
594 service: &str,
595 region: &str,
596) -> Vec<(String, String)> {
597 vec![
598 ("X-Goog-Algorithm".into(), "GOOG4-RSA-SHA256".into()),
599 (
600 "X-Goog-Credential".into(),
601 format!(
602 "{}/{}/{}/{}/goog4_request",
603 client_email,
604 now.format_date(),
605 region,
606 service
607 ),
608 ),
609 ("X-Goog-Date".into(), now.format_iso8601()),
610 ("X-Goog-Expires".into(), expires_in.as_secs().to_string()),
611 (
612 "X-Goog-SignedHeaders".into(),
613 req.header_name_to_vec_sorted().join(";"),
614 ),
615 ]
616}
617
618fn canonicalize_query(
619 req: &SigningRequest,
620 authentication_query: &[(String, String)],
621) -> Vec<(String, String)> {
622 let mut query = req
623 .query
624 .iter()
625 .chain(authentication_query)
626 .map(|(k, v)| {
627 (
628 utf8_percent_encode(k, &GOOG_QUERY_ENCODE_SET).to_string(),
629 utf8_percent_encode(v, &GOOG_QUERY_ENCODE_SET).to_string(),
630 )
631 })
632 .collect::<Vec<_>>();
633 query.sort();
634 query
635}
636
637fn canonical_uri(path: &str) -> Result<String> {
638 path.split('/')
639 .map(|segment| {
640 let decoded = percent_decode_str(segment).decode_utf8().map_err(|e| {
641 reqsign_core::Error::request_invalid("failed to decode URI path segment")
642 .with_source(e)
643 })?;
644 Ok(utf8_percent_encode(&decoded, &GOOG_URI_ENCODE_SET).to_string())
645 })
646 .collect::<Result<Vec<_>>>()
647 .map(|segments| segments.join("/"))
648}
649
650fn append_query_pairs(uri: &Uri, pairs: &[(String, String)]) -> Result<Uri> {
651 let mut pairs = pairs
652 .iter()
653 .map(|(key, value)| {
654 (
655 utf8_percent_encode(key, &GOOG_QUERY_ENCODE_SET).to_string(),
656 utf8_percent_encode(value, &GOOG_QUERY_ENCODE_SET).to_string(),
657 )
658 })
659 .collect::<Vec<_>>();
660 pairs.sort();
661 let fragment = pairs
662 .into_iter()
663 .map(|(key, value)| format!("{key}={value}"))
664 .collect::<Vec<_>>()
665 .join("&");
666 append_query_fragment(uri, &fragment)
667}
668
669fn append_query_fragment(uri: &Uri, fragment: &str) -> Result<Uri> {
670 if fragment.is_empty() {
671 return Ok(uri.clone());
672 }
673
674 let mut value = uri.to_string();
675 if uri.query().is_none() {
676 value.push('?');
677 } else if !value.ends_with('?') && !value.ends_with('&') {
678 value.push('&');
679 }
680 value.push_str(fragment);
681
682 value.parse().map_err(|e| {
683 reqsign_core::Error::request_invalid("failed to append signing query").with_source(e)
684 })
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use bytes::Bytes;
691 use http::header;
692 use reqsign_core::{ErrorKind, HttpSend, ProvideCredential, Signer};
693 use rsa::pkcs8::{EncodePrivateKey, LineEnding};
694 use std::sync::atomic::{AtomicUsize, Ordering};
695 use std::sync::{Arc, Mutex};
696
697 const RAW_QUERY: &str = "slash=%2F&hash=%23&=%26&equals=%3D&space=%20&encoded-plus=%2B&literal-plus=+&double=%252F&dup=first&dup=second&=empty-key&empty=&flag&flag=&";
698
699 #[derive(Debug, Default)]
700 struct Recorded {
701 payload_b64: Option<String>,
702 }
703
704 #[derive(Clone, Debug)]
705 struct MockHttpSend {
706 recorded: Arc<Mutex<Recorded>>,
707 expected_signer_email: String,
708 }
709
710 impl Default for MockHttpSend {
711 fn default() -> Self {
712 Self {
713 recorded: Arc::default(),
714 expected_signer_email: "test-signer@example.com".to_string(),
715 }
716 }
717 }
718
719 impl MockHttpSend {
720 fn with_expected_signer_email(mut self, signer_email: &str) -> Self {
721 self.expected_signer_email = signer_email.to_string();
722 self
723 }
724 }
725
726 impl HttpSend for MockHttpSend {
727 async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
728 assert_eq!(req.method(), http::Method::POST);
729 assert_eq!(
730 req.uri().to_string(),
731 format!(
732 "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:signBlob",
733 self.expected_signer_email
734 )
735 );
736 assert_eq!(
737 req.headers()
738 .get(header::CONTENT_TYPE)
739 .expect("content-type must exist")
740 .to_str()
741 .expect("content-type must be valid string"),
742 "application/json"
743 );
744 assert_eq!(
745 req.headers()
746 .get(header::AUTHORIZATION)
747 .expect("authorization must exist")
748 .to_str()
749 .expect("authorization must be valid string"),
750 "Bearer test-access-token"
751 );
752
753 let value: serde_json::Value =
754 serde_json::from_slice(req.body()).expect("body must be valid json");
755 let payload_b64 = value
756 .get("payload")
757 .and_then(|v| v.as_str())
758 .expect("payload must exist")
759 .to_string();
760
761 self.recorded.lock().unwrap().payload_b64 = Some(payload_b64);
762
763 let body = br#"{"signedBlob":"AQID"}"#;
765 Ok(http::Response::builder()
766 .status(http::StatusCode::OK)
767 .body(body.as_slice().into())
768 .expect("response must build"))
769 }
770 }
771
772 #[derive(Debug)]
773 struct OAuthTokenHttpSend;
774
775 impl HttpSend for OAuthTokenHttpSend {
776 async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
777 assert_eq!(req.method(), http::Method::POST);
778 assert_eq!(req.uri(), "https://oauth2.googleapis.com/token");
779 Ok(http::Response::builder()
780 .status(http::StatusCode::OK)
781 .body(Bytes::from_static(br#"{"access_token":"legacy-token"}"#))
782 .expect("response must build"))
783 }
784 }
785
786 #[derive(Debug)]
787 struct RepeatingProvider {
788 credential: Credential,
789 calls: Arc<AtomicUsize>,
790 }
791
792 impl ProvideCredential for RepeatingProvider {
793 type Credential = Credential;
794
795 async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
796 self.calls.fetch_add(1, Ordering::SeqCst);
797 Ok(Some(self.credential.clone()))
798 }
799 }
800
801 fn query_get<'a>(query: &'a str, key: &str) -> Option<&'a str> {
802 query.split('&').find_map(|kv| {
803 let (k, v) = kv.split_once('=')?;
804 if k == key { Some(v) } else { None }
805 })
806 }
807
808 fn parse_goog_date_to_timestamp(v: &str) -> Timestamp {
809 let year = &v[0..4];
810 let month = &v[4..6];
811 let day = &v[6..8];
812 let hour = &v[9..11];
813 let minute = &v[11..13];
814 let second = &v[13..15];
815 let rfc3339 = format!("{year}-{month}-{day}T{hour}:{minute}:{second}Z");
816 rfc3339.parse().expect("date must parse")
817 }
818
819 #[tokio::test]
820 async fn test_signed_url_via_iamcredentials_sign_blob() -> Result<()> {
821 let mock_http = MockHttpSend::default();
822 let ctx = Context::new().with_http_send(mock_http.clone());
823
824 let signer = RequestSigner::new("storage").with_signer_email("test-signer@example.com");
825
826 let cred = Credential::with_token(Token {
827 access_token: "test-access-token".to_string(),
828 expires_at: Some(Timestamp::now() + Duration::from_secs(60)),
829 });
830 assert!(!cred.is_valid());
831
832 let expires_in = Duration::from_secs(3600);
833 let original_uri =
834 format!("https://storage.googleapis.com/test-bucket/test%2Fobject?{RAW_QUERY}");
835
836 let mut builder = http::Request::builder();
837 builder = builder.method(http::Method::GET);
838 builder = builder.uri(&original_uri);
839 let req = builder.body(Bytes::new()).expect("request must build");
840 let (mut parts, _body) = req.into_parts();
841
842 signer
843 .sign_request(&ctx, &mut parts, Some(&cred), Some(expires_in))
844 .await?;
845
846 let query = parts.uri.query().expect("signed url must have query");
847 assert!(
848 parts
849 .uri
850 .to_string()
851 .starts_with(&format!("{original_uri}X-Goog-Algorithm="))
852 );
853 assert_eq!(
854 query_get(query, "X-Goog-Signature").expect("signature must exist"),
855 "010203"
856 );
857
858 let goog_date = query_get(query, "X-Goog-Date").expect("date must exist");
859 let now = parse_goog_date_to_timestamp(goog_date);
860
861 let mut builder = http::Request::builder();
862 builder = builder.method(http::Method::GET);
863 builder = builder.uri(&original_uri);
864 let req = builder.body(Bytes::new()).expect("request must build");
865 let (mut parts_for_rebuild, _body) = req.into_parts();
866
867 let mut signing_req = SigningRequest::build(&mut parts_for_rebuild)?;
868 let (string_to_sign, authentication_query) = signer.build_string_to_sign(
869 &mut signing_req,
870 "test-signer@example.com",
871 now,
872 expires_in,
873 )?;
874 let canonical_query = canonicalize_query(&signing_req, &authentication_query);
875 assert_eq!(
876 canonical_uri(&signing_req.path)?,
877 "/test-bucket/test%2Fobject"
878 );
879 assert!(canonical_query.contains(&("literal-plus".to_string(), "%2B".to_string())));
880 assert!(canonical_query.contains(&("double".to_string(), "%252F".to_string())));
881 let expected_payload_b64 = reqsign_core::hash::base64_encode(string_to_sign.as_bytes());
882
883 let recorded_payload_b64 = mock_http
884 .recorded
885 .lock()
886 .unwrap()
887 .payload_b64
888 .clone()
889 .expect("payload must be recorded");
890
891 assert_eq!(recorded_payload_b64, expected_payload_b64);
892
893 Ok(())
894 }
895
896 #[tokio::test]
897 async fn query_signing_uses_credential_signer_email() -> Result<()> {
898 let signer_email = "inferred-signer@example.com";
899 let ctx = Context::new()
900 .with_http_send(MockHttpSend::default().with_expected_signer_email(signer_email));
901 let credential = Credential::with_token(Token {
902 access_token: "test-access-token".to_string(),
903 expires_at: Some(Timestamp::now() + Duration::from_secs(60)),
904 })
905 .with_signer_email(signer_email);
906 let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
907 .body(())?
908 .into_parts()
909 .0;
910
911 RequestSigner::new("storage")
912 .sign_request(
913 &ctx,
914 &mut parts,
915 Some(&credential),
916 Some(Duration::from_secs(300)),
917 )
918 .await?;
919
920 assert!(
921 query_get(
922 parts.uri.query().expect("signed URL query must exist"),
923 "X-Goog-Credential"
924 )
925 .expect("credential query must exist")
926 .starts_with("inferred-signer%40example.com%2F")
927 );
928 Ok(())
929 }
930
931 #[tokio::test]
932 async fn explicit_signer_email_overrides_credential_identity() -> Result<()> {
933 let explicit_email = "explicit-signer@example.com";
934 let ctx = Context::new()
935 .with_http_send(MockHttpSend::default().with_expected_signer_email(explicit_email));
936 let credential = Credential::with_token(Token {
937 access_token: "test-access-token".to_string(),
938 expires_at: Some(Timestamp::now() + Duration::from_secs(60)),
939 })
940 .with_signer_email("inferred-signer@example.com");
941 let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
942 .body(())?
943 .into_parts()
944 .0;
945
946 RequestSigner::new("storage")
947 .with_signer_email(explicit_email)
948 .sign_request(
949 &ctx,
950 &mut parts,
951 Some(&credential),
952 Some(Duration::from_secs(300)),
953 )
954 .await?;
955
956 assert!(
957 query_get(
958 parts.uri.query().expect("signed URL query must exist"),
959 "X-Goog-Credential"
960 )
961 .expect("credential query must exist")
962 .starts_with("explicit-signer%40example.com%2F")
963 );
964 Ok(())
965 }
966
967 #[tokio::test]
968 async fn signer_refreshes_near_expiry_token_without_binding_it_to_signed_url_lifetime()
969 -> Result<()> {
970 let mock_http = MockHttpSend::default();
971 let ctx = Context::new().with_http_send(mock_http);
972 let credential = Credential::with_token(Token {
973 access_token: "test-access-token".to_string(),
974 expires_at: Some(Timestamp::now() + Duration::from_secs(60)),
975 });
976 assert!(!credential.is_valid());
977
978 let calls = Arc::new(AtomicUsize::new(0));
979 let provider = RepeatingProvider {
980 credential,
981 calls: calls.clone(),
982 };
983 let signer = Signer::new(
984 ctx,
985 provider,
986 RequestSigner::new("storage").with_signer_email("test-signer@example.com"),
987 );
988
989 for _ in 0..2 {
990 let mut parts = http::Request::get("https://storage.googleapis.com/test-bucket/object")
991 .body(())?
992 .into_parts()
993 .0;
994
995 signer
996 .sign(&mut parts, Some(Duration::from_secs(3600)))
997 .await?;
998 assert!(
999 parts
1000 .uri
1001 .query()
1002 .expect("signed URL query must exist")
1003 .contains("X-Goog-Signature=010203")
1004 );
1005 }
1006
1007 assert_eq!(calls.load(Ordering::SeqCst), 2);
1008 Ok(())
1009 }
1010
1011 #[tokio::test]
1012 async fn bearer_authentication_preserves_uri_and_header_values() -> Result<()> {
1013 let signer = RequestSigner::new("storage");
1014 let credential = Credential::with_token(Token {
1015 access_token: "test-access-token".to_string(),
1016 expires_at: None,
1017 })
1018 .with_signer_email("signer@example.com");
1019 let original_uri = format!("https://storage.googleapis.com/bucket/object?{RAW_QUERY}");
1020 let mut parts = http::Request::get(&original_uri)
1021 .header("x-custom", " value ")
1022 .body(())?
1023 .into_parts()
1024 .0;
1025
1026 signer
1027 .sign_request(&Context::new(), &mut parts, Some(&credential), None)
1028 .await?;
1029
1030 assert_eq!(parts.uri.to_string(), original_uri);
1031 assert_eq!(parts.headers["x-custom"], " value ");
1032 assert_eq!(
1033 parts.headers[header::AUTHORIZATION],
1034 "Bearer test-access-token"
1035 );
1036 Ok(())
1037 }
1038
1039 #[tokio::test]
1040 async fn bearer_authentication_uses_token_inside_refresh_window() -> Result<()> {
1041 let signer = RequestSigner::new("storage");
1042 let credential = Credential::with_token(Token {
1043 access_token: "test-access-token".to_string(),
1044 expires_at: Some(Timestamp::now() + Duration::from_secs(30)),
1045 });
1046 assert!(!credential.is_valid());
1047
1048 let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
1049 .body(())?
1050 .into_parts()
1051 .0;
1052
1053 signer
1054 .sign_request(&Context::new(), &mut parts, Some(&credential), None)
1055 .await?;
1056
1057 assert_eq!(
1058 parts.headers[header::AUTHORIZATION],
1059 "Bearer test-access-token"
1060 );
1061 Ok(())
1062 }
1063
1064 #[tokio::test]
1065 async fn service_account_exchange_preserves_optional_expiration_response() -> Result<()> {
1066 let private_key = rsa::RsaPrivateKey::new(&mut OsRng, 1024)
1067 .expect("private key must generate")
1068 .to_pkcs8_pem(LineEnding::LF)
1069 .expect("private key must encode")
1070 .to_string();
1071 let credential = Credential::with_service_account(ServiceAccount {
1072 private_key,
1073 client_email: "signer@example.iam.gserviceaccount.com".to_string(),
1074 });
1075 let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
1076 .body(())?
1077 .into_parts()
1078 .0;
1079
1080 RequestSigner::new("storage")
1081 .sign_request(
1082 &Context::new().with_http_send(OAuthTokenHttpSend),
1083 &mut parts,
1084 Some(&credential),
1085 None,
1086 )
1087 .await?;
1088
1089 assert_eq!(parts.headers[header::AUTHORIZATION], "Bearer legacy-token");
1090 Ok(())
1091 }
1092
1093 #[tokio::test]
1094 async fn signer_reuses_refreshed_token_for_operation_but_refreshes_next_time() -> Result<()> {
1095 let credential = Credential::with_token(Token {
1096 access_token: "test-access-token".to_string(),
1097 expires_at: Some(Timestamp::now() + Duration::from_secs(30)),
1098 });
1099 assert!(!credential.is_valid());
1100
1101 let calls = Arc::new(AtomicUsize::new(0));
1102 let provider = RepeatingProvider {
1103 credential,
1104 calls: calls.clone(),
1105 };
1106 let signer = Signer::new(Context::new(), provider, RequestSigner::new("storage"));
1107
1108 for _ in 0..2 {
1109 let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
1110 .body(())?
1111 .into_parts()
1112 .0;
1113
1114 signer.sign(&mut parts, None).await?;
1115 assert_eq!(
1116 parts.headers[header::AUTHORIZATION],
1117 "Bearer test-access-token"
1118 );
1119 }
1120
1121 assert_eq!(calls.load(Ordering::SeqCst), 2);
1122 Ok(())
1123 }
1124
1125 #[tokio::test]
1126 async fn bearer_authentication_rejects_token_shorter_than_operation_headroom() -> Result<()> {
1127 let signer = RequestSigner::new("storage");
1128 let credential = Credential::with_token(Token {
1129 access_token: "test-access-token".to_string(),
1130 expires_at: Some(Timestamp::now() + Duration::from_secs(5)),
1131 });
1132 let mut parts = http::Request::get("https://storage.googleapis.com/bucket/object")
1133 .body(())?
1134 .into_parts()
1135 .0;
1136 let original = parts.clone();
1137
1138 let err = signer
1139 .sign_request(&Context::new(), &mut parts, Some(&credential), None)
1140 .await
1141 .expect_err("token must cover the operation headroom");
1142
1143 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1144 assert_eq!(parts.uri, original.uri);
1145 assert_eq!(parts.headers, original.headers);
1146 Ok(())
1147 }
1148}