Microsoft-login scripten met pure Python requests

Hoe je een Microsoft-authenticatie (Azure AD en ADFS) volledig namaakt met alleen de requests-library. Van de SAML-redirectketen en het uitlezen van $Config, tot GetCredentialType, de sFT/sCtx flow tokens en het doorlopen van auto-submit SAML-forms.

Delen

Bijna elke enterprise-portal die je tegenkomt zet zijn login uit handen aan Microsoft. Je klikt op "inloggen", en voor je het weet stuitert je browser via een handvol redirects langs login.microsoftonline.com en/of een ADFS-server, om uiteindelijk weer terug te landen op de originele applicatie. Dat is prettig voor gebruikers, maar lastig als je iets wil automatiseren: er is geen nette API-token, alleen een reeks HTML-formulieren en cookies.

In deze post laat ik zien hoe je die hele dans namaakt met alleen de requests-library. Geen headless browser, geen Selenium, geen officiele SDK. Puur HTTP: forms scrapen, tokens uitlezen en formulieren terugposten tot je aan de andere kant weer bij de applicatie uitkomt, ingelogd en wel.

Dit is bedoeld voor je eigen accounts en systemen waar je toestemming voor hebt. Het is een oefening in het begrijpen van een auth-protocol, niet in het omzeilen van andermans beveiliging.

Het grote plaatje

Een Microsoft-login is geen enkel request maar een keten. Grofweg:

  1. Je vraagt een beschermde pagina op. De applicatie stuurt je met een SAML-request door naar Microsoft.
  2. Microsoft toont een loginpagina. Die pagina bevat geen simpel formulier, maar een groot stuk JavaScript met een $Config-object vol tokens.
  3. Je vertelt Microsoft wie je bent via GetCredentialType. Microsoft beslist dan: handel ik dit account zelf af (managed), of stuur ik je door naar een eigen bedrijfs-IdP zoals ADFS (federated)?
  4. Je stuurt je wachtwoord in, krijgt een SAMLResponse terug, en die post je terug naar de applicatie.

De kunst zit hem in stap 2 en 3: de tokens die Microsoft in de pagina verstopt, en de vertakking tussen managed en federated.

Alles in een sessie

De hele flow leunt op cookies. Elke redirect zet er wel een paar, en je hebt ze allemaal nodig aan het eind. Dus begin je met een enkele requests.Session die je overal doorheen sleept:

import requests

def build_session() -> requests.Session:
    session = requests.Session()
    session.headers.update({
        # Doe je voor als een echte browser: sommige IdP-endpoints
        # weigeren requests zonder geloofwaardige User-Agent.
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) "
            "Chrome/125.0 Safari/537.36"
        ),
        "Accept-Language": "nl-NL,nl;q=0.9,en;q=0.8",
    })
    return session

Een sessie regeert ze allen. Zolang je hem hergebruikt, hoef je je verder geen zorgen te maken over het handmatig meesturen van cookies.

Stap 1: de SAML-redirectketen

Je eerste GET op een beschermde URL levert vrijwel nooit direct content op. Wat je krijgt is een 302 (of een auto-submit form) richting Microsoft, met een SAMLRequest-parameter in de URL. requests volgt redirects standaard, dus na een enkele GET zit je al op de Microsoft-loginpagina:

resp = session.get(protected_url)
# resp.url is nu iets als:
# https://login.microsoftonline.com/<tenant>/saml2?SAMLRequest=...

Soms is de doorverwijzing geen echte HTTP-redirect maar een HTML-formulier dat zichzelf via JavaScript verstuurt. Die auto-submit forms komen later nog terug, dus het loont om er meteen een generieke helper voor te schrijven die elk formulier op een pagina uitleest:

import re
from html.parser import HTMLParser
from urllib.parse import urljoin


class FormParser(HTMLParser):
    """Pakt het eerste <form> op een pagina met al zijn velden."""

    def __init__(self) -> None:
        super().__init__()
        self.action: str | None = None
        self.method: str = "post"
        self.fields: dict[str, str] = {}

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str]]) -> None:
        a = dict(attrs)
        if tag == "form" and self.action is None:
            self.action = a.get("action")
            self.method = (a.get("method") or "post").lower()
        elif tag == "input" and a.get("name"):
            # Bewaar ook hidden velden: SAMLRequest, AuthMethod, enz.
            self.fields[a["name"]] = a.get("value", "")


def parse_form(html: str, base_url: str) -> tuple[str, dict[str, str]]:
    p = FormParser()
    p.feed(html)
    action = urljoin(base_url, p.action or "")
    return action, p.fields

Die hidden velden zijn goud. Als je ze niet netjes teruggeeft, klapt de flow verderop om.

Stap 2: het $Config-object uitlezen

De Microsoft-loginpagina rendert het login-formulier client-side. De server-state zit in een groot JavaScript-object genaamd $Config. Daar staan twee cruciale waarden in:

  • sFT (flow token): een anti-forgery token dat je bij elke volgende stap moet meesturen.
  • sCtx (context): een opaque blob die de loginsessie identificeert.

Je kunt geen JS uitvoeren met requests, dus je vist het $Config-blok met een regex uit de HTML en parset het als JSON:

import json
import re


def extract_config(html: str) -> dict:
    """Trekt het $Config JSON-object uit een Microsoft-loginpagina."""
    match = re.search(r"\$Config\s*=\s*(\{.*?\});", html, re.DOTALL)
    if not match:
        raise RuntimeError("Geen $Config gevonden, layout gewijzigd?")
    return json.loads(match.group(1))


config = extract_config(resp.text)
flow_token = config["sFT"]
ctx = config["sCtx"]
canary = config.get("canary")          # extra CSRF-achtig token
post_url = config["urlPost"]           # waar het wachtwoord straks heen gaat
cred_url = config["urlGetCredentialType"]

De exacte veldnamen (sFT, sCtx, urlPost, canary) zijn al jaren stabiel in de Microsoft-loginstack. Handig, want ze vormen de ruggengraat van al het volgende.

Stap 3: GetCredentialType, managed of federated?

Voordat je een wachtwoord instuurt, wil Microsoft weten om wie het gaat. Je POST je gebruikersnaam als JSON naar het GetCredentialType-endpoint. Het antwoord vertelt je welke kant je op moet:

def get_credential_type(session, cred_url, username, flow_token):
    payload = {
        "username": username,
        "isOtherIdpSupported": True,
        "checkPhones": False,
        "isRemoteNGCSupported": True,
        "isCookieBannerShown": False,
        "isFidoSupported": True,
        "originalRequest": ctx,
        "flowToken": flow_token,
    }
    r = session.post(cred_url, json=payload, headers={
        "Content-Type": "application/json; charset=UTF-8",
        "Origin": "https://login.microsoftonline.com",
        "Referer": resp.url,
    })
    return r.json()


info = get_credential_type(session, cred_url, username, flow_token)

Het interessante deel van het antwoord is Credentials:

creds = info.get("Credentials", {})

if creds.get("FederationRedirectUrl"):
    # FEDERATED: dit account hoort bij een eigen bedrijfs-IdP (ADFS).
    # Microsoft handelt het wachtwoord niet zelf af, maar stuurt door.
    federation_url = creds["FederationRedirectUrl"]
    saml_response = do_adfs_flow(session, federation_url, username, password)
else:
    # MANAGED: Microsoft valideert het wachtwoord zelf (Azure AD).
    saml_response = do_managed_flow(
        session, post_url, username, password, flow_token, ctx, canary
    )

Deze vertakking is de kern van het hele verhaal. Veel organisaties federeren naar hun eigen ADFS; anderen laten Azure AD het wachtwoord zelf checken. Je script moet beide aankunnen.

Stap 4a: de managed flow (Azure AD)

Bij een managed account post je gewoon je wachtwoord terug naar de urlPost uit $Config, samen met de flow token en context:

def do_managed_flow(session, post_url, username, password, flow_token, ctx, canary):
    payload = {
        "login": username,
        "loginfmt": username,
        "passwd": password,
        "ctx": ctx,
        "flowToken": flow_token,
        "canary": canary,
        "LoginOptions": 3,          # "blijf ingelogd" gedrag
        "type": 11,
    }
    r = session.post(post_url, data=payload, headers={
        "Origin": "https://login.microsoftonline.com",
        "Referer": resp.url,
    })

    # MFA? Dan komt er een KMSI- of proof-up-pagina i.p.v. de SAMLResponse.
    if any(m in r.text for m in ("BeginAuth", "ProcessAuth", "SAS/ProcessAuth")):
        raise MfaChallengeError("Account vereist MFA, hier stopt requests.")

    return finish_saml_walk(session, r)

Hier zit meteen de grootste beperking van de pure-requests-aanpak: MFA kun je niet scripten. Zodra er een telefoon-prompt of authenticator-push in de flow zit, houdt het op. De praktische workaround is om eenmalig via een echte browser (bijvoorbeeld Playwright) in te loggen, de cookies te bewaren, en die daarna in je requests.Session te herbruiken.

Stap 4b: de federated flow (ADFS)

Stuurt Microsoft je door naar een eigen IdP, dan land je op een ADFS-loginpagina. Die werkt met een gewoon HTML-formulier, geen $Config. Je detecteert de velden met regex-patronen (want ADFS gebruikt vaak eigen namen als UserName en Password) en post ze terug:

def do_adfs_flow(session, federation_url, username, password):
    page = session.get(federation_url)
    action, fields = parse_form(page.text, page.url)

    # ADFS noemt zijn velden vaak net anders: match op patroon.
    for name in fields:
        if re.search(r"user|email|login", name, re.I):
            fields[name] = username
        elif re.search(r"pass", name, re.I):
            fields[name] = password

    # AuthMethod en andere hidden velden blijven staan zoals ze waren.
    r = session.post(action, data=fields, headers={"Referer": page.url})
    return finish_saml_walk(session, r)

Merk op dat we de hidden velden (zoals AuthMethod) ongemoeid laten. Alleen de gebruikersnaam- en wachtwoordvelden vullen we in; de rest post je terug precies zoals de server ze aanleverde.

Stap 5: de SAML form walk naar huis

Of je nu via managed of federated bent gegaan, het eindresultaat is hetzelfde: een pagina met een verborgen formulier dat een SAMLResponse bevat, en dat zichzelf normaal via JavaScript zou versturen. Omdat wij geen JS draaien, moeten we die auto-submit forms met de hand doorlopen, net zo lang tot we weer op de applicatie uitkomen:

def finish_saml_walk(session, resp, max_hops: int = 5):
    """Volgt auto-submit SAML-forms tot we terug zijn bij de app."""
    for _ in range(max_hops):
        action, fields = parse_form(resp.text, resp.url)

        # Geen formulier meer met SAML-velden? Dan zijn we thuis.
        has_saml = any(k in fields for k in ("SAMLResponse", "SAMLRequest"))
        if not action or not has_saml:
            return resp

        resp = session.post(action, data=fields, headers={"Referer": resp.url})

    raise RuntimeError("SAML walk liep vast, te veel hops.")

Elke hop post het SAMLResponse-formulier naar de volgende partij, die weer een cookie zet en soms nog een formulier teruggeeft. Na een paar rondjes val je uit de lus zodra er geen SAML-veld meer in de pagina zit: op dat moment is je sessie volledig ingelogd, en kun je met dezelfde session de beschermde endpoints van de applicatie aanroepen alsof je een gewone gebruiker bent.

Wat je hieruit meeneemt

  • Een Microsoft-login is een keten van HTML-formulieren en cookies, geen enkel API-call. Als je die keten begrijpt, kun je hem met requests volledig namaken.
  • De twee tokens die alles bij elkaar houden zijn sFT (flow token) en sCtx (context), verstopt in het $Config-object op de loginpagina.
  • GetCredentialType is het scharnierpunt: het bepaalt of je in de managed (Azure AD) of federated (ADFS) tak terechtkomt.
  • Het eindstuk is altijd een SAML form walk: auto-submit formulieren met de hand doorposten tot je weer bij de applicatie bent.
  • De harde grens is MFA. Zodra dat aan staat, red je het niet met pure HTTP en moet je eenmalig via een browser inloggen en cookies hergebruiken.

Het mooie is dat deze aanpak nul afhankelijkheden nodig heeft buiten requests. Geen zware browser-automation, geen brosse UI-selectors. Alleen HTTP, een paar regexes en begrip van het protocol.

Tags

python, requests, microsoft-auth, azure-ad, adfs, saml, single-sign-on, oauth, web-scraping, authenticatie, sso, http

Neem contact op