1use std::collections::HashMap;
39use std::sync::Arc;
40
41use base64::engine::general_purpose::URL_SAFE_NO_PAD;
42use base64::Engine;
43use chrono::{DateTime, Duration, Utc};
44use hmac::{Hmac, Mac};
45use rand::RngCore;
46use serde::{Deserialize, Serialize};
47use sha2::Sha256;
48use sqlx::PgPool;
49use subtle::ConstantTimeEq;
50use tracing::warn;
51use uuid::Uuid;
52
53use backbone_orm::company_scope;
54use backbone_orm::org_scope;
55
56use crate::application::service::integrations_oauth_ports::{
57 OAuthCredentialFailure, OAuthCredentialStore, PURPOSE_OAUTH_TOKEN, TokenBundle,
58};
59use crate::infrastructure::http::{
60 EndpointOverrides, OAuthClientConfigs, OAuthTransport, ProviderRegistry, TokenRequestForm,
61 TransportFailure, ValidatedEndpoints,
62};
63
64pub const STATE_TTL_SECONDS: i64 = 600;
67
68pub const DEFAULT_STATE_SECRET_ENV: &str = "INTEGRATIONS_OAUTH_STATE_SECRET";
71
72pub const CALLBACK_PATH: &str = "/api/v1/integrations/oauth/callback";
75
76pub const COMPLETE_ACTION: &str = "complete";
80
81#[derive(Debug, Clone, serde::Deserialize)]
91#[serde(default)]
92pub struct IntegrationsOauthConfig {
93 pub public_base: Option<String>,
95 pub clients: OAuthClientConfigs,
97 pub endpoints: EndpointOverrides,
101 pub refresh_window_seconds: i64,
103 pub refresh_batch_size: i64,
105 pub shared_mailbox_bypass: bool,
108 pub state_secret_source: String,
111}
112
113impl Default for IntegrationsOauthConfig {
114 fn default() -> Self {
115 Self {
116 public_base: None,
117 clients: OAuthClientConfigs::default(),
118 endpoints: EndpointOverrides::default(),
119 refresh_window_seconds: 600,
120 refresh_batch_size: 100,
121 shared_mailbox_bypass: false,
122 state_secret_source: format!("env:{DEFAULT_STATE_SECRET_ENV}"),
123 }
124 }
125}
126
127#[derive(Debug, thiserror::Error)]
134pub enum OauthError {
135 #[error("invalid input: {0}")]
136 Invalid(String),
137 #[error("state rejected: {0}")]
138 State(String),
139 #[error("provider not configured: {0}")]
140 ProviderUnconfigured(String),
141 #[error("account not found")]
142 NotFound,
143 #[error("identity verification failed: {0}")]
144 Identity(String),
145 #[error("unstoreable token: {0}")]
146 Unstoreable(String),
147 #[error("provider transport: {0}")]
148 Transport(String),
149 #[error("credential store: {0}")]
150 Store(String),
151 #[error("db: {0}")]
152 Db(#[from] sqlx::Error),
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
163struct OAuthState {
164 account_id: Uuid,
165 provider: String,
166 nonce: String,
167 exp: i64,
168}
169
170struct StateSigner {
174 key: Vec<u8>,
175}
176
177impl StateSigner {
178 fn new(key: Vec<u8>) -> Self {
179 Self { key }
180 }
181
182 fn mint(&self, state: &OAuthState) -> Result<String, OauthError> {
183 let payload = serde_json::to_vec(state)
184 .map_err(|e| OauthError::State(format!("state serialization: {e}")))?;
185 let mac = Hmac::<Sha256>::new_from_slice(&self.key)
186 .expect("hmac accepts any key length")
187 .chain_update(&payload)
188 .finalize()
189 .into_bytes();
190 Ok(format!(
191 "{}.{}",
192 URL_SAFE_NO_PAD.encode(&payload),
193 URL_SAFE_NO_PAD.encode(mac)
194 ))
195 }
196
197 fn verify(&self, token: &str) -> Result<OAuthState, OauthError> {
198 let (payload_b64, mac_b64) = token
199 .split_once('.')
200 .ok_or_else(|| OauthError::State("malformed state".into()))?;
201 let payload = URL_SAFE_NO_PAD
202 .decode(payload_b64)
203 .map_err(|_| OauthError::State("malformed state payload".into()))?;
204 let presented = URL_SAFE_NO_PAD
205 .decode(mac_b64)
206 .map_err(|_| OauthError::State("malformed state signature".into()))?;
207 let expected = Hmac::<Sha256>::new_from_slice(&self.key)
208 .expect("hmac accepts any key length")
209 .chain_update(&payload)
210 .finalize()
211 .into_bytes();
212 if presented.len() != expected.len() || bool::from(presented.ct_eq(&expected)) == false {
214 return Err(OauthError::State("state signature mismatch".into()));
215 }
216 let state: OAuthState = serde_json::from_slice(&payload)
217 .map_err(|_| OauthError::State("state payload is not a valid binding".into()))?;
218 if state.exp <= Utc::now().timestamp() {
219 return Err(OauthError::State("state expired".into()));
220 }
221 Ok(state)
222 }
223}
224
225fn mint_pkce_verifier() -> String {
233 let mut bytes = [0u8; 48];
234 rand::thread_rng().fill_bytes(&mut bytes);
235 URL_SAFE_NO_PAD.encode(bytes)
236}
237
238fn pkce_challenge(verifier: &str) -> String {
240 use sha2::Digest;
241 URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
242}
243
244fn mint_nonce() -> String {
246 let mut bytes = [0u8; 16];
247 rand::thread_rng().fill_bytes(&mut bytes);
248 URL_SAFE_NO_PAD.encode(bytes)
249}
250
251#[derive(Debug, sqlx::FromRow)]
259pub struct AccountRow {
260 pub id: Uuid,
261 pub provider: String,
262 pub account_ref: String,
263 pub status: String,
264 pub scopes: String,
265 pub expires_at: Option<DateTime<Utc>>,
266 pub last_refreshed_at: Option<DateTime<Utc>>,
267}
268
269#[derive(Debug, Clone, Serialize)]
272pub struct AccountStatus {
273 pub account_id: Uuid,
274 pub provider: String,
275 pub account_ref: String,
276 pub status: String,
277 pub scopes: String,
278 pub expires_at: Option<DateTime<Utc>>,
279 pub last_refreshed_at: Option<DateTime<Utc>>,
280}
281
282impl From<AccountRow> for AccountStatus {
283 fn from(r: AccountRow) -> Self {
284 Self {
285 account_id: r.id,
286 provider: r.provider,
287 account_ref: r.account_ref,
288 status: r.status,
289 scopes: r.scopes,
290 expires_at: r.expires_at,
291 last_refreshed_at: r.last_refreshed_at,
292 }
293 }
294}
295
296#[derive(Debug, Deserialize)]
303pub struct AuthorizeRequest {
304 pub provider: String,
305 pub account_ref: String,
306 pub scopes: Option<String>,
307}
308
309#[derive(Debug, Serialize)]
312pub struct AuthorizeResponse {
313 pub account_id: Uuid,
314 pub authorize_url: String,
315 pub state_expires_in: i64,
316}
317
318#[derive(Debug, Deserialize)]
320pub struct CompleteRequest {
321 pub code: String,
322 pub state: String,
323}
324
325#[derive(Debug, Serialize)]
327pub struct CompleteOutcome {
328 pub account_id: Uuid,
329 pub provider: String,
330 pub account_ref: String,
331 pub status: String,
332 pub scopes: String,
333 pub expires_at: DateTime<Utc>,
334}
335
336#[derive(Debug, Clone, Default, PartialEq)]
339pub struct RefreshSummary {
340 pub refreshed: usize,
341 pub resynced: usize,
344 pub expired: usize,
345 pub failures: usize,
346}
347
348pub struct IntegrationsOauthService {
358 pool: PgPool,
359 registry: ProviderRegistry,
360 endpoints: HashMap<String, ValidatedEndpoints>,
361 clients: OAuthClientConfigs,
362 signer: StateSigner,
363 public_base: String,
364 refresh_window: Duration,
365 refresh_batch_size: i64,
366 shared_mailbox_bypass: bool,
367 transport: Arc<dyn OAuthTransport>,
368 store: Arc<dyn OAuthCredentialStore>,
369}
370
371impl IntegrationsOauthService {
372 pub fn build(
377 pool: PgPool,
378 config: IntegrationsOauthConfig,
379 transport: Arc<dyn OAuthTransport>,
380 store: Arc<dyn OAuthCredentialStore>,
381 ) -> anyhow::Result<Self> {
382 let state_secret_source = config.state_secret_source.trim().to_string();
383 let var_name = state_secret_source
384 .strip_prefix("env:")
385 .ok_or_else(|| {
386 anyhow::anyhow!(
387 "oauth.state_secret_source must name an environment variable (env:NAME), got {state_secret_source:?} — a file value is never accepted"
388 )
389 })?
390 .trim()
391 .to_string();
392 if var_name.is_empty() {
393 return Err(anyhow::anyhow!("oauth.state_secret_source names no variable"));
394 }
395 let secret = std::env::var(&var_name).map_err(|_| {
396 anyhow::anyhow!("environment variable {var_name} (the OAuth state-signing key) is not set")
397 })?;
398 if secret.len() < 32 {
399 return Err(anyhow::anyhow!(
400 "environment variable {var_name} must hold at least 32 bytes (an HMAC-SHA256 key)"
401 ));
402 }
403
404 let public_base = config
405 .public_base
406 .as_deref()
407 .map(str::trim)
408 .map(trim_trailing_slash)
409 .filter(|s| !s.is_empty())
410 .ok_or_else(|| anyhow::anyhow!("oauth.public_base is required (the OAuth redirect_uri base)"))?;
411
412 let registry = ProviderRegistry::with_builtin();
413 let mut endpoints = HashMap::new();
417 for provider in registry.providers() {
418 let resolved = ValidatedEndpoints::resolve(®istry, provider, &config.endpoints)
419 .map_err(|e| anyhow::anyhow!("oauth endpoint configuration refused: {e}"))?;
420 endpoints.insert(provider.to_string(), resolved);
421 }
422
423 Ok(Self {
424 pool,
425 registry,
426 endpoints,
427 clients: config.clients,
428 signer: StateSigner::new(secret.into_bytes()),
429 public_base,
430 refresh_window: Duration::seconds(config.refresh_window_seconds.max(0)),
431 refresh_batch_size: config.refresh_batch_size.max(1),
432 shared_mailbox_bypass: config.shared_mailbox_bypass,
433 transport,
434 store,
435 })
436 }
437
438 fn endpoints_for(&self, provider: &str) -> Result<(&ValidatedEndpoints, String), OauthError> {
439 let resolved = self
440 .endpoints
441 .get(provider)
442 .ok_or_else(|| OauthError::ProviderUnconfigured(format!("unknown provider {provider:?}")))?;
443 let client_id = self
444 .clients
445 .get(provider)
446 .map(|c| c.client_id.clone())
447 .filter(|id| !id.trim().is_empty())
448 .ok_or_else(|| OauthError::ProviderUnconfigured(format!("no OAuth client configured for {provider}")))?;
449 Ok((resolved, client_id))
450 }
451
452 fn redirect_uri(&self) -> String {
453 format!("{}{}", self.public_base, CALLBACK_PATH)
454 }
455
456 pub async fn authorize(&self, req: AuthorizeRequest) -> Result<AuthorizeResponse, OauthError> {
465 let provider = req.provider.trim().to_string();
466 let adapter = self
467 .registry
468 .lookup(&provider)
469 .ok_or_else(|| OauthError::Invalid(format!("unknown provider {provider:?}")))?;
470 let (endpoints, client_id) = self.endpoints_for(&provider)?;
471
472 let account_ref = normalize_account_ref(&req.account_ref)
473 .ok_or_else(|| OauthError::Invalid("account_ref must be 3..=120 chars without whitespace".into()))?;
474 if is_mail_provider(&provider) && !looks_like_email(&account_ref) {
475 return Err(OauthError::Invalid(format!(
476 "account_ref for {provider} must be the mailbox address (a valid email)"
477 )));
478 }
479
480 let scopes = req
481 .scopes
482 .map(|s| s.trim().to_string())
483 .filter(|s| !s.is_empty())
484 .unwrap_or_else(|| adapter.default_scopes.to_string());
485 if scopes.len() > 2048 || scopes.chars().any(|c| c.is_control()) {
486 return Err(OauthError::Invalid("scopes must be <= 2048 chars, no control characters".into()));
487 }
488
489 let verifier = if adapter.pkce_s256 { Some(mint_pkce_verifier()) } else { None };
497 let account_id = Uuid::new_v4();
498 let mut tx = self.pool.begin().await?;
499 bind_ambient_org_scope(&mut tx).await?;
500 sqlx::query(
501 "DELETE FROM integrations.integration_accounts
502 WHERE provider = $1::o_auth_provider AND account_ref = $2",
503 )
504 .bind(&provider)
505 .bind(&account_ref)
506 .execute(&mut *tx)
507 .await?;
508 sqlx::query(
509 "INSERT INTO integrations.integration_accounts
510 (id, provider, account_ref, status, scopes, pkce_verifier)
511 VALUES ($1, $2::o_auth_provider, $3, 'pending', $4, $5)",
512 )
513 .bind(account_id)
514 .bind(&provider)
515 .bind(&account_ref)
516 .bind(&scopes)
517 .bind(&verifier)
518 .execute(&mut *tx)
519 .await?;
520 tx.commit().await?;
521
522 let nonce = mint_nonce();
529 let state = self.signer.mint(&OAuthState {
530 account_id,
531 provider: provider.clone(),
532 nonce: nonce.clone(),
533 exp: Utc::now().timestamp() + STATE_TTL_SECONDS,
534 })?;
535
536 let mut url = reqwest::Url::parse(endpoints.authorize.as_str())
539 .map_err(|e| OauthError::Invalid(format!("authorize endpoint: {e}")))?;
540 {
541 let mut pairs = url.query_pairs_mut();
542 pairs.append_pair("response_type", "code");
543 pairs.append_pair("client_id", &client_id);
544 pairs.append_pair("redirect_uri", &self.redirect_uri());
545 pairs.append_pair("scope", &scopes);
546 pairs.append_pair("access_type", "offline");
550 pairs.append_pair("prompt", "consent");
551 if let Some(v) = verifier.as_deref() {
552 pairs.append_pair("code_challenge", &pkce_challenge(v));
553 pairs.append_pair("code_challenge_method", "S256");
554 }
555 pairs.append_pair("nonce", &nonce);
556 pairs.append_pair("state", &state);
557 }
558
559 Ok(AuthorizeResponse { account_id, authorize_url: url.to_string(), state_expires_in: STATE_TTL_SECONDS })
560 }
561
562 pub fn callback_page(&self, code: &str, state: &str) -> Result<String, OauthError> {
572 if code.trim().is_empty() {
573 return Err(OauthError::Invalid("callback is missing the authorization code".into()));
574 }
575 self.signer.verify(state)?;
577 Ok(auto_post_page(COMPLETE_ACTION, &[("code", code), ("state", state)]))
578 }
579
580 pub async fn complete(
592 &self,
593 company_id: Uuid,
594 req: CompleteRequest,
595 ) -> Result<CompleteOutcome, OauthError> {
596 let state = self.signer.verify(&req.state)?;
597 let account = self
598 .fetch_account(state.account_id)
599 .await?
600 .ok_or(OauthError::NotFound)?;
601 if account.provider != state.provider {
602 return Err(OauthError::State("state provider does not match the account".into()));
603 }
604 if account.status != "pending" {
605 return Err(OauthError::State(format!(
606 "account is {} (only a pending authorization can complete)",
607 account.status
608 )));
609 }
610
611 let provider = account.provider.clone();
612 let account_ref = account.account_ref.clone();
613 let adapter = self
614 .registry
615 .lookup(&provider)
616 .ok_or_else(|| OauthError::ProviderUnconfigured(provider.clone()))?;
617 let (endpoints, client_id) = self.endpoints_for(&provider)?;
618
619 let verifier = if adapter.pkce_s256 {
625 let stored = company_scope::fetch_optional_scalar_scoped(
626 &self.pool,
627 sqlx::query_scalar::<_, Option<String>>(
628 "SELECT pkce_verifier FROM integrations.integration_accounts WHERE id = $1",
629 )
630 .bind(state.account_id),
631 )
632 .await
633 .map_err(OauthError::Db)?
634 .flatten()
635 .filter(|v| !v.is_empty())
636 .ok_or(OauthError::State("pending authorization has no PKCE verifier".into()))?;
637 Some(stored)
638 } else {
639 None
640 };
641
642 let form = TokenRequestForm {
643 grant_type: "authorization_code".into(),
644 code: Some(req.code),
645 refresh_token: None,
646 redirect_uri: Some(self.redirect_uri()),
647 code_verifier: verifier,
648 client_id: client_id.clone(),
649 client_secret: self.clients.get(&provider).and_then(|c| c.client_secret.clone()),
650 scope: None,
651 };
652 let response = self
653 .transport
654 .exchange(&endpoints.token, &form)
655 .await
656 .map_err(|e| OauthError::Transport(e.to_string()))?;
657
658 let now = Utc::now();
660 let expires_at = response
661 .expires_at(now)
662 .ok_or_else(|| OauthError::Unstoreable("provider returned no expires_in; a permanent token is not a storable value".into()))?;
663
664 let identity = self
666 .transport
667 .fetch_identity(&endpoints.userinfo, &response.access_token)
668 .await
669 .map_err(|e| OauthError::Transport(e.to_string()))?;
670 verify_audience_and_nonce(&identity, response.id_token.as_deref(), &client_id, &state.nonce)?;
671 verify_email_match(
672 &identity,
673 &account_ref,
674 self.shared_mailbox_bypass,
675 &provider,
676 )?;
677
678 let carried_refresh = match response.refresh_token.clone() {
682 Some(r) => Some(r),
683 None => self
684 .store
685 .read_token(company_id, &provider, &account_ref)
686 .await
687 .ok()
688 .and_then(|prior| prior.refresh_token().map(str::to_string)),
689 };
690 let granted_scope = response.scope.clone().or_else(|| Some(adapter.default_scopes.to_string()));
691
692 let issued = self
698 .store
699 .issue(
700 company_id,
701 &provider,
702 &account_ref,
703 PURPOSE_OAUTH_TOKEN,
704 TokenBundle::new(
705 response.access_token.clone(),
706 carried_refresh.clone(),
707 expires_at,
708 granted_scope.clone(),
709 ),
710 expires_at,
711 )
712 .await;
713 if let Err(f) = issued {
714 if f.code == OAuthCredentialFailure::CODE_DUPLICATE_ACTIVE {
715 self.store
716 .rotate(
717 company_id,
718 &provider,
719 &account_ref,
720 TokenBundle::new(
721 response.access_token.clone(),
722 carried_refresh,
723 expires_at,
724 granted_scope,
725 ),
726 expires_at,
727 )
728 .await
729 .map_err(|f| OauthError::Store(f.to_string()))?;
730 } else {
731 return Err(OauthError::Store(f.to_string()));
732 }
733 }
734
735 let scopes = response.scope.clone().unwrap_or_default();
736 let updated = company_scope::execute_scoped(
740 &self.pool,
741 sqlx::query(
742 "UPDATE integrations.integration_accounts
743 SET status = 'active', scopes = $2, expires_at = $3,
744 last_refreshed_at = $4, pkce_verifier = NULL
745 WHERE id = $1 AND status = 'pending'",
746 )
747 .bind(state.account_id)
748 .bind(&scopes)
749 .bind(expires_at)
750 .bind(now),
751 )
752 .await
753 .map_err(OauthError::Db)?;
754 if updated.rows_affected() != 1 {
755 return Err(OauthError::State("authorization was completed concurrently".into()));
756 }
757
758 Ok(CompleteOutcome {
759 account_id: state.account_id,
760 provider,
761 account_ref,
762 status: "active".into(),
763 scopes,
764 expires_at,
765 })
766 }
767
768 pub async fn disconnect(&self, company_id: Uuid, account_id: Uuid) -> Result<(), OauthError> {
776 let account = self
777 .fetch_account(account_id)
778 .await?
779 .ok_or(OauthError::NotFound)?;
780 if account.status == "revoked" {
781 return Ok(());
782 }
783 match self.store.revoke(company_id, &account.provider, &account.account_ref).await {
784 Ok(()) => {}
785 Err(f) if f.code == OAuthCredentialFailure::CODE_NOT_FOUND => {}
788 Err(f) => return Err(OauthError::Store(f.to_string())),
789 }
790 company_scope::execute_scoped(
794 &self.pool,
795 sqlx::query(
796 "UPDATE integrations.integration_accounts
797 SET status = 'revoked', pkce_verifier = NULL
798 WHERE id = $1 AND status IN ('pending', 'active')",
799 )
800 .bind(account_id),
801 )
802 .await
803 .map_err(OauthError::Db)?;
804 Ok(())
805 }
806
807 pub async fn status(&self, account_id: Uuid) -> Result<AccountStatus, OauthError> {
813 self.fetch_account(account_id)
814 .await?
815 .map(Into::into)
816 .ok_or(OauthError::NotFound)
817 }
818
819 pub async fn refresh_due(&self, company_id: Uuid) -> Result<RefreshSummary, OauthError> {
837 let mut summary = RefreshSummary::default();
838 for _ in 0..self.refresh_batch_size {
839 let mut tx = self.pool.begin().await?;
840 bind_ambient_org_scope(&mut tx).await?;
841 let claimed = sqlx::query_as::<_, (Uuid, String, String, Option<DateTime<Utc>>)>(
842 "SELECT id, provider::text, account_ref, expires_at
843 FROM integrations.integration_accounts
844 WHERE status = 'active'
845 AND expires_at IS NOT NULL
846 AND expires_at < now() + make_interval(secs => $1)
847 ORDER BY expires_at ASC
848 LIMIT 1
849 FOR UPDATE SKIP LOCKED",
850 )
851 .bind(self.refresh_window.num_seconds())
852 .fetch_optional(&mut *tx)
853 .await?;
854 let Some((account_id, provider, account_ref, _mirror)) = claimed else {
855 tx.commit().await?;
856 break;
857 };
858 self.refresh_claimed(tx, company_id, account_id, provider, account_ref, &mut summary)
859 .await?;
860 }
861 Ok(summary)
862 }
863
864 pub async fn ensure_fresh(&self, company_id: Uuid, account_id: Uuid) -> Result<AccountStatus, OauthError> {
869 let account = self
870 .fetch_account(account_id)
871 .await?
872 .ok_or(OauthError::NotFound)?;
873 if account.status != "active" {
874 return Ok(account.into());
875 }
876 let due = account
877 .expires_at
878 .map(|e| e - self.refresh_window < Utc::now())
879 .unwrap_or(false);
880 if due {
881 let mut tx = self.pool.begin().await?;
882 bind_ambient_org_scope(&mut tx).await?;
883 let claimed = sqlx::query_as::<_, (Uuid, String, String, Option<DateTime<Utc>>)>(
884 "SELECT id, provider::text, account_ref, expires_at
885 FROM integrations.integration_accounts
886 WHERE id = $1 AND status = 'active'
887 LIMIT 1
888 FOR UPDATE SKIP LOCKED",
889 )
890 .bind(account_id)
891 .fetch_optional(&mut *tx)
892 .await?;
893 if let Some((account_id, provider, account_ref, _)) = claimed {
894 let mut summary = RefreshSummary::default();
895 self.refresh_claimed(tx, company_id, account_id, provider, account_ref, &mut summary)
896 .await?;
897 } else {
898 tx.commit().await?;
899 }
900 }
901 self.status(account_id).await
902 }
903
904 async fn refresh_claimed(
909 &self,
910 mut tx: sqlx::Transaction<'_, sqlx::Postgres>,
911 company_id: Uuid,
912 account_id: Uuid,
913 provider: String,
914 account_ref: String,
915 summary: &mut RefreshSummary,
916 ) -> Result<(), OauthError> {
917 let prior = match self.store.read_token(company_id, &provider, &account_ref).await {
921 Ok(b) => b,
922 Err(f) if f.code == OAuthCredentialFailure::CODE_NOT_FOUND
923 || f.code == OAuthCredentialFailure::CODE_NOT_ACTIVE
924 || f.code == OAuthCredentialFailure::CODE_EXPIRED =>
925 {
926 sqlx::query(
929 "UPDATE integrations.integration_accounts
930 SET status = 'expired', pkce_verifier = NULL
931 WHERE id = $1 AND status = 'active'",
932 )
933 .bind(account_id)
934 .execute(&mut *tx)
935 .await?;
936 tx.commit().await?;
937 summary.expired += 1;
938 return Ok(());
939 }
940 Err(f) => {
941 warn!(target: "integrations.oauth.refresh", account_id = %account_id, "credential store unreadable: {f}");
942 tx.rollback().await?;
943 summary.failures += 1;
944 return Ok(());
945 }
946 };
947 if prior.expires_at() - self.refresh_window >= Utc::now() {
948 sqlx::query(
949 "UPDATE integrations.integration_accounts
950 SET expires_at = $2
951 WHERE id = $1",
952 )
953 .bind(account_id)
954 .bind(prior.expires_at())
955 .execute(&mut *tx)
956 .await?;
957 tx.commit().await?;
958 summary.resynced += 1;
959 return Ok(());
960 }
961 let Some(refresh_token) = prior.refresh_token().map(str::to_string) else {
962 sqlx::query(
965 "UPDATE integrations.integration_accounts
966 SET status = 'expired', pkce_verifier = NULL
967 WHERE id = $1 AND status = 'active'",
968 )
969 .bind(account_id)
970 .execute(&mut *tx)
971 .await?;
972 tx.commit().await?;
973 summary.expired += 1;
974 return Ok(());
975 };
976
977 let Ok((endpoints, client_id)) = self.endpoints_for(&provider) else {
978 warn!(target: "integrations.oauth.refresh", account_id = %account_id, provider = %provider, "provider unconfigured; left due");
979 tx.rollback().await?;
980 summary.failures += 1;
981 return Ok(());
982 };
983 let form = TokenRequestForm {
984 grant_type: "refresh_token".into(),
985 code: None,
986 refresh_token: Some(refresh_token),
987 redirect_uri: None,
988 code_verifier: None,
989 client_id,
990 client_secret: self.clients.get(&provider).and_then(|c| c.client_secret.clone()),
991 scope: None,
993 };
994 let response = match self.transport.exchange(&endpoints.token, &form).await {
995 Ok(r) => r,
996 Err(f) if f.is_invalid_grant() => {
997 sqlx::query(
1000 "UPDATE integrations.integration_accounts
1001 SET status = 'expired', pkce_verifier = NULL
1002 WHERE id = $1 AND status = 'active'",
1003 )
1004 .bind(account_id)
1005 .execute(&mut *tx)
1006 .await?;
1007 tx.commit().await?;
1008 summary.expired += 1;
1009 return Ok(());
1010 }
1011 Err(f) => {
1012 warn!(target: "integrations.oauth.refresh", account_id = %account_id, "exchange failed; left due: {f}");
1013 tx.rollback().await?;
1014 summary.failures += 1;
1015 return Ok(());
1016 }
1017 };
1018 let now = Utc::now();
1019 let Some(expires_at) = response.expires_at(now) else {
1020 warn!(target: "integrations.oauth.refresh", account_id = %account_id, "refresh response carried no expires_in; refused as unstoreable");
1023 tx.rollback().await?;
1024 summary.failures += 1;
1025 return Ok(());
1026 };
1027 let refresh = response.refresh_token.clone().or_else(|| prior.refresh_token().map(str::to_string));
1031 let scope = response.scope.clone().or_else(|| prior.scope().map(str::to_string));
1032 self.store
1033 .rotate(
1034 company_id,
1035 &provider,
1036 &account_ref,
1037 TokenBundle::new(response.access_token.clone(), refresh, expires_at, scope.clone()),
1038 expires_at,
1039 )
1040 .await
1041 .map_err(|f| OauthError::Store(f.to_string()))?;
1042 sqlx::query(
1043 "UPDATE integrations.integration_accounts
1044 SET expires_at = $2, scopes = COALESCE($3, scopes), last_refreshed_at = $4
1045 WHERE id = $1",
1046 )
1047 .bind(account_id)
1048 .bind(expires_at)
1049 .bind(&scope)
1050 .bind(now)
1051 .execute(&mut *tx)
1052 .await?;
1053 tx.commit().await?;
1054 summary.refreshed += 1;
1055 Ok(())
1056 }
1057
1058 async fn fetch_account(&self, account_id: Uuid) -> Result<Option<AccountRow>, OauthError> {
1065 let row = org_scope::fetch_optional_row_scoped(
1066 &self.pool,
1067 sqlx::query(
1068 "SELECT id, provider::text AS provider, account_ref,
1069 status::text AS status, scopes, expires_at, last_refreshed_at
1070 FROM integrations.integration_accounts
1071 WHERE id = $1",
1072 )
1073 .bind(account_id),
1074 )
1075 .await
1076 .map_err(OauthError::Db)?;
1077 Ok(row
1078 .map(|r| sqlx::FromRow::from_row(&r))
1079 .transpose()?)
1080 }
1081}
1082
1083async fn bind_ambient_org_scope(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>) -> Result<(), OauthError> {
1089 if let Some(scope) = org_scope::current_org_scope() {
1090 org_scope::bind_org_scope_on(&mut **tx, &scope).await?;
1091 }
1092 Ok(())
1093}
1094
1095fn verify_audience_and_nonce(
1106 identity: &crate::infrastructure::http::IdentityClaims,
1107 id_token: Option<&str>,
1108 client_id: &str,
1109 state_nonce: &str,
1110) -> Result<(), OauthError> {
1111 let decoded = id_token.and_then(decode_id_token_claims);
1112 let audience = merge_claim(identity.audience.as_deref(), decoded.as_ref().and_then(|c| c.audience.clone()))?;
1113 let nonce = merge_claim(identity.nonce.as_deref(), decoded.as_ref().and_then(|c| c.nonce.clone()))?;
1114
1115 let aud = audience.ok_or_else(|| OauthError::Identity("identity carries no audience to verify".into()))?;
1116 if aud != client_id {
1117 return Err(OauthError::Identity(format!(
1118 "token audience {aud:?} is not this deployment's client (token substitution rejected)"
1119 )));
1120 }
1121 let nonce = nonce.ok_or_else(|| OauthError::Identity("identity carries no nonce to verify".into()))?;
1122 if nonce != state_nonce {
1123 return Err(OauthError::Identity("nonce does not match the signed state (replay rejected)".into()));
1124 }
1125 Ok(())
1126}
1127
1128fn verify_email_match(
1132 identity: &crate::infrastructure::http::IdentityClaims,
1133 account_ref: &str,
1134 shared_mailbox_bypass: bool,
1135 provider: &str,
1136) -> Result<(), OauthError> {
1137 let email = identity
1138 .email
1139 .as_deref()
1140 .map(str::trim)
1141 .filter(|e| !e.is_empty())
1142 .ok_or_else(|| OauthError::Identity("provider-side identity carries no email".into()))?;
1143 if identity.email_verified == Some(false) {
1144 return Err(OauthError::Identity("provider reports the email as unverified".into()));
1145 }
1146 let matches = email.eq_ignore_ascii_case(account_ref);
1147 if !matches {
1148 if shared_mailbox_bypass {
1149 warn!(
1150 target: "integrations.oauth",
1151 provider,
1152 account_ref,
1153 claimed_identity = email,
1154 "SHARED-MAILBOX BYPASS: token identity does not match the configured account_ref (declared admin bypass)"
1155 );
1156 return Ok(());
1157 }
1158 return Err(OauthError::Identity(format!(
1159 "token identity {email:?} does not match the configured account_ref"
1160 )));
1161 }
1162 Ok(())
1163}
1164
1165fn merge_claim(
1168 from_identity: Option<&str>,
1169 from_id_token: Option<String>,
1170) -> Result<Option<String>, OauthError> {
1171 match (from_identity, from_id_token) {
1172 (Some(a), Some(b)) if !a.eq_ignore_ascii_case(&b) => Err(OauthError::Identity(
1173 "identity reads disagree on a token claim (refused)".into(),
1174 )),
1175 (Some(a), _) => Ok(Some(a.to_string())),
1176 (None, Some(b)) => Ok(Some(b)),
1177 (None, None) => Ok(None),
1178 }
1179}
1180
1181#[derive(Debug, Default)]
1185struct DecodedIdToken {
1186 audience: Option<String>,
1187 nonce: Option<String>,
1188}
1189
1190fn decode_id_token_claims(id_token: &str) -> Option<DecodedIdToken> {
1191 let payload_b64 = id_token.split('.').nth(1)?;
1192 let payload = URL_SAFE_NO_PAD.decode(payload_b64.trim_end_matches('=')).ok()?;
1193 let value: serde_json::Value = serde_json::from_slice(&payload).ok()?;
1194 let audience = match value.get("aud")? {
1195 serde_json::Value::String(s) => Some(s.clone()),
1196 serde_json::Value::Array(a) => a.first().and_then(|v| v.as_str()).map(String::from),
1197 _ => None,
1198 };
1199 Some(DecodedIdToken {
1200 audience,
1201 nonce: value.get("nonce").and_then(|v| v.as_str()).map(String::from),
1202 })
1203}
1204
1205fn trim_trailing_slash(s: &str) -> String {
1210 s.trim_end_matches('/').to_string()
1211}
1212
1213fn is_mail_provider(provider: &str) -> bool {
1214 matches!(provider, "gmail" | "outlook")
1215}
1216
1217fn normalize_account_ref(raw: &str) -> Option<String> {
1218 let trimmed = raw.trim();
1219 if trimmed.len() < 3 || trimmed.len() > 120 || trimmed.chars().any(|c| c.is_whitespace()) {
1220 return None;
1221 }
1222 Some(trimmed.to_string())
1223}
1224
1225fn looks_like_email(value: &str) -> bool {
1226 match value.split_once('@') {
1227 Some((local, domain)) => {
1228 !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
1229 }
1230 None => false,
1231 }
1232}
1233
1234fn auto_post_page(action: &str, fields: &[(&str, &str)]) -> String {
1239 let inputs = fields
1240 .iter()
1241 .map(|(name, value)| {
1242 format!(
1243 r#" <input type="hidden" name="{}" value="{}">"#,
1244 html_escape(name),
1245 html_escape(value)
1246 )
1247 })
1248 .collect::<Vec<_>>()
1249 .join("\n");
1250 format!(
1251 "<!doctype html>\n<html>\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"robots\" content=\"noindex\">\n <title>Completing connection…</title>\n</head>\n<body onload=\"document.forms[0].submit()\">\n <form method=\"POST\" action=\"{action}\">\n{inputs}\n <noscript><button type=\"submit\">Continue</button></noscript>\n </form>\n</body>\n</html>\n"
1252 )
1253}
1254
1255fn html_escape(s: &str) -> String {
1256 s.replace('&', "&")
1257 .replace('<', "<")
1258 .replace('>', ">")
1259 .replace('\"', """)
1260 .replace('\'', "'")
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265 use super::*;
1266
1267 fn signer() -> StateSigner {
1268 StateSigner::new(vec![7u8; 32])
1269 }
1270
1271 fn state() -> OAuthState {
1272 OAuthState {
1273 account_id: Uuid::new_v4(),
1274 provider: "gmail".into(),
1275 nonce: mint_nonce(),
1276 exp: Utc::now().timestamp() + STATE_TTL_SECONDS,
1277 }
1278 }
1279
1280 #[test]
1281 fn state_roundtrip_and_constant_time_rejection() {
1282 let s = signer();
1283 let token = s.mint(&state()).unwrap();
1284 assert!(s.verify(&token).is_ok());
1285
1286 let (payload, mac) = token.split_at(token.len() - 1);
1288 let last = if mac.ends_with('A') { "B" } else { "A" };
1289 assert!(s.verify(&format!("{payload}{last}")).is_err(), "flipped signature byte must reject");
1290
1291 let (p, m) = token.split_once('.').unwrap();
1294 let first = p.chars().next().unwrap();
1295 let replacement = if first == 'e' { 'f' } else { 'e' };
1296 let tampered = format!("{replacement}{}", &p[1..]);
1297 assert_ne!(tampered, p, "tamper must actually change the payload");
1298 assert!(s.verify(&format!("{tampered}.{m}")).is_err());
1299
1300 assert!(s.verify("").is_err());
1302 assert!(s.verify("only-one-part").is_err());
1303 assert!(s.verify("....").is_err());
1304 }
1305
1306 #[test]
1307 fn expired_state_is_refused() {
1308 let s = signer();
1309 let mut st = state();
1310 st.exp = Utc::now().timestamp() - 1;
1311 let token = s.mint(&st).unwrap();
1312 assert!(matches!(s.verify(&token), Err(OauthError::State(_))));
1313 }
1314
1315 #[test]
1316 fn pkce_challenge_is_s256_of_verifier() {
1317 let v = mint_pkce_verifier();
1318 assert!((43..=128).contains(&v.len()), "verifier length {} out of spec", v.len());
1319 assert_eq!(pkce_challenge(&v).len(), 43);
1320 assert_ne!(pkce_challenge(&v), v);
1321 }
1322
1323 #[test]
1324 fn id_token_claims_decode_string_and_array_audience() {
1325 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
1326 let payload = serde_json::json!({"aud": ["client-1", "extra"], "nonce": "n-1", "email": "u@example.com"});
1327 let encoded = URL_SAFE_NO_PAD.encode(payload.to_string());
1328 let claims = decode_id_token_claims(&format!("header.{encoded}.signature")).unwrap();
1329 assert_eq!(claims.audience.as_deref(), Some("client-1"));
1330 assert_eq!(claims.nonce.as_deref(), Some("n-1"));
1331 assert!(decode_id_token_claims("not-a-jwt").is_none());
1332 }
1333
1334 #[test]
1335 fn gauntlet_enforces_audience_and_nonce() {
1336 use crate::infrastructure::http::IdentityClaims;
1337 let identity = IdentityClaims {
1338 sub: None,
1339 email: Some("u@example.com".into()),
1340 email_verified: Some(true),
1341 audience: Some("client-1".into()),
1342 nonce: Some("nonce-1".into()),
1343 };
1344 assert!(verify_audience_and_nonce(&identity, None, "client-1", "nonce-1").is_ok());
1345 assert!(verify_audience_and_nonce(&identity, None, "OTHER-CLIENT", "nonce-1").is_err());
1346 assert!(verify_audience_and_nonce(&identity, None, "client-1", "nonce-2").is_err());
1347 let empty = IdentityClaims::default();
1349 assert!(verify_audience_and_nonce(&empty, None, "client-1", "nonce-1").is_err());
1350 }
1351
1352 #[test]
1353 fn email_match_rules() {
1354 use crate::infrastructure::http::IdentityClaims;
1355 let identity = |email: Option<&str>, verified: Option<bool>| IdentityClaims {
1356 sub: None,
1357 email: email.map(String::from),
1358 email_verified: verified,
1359 audience: None,
1360 nonce: None,
1361 };
1362 assert!(verify_email_match(&identity(Some("U@Example.com"), Some(true)), "u@example.com", false, "gmail").is_ok());
1363 assert!(verify_email_match(&identity(Some("other@example.com"), Some(true)), "u@example.com", false, "gmail").is_err());
1364 assert!(verify_email_match(&identity(Some("shared@example.com"), Some(true)), "u@example.com", true, "gmail").is_ok());
1366 assert!(verify_email_match(&identity(Some("u@example.com"), Some(false)), "u@example.com", false, "gmail").is_err());
1368 assert!(verify_email_match(&identity(None, None), "u@example.com", false, "gmail").is_err());
1369 }
1370
1371 #[test]
1372 fn callback_page_escapes_attacker_influenceable_values() {
1373 let s = signer();
1374 let state = s.mint(&state()).unwrap();
1375 let page = super::auto_post_page(COMPLETE_ACTION, &[("code", "a\"<script>"), ("state", &state)]);
1376 assert!(!page.contains("<script>"), "raw script tag leaked into the page");
1377 assert!(page.contains("<script>"));
1378 assert!(page.contains("action=\"complete\""), "the form target is the relative completion route");
1379 }
1380
1381 #[test]
1382 fn account_ref_validation() {
1383 assert!(normalize_account_ref(" u@example.com ").is_some());
1384 assert!(normalize_account_ref("no").is_none());
1385 assert!(normalize_account_ref("has space@example.com").is_none());
1386 assert!(looks_like_email("u@example.com"));
1387 assert!(!looks_like_email("example.com"));
1388 assert!(!looks_like_email("@example.com"));
1389 }
1390}