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