1use crate::{authentication_error, get_authority_host, TokenCache};
5use azure_core::credentials::TokenRequestOptions;
6use azure_core::http::PipelineSendOptions;
7use azure_core::Result;
8use azure_core::{
9 credentials::{AccessToken, Secret, TokenCredential},
10 error::{ErrorKind, ResultExt},
11 http::{
12 headers::{self, content_type},
13 ClientOptions, Method, Pipeline, Request, Url,
14 },
15 Error,
16};
17use std::{any::type_name, fmt, str, sync::Arc};
18use url::form_urlencoded;
19
20#[derive(Default)]
22pub struct ClientSecretCredentialOptions {
23 pub client_options: ClientOptions,
25}
26
27impl fmt::Debug for ClientSecretCredentialOptions {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 f.debug_struct(type_name::<Self>()).finish_non_exhaustive()
30 }
31}
32
33pub struct ClientSecretCredential {
35 cache: TokenCache,
36 client_id: String,
37 endpoint: Url,
38 pipeline: Pipeline,
39 secret: Secret,
40}
41
42impl fmt::Debug for ClientSecretCredential {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 f.debug_struct(type_name::<Self>())
45 .field("client_id", &self.client_id)
46 .field("endpoint", &self.endpoint)
47 .finish_non_exhaustive()
48 }
49}
50
51impl ClientSecretCredential {
52 pub fn new(
61 tenant_id: &str,
62 client_id: String,
63 secret: Secret,
64 options: Option<ClientSecretCredentialOptions>,
65 ) -> Result<Arc<Self>> {
66 crate::validate_tenant_id(tenant_id)?;
67 crate::validate_not_empty(&client_id, "no client ID specified")?;
68 crate::validate_not_empty(secret.secret(), "no secret specified")?;
69
70 let options = options.unwrap_or_default();
71 let authority_host = get_authority_host(None, options.client_options.cloud.as_deref())?;
72 let endpoint = authority_host
73 .join(&format!("/{tenant_id}/oauth2/v2.0/token"))
74 .with_context_fn(ErrorKind::DataConversion, || {
75 format!("tenant_id '{tenant_id}' could not be URL encoded")
76 })?;
77
78 let pipeline = Pipeline::new(
79 option_env!("CARGO_PKG_NAME"),
80 option_env!("CARGO_PKG_VERSION"),
81 options.client_options,
82 Vec::default(),
83 Vec::default(),
84 None,
85 );
86
87 Ok(Arc::new(Self {
88 cache: TokenCache::new(),
89 client_id,
90 endpoint,
91 pipeline,
92 secret,
93 }))
94 }
95
96 async fn get_token_impl(
97 &self,
98 scopes: &[&str],
99 options: Option<TokenRequestOptions<'_>>,
100 ) -> Result<AccessToken> {
101 let mut req = Request::new(self.endpoint.clone(), Method::Post);
102 req.insert_header(
103 headers::CONTENT_TYPE,
104 content_type::APPLICATION_X_WWW_FORM_URLENCODED,
105 );
106 let body = form_urlencoded::Serializer::new(String::new())
107 .append_pair("client_id", &self.client_id)
108 .append_pair("client_secret", self.secret.secret())
109 .append_pair("grant_type", "client_credentials")
110 .append_pair("scope", &scopes.join(" "))
111 .finish();
112 req.set_body(body);
113
114 let options = options.unwrap_or_default();
115 let ctx = options.method_options.context.to_borrowed();
116 let res = self
117 .pipeline
118 .send(
119 &ctx,
120 &mut req,
121 Some(PipelineSendOptions {
122 skip_checks: true,
123 ..Default::default()
124 }),
125 )
126 .await?;
127
128 crate::handle_entra_response(res)
129 }
130}
131
132#[async_trait::async_trait]
133impl TokenCredential for ClientSecretCredential {
134 async fn get_token(
135 &self,
136 scopes: &[&str],
137 options: Option<TokenRequestOptions<'_>>,
138 ) -> Result<AccessToken> {
139 if scopes.is_empty() {
140 return Err(Error::with_message(
141 ErrorKind::Credential,
142 "no scopes specified",
143 ));
144 }
145 self.cache
146 .get_token(scopes, options, |s, o| self.get_token_impl(s, o))
147 .await
148 .map_err(|err| authentication_error(stringify!(ClientSecretCredential), err))
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::tests::*;
156 use azure_core::{
157 http::{headers::Headers, AsyncRawResponse, RawResponse, StatusCode, Transport},
158 Bytes, Result,
159 };
160 use std::vec;
161 use time::OffsetDateTime;
162
163 const FAKE_SECRET: &str = "fake secret";
164
165 fn is_valid_request(expected_authority: String) -> impl Fn(&Request) -> Result<()> {
166 let expected_url = format!("{}/oauth2/v2.0/token", expected_authority);
167 move |req: &Request| {
168 assert_eq!(Method::Post, req.method());
169 assert_eq!(expected_url, req.url().to_string());
170 assert_eq!(
171 req.headers().get_str(&headers::CONTENT_TYPE).unwrap(),
172 content_type::APPLICATION_X_WWW_FORM_URLENCODED.as_str()
173 );
174 Ok(())
175 }
176 }
177
178 #[tokio::test]
179 async fn cloud_configuration() {
180 for (cloud, expected_authority) in cloud_configuration_cases() {
181 let sts = MockSts::new(
182 vec![token_response()],
183 Some(Arc::new(is_valid_request(expected_authority))),
184 );
185 let credential = ClientSecretCredential::new(
186 FAKE_TENANT_ID,
187 FAKE_CLIENT_ID.to_string(),
188 FAKE_SECRET.into(),
189 Some(ClientSecretCredentialOptions {
190 client_options: ClientOptions {
191 transport: Some(Transport::new(Arc::new(sts))),
192 cloud: Some(Arc::new(cloud)),
193 ..Default::default()
194 },
195 }),
196 )
197 .expect("valid credential");
198
199 credential
200 .get_token(LIVE_TEST_SCOPES, None)
201 .await
202 .expect("token");
203 }
204 }
205
206 #[tokio::test]
207 async fn get_token_error() {
208 let body = Bytes::from(
209 r#"{"error":"invalid_client","error_description":"AADSTS7000215: Invalid client secret.","error_codes":[7000215],"timestamp":"2025-04-04 21:10:04Z","trace_id":"...","correlation_id":"...","error_uri":"https://login.microsoftonline.com/error?code=7000215"}"#,
210 );
211 let expected_status = StatusCode::BadRequest;
212 let mut headers = Headers::default();
213 headers.insert("key", "value");
214 let expected_response =
215 RawResponse::from_bytes(expected_status, headers.clone(), body.clone());
216 let sts = MockSts::new(
217 vec![AsyncRawResponse::from_bytes(expected_status, headers, body)],
218 Some(Arc::new(is_valid_request(
219 FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
220 ))),
221 );
222 let cred = ClientSecretCredential::new(
223 FAKE_TENANT_ID,
224 FAKE_CLIENT_ID.to_string(),
225 FAKE_SECRET.into(),
226 Some(ClientSecretCredentialOptions {
227 client_options: ClientOptions {
228 transport: Some(Transport::new(Arc::new(sts))),
229 ..Default::default()
230 },
231 }),
232 )
233 .expect("valid credential");
234
235 let err = cred
236 .get_token(LIVE_TEST_SCOPES, None)
237 .await
238 .expect_err("expected error");
239 assert!(matches!(err.kind(), ErrorKind::Credential));
240 assert_eq!(
241 "ClientSecretCredential authentication failed. AADSTS7000215: Invalid client secret.\nTo troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot#client-secret",
242 err.to_string(),
243 );
244 match err
245 .downcast_ref::<azure_core::Error>()
246 .expect("returned error should wrap an azure_core::Error")
247 .kind()
248 {
249 ErrorKind::HttpResponse {
250 error_code: Some(error_code),
251 raw_response: Some(response),
252 status,
253 } => {
254 assert_eq!("7000215", error_code);
255 assert_eq!(&expected_response, response.as_ref());
256 assert_eq!(expected_status, *status);
257 }
258 err => panic!("unexpected {:?}", err),
259 };
260 }
261
262 #[tokio::test]
263 async fn get_token_success() {
264 let expires_in = 3600;
265 let sts = MockSts::new(
266 vec![token_response()],
267 Some(Arc::new(is_valid_request(
268 FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
269 ))),
270 );
271 let cred = ClientSecretCredential::new(
272 FAKE_TENANT_ID,
273 FAKE_CLIENT_ID.to_string(),
274 FAKE_SECRET.into(),
275 Some(ClientSecretCredentialOptions {
276 client_options: ClientOptions {
277 transport: Some(Transport::new(Arc::new(sts))),
278 ..Default::default()
279 },
280 }),
281 )
282 .expect("valid credential");
283 let token = cred.get_token(LIVE_TEST_SCOPES, None).await.expect("token");
284
285 assert_eq!(FAKE_TOKEN, token.token.secret());
286
287 let lifetime =
291 token.expires_on.unix_timestamp() - OffsetDateTime::now_utc().unix_timestamp();
292 assert!(
293 (expires_in..expires_in + 1).contains(&lifetime),
294 "token should expire in ~{} seconds but actually expires in {} seconds",
295 expires_in,
296 lifetime
297 );
298
299 let cached_token = cred
301 .get_token(LIVE_TEST_SCOPES, None)
302 .await
303 .expect("cached token");
304 assert_eq!(token.token.secret(), cached_token.token.secret());
305 assert_eq!(token.expires_on, cached_token.expires_on);
306 }
307
308 #[test]
309 fn invalid_tenant_id() {
310 ClientSecretCredential::new(
311 "not a valid tenant",
312 FAKE_CLIENT_ID.to_string(),
313 FAKE_SECRET.into(),
314 None,
315 )
316 .expect_err("invalid tenant ID");
317 }
318
319 #[tokio::test]
320 async fn no_scopes() {
321 ClientSecretCredential::new(
322 FAKE_TENANT_ID,
323 FAKE_CLIENT_ID.to_string(),
324 FAKE_SECRET.into(),
325 None,
326 )
327 .expect("valid credential")
328 .get_token(&[], None)
329 .await
330 .expect_err("no scopes specified");
331 }
332}