Skip to content

Commit abe771c

Browse files
authored
Add ClientSecretCredential (#2438)
1 parent 7454ddd commit abe771c

File tree

5 files changed

+344
-13
lines changed

5 files changed

+344
-13
lines changed

sdk/identity/.dict.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
AADSTS
12
adfs
23
appservice
34
azureauth

sdk/identity/azure_identity/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- `AzureCliCredentialOptions` (new) accepts a `azure_core::process::Executor` to run the Azure CLI asynchronously.
99
The `tokio` feature is disabled by default so `std::process::Command` is used; otherwise, if enabled, `tokio::process::Command` is used.
1010
Callers can also implement the trait themselves to use a different asynchronous runtime.
11+
- Restored `ClientSecretCredential`
1112

1213
### Breaking Changes
1314

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
use crate::{credentials::cache::TokenCache, EntraIdTokenResponse};
5+
use crate::{EntraIdErrorResponse, TokenCredentialOptions};
6+
use azure_core::http::{Response, StatusCode};
7+
use azure_core::Result;
8+
use azure_core::{
9+
credentials::{AccessToken, Secret, TokenCredential},
10+
error::{ErrorKind, ResultExt},
11+
http::{
12+
headers::{self, content_type},
13+
Method, Request, Url,
14+
},
15+
Error,
16+
};
17+
use std::time::Duration;
18+
use std::{str, sync::Arc};
19+
use time::OffsetDateTime;
20+
use url::form_urlencoded;
21+
22+
/// Options for constructing a new [`ClientSecretCredential`].
23+
#[derive(Debug, Default)]
24+
pub struct ClientSecretCredentialOptions {
25+
/// Options for constructing credentials.
26+
pub credential_options: TokenCredentialOptions,
27+
}
28+
29+
/// Authenticates an application with a client secret.
30+
#[derive(Debug)]
31+
pub struct ClientSecretCredential {
32+
cache: TokenCache,
33+
client_id: String,
34+
endpoint: Url,
35+
options: TokenCredentialOptions,
36+
secret: Secret,
37+
}
38+
39+
impl ClientSecretCredential {
40+
pub fn new(
41+
tenant_id: &str,
42+
client_id: String,
43+
secret: Secret,
44+
options: Option<ClientSecretCredentialOptions>,
45+
) -> Result<Arc<Self>> {
46+
crate::validate_tenant_id(tenant_id)?;
47+
crate::validate_not_empty(&client_id, "no client ID specified")?;
48+
crate::validate_not_empty(secret.secret(), "no secret specified")?;
49+
50+
let options = options.unwrap_or_default();
51+
let endpoint = options
52+
.credential_options
53+
.authority_host()?
54+
.join(&format!("/{tenant_id}/oauth2/v2.0/token"))
55+
.with_context(ErrorKind::DataConversion, || {
56+
format!("tenant_id '{tenant_id}' could not be URL encoded")
57+
})?;
58+
59+
Ok(Arc::new(Self {
60+
cache: TokenCache::new(),
61+
client_id,
62+
endpoint,
63+
options: options.credential_options,
64+
secret,
65+
}))
66+
}
67+
68+
async fn get_token_impl(&self, scopes: &[&str]) -> Result<AccessToken> {
69+
let mut req = Request::new(self.endpoint.clone(), Method::Post);
70+
req.insert_header(
71+
headers::CONTENT_TYPE,
72+
content_type::APPLICATION_X_WWW_FORM_URLENCODED,
73+
);
74+
let body = form_urlencoded::Serializer::new(String::new())
75+
.append_pair("client_id", &self.client_id)
76+
.append_pair("client_secret", self.secret.secret())
77+
.append_pair("grant_type", "client_credentials")
78+
.append_pair("scope", &scopes.join(" "))
79+
.finish();
80+
req.set_body(body);
81+
82+
let res = self.options.http_client().execute_request(&req).await?;
83+
84+
match res.status() {
85+
StatusCode::Ok => {
86+
let token_response: EntraIdTokenResponse = deserialize(res).await?;
87+
Ok(AccessToken::new(
88+
token_response.access_token,
89+
OffsetDateTime::now_utc() + Duration::from_secs(token_response.expires_in),
90+
))
91+
}
92+
_ => {
93+
let error_response: EntraIdErrorResponse = deserialize(res).await?;
94+
let mut message = "ClientSecretCredential authentication failed".to_string();
95+
if !error_response.error_description.is_empty() {
96+
message = format!("{}: {}", message, error_response.error_description);
97+
}
98+
Err(Error::message(ErrorKind::Credential, message))
99+
}
100+
}
101+
}
102+
}
103+
104+
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
105+
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
106+
impl TokenCredential for ClientSecretCredential {
107+
async fn get_token(&self, scopes: &[&str]) -> Result<AccessToken> {
108+
if scopes.is_empty() {
109+
return Err(Error::message(ErrorKind::Credential, "no scopes specified"));
110+
}
111+
self.cache
112+
.get_token(scopes, self.get_token_impl(scopes))
113+
.await
114+
}
115+
}
116+
117+
async fn deserialize<T>(res: Response) -> Result<T>
118+
where
119+
T: serde::de::DeserializeOwned,
120+
{
121+
let t: T = res
122+
.into_json_body()
123+
.await
124+
.with_context(ErrorKind::Credential, || {
125+
"ClientSecretCredential authentication failed: invalid response"
126+
})?;
127+
Ok(t)
128+
}
129+
130+
#[cfg(test)]
131+
mod tests {
132+
use super::*;
133+
use crate::tests::*;
134+
use azure_core::{
135+
authority_hosts::AZURE_PUBLIC_CLOUD,
136+
http::{headers::Headers, Response, StatusCode},
137+
Bytes, Result,
138+
};
139+
use std::vec;
140+
141+
const FAKE_SECRET: &str = "fake secret";
142+
143+
fn is_valid_request(authority_host: &str, tenant_id: &str) -> impl Fn(&Request) -> Result<()> {
144+
let expected_url = format!("{}{}/oauth2/v2.0/token", authority_host, tenant_id);
145+
move |req: &Request| {
146+
assert_eq!(&Method::Post, req.method());
147+
assert_eq!(expected_url, req.url().to_string());
148+
assert_eq!(
149+
req.headers().get_str(&headers::CONTENT_TYPE).unwrap(),
150+
content_type::APPLICATION_X_WWW_FORM_URLENCODED.as_str()
151+
);
152+
Ok(())
153+
}
154+
}
155+
156+
#[tokio::test]
157+
async fn get_token_error() {
158+
let description = "AADSTS7000215: Invalid client secret.";
159+
let sts = MockSts::new(
160+
vec![Response::from_bytes(
161+
StatusCode::BadRequest,
162+
Headers::default(),
163+
Bytes::from(format!(
164+
r#"{{"error":"invalid_client","error_description":"{}","error_codes":[7000215],"timestamp":"2025-04-04 21:10:04Z","trace_id":"...","correlation_id":"...","error_uri":"https://login.microsoftonline.com/error?code=7000215"}}"#,
165+
description
166+
)),
167+
)],
168+
Some(Arc::new(is_valid_request(
169+
AZURE_PUBLIC_CLOUD.as_str(),
170+
FAKE_TENANT_ID,
171+
))),
172+
);
173+
let cred = ClientSecretCredential::new(
174+
FAKE_TENANT_ID,
175+
FAKE_CLIENT_ID.to_string(),
176+
FAKE_SECRET.into(),
177+
Some(ClientSecretCredentialOptions {
178+
credential_options: TokenCredentialOptions {
179+
http_client: Arc::new(sts),
180+
..Default::default()
181+
},
182+
}),
183+
)
184+
.expect("valid credential");
185+
186+
let err = cred
187+
.get_token(LIVE_TEST_SCOPES)
188+
.await
189+
.expect_err("expected error");
190+
assert!(matches!(err.kind(), ErrorKind::Credential));
191+
assert!(
192+
err.to_string().contains(description),
193+
"expected error description from the response, got '{}'",
194+
err
195+
);
196+
}
197+
198+
#[tokio::test]
199+
async fn get_token_success() {
200+
let expires_in = 3600;
201+
let sts = MockSts::new(
202+
vec![Response::from_bytes(
203+
StatusCode::Ok,
204+
Headers::default(),
205+
Bytes::from(format!(
206+
r#"{{"access_token":"{}","expires_in":{},"token_type":"Bearer"}}"#,
207+
FAKE_TOKEN, expires_in
208+
)),
209+
)],
210+
Some(Arc::new(is_valid_request(
211+
AZURE_PUBLIC_CLOUD.as_str(),
212+
FAKE_TENANT_ID,
213+
))),
214+
);
215+
let cred = ClientSecretCredential::new(
216+
FAKE_TENANT_ID,
217+
FAKE_CLIENT_ID.to_string(),
218+
FAKE_SECRET.into(),
219+
Some(ClientSecretCredentialOptions {
220+
credential_options: TokenCredentialOptions {
221+
http_client: Arc::new(sts),
222+
..Default::default()
223+
},
224+
}),
225+
)
226+
.expect("valid credential");
227+
let token = cred.get_token(LIVE_TEST_SCOPES).await.expect("token");
228+
229+
assert_eq!(FAKE_TOKEN, token.token.secret());
230+
231+
// allow a small margin when validating expiration time because it's computed as
232+
// the current time plus a number of seconds (expires_in) and the system clock
233+
// may have ticked into the next second since we assigned expires_in above
234+
let lifetime =
235+
token.expires_on.unix_timestamp() - OffsetDateTime::now_utc().unix_timestamp();
236+
assert!(
237+
(expires_in..expires_in + 1).contains(&lifetime),
238+
"token should expire in ~{} seconds but actually expires in {} seconds",
239+
expires_in,
240+
lifetime
241+
);
242+
243+
// sts will return an error if the credential sends another request
244+
let cached_token = cred
245+
.get_token(LIVE_TEST_SCOPES)
246+
.await
247+
.expect("cached token");
248+
assert_eq!(token.token.secret(), cached_token.token.secret());
249+
assert_eq!(token.expires_on, cached_token.expires_on);
250+
}
251+
252+
#[test]
253+
fn invalid_tenant_id() {
254+
ClientSecretCredential::new(
255+
"not a valid tenant",
256+
FAKE_CLIENT_ID.to_string(),
257+
FAKE_SECRET.into(),
258+
None,
259+
)
260+
.expect_err("invalid tenant ID");
261+
}
262+
263+
#[tokio::test]
264+
async fn no_scopes() {
265+
ClientSecretCredential::new(
266+
FAKE_TENANT_ID,
267+
FAKE_CLIENT_ID.to_string(),
268+
FAKE_SECRET.into(),
269+
None,
270+
)
271+
.expect("valid credential")
272+
.get_token(&[])
273+
.await
274+
.expect_err("no scopes specified");
275+
}
276+
}

sdk/identity/azure_identity/src/credentials/client_certificate_credentials.rs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
use crate::{credentials::cache::TokenCache, TokenCredentialOptions};
4+
use crate::{credentials::cache::TokenCache, EntraIdTokenResponse, TokenCredentialOptions};
55
use azure_core::{
66
base64,
77
credentials::{AccessToken, Secret, TokenCredential},
@@ -23,10 +23,8 @@ use openssl::{
2323
sign::Signer,
2424
x509::X509,
2525
};
26-
use serde::Deserialize;
2726
use std::{str, sync::Arc, time::Duration};
2827
use time::OffsetDateTime;
29-
use typespec_client_core::http::Model;
3028
use url::form_urlencoded;
3129

3230
/// Refresh time to use in seconds.
@@ -260,7 +258,7 @@ impl ClientCertificateCredential {
260258
return Err(http_response_from_body(rsp_status, &rsp_body).into_error());
261259
}
262260

263-
let response: AadTokenResponse = rsp.into_json_body().await?;
261+
let response: EntraIdTokenResponse = rsp.into_json_body().await?;
264262
Ok(AccessToken::new(
265263
response.access_token,
266264
OffsetDateTime::now_utc() + Duration::from_secs(response.expires_in),
@@ -331,15 +329,6 @@ impl ClientCertificateCredential {
331329
}
332330
}
333331

334-
#[derive(Model, Deserialize, Debug, Default)]
335-
#[serde(default)]
336-
struct AadTokenResponse {
337-
token_type: String,
338-
expires_in: u64,
339-
ext_expires_in: u64,
340-
access_token: String,
341-
}
342-
343332
fn get_encoded_cert(cert: &X509) -> azure_core::Result<String> {
344333
Ok(format!(
345334
"\"{}\"",

0 commit comments

Comments
 (0)