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}