Skip to main content

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(&params)
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}