Partie A — L'Admin prépare le terrain
A.1 Créer l'App Registration
C'est la carte d'identité de votre application chez Microsoft.
MonApp_2026)https://votre-app/callbackA.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.
"groupMembershipClaims" (valeur par défaut : null)"groupMembershipClaims": "SecurityGroup"A.3 Créer le secret client
A.4 Récupérer les Object IDs des groupes
Pour chaque groupe que l'application doit vérifier :
PowerShellConnect-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
| Information | Où la trouver |
|---|---|
| Tenant ID | Overview → Directory (tenant) ID |
| Application (client) ID | Overview → Application (client) ID |
| Client Secret | Valeur copiée à l'étape A.3 |
| Redirect URI | L'URL callback déclarée à l'étape A.1 |
| Object IDs des groupes | Résultat PowerShell de l'étape A.4 |
Partie B — Le développeur branche l'auth
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 ? |
|---|---|---|
openid | Active l'authentification OpenID Connect | NON |
profile | Récupérer le nom et l'identifiant de l'utilisateur | NON |
email | Récupérer l'adresse e-mail | NON |
User.Read | Lire le profil de l'utilisateur connecté | NON |
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 » :
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 :
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
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 HTTPPOST 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 payload | Contenu |
|---|---|
preferred_username | L'identifiant de l'utilisateur (ex : prenom.nom@votredomaine.com) |
name | Nom complet affiché |
email | Adresse 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.
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é
}
Scope supplémentaire
Ajoutez Mail.Send à vos scopes. C'est tout :
| Scope | Ça sert à quoi ? | Admin consent ? |
|---|---|---|
Mail.Send | Envoyer des mails au nom de l'utilisateur connecté | NON |
Vos scopes deviennent : openid profile email User.Read Mail.Send
L'appel API
Avec l'access_token obtenu lors de l'authentification :
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
refresh_token pour en obtenir un nouveau via POST /oauth2/v2.0/token avec grant_type=refresh_token.
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.
Mail.SendPré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 :
https://votre-app/callback_service (URL du callback dédié)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.
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énement | Impact | Action |
|---|---|---|
| 🔑 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 |
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.
grant_type=refresh_token sans envoyer de mail. Cela renouvelle le token et remet le compteur d'inactivité à zéro.
Architecture recommandée
| Composant | Rôle |
|---|---|
admin_token.php | Page d'admin protégée — bouton par boîte technique à authentifier |
callback_service.php | Callback 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.
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
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
SQLCREATE TABLE service_tokens (
service_name VARCHAR(50) PRIMARY KEY, -- 'conges', 'fournisseurs', etc.
refresh_token TEXT NOT NULL,
updated_at DATETIME NOT NULL
);
callback_service.php) dans l'App Registration Azure. Les scopes sont demandés dynamiquement — pas besoin de toucher aux API permissions.
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
CLE=valeur (sans espaces) mais le parser attendait CLE = valeur (avec espaces). Résultat : toute la ligne était injectée dans l'URL.
= rencontré, qu'il y ait des espaces ou non.
GroupMember.Read.All (même en delegated) exige un consentement admin. L'utilisateur standard ne peut pas consentir seul.
groups dans le token via le manifest (groupMembershipClaims: SecurityGroup).
groupMembershipClaims configuré.
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.