Skip to main content

backbone_integrations/application/service/
integrations_oauth_ports.rs

1//! The OAuth credential port (hand-authored, user-owned) — the ADR-0024
2//! amendment's placement rule, made concrete for the one OAuth generation.
3//!
4//! ADR-0024 as amended (2026-08-22) ships the fenced credential store in
5//! backbone-sapiens and binds every consumer with two placement rules:
6//! integration modules reach the store through a port, never a Cargo edge;
7//! and the store's surface stays verb-shaped. The payment-gateway's
8//! `CredentialReader` port took the amendment's "read port" phrase literally,
9//! because webhook verification only ever reads. The OAuth dance is the one
10//! consumer that must also MINT (issue the token bundle after the code
11//! exchange) and ROTATE (refresh-before-expiry, and providers that hand back
12//! a fresh refresh token on every exchange) — so this single port carries the
13//! store's full verb set (issue / read_token / rotate / revoke) instead of a
14//! read-only facet. The composing host binds those verbs 1:1 onto the store's
15//! service; the store's placement stays swappable either way, and this module
16//! imports nothing from backbone-sapiens.
17//!
18//! Secret discipline for the [`TokenBundle`] crossing the port:
19//! - `Debug` is redacted — token material cannot drift into a log line;
20//! - fields are zeroized on drop — a bundle exists only inside one verb call;
21//! - no process-global cache — every read goes back through the port, so a
22//!   rotation is observed immediately (ADR-0024 rule 3).
23//!
24//! `expires_at` is deliberately NOT optional on this port. ADR-0024 rule 2
25//! (honest lifetimes) makes a "permanent" oauth_token unstoreable: the
26//! provider's real `expires_in` is the only acceptable value, so the signature
27//! refuses `None` by construction. The host adapter widens to the store's
28//! optional `expires_at` column with `Some(..)` only.
29
30use chrono::{DateTime, Utc};
31use uuid::Uuid;
32use zeroize::Zeroize;
33
34/// The credential-store purpose for OAuth token material — the store's
35/// `CredentialPurpose::oauth_token` variant as a plain string, so this module
36/// carries no store types (the same discipline as the payment-gateway's
37/// `webhook_verify` / `api_read` labels).
38pub const PURPOSE_OAUTH_TOKEN: &str = "oauth_token";
39
40/// An OAuth token bundle crossing the credential port: the access token, the
41/// refresh token (absent when the provider does not return one), the honest
42/// expiry derived from the provider's `expires_in`, and the granted scope.
43///
44/// This is the ONLY secret-bearing shape in the module. Material is private,
45/// redacted in `Debug`, and zeroized on drop; accessors hand out `&str`
46/// references for constructing provider requests (the token-exchange form,
47/// the userinfo `Authorization` header) — never an owned copy.
48pub struct TokenBundle {
49    access_token: String,
50    refresh_token: Option<String>,
51    expires_at: DateTime<Utc>,
52    scope: Option<String>,
53}
54
55impl TokenBundle {
56    /// Assemble a bundle from a verified provider token response. Callers pass
57    /// `expires_at = now + expires_in`; a response without an expiry is
58    /// rejected upstream as unstoreable and never reaches this constructor.
59    pub fn new(
60        access_token: String,
61        refresh_token: Option<String>,
62        expires_at: DateTime<Utc>,
63        scope: Option<String>,
64    ) -> Self {
65        Self { access_token, refresh_token, expires_at, scope }
66    }
67
68    /// The access token — for the `Authorization` header of a provider call.
69    pub fn access_token(&self) -> &str {
70        &self.access_token
71    }
72
73    /// The refresh token, when the provider issued one — for the
74    /// `grant_type=refresh_token` exchange form.
75    pub fn refresh_token(&self) -> Option<&str> {
76        self.refresh_token.as_deref()
77    }
78
79    /// The honest expiry (mirrored on the account row; drives the
80    /// refresh-before-expiry window).
81    pub fn expires_at(&self) -> DateTime<Utc> {
82        self.expires_at
83    }
84
85    /// The granted scope, when the provider echoes it.
86    pub fn scope(&self) -> Option<&str> {
87        self.scope.as_deref()
88    }
89
90    /// Metadata safe for a response or log line — everything except the
91    /// tokens. This is the shape HTTP surfaces may carry (the store's
92    /// metadata-only rule applied at the port).
93    pub fn metadata(&self) -> TokenMetadata {
94        TokenMetadata { expires_at: self.expires_at, scope: self.scope.clone() }
95    }
96}
97
98impl std::fmt::Debug for TokenBundle {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        // Redacted by construction — the token strings never appear.
101        f.debug_struct("TokenBundle")
102            .field("access_token", &"[REDACTED]")
103            .field("refresh_token", &self.refresh_token.as_ref().map(|_| "[REDACTED]"))
104            .field("expires_at", &self.expires_at)
105            .field("scope", &self.scope)
106            .finish()
107    }
108}
109
110impl Drop for TokenBundle {
111    fn drop(&mut self) {
112        self.access_token.zeroize();
113        if let Some(ref mut refresh) = self.refresh_token {
114            refresh.zeroize();
115        }
116    }
117}
118
119/// The non-secret projection of a [`TokenBundle`] — the only token-adjacent
120/// shape an HTTP response or log line may carry.
121#[derive(Debug, Clone, PartialEq)]
122pub struct TokenMetadata {
123    pub expires_at: DateTime<Utc>,
124    pub scope: Option<String>,
125}
126
127/// Why a credential-store verb failed. A flat `{code, message}` record (the
128/// payment-gateway `CredentialFetch` discipline): stable `code` strings
129/// distinguish the cases, `message` carries context, and no store error type
130/// crosses the port.
131///
132/// Failure posture of the codes:
133/// - [`NotFound`](OAuthCredentialFailure::CODE_NOT_FOUND) / [`NotActive`](OAuthCredentialFailure::CODE_NOT_ACTIVE) /
134///   [`Expired`](OAuthCredentialFailure::CODE_EXPIRED) — honest refusals: the scope has no
135///   (readable) credential. Callers surface these, never retry them away.
136/// - [`DuplicateActive`](OAuthCredentialFailure::CODE_DUPLICATE_ACTIVE) — an active credential
137///   already exists for the scope; rotation is the only sanctioned replacement.
138///   A second `issue` for the same scope is a caller bug.
139/// - [`Transport`](OAuthCredentialFailure::CODE_TRANSPORT) — the store could not be reached;
140///   the one retryable code.
141#[derive(Debug, Clone, thiserror::Error)]
142#[error("credential store call failed ({code}): {message}")]
143pub struct OAuthCredentialFailure {
144    pub code: String,
145    pub message: String,
146}
147
148impl OAuthCredentialFailure {
149    /// No credential was ever issued for this scope.
150    pub const CODE_NOT_FOUND: &str = "not_found";
151    /// The scope's credential exists but is in a terminal (non-active) status.
152    pub const CODE_NOT_ACTIVE: &str = "not_active";
153    /// The scope's credential passed its honest expiry (the store observes
154    /// this lazily at read time).
155    pub const CODE_EXPIRED: &str = "expired";
156    /// An active credential already exists for the scope — rotate instead.
157    pub const CODE_DUPLICATE_ACTIVE: &str = "duplicate_active";
158    /// The store could not be reached — retryable.
159    pub const CODE_TRANSPORT: &str = "transport";
160
161    pub fn new(code: &str, message: impl Into<String>) -> Self {
162        Self { code: code.to_string(), message: message.into() }
163    }
164
165    pub fn not_found() -> Self {
166        Self::new(Self::CODE_NOT_FOUND, "no credential issued for this scope")
167    }
168
169    pub fn not_active(status: &str) -> Self {
170        Self::new(Self::CODE_NOT_ACTIVE, format!("credential is {status} (terminal)"))
171    }
172
173    pub fn expired() -> Self {
174        Self::new(Self::CODE_EXPIRED, "credential passed its honest expiry; rotate it")
175    }
176
177    pub fn duplicate_active() -> Self {
178        Self::new(Self::CODE_DUPLICATE_ACTIVE, "an active credential exists; rotate instead of issuing a second")
179    }
180
181    pub fn transport(message: impl Into<String>) -> Self {
182        Self::new(Self::CODE_TRANSPORT, message)
183    }
184
185    /// The one retryable failure — the store itself was unreachable.
186    pub fn is_transport(&self) -> bool {
187        self.code == Self::CODE_TRANSPORT
188    }
189}
190
191/// The credential-store port for the OAuth generation: the store's verb set
192/// (issue / read / rotate / revoke) over edge-free types. The composing host
193/// implements it as a thin adapter over the store's service verbs, scoped to
194/// `purpose = oauth_token` for every read/rotate/revoke; `issue` takes the
195/// purpose explicitly so the binding is visible at the call site.
196///
197/// The `company_id` parameter on every verb is the legacy tenancy twin
198/// (ADR-0029): this module's own tables carry no company column — the composing
199/// service's tenancy decorator owns org scoping — but the credential STORE on
200/// the far side is still company-scoped, so the caller (the composing host,
201/// which knows the tenant) names the store's scope key. An unknown or stale
202/// value fails closed at the store (honest not_found), never inside this module.
203///
204/// All writes ride the store's rotate-lineage semantics — the module never
205/// persists secret material itself, and replacement always goes through
206/// [`rotate`](OAuthCredentialStore::rotate) so lineage is preserved.
207#[async_trait::async_trait]
208pub trait OAuthCredentialStore: Send + Sync {
209    /// Store the FIRST credential for a scope (after the code exchange
210    /// verifies). Rejects with
211    /// [`DuplicateActive`](OAuthCredentialFailure::CODE_DUPLICATE_ACTIVE) when an active
212    /// credential already exists — replacement is [`rotate`](OAuthCredentialStore::rotate),
213    /// never a second issue. Returns the new credential id.
214    async fn issue(
215        &self,
216        company_id: Uuid,
217        provider: &str,
218        account_ref: &str,
219        purpose: &str,
220        bundle: TokenBundle,
221        expires_at: DateTime<Utc>,
222    ) -> Result<Uuid, OAuthCredentialFailure>;
223
224    /// Open the scope's active token bundle (the access-controlled read; the
225    /// only path that ever sees token material). Refuses
226    /// [`NotFound`](OAuthCredentialFailure::CODE_NOT_FOUND) / [`NotActive`](OAuthCredentialFailure::CODE_NOT_ACTIVE) /
227    /// [`Expired`](OAuthCredentialFailure::CODE_EXPIRED) honestly — those are never retried away.
228    async fn read_token(
229        &self,
230        company_id: Uuid,
231        provider: &str,
232        account_ref: &str,
233    ) -> Result<TokenBundle, OAuthCredentialFailure>;
234
235    /// Replace the scope's active credential atomically: the successor is
236    /// stored and the predecessor revoked with lineage preserved (the
237    /// refresh-before-expiry path; also the mechanism for providers that
238    /// rotate the refresh token itself). Returns the successor's id.
239    async fn rotate(
240        &self,
241        company_id: Uuid,
242        provider: &str,
243        account_ref: &str,
244        bundle: TokenBundle,
245        expires_at: DateTime<Utc>,
246    ) -> Result<Uuid, OAuthCredentialFailure>;
247
248    /// Withdraw the scope's credential (account disconnect). Idempotent once
249    /// the scope has had a credential; honest
250    /// [`NotFound`](OAuthCredentialFailure::CODE_NOT_FOUND) when it never had one.
251    async fn revoke(
252        &self,
253        company_id: Uuid,
254        provider: &str,
255        account_ref: &str,
256    ) -> Result<(), OAuthCredentialFailure>;
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn bundle() -> TokenBundle {
264        TokenBundle::new(
265            "SECRET-ACCESS-TOKEN-0123456789".into(),
266            Some("SECRET-REFRESH-TOKEN-9876543210".into()),
267            Utc::now() + chrono::Duration::hours(24),
268            Some("https://mail.google.com/".into()),
269        )
270    }
271
272    #[test]
273    fn debug_never_contains_token_material() {
274        let b = bundle();
275        let debugged = format!("{b:?}");
276        assert!(!debugged.contains("SECRET-ACCESS-TOKEN"), "access token leaked into Debug: {debugged}");
277        assert!(!debugged.contains("SECRET-REFRESH-TOKEN"), "refresh token leaked into Debug: {debugged}");
278        assert!(debugged.contains("[REDACTED]"), "redaction marker missing: {debugged}");
279        // Non-secret fields stay visible — Debug remains diagnostic.
280        assert!(debugged.contains("expires_at"), "expiry hidden, Debug no longer diagnostic: {debugged}");
281    }
282
283    #[test]
284    fn accessors_hand_out_references_and_metadata_only_projection() {
285        let b = bundle();
286        assert_eq!(b.access_token(), "SECRET-ACCESS-TOKEN-0123456789");
287        assert_eq!(b.refresh_token(), Some("SECRET-REFRESH-TOKEN-9876543210"));
288        let meta = b.metadata();
289        assert_eq!(meta.scope.as_deref(), Some("https://mail.google.com/"));
290        let meta_debug = format!("{meta:?}");
291        assert!(!meta_debug.contains("SECRET"), "metadata projection carries token material: {meta_debug}");
292    }
293
294    #[test]
295    fn failure_codes_are_stable_and_distinct() {
296        let codes = [
297            OAuthCredentialFailure::CODE_NOT_FOUND,
298            OAuthCredentialFailure::CODE_NOT_ACTIVE,
299            OAuthCredentialFailure::CODE_EXPIRED,
300            OAuthCredentialFailure::CODE_DUPLICATE_ACTIVE,
301            OAuthCredentialFailure::CODE_TRANSPORT,
302        ];
303        let mut sorted = codes.to_vec();
304        sorted.sort_unstable();
305        sorted.dedup();
306        assert_eq!(sorted.len(), codes.len(), "failure codes must not collide");
307
308        assert!(!OAuthCredentialFailure::not_found().is_transport());
309        assert!(OAuthCredentialFailure::transport("store unreachable").is_transport());
310        assert_eq!(OAuthCredentialFailure::duplicate_active().code, "duplicate_active");
311    }
312
313    #[test]
314    fn purpose_label_matches_the_store_vocabulary() {
315        // The store's CredentialPurpose variant name, as a plain string — the
316        // host adapter binds this exact label.
317        assert_eq!(PURPOSE_OAUTH_TOKEN, "oauth_token");
318    }
319}