Skip to main content

azure_identity/
client_secret_credential.rs

1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT License.
3
4use 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/// Options for constructing a new [`ClientSecretCredential`].
21#[derive(Default)]
22pub struct ClientSecretCredentialOptions {
23    /// Options for the credential's HTTP pipeline.
24    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
33/// Authenticates an application with a client secret.
34pub 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    /// Create a new `ClientSecretCredential`.
53    ///
54    /// # Arguments
55    /// - `tenant_id`: The tenant (directory) ID of the service principal.
56    /// - `client_id`: The client (application) ID of the service principal.
57    /// - `secret`: The client secret that was generated for the service principal.
58    /// - `options`: Options for configuring the credential. If `None`, the credential uses its default options.
59    ///
60    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        // allow a small margin when validating expiration time because it's computed as
288        // the current time plus a number of seconds (expires_in) and the system clock
289        // may have ticked into the next second since we assigned expires_in above
290        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        // sts will return an error if the credential sends another request
300        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}