1use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15use secrets_core::engine::{
16 CredentialShape, EngineDoc, EngineError, EngineResult, GeneratedCredential, PathDoc,
17 SecretsEngine, TtlDoc,
18};
19use secrets_core::lease::Lease;
20use secrets_core::mount::ConfigRoleStore;
21use secrets_core::storage::StorageBackend;
22use serde::{Deserialize, Serialize};
23use serde_json::json;
24use uuid::Uuid;
25
26const STORE: ConfigRoleStore = ConfigRoleStore::new("gworkspace/config/", "gworkspace/roles/");
27const MOUNT: &str = "gworkspace/creds/";
28const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
29const REVOKE_ENDPOINT: &str = "https://oauth2.googleapis.com/revoke";
30const JWT_BEARER_GRANT: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer";
31
32const ASSERTION_TTL_SECONDS: i64 = 600;
36
37const FALLBACK_ACCESS_TOKEN_TTL_SECONDS: i64 = 3600;
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct GworkspaceConfig {
46 #[serde(default)]
49 pub client_id: String,
50 #[serde(default)]
51 pub client_secret: String,
52 #[serde(default)]
55 pub refresh_token: Option<String>,
56 #[serde(default)]
58 pub service_account_key_json: Option<String>,
59}
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum Mode {
64 #[default]
66 RefreshToken,
67 DomainWideDelegation,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct RoleConfig {
74 pub target: String,
76 #[serde(default)]
77 pub mode: Mode,
78 #[serde(default)]
82 pub scopes: Vec<String>,
83 #[serde(default)]
86 pub subject: Option<String>,
87}
88
89#[derive(Debug, Deserialize)]
91struct ServiceAccountKey {
92 client_email: String,
93 private_key: String,
94}
95
96#[derive(Debug, Deserialize)]
97struct TokenResponse {
98 access_token: String,
99 #[serde(default)]
100 expires_in: Option<i64>,
101 #[serde(default)]
104 refresh_token: Option<String>,
105 #[serde(default)]
106 scope: Option<String>,
107}
108
109#[derive(Debug, Serialize, PartialEq, Eq)]
110struct AssertionClaims {
111 iss: String,
112 sub: String,
113 scope: String,
114 aud: String,
115 iat: i64,
116 exp: i64,
117}
118
119#[derive(Default)]
120pub struct GworkspaceEngine {
121 http: reqwest::Client,
122}
123
124impl GworkspaceEngine {
125 pub fn new() -> Self {
126 Self {
127 http: reqwest::Client::new(),
128 }
129 }
130
131 fn require_refresh_token(config: &GworkspaceConfig) -> EngineResult<&str> {
132 config.refresh_token.as_deref().filter(|t| !t.is_empty()).ok_or_else(|| {
133 EngineError::InvalidRequest(
134 "this gworkspace/config has no refresh_token. Complete the consent \
135 flow once and POST the resulting refresh token to the config path."
136 .into(),
137 )
138 })
139 }
140
141 fn service_account(config: &GworkspaceConfig) -> EngineResult<ServiceAccountKey> {
142 let raw = config
143 .service_account_key_json
144 .as_deref()
145 .filter(|k| !k.is_empty())
146 .ok_or_else(|| {
147 EngineError::InvalidRequest(
148 "domain_wide_delegation needs service_account_key_json in \
149 gworkspace/config"
150 .into(),
151 )
152 })?;
153 serde_json::from_str(raw).map_err(|e| {
154 EngineError::InvalidRequest(format!("service_account_key_json is not a valid key: {e}"))
155 })
156 }
157
158 fn assertion_claims(
163 service_account_email: &str,
164 role: &RoleConfig,
165 now: DateTime<Utc>,
166 ) -> EngineResult<AssertionClaims> {
167 let subject = role.subject.as_deref().filter(|s| !s.is_empty()).ok_or_else(|| {
168 EngineError::InvalidRequest(
169 "domain_wide_delegation requires `subject`: the email address of the \
170 user to impersonate"
171 .into(),
172 )
173 })?;
174 Ok(AssertionClaims {
175 iss: service_account_email.to_string(),
176 sub: subject.to_string(),
177 scope: role.scopes.join(" "),
178 aud: TOKEN_ENDPOINT.to_string(),
179 iat: now.timestamp(),
180 exp: now.timestamp() + ASSERTION_TTL_SECONDS,
181 })
182 }
183
184 fn sign_assertion(key: &ServiceAccountKey, claims: &AssertionClaims) -> EngineResult<String> {
185 let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(key.private_key.as_bytes())
186 .map_err(|e| {
187 EngineError::InvalidRequest(format!(
188 "service account private_key is not a valid RSA PEM: {e}"
189 ))
190 })?;
191 jsonwebtoken::encode(
192 &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
193 claims,
194 &encoding_key,
195 )
196 .map_err(|e| EngineError::Other(format!("failed to sign delegation assertion: {e}")))
197 }
198
199 async fn post_token_form(&self, form: &[(&str, &str)]) -> EngineResult<TokenResponse> {
200 let response = self
201 .http
202 .post(TOKEN_ENDPOINT)
203 .form(form)
204 .send()
205 .await
206 .map_err(|e| EngineError::Provider(format!("Google token request failed: {e}")))?;
207
208 let status = response.status();
209 let text = response.text().await.unwrap_or_default();
210 if !status.is_success() {
211 if text.contains("invalid_grant") {
215 return Err(EngineError::Provider(format!(
216 "Google returned invalid_grant — the authorisation is gone \
217 (revoked, expired, or the consent screen is still in Testing). \
218 Re-consent is required; retrying will not help. Body: {text}"
219 )));
220 }
221 return Err(EngineError::Provider(format!(
222 "Google returned {status}: {text}"
223 )));
224 }
225 serde_json::from_str(&text)
226 .map_err(|e| EngineError::Provider(format!("unexpected Google response: {e}")))
227 }
228
229 fn scope_description(role: &RoleConfig) -> Vec<String> {
230 let mut scoped = Vec::new();
231 match role.mode {
232 Mode::RefreshToken => {
233 scoped.push("identity:the user who granted consent".to_string());
234 }
235 Mode::DomainWideDelegation => scoped.push(format!(
236 "identity:{} (impersonated via domain-wide delegation)",
237 role.subject.as_deref().unwrap_or("UNSET")
238 )),
239 }
240 if role.scopes.is_empty() {
241 scoped.push("scopes:NONE — Google will reject the exchange".to_string());
242 } else {
243 scoped.extend(role.scopes.iter().map(|s| format!("scope:{s}")));
244 }
245 scoped
246 }
247
248 async fn persist_rotated_refresh_token(
251 storage: &dyn StorageBackend,
252 target: &str,
253 config: &GworkspaceConfig,
254 new_refresh_token: &str,
255 ) -> EngineResult<()> {
256 let mut updated = config.clone();
257 updated.refresh_token = Some(new_refresh_token.to_string());
258 let value = serde_json::to_value(&updated).map_err(|e| EngineError::Other(e.to_string()))?;
259 STORE
260 .handle_write::<GworkspaceConfig, RoleConfig>(
261 storage,
262 &format!("config/{target}"),
263 value,
264 )
265 .await
266 }
267}
268
269#[async_trait]
270impl SecretsEngine for GworkspaceEngine {
271 fn doc(&self) -> EngineDoc {
272 EngineDoc {
273 provider: "Google Workspace".to_string(),
274 mechanism: "brokered OAuth access tokens: the server holds the \
275 long-lived refresh token and exchanges it for a ~1 hour \
276 access token per request, so the consumer never sees the \
277 durable secret. Domain-wide delegation is available as a \
278 second mode."
279 .to_string(),
280 shape: CredentialShape::RefreshBroker,
281 revocable: false,
287 revoke_effect: "nothing at the provider. Google cannot invalidate an \
288 individual access token, so a revoked lease only \
289 deletes our record and stops renewal — the token keeps \
290 working for the rest of its hour. We deliberately do \
291 NOT call Google's /revoke on lease expiry: that would \
292 destroy the whole authorisation and require a human to \
293 re-consent. To do that on purpose, DELETE the config \
294 document and POST the refresh token to \
295 https://oauth2.googleapis.com/revoke."
296 .to_string(),
297 ttl: TtlDoc::range(
298 600,
299 3600,
300 "Google decides, and reports it in expires_in — about an hour in \
301 practice. The lease is set from that value rather than from a \
302 requested TTL, so it can never outlive the token.",
303 ),
304 scoping: "OAuth scopes only, chosen per role. There is no per-file or \
305 per-folder scoping in the credential: narrowness comes from \
306 picking drive.readonly or drive.metadata.readonly over drive, \
307 and from Drive ACLs on the content itself."
308 .to_string(),
309 root_credential: "a refresh token per authorised account (refresh_token \
310 mode), or a service-account key authorised for \
311 domain-wide delegation. Both live at \
312 gworkspace/config/{target} and are never readable back."
313 .to_string(),
314 paths: vec![
315 PathDoc::new(
316 "gworkspace/config/{target}",
317 &["POST", "GET", "DELETE"],
318 "sudo",
319 "register one account's OAuth client, refresh token or \
320 service-account key. GET reports only whether it is \
321 configured — no secret is ever returned.",
322 ),
323 PathDoc::new(
324 "gworkspace/roles/{role}",
325 &["POST", "GET", "DELETE"],
326 "create / read / sudo",
327 "define one consumer's mode, scopes and impersonated subject",
328 ),
329 PathDoc::new(
330 "gworkspace/creds/{role}",
331 &["GET"],
332 "read",
333 "exchange the stored grant for a short-lived access token and \
334 open a lease",
335 ),
336 PathDoc::new("gworkspace/help", &["GET"], "authenticated", "this document"),
337 ],
338 docs_url: Some("docs/delegation/google-workspace.md".to_string()),
339 caveats: vec![
340 "A refresh token expires after SEVEN DAYS while the OAuth consent \
341 screen's publishing status is still 'Testing'. This catches almost \
342 everyone once — publish the app."
343 .to_string(),
344 "Refresh tokens also die after six months of non-use, when the user \
345 revokes access, and in some cases on password change."
346 .to_string(),
347 "`invalid_grant` on refresh means the authorisation is gone and a \
348 human must re-consent. It is never a transient error, so never \
349 retry it as one."
350 .to_string(),
351 "Domain-wide delegation lets one service account impersonate ANY \
352 user in the domain for the granted scopes, with no user able to \
353 see or revoke it. Google's own guidance is to avoid it for new \
354 integrations — prefer per-user consent, i.e. refresh_token mode."
355 .to_string(),
356 "Shared drives are governed by ACL membership, not by scopes: the \
357 consenting user or impersonated subject must be an explicit member \
358 of the shared drive, and queries need corpora=drive with driveId."
359 .to_string(),
360 "Restricted Gmail and Drive scopes require app verification, and the \
361 most sensitive ones a third-party security assessment."
362 .to_string(),
363 ],
364 }
365 }
366
367 async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<serde_json::Value> {
368 STORE.handle_read::<RoleConfig>(storage, path).await
369 }
370
371 async fn write(
372 &self,
373 storage: &dyn StorageBackend,
374 path: &str,
375 data: serde_json::Value,
376 ) -> EngineResult<()> {
377 STORE
378 .handle_write::<GworkspaceConfig, RoleConfig>(storage, path, data)
379 .await
380 }
381
382 async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()> {
383 STORE.handle_delete(storage, path).await
384 }
385
386 async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>> {
387 STORE.handle_list(storage, prefix).await
388 }
389
390 async fn generate(
391 &self,
392 storage: &dyn StorageBackend,
393 role_name: &str,
394 ) -> EngineResult<GeneratedCredential> {
395 let role: RoleConfig = STORE.require_role(storage, role_name).await?;
396 let config: GworkspaceConfig = STORE.require_config(storage, &role.target).await?;
397
398 let now = Utc::now();
399 let token = match role.mode {
400 Mode::RefreshToken => {
401 let refresh_token = Self::require_refresh_token(&config)?;
402 self.post_token_form(&[
403 ("grant_type", "refresh_token"),
404 ("refresh_token", refresh_token),
405 ("client_id", &config.client_id),
406 ("client_secret", &config.client_secret),
407 ])
408 .await?
409 }
410 Mode::DomainWideDelegation => {
411 let key = Self::service_account(&config)?;
412 let claims = Self::assertion_claims(&key.client_email, &role, now)?;
413 let assertion = Self::sign_assertion(&key, &claims)?;
414 self.post_token_form(&[
415 ("grant_type", JWT_BEARER_GRANT),
416 ("assertion", &assertion),
417 ])
418 .await?
419 }
420 };
421
422 if let Some(rotated) = token.refresh_token.as_deref()
423 && Some(rotated) != config.refresh_token.as_deref()
424 {
425 Self::persist_rotated_refresh_token(storage, &role.target, &config, rotated).await?;
426 tracing::info!(target = %role.target, "stored a reissued Google refresh token");
427 }
428
429 let ttl_seconds = token.expires_in.unwrap_or(FALLBACK_ACCESS_TOKEN_TTL_SECONDS);
430 let lease = Lease {
431 id: Uuid::new_v4(),
432 token_id_hash: String::new(),
434 engine_mount: MOUNT.to_string(),
435 internal_data: json!({
438 "role": role_name,
439 "target": role.target,
440 "mode": role.mode,
441 }),
442 issued_at: now,
443 expires_at: now + chrono::Duration::seconds(ttl_seconds),
444 };
445
446 let credential = GeneratedCredential::new(
447 json!({
448 "access_token": token.access_token,
449 "token_type": "Bearer",
450 "expires_in": ttl_seconds,
451 "granted_scope": token.scope,
452 }),
453 lease,
454 Self::scope_description(&role),
455 );
456
457 Ok(match role.mode {
458 Mode::DomainWideDelegation => credential.with_shape(
461 CredentialShape::MintExpiryOnly,
462 "nothing. This token was minted by impersonating a user through \
463 domain-wide delegation, and Google cannot invalidate it. It stops \
464 working when it expires. To cut off delegation entirely, remove \
465 the service account's client ID from the Admin console.",
466 ),
467 Mode::RefreshToken => credential,
468 })
469 }
470
471 async fn revoke(&self, _storage: &dyn StorageBackend, lease: &Lease) -> EngineResult<()> {
472 tracing::warn!(
479 lease = %lease.id,
480 "gworkspace lease revoked locally only: Google cannot invalidate an \
481 issued access token, so it remains valid until it expires. To revoke \
482 the underlying authorisation on purpose, DELETE the config document \
483 and POST its refresh token to {REVOKE_ENDPOINT}"
484 );
485 Ok(())
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 fn role(mode: Mode, subject: Option<&str>, scopes: &[&str]) -> RoleConfig {
494 RoleConfig {
495 target: "acme".to_string(),
496 mode,
497 scopes: scopes.iter().map(|s| s.to_string()).collect(),
498 subject: subject.map(|s| s.to_string()),
499 }
500 }
501
502 #[test]
503 fn assertion_claims_impersonate_the_subject() {
504 let now = Utc::now();
505 let claims = GworkspaceEngine::assertion_claims(
506 "svc@acme.iam.gserviceaccount.com",
507 &role(
508 Mode::DomainWideDelegation,
509 Some("alice@acme.com"),
510 &["https://www.googleapis.com/auth/drive.readonly"],
511 ),
512 now,
513 )
514 .expect("claims");
515
516 assert_eq!(claims.iss, "svc@acme.iam.gserviceaccount.com");
517 assert_eq!(claims.sub, "alice@acme.com");
520 assert_eq!(claims.aud, TOKEN_ENDPOINT);
521 assert_eq!(claims.scope, "https://www.googleapis.com/auth/drive.readonly");
522 assert!(claims.exp - claims.iat <= 3600, "Google caps the assertion at one hour");
523 }
524
525 #[test]
526 fn assertion_claims_join_multiple_scopes_with_spaces() {
527 let claims = GworkspaceEngine::assertion_claims(
528 "svc@acme.iam.gserviceaccount.com",
529 &role(Mode::DomainWideDelegation, Some("alice@acme.com"), &["a", "b"]),
530 Utc::now(),
531 )
532 .expect("claims");
533 assert_eq!(claims.scope, "a b");
534 }
535
536 #[test]
539 fn domain_wide_delegation_requires_a_subject() {
540 for subject in [None, Some("")] {
541 let err = GworkspaceEngine::assertion_claims(
542 "svc@acme.iam.gserviceaccount.com",
543 &role(Mode::DomainWideDelegation, subject, &["a"]),
544 Utc::now(),
545 )
546 .expect_err("a missing subject must be refused");
547 assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
548 assert!(err.to_string().contains("subject"));
549 }
550 }
551
552 #[test]
553 fn domain_wide_delegation_requires_a_service_account_key() {
554 let config = GworkspaceConfig {
555 client_id: String::new(),
556 client_secret: String::new(),
557 refresh_token: None,
558 service_account_key_json: None,
559 };
560 let err = GworkspaceEngine::service_account(&config).expect_err("must be refused");
561 assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
562 }
563
564 #[test]
565 fn refresh_token_mode_requires_a_stored_refresh_token() {
566 for stored in [None, Some(String::new())] {
567 let config = GworkspaceConfig {
568 client_id: "id".to_string(),
569 client_secret: "secret".to_string(),
570 refresh_token: stored,
571 service_account_key_json: None,
572 };
573 let err = GworkspaceEngine::require_refresh_token(&config)
574 .expect_err("must be refused");
575 assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
576 }
577 }
578
579 #[test]
580 fn rejects_a_service_account_key_that_is_not_a_pem() {
581 let key = ServiceAccountKey {
582 client_email: "svc@acme.iam.gserviceaccount.com".to_string(),
583 private_key: "not a pem".to_string(),
584 };
585 let claims = GworkspaceEngine::assertion_claims(
586 &key.client_email,
587 &role(Mode::DomainWideDelegation, Some("alice@acme.com"), &["a"]),
588 Utc::now(),
589 )
590 .expect("claims");
591 let err = GworkspaceEngine::sign_assertion(&key, &claims).expect_err("must be refused");
592 assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
593 }
594
595 #[test]
596 fn scope_description_names_the_impersonated_user() {
597 let scoped = GworkspaceEngine::scope_description(&role(
598 Mode::DomainWideDelegation,
599 Some("alice@acme.com"),
600 &["https://www.googleapis.com/auth/drive.readonly"],
601 ));
602 assert!(scoped.iter().any(|s| s.contains("alice@acme.com")));
603 assert!(
604 scoped.contains(&"scope:https://www.googleapis.com/auth/drive.readonly".to_string())
605 );
606 }
607
608 #[test]
609 fn scope_description_flags_a_role_with_no_scopes() {
610 let scoped = GworkspaceEngine::scope_description(&role(Mode::RefreshToken, None, &[]));
611 assert!(scoped.iter().any(|s| s.contains("scopes:NONE")));
612 }
613
614 #[test]
615 fn mode_defaults_to_the_safer_refresh_token_flow() {
616 let role: RoleConfig =
617 serde_json::from_value(json!({ "target": "acme" })).expect("minimal role");
618 assert_eq!(role.mode, Mode::RefreshToken);
619 }
620
621 #[test]
626 fn doc_reports_the_truth_about_revocation() {
627 let doc = GworkspaceEngine::new().doc();
628 assert_eq!(doc.shape, CredentialShape::RefreshBroker);
629 assert!(!doc.revocable);
630 assert!(!doc.shape.revocable());
631 assert!(doc.revoke_effect.contains("nothing at the provider"));
632 }
633}