faitza.com
faitza.com
> blog_tech
Token Bearer pour les APIs Microsoft en PowerShell

Token Bearer pour les APIs Microsoft en PowerShell

Calcul... #powershell #azure #oauth2 #rest-api

Intro

Chaque API Microsoft (Graph, ARM, Azure DevOps, Key Vault, SharePoint…) exige un Bearer token dans le header HTTP de chaque requête. Ce token prouve l'identité de l'appelant et les droits associés. Deux contraintes s'ajoutent :

  • Chaque API attend un token lié à sa propre ressource OAuth2 — un token Graph ne peut pas appeler ARM.
  • Les tokens expirent (en général après 1 heure) et doivent être renouvelés.

Get-Faitza_Modul_Headers centralise cette logique. On lui passe un alias simple ("graph", "arm", "azdevops"…) et elle retourne un hashtable de headers prêt à être passé à Invoke-RestMethod. Le token est obtenu via Azure CLI, mis en cache en session, et renouvelé automatiquement avant expiration.

Prérequis

La fonction s'appuie sur Azure CLI pour récupérer le token. Une session authentifiée est nécessaire :

  • En développement : az login
  • Avec un Service Principal : az login --service-principal -u <appId> -p <secret> --tenant <tenantId>
  • Sur une VM/runbook avec Managed Identity : az login --identity

La fonction

Le module complet est dans Faitza_Function/Faitza_Modul/Faitza_Modul.ps1. Il contient deux fonctions : Get-Faitza_Modul_Headers (tokens) et Connect-Faitza_Modul_ExchangeOnline (Exchange Online).

function Get-Faitza_Modul_Headers {
    [CmdletBinding()]
    param (
        [string]$resource,
        [string]$contenttype = "json",
        [string]$tenant      = "faitza",
        [switch]$ForceNewToken
    )

    function Get-Faitza_Modul_Token ($resource, $tenant) {
        Write-Host "$($PSStyle.Background.Red)>>> Get-Faitza_Modul_Headers <<<$($PSStyle.Reset)"
        switch ($resource.ToLower()) {
            "graph"        { $resourceValue = "https://graph.microsoft.com" }
            "purview"      { $resourceValue = "b26e684c-5068-4120-a679-64a5d2c909d9" }
            "arm"          { $resourceValue = "https://management.azure.com" }
            "azdevops"     { $resourceValue = "499b84ac-1321-427f-aa17-267ca6975798" }
            "exchange"     { $resourceValue = "https://outlook.office365.com" }
            "sharepoint"   { $resourceValue = "https://$tenant.sharepoint.com" }
            "keyvault"     { $resourceValue = "https://vault.azure.net" }
            "storage"      { $resourceValue = "https://storage.azure.com" }
            "loganalytics" { $resourceValue = "https://api.loganalytics.io" }
            "security"     { $resourceValue = "https://api.security.microsoft.com" }
            "monitor"      { $resourceValue = "https://monitor.azure.com" }
            default {
                if ($resource -match "management.azure.com") { $resourceValue = "https://management.azure.com" }
                elseif ($resource.StartsWith("http") -or $resource.StartsWith("api://") -or $resource -match "^[a-f0-9]{8}-") {
                    $resourceValue = $resource
                }
                else { throw "Resource inconnue : $resource" }
            }
        }

        try {
            $azTokenJson    = az account get-access-token --resource $resourceValue --output json | ConvertFrom-Json
            $TokenBrut      = $azTokenJson.accessToken
            $unixEpoch      = [DateTime]::new(1970, 1, 1, 0, 0, 0, 0, [DateTimeKind]::Utc)
            $expiresOnUtc   = $unixEpoch.AddSeconds($azTokenJson.expires_on)
            $tzParis        = [System.TimeZoneInfo]::FindSystemTimeZoneById("Romance Standard Time")
            $expiresOnParis = [System.TimeZoneInfo]::ConvertTimeFromUtc($expiresOnUtc, $tzParis)
        } catch {
            throw "Erreur 'az account get-access-token' pour $resourceValue : $_"
        }

        try {
            $payload   = $TokenBrut.Split('.')[1]
            $payload   = $payload.Replace('-', '+').Replace('_', '/')
            while (($payload.Length % 4) -ne 0) { $payload += "=" }
            $TokenData = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) | ConvertFrom-Json

            Write-Host "--------------------------------------------------"
            if ($resource.ToLower() -eq "graph") {
                if ($TokenData.roles) { Write-Host "Permissions App (Roles)   : $($TokenData.roles -join ', ')" }
                if ($TokenData.scp)   { Write-Host "Permissions User (Scopes) : $($TokenData.scp)" }
            } else {
                Write-Host "Permissions App (Roles) : $resource"
                Write-Host "> $resourceValue"
            }
            Write-Host "Token expires (Paris)   : $($expiresOnParis.ToString('yyyy-MM-dd HH:mm:ss'))"
            if ("api" -notin $resourceValue) {
                Write-Host "App ID                  : $($TokenData.appid.Insert(1, [char]0x200B))"
            }
            Write-Host "--------------------------------------------------"
        } catch {
            Write-Host "Impossible de lire les permissions du token."
        } finally {
            Write-Host "$($PSStyle.Background.Red)<<< Get-Faitza_Modul_Headers >>>$($PSStyle.Reset)"
        }

        return @{
            resource    = $resourceValue
            AccessToken = $TokenBrut
            Expireson   = $expiresOnParis
        }
    }

    try {
        if ([string]::IsNullOrWhiteSpace($resource)) { throw "Paramètre -resource manquant." }
        $global:Faitza_token ??= @{}
        $resKey = $resource.ToLower()

        $needsNewToken = $ForceNewToken -or
                         -not $global:Faitza_token.ContainsKey($resKey) -or
                         $global:Faitza_token[$resKey].Expireson -lt (
                             [System.TimeZoneInfo]::ConvertTimeBySystemTimeZoneId(
                                 (Get-Date).ToUniversalTime(), "Romance Standard Time"
                             ).AddMinutes(5)
                         )

        if ($needsNewToken) {
            if ($ForceNewToken) {
                Write-Host "$($PSStyle.Foreground.BrightYellow)Nettoyage du cache Azure CLI...$($PSStyle.Reset)"
                @(
                    "C:\Users\svc_faitza_infra$\.Azure",
                    "C:\Users\svc_faitza_infra$\AppData\Local\.IdentityService"
                ) | Where-Object { Test-Path $_ } | ForEach-Object {
                    Remove-Item -Path $_ -Recurse -Force -ErrorAction SilentlyContinue
                }
            }
            $global:Faitza_token[$resKey] = Get-Faitza_Modul_Token -resource $resource -tenant $tenant
        }

        $headers = @{
            Authorization  = "Bearer $($global:Faitza_token[$resKey].AccessToken)"
            "Content-Type" = "application/$contenttype"
        }
        if ($resKey -eq "graph")   { $headers["ConsistencyLevel"] = "eventual" }
        if ($resKey -eq "purview") { $headers["X-AllowWithAADToken"] = "true" }

        return $headers

    } catch {
        throw "Impossible d'obtenir un token pour '$resource' : $_"
    }
}

Ressources supportées

Le paramètre -resource accepte des alias courts. La fonction les traduit vers la ressource OAuth2 attendue par Azure CLI. Le paramètre -tenant (défaut : "faitza") est utilisé uniquement pour l'alias sharepoint.

# Alias disponibles
Get-Faitza_Modul_Headers -resource "graph"         # → https://graph.microsoft.com
Get-Faitza_Modul_Headers -resource "arm"           # → https://management.azure.com
Get-Faitza_Modul_Headers -resource "azdevops"      # → 499b84ac-1321-427f-aa17-267ca6975798
Get-Faitza_Modul_Headers -resource "keyvault"      # → https://vault.azure.net
Get-Faitza_Modul_Headers -resource "exchange"      # → https://outlook.office365.com
Get-Faitza_Modul_Headers -resource "sharepoint"    # → https://<tenant>.sharepoint.com
Get-Faitza_Modul_Headers -resource "storage"       # → https://storage.azure.com
Get-Faitza_Modul_Headers -resource "loganalytics"  # → https://api.loganalytics.io
Get-Faitza_Modul_Headers -resource "security"      # → https://api.security.microsoft.com
Get-Faitza_Modul_Headers -resource "purview"       # → GUID Purview (+ header X-AllowWithAADToken)
Get-Faitza_Modul_Headers -resource "monitor"       # → https://monitor.azure.com

# Ressource personnalisée (URL, api://, ou GUID Entra)
Get-Faitza_Modul_Headers -resource "https://mon-api.example.com"
Get-Faitza_Modul_Headers -resource "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

Deux headers supplémentaires sont ajoutés automatiquement selon la ressource :

  • graphConsistencyLevel: eventual — requis pour les requêtes $count et $search sur Graph.
  • purviewX-AllowWithAADToken: true — requis dans certains scénarios eDiscovery.

Pour le Content-Type, la valeur par défaut est application/json. Certaines APIs (Azure DevOps PATCH, Graph batch) attendent un type différent :

# Créer / modifier un work item Azure DevOps — Content-Type json-patch+json obligatoire
$headers = Get-Faitza_Modul_Headers -resource "azdevops" -contenttype "json-patch+json"

Cache & ForceNewToken

Les tokens sont stockés dans $global:Faitza_token, un hashtable indexé par alias de ressource. À chaque appel, la fonction vérifie si le token en cache expire dans moins de 5 minutes. Si oui (ou s'il n'existe pas), elle en demande un nouveau.

Le cache est lié à la session PowerShell

$global:Faitza_token n'est pas persisté sur disque. À chaque ouverture de terminal, le cache repart à zéro. C'est voulu : un access token ne doit pas être stocké durablement.

Le switch -ForceNewToken force le renouvellement même si le token en cache est encore valide. Il efface aussi les dossiers .Azure et .IdentityService du compte de service pour repartir d'une identité propre — utile quand Azure CLI a mis en cache des credentials corrompus.

# Forcer le renouvellement du token et vider le cache Azure CLI
$headers = Get-Faitza_Modul_Headers -resource "graph" -ForceNewToken

Exchange Online — cas particulier

Exchange Online ne fonctionne pas comme les autres APIs Microsoft. Il n'existe pas d'endpoint REST pour les opérations d'administration Exchange (boîtes partagées, délégations, quotas…) : une connexion au module ExchangeOnlineManagement est toujours obligatoire.

Connect-Faitza_Modul_ExchangeOnline (définie dans le même fichier Faitza_Modul.ps1) gère cette connexion via un token Azure CLI ciblant la ressource outlook.office365.com — distincte de la ressource Graph.

Article dédié : Connexion Exchange Online — pourquoi pas l'API REST ?

Aller plus loin

Get-Faitza_Modul_Headers est la brique de base de tous les modules Faitza_*. Voici les articles qui l'utilisent en pratique :

Pièges & bonnes pratiques

Token Graph vs token ARM : ne pas confondre

Un token obtenu avec -resource "graph" ne peut pas être utilisé pour appeler management.azure.com — et vice versa. Chaque ressource OAuth2 génère un token distinct. La fonction gère ce cloisonnement par alias.

Diagnostic via le décodage JWT

À chaque nouveau token, la fonction décode le JWT et affiche les permissions (roles / scp), l'App ID et la date d'expiration. C'est utile pour diagnostiquer immédiatement une erreur 403 : si la permission manquante n'est pas dans la liste affichée, le problème vient du compte ou de l'application, pas du script.

Pas de az login dans les scripts automatisés

En production (runbook, tâche planifiée), la session Azure CLI doit être pré-authentifiée avant l'exécution du script — az login --identity sur une VM avec Managed Identity ou az login --service-principal dans un pipeline.

// Commentaires

Aucun commentaire pour l'instant.