🌟

[C# .NET MAUI] OAuth2で各クラウドストレージにアクセスする

に公開

はじめに

現在、Raspberry Piをベースとした、AI CAPTUREという監視カメラシステムを公開中ですが、そこで利用している様々な技術的なトピックをメモ代わりにまとめていきたいと思います。

https://aicap.daddysoffice.com/index.html

このサービスでは、Raspberry Pi5ベースのAIBOXを遠隔操作するAndroid/iOSアプリを開発し公開しています。

このアプリは、Microsoftが提供している、クロスプラットフォームアプリを開発するためのフレームワーク .NET MAUIを使って開発しています。

.NET MAUIは、同じくクロスプラットフォームのFlutterやReact Nativeなどと比べると抽象化が浅く、結局Android/iOSネイティブの知識が必要になりますが、その反面、各OSの新機能への対応が早く、また、必要であれば、Partial Classなどを使って、必要なネイティブAPIをC#で直接呼び出せたりと、C#を使ってネイティブ寄りにやりたい人には非常に魅力的なフレームワークです。

今回開発したアプリでは、監視カメラ映像を、GoogleDriveやBoxなどのクラウドストレージに録画する機能が実装されており、この機能により、自分のプライベートなクラウドストレージに録画し、他人に見られることなく、どこからでも録画映像の視聴が可能になっています。

今回は、これらクラウドストレージへのコンテンツの保存や取得、削除などをOAuth2認証を使用して実現する手順や方法などについて説明したいと思います。

OAuth2認証

OAuth2認証とは、ユーザーのパスワードをアプリに渡さずに、アプリがユーザーのアカウントに安全にアクセスできる仕組みで、以下のような流れで認証を行います。

  1. アプリが認可リクエストを送る

    • ブラウザを開き、クラウドサービスの認証ページを開く。
  2. ユーザーが権限を付与

    • ユーザーがログインして、アプリにアクセス許可を与える。
  3. 認可コードを受け取る

    • クラウドサービスがリダイレクトURIに認可コードを付与して返す。
  4. 認可コードをアクセストークン/リフレッシュトークンと交換

    • アプリが認可コードを使い、クラウドサービスからアクセストークンやリフレッシュトークンを取得。
  5. APIにアクセス

    • 取得したアクセストークンを使って、クラウドサービスのAPIにアクセス。

Google DriveでのOAuth2認証の流れ

OAuth2認証の流れをGoogle Driveを例に見てみます。

まず、下記のURLをブラウザで開くとGoogleアカウントのログイン画面が表示されます。

Googleアカウントにログインすると、次にアプリへの権限付与の許可画面に遷移します。

ユーザーが権限付与を了承すると、URLのredirect_uriに指定したURIに、認可コードが付与されてリダイレクトされます。

その認可コード(code)をRedirectUriからパースして取得したら、以下のURLに、必要なパラメータをapplication/x-www-form-urlencoded形式でPOSTで送信します。

その戻り値としてアクセストークンやリフレッシュトークンが返却されます

{
  "access_token": "xxxxxxx",
  "expires_in": 3599,
  "refresh_token": "xxxxxx",
  "scope": "https://www.googleapis.com/auth/drive.file",
  "token_type": "Bearer"
}

この取得したアクセストークンを使用してAPIをコールします。

アクセストークンには期限がありますので、期限切れになる前にリフレッシュトークンを使用して新しいアクセストークンを取得する必要があります。
その為、リフレッシュトークンはサーバー上などに安全に保持しておくことになります。

アプリの登録

今回のクラウドストレージ録画機能は、以下の2つのストレージサービスに対応するように開発を始めました。

  • Google Drive:Google アカウントで利用できる、ファイルの保存・共有・共同編集が可能なクラウドストレージ。
  • Box:企業向けにセキュリティや管理機能を強化したクラウドファイル共有・コラボレーションサービス。

どちらのサービスも、OAuth2認証でアプリに権限を付与し、APIをコールする、という共通の流れになります。

まずは事前準備として、これらのサービスにアプリを登録して、必要な情報(クライアントID/クライアントシークレット/リダイレクトURI)を取得します。

Redirect Uriの決定

OAuth2認証では、ユーザーが権限付与を了承すると、指定したリダイレクトURIに、認可コードを付与してリダイレクトされます。

まずはこのリダイレクトURIを決める必要があります。

Webサービスアプリであれば、自サーバーへのURLを設定して、サーバーで認可コードを取得して処理を行う、という流れになりますが、今回はスマホアプリなので、アプリ内で取得できるようにする必要があります。

その手法として、一般的にはカスタムスキームによるアプリ起動の仕組みが使われます。

この方法は、myapp://oauth2redirectのような独自URIスキームをアプリに登録し、ブラウザや外部アプリからそのURIが開かれると、自分のアプリが起動して処理できる仕組みで、Android/iOS両方で利用できます。

このカスタムスキームを、OAuth2のリダイレクトURIに設定することで、ブラウザから認可コードを付与したURIのリダイレクトをアプリ内で処理できるようになります。

しかし、Google Driveなどではこの方法で問題ないのですが、Boxでは、リダイレクトURIに、http/https以外のプロトコルを設定するとエラーになり、カスタムスキームURIが設定出来ません。

その為、Boxを利用する場合は、定義した独自URIスキームにリダイレクトさせる処理をWebサーバー上で公開し、そのサーバーのURLをリダイレクトURIに設定する必要があります。

流れとしては、以下のような感じです。

認証画面(Webブラウザ) -> Redirect Uri(https://myserver/oauthredirect) -> カスタムスキームURI(myapp://oauth2redirect)

要は、リダイレクトされてきたものを独自カスタムスキームで再度リダイレクトする、という(無駄な)処理です。。。

私はAWSのLambdaで以下のようなコードをDeployし、CloudFrontのLambda Edgeとして公開し、そのURLをリダイレクトURIとして使用しています。

def lambda_handler(event, context):
    # CloudFront request/response objects
    cf = event['Records'][0]['cf']
    request = cf['request']
    
    # Extract provider name: /oauth/box → "box"
    uri_parts = request.get('uri', '').split('/')
    provider = uri_parts[2] if len(uri_parts) >= 3 else "unknown"

    # Extract query params (code, error)
    qs = request.get('querystring', '')
    params = {}
    if qs:
        for pair in qs.split('&'):
            if '=' in pair:
                key, value = pair.split('=', 1)
                params[key] = value

    # Build redirect URL
    redirect = f"myapp://oauth2redirect?provider={provider}"

    if "code" in params:
        redirect += f"&code={params['code']}"
    if "error" in params:
        redirect += f"&error={params['error']}"

    # Return HTTP 302 redirect
    return {
        "status": "302",
        "statusDescription": "Found",
        "headers": {
            "location": [
                {
                    "key": "Location",
                    "value": redirect
                }
            ],
            "cache-control": [
                {
                    "key": "Cache-Control",
                    "value": "no-store"
                }
            ]
        }
    }

Boxでのアプリ登録

OAuth2認証を使用するアプリの登録を行います。

まずは、Boxのアカウントを作成します。
https://www.box.com/ja-jp/home

アカウント作成後、ログインすると、左メニューの下に「開発者コンソール」がありますので、そこを選択して開発者コンソールを開きます。

開発者コンソールを開くと、右上に「アプリの新規登録」ボタンがありますので、それを押すと、アプリの新規登録画面が表示されます。

アプリ名を入力し、アプリタイプを「OAuth2」に設定してアプリを作成します。

アプリを作成したら、そのアプリの詳細画面を開きます。

この画面に表示されている「クライアントID」と「クライアントシークレット」をコピーして、その下の「リダイレクトURI」にリダイレクト先のサーバーのURLを入力します。

この取得した、クライアントID/クライアントシークレット/リダイレクトURIを使用して、OAuth2認証を行います。

Google Driveでのアプリ登録

OAuth2認証で、アプリからGoogle DriveのAPIを利用するアプリを登録するのは、少し面倒です。

まず、Googleアカウントがない場合は作成して、Google Cloud Consoleを開きます。

https://cloud.google.com/

最初にプロジェクトを作成し、「APIとサービス」ページを開き、画面上部にある「APIとサービスを有効にする」をクリックします。

検索窓に「GoogleDrive」と入力して、Google Drive APIを選択し、APIを有効にします。

これで、Google Drive APIを利用できるようになったので、今度はアプリを登録して、クライアントID/クライアントシークレットを取得します。

再度、APIとサービスの最初の画面に戻り、今度は「OAuth同意画面」を選択します。

ここでは、ユーザに権限付与を許可してもらうために表示されるページの情報を入力します。

まずは「ブランディング」を選択して、アプリ名を入力します。
その下の「ユーザーサポートメール」にはアカウントのメールアドレスが表示されると思いますので、そのままで設定を更新します。

次は「対象」を選択します。
ここでは、このOAuth2認証を利用できるユーザーを指定します。

GoogleのOAuth2認証は、本番運用とテスト運用の2種類のレベルがあり、最初はテスト運用になっています。

このテスト運用の状態では、このページで登録した最大100人のGoogleアカウントユーザのみが、OAuth2認証を利用できます。

本番運用にすると、この制限はなくなり、どのGoogleアカウントユーザでも利用できるようになりますが、本番運用に変更するには、ブランディングページで必要な情報をすべて入力し、Googleの審査を受ける必要があります。

とりあえず今回はテスト運用のまま進めますので、利用するGoogleアカウントのメールアドレスを登録してください。

最後にアプリケーションを登録します。

メニューから「クライアント」を選択し、「クライアントの作成」を押して、クライアントの情報入力画面を表示します。

「アプリケーションの種類」はウェブアプリケーションを選択して、アプリの名前を入力します。

その下の方にリダイレクトURIの入力欄がありますので、そこにアプリに登録するカスタムスキームを設定します。

クライアントを作成すると、ポップアップ画面でクライアントIDとクライアントシークレットが表示されますので、これをコピーします。

この取得した、クライアントID/クライアントシークレット/リダイレクトURIを使用して、OAuth2認証を行います。

.NET MAUIでのOAuth2認証の実装

アプリを登録して、クライアントID/クライアントシークレット/リダイレクトURIを取得したら、実装に入ります。

ここではC# .NET MAUIでの実装を説明します。

認証用クラスの実装

.NET MAUIでは、OAuth2認証フローを実現するWebAuthenticatorという便利なクラスがあり、認証用URIとリダイレクトURIを引数に指定するだけで、以下の処理をまとめて面倒を見てくれます。

  1. OS標準のブラウザで認証ページを開く
  2. ユーザーのログイン・同意を待つ
  3. リダイレクトURI(カスタムスキーマ or https)をフック
  4. 認可コードやトークンをアプリに返す
await WebAuthenticator.AuthenticateAsync(authUri, callbackUri);

Androidであれば、Chrome Custom Tabsで認証URLを開き、リダイレクトURIをIntentで取得、iOSでは、ASWebAuthenticationSessionを起動して、OSがリダイレクトURIをアプリに戻す、という処理を上記の1文で実行できる便利なクラスです。

このクラスを使用して、以下のような認証クラス(OAuth2Authenticator)を定義します。

OAuth2Authenticator
public static class OAuth2Authenticator
{
    // トークン返却用クラス
    public class TokenResponse
    {
        public string access_token { get; set; } = string.Empty;
        public string refresh_token { get; set; } = string.Empty;
        public int expires_in { get; set; } = 0;
        public string token_type { get; set; } = string.Empty;
    }

    //
    // OAuth2認証を行い、トークンを取得する
    public static async Task<TokenResponse> AuthenticateAsync(IOAuth2Provider provider)
    {
        // 1. 認証ページを表示するURLを作成
        var authUrl = BuildAuthUrl(provider);
        // 2. 外部ブラウザを起動しで認証実行&認可コードを取得
        var code = await GetAuthorizationCode(authUrl, provider.RedirectUri);
        // 3. 認可コードを使用してアクセストークン/リフレッシュトークンを取得
        return await ExchangeCodeForToken(provider, code);
    }

    //
    // 1. 認証ページを表示するURLを作成
    private static string BuildAuthUrl(IOAuth2Provider provider)
    {
        var parameters = provider.GetAuthParameters();
        var query = string.Join("&",
            parameters.Select(kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}"));
        return $"{provider.AuthUrl}?{query}";
    }

    //
    // 2. 外部ブラウザを起動しで認証実行&認可コードを取得
    private static async Task<string> GetAuthorizationCode(string authUri, string redirectUri)
    {
        var authResult = await WebAuthenticator.Default.AuthenticateAsync(
            new Uri(authUri),
            new Uri(redirectUri)
        );
        if (authResult.Properties.TryGetValue("code", out var code))
        {
            return code;
        }
        throw new Exception("Could not get code parameter!");
    }

    //
    // 3. 認可コードを使用してアクセストークン/リフレッシュトークンを取得
    private static async Task<TokenResponse> ExchangeCodeForToken(
            IOAuth2Provider provider,
            string code)
    {
        var parameters = provider.GetTokenParameters(code);
        var content = new FormUrlEncodedContent(parameters);

        using var client = new HttpClient();
        var response = await client.PostAsync(provider.TokenUrl, content);

        if (!response.IsSuccessStatusCode)
        {
            var error = await response.Content.ReadAsStringAsync();
            throw new Exception($"Cloud not get token!: {error}");
        }

        var json = await response.Content.ReadAsStringAsync();
        var res = JsonSerializer.Deserialize<TokenResponse>(json);
        if (res == null)
        {
            throw new Exception("Fail to JsonSerializer.Deserialize<TokenResponse>(json)!");
        }

        return res;

    }
}

使い方は、利用するサービスのProviderを引数に、スタティックメソッドのOAuth2Authenticator.AuthenticateAsync()をコールするだけです。

使い方

var response = await OAuth2Authenticator.AuthenticateAsync(new GoogleDriveProvider())

メソッドの内部は、引数で渡されたProviderを使って以下の処理を行います。

  1. 認証ページを表示するURLを作成
  2. 外部ブラウザを起動しで認証実行&認可コードを取得
  3. 認可コードを使用してアクセストークン/リフレッシュトークンを取得
//
// OAuth2認証を行い、トークンを取得する
public static async Task<TokenResponse> AuthenticateAsync(IOAuth2Provider provider)
{
    // 1. 認証ページを表示するURLを作成
    var authUrl = BuildAuthUrl(provider);
    // 2. 外部ブラウザを起動しで認証実行&認可コードを取得
    var code = await GetAuthorizationCode(authUrl, provider.RedirectUri);
    // 3. 認可コードを使用してアクセストークン/リフレッシュトークンを取得
    return await ExchangeCodeForToken(provider, code);
}

プロバイダーのインターフェイス

OAuth2認証は、渡すパラメータの名称などが異なるのみで、基本的な処理の流れはどのサービスも同じになります。

その為、以下のようなインターフェイス(IOAuth2Provider)を定義し、それを各サービス用プロバイダークラスで実装していきます。

IOAuth2Provider Interface
public interface IOAuth2Provider
{
    string Type { get; }      // プロバイダータイプ(表示用)
    string AuthUrl { get; }     // 認証ページ表示URL
    string TokenUrl { get; }    // トークン取得URL
    string Scope { get; }       // 権限スコープ
    string RedirectUri { get; } // リダイレクトURI
    public string ClientId { get; }     // クライアントID
    public string ClientSecret { get; } // クライアントシークレット

    // 認証ページ表示URLに渡すパラメータを返却する
    Dictionary<string, string> GetAuthParameters();
    
    // トークン取得URLみ渡すパラメータを返却する
    Dictionary<string, string> GetTokenParameters(string code);
}

このインターフェイスを各サービスに合わせて実装していきます。

Boxプロバイダー

まずはBox用のProvider(BoxProvider)の実装です。

BoxProvider
public class BoxProvider : IOAuth2Provider
{
    public string Type => "box";
    public string AuthUrl => "https://account.box.com/api/oauth2/authorize";
    public string TokenUrl => "https://api.box.com/oauth2/token";
    public string Scope => ""; // Boxはscopeパラメータ不要
    public string RedirectUri => "https://myserver/oauthredirect"; // Boxはhttpsでないと設定できない!!
    public string ClientId => "xxxx";
    public string ClientSecret => "xxxx";

    public Dictionary<string, string> GetAuthParameters()
    {
        return new Dictionary<string, string>
        {
            ["client_id"] = ClientId,
            ["redirect_uri"] = RedirectUri,
            ["response_type"] = "code"
            // Boxは access_type, prompt 不要
        };
    }

    public Dictionary<string, string> GetTokenParameters(string code)
    {
        return new Dictionary<string, string>
        {
            ["grant_type"] = "authorization_code",
            ["code"] = code,
            ["client_id"] = ClientId,
            ["client_secret"] = ClientSecret
            // BoxはRedirectUriは不要
        };
    }
}

IOAuth2Providerで定義されたGETプロパティやメソッドの中身をサービスに合わせて実装します。
ちなみに、Boxは基本的にストレージサービスなので、権限の範囲を規定するスコープパラメータを使用しません。

出来上がったら、以下のようにOAuth2Authenticator.AuthenticateAsync()の引数に指定すれば、最終的にトークンが取得できます。

使い方

var response = await OAuth2Authenticator.AuthenticateAsync(new BoxProvider())

GoogleDriveプロバイダー

こちらはGoogle Drive用のProvider(GoogleDriveProvider)の実装です。

GoogleDriveProvider
public class GoogleDriveProvider : IOAuth2Provider
{
    public string Type => "googledrive";
    public string AuthUrl => "https://accounts.google.com/o/oauth2/v2/auth";
    public string TokenUrl => "https://oauth2.googleapis.com/token";
    public string Scope => "https://www.googleapis.com/auth/drive.file";
    public string RedirectUri => "myapp://oauth2redirect";
    public string ClientId => "xxxxxx";
    public string ClientSecret => "xxxxxx";

    public Dictionary<string, string> GetAuthParameters()
    {
        return new Dictionary<string, string>
        {
            ["client_id"] = ClientId,
            ["redirect_uri"] = RedirectUri,
            ["response_type"] = "code",
            ["scope"] = Scope,
            ["access_type"] = "offline",
            ["prompt"] = "consent"
        };
    }

    public Dictionary<string, string> GetTokenParameters(string code)
    {
        return new Dictionary<string, string>
        {
            ["grant_type"] = "authorization_code",
            ["code"] = code,
            ["client_id"] = ClientId,
            ["client_secret"] = ClientSecret,
            ["redirect_uri"] = RedirectUri
        };
    }
}

Googleでは数多くのサービスがAPIとして提供されているので、使用するサービスのAPIをスコープで指定します。

今回はGoogle Driveで自分のアプリが作成したコンテンツのみへのアクセスを可能にする以下のスコープを指定します。

https://www.googleapis.com/auth/drive.file

複数のスコープを指定する場合はスペースを入れて続けて記載してください。

権限スコープの一覧はこちらに載っています。
また、使用するAPIのヘルプページにも必要なスコープの記載があります。

https://developers.google.com/identity/protocols/oauth2/scopes?hl=ja

出来上がったら、以下のようにOAuth2Authenticator.AuthenticateAsync()の引数に指定すれば、最終的にトークンが取得できます。

使い方

var response = await OAuth2Authenticator.AuthenticateAsync(new GoogleDriveProvider())

OneDriveプロバイダー

今回のアプリの開発では、最初はOneDriveも対応する方向で進めていましたので、OneDrive用のプロバイダーも作成していました。

ちゃんと動きますので、こちらも載せておきます。

OneDriveProvider
public class OneDriveProvider : IOAuth2Provider
{
    public string Type => "onedrive";
    public string AuthUrl => "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize";
    public string TokenUrl => "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
    public string Scope => "Files.ReadWrite offline_access";
    public string RedirectUri => "myapp://oauth2redirect";
    public string ClientId => "xxxxx";
    public string ClientSecret => "xxxxx";

    public Dictionary<string, string> GetAuthParameters()
    {
        return new Dictionary<string, string>
        {
            ["client_id"] = ClientId,
            ["redirect_uri"] = RedirectUri,
            ["response_type"] = "code",
            ["scope"] = Scope,
            ["response_mode"] = "query"
        };
    }

    public Dictionary<string, string> GetTokenParameters(string code)
    {
        return new Dictionary<string, string>
        {
            ["grant_type"] = "authorization_code",
            ["code"] = code,
            ["client_id"] = ClientId,
            ["client_secret"] = ClientSecret,
            ["redirect_uri"] = RedirectUri
        };
    }
}

出来上がったら、以下のようにOAuth2Authenticator.AuthenticateAsync()の引数に指定すれば、最終的にトークンが取得できます。

使い方

var response = await OAuth2Authenticator.AuthenticateAsync(new OneDriveProvider())

APIの実行

アクセストークンが取得できれば、あとはAPIをコールするだけです!
使用するAPIをコールするHTTPリクエストのヘッダーに、取得したアクセストークンを指定してリクエストを送信すればOKです。

以下は指定したファイル(file_id)を削除するGoogle DriveのAPIをコールする例です。

curlで実行(DELETEメソッドでリクエスト)
curl -X DELETE \
  -H "Authorization: Bearer [アクセストークン]" \
  https://www.googleapis.com/drive/v3/files/{file_id}

OneDriveを対応から外した理由

最初にも記載しましたが、当初はOneDriveも対応する方向で開発していましたが、コンテンツの削除処理の仕様の違いで対応を断念しました。。。

今回のアプリでは、カメラ映像を各ストレージの保存する機能を実装しましたが、設定した保存容量をオーバーしそうになったら古い映像から削除していって容量を確保する、という仕様にしています。

その際、いわゆるゴミ箱の扱いが、Box/Google Drive/OneDriveで異なっており、その調整が必要になりました。

Boxのファイル削除仕様APIの仕様

Boxのファイル削除は、以下のURLに対してHTTP DELETEメソッドをコールします。

Boxのファイル削除API(DELETEメソッドでリクエスト)
https://api.box.com/2.0/files/{file_id}

しかしこのAPIをコールすると、ファイルはゴミ箱に移動する仕様になっており、ストレージの空き容量は増えません。

その為、ストレージ容量を確保するために、ファイル削除APIをコールしたら、下記のゴミ箱から削除APIも併せてコールする必要があります。

Boxのゴミ箱からファイルを削除するAPI(DELETEメソッドでリクエスト)
https://api.box.com/2.0/files/{file_id}/trash

この2つを続けてコールすることでファイルが完全に削除されます。

Google Driveのファイル削除APIの仕様

Google Driveのファイル削除は、以下のURLに対してHTTP DELETEメソッドをコールします。

Google Driveのファイル削除API(DELETEメソッドでリクエスト)
https://www.googleapis.com/drive/v3/files/{file_id}

Google Drive では、このAPIをコールすると、ゴミ箱を経由せずに完全削除になり、空き容量が増えることになります。

OneDriveのファイル削除APIの仕様

OneDriveのファイル削除は、以下のURLに対してHTTP DELETEメソッドをコールします。

OneDriveのファイル削除API(DELETEメソッドでリクエスト)
https://graph.microsoft.com/v1.0/me/drive/items/{file_id}/content

OneDriveの場合は、Box同様、このAPIをコールするだけではゴミ箱に移動するのみで完全削除ができません。

ただ、困ったことに、OneDriveにはゴミ箱からファイルを削除するAPIが存在しません。

エンタープライズ契約(?)だと、ゴミ箱を空にできるという情報はあったのですが、それでもゴミ箱に入っている個別のファイルを削除することはできないようです。

0バイトのファイルで上書きしてからゴミ箱に移動してみたりしましたが、上書きしてもファイル履歴上に前のデータが残っているらしく、表示上は0バイトでも空き容量は増えませんでした。。。

ということで、今回の開発の要件には合わなそうなので、OneDriveはサポートから外すことになりました。。。

最後に

今回提示したソースコードは、ストレージサービスだけでなく、OAuth2経由で、他のサービスのAPIをアプリから使用する際にも利用できます。

ここに載せたソースコードは自由に改変して利用してもらって構いませんので、色々と試してみてください!

特に個人的にはC# .NET MAUI押しです!
興味を持った方はぜひMAUIも触ってみてください。

Discussion