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::{
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/// Claims is used to build JWT for Google Cloud.
39#[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/// Header is used to build RS256 JWT for Google Cloud OAuth2.
63#[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/// OAuth2 token response.
79#[derive(Deserialize)]
80struct TokenResponse {
81    access_token: String,
82    #[serde(default)]
83    expires_in: Option<u64>,
84}
85
86/// RequestSigner for Google service requests.
87#[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    /// Create a new builder with the specified service.
108    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    /// Set the OAuth2 scope.
118    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
119        self.scope = Some(scope.into());
120        self
121    }
122
123    /// Set the signer service account email used for query signing via IAMCredentials `signBlob`.
124    ///
125    /// This is required when generating signed URLs without an embedded service account private key
126    /// (e.g. ADC / WIF / impersonation tokens).
127    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    /// Set the region for the builder.
133    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    /// Exchange a service account for an access token.
143    ///
144    /// This method is used internally when a token is needed but only a service account
145    /// is available. It creates a JWT and exchanges it for an OAuth2 access token.
146    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        // Exchange JWT for access token
162        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            // Query signing - prefer ServiceAccount, otherwise use IAMCredentials signBlob if possible.
424            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            // Header authentication - prefer valid token, otherwise exchange from SA
459            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                        // Token expired, but we have SA, exchange for new token
470                        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                    // No token but have SA, exchange for token
489                    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    // 256 is specially chosen to avoid reallocation for most requests.
530    let mut f = String::with_capacity(256);
531
532    // Insert method
533    f.push_str(req.method.as_str());
534    f.push('\n');
535
536    // Insert encoded path
537    f.push_str(&canonical_uri(&req.path)?);
538    f.push('\n');
539
540    // Insert query
541    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    // Insert signed headers
551    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    // Insert HOST header if not present.
573    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&amp=%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            // base64([0x01, 0x02, 0x03]) -> hex signature "010203"
738            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}