Blazor WebAssemblyとMicrosoft GraphでOneDriveを読み書きする

## 概要

以下を読みながら、`Blazor WebAssembly`と`Microsoft Graph API`でOneDriveの読み書きを試したときのメモ。

- microsoftgraph/msgraph-sdk-dotnet  
  https://github.com/microsoftgraph/msgraph-sdk-dotnet
- Secure an ASP.NET Core Blazor WebAssembly standalone app with Microsoft Accounts
  https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/standalone-with-microsoft-accounts?view=aspnetcore-6.0
- Use Graph API with ASP.NET Core Blazor WebAssembly  
  https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/graph-api?view=aspnetcore-6.0
- List available drives  
  https://docs.microsoft.com/en-us/graph/api/drive-list?view=graph-rest-1.0&tabs=http

開発環境構築方法は割愛する。
Visual Studio 2022でBlazor WebAssemblyの開発ができる状態であれば、大丈夫なはず。

ざっくりな流れは、以下のとおり。

1. Azure Active Directoryにアプリを登録する
1. Blazor WebAssemblyを実装する

## Azure Active Directoryにアプリを登録する

Azure Active Directoryにアプリを登録する必要がある。

1. 以下にアクセスする。  
  https://aad.portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/Overview
1. [追加] -> [アプリの登録]をクリックする。  
  ![](https://camo.qiitausercontent.com/5c9f212a4f168c586da7c66f95010429af29f111/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f64343033333033622d363736372d373135652d656132372d3965383931396636306366622e706e67)
1. 各種入力をして、登録をクリックする。  
   ![](https://camo.qiitausercontent.com/4f3ee9fe25f8dfb7039aa8b5a6a652a84ca5125d/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f65643436636631612d393865382d623265632d623233392d3630666135316534626366392e706e67)  
   1. 名前  
      任意の名前。OneDriveReadWriteByBlazorWasmとした。
   1. サポートされているアカウントの種類
   1. リダイレクトURL  
      あとで設定する
1. 表示されているページのURLとクライアントIDを控える。  
   ![](https://camo.qiitausercontent.com/4cd9c7b319631a3735c0b63d7ca8eb64c08cac54/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f33316530393966362d626335662d303563342d343935352d6166336138363364326637662e706e67)
1. パブリッククライアントフローを許可し、保存する。  
   ![](https://camo.qiitausercontent.com/efb1e4c6d4daa27241e4a73615aa7294ea13c67f/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f32303163636433352d643231642d633132332d313366392d6536393436303934323961352e706e67)
1. APIのアクセスを許可する。
   1. [APIのアクセス許可] -> [アクセス許可の追加] -> [Microsoft Graph] を選択する  
      ![](https://camo.qiitausercontent.com/cdc8a61328d391921b51ce7503b13ea0fe08ad72/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f65356136336438662d343439362d373263332d333235652d3032386538643836663037642e706e67)
   1. [委任されたアクセス許可]を選択する  
      ![](https://camo.qiitausercontent.com/93e036276d17a9023eb9d72dd982907581ce44ea/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f61633138386162362d626138642d333637302d346532392d3532353463383739353566372e706e67)
   1. `User.Read`と`Files.ReadWrite`を追加する  
      ![](https://camo.qiitausercontent.com/de59a5415fbab7f73012f9854287667a15479457/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f36393632333631372d396237392d333538372d346437642d6337653732316334336136332e706e67)

## Blazor WebAssemblyを実装する

ここから本題。

### プロジェクト作成

```powershell
dotnet new blazorwasm -au SingleOrg --client-id "{CLIENT ID}" --tenant-id "common" -o {APP NAME}
```

|プレースホルダー|Azure Active Directory Name  |例                                   |備考         |
|--------------|------------------------------|------------------------------------|-------------|
|APP NAME      |-                             |-                                   |任意のアプリ名|
|CLIENT ID     |アプリケーション(クライアント)ID|41451fa7-82d9-4673-8fa5-69eff5a761fd|上で控えたやつ |

### Nugetパッケージ[Microsoft.Graph]をプロジェクトに追加

```powershell
cd {APP NAME}.csprojがあるフォルダ
dotnet add package Microsoft.Graph
```

### openid と offline_access の DefaultAccessTokenScopes の MsalProviderOptions のペアを追加

Program.csにaddの2行を追加する

```csharp
builder.Services.AddMsalAuthentication(options =>
{
    builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
    options.ProviderOptions.DefaultAccessTokenScopes.Add("openid");          // add
    options.ProviderOptions.DefaultAccessTokenScopes.Add("offline_access");  // add
});
```

### ログインできることを確認

1. `{APP NAME}.csproj`があるフォルダに移動して`dotnet run`を実行する。  
   以下が出力される。

   ```powershell
   Building...
   info: Microsoft.Hosting.Lifetime[14]
         Now listening on: https://localhost:7176
   info: Microsoft.Hosting.Lifetime[14]
         Now listening on: http://localhost:5013
   info: Microsoft.Hosting.Lifetime[0]
         Application started. Press Ctrl+C to shut down.
   info: Microsoft.Hosting.Lifetime[0]
         Hosting environment: Development
   info: Microsoft.Hosting.Lifetime[0]
         Content root path: xxxxxxxxx\OneDriveReadWriteByBlazorWasm\OneDriveReadWriteByBlazorWasm
   ```

1. 上で控えたURLをブラウザで表示し、[リダイレクトURLを追加する]をクリックする。  
   ![](https://camo.qiitausercontent.com/4cd9c7b319631a3735c0b63d7ca8eb64c08cac54/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f33316530393966362d626335662d303563342d343935352d6166336138363364326637662e706e67)

1. [プラットホームを追加]をクリックする。  
  ![](https://camo.qiitausercontent.com/f6d2f909901d6d090c0dd665df22f34067b94b07/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f31343165306435622d363262642d376133322d393330392d6332396234623230366165652e706e67)

1. [シングルページアプリケーション]をクリックする。  
   ![](https://camo.qiitausercontent.com/38039c0fe448b1ef816e14ff82a6997b3fa2e2cc/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f64376431303062312d373765642d666462362d653065642d3230623739333339373538302e706e67)

1. [リダイレクトURL]を入力し、[構成]をクリックする。  
   [リダイレクトURL]は`https://localhost:ポート番号/authentication/login-callback`とする。  
   ポート番号は`dotnet run`の最初に出るURLを参照。  
   上の例で言うと`https://localhost:7176`  
   ![](https://camo.qiitausercontent.com/b9468d767410927617b1bb15eec5964279b098ee/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f38353636663633362d383635662d663138352d346132652d3537653836336162326564392e706e67)

1. ブラウザで`dotnet run`の最初に出るURLを表示し、`Log in`をクリックする。  
   ![](https://camo.qiitausercontent.com/359c32ee083a9962383dcf71ecd5b11a94da0ee8/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f39353534636463612d353262342d623461632d333031622d3835656231376363313833382e706e67)

1. 以下が表示されたら、Microsoftアカウントにログインする。  
   ![](https://camo.qiitausercontent.com/482388b2797b6f93a765961109f10731d35d9ea1/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f36366631623637632d633763302d396662612d353663632d3138343939666661646666642e706e67)

1. ログインできると、赤枠のところに"Hello 名前!"が表示される。  
   ![](https://camo.qiitausercontent.com/54018293e2c012d433539e3f898d7d0cf0f480ed/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f64646538366637342d616332362d656361372d636665392d6632613137663038633333612e706e67)

1. ログインできたことを確認したら、ブラウザを閉じる。

1. `dotnet run`を[Ctrl + C]で止める。

### Microsoft Graph APIを使用して、OneDriveを読み書き

- Visual Studio 2022で`{APP NAME}.csproj`を開く。

- プロジェクト直下に`GraphClientExtensions.cs`を追加し、以下のコードを書く。  

```csharp
namespace {APP NAME}
{
    using System;
    using System.Net.Http;
    using System.Net.Http.Headers;
    using System.Threading;
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
    using Microsoft.Authentication.WebAssembly.Msal.Models;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Graph;

    internal static class GraphClientExtensions
    {
        public static IServiceCollection AddGraphClient(
            this IServiceCollection services, params string[] scopes)
        {
            services.Configure<RemoteAuthenticationOptions<MsalProviderOptions>>(
                options =>
                {
                    foreach (var scope in scopes)
                    {
                        options.ProviderOptions.AdditionalScopesToConsent.Add(scope);
                    }
                });

            services.AddScoped<IAuthenticationProvider,
                NoOpGraphAuthenticationProvider>();
            services.AddScoped<IHttpProvider, HttpClientHttpProvider>(sp =>
                new HttpClientHttpProvider(new HttpClient()));
            services.AddScoped(sp =>
            {
                return new GraphServiceClient(
                    sp.GetRequiredService<IAuthenticationProvider>(),
                    sp.GetRequiredService<IHttpProvider>());
            });

            return services;
        }

        private class NoOpGraphAuthenticationProvider : IAuthenticationProvider
        {
            public NoOpGraphAuthenticationProvider(IAccessTokenProvider tokenProvider)
            {
                TokenProvider = tokenProvider;
            }

            public IAccessTokenProvider TokenProvider { get; }

            public async Task AuthenticateRequestAsync(HttpRequestMessage request)
            {
                var result = await TokenProvider.RequestAccessToken(
                    new AccessTokenRequestOptions()
                    {
                        Scopes = new[] {
                            "https://graph.microsoft.com/User.Read",
                            "https://graph.microsoft.com/Files.ReadWrite"
                        }
                    });

                if (result.TryGetToken(out var token))
                {
                    request.Headers.Authorization ??= new AuthenticationHeaderValue(
                        "Bearer", token.Value);
                }
            }
        }

        private class HttpClientHttpProvider : IHttpProvider
        {
            private readonly HttpClient http;

            public HttpClientHttpProvider(HttpClient http)
            {
                this.http = http;
            }

            public ISerializer Serializer { get; } = new Serializer();

            public TimeSpan OverallTimeout { get; set; } = TimeSpan.FromSeconds(300);

            public void Dispose()
            {
            }

            public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
            {
                return http.SendAsync(request);
            }

            public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
                HttpCompletionOption completionOption,
                CancellationToken cancellationToken)
            {
                return http.SendAsync(request, completionOption, cancellationToken);
            }
        }
    }
}
```

- Program.csにaddの1行を追加する  

```csharp
builder.Services.AddMsalAuthentication(options =>
{
    builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
    options.ProviderOptions.DefaultAccessTokenScopes.Add("openid");
    options.ProviderOptions.DefaultAccessTokenScopes.Add("offline_access");
});

// add
builder.Services.AddGraphClient("https://graph.microsoft.com/User.Read", "https://graph.microsoft.com/Files.ReadWrite");
```

- `Pages/GraphExample.razor`を追加し、以下のコードを書く  

```csharp
@page "/GraphExample"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.Graph
@using System.IO
@using System.Text
@attribute [Authorize]
@inject GraphServiceClient GraphClient

<h3>Graph Client Example</h3>

<h4>User infomation</h4>

@if (user != null)
{
    <p>DisplayName: @user.DisplayName</p>
    <p>UserPrincipalName: @user.UserPrincipalName</p>
}

<h4>Onedrive root items</h4>

@if (rootChildren != null)
{
    <table class="table">
        <thead>
            <tr>
                <th>Name</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var rootChild in rootChildren)
            {
                <tr>
                    <td>@rootChild.Name</td>
                </tr>
            }
        </tbody>
    </table>
}

<h4>uploaded_by_graph_example</h4>
@if (lines != null)
{
    @foreach(var line in lines)
    {
        <p>@line</p>
    }
}


@code {
    private User? user;
    private IDriveItemChildrenCollectionPage? rootChildren;
    private List<string>? lines;

    protected override async Task OnInitializedAsync()
    {
        await this.InitializeUser();

        await this.PutFileToOneDrive();

        await this.InitializeItemList();

        await this.ReadTextFromOneDrive();
    }

    private async Task InitializeUser()
    {
        var request = GraphClient.Me.Request();
        user = await request.GetAsync();
    }

    private async Task PutFileToOneDrive()
    {
        await GraphClient.Me.Drive.Root
            .ItemWithPath("uploaded_by_graph_example.txt").Content
            .Request()
            .PutAsync<DriveItem>(new MemoryStream(Encoding.GetEncoding("UTF-8").GetBytes("line1\nline2\n行3\n")));
    }

    private async Task InitializeItemList()
    {
        rootChildren = await GraphClient.Me.Drive.Root.Children.Request().GetAsync();
    }

    private async Task ReadTextFromOneDrive()
    {
        var stream = await GraphClient.Me.Drive.Root
            .ItemWithPath("uploaded_by_graph_example.txt").Content
            .Request()
            .GetAsync();

        var ms = new MemoryStream();
        await stream.CopyToAsync(ms);
        var text = Encoding.GetEncoding("UTF-8").GetString(ms.ToArray());
        lines = text.Split('\n').ToList();
    }
}
```

- {APP NAME}.csprojがあるフォルダに移動してdotnet runを実行し、ブラウザで`https://localhost:ポート番号/`を表示する。

- [Log in]をクリックし、Microsoftアカウントにログインする。

   ![](https://camo.qiitausercontent.com/359c32ee083a9962383dcf71ecd5b11a94da0ee8/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f39353534636463612d353262342d623461632d333031622d3835656231376363313833382e706e67)
   ![](https://camo.qiitausercontent.com/482388b2797b6f93a765961109f10731d35d9ea1/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f36366631623637632d633763302d396662612d353663632d3138343939666661646666642e706e67)

- ログインできると、赤枠のところに"Hello 名前!"が表示される。  
   ![](https://camo.qiitausercontent.com/54018293e2c012d433539e3f898d7d0cf0f480ed/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f64646538366637342d616332362d656361372d636665392d6632613137663038633333612e706e67)

- ブラウザで`https://localhost:ポート番号/GraphExample`を表示する。  
   ![](https://camo.qiitausercontent.com/4e4462e54373110703055504ae9a6dbd522859e4/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f39353161623263372d376532342d613464632d633931352d6430323163353761643065382e706e67)  
   1. 上手くいっていれば、以下の動作をする。
      1. ユーザー情報(User infomation)が表示される。
      1. One Driveのルート直下にある要素一覧(Onedrive root items)が表示される。  
         書き込みができていれば、`uploaded_by_graph_example.txt`がある。
      1. 書き込んだファイルを読み込んだ結果(uploaded_by_graph_example)に書き込んだ文字列が表示される。
   1. 念のため、OneDriveを直接覗いてアップロードされていることを確認する。  
      ![](https://camo.qiitausercontent.com/a2d7e0e635ffb7ea589cdb6bfa20542dcf38aedb/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323633383834352f63643836643561332d656538642d356561622d363164382d6238333165393762353062312e706e67)