From dacaacb400c8db110388e4577ce74f32e1301680 Mon Sep 17 00:00:00 2001 From: Charles Lowell <10964656+chlowell@users.noreply.github.com> Date: Fri, 4 Apr 2025 23:18:00 +0000 Subject: [PATCH 1/5] Entra ID models --- .../client_certificate_credentials.rs | 15 ++------------- sdk/identity/azure_identity/src/lib.rs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/sdk/identity/azure_identity/src/credentials/client_certificate_credentials.rs b/sdk/identity/azure_identity/src/credentials/client_certificate_credentials.rs index 375e29140c8..fb35ee1b43e 100644 --- a/sdk/identity/azure_identity/src/credentials/client_certificate_credentials.rs +++ b/sdk/identity/azure_identity/src/credentials/client_certificate_credentials.rs @@ -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}, @@ -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. @@ -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), @@ -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 { Ok(format!( "\"{}\"", diff --git a/sdk/identity/azure_identity/src/lib.rs b/sdk/identity/azure_identity/src/lib.rs index 786ac0a877c..e83788e7a08 100644 --- a/sdk/identity/azure_identity/src/lib.rs +++ b/sdk/identity/azure_identity/src/lib.rs @@ -20,7 +20,24 @@ pub use azure_pipelines_credential::*; pub use chained_token_credential::*; pub use credentials::*; pub use managed_identity_credential::*; +use serde::Deserialize; use std::borrow::Cow; +use typespec_client_core::http::Model; + +#[derive(Debug, Default, Deserialize, Model)] +#[serde(default)] +struct EntraIdErrorResponse { + error_description: String, +} + +#[derive(Debug, Default, Deserialize, Model)] +#[serde(default)] +struct EntraIdTokenResponse { + token_type: String, + expires_in: u64, + ext_expires_in: u64, + access_token: String, +} fn validate_not_empty(value: &str, message: C) -> Result<()> where From 92199a6d0564385e80ee4e318e2a0eb50ff77983 Mon Sep 17 00:00:00 2001 From: Charles Lowell <10964656+chlowell@users.noreply.github.com> Date: Fri, 4 Apr 2025 23:18:13 +0000 Subject: [PATCH 2/5] Add ClientSecretCredential --- sdk/identity/azure_identity/CHANGELOG.md | 1 + .../src/client_secret_credential.rs | 276 ++++++++++++++++++ sdk/identity/azure_identity/src/lib.rs | 47 +++ 3 files changed, 324 insertions(+) create mode 100644 sdk/identity/azure_identity/src/client_secret_credential.rs diff --git a/sdk/identity/azure_identity/CHANGELOG.md b/sdk/identity/azure_identity/CHANGELOG.md index f9f3d02b5d9..fee305c8188 100644 --- a/sdk/identity/azure_identity/CHANGELOG.md +++ b/sdk/identity/azure_identity/CHANGELOG.md @@ -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 diff --git a/sdk/identity/azure_identity/src/client_secret_credential.rs b/sdk/identity/azure_identity/src/client_secret_credential.rs new file mode 100644 index 00000000000..7f56cfba0b5 --- /dev/null +++ b/sdk/identity/azure_identity/src/client_secret_credential.rs @@ -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: String, + client_id: String, + secret: String, + options: Option, + ) -> Result> { + crate::validate_tenant_id(&tenant_id)?; + crate::validate_not_empty(&client_id, "no client ID specified")?; + crate::validate_not_empty(&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: secret.into(), + })) + } + + async fn get_token_impl(&self, scopes: &[&str]) -> Result { + 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 { + 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(res: Response) -> Result +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.to_string(), + FAKE_CLIENT_ID.to_string(), + FAKE_SECRET.to_string(), + 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.to_string(), + FAKE_CLIENT_ID.to_string(), + FAKE_SECRET.to_string(), + 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".to_string(), + FAKE_CLIENT_ID.to_string(), + FAKE_SECRET.to_string(), + None, + ) + .expect_err("invalid tenant ID"); + } + + #[tokio::test] + async fn no_scopes() { + ClientSecretCredential::new( + FAKE_TENANT_ID.to_string(), + FAKE_CLIENT_ID.to_string(), + FAKE_SECRET.to_string(), + None, + ) + .expect("valid credential") + .get_token(&[]) + .await + .expect_err("no scopes specified"); + } +} diff --git a/sdk/identity/azure_identity/src/lib.rs b/sdk/identity/azure_identity/src/lib.rs index e83788e7a08..b2ff41bc4e5 100644 --- a/sdk/identity/azure_identity/src/lib.rs +++ b/sdk/identity/azure_identity/src/lib.rs @@ -7,6 +7,7 @@ mod authorization_code_flow; mod azure_pipelines_credential; mod chained_token_credential; +mod client_secret_credential; mod credentials; mod env; mod federated_credentials_flow; @@ -18,6 +19,7 @@ mod timeout; use azure_core::{error::ErrorKind, Error, Result}; pub use azure_pipelines_credential::*; pub use chained_token_credential::*; +pub use client_secret_credential::*; pub use credentials::*; pub use managed_identity_credential::*; use serde::Deserialize; @@ -130,6 +132,51 @@ fn test_validate_tenant_id() { #[cfg(test)] mod tests { + use azure_core::{ + error::ErrorKind, + http::{Request, Response}, + Error, Result, + }; + use std::sync::{Arc, Mutex}; + + pub const FAKE_CLIENT_ID: &str = "fake-client"; + pub const FAKE_TENANT_ID: &str = "fake-tenant"; + pub const FAKE_TOKEN: &str = "***"; pub const LIVE_TEST_RESOURCE: &str = "https://management.azure.com"; pub const LIVE_TEST_SCOPES: &[&str] = &["https://management.azure.com/.default"]; + + pub type RequestCallback = Arc Result<()> + Send + Sync>; + + pub struct MockSts { + responses: Mutex>, + on_request: Option, + } + + impl std::fmt::Debug for MockSts { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MockSts").finish() + } + } + + impl MockSts { + pub fn new(responses: Vec, on_request: Option) -> Self { + Self { + responses: Mutex::new(responses), + on_request, + } + } + } + + #[async_trait::async_trait] + impl azure_core::http::HttpClient for MockSts { + async fn execute_request(&self, request: &Request) -> Result { + self.on_request.as_ref().map_or(Ok(()), |f| f(request))?; + let mut responses = self.responses.lock().unwrap(); + if responses.is_empty() { + Err(Error::message(ErrorKind::Other, "No more mock responses")) + } else { + Ok(responses.remove(0)) // Use remove(0) to return responses in the correct order + } + } + } } From de477b0429c045b9688b5b8a0ef6214ec0b10420 Mon Sep 17 00:00:00 2001 From: Charles Lowell <10964656+chlowell@users.noreply.github.com> Date: Mon, 7 Apr 2025 19:35:35 +0000 Subject: [PATCH 3/5] cspell --- sdk/identity/.dict.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/identity/.dict.txt b/sdk/identity/.dict.txt index a82b9d8a0be..d81c0b68859 100644 --- a/sdk/identity/.dict.txt +++ b/sdk/identity/.dict.txt @@ -1,3 +1,4 @@ +AADSTS adfs appservice azureauth From 5f736ef7e15334b0f9c2ae3f263ef34c6669a02c Mon Sep 17 00:00:00 2001 From: Charles Lowell <10964656+chlowell@users.noreply.github.com> Date: Tue, 8 Apr 2025 17:16:19 +0000 Subject: [PATCH 4/5] update parameter types --- .../src/client_secret_credential.rs | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/sdk/identity/azure_identity/src/client_secret_credential.rs b/sdk/identity/azure_identity/src/client_secret_credential.rs index 7f56cfba0b5..31e0286d4bc 100644 --- a/sdk/identity/azure_identity/src/client_secret_credential.rs +++ b/sdk/identity/azure_identity/src/client_secret_credential.rs @@ -38,14 +38,14 @@ pub struct ClientSecretCredential { impl ClientSecretCredential { pub fn new( - tenant_id: String, + tenant_id: &str, client_id: String, - secret: String, + secret: Secret, options: Option, ) -> Result> { - crate::validate_tenant_id(&tenant_id)?; + crate::validate_tenant_id(tenant_id)?; crate::validate_not_empty(&client_id, "no client ID specified")?; - crate::validate_not_empty(&secret, "no secret specified")?; + crate::validate_not_empty(secret.secret(), "no secret specified")?; let options = options.unwrap_or_default(); let endpoint = options @@ -61,7 +61,7 @@ impl ClientSecretCredential { client_id, endpoint, options: options.credential_options, - secret: secret.into(), + secret, })) } @@ -171,9 +171,9 @@ mod tests { ))), ); let cred = ClientSecretCredential::new( - FAKE_TENANT_ID.to_string(), + FAKE_TENANT_ID, FAKE_CLIENT_ID.to_string(), - FAKE_SECRET.to_string(), + FAKE_SECRET.into(), Some(ClientSecretCredentialOptions { credential_options: TokenCredentialOptions { http_client: Arc::new(sts), @@ -213,9 +213,9 @@ mod tests { ))), ); let cred = ClientSecretCredential::new( - FAKE_TENANT_ID.to_string(), + FAKE_TENANT_ID, FAKE_CLIENT_ID.to_string(), - FAKE_SECRET.to_string(), + FAKE_SECRET.into(), Some(ClientSecretCredentialOptions { credential_options: TokenCredentialOptions { http_client: Arc::new(sts), @@ -252,9 +252,9 @@ mod tests { #[test] fn invalid_tenant_id() { ClientSecretCredential::new( - "not a valid tenant".to_string(), + "not a valid tenant", FAKE_CLIENT_ID.to_string(), - FAKE_SECRET.to_string(), + FAKE_SECRET.into(), None, ) .expect_err("invalid tenant ID"); @@ -263,9 +263,9 @@ mod tests { #[tokio::test] async fn no_scopes() { ClientSecretCredential::new( - FAKE_TENANT_ID.to_string(), + FAKE_TENANT_ID, FAKE_CLIENT_ID.to_string(), - FAKE_SECRET.to_string(), + FAKE_SECRET.into(), None, ) .expect("valid credential") From cf39ac9fadd1fcdd4f7d4abb67c1b00c131061f8 Mon Sep 17 00:00:00 2001 From: Charles Lowell <10964656+chlowell@users.noreply.github.com> Date: Tue, 8 Apr 2025 18:14:56 +0000 Subject: [PATCH 5/5] merge main's lib.rs change --- sdk/identity/azure_identity/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/identity/azure_identity/src/lib.rs b/sdk/identity/azure_identity/src/lib.rs index b2ff41bc4e5..84a891c6dc8 100644 --- a/sdk/identity/azure_identity/src/lib.rs +++ b/sdk/identity/azure_identity/src/lib.rs @@ -18,7 +18,6 @@ mod timeout; use azure_core::{error::ErrorKind, Error, Result}; pub use azure_pipelines_credential::*; -pub use chained_token_credential::*; pub use client_secret_credential::*; pub use credentials::*; pub use managed_identity_credential::*;