Skip to main content

spiral_auth/providers/
client.rs

1use std::fmt::{Debug, Formatter};
2
3use reqwest_middleware::ClientWithMiddleware as Client;
4use secrecy::{ExposeSecret as _, SecretString};
5use url::Url;
6
7use crate::http::Accepted as _;
8use crate::{
9    CredentialResult, OAuth2ClientAuthentication, OAuth2Grant, OAuth2TokenRequest,
10    OAuth2TokenResponse, Token,
11};
12
13/// A client ID and secret used for OAuth2 client-credentials authentication.
14#[derive(Clone)]
15pub struct ClientCredentials {
16    client_id: String,
17    client_secret: SecretString,
18}
19
20impl ClientCredentials {
21    /// Create credentials that are owned by one Spiral client.
22    pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
23        Self {
24            client_id: client_id.into(),
25            client_secret: SecretString::from(client_secret.into()),
26        }
27    }
28
29    fn client_id(&self) -> &str {
30        &self.client_id
31    }
32
33    fn client_secret(&self) -> &str {
34        self.client_secret.expose_secret()
35    }
36}
37
38impl Debug for ClientCredentials {
39    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
40        f.debug_struct("ClientCredentials")
41            .field("client_id", &self.client_id)
42            .finish_non_exhaustive()
43    }
44}
45
46pub struct ClientCredentialsProvider {
47    client: Client,
48    api_url: Url,
49    credentials: Option<ClientCredentials>,
50}
51
52impl ClientCredentialsProvider {
53    pub fn new(client: impl Into<Client>, api_url: Url) -> Self {
54        ClientCredentialsProvider {
55            client: client.into(),
56            api_url,
57            credentials: None,
58        }
59    }
60
61    /// Create a provider with credentials scoped to this provider instance.
62    pub fn with_credentials(
63        client: impl Into<Client>,
64        api_url: Url,
65        credentials: ClientCredentials,
66    ) -> Self {
67        ClientCredentialsProvider {
68            client: client.into(),
69            api_url,
70            credentials: Some(credentials),
71        }
72    }
73}
74
75impl Debug for ClientCredentialsProvider {
76    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
77        f.debug_struct("ClientCredentialsProvider").finish()
78    }
79}
80
81impl ClientCredentialsProvider {
82    /// A usable token, or `None` when this provider has no credentials to offer.
83    pub async fn token(&self) -> CredentialResult<Option<Token>> {
84        let (client_id, client_secret) = match &self.credentials {
85            Some(credentials) => (
86                credentials.client_id().to_owned(),
87                credentials.client_secret().to_owned(),
88            ),
89            None => {
90                let Ok(client_id) = std::env::var("SPIRAL_CLIENT_ID") else {
91                    return Ok(None);
92                };
93                let Ok(client_secret) = std::env::var("SPIRAL_CLIENT_SECRET") else {
94                    bail!("SPIRAL_CLIENT_SECRET environment variable expected but not set")
95                };
96                (client_id, client_secret)
97            }
98        };
99
100        // Exchange client credentials for a token
101        let response = self
102            .client
103            .post(
104                self.api_url
105                    .join("/oauth/token")
106                    .map_err(|e| invalid!("invalid url {e}"))?,
107            )
108            .form(&OAuth2TokenRequest {
109                client: OAuth2ClientAuthentication {
110                    client_id: Some(client_id),
111                    client_secret: Some(client_secret),
112                    ..Default::default()
113                },
114                grant: OAuth2Grant::ClientCredentials {
115                    resource: None,
116                    authorization_details: None,
117                },
118            })
119            .send()
120            .await?
121            .accepted()
122            .await?
123            .json::<OAuth2TokenResponse>()
124            .await?;
125
126        Ok(Some(response.into_access_token()))
127    }
128}
129
130impl_token_source!(ClientCredentialsProvider);
131
132#[cfg(test)]
133mod tests {
134    use reqwest::header::{HeaderMap, HeaderValue};
135    use reqwest::redirect::Policy;
136    use serde_json::json;
137    use wiremock::matchers::{header, method, path};
138    use wiremock::{Mock, MockServer, ResponseTemplate};
139
140    use super::*;
141    use crate::install_default_crypto_provider;
142
143    #[tokio::test]
144    #[expect(
145        clippy::disallowed_methods,
146        reason = "Verify that auth uses a caller-owned transport"
147    )]
148    async fn token_requests_use_the_supplied_transport() {
149        install_default_crypto_provider();
150        let server = MockServer::start().await;
151        Mock::given(method("POST"))
152            .and(path("/oauth/token"))
153            .and(header("x-host-transport", "present"))
154            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
155                "access_token": "issued", "token_type": "Bearer", "expires_in": 900
156            })))
157            .expect(1)
158            .mount(&server)
159            .await;
160        let mut headers = HeaderMap::new();
161        headers.insert("x-host-transport", HeaderValue::from_static("present"));
162        let http = reqwest::Client::builder()
163            .redirect(Policy::none())
164            .default_headers(headers)
165            .build()
166            .unwrap();
167        let provider = ClientCredentialsProvider::with_credentials(
168            http,
169            server.uri().parse().unwrap(),
170            ClientCredentials::new("client", "secret"),
171        );
172        assert_eq!(
173            provider.token().await.unwrap().unwrap().expose_secret(),
174            "issued"
175        );
176    }
177
178    #[test]
179    fn client_credentials_debug_output_redacts_the_secret() {
180        let credentials = ClientCredentials::new("client-id", "client-secret");
181        let debug = format!("{credentials:?}");
182        assert!(debug.contains("client-id"));
183        assert!(!debug.contains("client-secret"));
184    }
185}