Skip to main content

systemprompt_security/google/
mod.rs

1//! Google service-account credentials: RS256 assertion in, access token out.
2//!
3//! Implements the JWT-bearer profile (RFC 7523) that Google's token endpoint
4//! accepts: sign a short assertion with the service account's private key,
5//! POST it as `urn:ietf:params:oauth:grant-type:jwt-bearer`, receive an access
6//! token valid for about an hour.
7//!
8//! This module is now one *implementation* of the credential model in
9//! [`crate::credential`] rather than a credential story of its own: it decides
10//! how a Google key is signed and exchanged, and nothing else. Parsing,
11//! caching, scoping and endpoint filling are generic and live there. A key
12//! file names itself by its `type` field (`SERVICE_ACCOUNT_TYPE`) and the
13//! decision is made once, in [`crate::credential::ProviderCredential::parse`].
14//! A token is cached per stored secret, so two providers sharing one secret
15//! share one token and two secrets never share one entry.
16//!
17//! Lives in the security crate rather than beside the gateway because
18//! boot-time model discovery needs the same token with none of the gateway's
19//! request machinery, and a credential minted in two places is two caches and
20//! two ways to be wrong.
21//!
22//! Copyright (c) systemprompt.io — Business Source License 1.1.
23//! See <https://systemprompt.io> for licensing details.
24
25use std::time::{Duration, SystemTime, UNIX_EPOCH};
26
27use jsonwebtoken::{Algorithm, EncodingKey, Header};
28use serde::{Deserialize, Serialize};
29
30use crate::credential::cache::clamp_ttl;
31use crate::credential::{CredentialError, http};
32
33// Why: Google limits a service-account JWT assertion's lifetime to one hour.
34const ASSERTION_TTL: Duration = Duration::from_secs(3600);
35
36// Why: the assertion is rejected if its `iat` is in the future by even a
37// second, and a host clock a little ahead of Google's is the ordinary case,
38// not the exotic one. Back-dating costs nothing: the lifetime is measured
39// from `iat`, so the token is not shortened, only started earlier.
40const CLOCK_SKEW: Duration = Duration::from_secs(60);
41
42const SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
43
44pub(crate) const SERVICE_ACCOUNT_TYPE: &str = "service_account";
45
46#[derive(Clone, Deserialize)]
47pub struct ServiceAccountKey {
48    pub client_email: String,
49    pub private_key: String,
50    // Why: the project the key belongs to is the only project a token minted
51    // from it can address, so it is where a `{project}` endpoint segment is
52    // filled from — never from the catalog, which would name a tenant in a
53    // file that ships with every image.
54    pub project_id: String,
55    #[serde(default = "default_token_uri")]
56    pub token_uri: String,
57}
58
59// Why: the derived `Debug` would print `private_key`, and this type is held
60// inside a `ProviderCredential` that the gateway logs the shape of. The only
61// fields worth seeing are the ones that identify the key, not the one that is
62// the key.
63impl std::fmt::Debug for ServiceAccountKey {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.debug_struct("ServiceAccountKey")
66            .field("client_email", &self.client_email)
67            .field("project_id", &self.project_id)
68            .field("token_uri", &self.token_uri)
69            .field("private_key", &"<redacted>")
70            .finish()
71    }
72}
73
74fn default_token_uri() -> String {
75    "https://oauth2.googleapis.com/token".to_owned()
76}
77
78#[derive(Debug, Serialize)]
79struct Assertion<'a> {
80    iss: &'a str,
81    scope: &'a str,
82    aud: &'a str,
83    iat: u64,
84    exp: u64,
85}
86
87#[derive(Debug, Deserialize)]
88struct TokenResponse {
89    access_token: String,
90    #[serde(default)]
91    expires_in: Option<u64>,
92}
93
94pub async fn access_token(
95    cache_key: &str,
96    key: &ServiceAccountKey,
97) -> Result<String, CredentialError> {
98    crate::credential::cache::token_for(cache_key, || async {
99        let response = exchange(key).await?;
100        Ok((response.access_token, clamp_ttl(response.expires_in)))
101    })
102    .await
103}
104
105async fn exchange(key: &ServiceAccountKey) -> Result<TokenResponse, CredentialError> {
106    let assertion = sign_assertion(key)?;
107    let form = [
108        (
109            "grant_type",
110            "urn:ietf:params:oauth:grant-type:jwt-bearer".to_owned(),
111        ),
112        ("assertion", assertion),
113    ];
114
115    let body = http::post_form(&key.token_uri, &form).await?;
116    serde_json::from_str(&body).map_err(CredentialError::UnreadableBody)
117}
118
119fn sign_assertion(key: &ServiceAccountKey) -> Result<String, CredentialError> {
120    let now = SystemTime::now()
121        .duration_since(UNIX_EPOCH)
122        .map_err(CredentialError::Clock)?
123        .as_secs()
124        .saturating_sub(CLOCK_SKEW.as_secs());
125
126    let claims = Assertion {
127        iss: &key.client_email,
128        scope: SCOPE,
129        aud: &key.token_uri,
130        iat: now,
131        exp: now + ASSERTION_TTL.as_secs(),
132    };
133
134    let encoding = EncodingKey::from_rsa_pem(key.private_key.as_bytes())
135        .map_err(CredentialError::SigningKey)?;
136
137    jsonwebtoken::encode(&Header::new(Algorithm::RS256), &claims, &encoding)
138        .map_err(CredentialError::Sign)
139}