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

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

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

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

![背景應用程式透過憑證與金鑰驗證身分，經過雲端安全閘道將檔案寫入 SharePoint 與 Teams 資料夾](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/entra-app-sharepoint/entra-app-sharepoint-banner.webp)

> 本文的操作環境為 **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 整合](https://learn.microsoft.com/en-us/sharepoint/teams-connected-sites) 文件。

所以，當我在 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 中的企業應用程式與服務主體物件](https://blog.miniasp.com/post/2024/03/27/Understanding-Enterprise-Application-and-Service-Principal-objects-in-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 金鑰](https://blog.miniasp.com/post/2024/03/14/How-to-create-client-secret-for-Microsoft-Entra-that-expired-at-100-years-later/)。

#### 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 權限概觀](https://learn.microsoft.com/en-us/graph/permissions-selected-overview)。

### 整體流程

整個設定流程如下：

```
產生憑證與私密金鑰
→ 在 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 簽發的憑證，可以依照公司的憑證管理政策決定。

依照[微軟的文件](https://learn.microsoft.com/en-us/entra/identity-platform/howto-create-self-signed-certificate)，Entra ID 目前只支援 RSA 金鑰，建議使用 2048 位元的金鑰長度，憑證使用 SHA-256 簽章（也支援 SHA-384 與 SHA-512）。

#### 使用 OpenSSL 產生憑證

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

```bash
umask 077
mkdir -p certs
cd certs
```

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

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

```ini
[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 私密金鑰與自簽憑證，有效期限設定為兩年：

```bash
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` 交給管理員上傳：

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

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

```bash
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 歷史紀錄裡。

#### 確認產生的憑證

先檢查憑證的內容：

```bash
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
```

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

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

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

```bash
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：

```bash
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 匯出：

```bash
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** 雜湊：

```bash
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 知道要用哪一張憑證驗證簽章：

```bash
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` 產生憑證。以下指令取自[微軟的文件](https://learn.microsoft.com/en-us/entra/identity-platform/howto-create-self-signed-certificate)，我加上了 `-NotAfter` 把有效期限設為兩年（預設是一年）：

```powershell
$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](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/entra-app-sharepoint/entra-app-register.webp)

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

按下 **Register** 之後，就會進入應用程式的 **Overview** 頁面：

![應用程式的 Overview 頁面，顯示 Application (client) ID、Object ID、Directory (tenant) ID 等資訊](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/entra-app-sharepoint/entra-app-overview.webp)

這個頁面有三個看起來很像的 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 格式的公開憑證](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/entra-app-sharepoint/entra-app-upload-certificate.webp)

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

![Certificates 分頁顯示已上傳的憑證，包含 Thumbprint、Description、Start date 與 Expires](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/entra-app-sharepoint/entra-app-certificates.webp)

這裡**不要建立 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](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/entra-app-sharepoint/entra-app-request-api-permissions.webp)

注意要選擇 **Application permissions**，不是 Delegated permissions。

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

![API permissions 頁面只有 Files.SelectedOperations.Selected 一個應用程式權限，狀態為 Granted](https://stwillblogassets.blob.core.windows.net/files/images/2026/10/entra-app-sharepoint/entra-app-api-permissions.webp)

授與管理員同意這個步驟，需要**特殊權限角色管理員**（Privileged Role Administrator）或全域管理員才能操作。依據[微軟的文件](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent)，雲端應用程式管理員（Cloud Application Administrator）與應用程式管理員（Application Administrator）可以同意大部分的權限，**唯獨 Microsoft Graph 的應用程式權限例外**。如果按鈕是灰色的，通常就是角色不夠。

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

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

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

```http
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 權限的文件](https://learn.microsoft.com/en-us/graph/permissions-selected-overview)，呼叫這支 API 需要 `Sites.FullControl.All` 權限。以委派方式呼叫時，登入的使用者本身也必須有權限管理這個資料夾，例如網站擁有者。

#### 使用 Microsoft Graph PowerShell 授權

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

```powershell
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
```

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

```powershell
$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](https://developer.microsoft.com/graph/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(簽章)
```

依據[微軟的憑證認證文件](https://learn.microsoft.com/en-us/entra/identity-platform/certificate-credentials)，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 手動做一次：

```bash
#!/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：

```bash
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}'
```

```json
{"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 組出網址，這也是步驟四要把所有識別碼記下來的原因。

#### 小檔案上傳

依據[微軟的文件](https://learn.microsoft.com/en-us/graph/api/driveitem-put-content)，250 MB 以下的檔案，可以直接用一個 `PUT` 請求上傳：

```bash
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），再把檔案分段送出：

```bash
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` 之後，依序送出每一段。以下是兩段的範例：

```bash
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` 標頭，因為這個網址本身就帶有授權資訊。

實際的程式還要處理分段迴圈、失敗重試，以及中斷後查詢上傳進度再接續。這部分可以參考微軟的[可續傳上傳文件](https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession)。

### 驗證權限真的只到資料夾

設定完成後，我用應用程式的 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` 不一樣：

```http
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 權限則把存取範圍縮到最小。如果你也有類似的自動化需求，建議先從一個測試資料夾開始，確認上傳與權限範圍都符合預期，再套用到正式的資料夾。

### 相關連結

-   [Teams 與 SharePoint 整合](https://learn.microsoft.com/en-us/sharepoint/teams-connected-sites)
-   [Overview of Selected Permissions in OneDrive and SharePoint](https://learn.microsoft.com/en-us/graph/permissions-selected-overview)
-   [Create permission on a driveItem](https://learn.microsoft.com/en-us/graph/api/driveitem-post-permissions)
-   [Create permission on a site](https://learn.microsoft.com/en-us/graph/api/site-post-permissions)
-   [Microsoft identity platform certificate credentials](https://learn.microsoft.com/en-us/entra/identity-platform/certificate-credentials)
-   [Create a self-signed public certificate to authenticate your application](https://learn.microsoft.com/en-us/entra/identity-platform/howto-create-self-signed-certificate)
-   [Grant tenant-wide admin consent to an application](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent)
-   [Upload small files](https://learn.microsoft.com/en-us/graph/api/driveitem-put-content)
-   [Upload large files with an upload session](https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession)
-   [認識 Microsoft Entra ID 中的企業應用程式與服務主體物件](https://blog.miniasp.com/post/2024/03/27/Understanding-Enterprise-Application-and-Service-Principal-objects-in-Microsoft-Entra-ID/)
-   [如何在 Microsoft Entra ID 中建立一個 100 年都不會過期的 Client Secret 金鑰](https://blog.miniasp.com/post/2024/03/14/How-to-create-client-secret-for-Microsoft-Entra-that-expired-at-100-years-later/)
