authkestra_engine/flow/client_credentials_flow.rs
1use crate::auth::{error::AuthError, state::OAuthToken};
2use crate::client_assertion::{self, CLIENT_ASSERTION_TYPE_JWT_BEARER};
3use jsonwebtoken::{Algorithm, EncodingKey};
4
5/// How a `ClientCredentialsFlow` authenticates itself to the token endpoint.
6///
7/// An enum rather than a trait object: there are exactly two RFC-defined
8/// shapes this flow speaks (a shared secret, or a self-signed assertion), and
9/// both need direct access to this struct's private fields (`client_id`,
10/// `token_url`) to do their job — an `AuthMethod`-style trait would need to
11/// hand those back in, for no abstraction benefit over a closed enum only
12/// this module matches on.
13enum ClientAuth {
14 /// `client_secret_post` (RFC 6749 §2.3.1): the shared secret is sent as
15 /// a form parameter alongside the request.
16 Secret(String),
17 /// `private_key_jwt` (RFC 7523 §2.2): no shared secret is sent at all —
18 /// instead, a freshly minted assertion proves possession of the private
19 /// half of a key the authorization server already has the public half
20 /// of. See [`crate::client_assertion::mint_client_assertion`].
21 PrivateKeyJwt {
22 encoding_key: EncodingKey,
23 alg: Algorithm,
24 /// Stamped onto the assertion's header `kid`, if the server needs
25 /// it to select among several registered keys. `None` is valid only
26 /// when the server has exactly one registered key for this client —
27 /// see `authkestra_op::client_assertion::select_key`.
28 kid: Option<String>,
29 },
30}
31
32/// Orchestrates the Client Credentials Flow (RFC 6749 Section 4.4).
33///
34/// This flow is used by clients to obtain an access token outside of the context
35/// of a user. This is typically used for client-to-client communication.
36pub struct ClientCredentialsFlow {
37 client_id: String,
38 auth: ClientAuth,
39 token_url: String,
40 http_client: reqwest::Client,
41}
42
43impl ClientCredentialsFlow {
44 /// Creates a new `ClientCredentialsFlow` instance authenticating with a
45 /// shared `client_secret` (RFC 6749 §2.3.1).
46 ///
47 /// # Arguments
48 ///
49 /// * `client_id` - The client ID assigned to the client.
50 /// * `client_secret` - The client secret assigned to the client.
51 /// * `token_url` - The URL of the token endpoint.
52 pub fn new(client_id: String, client_secret: String, token_url: String) -> Self {
53 Self {
54 client_id,
55 auth: ClientAuth::Secret(client_secret),
56 token_url,
57 http_client: reqwest::Client::new(),
58 }
59 }
60
61 /// Creates a new `ClientCredentialsFlow` instance authenticating with
62 /// `private_key_jwt` (RFC 7523 §2.2) instead of a shared secret.
63 ///
64 /// Use this when the client cannot hold a shared secret at all — e.g. a
65 /// backend service that only ever authenticates from a keystore holding
66 /// an asymmetric keypair, with just the public half registered against
67 /// this `client_id` at the authorization server. `get_token` mints a
68 /// fresh assertion JWT (`iss`/`sub` = `client_id`, `aud` = `token_url`, a
69 /// new `jti`, and `exp` bounded by
70 /// [`crate::client_assertion::MAX_CLIENT_ASSERTION_LIFETIME_SECS`]) on
71 /// every call and sends it as `client_assertion` alongside
72 /// `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`,
73 /// in place of `client_secret`.
74 ///
75 /// # Arguments
76 ///
77 /// * `client_id` - The client ID assigned to the client.
78 /// * `signing_key` - The private key to sign assertions with. Must match
79 /// `alg` (e.g. an Ed25519 key for [`Algorithm::EdDSA`]).
80 /// * `alg` - The signature algorithm `signing_key` signs with. This
81 /// crate's own OP (`authkestra_op::client_assertion`) derives the
82 /// algorithm it will accept from the client's *registered public key*,
83 /// never from this header, so `alg` here must agree with whatever key
84 /// type was registered.
85 /// * `token_url` - The URL of the token endpoint; also the `aud` claim
86 /// minted into every assertion.
87 pub fn new_private_key_jwt(
88 client_id: String,
89 signing_key: EncodingKey,
90 alg: Algorithm,
91 token_url: String,
92 ) -> Self {
93 Self {
94 client_id,
95 auth: ClientAuth::PrivateKeyJwt {
96 encoding_key: signing_key,
97 alg,
98 kid: None,
99 },
100 token_url,
101 http_client: reqwest::Client::new(),
102 }
103 }
104
105 /// Stamps `kid` onto the header of every assertion minted by
106 /// `private_key_jwt` authentication, so a server with several keys
107 /// registered for this client can tell which one signed it (see
108 /// `authkestra_op::client_assertion::select_key`).
109 ///
110 /// A no-op when this flow was constructed via [`Self::new`] — there is
111 /// no assertion to stamp a `kid` onto when authenticating with a shared
112 /// secret.
113 pub fn with_kid(mut self, kid: impl Into<String>) -> Self {
114 if let ClientAuth::PrivateKeyJwt { kid: slot, .. } = &mut self.auth {
115 *slot = Some(kid.into());
116 }
117 self
118 }
119
120 /// Obtains an access token using the client credentials.
121 ///
122 /// # Arguments
123 ///
124 /// * `scopes` - An optional list of scopes to request.
125 ///
126 /// # Returns
127 ///
128 /// A `Result` containing the `OAuthToken` if successful, or an `AuthError` otherwise.
129 #[tracing::instrument(skip(self, scopes), fields(client_id = %self.client_id))]
130 pub async fn get_token(&self, scopes: Option<&[&str]>) -> Result<OAuthToken, AuthError> {
131 let mut params: Vec<(&str, String)> = vec![
132 ("grant_type", "client_credentials".to_string()),
133 ("client_id", self.client_id.clone()),
134 ];
135
136 match &self.auth {
137 ClientAuth::Secret(secret) => {
138 tracing::debug!("authenticating with client_secret_post");
139 params.push(("client_secret", secret.clone()));
140 }
141 ClientAuth::PrivateKeyJwt {
142 encoding_key,
143 alg,
144 kid,
145 } => {
146 tracing::debug!("authenticating with private_key_jwt; minting a fresh assertion");
147 let assertion = client_assertion::mint_client_assertion(
148 &self.client_id,
149 &self.token_url,
150 encoding_key,
151 *alg,
152 kid.as_deref(),
153 client_assertion::MAX_CLIENT_ASSERTION_LIFETIME_SECS,
154 )
155 .map_err(|e| {
156 tracing::error!(error = %e, "failed to mint private_key_jwt client assertion");
157 e
158 })?;
159 params.push((
160 "client_assertion_type",
161 CLIENT_ASSERTION_TYPE_JWT_BEARER.to_string(),
162 ));
163 params.push(("client_assertion", assertion));
164 }
165 }
166
167 if let Some(s) = scopes {
168 params.push(("scope", s.join(" ")));
169 }
170
171 let response = self
172 .http_client
173 .post(&self.token_url)
174 .header("Accept", "application/json")
175 .form(¶ms)
176 .send()
177 .await
178 .map_err(|e| {
179 tracing::error!(error = %e, "network error requesting token");
180 AuthError::Network
181 })?;
182
183 if !response.status().is_success() {
184 let error_text = response.text().await.unwrap_or_default();
185 tracing::warn!(error = %error_text, "token request failed");
186 return Err(AuthError::Provider(format!(
187 "Token request failed: {error_text}"
188 )));
189 }
190
191 response.json::<OAuthToken>().await.map_err(|e| {
192 tracing::error!(error = %e, "failed to parse token response");
193 AuthError::Provider(format!("Failed to parse token response: {e}"))
194 })
195 }
196}