oauth2_broker/flows/
client_credentials.rs

1//! Client Credentials flow orchestration with caching + singleflight guards.
2//!
3//! The broker exposes [`Broker::client_credentials`] so callers can reuse cached
4//! access tokens for service-to-service principals. Each request uses the same
5//! tenant/principal/scope tuple used by other flows, evaluates a jittered
6//! preemptive window, and only calls the provider when the cached record is
7//! missing/expired/forced. A per-`StoreKey` singleflight guard ensures concurrent
8//! callers piggy-back on the same in-flight refresh instead of stampeding the
9//! token endpoint.
10
11// self
12use crate::{
13	_prelude::*,
14	auth::{TokenFamily, TokenRecord},
15	error::ConfigError,
16	flows::{
17		Broker,
18		common::{self, CachedTokenRequest},
19	},
20	http::TokenHttpClient,
21	oauth::{BasicFacade, OAuth2Facade, TransportErrorMapper},
22	obs::{self, FlowKind, FlowOutcome, FlowSpan},
23	provider::{GrantType, ProviderStrategy},
24	store::{BrokerStore, StoreKey},
25};
26
27impl<C, M> Broker<C, M>
28where
29	C: TokenHttpClient + ?Sized,
30	M: TransportErrorMapper<C::TransportError> + ?Sized,
31{
32	/// Performs the `client_credentials` grant with caching + singleflight guards.
33	pub async fn client_credentials(&self, request: CachedTokenRequest) -> Result<TokenRecord> {
34		const KIND: FlowKind = FlowKind::ClientCredentials;
35
36		let span = FlowSpan::new(KIND, "client_credentials");
37
38		obs::record_flow_outcome(KIND, FlowOutcome::Attempt);
39
40		let result = span
41			.instrument(async move {
42				self.ensure_client_credentials_supported()?;
43
44				let tenant = request.tenant.clone();
45				let principal = request.principal.clone();
46				let store_scope = request.scope.clone();
47				let requested_scope = store_scope.clone();
48				let mut family = TokenFamily::new(tenant, principal);
49
50				family.provider = Some(self.descriptor.id.clone());
51
52				let key = StoreKey::new(&family, &store_scope);
53				let guard = common::flow_guard(self, &key);
54				let _singleflight = guard.lock().await;
55				let now = OffsetDateTime::now_utc();
56
57				if let Some(current) =
58					<dyn BrokerStore>::fetch(self.store.as_ref(), &family, &store_scope)
59						.await
60						.map_err(Error::from)?
61						.filter(|record| !request.should_refresh(record, now))
62				{
63					return Ok(current);
64				}
65
66				let grant = GrantType::ClientCredentials;
67				let mut form = {
68					let mut map = BTreeMap::new();
69
70					map.insert("grant_type".into(), grant.as_str().into());
71
72					map
73				};
74
75				if let Some(scope_value) =
76					common::format_scope(&requested_scope, self.descriptor.quirks.scope_delimiter)
77				{
78					form.insert("scope".into(), scope_value);
79				}
80
81				<dyn ProviderStrategy>::augment_token_request(
82					self.strategy.as_ref(),
83					grant,
84					&mut form,
85				);
86
87				let extra_params: Vec<(String, String)> = form
88					.into_iter()
89					.filter(|(key, _)| key != "grant_type" && key != "scope")
90					.collect();
91				let scope_params = requested_scope.iter().collect::<Vec<_>>();
92				let facade: BasicFacade<C, M> = BasicFacade::from_descriptor(
93					&self.descriptor,
94					&self.client_id,
95					self.client_secret.as_deref(),
96					None,
97					self.http_client.clone(),
98					self.transport_mapper.clone(),
99				)?;
100				let record = facade
101					.exchange_client_credentials(
102						self.strategy.as_ref(),
103						family,
104						scope_params.as_slice(),
105						extra_params.as_slice(),
106					)
107					.await?;
108
109				<dyn BrokerStore>::save(self.store.as_ref(), record.clone())
110					.await
111					.map_err(Error::from)?;
112
113				Ok(record)
114			})
115			.await;
116
117		match &result {
118			Ok(_) => obs::record_flow_outcome(KIND, FlowOutcome::Success),
119			Err(_) => obs::record_flow_outcome(KIND, FlowOutcome::Failure),
120		}
121
122		result
123	}
124
125	fn ensure_client_credentials_supported(&self) -> Result<()> {
126		if self.descriptor.supports(GrantType::ClientCredentials) {
127			Ok(())
128		} else {
129			Err(ConfigError::UnsupportedGrant {
130				descriptor: self.descriptor.id.to_string(),
131				grant: "client_credentials",
132			}
133			.into())
134		}
135	}
136}