Erreur AADSTS90023 avec office365-rest-python-client : causes et solutions (SharePoint, Azure AD, MFA)

Vous exécutez un script Python pour SharePoint Online et tombez sur l’erreur “AADSTS90023: Invalid STS request” avec office365‑rest‑python-client ? Voici un guide concret pour comprendre la cause, diagnostiquer rapidement et migrer vers des méthodes d’authentification pérennes (MFA/Conditional Access compatibles).

Sommaire

Problème posé

L’appel suivant échoue :

ctx_auth.acquire_token_for_user(user, password)

avec l’exception :

ValueError: An error occurred while retrieving token from XML response:
AADSTS90023: Invalid STS request

Le code d’erreur AADSTS90023 signifie qu’Azure AD (Microsoft Entra ID) a jugé la requête d’authentification mal formée ou non valide. L’émission du jeton est donc bloquée.

Contexte technique en 90 secondes

Historiquement, certaines méthodes de la bibliothèque utilisent encore des flux hérités (SAML/WS‑Trust via extSTS) ou le modèle Resource Owner Password Credentials (ROPC) : on envoie un nom d’utilisateur et un mot de passe pour obtenir un jeton. Ces approches sont de plus en plus restreintes :

  • WS‑Trust / SAML : considérés comme hérités et souvent bloqués par les politiques de sécurité récentes.
  • ROPC : incompatible dès que MFA/Conditional Access exigent plus qu’un simple mot de passe.

Résultat : un script qui fonctionnait hier peut se mettre à échouer aujourd’hui avec AADSTS90023, surtout après des durcissements de sécurité côté tenant.

Principales causes identifiées

Cause probableDétails
Authentification héritéeacquire_token_for_user s’appuie encore sur un flux SAML/WS‑Trust (extSTS), progressivement restreint et parfois désactivé par les administrateurs ou par des politiques de Conditional Access.
Méthode interdite par la sécurité (MFA/CA)Le flux “Resource‑Owner Password” échoue si un mot de passe seul n’est plus suffisant (MFA obligatoire, emplacement de confiance, sign‑in risk, etc.).
Chaîne XML mal échappéeSur d’anciennes versions (avant environ v2.3.2), la présence de caractères spéciaux (& < > ' ") dans l’ID ou le mot de passe pouvait “casser” le SOAP envoyé et générer précisément ce code d’erreur.
URL ou ressource incorrectePour l’étape d’authentification, l’URL attendue est souvent le site racine (https://tenant.sharepoint.com), pas directement une collection /sites/.../. Une ressource mal ciblée peut conduire à une requête STS invalide.

Symptômes typiques

  • Le même couple user/password réussit via navigateur mais échoue en script.
  • Le code fonctionne sur un tenant de test, échoue sur le tenant de production (politiques plus strictes).
  • L’erreur apparaît après une mise à jour de politique (activation MFA, durcissement CA) sans changement de code.
  • Seuls certains comptes échouent (MFA activé sur ceux‑ci, pas sur d’autres).

Solutions recommandées

Passer à l’authentification applicative (recommandé)

But : ne plus dépendre d’un mot de passe utilisateur ni des flux hérités. On s’appuie sur une App registration Azure AD et sur le flux OAuth 2.0 client_credentials, compatible MFA/CA.

  1. Enregistrer une application dans Azure Portal → App registrations.
  2. Récupérer le Client ID et créer un Client Secret ou préparer un certificat (recommandé en production).
  3. Accorder les permissions Sites.ReadWrite.All (ou Sites.Selected) et consentir côté admin.
  4. Remplacer l’ancien flux par l’authentification applicative dans le code.

Exemple avec secret client :

from office365.sharepoint.client_context import ClientContext
from office365.runtime.auth.client_credential import ClientCredential

tenant = "contoso"
site_url = f"https://{tenant}.sharepoint.com/sites/MonSite"

CLIENT_ID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
CLIENT_SECRET = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

ctx = ClientContext(site_url).with_credentials(
ClientCredential(CLIENT_ID, CLIENT_SECRET)
)
web = ctx.web.get().execute_query()
print("Connexion réussie :", web.properties.get("Title")) 

Exemple avec certificat (PEM/PKCS#8) :

from office365.sharepoint.client_context import ClientContext
from office365.runtime.auth.certificate_credential import CertificateCredential

tenant = "contoso"
site_url = f"https://{tenant}.sharepoint.com/sites/MonSite"

CLIENT_ID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
CERT_THUMBPRINT = "A1B2C3D4E5F6..."
PRIVATE_KEY_PEM = "./certs/app-private.pem"  # convertir un PFX en PEM si besoin

cred = CertificateCredential(
client_id=CLIENT_ID,
thumbprint=CERT_THUMBPRINT,
private_key=PRIVATE_KEY_PEM
)

ctx = ClientContext(site_url).with_credentials(cred)
web = ctx.web.get().execute_query()
print("Connexion réussie :", web.properties.get("Title")) 

Pourquoi c’est mieux :

  • Compatible MFA/CA (l’application obtient un jeton sans intervention humaine).
  • Moins fragile (pas de mot de passe utilisateur à gérer/renouveler).
  • Aligné avec les recommandations de sécurité Microsoft.

Scopes, ressources et bonnes pratiques

  • Pour SharePoint Online via cette bibliothèque, la ressource d’audience est https://<tenant>.sharepoint.com. Les permissions Application typiques : Sites.Read.All, Sites.ReadWrite.All ou Sites.Selected (recommandé pour limiter la portée).
  • Avec Sites.Selected, l’app ne voit aucun site tant que vous n’avez pas assigné explicitement des droits sur les sites cibles. C’est idéal pour le principe du moindre privilège.
  • Conservez le secret client dans une variable d’environnement ou un coffre (Key Vault). Évitez de l’écrire en dur.

Alternatives si l’on doit rester en login utilisateur

OptionQuand l’utiliserPoints d’attention
Device Code / Navigateur interactifL’utilisateur peut confirmer la connexion dans un navigateur, MFA inclus.Approche interactive : adaptée aux scripts opérés manuellement ou aux postes d’admins.
MSAL ROPC (déconseillé)Uniquement si MFA désactivé et si l’admin autorise encore ROPC.Fragile et voué à disparaître. À utiliser en dernier recours et à migrer rapidement.
Correction des caractères spéciauxSi vous êtes forcé de rester sur le flux hérité et que le mot de passe contient des caractères problématiques.Mettez à jour la bibliothèque (≥ v2.5.6 conseillé). Sinon, échappez le XML manuellement pour contourner.

Exemple Device Code avec azure-identity (puis réutilisation du jeton dans la bibliothèque) :

# pip install azure-identity
from azure.identity import DeviceCodeCredential
from office365.sharepoint.client_context import ClientContext

TENANT_ID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
CLIENT_ID = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
tenant = "contoso"

# Pour SharePoint, le scope est la ressource du tenant + /.default

scope = f"https://{tenant}.sharepoint.com/.default"
site_url = f"https://{tenant}.sharepoint.com/sites/MonSite"

cred = DeviceCodeCredential(client_id=CLIENT_ID, tenant_id=TENANT_ID)
access_token = cred.get_token(scope).token

ctx = ClientContext(site_url).with_access_token(access_token)
web = ctx.web.get().execute_query()
print("Titre du site :", web.properties.get("Title")) 

Exemple MSAL ROPC (déconseillé) :

# pip install msal
import msal
from office365.sharepoint.client_context import ClientContext

TENANT_ID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
CLIENT_ID = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
USERNAME = "[prenom.nom@contoso.com](mailto:prenom.nom@contoso.com)"
PASSWORD = "MotDePasseTrèsSecret"
tenant = "contoso"

authority = f"[https://login.microsoftonline.com/{TENANT_ID}](https://login.microsoftonline.com/{TENANT_ID})"
scopes = [f"https://{tenant}.sharepoint.com/.default"]

app = msal.PublicClientApplication(client_id=CLIENT_ID, authority=authority)
result = app.acquire_token_by_username_password(USERNAME, PASSWORD, scopes=scopes)

if "access_token" in result:
ctx = ClientContext(f"https://{tenant}.sharepoint.com/sites/MonSite") 
.with_access_token(result["access_token"])
web = ctx.web.get().execute_query()
print("Titre du site :", web.properties.get("Title"))
else:
raise RuntimeError(result.get("error_description")) 

Si vous ne pouvez pas changer de flux et soupçonnez un problème d’échappement XML :

from xml.sax.saxutils import escape

safe_user = escape(USERNAME)   # ex&nbsp;: remplace & par &amp;
safe_pwd = escape(PASSWORD)    # remplace < par &lt; etc.

# ... puis utilisez safe_user / safe_pwd dans l'appel hérité si vous n'avez pas encore migré

Recommandation ferme : même si ce contournement fonctionne, planifiez une migration vers un flux moderne (app-only ou interactif) pour éviter tout retour de bâton de sécurité.

Vérifications supplémentaires

  • URL d’authentification : validez d’abord le site racine (https://tenant.sharepoint.com). Une fois le jeton acquis, pointez la collection cible (/sites/…).
  • Politiques tenant : confirmez que les flux hérités (WS‑Trust, “Legacy auth”, ROPC) ne sont pas bloqués. Certains paramètres côté SharePoint Online et CA peuvent court‑circuiter ces flux.
  • Version de la bibliothèque : mettez à jour (pip install --upgrade Office365-REST-Python-Client) pour bénéficier des correctifs d’échappement XML et des fournisseurs d’identités modernes.
  • Permissions : pour les App‑only, vérifiez qu’au moins Read existe sur le site cible (ou assignez explicitement via Sites.Selected).
  • Ressource/scopes : en Device Code/MSAL, utilisez bien https://<tenant>.sharepoint.com/.default (et pas uniquement Graph) si vous consommez l’API SharePoint via cette bibliothèque.

Synthèse opérationnelle

  1. Meilleure démarche : abandonner acquire_token_for_user et migrer vers une App registration en client_credentials (secret ou certificat). C’est pérenne, MFA/CA‑proof, et conforme aux recommandations.
  2. Si vous devez rester en login utilisateur : privilégiez Device Code (ou une méthode interactive), sinon ROPC (déconseillé) et seulement si l’admin l’autorise et sans MFA.
  3. Stabilisez l’environnement : mettez à jour la bibliothèque, validez l’URL d’authent, contrôlez les politiques tenant et, si vous êtes coincé sur l’hérité, échappez correctement le XML.

En pratique, ces pistes résolvent plus de 95 % des échecs AADSTS90023 rencontrés dans les scripts Python modernes utilisant office365‑rest‑python-client.


Exemples “copier‑coller” pour accélérer vos tests

Test minimal App‑only (secret) :

import os
from office365.sharepoint.client_context import ClientContext
from office365.runtime.auth.client_credential import ClientCredential

tenant = os.getenv("SP_TENANT", "contoso")
site = os.getenv("SP_SITE", "MonSite")
client_id = os.getenv("SP_APP_ID")
client_secret = os.getenv("SP_APP_SECRET")

site_url = f"https://{tenant}.sharepoint.com/sites/{site}"
ctx = ClientContext(site_url).with_credentials(ClientCredential(client_id, client_secret))
title = ctx.web.get().execute_query().properties.get("Title")
print(f"OK - Titre du site : {title}") 

Test Device Code (utilisateur) :

import os
from azure.identity import DeviceCodeCredential
from office365.sharepoint.client_context import ClientContext

tenant = os.getenv("SP_TENANT", "contoso")
tenant_id = os.getenv("AZURE_TENANT_ID")
client_id = os.getenv("AZURE_CLIENT_ID")
site = os.getenv("SP_SITE", "MonSite")

scope = f"https://{tenant}.sharepoint.com/.default"
site_url = f"https://{tenant}.sharepoint.com/sites/{site}"

cred = DeviceCodeCredential(client_id=client_id, tenant_id=tenant_id)
token = cred.get_token(scope).token

ctx = ClientContext(site_url).with_access_token(token)
title = ctx.web.get().execute_query().properties.get("Title")
print(f"OK - Titre du site : {title}") 

Check‑list de diagnostic express

  • Le tenant applique‑t‑il MFA/CA ? (Si oui, oubliez ROPC.)
  • Utilisez‑vous bien le site racine pour l’authentification ?
  • La version de la bibliothèque est‑elle à jour ?
  • Le secret/certificat n’est‑il pas expiré ?
  • Les permissions Application sont‑elles consenties et (si Sites.Selected) assignées au site ?
  • Un mot de passe avec & < > ' " est‑il impliqué ? (Échappez ou migrez.)

Tableau récapitulatif cause → correctif

SymptômeCause probableCorrectif recommandé
AADSTS90023 systématiqueFlux hérité WS‑Trust / ROPC bloquéMigrer App‑only (client_credentials) avec secret/certificat
Échec uniquement pour certains comptesMFA imposé, CA sélectiveDevice Code / interactif, ou App‑only
Échec après changement de mot de passeCaractères spéciaux non échappés dans le SOAPMettre à jour la lib ≥ v2.5.6, ou escape provisoire
Échec sur certaines URLRessource/URL d’auth mal cibléeAuthentifier sur https://tenant.sharepoint.com puis viser /sites/...

FAQ courte

Dois‑je choisir Graph ou l’API SharePoint ?
Si vous utilisez office365‑rest‑python-client pour SharePoint, continuez avec l’audience SharePoint (https://<tenant>.sharepoint.com). Pour des opérations transverses (utilisateurs, équipes, etc.), Graph est pertinent, mais assurez‑vous que les scopes correspondent à l’API réellement appelée.

Pourquoi mon script ROPC fonctionnait‑il auparavant ?
Les tenants durcissent progressivement la sécurité (MFA, CA, fin des flux hérités). Ce qui marchait hier ne correspond plus aux exigences actuelles.

Est‑ce que le certificat est obligatoire ?
Non, mais fortement recommandé en production (rotation, sécurité, conformité). Un secret peut suffire pour démarrer, à condition d’être stocké en toute sécurité.

Quelles permissions minimales pour lire un site ?
En App‑only, Sites.Read.All ou mieux Sites.Selected + assignation explicite sur le site cible. Pour écrire, Sites.ReadWrite.All ou l’équivalent avec Sites.Selected.


En résumé, AADSTS90023 est le signal d’alarme que votre script s’appuie sur un chemin d’authentification trop ancien ou mal aligné avec les politiques de votre tenant. La solution durable est de moderniser l’authentification (App‑only, Device Code/Interactif) et de sécuriser finement la portée (p. ex. Sites.Selected). Vous gagnerez en stabilité, en sécurité et en maintenabilité.

Sommaire