Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sdk/identity/.dict.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
AADSTS
adfs
appservice
azureauth
Expand Down
1 change: 1 addition & 0 deletions sdk/identity/azure_identity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- `AzureCliCredentialOptions` (new) accepts a `azure_core::process::Executor` to run the Azure CLI asynchronously.
The `tokio` feature is disabled by default so `std::process::Command` is used; otherwise, if enabled, `tokio::process::Command` is used.
Callers can also implement the trait themselves to use a different asynchronous runtime.
- Restored `ClientSecretCredential`

### Breaking Changes

Expand Down
276 changes: 276 additions & 0 deletions sdk/identity/azure_identity/src/client_secret_credential.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

use crate::{credentials::cache::TokenCache, EntraIdTokenResponse};
use crate::{EntraIdErrorResponse, TokenCredentialOptions};
use azure_core::http::{Response, StatusCode};
use azure_core::Result;
use azure_core::{
credentials::{AccessToken, Secret, TokenCredential},
error::{ErrorKind, ResultExt},
http::{
headers::{self, content_type},
Method, Request, Url,
},
Error,
};
use std::time::Duration;
use std::{str, sync::Arc};
use time::OffsetDateTime;
use url::form_urlencoded;

/// Options for constructing a new [`ClientSecretCredential`].
#[derive(Debug, Default)]
pub struct ClientSecretCredentialOptions {
/// Options for constructing credentials.
pub credential_options: TokenCredentialOptions,
}

/// Authenticates an application with a client secret.
#[derive(Debug)]
pub struct ClientSecretCredential {
cache: TokenCache,
client_id: String,
endpoint: Url,
options: TokenCredentialOptions,
secret: Secret,
}

impl ClientSecretCredential {
pub fn new(
tenant_id: &str,
client_id: String,
secret: Secret,
options: Option<ClientSecretCredentialOptions>,
) -> Result<Arc<Self>> {
crate::validate_tenant_id(tenant_id)?;
crate::validate_not_empty(&client_id, "no client ID specified")?;
crate::validate_not_empty(secret.secret(), "no secret specified")?;

let options = options.unwrap_or_default();
let endpoint = options
.credential_options
.authority_host()?
.join(&format!("/{tenant_id}/oauth2/v2.0/token"))
.with_context(ErrorKind::DataConversion, || {
format!("tenant_id '{tenant_id}' could not be URL encoded")
})?;

Ok(Arc::new(Self {
cache: TokenCache::new(),
client_id,
endpoint,
options: options.credential_options,
secret,
}))
}

async fn get_token_impl(&self, scopes: &[&str]) -> Result<AccessToken> {
let mut req = Request::new(self.endpoint.clone(), Method::Post);
req.insert_header(
headers::CONTENT_TYPE,
content_type::APPLICATION_X_WWW_FORM_URLENCODED,
);
let body = form_urlencoded::Serializer::new(String::new())
.append_pair("client_id", &self.client_id)
.append_pair("client_secret", self.secret.secret())
.append_pair("grant_type", "client_credentials")
.append_pair("scope", &scopes.join(" "))
.finish();
req.set_body(body);

let res = self.options.http_client().execute_request(&req).await?;

match res.status() {
StatusCode::Ok => {
let token_response: EntraIdTokenResponse = deserialize(res).await?;
Ok(AccessToken::new(
token_response.access_token,
OffsetDateTime::now_utc() + Duration::from_secs(token_response.expires_in),
))
}
_ => {
let error_response: EntraIdErrorResponse = deserialize(res).await?;
let mut message = "ClientSecretCredential authentication failed".to_string();
if !error_response.error_description.is_empty() {
message = format!("{}: {}", message, error_response.error_description);
}
Err(Error::message(ErrorKind::Credential, message))
}
}
}
}

#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl TokenCredential for ClientSecretCredential {
async fn get_token(&self, scopes: &[&str]) -> Result<AccessToken> {
if scopes.is_empty() {
return Err(Error::message(ErrorKind::Credential, "no scopes specified"));
}
self.cache
.get_token(scopes, self.get_token_impl(scopes))
.await
}
}

async fn deserialize<T>(res: Response) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
let t: T = res
.into_json_body()
.await
.with_context(ErrorKind::Credential, || {
"ClientSecretCredential authentication failed: invalid response"
})?;
Ok(t)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::tests::*;
use azure_core::{
authority_hosts::AZURE_PUBLIC_CLOUD,
http::{headers::Headers, Response, StatusCode},
Bytes, Result,
};
use std::vec;

const FAKE_SECRET: &str = "fake secret";

fn is_valid_request(authority_host: &str, tenant_id: &str) -> impl Fn(&Request) -> Result<()> {
let expected_url = format!("{}{}/oauth2/v2.0/token", authority_host, tenant_id);
move |req: &Request| {
assert_eq!(&Method::Post, req.method());
assert_eq!(expected_url, req.url().to_string());
assert_eq!(
req.headers().get_str(&headers::CONTENT_TYPE).unwrap(),
content_type::APPLICATION_X_WWW_FORM_URLENCODED.as_str()
);
Ok(())
}
}

#[tokio::test]
async fn get_token_error() {
let description = "AADSTS7000215: Invalid client secret.";
let sts = MockSts::new(
vec![Response::from_bytes(
StatusCode::BadRequest,
Headers::default(),
Bytes::from(format!(
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"}}"#,
description
)),
)],
Some(Arc::new(is_valid_request(
AZURE_PUBLIC_CLOUD.as_str(),
FAKE_TENANT_ID,
))),
);
let cred = ClientSecretCredential::new(
FAKE_TENANT_ID,
FAKE_CLIENT_ID.to_string(),
FAKE_SECRET.into(),
Some(ClientSecretCredentialOptions {
credential_options: TokenCredentialOptions {
http_client: Arc::new(sts),
..Default::default()
},
}),
)
.expect("valid credential");

let err = cred
.get_token(LIVE_TEST_SCOPES)
.await
.expect_err("expected error");
assert!(matches!(err.kind(), ErrorKind::Credential));
assert!(
err.to_string().contains(description),
"expected error description from the response, got '{}'",
err
);
}

#[tokio::test]
async fn get_token_success() {
let expires_in = 3600;
let sts = MockSts::new(
vec![Response::from_bytes(
StatusCode::Ok,
Headers::default(),
Bytes::from(format!(
r#"{{"access_token":"{}","expires_in":{},"token_type":"Bearer"}}"#,
FAKE_TOKEN, expires_in
)),
)],
Some(Arc::new(is_valid_request(
AZURE_PUBLIC_CLOUD.as_str(),
FAKE_TENANT_ID,
))),
);
let cred = ClientSecretCredential::new(
FAKE_TENANT_ID,
FAKE_CLIENT_ID.to_string(),
FAKE_SECRET.into(),
Some(ClientSecretCredentialOptions {
credential_options: TokenCredentialOptions {
http_client: Arc::new(sts),
..Default::default()
},
}),
)
.expect("valid credential");
let token = cred.get_token(LIVE_TEST_SCOPES).await.expect("token");

assert_eq!(FAKE_TOKEN, token.token.secret());

// allow a small margin when validating expiration time because it's computed as
// the current time plus a number of seconds (expires_in) and the system clock
// may have ticked into the next second since we assigned expires_in above
let lifetime =
token.expires_on.unix_timestamp() - OffsetDateTime::now_utc().unix_timestamp();
assert!(
(expires_in..expires_in + 1).contains(&lifetime),
"token should expire in ~{} seconds but actually expires in {} seconds",
expires_in,
lifetime
);

// sts will return an error if the credential sends another request
let cached_token = cred
.get_token(LIVE_TEST_SCOPES)
.await
.expect("cached token");
assert_eq!(token.token.secret(), cached_token.token.secret());
assert_eq!(token.expires_on, cached_token.expires_on);
}

#[test]
fn invalid_tenant_id() {
ClientSecretCredential::new(
"not a valid tenant",
FAKE_CLIENT_ID.to_string(),
FAKE_SECRET.into(),
None,
)
.expect_err("invalid tenant ID");
}

#[tokio::test]
async fn no_scopes() {
ClientSecretCredential::new(
FAKE_TENANT_ID,
FAKE_CLIENT_ID.to_string(),
FAKE_SECRET.into(),
None,
)
.expect("valid credential")
.get_token(&[])
.await
.expect_err("no scopes specified");
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

use crate::{credentials::cache::TokenCache, TokenCredentialOptions};
use crate::{credentials::cache::TokenCache, EntraIdTokenResponse, TokenCredentialOptions};
use azure_core::{
base64,
credentials::{AccessToken, Secret, TokenCredential},
Expand All @@ -23,10 +23,8 @@ use openssl::{
sign::Signer,
x509::X509,
};
use serde::Deserialize;
use std::{str, sync::Arc, time::Duration};
use time::OffsetDateTime;
use typespec_client_core::http::Model;
use url::form_urlencoded;

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

let response: AadTokenResponse = rsp.into_json_body().await?;
let response: EntraIdTokenResponse = rsp.into_json_body().await?;
Ok(AccessToken::new(
response.access_token,
OffsetDateTime::now_utc() + Duration::from_secs(response.expires_in),
Expand Down Expand Up @@ -331,15 +329,6 @@ impl ClientCertificateCredential {
}
}

#[derive(Model, Deserialize, Debug, Default)]
#[serde(default)]
struct AadTokenResponse {
token_type: String,
expires_in: u64,
ext_expires_in: u64,
access_token: String,
}

fn get_encoded_cert(cert: &X509) -> azure_core::Result<String> {
Ok(format!(
"\"{}\"",
Expand Down
Loading