# Ghi chú tránh lặp lại sai sót — Entra App + Microsoft Graph app-only cho SharePoint Ingestion ## 1) Tóm tắt sự cố đã gặp ### Hiện tượng - Lấy token **thành công** bằng `client_credentials`. - Token có các claim hợp lệ kiểu app-only: - `aud = https://graph.microsoft.com` - `idtyp = app` - Nhưng gọi các API Graph như: - `GET /sites/{hostname}` - `GET /sites/{hostname}:/{server-relative-path}` đều bị **401 Unauthorized**. ### Nguyên nhân gốc App Registration đã được cấp **Delegated permissions** thay vì **Application permissions**. Cụ thể lúc xảy ra lỗi, app đang có kiểu quyền như: - `Files.Read.All` → **Delegated** - `Sites.Read.All` → **Delegated** - `User.Read` → **Delegated** Trong khi luồng đang dùng là: - **OAuth 2.0 client credentials flow** - **app-only / daemon / service-to-service** - **không có user đăng nhập interactive** => Kết quả: token app-only **không có `roles`**, nên Microsoft Graph không chấp nhận token cho các API cần application permissions. --- ## 2) Bài học rút ra (phải kiểm tra trước khi test Graph) ### Quy tắc số 1 Nếu dùng **client credentials flow** thì **bắt buộc** phải cấp quyền ở dạng: - **Microsoft Graph → Application permissions** **Không dùng Delegated permissions** cho bài toán ingestion app-only. ### Quy tắc số 2 Sau khi sửa permission trong Entra App: 1. Bấm **Grant admin consent** 2. Lấy **token mới hoàn toàn** 3. Decode token và **kiểm tra `roles`** trước khi gọi Graph ### Quy tắc số 3 Nếu token app-only **không có `roles`**, thì **không test Graph tiếp**. Phải quay lại kiểm tra: - Permission type có phải **Application** không - Có phải **Microsoft Graph** không - Đã **Grant admin consent** chưa - Có cần đợi propagation vài phút không --- ## 3) Cấu hình đúng cho PoC hiện tại ### Tối thiểu cần có trong Entra App #### Microsoft Graph → Application permissions - `Sites.Read.All` - `Files.Read.All` ### Không cần cho app-only ingestion hiện tại - `User.Read` (Delegated) - Interactive login - Redirect URI cho user login --- ## 4) Checklist fail-fast trước khi test Graph ## Checklist nhanh - [ ] App dùng **client credentials flow** - [ ] API permissions nằm dưới **Microsoft Graph** - [ ] Permission type là **Application** - [ ] Có `Sites.Read.All` - [ ] Có `Files.Read.All` - [ ] Đã **Grant admin consent** - [ ] Đã lấy **token mới** sau khi sửa permission - [ ] Token decode ra có `roles` - [ ] `roles` chứa ít nhất: - `Sites.Read.All` - `Files.Read.All` Nếu **một trong các dòng trên fail**, không chạy tiếp phần test Graph site/drive. --- ## 5) Script test hoàn chỉnh (đã thêm guard để tránh lặp lỗi) > Script này làm đúng 4 việc: > 1. Lấy token mới > 2. Decode token > 3. **Kiểm tra `roles` trước** > 4. Chỉ khi roles đúng mới test Graph site endpoints ```powershell $ErrorActionPreference = "Stop" # ========================================== # Điền thông tin tại đây # ========================================== $TenantId = "" $ClientId = "" $ClientSecret = "" $SharePointHost = "285pdg.sharepoint.com" $SitePath = "/sites/poc_system" # ========================================== function Get-GraphToken { param( [string]$TenantId, [string]$ClientId, [string]$ClientSecret ) $tokenUrl = "https://login.microsoftonline.com/" + $TenantId + "/oauth2/v2.0/token" $body = @{ client_id = $ClientId client_secret = $ClientSecret scope = "https://graph.microsoft.com/.default" grant_type = "client_credentials" } return Invoke-RestMethod ` -Method Post ` -Uri $tokenUrl ` -ContentType "application/x-www-form-urlencoded" ` -Body $body } function Decode-JwtPayload { param([string]$Jwt) $parts = $Jwt.Split('.') if ($parts.Length -lt 2) { throw "JWT không hợp lệ." } $payload = $parts[1] switch ($payload.Length % 4) { 2 { $payload += '==' } 3 { $payload += '=' } } $payload = $payload.Replace('-', '+').Replace('_', '/') $bytes = [System.Convert]::FromBase64String($payload) $json = [System.Text.Encoding]::UTF8.GetString($bytes) return $json | ConvertFrom-Json } function Invoke-GraphGet { param( [string]$Url, [string]$AccessToken, [string]$Label ) Write-Host "" Write-Host ("=== " + $Label + " ===") -ForegroundColor Cyan Write-Host $Url -ForegroundColor DarkGray $headers = @{ Authorization = "Bearer " + $AccessToken } try { $resp = Invoke-WebRequest ` -Method Get ` -Uri $Url ` -Headers $headers ` -UseBasicParsing Write-Host "OK" -ForegroundColor Green Write-Host ("HTTP " + [int]$resp.StatusCode) -ForegroundColor Green if ($resp.Content) { $json = $resp.Content | ConvertFrom-Json $json | ConvertTo-Json -Depth 10 return $json } return $null } catch { Write-Host "FAILED" -ForegroundColor Red Write-Host $_.Exception.Message -ForegroundColor Red if ($_.Exception.Response -ne $null) { $resp = $_.Exception.Response Write-Host "" Write-Host "Status code:" -ForegroundColor Yellow try { Write-Host ([int]$resp.StatusCode) } catch { Write-Host "Không đọc được StatusCode" } Write-Host "" Write-Host "WWW-Authenticate:" -ForegroundColor Yellow try { Write-Host $resp.Headers["WWW-Authenticate"] } catch { Write-Host "Không có WWW-Authenticate header" } Write-Host "" Write-Host "Response body:" -ForegroundColor Yellow try { $stream = $resp.GetResponseStream() $reader = New-Object System.IO.StreamReader($stream) $bodyText = $reader.ReadToEnd() Write-Host $bodyText } catch { Write-Host "Không đọc được response detail." } } return $null } } # ======================================================= # 1) Lấy token mới # ======================================================= $tokenResponse = Get-GraphToken ` -TenantId $TenantId ` -ClientId $ClientId ` -ClientSecret $ClientSecret $accessToken = $tokenResponse.access_token Write-Host "TOKEN OK" -ForegroundColor Green Write-Host ("token_type : " + $tokenResponse.token_type) Write-Host ("expires_in : " + $tokenResponse.expires_in) # ======================================================= # 2) Decode token + kiểm tra claims quan trọng # ======================================================= $claims = Decode-JwtPayload -Jwt $accessToken Write-Host "" Write-Host "=== TOKEN CLAIMS ===" -ForegroundColor Cyan Write-Host ("aud : " + $claims.aud) Write-Host ("appid : " + $claims.appid) if ($claims.PSObject.Properties.Name -contains "idtyp") { Write-Host ("idtyp : " + $claims.idtyp) } $roles = @() if ($claims.PSObject.Properties.Name -contains "roles") { $roles = @($claims.roles) Write-Host "roles:" -ForegroundColor Yellow $roles | ForEach-Object { Write-Host (" - " + $_) } } else { Write-Host "roles: " -ForegroundColor Red } # ======================================================= # 3) Guard bắt buộc: nếu không có roles đúng thì stop luôn # ======================================================= $hasSitesRead = $roles -contains "Sites.Read.All" $hasFilesRead = $roles -contains "Files.Read.All" if (-not $hasSitesRead) { Write-Host "" Write-Host "STOP: Token chưa có role 'Sites.Read.All'." -ForegroundColor Red Write-Host "=> Kiểm tra lại Microsoft Graph -> Application permissions -> Sites.Read.All -> Grant admin consent." -ForegroundColor Yellow return } if (-not $hasFilesRead) { Write-Host "" Write-Host "WARNING: Token chưa có role 'Files.Read.All'." -ForegroundColor Yellow Write-Host "=> Site test có thể pass, nhưng drive/delta về sau sẽ fail." -ForegroundColor Yellow } # ======================================================= # 4) TEST 1 - GET /sites/{hostname} # ======================================================= $rootByHostUrl = "https://graph.microsoft.com/v1.0/sites/" + $SharePointHost + "?`$select=id,webUrl" $rootSite = Invoke-GraphGet ` -Url $rootByHostUrl ` -AccessToken $accessToken ` -Label "TEST 1 - GET /sites/{hostname}" # ======================================================= # 5) TEST 2 - GET /sites/{hostname}:/{server-relative-path} # ======================================================= $siteRef = $SharePointHost + ":" + $SitePath $resolveSiteUrl = "https://graph.microsoft.com/v1.0/sites/" + $siteRef + "?`$select=id,displayName,webUrl" $resolvedSite = Invoke-GraphGet ` -Url $resolveSiteUrl ` -AccessToken $accessToken ` -Label "TEST 2 - GET /sites/{hostname}:/{server-relative-path}" # ======================================================= # 6) Summary # ======================================================= Write-Host "" Write-Host "=== SUMMARY ===" -ForegroundColor Cyan if ($null -ne $rootSite) { Write-Host "TEST 1: PASS" -ForegroundColor Green } else { Write-Host "TEST 1: FAIL" -ForegroundColor Red } if ($null -ne $resolvedSite) { Write-Host "TEST 2: PASS" -ForegroundColor Green } else { Write-Host "TEST 2: FAIL" -ForegroundColor Red } ``` --- ## 6) Kỳ vọng đúng sau khi sửa quyền ### Token claim đúng ```text aud : https://graph.microsoft.com appid : idtyp : app roles: - Sites.Read.All - Files.Read.All ``` ### Test site đúng - `TEST 1 - GET /sites/{hostname}` → PASS - `TEST 2 - GET /sites/{hostname}:/{server-relative-path}` → PASS --- ## 7) Ghi chú vận hành nội bộ ### Khi nào được đi tiếp sang ingestion skeleton? Chỉ khi đạt đủ 3 điều kiện: 1. Token có `roles` 2. `TEST 1` pass 3. `TEST 2` pass ### Nếu token vẫn không có `roles` - Kiểm tra lại **Type** của permission có phải **Application** không - Kiểm tra lại permission có nằm dưới **Microsoft Graph** không - Kiểm tra đã bấm **Grant admin consent** chưa - Đợi vài phút rồi lấy **token mới** lần nữa --- ## 8) Tên lỗi nội bộ để nhớ > **Sai lầm đã gặp:** > “Dùng `client_credentials` nhưng lại cấp **Delegated permissions** trong App Registration.” ### Cách nhớ nhanh - **App-only** → **Application permissions** - **Có user login** → **Delegated permissions**