From e8f3f144f401d325d6266da2e6020896ba2cb98f Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:39:17 -0700 Subject: [PATCH] entra-live-check: support a confidential-client app registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `entra-live-check` has been RED on main since 2026-08-04 — nine consecutive runs. It is not a flake and not a code regression; the tenant's app registration stopped being treated as a public client, so the password grant is refused: thread 'main' panicked at auth-oidc/examples/entra_live_check.rs:60:5: Entra rejected the real password grant (401 Unauthorized): {"error":"invalid_client", "error_codes":[7000218],"error_description":"AADSTS7000218: The request body must contain the following parameter: 'client_assertion' or 'client_secret'. ..."} A public client sends no secret; a confidential one must. Which of the two an app registration is comes down to its "Allow public client flows" setting, and Entra reports the mismatch as AADSTS7000218 without ever naming that setting — so the failure gave no route to a fix. The example now sends `client_secret` when an `ENTRA_CLIENT_SECRET` is provisioned and omits it when it is not, covering both shapes, and the assertion message names AADSTS7000218 and both remedies. THIS ALONE DOES NOT TURN THE CHECK GREEN. It only makes the fix possible without another code change. One of these must still happen on the tenant side: - re-enable "Allow public client flows" on the app registration, or - provision an ENTRA_CLIENT_SECRET repo secret. Until then the check stays red, and it should stay red — it is reporting a real loss of live-IdP coverage, not noise. --- .github/workflows/entra-live-check.yml | 4 ++++ auth-oidc/examples/entra_live_check.rs | 31 +++++++++++++++++++------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/.github/workflows/entra-live-check.yml b/.github/workflows/entra-live-check.yml index 9baf011..de7d4f4 100644 --- a/.github/workflows/entra-live-check.yml +++ b/.github/workflows/entra-live-check.yml @@ -49,6 +49,10 @@ jobs: env: ENTRA_TENANT_ID: ${{ secrets.ENTRA_TENANT_ID }} ENTRA_CLIENT_ID: ${{ secrets.ENTRA_CLIENT_ID }} + # Optional: only set when the app registration is a CONFIDENTIAL client (i.e. "Allow + # public client flows" is off). Absent, the check sends no secret, which is what a public + # client requires. + ENTRA_CLIENT_SECRET: ${{ secrets.ENTRA_CLIENT_SECRET }} ENTRA_TEST_USERNAME: ${{ secrets.ENTRA_TEST_USERNAME }} ENTRA_TEST_PASSWORD: ${{ secrets.ENTRA_TEST_PASSWORD }} run: cargo run --example entra_live_check diff --git a/auth-oidc/examples/entra_live_check.rs b/auth-oidc/examples/entra_live_check.rs index d2e15a9..9adc91a 100644 --- a/auth-oidc/examples/entra_live_check.rs +++ b/auth-oidc/examples/entra_live_check.rs @@ -39,15 +39,26 @@ fn main() { let token_endpoint = format!("https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"); let http = reqwest::blocking::Client::new(); + // A PUBLIC client sends no secret; a CONFIDENTIAL one must. Which of the two an app + // registration is depends on its "Allow public client flows" setting, and flipping that setting + // in the portal changes what this request has to look like — Entra rejects the mismatch with + // AADSTS7000218 rather than anything that names the setting. Sending the secret only when one + // is provisioned lets the same code cover both, so the check does not have to be edited every + // time the tenant is reconfigured. + let client_secret = env_or_skip("ENTRA_CLIENT_SECRET"); + let mut form = vec![ + ("grant_type", "password"), + ("client_id", client_id.as_str()), + ("username", username.as_str()), + ("password", password.as_str()), + ("scope", "openid profile email"), + ]; + if let Some(secret) = client_secret.as_deref() { + form.push(("client_secret", secret)); + } let resp = http .post(&token_endpoint) - .form(&[ - ("grant_type", "password"), - ("client_id", &client_id), - ("username", &username), - ("password", &password), - ("scope", "openid profile email"), - ]) + .form(&form) .send() .expect("real POST to Entra's real token endpoint"); @@ -61,7 +72,11 @@ fn main() { status.is_success(), "Entra rejected the real password grant ({status}): {body}. If this is \ AADSTS50055 (password expired), sign in interactively once as the test user to set a \ - permanent password, then re-provision the ENTRA_TEST_PASSWORD secret." + permanent password, then re-provision the ENTRA_TEST_PASSWORD secret. If it is \ + AADSTS7000218 ('the request body must contain client_assertion or client_secret'), the \ + app registration is being treated as a CONFIDENTIAL client: either re-enable 'Allow \ + public client flows' on it, or provision an ENTRA_CLIENT_SECRET repo secret, which this \ + check will then send." ); let id_token = body["id_token"] .as_str()