如何使用 Microsoft Entra ID 應用程式與憑證存取 SharePoint 與 Teams 的指定資料夾

最近有個需求,要讓一台電腦上的排程程式,每天自動把產出的檔案上傳到公司 Teams 頻道的「檔案」裡面,讓團隊成員可以直接在 Teams 取用。這種每天定時執行的工作,當然不能靠人工登入,也不適合把某個人的帳號密碼寫進設定檔。

我一開始用第三方工具,以自己的帳號登入 SharePoint 測試,結果馬上被公司租用戶的政策擋下來:未經核准的第三方應用程式,不能代替使用者存取組織的資料。這其實是正確的安全設定,所以我決定走正規的路線:在 Microsoft Entra ID 註冊一個應用程式,讓程式用「憑證」證明自己的身分,並且把權限縮小到只能寫入兩個指定的資料夾。

這篇文章就來整理完整的設定流程,從產生憑證與私密金鑰開始,一路到設定權限、取得 Access Token、上傳檔案,以及驗證權限真的只到資料夾。過程中我原本以為 Sites.Selected 就能做到資料夾層級的授權,查了文件才發現不是這樣,這也是這次最值得記錄的地方。

背景應用程式透過憑證與金鑰驗證身分,經過雲端安全閘道將檔案寫入 SharePoint 與 Teams 資料夾

本文的操作環境為 macOS 26.6、OpenSSL 4.0.3、PowerShell 7.6 與 Microsoft.Graph.Authentication 2.37.0 模組,API 使用 Microsoft Graph v1.0,測試日期為 2026 年 10 月。文中的租用戶、網站、資料夾、憑證與各種 ID 都是示意資料,截圖中的帳號、應用程式名稱、識別碼與憑證資訊也已經遮蔽,使用時請換成自己的環境。

先搞懂幾個基本觀念

這個流程牽涉到 Teams、SharePoint、Microsoft Entra ID、Microsoft Graph 與憑證,名詞非常多。先把它們之間的關係弄清楚,後面的步驟就不容易做錯。

Teams 的檔案其實存放在 SharePoint

每建立一個 Team,系統就會自動建立一個對應的 SharePoint 網站,微軟稱為「父網站」(Parent site)。Team 裡所有的標準頻道都共用這個網站的預設文件庫(Shared Documents,中文介面顯示為「文件」),每個頻道對應文件庫中的一個資料夾。每個 Team 都有一個名為 General 的標準頻道,也就是中文介面上的「一般」頻道。

私人頻道(Private channel)與共用頻道(Shared channel)比較特別,它們各自擁有獨立的 SharePoint 網站,不在父網站的文件庫裡面。詳細說明可以參考微軟的 Teams 與 SharePoint 整合 文件。

所以,當我在 Teams「一般」頻道的「檔案」分頁看到一個 Reports 資料夾,它在 SharePoint 裡的實際位置其實是:

/sites/demo-team/Shared Documents/General/Reports

要確認實際路徑,最簡單的方法是在 Teams 的「檔案」分頁選擇「在 SharePoint 中開啟」,然後看瀏覽器網址列:

https://contoso.sharepoint.com/sites/demo-team/Shared%20Documents/Forms/AllItems.aspx?id=%2Fsites%2Fdemo-team%2FShared%20Documents%2FGeneral%2FReports%2FDaily&viewid=...

網址中的 id 參數經過 URL 編碼,解碼之後就是 /sites/demo-team/Shared Documents/General/Reports/Daily。從這裡可以拆出三個後面會用到的資訊:

項目 本文的示意值
網站路徑 /sites/demo-team
文件庫 Shared Documents(預設文件庫)
文件庫內的資料夾路徑 General/Reports/Daily

💡 我這次就差點在這裡寫錯路徑。在 Teams 裡面看到的是 Reports 資料夾,但在文件庫裡,它的上面還有一層 General。

應用程式註冊與服務主體

在 Entra ID 的「應用程式註冊」(App registrations)建立應用程式時,其實會產生兩個物件:

  • 應用程式物件(Application object):定義這個應用程式本身,包含它的名稱、憑證,以及它要求哪些 API 權限。
  • 服務主體(Service principal):這個應用程式在租用戶裡的「身分」。權限真正授予的對象是服務主體,在 Azure Portal 的「企業應用程式」(Enterprise applications)看到的就是它。

透過 Azure Portal 註冊應用程式時,服務主體會自動建立。這兩個物件的關係,我之前寫過一篇認識 Microsoft Entra ID 中的企業應用程式與服務主體物件,有興趣可以參考。

這篇文章會用到的識別碼,最重要的是 Application (client) ID,後文簡稱 Client ID。取得 Token 與授權資料夾的時候,用的都是 Client ID,不是物件識別碼(Object ID)。

委派權限與應用程式權限

Microsoft Graph 的權限分成兩種:

類型 執行方式 實際能存取的範圍
委派權限(Delegated permissions) 有使用者登入,應用程式代替該使用者存取 應用程式權限與使用者權限的交集
應用程式權限(Application permissions) 沒有使用者登入,應用程式以自己的身分存取 由授予應用程式的權限決定

每天自動執行的排程工作沒有人在電腦前登入,所以這次要使用的是應用程式權限。應用程式權限一律需要管理員同意(Admin consent),一般使用者無法自行同意。

為什麼用憑證,而不用 Client Secret

應用程式在沒有使用者的情況下取得 Token,使用的是 OAuth 2.0 的 Client Credentials 流程。在這個流程裡,應用程式必須向 Entra ID 證明「我就是這個應用程式」,方式有兩種:

方式 運作原理 風險
Client Secret 應用程式把一串密碼直接送給 Entra ID 比對 密碼是明文字串,需要在 Entra ID 與執行環境之間傳遞與保存,容易被誤存進設定檔或版本控制
憑證 應用程式用私密金鑰簽署一份 JWT,Entra ID 用事先上傳的公開金鑰驗證簽章 私密金鑰從頭到尾不離開執行環境,傳送出去的只有簽章

使用憑證還有一個實務上的好處:如果應用程式註冊是由公司 IT 負責,你只需要把公開憑證交給他們上傳,私密金鑰完全不必經手第二個人。微軟的文件也明確建議應用程式使用憑證,而不是 Client Secret。

另外,從 Azure Portal 建立的 Client Secret 最長只能設定兩年,這部分可以參考我之前寫的如何在 Microsoft Entra ID 中建立一個 100 年都不會過期的 Client Secret 金鑰。

Selected 權限:先同意,再指定資源

Sites.ReadWrite.All 這類權限一經同意,應用程式就能存取整個租用戶的所有網站,對一個只需要上傳檔案的排程程式來說,權限實在太大了。微軟為此提供了一系列 Selected 權限:

權限 最小可以授權到
Sites.Selected 整個網站(Site collection)
Lists.SelectedOperations.Selected 單一清單或文件庫
ListItems.SelectedOperations.Selected 清單項目、資料夾或檔案
Files.SelectedOperations.Selected 文件庫中的資料夾或檔案

Selected 權限的設計是「三個條件都成立,才有存取權」:

  1. 應用程式在 Entra ID 取得對應的 Selected 權限,並完成管理員同意。
  2. 透過 Graph API 對特定資源(網站、清單、資料夾或檔案)授權給這個應用程式,並指定角色。
  3. 應用程式取得的 Token 裡面,確實包含這個權限。

少了任何一個條件,應用程式就沒有存取權。換句話說,完成管理員同意之後,應用程式其實還碰不到任何檔案,必須再逐一指定它可以存取的資源。

可以指定的角色有四種:read、write、owner 與 fullcontrol。只需要上傳檔案的話,給 write 就夠了。

ListItems.SelectedOperations.Selected 與 Files.SelectedOperations.Selected 的差別在於:在 SharePoint 裡所有檔案都是清單項目,但清單項目不一定是檔案。Files.SelectedOperations.Selected 只能操作文件庫裡的檔案,範圍比較小。這次的目標是文件庫裡的兩個資料夾,所以我選擇 Files.SelectedOperations.Selected。完整說明可以參考 Selected 權限概觀。

整體流程

整個設定流程如下:

產生憑證與私密金鑰
→ 在 Entra ID 註冊應用程式,上傳公開憑證
→ 加入 Files.SelectedOperations.Selected 權限,由管理員同意
→ 透過 Graph API 對指定資料夾授權 write
→ 程式用私密金鑰簽署 JWT,取得 Access Token
→ 呼叫 Graph API 上傳檔案

每個步驟需要的角色不同。如果你不是租用戶的管理員,可以依照這張表找對應的人協助:

步驟 需要的角色
產生憑證 不需要任何 Entra ID 角色,在執行程式的電腦上進行
註冊應用程式、上傳憑證 應用程式系統管理員(Application Administrator)以上
管理員同意 特殊權限角色管理員(Privileged Role Administrator)或全域管理員
授權資料夾 能管理該資料夾權限的帳號,例如網站擁有者

步驟一:產生憑證與私密金鑰

公開金鑰、私密金鑰與憑證

先快速說明這三個名詞:

  • 私密金鑰(Private key):用來產生簽章,必須妥善保管,只放在執行程式的電腦上。
  • 公開金鑰(Public key):用來驗證簽章,可以公開給任何人。
  • X.509 憑證:把公開金鑰、主體名稱(Subject)、有效期限等資訊包在一起,再由簽發者簽章的檔案。

一般網站使用的 TLS 憑證,需要由受信任的憑證授權單位(CA)簽發,瀏覽器才會信任它。不過,Entra ID 驗證應用程式憑證時,信任的來源是你事先上傳的公開金鑰,所以使用自己簽發給自己的自簽憑證就可以了。微軟的文件把自簽憑證定位在測試用途,正式環境是否要改用 CA 簽發的憑證,可以依照公司的憑證管理政策決定。

依照微軟的文件,Entra ID 目前只支援 RSA 金鑰,建議使用 2048 位元的金鑰長度,憑證使用 SHA-256 簽章(也支援 SHA-384 與 SHA-512)。

使用 OpenSSL 產生憑證

我在 macOS 上使用 OpenSSL 產生憑證。先建立一個只有自己能讀取的目錄:

umask 077
mkdir -p certs
cd certs

umask 077 讓之後建立的檔案預設只有擁有者能讀寫,避免私密金鑰被同一台電腦的其他帳號讀取。

接著建立一份 OpenSSL 設定檔 openssl.cnf:

[req]
distinguished_name = dn
x509_extensions    = v3_ext
prompt             = no

[dn]
CN = TeamsFiles-Uploader
O  = Duotify

[v3_ext]
basicConstraints     = critical,CA:FALSE
keyUsage             = critical,digitalSignature
extendedKeyUsage     = clientAuth
subjectKeyIdentifier = hash

各設定的意義如下:

設定 說明
prompt = no 直接使用設定檔的主體名稱,不要互動詢問
CN、O 憑證的主體名稱,會顯示在 Entra ID 的憑證清單,取個看得出用途的名字
basicConstraints = CA:FALSE 標示這不是 CA 憑證,不能用來簽發其他憑證
keyUsage = digitalSignature 這把金鑰的用途是數位簽章
extendedKeyUsage = clientAuth 這張憑證用於「用戶端驗證」
subjectKeyIdentifier = hash 加上金鑰識別碼,方便工具辨識

之所以要自己寫設定檔,是因為 openssl req -x509 預設會套用系統設定檔裡的 CA 延伸欄位,產生的憑證會被標示為 CA。這些延伸欄位只是用來標示用途,但把它們設定正確是好習慣。

然後產生 RSA 2048 私密金鑰與自簽憑證,有效期限設定為兩年:

openssl req -x509 -newkey rsa:2048 -sha256 -days 730 -noenc \
  -config openssl.cnf \
  -keyout TeamsFiles-Uploader.key.pem \
  -out TeamsFiles-Uploader.crt.pem

-noenc 代表不加密 PEM 格式的私密金鑰,也就是舊版 OpenSSL 的 -nodes。等一下會把私密金鑰打包進有密碼保護的 PFX,這份未加密的 PEM 只是過渡檔案。

Entra ID 接受 .cer、.pem 與 .crt 格式的公開憑證,我習慣另外轉一份 DER 格式的 .cer 交給管理員上傳:

openssl x509 -in TeamsFiles-Uploader.crt.pem \
  -outform DER -out TeamsFiles-Uploader.cer

最後把憑證與私密金鑰打包成 PFX,並用一組隨機密碼保護。如果程式要在 Windows 上執行,這個 PFX 就是要匯入 Windows 憑證存放區的檔案:

openssl rand -base64 24 | tr -d '\n' > TeamsFiles-Uploader.pfx-password.txt

openssl pkcs12 -export -name "TeamsFiles-Uploader" \
  -inkey TeamsFiles-Uploader.key.pem \
  -in TeamsFiles-Uploader.crt.pem \
  -out TeamsFiles-Uploader.pfx \
  -passout file:TeamsFiles-Uploader.pfx-password.txt

-passout file: 讓 OpenSSL 從檔案讀取密碼,密碼不會出現在命令列參數與 Shell 歷史紀錄裡。

確認產生的憑證

先檢查憑證的內容:

openssl x509 -in TeamsFiles-Uploader.crt.pem -noout \
  -subject -startdate -enddate \
  -ext keyUsage,extendedKeyUsage,basicConstraints

輸出結果如下:

subject=CN=TeamsFiles-Uploader, O=Duotify
notBefore=Oct  6 16:46:30 2026 GMT
notAfter=Oct  5 16:46:30 2028 GMT
X509v3 Basic Constraints: critical
    CA:FALSE
X509v3 Key Usage: critical
    Digital Signature
X509v3 Extended Key Usage:
    TLS Web Client Authentication

再確認私密金鑰與憑證確實是同一對。做法是從兩邊各取出公開金鑰,比對是否相同:

diff <(openssl x509 -in TeamsFiles-Uploader.crt.pem -noout -pubkey) \
     <(openssl pkey -in TeamsFiles-Uploader.key.pem -pubout) \
  && echo "key matches cert"

最後確認 PFX 可以用密碼開啟:

openssl pkcs12 -in TeamsFiles-Uploader.pfx -noenc -info \
  -passin file:TeamsFiles-Uploader.pfx-password.txt > /dev/null \
  && echo "PFX OK"

產生的檔案整理

檔案 內容 含私密金鑰 用途
*.key.pem 私密金鑰,未加密 是 過渡檔案,部署完成後刪除
*.crt.pem 公開憑證,PEM 格式 否 備用
*.cer 公開憑證,DER 格式 否 上傳到 Entra ID
*.pfx 憑證加私密金鑰,有密碼保護 是 匯入執行程式的電腦
*.pfx-password.txt PFX 的密碼 — 不要和 PFX 放在一起傳送

只有 .cer 可以交給別人,其他含有私密金鑰或密碼的檔案,都只應該存在執行程式的電腦上。

PFX 的加密格式

OpenSSL 3 之後,pkcs12 -export 預設使用 AES-256-CBC 搭配 PBKDF2 加密 PFX,MAC 使用 SHA-256:

openssl pkcs12 -in TeamsFiles-Uploader.pfx -noenc -info \
  -passin file:TeamsFiles-Uploader.pfx-password.txt 2>&1 \
  | grep -E 'MAC:|PBES2'
MAC: sha256, Iteration 2048
PKCS7 Encrypted data: PBES2, PBKDF2, AES-256-CBC, Iteration 2048, PRF hmacWithSHA256
Shrouded Keybag: PBES2, PBKDF2, AES-256-CBC, Iteration 2048, PRF hmacWithSHA256

如果要匯入的 Windows 版本比較舊(例如 Windows Server 2016),可能無法讀取 AES-256 加密的 PFX。這時可以改用 3DES 匯出:

openssl pkcs12 -export -name "TeamsFiles-Uploader" \
  -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 \
  -inkey TeamsFiles-Uploader.key.pem \
  -in TeamsFiles-Uploader.crt.pem \
  -out TeamsFiles-Uploader-3des.pfx \
  -passout file:TeamsFiles-Uploader.pfx-password.txt

OpenSSL 也有一個 -legacy 參數,但我實際檢查後發現,它對憑證部分使用的是 40 位元的 RC2 加密,強度比 3DES 還低,所以我比較建議用上面明確指定 3DES 的寫法。這個 3DES 指令我有在 OpenSSL 4.0.3 執行並確認輸出格式,但沒有在舊版 Windows 實際測試匯入。

計算憑證指紋(Thumbprint)

憑證指紋是憑證 DER 編碼內容的雜湊值,用來識別「是哪一張憑證」。Azure Portal 與 Windows 憑證存放區顯示的 Thumbprint,都是 SHA-1 雜湊:

openssl x509 -in TeamsFiles-Uploader.crt.pem -noout -fingerprint -sha1 \
  | cut -d= -f2 | tr -d ':'
1B6C1B10CB7E91C8C892184EB9FD5AB95E0E354E

OpenSSL 預設會用冒號分隔每個位元組,後面的 tr -d ':' 把冒號移除,就會跟 Azure Portal 顯示的格式一致。

等一下簽署 JWT 時,還會用到另一種格式:SHA-256 雜湊經過 Base64url 編碼後的值,放在 JWT Header 的 x5t#S256 欄位,讓 Entra ID 知道要用哪一張憑證驗證簽章:

openssl x509 -in TeamsFiles-Uploader.crt.pem -outform DER \
  | openssl dgst -sha256 -binary \
  | openssl base64 -A | tr '+/' '-_' | tr -d '='
zvgBojxgpHCWbxNjjtKOADUD_dThSH3y1-aFHJEAg04

Base64url 是 Base64 的網址安全版本:把 + 換成 -、/ 換成 _,並且去掉結尾的 =。JWT 的每個部分都使用這種編碼。

在 Windows 上產生憑證

如果你是在 Windows 上作業,也可以使用 PowerShell 內建的 New-SelfSignedCertificate 產生憑證。以下指令取自微軟的文件,我加上了 -NotAfter 把有效期限設為兩年(預設是一年):

$certname = "TeamsFiles-Uploader"
$cert = New-SelfSignedCertificate -Subject "CN=$certname" `
    -CertStoreLocation "Cert:\CurrentUser\My" `
    -KeyExportPolicy Exportable -KeySpec Signature `
    -KeyLength 2048 -KeyAlgorithm RSA -HashAlgorithm SHA256 `
    -NotAfter (Get-Date).AddYears(2)

# 匯出公開憑證,上傳到 Entra ID 用
Export-Certificate -Cert $cert -FilePath ".\$certname.cer"

# 匯出含私密金鑰的 PFX(需要搬到其他電腦執行時才需要)
$mypwd = Read-Host -AsSecureString -Prompt "PFX 密碼"
Export-PfxCertificate -Cert $cert -FilePath ".\$certname.pfx" -Password $mypwd

# 憑證的 Thumbprint
$cert.Thumbprint

這種做法產生的私密金鑰會直接存在 Windows 憑證存放區,如果程式就在同一台電腦執行,連 PFX 都不需要匯出。本文實際測試使用的是 OpenSSL,Windows 這段指令沒有實測。

步驟二:在 Entra ID 註冊應用程式

到 Azure Portal 的 Microsoft Entra ID → App registrations,選擇 New registration:

在 Azure Portal 註冊新的應用程式,名稱填入 TeamsFiles-Uploader,帳戶類型選擇 Single tenant only

  • Name:應用程式名稱,之後可以修改。
  • Supported account types:選擇 Single tenant only,只有自己的租用戶可以使用這個應用程式。
  • Redirect URI:留空。Client Credentials 流程不需要瀏覽器登入,所以不需要重新導向網址。

按下 Register 之後,就會進入應用程式的 Overview 頁面:

應用程式的 Overview 頁面,顯示 Application (client) ID、Object ID、Directory (tenant) ID 等資訊

這個頁面有三個看起來很像的 GUID,很容易搞混:

欄位 說明 本文用途
Application (client) ID 應用程式的識別碼,也就是 Client ID 取得 Token、授權資料夾
Object ID 應用程式物件在目錄裡的識別碼 本文用不到
Directory (tenant) ID 租用戶的識別碼,也就是 Tenant ID 取得 Token

右邊的 Managed application in local directory 連到的就是前面提到的服務主體,也就是這個應用程式在「企業應用程式」裡的那一筆資料。

上傳公開憑證

進入 Certificates & secrets → Certificates,選擇 Upload certificate,選擇剛剛產生的 .cer 檔案:

Upload certificate 面板,可以上傳 .cer、.pem 或 .crt 格式的公開憑證

上傳完成後,確認清單中顯示的 Thumbprint 與自己計算的 SHA-1 指紋相同:

Certificates 分頁顯示已上傳的憑證,包含 Thumbprint、Description、Start date 與 Expires

這裡不要建立 Client Secret。回到 Overview 頁面,Client credentials 欄位應該顯示 1 certificate, 0 secret。

💡 這裡上傳的 .cer 只有公開金鑰。如果幫你設定 Entra ID 的是公司 IT,你只要把 .cer 交給他們就好,同時告訴他們憑證的指紋,請他們上傳後核對。

步驟三:加入 API 權限並授與管理員同意

進入 API permissions,選擇 Add a permission → Microsoft Graph → Application permissions,搜尋 Files.SelectedOperations,勾選 Files.SelectedOperations.Selected:

Request API permissions 面板中,選擇 Application permissions 並勾選 Files.SelectedOperations.Selected

注意要選擇 Application permissions,不是 Delegated permissions。

新註冊的應用程式預設會帶一個 User.Read 委派權限,這個應用程式用不到,可以把它移除。最後按下 Grant admin consent for 〈組織名稱〉,確認狀態變成綠色勾號:

API permissions 頁面只有 Files.SelectedOperations.Selected 一個應用程式權限,狀態為 Granted

授與管理員同意這個步驟,需要特殊權限角色管理員(Privileged Role Administrator)或全域管理員才能操作。依據微軟的文件,雲端應用程式管理員(Cloud Application Administrator)與應用程式管理員(Application Administrator)可以同意大部分的權限,唯獨 Microsoft Graph 的應用程式權限例外。如果按鈕是灰色的,通常就是角色不夠。

再提醒一次:做到這一步,應用程式仍然沒有任何檔案的存取權。接下來還要指定它可以存取哪些資料夾。

步驟四:授權應用程式寫入指定資料夾

對資料夾授權這個動作,在 Entra ID 與 SharePoint 都沒有操作介面,必須呼叫 Microsoft Graph API:

POST https://graph.microsoft.com/v1.0/drives/{drive-id}/items/{folder-item-id}/permissions
Content-Type: application/json

{
  "roles": ["write"],
  "grantedToV2": {
    "application": { "id": "{Client ID}" }
  }
}

這裡有幾個重點:

  • URL 裡需要的是 Drive ID 與資料夾的 Item ID,不是資料夾路徑。Drive 是 Microsoft Graph 對文件庫的稱呼。
  • Request body 只接受 grantedToV2 這個屬性,application.id 填入 Client ID。
  • 依據 Selected 權限的文件,呼叫這支 API 需要 Sites.FullControl.All 權限。以委派方式呼叫時,登入的使用者本身也必須有權限管理這個資料夾,例如網站擁有者。

使用 Microsoft Graph PowerShell 授權

我寫了一支 PowerShell 腳本,自動查出 Site ID、Drive ID 與資料夾的 Item ID,再完成授權。只需要安裝 Microsoft.Graph.Authentication 模組:

Install-Module Microsoft.Graph.Authentication -Scope CurrentUser

腳本 grant-folder-access.ps1 如下,把開頭的變數換成自己的環境:

$TenantId = 'aaaabbbb-0000-cccc-1111-dddd2222eeee'
$ClientId = '00001111-aaaa-2222-bbbb-3333cccc4444'
$SiteHost = 'contoso.sharepoint.com'
$SitePath = '/sites/demo-team'
$Folders  = 'General/Reports/Daily', 'General/Reports/Weekly'   # 相對於預設文件庫
$Graph    = 'https://graph.microsoft.com/v1.0'

$ErrorActionPreference = 'Stop'
Connect-MgGraph -TenantId $TenantId -Scopes 'Sites.FullControl.All' -NoWelcome

try {
    $site  = Invoke-MgGraphRequest -Method GET -Uri "$Graph/sites/${SiteHost}:${SitePath}"
    $drive = Invoke-MgGraphRequest -Method GET -Uri "$Graph/sites/$($site.id)/drive"
    Write-Host "網站:$($site.webUrl)"
    Write-Host "文件庫:$($drive.webUrl)"

    $body = @{
        roles       = @('write')
        grantedToV2 = @{ application = @{ id = $ClientId } }
    } | ConvertTo-Json -Depth 5

    $result = [ordered]@{
        'Tenant ID' = $TenantId
        'Client ID' = $ClientId
        'Site ID'   = $site.id
        'Drive ID'  = $drive.id
    }

    foreach ($path in $Folders) {
        $folder   = Invoke-MgGraphRequest -Method GET -Uri "$Graph/drives/$($drive.id)/root:/$path"
        $permsUri = "$Graph/drives/$($drive.id)/items/$($folder.id)/permissions"

        # 已授權過就略過,可以安全地重複執行
        $existing = (Invoke-MgGraphRequest -Method GET -Uri $permsUri).value |
            Where-Object { $_.grantedToV2.application.id -eq $ClientId }
        if ($existing) {
            Write-Host "$path 先前已授權:$($existing.roles -join ', ')"
        } else {
            $granted = Invoke-MgGraphRequest -Method POST -Uri $permsUri -Body $body -ContentType 'application/json'
            Write-Host "$path 已授權:$($granted.roles -join ', ')"
        }
        $result["Folder Item ID ($path)"] = $folder.id
    }

    $result.GetEnumerator() | Format-Table Name, Value -AutoSize -Wrap
}
finally {
    Disconnect-MgGraph | Out-Null
}

腳本做了這幾件事:

  1. 用 /sites/{hostname}:{路徑} 的格式,從網站網址查出 Site ID。
  2. 用 /sites/{site-id}/drive 取得網站的預設文件庫。如果資料夾不在預設文件庫,要改用 /sites/{site-id}/drives 列出所有文件庫,再挑出正確的那一個。
  3. 用 /drives/{drive-id}/root:/{路徑} 的格式,從資料夾路徑查出 Item ID。
  4. 先查詢資料夾現有的權限,沒有授權過才送出 POST,所以重複執行也不會出問題。
  5. 最後印出所有識別碼,後面寫上傳程式時會用到。

PowerShell 字串裡寫成 ${SiteHost}:${SitePath},是因為 $SiteHost: 這種寫法會被 PowerShell 當成「磁碟機範圍」的變數語法,必須用大括號把變數名稱包起來。

執行腳本時會開啟瀏覽器登入,並要求同意 Sites.FullControl.All 委派權限。如果你是管理員,同意畫面上會有一個「代表您的組織同意」(Consent on behalf of your organization)的核取方塊,不要勾選。這個權限只是這次授權操作要用,只需要同意給自己。

執行結果如下(識別碼為示意資料):

網站:https://contoso.sharepoint.com/sites/demo-team
文件庫:https://contoso.sharepoint.com/sites/demo-team/Shared%20Documents
General/Reports/Daily 已授權:write
General/Reports/Weekly 已授權:write

Name                                    Value
----                                    -----
Tenant ID                               aaaabbbb-0000-cccc-1111-dddd2222eeee
Client ID                               00001111-aaaa-2222-bbbb-3333cccc4444
Site ID                                 contoso.sharepoint.com,11112222-bbbb-3333-cccc-4444dddd5555,22223333-cccc-4444-dddd-5555eeee6666
Drive ID                                b!ExAmPlEdRiVeIdExAmPlEdRiVeIdExAmPlEdRiVeIdExAmPlEdRiVeId
Folder Item ID (General/Reports/Daily)  01EXAMPLEFOLDERIDDAILY000000000000
Folder Item ID (General/Reports/Weekly) 01EXAMPLEFOLDERIDWEEKLY00000000000

使用 Graph Explorer 授權

如果不方便執行 PowerShell,也可以在 Graph Explorer 手動送出相同的請求。登入後先在 Modify permissions 同意 Sites.FullControl.All,再依序執行:

# 方法 URL 從回應取得
1 GET /v1.0/sites/contoso.sharepoint.com:/sites/demo-team id 就是 Site ID
2 GET /v1.0/sites/{Site ID}/drive id 就是 Drive ID
3 GET /v1.0/drives/{Drive ID}/root:/General/Reports/Daily id 就是資料夾的 Item ID
4 POST /v1.0/drives/{Drive ID}/items/{Item ID}/permissions 成功會回傳 201 Created

授權之後的副作用

依據微軟的文件,對清單、資料夾或檔案授權應用程式,會讓該資源中斷權限繼承,變成擁有獨立權限的項目。授權完成後,建議確認原本的使用者仍然可以正常開啟這兩個資料夾。之後如果調整上層資料夾的權限,也要記得這兩個子資料夾是獨立設定的,不會自動跟著變更。

對整個網站授權則不會有這個問題,因為網站本來就是權限繼承的最上層。

步驟五:用憑證取得 Access Token

設定完成後,程式就可以用憑證向 Entra ID 取得 Access Token 了。

Client Assertion 是什麼

在 Client Credentials 流程中,使用憑證的應用程式不會送出密碼,而是送出一份自己簽署的 JWT,微軟稱為 Client Assertion。這份 JWT 由三個部分組成,彼此以 . 分隔:

Base64url(Header).Base64url(Claims).Base64url(簽章)

依據微軟的憑證認證文件,Header 的內容如下:

欄位 值 說明
alg PS256 簽章演算法,使用 RSA-PSS 搭配 SHA-256
typ JWT 固定值
x5t#S256 憑證 SHA-256 指紋的 Base64url 編碼 告訴 Entra ID 要用哪一張憑證驗證

Claims 的內容如下:

Claim 值 說明
aud https://login.microsoftonline.com/{Tenant ID}/oauth2/v2.0/token 這份 JWT 要交給誰,也就是 Token 端點
iss Client ID 簽發者,也就是應用程式自己
sub Client ID 主體,與 iss 相同
jti 隨機 GUID 這份 JWT 的唯一識別碼
nbf、iat 目前時間(Unix 秒數) 生效時間與簽發時間
exp 目前時間加 5 到 10 分鐘 到期時間,微軟建議不要超過 nbf 之後 10 分鐘

微軟文件特別提到簽章要使用 PSS padding,這也是 alg 要指定為 PS256,而不是常見的 RS256 的原因。

用 OpenSSL 手動簽署 JWT

正式的程式碼,建議使用 MSAL 這類官方程式庫處理,例如 MSAL.NET 的 .WithCertificate()。不過,為了真正理解整個流程,我先用 Bash 搭配 OpenSSL 手動做一次:

#!/usr/bin/env bash
set -euo pipefail

TENANT='aaaabbbb-0000-cccc-1111-dddd2222eeee'
CLIENT='00001111-aaaa-2222-bbbb-3333cccc4444'
KEY='certs/TeamsFiles-Uploader.key.pem'
CRT='certs/TeamsFiles-Uploader.crt.pem'

b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }

# 1. Header:PS256 加上憑證的 SHA-256 指紋
x5t256=$(openssl x509 -in "$CRT" -outform DER | openssl dgst -sha256 -binary | b64url)
header=$(printf '{"alg":"PS256","typ":"JWT","x5t#S256":"%s"}' "$x5t256" | b64url)

# 2. Claims:有效期限 10 分鐘
now=$(date +%s)
claims=$(printf '{"aud":"https://login.microsoftonline.com/%s/oauth2/v2.0/token","iss":"%s","sub":"%s","jti":"%s","nbf":%d,"iat":%d,"exp":%d}' \
  "$TENANT" "$CLIENT" "$CLIENT" "$(uuidgen)" "$now" "$now" $((now + 600)) | b64url)

# 3. 簽章:RSA-PSS,salt 長度等於雜湊長度(32 bytes)
signature=$(printf '%s.%s' "$header" "$claims" \
  | openssl dgst -sha256 -sign "$KEY" \
      -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:digest -binary \
  | b64url)

# 4. 用 Client Assertion 換取 Access Token
TOKEN=$(curl -sS -X POST "https://login.microsoftonline.com/$TENANT/oauth2/v2.0/token" \
  --data-urlencode "client_id=$CLIENT" \
  --data-urlencode "scope=https://graph.microsoft.com/.default" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
  --data-urlencode "client_assertion=$header.$claims.$signature" \
  | jq -r '.access_token')

幾個需要說明的地方:

  • rsa_pss_saltlen:digest 讓 PSS 的 salt 長度等於雜湊長度,SHA-256 就是 32 bytes,這是 JWT 規格對 PS256 的要求。
  • scope 要填 https://graph.microsoft.com/.default。應用程式權限不能在請求時挑選個別權限,.default 代表「這個應用程式已經被授予的所有權限」。
  • client_assertion_type 是固定值,告訴 Entra ID 這次用的是 JWT 形式的憑證認證。

取得 Token 之後,可以解開它的 Claims 確認內容。Access Token 也是 JWT,中間那段就是 Claims:

b64url_decode() {
  local s; s=$(tr '_-' '/+')
  while (( ${#s} % 4 )); do s+='='; done
  printf '%s' "$s" | openssl base64 -d -A
}

cut -d. -f2 <<<"$TOKEN" | b64url_decode | jq -c '{aud, tid, appid, roles}'
{"aud":"https://graph.microsoft.com","tid":"aaaabbbb-0000-cccc-1111-dddd2222eeee","appid":"00001111-aaaa-2222-bbbb-3333cccc4444","roles":["Files.SelectedOperations.Selected"]}

roles 裡面只有 Files.SelectedOperations.Selected,這就是前面說的第三個條件:Token 裡確實帶有這個權限。

步驟六:上傳檔案

因為應用程式只拿到兩個資料夾的權限,它沒辦法用路徑瀏覽網站或文件庫。像 /drives/{drive-id}/root:/General/Reports/Daily 這種從根目錄開始的路徑,應用程式會得到 404。所以上傳時要直接用 Drive ID 與資料夾的 Item ID 組出網址,這也是步驟四要把所有識別碼記下來的原因。

小檔案上傳

依據微軟的文件,250 MB 以下的檔案,可以直接用一個 PUT 請求上傳:

DRIVE_ID='b!ExAmPlEdRiVeIdExAmPlEdRiVeIdExAmPlEdRiVeIdExAmPlEdRiVeId'
FOLDER_ID='01EXAMPLEFOLDERIDDAILY000000000000'

curl -sS -X PUT \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: text/plain; charset=utf-8' \
  --data-binary @hello.txt \
  "https://graph.microsoft.com/v1.0/drives/$DRIVE_ID/items/$FOLDER_ID:/hello.txt:/content"

網址的 items/{folder-id}:/hello.txt:/content 代表「在這個資料夾底下,名為 hello.txt 的檔案內容」。檔案不存在時會建立新檔案,成功回傳 201 Created;檔案已經存在時,則會覆寫它的內容。

大檔案分段上傳

超過 250 MB 的檔案,或是希望網路中斷後可以接續上傳的情況,要先建立一個上傳工作階段(Upload session),再把檔案分段送出:

FILE=archive.zip
NAME=$(basename "$FILE")

UPLOAD_URL=$(curl -sS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"item":{"@microsoft.graph.conflictBehavior":"replace"}}' \
  "https://graph.microsoft.com/v1.0/drives/$DRIVE_ID/items/$FOLDER_ID:/$NAME:/createUploadSession" \
  | jq -r '.uploadUrl')

取得 uploadUrl 之後,依序送出每一段。以下是兩段的範例:

TOTAL=$(stat -f %z "$FILE")   # macOS 的 stat 寫法;Linux 請改用 stat -c %s
CHUNK=5242880                 # 5 MiB,必須是 320 KiB 的倍數

head -c $CHUNK "$FILE" > part1
tail -c $((TOTAL - CHUNK)) "$FILE" > part2

curl -sS -X PUT -H "Content-Range: bytes 0-$((CHUNK - 1))/$TOTAL" \
  --data-binary @part1 "$UPLOAD_URL"

curl -sS -X PUT -H "Content-Range: bytes $CHUNK-$((TOTAL - 1))/$TOTAL" \
  --data-binary @part2 "$UPLOAD_URL"

分段上傳有幾個規則:

  • 每一段的大小必須是 320 KiB(327,680 bytes)的倍數,最後一段除外。
  • 每一段都要帶 Content-Range: bytes {起}-{迄}/{總大小} 標頭。
  • 中間每一段成功都會回傳 202 Accepted,最後一段回傳 201 Created,內容就是建立好的檔案資訊。
  • 對 uploadUrl 送出 PUT 時,不要帶 Authorization 標頭,因為這個網址本身就帶有授權資訊。

實際的程式還要處理分段迴圈、失敗重試,以及中斷後查詢上傳進度再接續。這部分可以參考微軟的可續傳上傳文件。

驗證權限真的只到資料夾

設定完成後,我用應用程式的 Token 做了幾項測試,確認它能做的事情,以及不能做的事情:

測試 結果
上傳小檔案到兩個資料夾 201 Created,再下載回來比對內容一致
分段上傳 6 MiB 的檔案(5 MiB 加 1 MiB) 兩段分別回傳 202 與 201,檔案大小一致
寫入上一層的 Reports 資料夾 404 Not Found
讀取網站資訊 /sites/{site-id} 403 Forbidden
讀取其他網站 /sites/root 403 Forbidden
從根目錄路徑存取 root:/General 404 Not Found
搜尋整個文件庫 空的結果
列出文件庫根目錄 root/children 200 OK,但清單是空的

最後一項一開始讓我嚇了一跳,以為權限沒有設定好。實際看了回應內容才發現,回傳的是一份空清單。SharePoint 會把應用程式沒有權限的項目過濾掉,所以它看到的根目錄是空的。應用程式另外也能讀取文件庫本身的名稱與網址,但讀不到任何檔案。

HTTP 200 不等於看得到資料。驗證權限範圍時,要檢查回應的內容,不能只看狀態碼。

另外,寫入上一層資料夾得到的是 404,不是 403。對應用程式來說,它沒有權限的資料夾就像不存在一樣。

這次踩雷後留下的幾個提醒

Sites.Selected 不是資料夾層級的權限

我一開始的規劃,是請 IT 設定 Sites.Selected,再對網站授權 write。寫說明文件時,我在標題寫了「授權應用程式存取指定資料夾」,後來查證 API 文件才發現,Sites.Selected 最小只能授權到整個網站,應用程式可以寫入該網站的所有文件庫,根本做不到我想要的「只限兩個資料夾」。

要授權到資料夾層級,必須改用 Files.SelectedOperations.Selected 或 ListItems.SelectedOperations.Selected。這兩個權限已經是 Microsoft Graph v1.0 的正式功能。

如果你的需求確實是整個網站,Sites.Selected 的授權方式如下,注意這支 API 用的是 grantedToIdentities,跟資料夾的 grantedToV2 不一樣:

POST https://graph.microsoft.com/v1.0/sites/{site-id}/permissions
Content-Type: application/json

{
  "roles": ["write"],
  "grantedToIdentities": [{
    "application": { "id": "{Client ID}", "displayName": "TeamsFiles-Uploader" }
  }]
}

如果採用這個做法,建議為應用程式另外建立一個專用網站,避免它能寫入不相關的文件庫。

同意權限不等於有存取權

Selected 權限在管理員同意之後,應用程式的存取範圍是「零」。如果只做到步驟三就開始測試,一定會得到 403 或 404,這時要回頭確認步驟四有沒有完成。反過來說,這也是 Selected 權限最安全的地方:就算有人誤把這個權限加到別的應用程式並完成同意,沒有逐一指定資源,那個應用程式也什麼都存取不到。

Client ID 與 Object ID 不要搞混

應用程式的 Overview 頁面有好幾個 GUID。取得 Token 時的 client_id、JWT 的 iss 與 sub,以及授權資料夾時的 application.id,用的都是 Application (client) ID。

授權資料夾的帳號只需要同意給自己

Sites.FullControl.All 是權限非常大的委派權限,只是為了完成授權操作才需要。同意時不要勾選「代表您的組織同意」,讓這個同意只套用在自己的帳號上。

憑證到期與撤銷

本文的憑證有效期限是兩年。憑證到期之後,應用程式就無法取得 Token,排程會直接失敗,所以要記得在到期前換新:

  1. 用相同的方式產生一張新憑證。
  2. 把新憑證的 .cer 上傳到同一個應用程式。Entra ID 允許一個應用程式同時存在多張憑證,新舊憑證可以並存。
  3. 把新的 PFX 部署到執行程式的電腦,確認可以正常取得 Token。
  4. 從 Entra ID 刪除舊憑證。

如果要撤銷應用程式的存取權,有兩種層級:

  • 撤銷單一資料夾:用 GET /drives/{drive-id}/items/{item-id}/permissions 找出這個應用程式那一筆權限的 id,再用 DELETE /drives/{drive-id}/items/{item-id}/permissions/{permission-id} 刪除。
  • 全面停用:到 Entra ID 撤銷這個應用程式的管理員同意,或直接刪除應用程式註冊。

結語

這次實際完成的部分,包括:以 OpenSSL 產生憑證,在 Entra ID 註冊應用程式並上傳憑證,設定 Files.SelectedOperations.Selected 並完成管理員同意,用 Microsoft Graph PowerShell 對兩個資料夾授權。之後用 PS256 簽署的 Client Assertion 成功取得 Token,並完成小檔案上傳與 6 MiB 的分段上傳。前面列出的權限範圍測試,也都是用同一個 Token 實際測試的結果。

還沒有驗證的部分,包括:分段上傳中斷後的接續、在 Windows 電腦上以排程帳號無人值守執行、Windows 的 New-SelfSignedCertificate 指令,以及 3DES 格式的 PFX 在舊版 Windows 的匯入。這些要等正式的上傳程式完成後再測試。

整個流程看起來步驟很多,但每個步驟都有它的道理:憑證讓私密金鑰不必離開執行環境,應用程式權限讓排程不必依賴任何人的帳號,Selected 權限則把存取範圍縮到最小。如果你也有類似的自動化需求,建議先從一個測試資料夾開始,確認上傳與權限範圍都符合預期,再套用到正式的資料夾。

相關連結

留言評論