Erreur ID3035 SharePoint REST avec Node.js : corriger l’auth Azure AD (v1/v2, scope vs resource)

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.

Sommaire

Vue d’ensemble de la question

Le scénario typique est le suivant :

  1. Le script Node.js acquiert un jeton d’application (flux client credentials) auprès d’Azure AD/Entra.
  2. Ce jeton est placé dans l’en‑tête Authorization: Bearer <token> pour appeler https://<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équenteExplication rapideCorrectif suggéré
Mélange v2/v1Le 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éesL’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 incompletsSharePoint 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éeUn 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.

À retenirID3035 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 :

Aspectsv1 (/oauth2/token)v2 (/oauth2/v2.0/token)
Paramètre de ciblageresource=https://<tenant>.sharepoint.comscope=https://<tenant>.sharepoint.com/.default
Où se définissent les permissionsAu niveau de l’API « SharePoint » (Application permissions)Idem ; .default agrège toutes les permissions consenties pour cette ressource
Erreurs typiquesOubli de resourceUsage 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 éventuellement OData-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, le directory (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 avec grant_type=client_credentials et scope=https://<tenant>.sharepoint.com/.default.
  • v1 : POST /oauth2/token avec grant_type=client_credentials et resource=https://<tenant>.sharepoint.com.
  • Jamais combiner resource et scope.
  • 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 pas scp.

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 pour contoso.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ômeCause profondeAction
ID3035 à l’obtention du jetonRequête v2 avec resource=... (paramètre interdit)Remplacer par scope=.../.default
ID3035 côté SharePointJeton destiné à Graph utilisé sur SharePointDemander un jeton SharePoint (scope/ressource corrige) ou basculer l’appel vers Graph
403 « Access denied »Permissions Graph ajoutées seulement, appel vers SharePoint RESTAjouter permissions SharePoint et re‑consentir
404 / liste introuvableMauvais 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èreSharePoint RESTMicrosoft Graph
Surface d’APISpécifique à SharePoint, finement typée pour listes/sitesUnifiée, stable, couvre d’autres workloads M365
Permissions« SharePoint » (Application permissions)« Microsoft Graph » (Application permissions)
Cas d’usageInterop avancée SharePoint, endpoints REST héritésInté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 »

  1. Ajouter SharePoint → Sites.ReadWrite.All (ou Sites.Read.All) à l’application et consentir.
  2. Obtenir un jeton v2 pour https://<tenant>.sharepoint.com/.default.
  3. Appeler GET https://<tenant>.sharepoint.com/sites/<Site>/_api/web/lists avec les en‑têtes recommandés.
  4. 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êteValeurPourquoi
AuthorizationBearer <access_token>Porter le jeton d’application
Acceptapplication/json;odata=nometadataRéponses plus légères et faciles à parser
OData-Version4.0Explicite le protocole ; utile selon les bibliothèques
User-Agentmy-app/1.0Traç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.

Sommaire