REX
2026
pour les Nuls
Guide pratique — La Cyber pour les Nuls
L'Authentification
Azure AD
🔐 OAuth2 + PKCE 👥 Vérification de groupe 📧 Envoi mail Graph 📬 Boîtes techniques 🚫 Zéro admin consent 💻 PHP · Python · JS · Java · C#
🤓
La Cyber pour les Nuls — Avril 2026
🛠️

Partie A — L'Admin prépare le terrain

À faire une seule fois par un administrateur Azure. Ensuite, on donne les clés aux développeurs.

A.1 Créer l'App Registration

C'est la carte d'identité de votre application chez Microsoft.

Portail Azure → Entra ID → App registrations → New registration
Nom : choisir un nom clair (ex : MonApp_2026)
Supported account types : « Accounts in this organizational directory only »
Redirect URI : Webhttps://votre-app/callback
Truc
Le Redirect URI est l'URL vers laquelle Azure renverra l'utilisateur après l'authentification. C'est votre page de « callback » — elle doit être en HTTPS.

A.2 Le manifest — la clé magique

Cette étape permet d'inclure les groupes de l'utilisateur directement dans le token d'identité, sans appel API supplémentaire.

App Registration → Manifest
Chercher "groupMembershipClaims" (valeur par défaut : null)
Remplacer par : "groupMembershipClaims": "SecurityGroup"
Sauvegarder
N'oubliez pas
Sans cette config, le token ne contiendra pas les groupes, et l'application ne pourra pas vérifier si l'utilisateur a le droit d'accès. C'est LA config essentielle côté admin.

A.3 Créer le secret client

App Registration → Certificates & secrets → New client secret
Durée : 24 mois recommandé
Copier immédiatement la valeur — elle ne sera plus jamais visible !
Attention
Le secret est affiché UNE SEULE FOIS. Si vous le perdez, il faudra en créer un nouveau. Notez-le dans un endroit sécurisé (coffre-fort, KeePass…).

A.4 Récupérer les Object IDs des groupes

Pour chaque groupe que l'application doit vérifier :

PowerShell
Connect-MgGraph -Scopes "Group.Read.All"
Get-MgGroup -Filter "displayName eq 'MON-GROUPE'" | Select Id, DisplayName

A.5 Ce que l'admin donne aux développeurs

InformationOù la trouver
Tenant IDOverview → Directory (tenant) ID
Application (client) IDOverview → Application (client) ID
Client SecretValeur copiée à l'étape A.3
Redirect URIL'URL callback déclarée à l'étape A.1
Object IDs des groupesRésultat PowerShell de l'étape A.4
Truc
C'est tout ce qu'il faut côté admin. Pas besoin de toucher à « API permissions » dans le portail Azure : les scopes sont demandés dynamiquement par l'application et l'utilisateur consent lui-même au premier login.
💻

Partie B — Le développeur branche l'auth

Le minimum vital : authentifier un utilisateur et vérifier qu'il est dans le bon groupe.

B.1 Les scopes à demander

Voici les 4 scopes nécessaires pour l'authentification. Aucun ne nécessite de consentement admin :

ScopeÇa sert à quoi ?Admin consent ?
openidActive l'authentification OpenID ConnectNON
profileRécupérer le nom et l'identifiant de l'utilisateurNON
emailRécupérer l'adresse e-mailNON
User.ReadLire le profil de l'utilisateur connectéNON
Ne faites pas ça
N'ajoutez PAS GroupMember.Read.All dans vos scopes. Même en « delegated », ce scope exige un consentement admin. On n'en a pas besoin : les groupes arrivent directement dans le token grâce au manifest (Partie A).

B.2 Le flux en images

Voici ce qui se passe quand un utilisateur clique sur « Se connecter avec Microsoft » :

1
🖱️ L'utilisateur clique sur « Se connecter avec Microsoft »
2
🔀 Votre serveur génère un state (anti-piratage) + un code_verifier PKCE, les stocke en session, et redirige vers Azure
3
🔐 L'utilisateur s'authentifie chez Microsoft (login + MFA si activé)
4
↩️ Azure redirige vers votre callback avec un code d'autorisation
5
🔄 Votre serveur échange ce code contre des tokens (id_token + access_token)
6
📋 Vous décodez le id_token (JWT) → profil + liste des groupes
7
✅ Vous vérifiez que l'Object ID du groupe autorisé est dans la liste → accès accordé !

B.3 Étape par étape dans le code

Étape 1 — Construire l'URL de redirection

Générer un state aléatoire et un code_verifier PKCE, puis rediriger :

URL à construire
https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize
  ?client_id={client_id}
  &response_type=code
  &redirect_uri={votre_callback_url}
  &scope=openid profile email User.Read
  &state={state_aleatoire}
  &code_challenge={sha256_du_code_verifier_en_base64url}
  &code_challenge_method=S256
  &prompt=select_account
N'oubliez pas
Le state protège contre les attaques CSRF : vous le stockez en session et vous vérifiez qu'il revient identique dans le callback.
Le PKCE (code_verifier/code_challenge) empêche un attaquant d'utiliser votre code d'autorisation même s'il l'intercepte.

Étape 2 — Échanger le code contre les tokens

Dans votre callback, après avoir vérifié le state :

Requête HTTP
POST https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded

client_id={client_id}
&client_secret={client_secret}
&grant_type=authorization_code
&code={le_code_recu_dans_le_callback}
&redirect_uri={votre_callback_url}
&scope=openid profile email User.Read
&code_verifier={le_code_verifier_stocke_en_session}

La réponse JSON contient : id_token, access_token, refresh_token.

Étape 3 — Décoder le token et vérifier le groupe

L'id_token est un JWT = 3 parties séparées par des points. La 2ème partie (payload) contient les infos en JSON encodé en base64url :

Champ du payloadContenu
preferred_usernameL'identifiant de l'utilisateur (ex : prenom.nom@votredomaine.com)
nameNom complet affiché
emailAdresse e-mail
groups🎯 Tableau des Object IDs des groupes

Pour autoriser l'accès : vérifier que l'Object ID de votre groupe est dans le tableau groups.

Truc
Le payload JWT n'est pas chiffré, juste encodé en base64. La sécurité repose sur le fait que vous avez obtenu ce token directement du serveur Microsoft via HTTPS + PKCE — pas d'un tiers.

B.4 Exemples de code — Authentification

Chaque exemple montre : 1) l'échange code → tokens et 2) le décodage du JWT pour extraire les groupes.

PHP
// 1. Échange code → tokens
$ch = curl_init("https://login.microsoftonline.com/{$tenantId}/oauth2/v2.0/token");
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POSTFIELDS     => http_build_query([
        'client_id'     => $clientId,
        'client_secret' => $clientSecret,
        'grant_type'    => 'authorization_code',
        'code'          => $_GET['code'],
        'redirect_uri'  => $redirectUri,
        'scope'         => 'openid profile email User.Read',
        'code_verifier' => $_SESSION['code_verifier'],
    ]),
]);
$tokens = json_decode(curl_exec($ch), true);

// 2. Décoder le payload JWT (2ème segment)
$parts   = explode('.', $tokens['id_token']);
$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);

$userName = $payload['preferred_username'];   // prenom.nom@votredomaine.com
$groups   = $payload['groups'] ?? [];         // ["Object-ID-1", "Object-ID-2", ...]

// 3. Vérifier le groupe
if (in_array($monGroupeAutorise, $groups)) {
    // ✅ Accès autorisé — créer la session
}
Python
import requests, base64, json

# 1. Échange code → tokens
tokens = requests.post(
    f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token",
    data={
        "client_id": client_id, "client_secret": client_secret,
        "grant_type": "authorization_code", "code": request.args["code"],
        "redirect_uri": redirect_uri,
        "scope": "openid profile email User.Read",
        "code_verifier": session["code_verifier"],
    }
).json()

# 2. Décoder le payload JWT
payload = json.loads(base64.urlsafe_b64decode(tokens["id_token"].split(".")[1] + "=="))
groups = payload.get("groups", [])

# 3. Vérifier le groupe
if mon_groupe_id in groups:
    # ✅ Accès autorisé
JavaScript / Node.js
// 1. Échange code → tokens
const params = new URLSearchParams({
  client_id: clientId, client_secret: clientSecret,
  grant_type: "authorization_code", code: req.query.code,
  redirect_uri: redirectUri,
  scope: "openid profile email User.Read",
  code_verifier: req.session.codeVerifier,
});
const tokens = await fetch(
  `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
  { method: "POST", body: params }
).then(r => r.json());

// 2. Décoder le payload JWT
const payload = JSON.parse(
  Buffer.from(tokens.id_token.split(".")[1], "base64url").toString()
);
const groups = payload.groups || [];

// 3. Vérifier le groupe
if (groups.includes(monGroupeAutorise)) {
  // ✅ Accès autorisé
}
Java (JDK 11+)
// 1. Échange code → tokens
var body = "client_id=" + clientId + "&client_secret=" + clientSecret
    + "&grant_type=authorization_code&code=" + code
    + "&redirect_uri=" + URLEncoder.encode(redirectUri, UTF_8)
    + "&scope=" + URLEncoder.encode("openid profile email User.Read", UTF_8)
    + "&code_verifier=" + codeVerifier;

var req = HttpRequest.newBuilder()
    .uri(URI.create("https://login.microsoftonline.com/" + tenantId + "/oauth2/v2.0/token"))
    .header("Content-Type", "application/x-www-form-urlencoded")
    .POST(HttpRequest.BodyPublishers.ofString(body)).build();
var resp = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
var tokens = new JSONObject(resp.body());

// 2. Décoder le payload JWT
var payload = new String(Base64.getUrlDecoder().decode(
    tokens.getString("id_token").split("\\.")[1]));
var groups = new JSONObject(payload).getJSONArray("groups");

// 3. Vérifier le groupe
if (groups.toList().contains(monGroupeAutorise)) {
    // ✅ Accès autorisé
}
C# / .NET
// 1. Échange code → tokens
var tokenParams = new Dictionary<string, string> {
    ["client_id"] = clientId, ["client_secret"] = clientSecret,
    ["grant_type"] = "authorization_code", ["code"] = code,
    ["redirect_uri"] = redirectUri,
    ["scope"] = "openid profile email User.Read",
    ["code_verifier"] = codeVerifier,
};
var resp = await httpClient.PostAsync(
    $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token",
    new FormUrlEncodedContent(tokenParams));
var tokens = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());

// 2. Décoder le payload JWT
var payload = JsonDocument.Parse(
    Convert.FromBase64String(tokens.RootElement.GetProperty("id_token")
        .GetString().Split('.')[1].PadRight(/* base64 padding */)));
var groups = payload.RootElement.GetProperty("groups")
    .EnumerateArray().Select(g => g.GetString()).ToList();

// 3. Vérifier le groupe
if (groups.Contains(monGroupeAutorise)) {
    // ✅ Accès autorisé
}

📦 OPTION — Envoi de mails

Si votre application a besoin d'envoyer des mails au nom de l'utilisateur connecté, lisez cette section.
Sinon, vous pouvez passer directement à la Partie C.

Scope supplémentaire

Ajoutez Mail.Send à vos scopes. C'est tout :

ScopeÇa sert à quoi ?Admin consent ?
Mail.SendEnvoyer des mails au nom de l'utilisateur connectéNON

Vos scopes deviennent : openid profile email User.Read Mail.Send

N'oubliez pas
L'utilisateur verra un prompt de consentement la première fois : « Envoyer des courriers en votre nom ». C'est normal et c'est transparent par la suite.

L'appel API

Avec l'access_token obtenu lors de l'authentification :

Requête HTTP
POST https://graph.microsoft.com/v1.0/me/sendMail
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "message": {
    "subject": "Mon sujet",
    "body": { "contentType": "HTML", "content": "<p>Corps du mail</p>" },
    "toRecipients": [
      { "emailAddress": { "address": "destinataire@votredomaine.com" } }
    ]
  },
  "saveToSentItems": true
}

Réponse : HTTP 202 Accepted (pas de body). Le mail part depuis l'adresse de l'utilisateur connecté.

Exemples de code — Envoi mail

PHP
$ch = curl_init("https://graph.microsoft.com/v1.0/me/sendMail");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer {$accessToken}",
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "message" => [
            "subject" => $subject,
            "body"    => ["contentType" => "HTML", "content" => $body],
            "toRecipients" => [["emailAddress" => ["address" => $to]]],
        ],
        "saveToSentItems" => true,
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
curl_exec($ch);
// curl_getinfo($ch, CURLINFO_HTTP_CODE) === 202 → OK
Python
r = requests.post(
    "https://graph.microsoft.com/v1.0/me/sendMail",
    headers={"Authorization": f"Bearer {access_token}"},
    json={"message": {
        "subject": subject,
        "body": {"contentType": "HTML", "content": body},
        "toRecipients": [{"emailAddress": {"address": to}}],
    }, "saveToSentItems": True},
)
assert r.status_code == 202  # OK
JavaScript / Node.js
const res = await fetch("https://graph.microsoft.com/v1.0/me/sendMail", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${accessToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    message: {
      subject, body: { contentType: "HTML", content: bodyHtml },
      toRecipients: [{ emailAddress: { address: to } }],
    },
    saveToSentItems: true,
  }),
});
// res.status === 202 → OK
Java
var json = """
  {"message":{"subject":"%s","body":{"contentType":"HTML","content":"%s"},
  "toRecipients":[{"emailAddress":{"address":"%s"}}]},"saveToSentItems":true}
  """.formatted(subject, body, to);

var req = HttpRequest.newBuilder()
    .uri(URI.create("https://graph.microsoft.com/v1.0/me/sendMail"))
    .header("Authorization", "Bearer " + accessToken)
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(json)).build();
var resp = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
// resp.statusCode() == 202 → OK
C#
var msg = new {
    message = new {
        subject, body = new { contentType = "HTML", content = bodyHtml },
        toRecipients = new[] { new { emailAddress = new { address = to } } },
    },
    saveToSentItems = true,
};
var req = new HttpRequestMessage(HttpMethod.Post,
    "https://graph.microsoft.com/v1.0/me/sendMail");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
req.Content = new StringContent(
    JsonSerializer.Serialize(msg), Encoding.UTF8, "application/json");
var resp = await httpClient.SendAsync(req);
// resp.StatusCode == HttpStatusCode.Accepted → OK
Truc
Pensez au refresh token ! L'access_token expire au bout d'~1 heure. Si votre session dure plus longtemps, utilisez le refresh_token pour en obtenir un nouveau via POST /oauth2/v2.0/token avec grant_type=refresh_token.

📬 OPTION — Boîte aux lettres technique

Si votre application doit envoyer des mails depuis une boîte de service (congés, fournisseurs, notifications…),
sans lien avec l'utilisateur connecté. Sinon, passez à la Partie C.

Le principe

On utilise toujours du consentement utilisateur (zéro admin consent), mais avec un twist : un administrateur fonctionnel se connecte une seule fois avec le compte technique via une page d'admin dédiée. L'application stocke le refresh_token de ce compte, et l'utilise ensuite pour envoyer des mails en autonomie.

1
🛠️ L'admin fonctionnel ouvre la page d'admin des tokens de l'application
2
🔐 Il clique sur « Authentifier conges@votredomaine.com » → redirection Azure
3
✅ Il se connecte avec le compte technique et consent au scope Mail.Send
4
💾 Le callback stocke le refresh_token de manière persistante (BDD ou fichier chiffré)
5
📧 L'application utilise ce refresh_token pour envoyer des mails à la demande, sans interaction humaine
N'oubliez pas
Ce flux est indépendant du login des utilisateurs de l'application. L'utilisateur final se connecte avec son propre compte (Partie B), tandis que la boîte technique a son propre token stocké côté serveur. Les deux coexistent.

Prérequis côté admin Azure

La boîte technique utilise la même App Registration que le login utilisateur. L'admin Azure doit simplement ajouter un Redirect URI supplémentaire pour le callback dédié aux tokens de service :

App Registration → Authentication
Dans la section « Web — Redirect URIs », cliquer Add URI
Ajouter : https://votre-app/callback_service (URL du callback dédié)
Sauvegarder
Truc
Vous avez maintenant deux Redirect URIs dans la même App Registration :

1. https://votre-app/callback → pour le login utilisateur (Partie B)
2. https://votre-app/callback_service → pour l'authentification des boîtes techniques (cette option)

Pas besoin de toucher aux API permissions ni au manifest — les scopes sont demandés dynamiquement.
N'oubliez pas — Scope offline_access
Le scope offline_access est indispensable dans l'URL d'autorisation des boîtes techniques. C'est lui qui demande à Azure de fournir un refresh_token dans la réponse. Sans lui, vous n'obtiendrez qu'un access_token éphémère (~1 heure) et la boîte technique ne pourra pas envoyer de mails au-delà de cette durée.

Ce scope ne nécessite pas d'admin consent.

Quand ré-authentifier la boîte technique ?

Le refresh_token est un « pass longue durée ». Il sera automatiquement renouvelé à chaque utilisation tant que la boîte envoie des mails régulièrement. Mais il devient invalide dans les cas suivants :

ÉvénementImpactAction
🔑 Rotation du secret client
L'App Registration Azure a un nouveau secret
Le refresh échoue car le secret envoyé ne correspond plus Mettre à jour le secret dans la config de l'application, puis ré-authentifier chaque boîte technique
🔒 Changement du mot de passe
Le mot de passe du compte technique a été modifié
Azure révoque tous les refresh_tokens du compte Ré-authentifier la boîte technique avec le nouveau mot de passe
🛡️ MFA ré-enregistrée
Le compte technique a reconfiguré son MFA
Azure peut révoquer les tokens selon la politique conditionnelle Ré-authentifier la boîte technique
Inactivité > 90 jours
Aucun mail envoyé depuis 3 mois
Le refresh_token expire par défaut (politique Azure) Ré-authentifier la boîte technique — ou mettre en place un cron de refresh préventif
🚫 Révocation admin
Un admin Azure a révoqué les sessions du compte
Tous les tokens sont invalidés immédiatement Ré-authentifier la boîte technique
📋 Politique d'accès conditionnel
Nouvelle politique de sécurité sur le tenant
Azure peut exiger une ré-authentification selon les règles Ré-authentifier la boîte technique
Attention
Comment détecter qu'il faut ré-authentifier ? Votre fonction sendMailService() retournera false et l'erreur sera logguée côté serveur. En production, prévoyez un mécanisme d'alerte (mail d'alerte à l'admin, entrée dans un dashboard) quand le refresh échoue, pour ne pas découvrir le problème trop tard.
Truc — Refresh préventif
Pour éviter l'expiration par inactivité (90 jours), vous pouvez mettre en place un cron job hebdomadaire qui appelle le endpoint token avec grant_type=refresh_token sans envoyer de mail. Cela renouvelle le token et remet le compteur d'inactivité à zéro.

Architecture recommandée

ComposantRôle
admin_token.phpPage d'admin protégée — bouton par boîte technique à authentifier
callback_service.phpCallback dédié — stocke le refresh_token en BDD (table service_tokens)
Fonction sendMailService()Obtient un access_token via refresh, envoie le mail via Graph

Implémentation — Page d'admin

La page d'admin génère un lien OAuth2 pour chaque boîte technique. Le paramètre login_hint pré-remplit l'adresse, et state identifie quelle boîte est en cours d'authentification.

URL de redirection (par boîte technique)
https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize
  ?client_id={client_id}
  &response_type=code
  &redirect_uri={callback_service_url}
  &scope=openid Mail.Send offline_access
  &state=service:conges
  &login_hint=conges@votredomaine.com
  &prompt=consent
  &code_challenge={code_challenge}
  &code_challenge_method=S256
Truc
offline_access est le scope qui demande un refresh_token. Sans lui, Azure ne renvoie qu'un access_token éphémère (~1h).
login_hint pré-remplit l'adresse mail dans le formulaire de connexion Azure.
prompt=consent force le re-consentement (utile après un changement de secret).

Implémentation — Callback et stockage

PHP — callback_service.php
// 1. Vérifier le state
$state = $_GET['state'];  // ex : "service:conges"
$serviceName = explode(':', $state)[1];

// 2. Échanger le code → tokens
$tokens = httpPost("https://login.microsoftonline.com/{$tenantId}/oauth2/v2.0/token", [
    'client_id'     => $clientId,
    'client_secret' => $clientSecret,
    'grant_type'    => 'authorization_code',
    'code'          => $_GET['code'],
    'redirect_uri'  => $callbackServiceUrl,
    'scope'         => 'openid Mail.Send offline_access',
    'code_verifier' => $_SESSION['code_verifier_service'],
]);

// 3. Stocker le refresh_token en BDD
$stmt = $bdd->prepare("
    INSERT INTO service_tokens (service_name, refresh_token, updated_at)
    VALUES (?, ?, NOW())
    ON DUPLICATE KEY UPDATE refresh_token = VALUES(refresh_token),
                            updated_at = NOW()
");
$stmt->execute([$serviceName, $tokens['refresh_token']]);

Implémentation — Envoi mail depuis la boîte technique

PHP — fonction sendMailService()
function sendMailService(string $serviceName, string $to, string $subject, string $body): bool
{
    // 1. Récupérer le refresh_token stocké
    $stmt = $GLOBALS['bdd']->prepare(
        "SELECT refresh_token FROM service_tokens WHERE service_name = ?");
    $stmt->execute([$serviceName]);
    $refreshToken = $stmt->fetchColumn();
    if (!$refreshToken) {
        error_log("sendMailService: pas de token pour '{$serviceName}'");
        return false;
    }

    // 2. Obtenir un access_token frais via le refresh_token
    $tokens = httpPost(
        "https://login.microsoftonline.com/" . env('TENANT_ID_LOGON') . "/oauth2/v2.0/token",
        [
            'client_id'     => env('APPLICATION_ID_LOGON'),
            'client_secret' => env('CLIENT_SECRET_LOGON'),
            'grant_type'    => 'refresh_token',
            'refresh_token' => $refreshToken,
            'scope'         => 'Mail.Send offline_access',
        ]
    );
    if (!$tokens || isset($tokens['error'])) {
        error_log("sendMailService: refresh failed for '{$serviceName}': "
            . ($tokens['error_description'] ?? 'unknown'));
        return false;
    }

    // 2b. Mettre à jour le refresh_token (il peut être renouvelé par Azure)
    if (!empty($tokens['refresh_token'])) {
        $stmt = $GLOBALS['bdd']->prepare(
            "UPDATE service_tokens SET refresh_token = ?, updated_at = NOW()
             WHERE service_name = ?");
        $stmt->execute([$tokens['refresh_token'], $serviceName]);
    }

    // 3. Envoyer le mail via Graph
    $ch = curl_init("https://graph.microsoft.com/v1.0/me/sendMail");
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer " . $tokens['access_token'],
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS => json_encode([
            "message" => [
                "subject" => $subject,
                "body"    => ["contentType" => "HTML", "content" => $body],
                "toRecipients" => [["emailAddress" => ["address" => $to]]],
            ],
            "saveToSentItems" => true,
        ]),
    ]);
    curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    return $httpCode === 202;
}

// Utilisation :
sendMailService('conges', 'manager@votredomaine.com', 'Demande de congés', $htmlBody);
sendMailService('fournisseurs', 'achat@vendor.com', 'Commande #1234', $htmlBody);

Table SQL

SQL
CREATE TABLE service_tokens (
    service_name  VARCHAR(50) PRIMARY KEY,     -- 'conges', 'fournisseurs', etc.
    refresh_token TEXT NOT NULL,
    updated_at    DATETIME NOT NULL
);
Truc
Vous pouvez utiliser la même App Registration pour le login utilisateur et pour les boîtes techniques. Il suffit d'ajouter le Redirect URI du callback service (callback_service.php) dans l'App Registration Azure. Les scopes sont demandés dynamiquement — pas besoin de toucher aux API permissions.
Attention — Sécurité
La page d'admin des tokens doit être protégée (accessible uniquement aux administrateurs de l'application). Le refresh_token stocké en BDD donne accès à la boîte mail technique — traitez-le comme un mot de passe. Idéalement, chiffrez-le en BDD (AES-256).
Récapitulatif des 3 modes d'envoi
1. Mail utilisateur (Option 1) : l'access_token de la session envoie au nom de l'utilisateur connecté.
2. Boîte technique (Option 2) : un refresh_token pré-authentifié envoie au nom de la boîte de service.
3. Fallback SMTP : si Graph échoue, le relais SMTP Exchange prend le relais.

Les 3 modes fonctionnent sans aucun consentement admin Azure.
🐛

Partie C — Les pièges qu'on a évités pour vous

Retour d'expérience sur un projet pilote réel.
❌ Erreur AADSTS90102 — « redirect_uri must be a valid absolute URI »
Cause
Le fichier de configuration utilisait CLE=valeur (sans espaces) mais le parser attendait CLE = valeur (avec espaces). Résultat : toute la ligne était injectée dans l'URL.
Solution
Rendre le parser tolérant : splitter sur le premier = rencontré, qu'il y ait des espaces ou non.
❌ « Approbation administrateur requise »
Cause
Le scope GroupMember.Read.All (même en delegated) exige un consentement admin. L'utilisateur standard ne peut pas consentir seul.
Solution
Supprimer ce scope et utiliser le claim groups dans le token via le manifest (groupMembershipClaims: SecurityGroup).
❌ Claim « groups » absent du token
Cause
Le manifest de l'App Registration n'avait pas encore groupMembershipClaims configuré.
Solution
Modifier le manifest → effet immédiat sur les tokens émis ensuite.
La leçon à retenir
Avec 4 scopes (openid, profile, email, User.Read) + le manifest groupMembershipClaims, vous avez une authentification complète avec vérification de groupe, sans aucun consentement admin. Ajoutez Mail.Send uniquement si vous avez besoin d'envoyer des mails.