Un script Node.js obtient un jeton via Azure AD mais l’appel à l’API REST SharePoint renvoie l’erreur ID3035 : The request was not valid or is malformed. Voici un guide exhaustif pour diagnostiquer et corriger ce problème, avec exemples de code et check-lists opérationnelles.
Vue d’ensemble de la question
Le scénario typique est le suivant :
- Le script Node.js acquiert un jeton d’application (flux client credentials) auprès d’Azure AD/Entra.
- Ce jeton est placé dans l’en‑tête
Authorization: Bearer <token>
pour appelerhttps://<tenant>.sharepoint.com/_api/web/lists
(ou un Web/site spécifique).
Le jeton est bien renvoyé, mais l’appel SharePoint échoue avec ID3035. Cette erreur provient en général de paramètres OAuth mal formés (mélange v1/v2, resource
vs scope
) ou d’un jeton destiné au mauvais destinataire (audience).
Analyse des causes probables
Origine fréquente | Explication rapide | Correctif suggéré |
---|---|---|
Mélange v2/v1 | Le point de terminaison v2 (/oauth2/v2.0/token ) accepte scope mais pas resource. SharePoint REST requiert un jeton de ressource (ciblé sur le domaine SharePoint). | • Passer au point de terminaison v1 (/oauth2/token ) ou• Rester en v2 mais utiliser scope=https://<tenant>.sharepoint.com/.default . |
Permissions mal ciblées | L’autorisation Sites.ReadWrite.All est une permission pour Microsoft Graph. Or l’appel est effectué vers l’API native SharePoint. | Ajouter des permissions SharePoint → Sites.FullControl.All (ou niveau requis) et donner le consentement administrateur, ou basculer vos appels vers Microsoft Graph (/sites/{id}/lists ). |
En‑têtes REST incomplets | SharePoint attend souvent Accept: application/json;odata=nometadata (ou équivalent). Sans être la cause directe d’ID3035, l’absence d’en‑tête nuit au diagnostic et à l’content negotiation. | Ajouter l’en‑tête Accept précis ; optionnellement OData-Version: 4.0 . |
URL d’API mal formée | Un host ou un chemin qui ne correspond pas au public du jeton (ex. oublier /sites/{siteName} , utiliser -admin ou -my à tort) déclenche un rejet. | Vérifier que sharePointUrl et la resource (ou le scope ) pointent vers exactement la même URL de base. |
À retenir — ID3035 est renvoyée par la couche d’identité quand la requête de jeton ou l’usage du jeton est incohérent : paramètre manquant, audience erronée, mélange v1/v2, scope
/resource
contradictoires, etc.
Comprendre v1 vs v2, resource
vs scope
Azure AD expose deux conventions pour obtenir un jeton d’application :
Aspects | v1 (/oauth2/token ) | v2 (/oauth2/v2.0/token ) |
---|---|---|
Paramètre de ciblage | resource=https://<tenant>.sharepoint.com | scope=https://<tenant>.sharepoint.com/.default |
Où se définissent les permissions | Au niveau de l’API « SharePoint » (Application permissions) | Idem ; .default agrège toutes les permissions consenties pour cette ressource |
Erreurs typiques | Oubli de resource | Usage de resource (interdit), ou mauvais domaine dans scope |
Dans les deux cas, le jeton retourné porte une audience (claim aud
) spécifique à SharePoint Online. Si vous demandez un jeton pour Microsoft Graph mais l’utilisez contre *.sharepoint.com
, SharePoint rejettera la requête.
Check‑list de préparation
- Application Azure AD enregistrée (type « compte organisationnel », client credentials).
- Un secret client (ou certificat) valide.
- Permissions Application ajoutées et consenties :
- Si vous appelez SharePoint REST : SharePoint → Sites.Read.All / Sites.ReadWrite.All / Sites.FullControl.All selon besoin.
- Si vous appelez Microsoft Graph : Microsoft Graph → Sites.Read.All / Sites.ReadWrite.All / autres scopes requis.
- Option de sécurité : Sites.Selected pour restreindre l’app à des sites précis (n’oubliez pas la délégation de site).
- URL cible exacte (host
<tenant>.sharepoint.com
, chemin du site si nécessaire). - En‑têtes HTTP conformes (
Authorization
,Accept
, et éventuellementOData-Version
).
Solution pas‑à‑pas (option v2 moderne)
1) Configuration de l’application
- Dans Azure AD → « App registrations », créez/ouvrez l’application.
- Ajoutez des Application permissions côté ressource SharePoint (par ex. Sites.ReadWrite.All ou Sites.FullControl.All), puis accordez le consentement administrateur.
- Notez l’
application (client) ID
, ledirectory (tenant) ID
et le secret/certificat.
2) Obtention du jeton d’application (v2, .default
)
const tenantId = process.env.TENANT_ID;
const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
// Point clé : le scope doit cibler *votre* domaine SharePoint
const scope = '[https://contoso.sharepoint.com/.default](https://contoso.sharepoint.com/.default)';
const body =
`client_id=${encodeURIComponent(clientId)}` +
`&scope=${encodeURIComponent(scope)}` +
`&client_secret=${encodeURIComponent(clientSecret)}` +
`&grant_type=client_credentials`;
const res = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body
});
const token = await res.json();
if (!res.ok) {
throw new Error(`Token error: ${res.status} ${res.statusText} - ${JSON.stringify(token)}`);
}
const accessToken = token.access_token;
Bon réflexe — En v2, évitez de demander des scopes « dynamiques ». .default
garantit que le jeton ne contiendra que les permissions déjà consenties sur la ressource ciblée.
3) Appel SharePoint REST
const apiEndpoint = 'https://contoso.sharepoint.com/sites/Marketing/_api/web/lists?$select=Id,Title';
const response = await fetch(apiEndpoint, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json;odata=nometadata'
}
});
if (!response.ok) {
const text = await response.text();
throw new Error(`SP error: ${response.status} ${response.statusText} - ${text}`);
}
const data = await response.json();
console.log(data);
Si tout est correct, vous devriez recevoir un JSON listant les bibliothèques et listes du site ciblé.
Alternative : tout passer par Microsoft Graph
Si votre application dispose déjà de Microsoft Graph → Sites.ReadWrite.All, vous pouvez conserver cette surface d’API et remplacer uniquement l’URL :
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const scope = 'https://graph.microsoft.com/.default'; // Notez le scope côté Graph
// ... (POST token identique, mais avec scope Graph)
const sitePath = '/sites/contoso.sharepoint.com:/sites/Marketing';
const apiGraph = `https://graph.microsoft.com/v1.0${sitePath}:/lists`;
const graphRes = await fetch(apiGraph, {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
const listsGraph = await graphRes.json();
Graph offre une surface cohérente et des modèles de permission homogènes. Il évite aussi les subtilités v1/v2 de SharePoint REST.
Exemples complets avec msal-node
et cache de jeton
Pour une application de production, privilégiez msal-node
(Confidential Client) : gestion native du cache, re‑tentatives, télémétrie.
import 'dotenv/config';
import { ConfidentialClientApplication } from '@azure/msal-node';
const tenantId = process.env.TENANT_ID;
const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;
const cca = new ConfidentialClientApplication({
auth: {
clientId,
authority: `https://login.microsoftonline.com/${tenantId}`,
clientSecret
},
system: { loggerOptions: { piiLoggingEnabled: false } }
});
async function getTokenForSharePoint() {
const result = await cca.acquireTokenByClientCredential({
scopes: ['https://contoso.sharepoint.com/.default']
});
if (!result?.accessToken) throw new Error('Aucun jeton');
return result.accessToken;
}
async function getLists(siteRelative = '/sites/Marketing') {
const token = await getTokenForSharePoint();
const url = `https://contoso.sharepoint.com${siteRelative}/_api/web/lists?$select=Id,Title`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json;odata=nometadata'
}
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
getLists().then(console.log).catch(err => {
console.error('Erreur:', err.message);
});
Conseil perf — Ne redemandez pas un jeton à chaque requête : sa durée de vie est typiquement d’environ 60 minutes. Laissez msal-node
gérer le cache, ou mettez en place un cache mémoire/processus.
Débogage méthodique d’ID3035
1) Vérifier la requête de jeton
- v2 :
POST /oauth2/v2.0/token
avecgrant_type=client_credentials
etscope=https://<tenant>.sharepoint.com/.default
. - v1 :
POST /oauth2/token
avecgrant_type=client_credentials
etresource=https://<tenant>.sharepoint.com
. - Jamais combiner
resource
etscope
. - Client ID et secret valides, pas d’espaces ou de caractères non encodés.
2) Décoder localement le JWT (sans l’envoyer à un service externe)
Décodez l’en‑tête et le corps pour vérifier : aud
, iss
, tid
, appid
, roles
(app‑only) ou scp
(délégué).
function b64uToJson(part) {
part = part.replace(/-/g, '+').replace(/_/g, '/');
const pad = part.length % 4 ? 4 - (part.length % 4) : 0;
const str = Buffer.from(part + '='.repeat(pad), 'base64').toString('utf8');
return JSON.parse(str);
}
function inspectJwt(token) {
const [h, p] = token.split('.');
const header = b64uToJson(h);
const payload = b64uToJson(p);
console.log({ header, payload });
// Vérifier : payload.aud, payload.roles || payload.scp
}
- aud doit correspondre à SharePoint Online (jeton émis pour
*.sharepoint.com
). - roles (app‑only) doit contenir au moins le niveau de permission requis (
Sites.Read.All
,Sites.ReadWrite.All
, etc.). - scp est présent uniquement pour des jetons délégués (utilisateur) ; avec client credentials, attendez‑vous à voir
roles
et passcp
.
3) Valider la cible HTTP
- L’hôte utilisé dans l’URL d’API doit être aligné avec la ressource du jeton :
contoso.sharepoint.com
↔ jeton pourcontoso.sharepoint.com
. - Évitez
-admin
ou-my
si vous visez un site d’équipe ;-my
concerne OneDrive. - Le chemin du site est requis si vos listes ne sont pas sur le site racine :
/sites/Marketing
,/teams/Comite
, etc.
4) Réexécuter avec curl
(isoler l’erreur)
# 1) Jeton v2 pour SharePoint
curl -sS -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&grant_type=client_credentials&scope=https%3A%2F%2Fcontoso.sharepoint.com%2F.default" \
https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token | jq .
# 2) Appel REST
curl -sS -H "Authorization: Bearer "
-H "Accept: application/json;odata=nometadata"
"[https://contoso.sharepoint.com/sites/Marketing/_api/web/lists?$select=Id,Title](https://contoso.sharepoint.com/sites/Marketing/_api/web/lists?$select=Id,Title)" | jq .
5) Si l’erreur devient 401/403
- 401 : jeton absent/expiré, audience incorrecte, signature invalide.
- 403 : jeton valide, mais permission insuffisante ; accordez l’Application permission adéquate du bon côté ressource (SharePoint vs Graph), ou utilisez Sites.Selected + délégation au site.
Pièges courants et correctifs
Symptôme | Cause profonde | Action |
---|---|---|
ID3035 à l’obtention du jeton | Requête v2 avec resource=... (paramètre interdit) | Remplacer par scope=.../.default |
ID3035 côté SharePoint | Jeton destiné à Graph utilisé sur SharePoint | Demander un jeton SharePoint (scope/ressource corrige) ou basculer l’appel vers Graph |
403 « Access denied » | Permissions Graph ajoutées seulement, appel vers SharePoint REST | Ajouter permissions SharePoint et re‑consentir |
404 / liste introuvable | Mauvais chemin de site (/sites/... ) | Valider l’URL complète du site et le nom de la liste |
Quand choisir SharePoint REST vs Microsoft Graph ?
Critère | SharePoint REST | Microsoft Graph |
---|---|---|
Surface d’API | Spécifique à SharePoint, finement typée pour listes/sites | Unifiée, stable, couvre d’autres workloads M365 |
Permissions | « SharePoint » (Application permissions) | « Microsoft Graph » (Application permissions) |
Cas d’usage | Interop avancée SharePoint, endpoints REST hérités | Intégration multi‑services, gouvernance simplifiée |
Sécurité et bonnes pratiques
- Secrets : utilisez des identités managées ou des certificats quand c’est possible. À défaut, stockez le secret dans un coffre sécurisé.
- Principe du moindre privilège : Sites.Selected permet de restreindre l’application à des sites donnés (n’oubliez pas l’étape d’assignation sur chaque site autorisé).
- Rotation : surveillez la date d’expiration des secrets/certificats et planifiez une rotation.
- Journalisation : tracez les correlation IDs, codes HTTP, en‑têtes clés (sans exposer le jeton en clair).
Recette express (copier‑coller) : de zéro à « lists »
- Ajouter SharePoint → Sites.ReadWrite.All (ou Sites.Read.All) à l’application et consentir.
- Obtenir un jeton v2 pour
https://<tenant>.sharepoint.com/.default
. - Appeler
GET https://<tenant>.sharepoint.com/sites/<Site>/_api/web/lists
avec les en‑têtes recommandés. - Si 403 : confirmer que le jeton porte bien les
roles
attendus et que l’app a accès au site (si Sites.Selected).
Exemple robuste d’architecture Node.js
Ci‑dessous, un module minimaliste et réutilisable : gestion du jeton, client HTTP, vérifications et erreurs explicites.
// auth.js
import { ConfidentialClientApplication } from '@azure/msal-node';
export class SharePointAuth {
constructor({ tenantId, clientId, clientSecret, spDomain }) {
this.spDomain = spDomain; // ex. contoso.sharepoint.com
this.cca = new ConfidentialClientApplication({
auth: {
clientId,
authority: `https://login.microsoftonline.com/${tenantId}`,
clientSecret
}
});
this.scopes = [`https://${spDomain}/.default`];
}
async getToken() {
const res = await this.cca.acquireTokenByClientCredential({ scopes: this.scopes });
if (!res?.accessToken) throw new Error('Impossible d’obtenir un jeton SharePoint');
return res.accessToken;
}
}
// sp-client.js
export class SharePointClient {
constructor({ auth, sitePath }) {
this.auth = auth; // instance de SharePointAuth
this.sitePath = sitePath; // ex. /sites/Marketing
this.base = `https://${auth.spDomain}${sitePath}`;
}
async getLists() {
const token = await this.auth.getToken();
const url = `${this.base}/_api/web/lists?$select=Id,Title`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json;odata=nometadata',
'OData-Version': '4.0'
}
});
if (!res.ok) {
const body = await res.text();
const e = new Error(`HTTP ${res.status} ${res.statusText}`);
e.body = body;
throw e;
}
return res.json();
}
}
// index.js
import 'dotenv/config';
import { SharePointAuth } from './auth.js';
import { SharePointClient } from './sp-client.js';
const auth = new SharePointAuth({
tenantId: process.env.TENANT_ID,
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
spDomain: 'contoso.sharepoint.com'
});
const sp = new SharePointClient({ auth, sitePath: '/sites/Marketing' });
sp.getLists()
.then(({ value }) => console.table(value.map(v => ({ Id: v.Id, Title: v.Title }))))
.catch(err => {
console.error(err.message);
if (err.body) console.error(err.body);
});
FAQ rapide
Pourquoi un jeton Graph ne marche‑t‑il pas sur SharePoint REST ?
Parce que le jeton est émis pour une audience différente : SharePoint REST valide que le destinataire du jeton correspond à sa ressource. Si l’audience n’est pas la bonne, la requête est rejetée.
Faut‑il obligatoirement utiliser /sites/<site>
?
Non ; si la liste est sur le site racine, l’URL racine suffit. En pratique, la majorité des données utiles résident sous un site dédié : autant qualifier explicitement /sites/<site>
pour éviter toute ambiguïté.
Peut‑on mélanger v1 et v2 ?
Évitez. Utilisez v2 + .default
(recommandé) ou, si vous avez un historique v1, restez v1 mais sans introduire de scope
. Le mélange est une cause fréquente d’ID3035.
Et si je dois restreindre l’app à un seul site ?
Demandez l’Application permission Sites.Selected, consentez‑la, puis accordez explicitement l’accès au site cible (rôle lecture/écriture/propriétaire). Sans cette délégation de site, l’app restera bloquée.
Résumé opératoire
- En v2, demandez un jeton avec
scope=https://<tenant>.sharepoint.com/.default
. - Pour SharePoint REST, ajoutez des Application permissions côté ressource SharePoint (pas seulement Graph).
- Alignez strictement l’hôte de l’API et la ressource du jeton.
- Décodez le JWT pour valider
aud
+roles
. - En alternative, appelez Microsoft Graph et utilisez
https://graph.microsoft.com/.default
.
Annexe : variante v1 (si héritage)
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/token`;
const form =
`client_id=${encodeURIComponent(clientId)}` +
`&client_secret=${encodeURIComponent(clientSecret)}` +
`&grant_type=client_credentials` +
`&resource=${encodeURIComponent('https://contoso.sharepoint.com')}`;
const tokenRes = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form
});
Cette approche reste valable dans les environnements qui standardisent encore sur v1. Évitez toutefois de l’entremêler avec v2 sur le même projet.
Annexe : en‑têtes recommandés et formats OData
En‑tête | Valeur | Pourquoi |
---|---|---|
Authorization | Bearer <access_token> | Porter le jeton d’application |
Accept | application/json;odata=nometadata | Réponses plus légères et faciles à parser |
OData-Version | 4.0 | Explicite le protocole ; utile selon les bibliothèques |
User-Agent | my-app/1.0 | Traçabilité côté serveur |
Annexe : messages d’erreur utiles (après correction d’ID3035)
- 401 Unauthorized — Jeton manquant/expiré ; régénérez le jeton, vérifiez la durée et le décalage d’horloge.
- 403 Forbidden — Permissions insuffisantes ; ajoutez la permission manquante et re‑consentez, ou accordez l’accès au site si Sites.Selected.
- 404 Not Found — Mauvais chemin de site ou liste supprimée ; validez l’URL exacte.
- 429 / 503 — Throttling/maintenance ; mettez en place une stratégie de re‑tentatives avec backoff exponentiel.
Conclusion
L’erreur ID3035 survient quasi systématiquement lorsqu’on mixe les paradigmes v1/v2 (resource
vs scope
) ou lorsqu’on emploie un jeton émis pour une autre ressource (Graph ↔ SharePoint). En normalisant votre flux sur v2 + .default
, en alignant strictement l’hôte SharePoint et l’audience du jeton, et en vérifiant le contenu du JWT (aud
, roles
), vous éliminez la cause racine et sécurisez vos appels Node.js → SharePoint. Si votre projet touche plusieurs workloads, Microsoft Graph reste une alternative élégante et gouvernable.