Skip to main content

reqsign_google/
sign_request.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use 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/// Claims is used to build JWT for Google Cloud.
38#[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/// Header is used to build RS256 JWT for Google Cloud OAuth2.
62#[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/// OAuth2 token response.
78#[derive(Deserialize)]
79struct TokenResponse {
80    access_token: String,
81    #[serde(default)]
82    expires_in: Option<u64>,
83}
84
85/// RequestSigner for Google service requests.
86#[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    /// Create a new builder with the specified service.
107    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    /// Set the OAuth2 scope.
117    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
118        self.scope = Some(scope.into());
119        self
120    }
121
122    /// Set the signer service account email used for query signing via IAMCredentials `signBlob`.
123    ///
124    /// This is required when generating signed URLs without an embedded service account private key
125    /// (e.g. ADC / WIF / impersonation tokens).
126    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    /// Set the region for the builder.
132    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    /// Exchange a service account for an access token.
142    ///
143    /// This method is used internally when a token is needed but only a service account
144    /// is available. It creates a JWT and exchanges it for an OAuth2 access token.
145    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        // Exchange JWT for access token
161        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            // Query signing - prefer ServiceAccount, otherwise use IAMCredentials signBlob if possible.
423            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            // Header authentication - prefer valid token, otherwise exchange from SA
458            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                        // Token expired, but we have SA, exchange for new token
469                        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                    // No token but have SA, exchange for token
488                    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    // 256 is specially chosen to avoid reallocation for most requests.
529    let mut f = String::with_capacity(256);
530
531    // Insert method
532    f.push_str(req.method.as_str());
533    f.push('\n');
534
535    // Insert encoded path
536    f.push_str(&canonical_uri(&req.path)?);
537    f.push('\n');
538
539    // Insert query
540    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    // Insert signed headers
550    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    // Insert HOST header if not present.
572    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&amp=%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            // base64([0x01, 0x02, 0x03]) -> hex signature "010203"
737            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}