spiral_auth/providers/
client.rs1use 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#[derive(Clone)]
15pub struct ClientCredentials {
16 client_id: String,
17 client_secret: SecretString,
18}
19
20impl ClientCredentials {
21 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 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 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 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}