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