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::header;
19use jsonwebtoken::{Algorithm, EncodingKey, Header as JwtHeader};
20use log::debug;
21use percent_encoding::{percent_decode_str, utf8_percent_encode};
22use rsa::pkcs1v15::SigningKey;
23use rsa::pkcs8::DecodePrivateKey;
24use rsa::rand_core::OsRng;
25use rsa::signature::RandomizedSigner;
26use serde::{Deserialize, Serialize};
27use std::borrow::Cow;
28use std::time::Duration;
29
30use reqsign_core::{
31    Context, Result, SignRequest, SigningCredential, SigningMethod, SigningRequest,
32    hash::hex_sha256, time::*,
33};
34
35use crate::constants::{DEFAULT_SCOPE, GOOG_QUERY_ENCODE_SET, GOOG_URI_ENCODE_SET, GOOGLE_SCOPE};
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/// OAuth2 token response.
63#[derive(Deserialize)]
64struct TokenResponse {
65    access_token: String,
66    #[serde(default)]
67    expires_in: Option<u64>,
68}
69
70/// RequestSigner for Google service requests.
71#[derive(Debug)]
72pub struct RequestSigner {
73    service: String,
74    region: String,
75    scope: Option<String>,
76    signer_email: Option<String>,
77}
78
79impl Default for RequestSigner {
80    fn default() -> Self {
81        Self {
82            service: String::new(),
83            region: "auto".to_string(),
84            scope: None,
85            signer_email: None,
86        }
87    }
88}
89
90impl RequestSigner {
91    /// Create a new builder with the specified service.
92    pub fn new(service: impl Into<String>) -> Self {
93        Self {
94            service: service.into(),
95            region: "auto".to_string(),
96            scope: None,
97            signer_email: None,
98        }
99    }
100
101    /// Set the OAuth2 scope.
102    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
103        self.scope = Some(scope.into());
104        self
105    }
106
107    /// Set the signer service account email used for query signing via IAMCredentials `signBlob`.
108    ///
109    /// This is required when generating signed URLs without an embedded service account private key
110    /// (e.g. ADC / WIF / impersonation tokens).
111    pub fn with_signer_email(mut self, signer_email: impl Into<String>) -> Self {
112        self.signer_email = Some(signer_email.into());
113        self
114    }
115
116    /// Set the region for the builder.
117    pub fn with_region(mut self, region: impl Into<String>) -> Self {
118        self.region = region.into();
119        self
120    }
121
122    /// Exchange a service account for an access token.
123    ///
124    /// This method is used internally when a token is needed but only a service account
125    /// is available. It creates a JWT and exchanges it for an OAuth2 access token.
126    async fn exchange_token(&self, ctx: &Context, sa: &ServiceAccount) -> Result<Token> {
127        let scope = self
128            .scope
129            .clone()
130            .or_else(|| ctx.env_var(GOOGLE_SCOPE))
131            .unwrap_or_else(|| DEFAULT_SCOPE.to_string());
132
133        debug!("exchanging service account for token with scope: {scope}");
134
135        // Create JWT
136        let jwt = jsonwebtoken::encode(
137            &JwtHeader::new(Algorithm::RS256),
138            &Claims::new(&sa.client_email, &scope),
139            &EncodingKey::from_rsa_pem(sa.private_key.as_bytes()).map_err(|e| {
140                reqsign_core::Error::unexpected("failed to parse RSA private key").with_source(e)
141            })?,
142        )
143        .map_err(|e| reqsign_core::Error::unexpected("failed to encode JWT").with_source(e))?;
144
145        // Exchange JWT for access token
146        let body =
147            format!("grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion={jwt}");
148        let req = http::Request::builder()
149            .method(http::Method::POST)
150            .uri("https://oauth2.googleapis.com/token")
151            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
152            .body(body.into_bytes().into())
153            .map_err(|e| {
154                reqsign_core::Error::unexpected("failed to build HTTP request").with_source(e)
155            })?;
156
157        let resp = ctx.http_send(req).await?;
158
159        if resp.status() != http::StatusCode::OK {
160            let body = String::from_utf8_lossy(resp.body());
161            return Err(reqsign_core::Error::unexpected(format!(
162                "exchange token failed: {body}"
163            )));
164        }
165
166        let token_resp: TokenResponse = serde_json::from_slice(resp.body()).map_err(|e| {
167            reqsign_core::Error::unexpected("failed to parse token response").with_source(e)
168        })?;
169
170        let expires_at = token_resp
171            .expires_in
172            .map(|expires_in| Timestamp::now() + Duration::from_secs(expires_in));
173
174        Ok(Token {
175            access_token: token_resp.access_token,
176            expires_at,
177        })
178    }
179
180    fn build_token_auth(
181        &self,
182        parts: &mut http::request::Parts,
183        token: &Token,
184    ) -> Result<SigningRequest> {
185        let mut req = SigningRequest::build(parts)?;
186
187        req.headers.insert(header::AUTHORIZATION, {
188            let mut value: http::HeaderValue = format!("Bearer {}", &token.access_token)
189                .parse()
190                .map_err(|e| {
191                    reqsign_core::Error::unexpected("failed to parse header value").with_source(e)
192                })?;
193            value.set_sensitive(true);
194            value
195        });
196
197        Ok(req)
198    }
199
200    fn build_string_to_sign(
201        &self,
202        req: &mut SigningRequest,
203        client_email: &str,
204        now: Timestamp,
205        expires_in: Duration,
206    ) -> Result<String> {
207        canonicalize_header(req)?;
208
209        canonicalize_query(
210            req,
211            SigningMethod::Query(expires_in),
212            client_email,
213            now,
214            &self.service,
215            &self.region,
216        )?;
217
218        let creq = canonical_request_string(req)?;
219        let encoded_req = hex_sha256(creq.as_bytes());
220
221        let scope = format!(
222            "{}/{}/{}/goog4_request",
223            now.format_date(),
224            self.region,
225            self.service
226        );
227        debug!("calculated scope: {scope}");
228
229        let string_to_sign = {
230            let mut f = String::new();
231            f.push_str("GOOG4-RSA-SHA256");
232            f.push('\n');
233            f.push_str(&now.format_iso8601());
234            f.push('\n');
235            f.push_str(&scope);
236            f.push('\n');
237            f.push_str(&encoded_req);
238            f
239        };
240        debug!("calculated string to sign: {string_to_sign}");
241
242        Ok(string_to_sign)
243    }
244
245    fn sign_with_service_account(private_key_pem: &str, string_to_sign: &str) -> Result<String> {
246        let mut rng = OsRng;
247        let private_key = rsa::RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(|e| {
248            reqsign_core::Error::unexpected("failed to parse private key").with_source(e)
249        })?;
250        let signing_key = SigningKey::<sha2::Sha256>::new(private_key);
251        let signature = signing_key.sign_with_rng(&mut rng, string_to_sign.as_bytes());
252
253        Ok(signature.to_string())
254    }
255
256    fn build_signed_query_with_service_account(
257        &self,
258        parts: &mut http::request::Parts,
259        service_account: &ServiceAccount,
260        expires_in: Duration,
261    ) -> Result<SigningRequest> {
262        let mut req = SigningRequest::build(parts)?;
263        let now = Timestamp::now();
264
265        let string_to_sign =
266            self.build_string_to_sign(&mut req, &service_account.client_email, now, expires_in)?;
267        let signature =
268            Self::sign_with_service_account(&service_account.private_key, &string_to_sign)?;
269
270        req.query.push(("X-Goog-Signature".to_string(), signature));
271
272        Ok(req)
273    }
274
275    async fn sign_via_iamcredentials(
276        &self,
277        ctx: &Context,
278        token: &Token,
279        signer_email: &str,
280        payload: &[u8],
281    ) -> Result<String> {
282        #[derive(Serialize)]
283        struct SignBlobRequest<'a> {
284            payload: &'a str,
285        }
286
287        #[derive(Deserialize)]
288        #[serde(rename_all = "camelCase")]
289        struct SignBlobResponse {
290            signed_blob: String,
291        }
292
293        let payload_b64 = reqsign_core::hash::base64_encode(payload);
294        let body = serde_json::to_vec(&SignBlobRequest {
295            payload: &payload_b64,
296        })
297        .map_err(|e| {
298            reqsign_core::Error::unexpected("failed to encode signBlob request").with_source(e)
299        })?;
300
301        let req = http::Request::builder()
302            .method(http::Method::POST)
303            .uri(format!(
304                "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{signer_email}:signBlob"
305            ))
306            .header(header::CONTENT_TYPE, "application/json")
307            .header(header::AUTHORIZATION, {
308                let mut value: http::HeaderValue = format!("Bearer {}", &token.access_token)
309                    .parse()
310                    .map_err(|e| {
311                        reqsign_core::Error::unexpected("failed to parse header value")
312                            .with_source(e)
313                    })?;
314                value.set_sensitive(true);
315                value
316            })
317            .body(body.into())
318            .map_err(|e| {
319                reqsign_core::Error::unexpected("failed to build HTTP request").with_source(e)
320            })?;
321
322        let resp = ctx.http_send(req).await?;
323
324        if resp.status() != http::StatusCode::OK {
325            let body = String::from_utf8_lossy(resp.body());
326            return Err(reqsign_core::Error::unexpected(format!(
327                "iamcredentials signBlob failed: {body}"
328            )));
329        }
330
331        let sign_resp: SignBlobResponse = serde_json::from_slice(resp.body()).map_err(|e| {
332            reqsign_core::Error::unexpected("failed to parse signBlob response").with_source(e)
333        })?;
334
335        let signed = reqsign_core::hash::base64_decode(&sign_resp.signed_blob)?;
336
337        Ok(hex_encode_upper(&signed))
338    }
339
340    async fn build_signed_query_via_iamcredentials(
341        &self,
342        ctx: &Context,
343        parts: &mut http::request::Parts,
344        token: &Token,
345        signer_email: &str,
346        expires_in: Duration,
347    ) -> Result<SigningRequest> {
348        let mut req = SigningRequest::build(parts)?;
349        let now = Timestamp::now();
350
351        let string_to_sign = self.build_string_to_sign(&mut req, signer_email, now, expires_in)?;
352        let signature = self
353            .sign_via_iamcredentials(ctx, token, signer_email, string_to_sign.as_bytes())
354            .await?;
355
356        req.query.push(("X-Goog-Signature".to_string(), signature));
357
358        Ok(req)
359    }
360}
361impl SignRequest for RequestSigner {
362    type Credential = Credential;
363
364    async fn sign_request(
365        &self,
366        ctx: &Context,
367        req: &mut http::request::Parts,
368        credential: Option<&Self::Credential>,
369        expires_in: Option<Duration>,
370    ) -> Result<()> {
371        let Some(cred) = credential else {
372            return Ok(());
373        };
374
375        let signing_req = match expires_in {
376            // Query signing - prefer ServiceAccount, otherwise use IAMCredentials signBlob if possible.
377            Some(expires) => {
378                if let Some(sa) = cred.service_account.as_ref() {
379                    self.build_signed_query_with_service_account(req, sa, expires)?
380                } else if let (Some(token), Some(signer_email)) =
381                    (cred.token.as_ref(), self.signer_email.as_deref())
382                {
383                    if !token.is_valid() {
384                        return Err(reqsign_core::Error::credential_invalid(
385                            "token required for iamcredentials signBlob query signing",
386                        ));
387                    }
388
389                    self.build_signed_query_via_iamcredentials(
390                        ctx,
391                        req,
392                        token,
393                        signer_email,
394                        expires,
395                    )
396                    .await?
397                } else {
398                    return Err(reqsign_core::Error::credential_invalid(
399                        "service account or token + signer_email required for query signing",
400                    ));
401                }
402            }
403            // Header authentication - prefer valid token, otherwise exchange from SA
404            None => {
405                // Check if we have a valid token
406                if let Some(token) = &cred.token {
407                    if token.is_valid() {
408                        self.build_token_auth(req, token)?
409                    } else if let Some(sa) = &cred.service_account {
410                        // Token expired, but we have SA, exchange for new token
411                        debug!("token expired, exchanging service account for new token");
412                        let new_token = self.exchange_token(ctx, sa).await?;
413                        self.build_token_auth(req, &new_token)?
414                    } else {
415                        return Err(reqsign_core::Error::credential_invalid(
416                            "token expired and no service account available",
417                        ));
418                    }
419                } else if let Some(sa) = &cred.service_account {
420                    // No token but have SA, exchange for token
421                    debug!("no token available, exchanging service account for token");
422                    let token = self.exchange_token(ctx, sa).await?;
423                    self.build_token_auth(req, &token)?
424                } else {
425                    return Err(reqsign_core::Error::credential_invalid(
426                        "no valid credential available",
427                    ));
428                }
429            }
430        };
431
432        signing_req.apply(req).map_err(|e| {
433            reqsign_core::Error::unexpected("failed to apply signing request").with_source(e)
434        })
435    }
436}
437
438fn hex_encode_upper(bytes: &[u8]) -> String {
439    use std::fmt::Write;
440
441    let mut out = String::with_capacity(bytes.len() * 2);
442    for b in bytes {
443        write!(&mut out, "{:02X}", b).expect("writing to string must succeed");
444    }
445    out
446}
447
448fn canonical_request_string(req: &mut SigningRequest) -> Result<String> {
449    // 256 is specially chosen to avoid reallocation for most requests.
450    let mut f = String::with_capacity(256);
451
452    // Insert method
453    f.push_str(req.method.as_str());
454    f.push('\n');
455
456    // Insert encoded path
457    let path = percent_decode_str(&req.path)
458        .decode_utf8()
459        .map_err(|e| reqsign_core::Error::unexpected("failed to decode path").with_source(e))?;
460    f.push_str(&Cow::from(utf8_percent_encode(&path, &GOOG_URI_ENCODE_SET)));
461    f.push('\n');
462
463    // Insert query
464    f.push_str(&SigningRequest::query_to_string(
465        req.query.clone(),
466        "=",
467        "&",
468    ));
469    f.push('\n');
470
471    // Insert signed headers
472    let signed_headers = req.header_name_to_vec_sorted();
473    for header in signed_headers.iter() {
474        let value = &req.headers[*header];
475        f.push_str(header);
476        f.push(':');
477        f.push_str(value.to_str().expect("header value must be valid"));
478        f.push('\n');
479    }
480    f.push('\n');
481    f.push_str(&signed_headers.join(";"));
482    f.push('\n');
483    f.push_str("UNSIGNED-PAYLOAD");
484
485    debug!("canonical request string: {f}");
486    Ok(f)
487}
488
489fn canonicalize_header(req: &mut SigningRequest) -> Result<()> {
490    for (_, value) in req.headers.iter_mut() {
491        SigningRequest::header_value_normalize(value)
492    }
493
494    // Insert HOST header if not present.
495    if req.headers.get(header::HOST).is_none() {
496        req.headers.insert(
497            header::HOST,
498            req.authority.as_str().parse().map_err(|e| {
499                reqsign_core::Error::unexpected("failed to parse host header").with_source(e)
500            })?,
501        );
502    }
503
504    Ok(())
505}
506
507fn canonicalize_query(
508    req: &mut SigningRequest,
509    method: SigningMethod,
510    client_email: &str,
511    now: Timestamp,
512    service: &str,
513    region: &str,
514) -> Result<()> {
515    if let SigningMethod::Query(expire) = method {
516        req.query
517            .push(("X-Goog-Algorithm".into(), "GOOG4-RSA-SHA256".into()));
518        req.query.push((
519            "X-Goog-Credential".into(),
520            format!(
521                "{}/{}/{}/{}/goog4_request",
522                client_email,
523                now.format_date(),
524                region,
525                service
526            ),
527        ));
528        req.query.push(("X-Goog-Date".into(), now.format_iso8601()));
529        req.query
530            .push(("X-Goog-Expires".into(), expire.as_secs().to_string()));
531        req.query.push((
532            "X-Goog-SignedHeaders".into(),
533            req.header_name_to_vec_sorted().join(";"),
534        ));
535    }
536
537    // Return if query is empty.
538    if req.query.is_empty() {
539        return Ok(());
540    }
541
542    // Sort by param name
543    req.query.sort();
544
545    req.query = req
546        .query
547        .iter()
548        .map(|(k, v)| {
549            (
550                utf8_percent_encode(k, &GOOG_QUERY_ENCODE_SET).to_string(),
551                utf8_percent_encode(v, &GOOG_QUERY_ENCODE_SET).to_string(),
552            )
553        })
554        .collect();
555
556    Ok(())
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use bytes::Bytes;
563    use http::header;
564    use reqsign_core::HttpSend;
565    use std::sync::{Arc, Mutex};
566
567    #[derive(Debug, Default)]
568    struct Recorded {
569        payload_b64: Option<String>,
570    }
571
572    #[derive(Clone, Debug, Default)]
573    struct MockHttpSend {
574        recorded: Arc<Mutex<Recorded>>,
575    }
576    impl HttpSend for MockHttpSend {
577        async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
578            assert_eq!(req.method(), http::Method::POST);
579            assert_eq!(
580                req.uri().to_string(),
581                "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test-signer@example.com:signBlob"
582            );
583            assert_eq!(
584                req.headers()
585                    .get(header::CONTENT_TYPE)
586                    .expect("content-type must exist")
587                    .to_str()
588                    .expect("content-type must be valid string"),
589                "application/json"
590            );
591            assert_eq!(
592                req.headers()
593                    .get(header::AUTHORIZATION)
594                    .expect("authorization must exist")
595                    .to_str()
596                    .expect("authorization must be valid string"),
597                "Bearer test-access-token"
598            );
599
600            let value: serde_json::Value =
601                serde_json::from_slice(req.body()).expect("body must be valid json");
602            let payload_b64 = value
603                .get("payload")
604                .and_then(|v| v.as_str())
605                .expect("payload must exist")
606                .to_string();
607
608            self.recorded.lock().unwrap().payload_b64 = Some(payload_b64);
609
610            // base64([0x01, 0x02, 0x03]) -> hex signature "010203"
611            let body = br#"{"signedBlob":"AQID"}"#;
612            Ok(http::Response::builder()
613                .status(http::StatusCode::OK)
614                .body(body.as_slice().into())
615                .expect("response must build"))
616        }
617    }
618
619    fn query_get<'a>(query: &'a str, key: &str) -> Option<&'a str> {
620        query.split('&').find_map(|kv| {
621            let (k, v) = kv.split_once('=')?;
622            if k == key { Some(v) } else { None }
623        })
624    }
625
626    fn parse_goog_date_to_timestamp(v: &str) -> Timestamp {
627        let year = &v[0..4];
628        let month = &v[4..6];
629        let day = &v[6..8];
630        let hour = &v[9..11];
631        let minute = &v[11..13];
632        let second = &v[13..15];
633        let rfc3339 = format!("{year}-{month}-{day}T{hour}:{minute}:{second}Z");
634        rfc3339.parse().expect("date must parse")
635    }
636
637    #[tokio::test]
638    async fn test_signed_url_via_iamcredentials_sign_blob() -> Result<()> {
639        let mock_http = MockHttpSend::default();
640        let ctx = Context::new().with_http_send(mock_http.clone());
641
642        let signer = RequestSigner::new("storage").with_signer_email("test-signer@example.com");
643
644        let cred = Credential::with_token(Token {
645            access_token: "test-access-token".to_string(),
646            expires_at: None,
647        });
648
649        let expires_in = Duration::from_secs(60);
650
651        let mut builder = http::Request::builder();
652        builder = builder.method(http::Method::GET);
653        builder = builder.uri("https://storage.googleapis.com/test-bucket/test-object");
654        let req = builder.body(Bytes::new()).expect("request must build");
655        let (mut parts, _body) = req.into_parts();
656
657        signer
658            .sign_request(&ctx, &mut parts, Some(&cred), Some(expires_in))
659            .await?;
660
661        let query = parts.uri.query().expect("signed url must have query");
662        assert_eq!(
663            query_get(query, "X-Goog-Signature").expect("signature must exist"),
664            "010203"
665        );
666
667        let goog_date = query_get(query, "X-Goog-Date").expect("date must exist");
668        let now = parse_goog_date_to_timestamp(goog_date);
669
670        let mut builder = http::Request::builder();
671        builder = builder.method(http::Method::GET);
672        builder = builder.uri("https://storage.googleapis.com/test-bucket/test-object");
673        let req = builder.body(Bytes::new()).expect("request must build");
674        let (mut parts_for_rebuild, _body) = req.into_parts();
675
676        let mut signing_req = SigningRequest::build(&mut parts_for_rebuild)?;
677        let string_to_sign = signer.build_string_to_sign(
678            &mut signing_req,
679            "test-signer@example.com",
680            now,
681            expires_in,
682        )?;
683        let expected_payload_b64 = reqsign_core::hash::base64_encode(string_to_sign.as_bytes());
684
685        let recorded_payload_b64 = mock_http
686            .recorded
687            .lock()
688            .unwrap()
689            .payload_b64
690            .clone()
691            .expect("payload must be recorded");
692
693        assert_eq!(recorded_payload_b64, expected_payload_b64);
694
695        Ok(())
696    }
697}