最近有個需求,要讓一台電腦上的排程程式,每天自動把產出的檔案上傳到公司 Teams 頻道的「檔案」裡面,讓團隊成員可以直接在 Teams 取用。這種每天定時執行的工作,當然不能靠人工登入,也不適合把某個人的帳號密碼寫進設定檔。
我一開始用第三方工具,以自己的帳號登入 SharePoint 測試,結果馬上被公司租用戶的政策擋下來:未經核准的第三方應用程式,不能代替使用者存取組織的資料。這其實是正確的安全設定,所以我決定走正規的路線:在 Microsoft Entra ID 註冊一個應用程式,讓程式用「憑證」證明自己的身分,並且把權限縮小到只能寫入兩個指定的資料夾。
這篇文章就來整理完整的設定流程,從產生憑證與私密金鑰開始,一路到設定權限、取得 Access Token、上傳檔案,以及驗證權限真的只到資料夾。過程中我原本以為 Sites.Selected 就能做到資料夾層級的授權,查了文件才發現不是這樣,這也是這次最值得記錄的地方。

本文的操作環境為 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 權限的設計是「三個條件都成立,才有存取權」:
- 應用程式在 Entra ID 取得對應的 Selected 權限,並完成管理員同意。
- 透過 Graph API 對特定資源(網站、清單、資料夾或檔案)授權給這個應用程式,並指定角色。
- 應用程式取得的 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:

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

這個頁面有三個看起來很像的 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 檔案:

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

這裡不要建立 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:

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

授與管理員同意這個步驟,需要特殊權限角色管理員(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
}
腳本做了這幾件事:
- 用
/sites/{hostname}:{路徑} 的格式,從網站網址查出 Site ID。 - 用
/sites/{site-id}/drive 取得網站的預設文件庫。如果資料夾不在預設文件庫,要改用 /sites/{site-id}/drives 列出所有文件庫,再挑出正確的那一個。 - 用
/drives/{drive-id}/root:/{路徑} 的格式,從資料夾路徑查出 Item ID。 - 先查詢資料夾現有的權限,沒有授權過才送出
POST,所以重複執行也不會出問題。 - 最後印出所有識別碼,後面寫上傳程式時會用到。
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,排程會直接失敗,所以要記得在到期前換新:
- 用相同的方式產生一張新憑證。
- 把新憑證的
.cer 上傳到同一個應用程式。Entra ID 允許一個應用程式同時存在多張憑證,新舊憑證可以並存。 - 把新的 PFX 部署到執行程式的電腦,確認可以正常取得 Token。
- 從 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 權限則把存取範圍縮到最小。如果你也有類似的自動化需求,建議先從一個測試資料夾開始,確認上傳與權限範圍都符合預期,再套用到正式的資料夾。
相關連結