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