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