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