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