Skip to main content

backbone_integrations/application/service/
integrations_oauth.rs

1//! The one OAuth generation (hand-authored, user-owned) — the core flow.
2//!
3//! ONE HMAC-bound flow serves every provider (gmail / outlook mail, Google /
4//! Microsoft calendar): an authenticated initiation mints a Tier-A state that
5//! is signature-bound to the account row, the provider redirect target is a
6//! SIDE-EFFECT-FREE page that auto-POSTs the code home (the RFC-8058 shape —
7//! a safe method never writes), and the completion re-verifies the state,
8//! exchanges the code at a VALIDATED endpoint, and runs the identity gauntlet
9//! (audience, nonce, email match) before any token is stored. Providers
10//! differ only in adapter data carried by the endpoint guard's registry —
11//! there is no per-provider flow code, and no legacy public-callback route
12//! exists anywhere on the surface.
13//!
14//! Secret placement (the credential-store ADR, as amended): token material
15//! crosses only the [`OAuthCredentialStore`] port — the module never persists
16//! a secret, and reaches the store with zero Cargo edge to its home crate.
17//! Lifetimes are honest everywhere: a provider response without `expires_in`
18//! is rejected as unstoreable, and a stored bundle's `expires_at` is
19//! `now + expires_in`, mirrored on the account row.
20//!
21//! The account row is flow bookkeeping only (binding, lifecycle, the expiry
22//! mirror, and the transient PKCE verifier, which lives solely while the
23//! authorization is pending and is cleared the moment the exchange consumes
24//! it). Its lifecycle is one hand_set enum: pending → active | revoked,
25//! active → expired | revoked, with expired/revoked terminal — a
26//! re-authorization replaces the row rather than un-terminal-ing it.
27//!
28//! Tenancy (ADR-0029): the module's tables carry no company column — the
29//! composing service's tenancy decorator owns org scoping (org_unit_id +
30//! RLS + the per-unit uniqueness). The `company_id` that remains on these
31//! verbs is the documented legacy twin: it is ONLY the credential-store
32//! scope key (the store across the port is still company-scoped); the
33//! caller (the composing host, which knows the tenant) names it, and an
34//! unknown value fails closed at the store — an honest `not_found` — never
35//! inside this module. `authorize` and `status` touch no store, so they
36//! take none.
37
38use 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
64/// How long a minted state stays verifiable (seconds). Tier-A capability
65/// rule: mandatory expiry, short by construction.
66pub const STATE_TTL_SECONDS: i64 = 600;
67
68/// The environment variable the state-signing key is read from — the key is
69/// never a file value and never a config-file value.
70pub const DEFAULT_STATE_SECRET_ENV: &str = "INTEGRATIONS_OAUTH_STATE_SECRET";
71
72/// The fixed callback path (appended to the deployment's public base to build
73/// the OAuth `redirect_uri`; the browser lands here after consent).
74pub const CALLBACK_PATH: &str = "/api/v1/integrations/oauth/callback";
75
76/// Where the callback page auto-POSTs the code — the SAME mount as the
77/// callback, one level up. Relative, so the flow works under whatever prefix
78/// the composing host mounted the module's routes at.
79pub const COMPLETE_ACTION: &str = "complete";
80
81// ─────────────────────────────────────────────────────────────────────────────
82// Configuration
83// ─────────────────────────────────────────────────────────────────────────────
84
85/// The `oauth:` configuration section (module-local, non-secret). Endpoint
86/// overrides and client ids live here; the state-signing key comes from the
87/// environment named by `state_secret_source`; a client secret, when a
88/// deployment runs a confidential client, is supplied out-of-band and never
89/// committed in this file.
90#[derive(Debug, Clone, serde::Deserialize)]
91#[serde(default)]
92pub struct IntegrationsOauthConfig {
93    /// Deployment base for the OAuth `redirect_uri` (e.g. `https://api.example.com`).
94    pub public_base: Option<String>,
95    /// Per-provider OAuth client registrations (the `oauth.clients` section).
96    pub clients: OAuthClientConfigs,
97    /// Per-provider endpoint overrides (the `oauth.endpoints` section) — empty
98    /// by default (registry values); every value passes the endpoint guard at
99    /// build time AND per request.
100    pub endpoints: EndpointOverrides,
101    /// Refresh before expiry by this many seconds.
102    pub refresh_window_seconds: i64,
103    /// Upper bound on accounts refreshed in one sweep.
104    pub refresh_batch_size: i64,
105    /// Declared admin bypass for the identity email match (shared mailboxes a
106    /// system operator legitimately relays). Default OFF; every use is logged.
107    pub shared_mailbox_bypass: bool,
108    /// Where the state-signing key comes from — an environment variable
109    /// (`env:NAME`); any other shape is refused.
110    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// ─────────────────────────────────────────────────────────────────────────────
128// Errors
129// ─────────────────────────────────────────────────────────────────────────────
130
131/// Why an OAuth-flow call failed. Every rejection path leaves ZERO writes:
132/// the account stays pending, the store is untouched.
133#[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// ─────────────────────────────────────────────────────────────────────────────
156// The Tier-A state (mint / verify)
157// ─────────────────────────────────────────────────────────────────────────────
158
159/// What the signed state binds: the account the authorization is FOR, the
160/// provider it runs against, a fresh nonce, and a mandatory expiry. Payload
161/// is visible-but-unforgeable (signed, not encrypted) — it carries no secret.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163struct OAuthState {
164    account_id: Uuid,
165    provider: String,
166    nonce: String,
167    exp: i64,
168}
169
170/// HMAC-SHA256 over the payload bytes; comparison is constant-time and expiry
171/// is mandatory. A tampered, flipped-byte, or expired state is refused before
172/// ANY database read or write.
173struct 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        // Constant-time comparison — a flipped byte must not become an oracle.
213        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
225// ─────────────────────────────────────────────────────────────────────────────
226// PKCE + nonce material
227// ─────────────────────────────────────────────────────────────────────────────
228
229/// A fresh PKCE S256 code verifier (43–128 chars per the spec; 48 random
230/// bytes → 64 url-safe chars). Single-use: stored on the pending account,
231/// consumed by exactly one exchange, then cleared.
232fn 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
238/// The S256 challenge for a verifier (base64url(SHA-256(verifier))).
239fn pkce_challenge(verifier: &str) -> String {
240    use sha2::Digest;
241    URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
242}
243
244/// A fresh 128-bit nonce for the state binding.
245fn 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// ─────────────────────────────────────────────────────────────────────────────
252// Row shapes (plain strings; enum casts live in the SQL)
253// ─────────────────────────────────────────────────────────────────────────────
254
255/// One account row as the flow reads it — no enum types (the SQL casts
256/// `$n::o_auth_provider` / selects `provider::text`), so runtime-bound
257/// parameters resolve against the named Postgres types.
258#[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/// Metadata-only account projection — the only account shape an HTTP
270/// response carries.
271#[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// ─────────────────────────────────────────────────────────────────────────────
297// Request / response shapes
298// ─────────────────────────────────────────────────────────────────────────────
299
300/// Start an authorization. `account_ref` is the provider-side identity this
301/// connection claims (the mailbox address for mail providers).
302#[derive(Debug, Deserialize)]
303pub struct AuthorizeRequest {
304    pub provider: String,
305    pub account_ref: String,
306    pub scopes: Option<String>,
307}
308
309/// The authorize response — where to send the browser, and how long the
310/// binding lives. No secrets.
311#[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/// Complete an authorization with the code the provider handed back.
319#[derive(Debug, Deserialize)]
320pub struct CompleteRequest {
321    pub code: String,
322    pub state: String,
323}
324
325/// The completion outcome — metadata only; token material never crosses HTTP.
326#[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/// One sweep's counters (the request-path lazy refresh reports the same
337/// shape for its single account).
338#[derive(Debug, Clone, Default, PartialEq)]
339pub struct RefreshSummary {
340    pub refreshed: usize,
341    /// Due by the mirror, but the store's bundle is still fresh (another
342    /// runner rotated first) — the mirror was re-synced, nothing exchanged.
343    pub resynced: usize,
344    pub expired: usize,
345    pub failures: usize,
346}
347
348// ─────────────────────────────────────────────────────────────────────────────
349// The service
350// ─────────────────────────────────────────────────────────────────────────────
351
352/// The one OAuth generation service. Built once (config validated at build —
353/// a bad endpoint override or a missing state secret refuses construction),
354/// holding the resolved-and-validated endpoints per provider, the Tier-A
355/// state signer, and the two ports (credential store + outbound transport)
356/// the flow runs through.
357pub 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    /// Validate configuration and construct the service. FAIL CLOSED: an
373    /// unusable `state_secret_source`, a missing `public_base`, or ANY
374    /// endpoint override the guard refuses is a build error — there is no
375    /// degraded mode that skips the guard.
376    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        // Every provider's endpoints resolve-and-validate NOW: registry value
414        // or override, the same rule set. A bad override anywhere means no
415        // module — deny by default, not per-request degradation.
416        let mut endpoints = HashMap::new();
417        for provider in registry.providers() {
418            let resolved = ValidatedEndpoints::resolve(&registry, 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    // ── 1. authorize ────────────────────────────────────────────────────────
457
458    /// Start ONE authorization through the ONE flow. Validates the claimed
459    /// provider-side identity, replaces any existing row for the scope with a
460    /// FRESH pending account (terminal statuses are never transitioned out
461    /// of), mints the PKCE verifier + Tier-A state, and returns the provider
462    /// authorize URL. Zero store contact on this path (and no company
463    /// parameter — the module's rows are unfenced; ADR-0029).
464    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        // Fresh pending row: replace-on-reauthorize (a terminal row is deleted,
490        // never resurrected). The PKCE verifier rides the pending row — the
491        // state's account binding is what makes it recoverable. Tenancy
492        // (ADR-0029): the module invents no scope — the transaction binds the
493        // composing service's ambient org scope when one is resolved (the
494        // decorator's org fence and per-unit uniqueness then apply) and runs
495        // unscoped otherwise, where a decorated deployment refuses it.
496        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        // The nonce rides BOTH channels: into the signed state (compared at
523        // completion) and into the authorize request (echoed by the provider
524        // inside the id_token — the echo is the proof the token was minted
525        // for THIS authorization, not replayed). Omitting the request pair
526        // while requiring the echo would make completion unreachable against
527        // every real provider.
528        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        // The authorize URL — built ONLY from the validated endpoint (the
537        // `Url` type is reqwest's re-export; no extra dependency).
538        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            // One flow, uniform parameters: offline access + forced consent so
547            // the provider issues a refresh token; providers that don't use
548            // them ignore them.
549            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    // ── 2. callback (side-effect-free by construction) ─────────────────────
563
564    /// Render the provider redirect target: verify the state (constant-time,
565    /// mandatory expiry) and return a minimal HTML page that auto-submits an
566    /// invisible POST form carrying `code` + `state` to the completion route.
567    /// A SAFE METHOD: this writes nothing, mints nothing, transitions nothing
568    /// — a GET here cannot change a single row. Garbage / tampered / expired
569    /// state → rejection, still zero writes. The form target is RELATIVE, so
570    /// no state-supplied (or any other) URL is ever redirected to.
571    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        // Verify-only: no DB read, no DB write, no store call.
576        self.signer.verify(state)?;
577        Ok(auto_post_page(COMPLETE_ACTION, &[("code", code), ("state", state)]))
578    }
579
580    // ── 3. complete (the gauntlet) ──────────────────────────────────────────
581
582    /// Complete an authorization: re-verify the state, exchange the code at
583    /// the VALIDATED token endpoint (PKCE verifier from the pending account),
584    /// then run the identity gauntlet — audience == configured client id,
585    /// id_token nonce == the nonce minted into the signed state, and the
586    /// provider-side identity must match the claimed `account_ref`. Only
587    /// then is the bundle stored (honest expiry, never NULL) and the account
588    /// transitioned pending → active. ANY rejection leaves zero writes.
589    /// `company_id` is the credential-store scope key (the legacy tenancy
590    /// twin, ADR-0029) — the module's own rows are unfenced.
591    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        // The exchange form. The PKCE verifier comes from the pending account
620        // row (the state's binding makes it recoverable server-side only).
621        // ID-only (ADR-0029): the read rides the composing service's
622        // request-dedicated connection when one is bound — the decorator's
623        // org fence hides another tenant's row — else runs plainly on the pool.
624        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        // Honest lifetimes: a response with no expires_in is unstoreable.
659        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        // The identity gauntlet.
665        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        // Assemble the bundle inputs once. A compliant first consent hands back
679        // a refresh token; a re-authorization may not — carry the prior one
680        // forward (rotation lineage keeps the account reconnectable either way).
681        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        // Store first (the store's own fences apply), then transition the
693        // account under the same honest expiry. A scope that already holds an
694        // active credential (re-connect) rotates — lineage, never a second row.
695        // The bundle is not Clone (it is secret-bearing by design), so the
696        // rotate path re-assembles it from the same inputs.
697        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        // ID-only, state-guarded transition (ADR-0029): rides the
737        // request-dedicated connection when the composing service bound one,
738        // else plainly on the pool.
739        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    // ── 4. disconnect ───────────────────────────────────────────────────────
769
770    /// Disconnect: revoke the scope's credential through the port (a scope
771    /// that never had one revokes cleanly), then transition the account to
772    /// its terminal `revoked`. Idempotent for an already-revoked account.
773    /// `company_id` is the credential-store scope key (the legacy tenancy
774    /// twin, ADR-0029).
775    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            // Never issued (a pending authorization abandoned mid-flight) —
786            // the account still revokes.
787            Err(f) if f.code == OAuthCredentialFailure::CODE_NOT_FOUND => {}
788            Err(f) => return Err(OauthError::Store(f.to_string())),
789        }
790        // ID-only, state-guarded transition (ADR-0029): rides the
791        // request-dedicated connection when the composing service bound one,
792        // else plainly on the pool.
793        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    // ── 5. status ───────────────────────────────────────────────────────────
808
809    /// The account's metadata (never secret material — none exists on the row).
810    /// No company parameter: the read is ID-only and the decorator's org fence
811    /// scopes it (ADR-0029).
812    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    // ── 6. refresh (service-internal; the scheduler and the request path
820    //        both land here) ────────────────────────────────────────────────
821
822    /// Refresh every due account for ONE credential-store scope (`company_id`
823    /// — the legacy tenancy twin, ADR-0029: the module's own tables are
824    /// unfenced, but the store across the port is still company-scoped, so
825    /// the host enumerates the scopes it serves and names one here). Each
826    /// account is claimed `FOR UPDATE SKIP LOCKED` in its OWN short
827    /// transaction with the lock held through processing — concurrent runners
828    /// take disjoint accounts — and commits independently (one provider
829    /// outage rolls back exactly its own account). The transaction binds the
830    /// composing service's ambient org scope when one is resolved, so a
831    /// decorated deployment's fence applies to the claim and the mirror
832    /// updates. `invalid_grant` expires the account (the user must
833    /// reconnect); the credential is left to the store's lazy expiry. Re-runs
834    /// converge: a bundle the store already rotated is only re-mirrored,
835    /// never re-exchanged.
836    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    /// Request-path lazy refresh (refresh-on-use): if THIS account is inside
865    /// the refresh window, run the same single-account refresh the sweep
866    /// runs. Returns the account's metadata either way. `company_id` is the
867    /// credential-store scope key (the legacy tenancy twin, ADR-0029).
868    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    /// Refresh one claimed account inside its own transaction (lock held
905    /// through processing; every exit path commits the outcome or rolls back
906    /// to leave the account due for the next tick). `company_id` is the
907    /// credential-store scope key (the legacy tenancy twin, ADR-0029).
908    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        // The store is the expiry authority: read the bundle, and if IT is
918        // still fresh, only the account's mirror was stale (another runner
919        // rotated first) — re-sync the mirror, never re-exchange.
920        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                // No usable credential: the connection is dead — expire the
927                // account (terminal; the user must reconnect).
928                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            // No refresh token ever stored: nothing to exchange — expire the
963            // account (the access token dies at its honest expiry).
964            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            // No scope: keep the originally granted scopes.
992            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                // The refresh token is dead: expire the account, leave the
998                // credential to the store's lazy expiry.
999                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            // Unstoreable response: refuse it, leave the account due (visible
1021            // in the run's failure count), never fake an expiry.
1022            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        // Microsoft rotates the refresh token itself; Google does not — carry
1028        // the prior one forward when the response omits it. Either way the
1029        // successor rides rotate-lineage semantics.
1030        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    // ── reads ───────────────────────────────────────────────────────────────
1059
1060    /// Read one account row — ID-only (ADR-0029): the read rides the
1061    /// composing service's request-dedicated connection when one is bound —
1062    /// the decorator's org fence hides another tenant's row — else runs
1063    /// plainly on the pool.
1064    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
1083/// Bind the ambient org scope of the current request onto a fresh transaction,
1084/// when the composing service resolved one (ADR-0029). The decorator's org
1085/// fence (org_unit_id + RLS) then applies inside the transaction; with no
1086/// scope bound the transaction runs unscoped and a decorated deployment
1087/// refuses its writes (fail closed).
1088async 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
1095// ─────────────────────────────────────────────────────────────────────────────
1096// The identity gauntlet
1097// ─────────────────────────────────────────────────────────────────────────────
1098
1099/// Enforce the audience + nonce binding: `aud == client_id` (token
1100/// substitution defense) and `nonce == the nonce minted into the signed
1101/// state` (replay binding). Sources, cross-checked when both exist: the
1102/// server-side identity read, and the id_token's own claims decoded here
1103/// (the token endpoint's response over TLS — never a browser-side claim).
1104/// Absent everywhere ⇒ unverified ⇒ rejected.
1105fn 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
1128/// The email match: the provider-side identity must be the identity the
1129/// account claims. An unverified email is refused; a mismatch rejects unless
1130/// the declared shared-mailbox bypass is ON (logged on every use).
1131fn 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
1165/// Merge two sources of one claim; disagreement is a rejection (two
1166/// server-side reads of the same fact must not conflict).
1167fn 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/// The id_token claims this flow enforces (decoded, not signature-verified —
1182/// the token arrived server-side over TLS from the validated token endpoint;
1183/// the audience + nonce checks below are the replay/substitution defense).
1184#[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
1205// ─────────────────────────────────────────────────────────────────────────────
1206// Helpers
1207// ─────────────────────────────────────────────────────────────────────────────
1208
1209fn 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
1234/// Minimal HTML page that auto-submits an invisible POST form — the safe-method
1235/// split: the provider's redirect (GET) writes nothing; the mutation rides the
1236/// form POST. All inserted values are HTML-escaped (they are provider- and
1237/// attacker-influenceable strings).
1238fn 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('&', "&amp;")
1257        .replace('<', "&lt;")
1258        .replace('>', "&gt;")
1259        .replace('\"', "&quot;")
1260        .replace('\'', "&#39;")
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        // Flip one byte of the signature → rejected.
1287        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        // Tamper with the payload → rejected (replace the first char with one
1292        // guaranteed different, so the tamper is real whatever the UUID was).
1293        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        // Truncated / malformed shapes → rejected, never panicked.
1301        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        // Claims absent everywhere ⇒ unverified ⇒ rejected (fail closed).
1348        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        // Mismatch + declared bypass ⇒ allowed (the log line is the audit).
1365        assert!(verify_email_match(&identity(Some("shared@example.com"), Some(true)), "u@example.com", true, "gmail").is_ok());
1366        // Unverified email ⇒ refused even on a match.
1367        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("&lt;script&gt;"));
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}