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