【Entra ID】SharePoint APIを実行するためのaccessTokenを取得する

# やりたいこと
REST APIで、人の手を介さず、SharePoint Onlineのファイルやフォルダを操作したい。

https://learn.microsoft.com/ja-jp/sharepoint/dev/sp-add-ins/working-with-folders-and-files-with-rest#working-with-files-by-using-rest

上のリファレンスを読むに、以下リクエストのaccessTokenの値を取得すれば良さそう。
```
GET https://{site_url}/_api/web/GetFolderByServerRelativeUrl('/Shared Documents')
Authorization: "Bearer " + accessToken
Accept: "application/json;odata=verbose"
```
……このアクセストークンの取得に思いのほか手間取ったので、備忘録として残します。

:::note warn
**注意**
`SharePoint API accessToken`等で検索すると、**Azure ACS**を使った記事が多くヒットします。
具体的には、以下URLのように、SharePointの**AppRegNew.aspx**でクライアントIDやクライアントシークレットを登録し、SharePoint上で直接アクセストークンを発行する方法です。
https://ホスト名.sharepoint.com/XXXXX/_layouts/15/AppRegNew.aspx

[この方法](https://learn.microsoft.com/ja-jp/sharepoint/dev/sp-add-ins-modernize/from-acs-to-aad-apps)は、Azure ACS(Access Control Service)を使いますが、Azure ACSは**廃止予定**のため、推奨しません。
>As part of this evolution of Microsoft 365 solutions we will be retiring the use of Azure ACS (Access Control Services) for SharePoint Online auth needs and believe Microsoft 365 customers will be better served by modern auth offered via Microsoft Entra ID. **Azure ACS will stop working for new tenants as of November 1st, 2024 and it will stop working for existing tenants and will be fully retired as of April 2nd, 2026.**
https://learn.microsoft.com/ja-jp/sharepoint/dev/sp-add-ins/retirement-announcement-for-azure-acs
:::
# やること（概要）
Azure ACSではなく、Microsoftが推奨している**Entra ID (旧Azure AD)を使った方法でアクセストークンを取得する。**
そのために……
1. Entra IDの検証環境を用意する。（既にある場合は不要）
1. Entra IDにSharePointアクセストークン発行用のアプリを登録する
1. SharePointのアクセストークン取得時に必要な証明書を準備する（理由は後述）
1. 証明書を使用し、SharePointのアクセストークンを取得する
1. 取得したアクセストークンで、SharePoint APIを実行する

# 1. Entra ID環境の用意（既にある場合は不要）
Microsoft 365 開発者プログラムに参加すると、無料・クレカ登録無しで、Entra ID含むMicrosoft365の環境が手に入ります。超便利。

https://learn.microsoft.com/ja-jp/office/developer-program/microsoft-365-developer-program

登録後に、以下URLにアクセスすると、Entra IDが利用できます。
https://portal.azure.com/
ライセンスは、`Microsoft Entra ID P2`です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431251/9ca6bd57-3200-a4a2-e991-af9877cb2afc.png)

参考：
https://www.softbanktech.co.jp/special/blog/cloud_blog/2022/0120/

# 2. Entra IDにSharePointアクセストークン発行用のアプリを登録する

**① Entra IDの左メニュー「アプリの登録」を選択し、左上の「＋新規登録」をクリックする。**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431251/3139f515-dbfc-6617-fffc-1bdd8a970a45.png)

**② アプリの名前を適当に付けて「登録」をクリックする。（リダイレクトURIは省略）**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431251/55d687fa-f114-ecb7-9d09-7b6e174dece5.png)

③ **アプリケーション（クライアント）ID**と、**ディレクトリ（テナント）ID**を記録しておく。
以下では見切れていますが、アプリケーション（クライアント…が、アプリケーション（クライアント）IDです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431251/543618a0-c21c-8c53-7a75-4d322cdd5079.png)

**④「APIのアクセス許可」を選択し、「アクセス許可の追加」をクリックする。**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431251/e2f45a64-7500-24c8-6db0-e8ed72decede.png)
SharePointを選択し、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431251/3e5fe198-9d8c-bc2c-ede6-e73d9832c514.png)
以下を設定し「アクセス許可の追加」をクリックする。
（今回はフル権限を与えているが、場合によって適した権限にする）
>アプリケーションに必要なアクセス許可の種類 -> アプリケーションの許可
アクセス許可を選択 -> Sites.FullControl.All, TermStore.ReadWrite.All, User.ReadWrite.All

「XXXに管理者の同意を与えます」をクリックして、「はい」を押下する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431251/904e9ef7-cba1-574d-240d-1d92fb85ed0c.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431251/c9f9be03-fd10-2de1-82be-96dd6a970203.png)

上の「状態」が「XXXに付与されました」の緑のチェックマークになっていればOKです。

# 3. SharePointのアクセストークン取得時に必要な証明書を準備する

:::note warn
**なぜ、証明書？**
通常、アクセストークンの取得は、クライアントIDとクライアントシークレットの認証で行われることが多いです。
しかし、クライアントIDとクライアントシークレットの認証でSharePoint API用のアクセストークンを取得すると、SharePoint API実行時に、以下のようなエラーが返ってきます。
```
Unsupported app only token.
```
どうも、SharePointに関しては、クライアントシークレットではなく、X.509の証明書を用いた認証でアクセストークンを取得しないと、APIが使えないようになっているようです。
参考：
https://blog.mastykarz.nl/azure-ad-app-only-access-token-using-certificate-dotnet-core
:::

**① 以下手順を参考に、自己署名証明書を準備する。**

https://learn.microsoft.com/ja-jp/entra/identity-platform/howto-create-self-signed-certificate

PowerShellで以下を実行し、パスワード付の証明書を作成します。（version 7で実行）
```powershell
# XXXXXには、任意の証明書名やパスワードをセットしてください。
#
# 証明書生成
$certname = "XXXXX"
$cert = New-SelfSignedCertificate -Subject "CN=$certname" -CertStoreLocation "Cert:\CurrentUser\My" -KeyExportPolicy Exportable -KeySpec Signature -KeyLength 2048 -KeyAlgorithm RSA -HashAlgorithm SHA256
Export-Certificate -Cert $cert -FilePath "C:\work\$certname.cer"   ## Specify your preferred location

$mypwd = ConvertTo-SecureString -String "XXXXX" -Force -AsPlainTextt-PfxCertificate -Cert $cert -FilePath "C:work\$certname.pfx" -Password $mypwd   ## Specify your preferred location
```

**② Entra IDに証明書を登録する。**

Entra IDの「アプリの登録」から、SharePoint用に登録したアプリを選択し、右上の「証明書またはシークレットの追加」をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431251/543618a0-c21c-8c53-7a75-4d322cdd5079.png)
「証明書のアップロード」を選択し、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431251/b691d95d-a538-4f22-eb0c-a7634c053765.png)
②で作成した.cerファイルをアップロードし「追加」をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431251/1f5f51dd-684b-ed85-acb7-b2c526584efa.png)

証明書が登録されていればOKです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3431251/e6dfa0c2-f07d-6480-4e88-8cb239e7c8cf.png)


# 4. 証明書を使用し、SharePointのアクセストークンを取得する

以下の「2番目のケース:証明書を使ったアクセストークン要求」に証明書を使用したアクセストークン取得方法が記載されています。

https://learn.microsoft.com/ja-jp/entra/identity-platform/v2-oauth2-client-creds-grant-flow#second-case-access-token-request-with-a-certificate

アクセストークン取得リクエストには、以下のパラメータが必要と記載されています。

|パラメーター  |値  |説明  |
|---|---|---|
|tenant  |d27abxxx-xxxx-xxxx-xxxx-xxxxxxxxxx  |上の2.⑤のディレクトリ(テナント)ID  |
|client_id  |067cbxxx-xxxx-xxxx-xxxx-xxxxxxxxxx  |上の2.⑤のアプリケーション(クライアント)ID  |
|scope|https://<ホスト名>.sharepoint.com/.default|ホスト名は、自分のMicrosoft 365環境の値|
|client_assertion_type|urn:ietf:params:oauth:client-assertion-type:jwt-bearer|固定値|
|client_assertion|証明書から生成したJWT|後述|
|grant_type|client_credentials|固定値|

……え？ `client_assertion`にJWT？ アクセストークン（JWT）を取得するためのリクエストに、さらにJWTが必要？と不可解な気もしますが、必要なようです。

**① 証明書からJWTを生成する**
Microsoftが提供するC#のライブラリで恐縮ですが……

NuGetパッケージマネージャーから、以下4つのパッケージをインストールして、さらに下のC#ファイルを実行します。
1. Microsoft.IdentityModel.Abstractions 
2. Microsoft.IdentityModel.JsonWebTokens
3. Microsoft.IdentityModel.Logging
4. Microsoft.IdentityModel.Tokens

```c#
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;

namespace GetJwtToken
{
    internal class Program
    {
        static void Main(string[] args)
        {
            string ClientID = "上の2.⑤のアプリケーション(クライアント)ID";
            string CertPassWord = "上で作成した証明書のパスワード";
            string aud = "https://login.microsoftonline.com/上の2.⑤のディレクトリ(テナント)ID/v2.0/";
            // 上で作成した証明書の.pfxファイルを指定
            string CertificatePath_Pfx = "C:\\work\\cert.pfx";

            X509Certificate2 cert = new X509Certificate2(CertificatePath_Pfx, CertPassWord);
            var claims = new System.Collections.Generic.Dictionary<String, Object>();
            claims["aud"] = aud;
            claims["iss"] = ClientID;
            claims["sub"] = ClientID;
            claims["jti"] = Guid.NewGuid().ToString("D");

            var signCred = new Microsoft.IdentityModel.Tokens.X509SigningCredentials(cert);
            var securityTokenDescriptor = new Microsoft.IdentityModel.Tokens.SecurityTokenDescriptor();
            securityTokenDescriptor.Claims = claims;
            securityTokenDescriptor.SigningCredentials = signCred;

            Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler tokenHandler = new Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler();
            var clientAssertion = tokenHandler.CreateToken(securityTokenDescriptor);
            Console.Write(clientAssertion);
            Console.ReadKey();

        }
    }
}
```
（探せば、PowerShellやシェルスクリプトで実行できるものもあるとは思いますが、力尽きて……）

正常に実行完了すると以下のようなJWTがコンソールに出力されます。
```
eyJhbGciOiJSUzI1NiIsImtpZC[...]XLeLvTe9yude0_ePDlaj8WJ4ZCuDA
```

**② 上で取得したJWTをセットから、SharePointのアクセストークンを取得する**
```powershell
################################################
# Entra_ID側の環境変数
#
# 対象Microsoft 365のホスト名
$host_name = "Microsofrt 365のホスト名"
# Entra_IDのテナントID
$tenant_id = "上の2.⑤のディレクトリ(テナント)ID"
# Entra_IDのアプリケーションID
$client_id = "上の2.⑤のアプリケーション(クライアント)ID"


###############################################
# 固定変数
#
# grant_type
$grant_type = "client_credentials"
# スコープ(今回はsharepoint)
$scope = "https://" + $host_name + ".sharepoint.com/.default"

$client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
$client_assertion = "上で生成したJWT"

#################################################
# アクセストークン取得関数
function getAccessToken{
    $headers=@{}
    $headers.Add("accept", "application/json")
    $headers.Add("content-type", "application/x-www-form-urlencoded")
    $get_url = 'https://login.microsoftonline.com/' + $tenant_id + '/oauth2/v2.0/token'
    $body = 'grant_type=' + $grant_type + '&client_id=' + $client_id + '&client_assertion_type =' + $client_assertion_type + '' + '&client_assertion=' + $client_assertion + '&scope=' + $scope 

    try {
        $response = Invoke-WebRequest -Uri $get_url -Method POST -Headers $headers -ContentType 'application/x-www-form-urlencoded' -Body $body
    }catch{
        Write-Host "StatusCode:" $_
        exit
    }
    $response_json = ConvertFrom-Json $response
    return $response_json.access_token
}

# アクセストークン取得(有効期限は1時間)
$access_token = getAccessToken
Write-Host $access_token
```

正常に実行完了すると以下のようなアクセストークンがコンソールに出力されます。これで、SharePoint APIが実行できます。
```
eyJ0eXAiOiJKV1Q[...]5vhTUh14dwYV3tGp1SZLK1e8PvQwSzPqxUG7CVw
```


# 5. 取得したアクセストークンで、SharePoint APIを実行する

上で取得したアクセストークンを使い、SharePointのapitestというサイトのtestフォルダのtest_document.txtの内容を取得するAPIが実行できました。
```cmd
curl -i -X GET \
   -H "Authorization:Bearer eyJ0eXAiOiJKV1Q[...]5vhTUh14dwYV3tGp1SZLK1e8PvQwSzPqxUG7CVw" \
   -H "Accept:application/json;odata=verbose" \
   -H "Content-Type:application/json; odata=verbose" \
   'https://ホスト名.sharepoint.com/sites/apitest/_api/web/GetFolderByServerRelativeUrl('test')/Files('test_document.txt')/$value'

```