🐦

.NET 6 (C#) で Twitter API v2 の OAuth 2.0 を試す

2022/04/03に公開1

概要

Twitter API v2 でユーザー認証も OAuth 2.0 になり権限を細かく設定できるようになったらしいとのことでちょっと試してみました。

OAuth 2.0 Authorization Code Flow with PKCE

https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code
https://developer.twitter.com/en/docs/authentication/oauth-2-0/user-access-token
https://zenn.dev/kg0r0/articles/8b1cfe654a1cee

詳しい説明はドキュメントや上の記事を参照してもらった方が確実なのでここでは説明しません。
画像だけドキュメントから引用しておきます。

アプリの登録

登録済みのアプリがあったので OAuth 2.0 の登録だけやります。

歯車アイコンをクリック。

Edit をクリック。

OAuth 2.0 を有効化。

Type of App で Web App (サーバーで動くアプリを指してます)または Automated App or bot を選択。
後述しますが今回は Confidential Client で説明していきます。

iOS / Android アプリの場合は Native App, ブラウザで動く JS は Single Page App を選択してください。
この記事では説明しませんが認証方法が少し異なります。

クライアントのタイプ

https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code
https://datatracker.ietf.org/doc/html/rfc6749#section-2.1

Confidential Client は、許可されていない第三者にクレデンシャルを公開することなく安全な方法でクレデンシャルを保持し、クライアントの秘密を安全に保つ認証サーバーで安全に認証できます。Public Client は通常、ブラウザまたはモバイルデバイスで実行されており、クライアントシークレットを使用できません。Confidential Client であるアプリのタイプを選択すると、クライアントシークレットが提供されます。

クライアントのタイプには confidential と public の2種類があります。
ユーザーに秘匿情報が見えないものと見えるものです。

この記事では秘匿情報が見えない前提の Confidential Client を扱います。
クライアントシークレットが使えます。

.NET 6 (C#) でコードを書く

プロジェクトを作って動かすところは割愛します。
コアなコードだけ紹介していきます。
まずはクラスの全体像です。個々の説明は後述します。

クラス全体
public class Twitter
{
    private static readonly HttpClient HttpClient = new HttpClient();

    public string ConstructAuthorizeUrl(string clientId, string redirectUri, IEnumerable<Scope> scopes, string state, string challenge, string challengeMethod = "S256") {}
    public string RetrieveAuthorizationCode(string callbackedUri, string state) {}
    public async Task<string> GetAccessToken(string clientId, string clientSecret, string redirectUrl, string code, string verifier) {}
    public async Task<string> RefreshToken(string clientId, string clientSecret, string refreshToken) {}
    public async Task<string> RevokeToken(string clientId, string clientSecret, string accessToken) {}
    public async Task<string> GetTweet(string accessToken, long id) {}
    public async Task<string> PostTweet(string accessToken, string text) {}
}

public enum Scope {}
public static class ScopeExtension {}

HttpClient はリクエスト毎に Dispose してはいけないのでクラスに持ちます。
https://qiita.com/superriver/items/91781bca04a76aec7dc0
https://docs.microsoft.com/ja-jp/dotnet/api/system.net.http.httpclient?view=net-6.0

便宜上1つのクラスにしてパラメーターを全て引数で受け取っていますが、実際はもうちょっと工夫した方がいいような気がします。お試しコードなのでご容赦を。

スコープ

一覧はこちら
各 API に必要なスコープは下記またはそれぞれのドキュメントに記載されています。

https://developer.twitter.com/en/docs/authentication/guides/v2-authentication-mapping

扱いやすいように enum に定義しておきます。
文字列に戻すために拡張メソッドを利用しています。

Scope.cs
public enum Scope
{
    TweetRead,
    TweetWrite,
    TweetModerateWrite,
    UsersRead,
    FollowRead,
    FollowWrite,
    OfflineAccess,
    SpaceRead,
    MuteRead,
    MuteWrite,
    LikeRead,
    LikeWrite,
    ListRead,
    ListWrite,
    BlockRead,
    BlockWrite,
    BookmarkRead,
    BookmarkWrite,
}

public static class ScopeExtension
{
    private static Dictionary<Scope, string> Values = new Dictionary<Scope, string>()
    {
        { Scope.TweetRead, "tweet.read" },
        { Scope.TweetWrite, "tweet.write" },
        { Scope.TweetModerateWrite, "tweet.moderate.write" },
        { Scope.UsersRead, "users.read" },
        { Scope.FollowRead, "follow.read" },
        { Scope.FollowWrite, "follow.write" },
        { Scope.OfflineAccess, "offline.access" },
        { Scope.SpaceRead, "space.read" },
        { Scope.MuteRead, "mute.read" },
        { Scope.MuteWrite, "mute.write" },
        { Scope.LikeRead, "like.read" },
        { Scope.LikeWrite, "like.write" },
        { Scope.ListRead, "list.read" },
        { Scope.ListWrite, "list.write" },
        { Scope.BlockRead, "block.read" },
        { Scope.BlockWrite, "block.write" },
        { Scope.BookmarkRead, "bookmark.read" },
        { Scope.BookmarkWrite, "bookmark.write" },
    };

    public static string GetValue(this Scope scope)
    {
        return Values[scope];
    }
}

認可 URL の生成

生成される URL がドキュメントと少し異なるようだが動いてはいる。

public string ConstructAuthorizeUrl(string clientId, string redirectUri, IEnumerable<Scope> scopes, string state, string challenge, string challengeMethod = "S256")
{
    var query = HttpUtility.ParseQueryString("");
    query.Add("response_type", "code");
    query.Add("client_id", clientId);
    query.Add("redirect_uri", redirectUri);
    query.Add("scope", string.Join(" ", scopes.Select(x => x.GetValue())));
    query.Add("state", state);
    query.Add("code_challenge", challenge);
    query.Add("code_challenge_method", challengeMethod);

    var uriBuilder = new UriBuilder("https://twitter.com/i/oauth2/authorize")
    {
        Query = query.ToString(),
    };
    return uriBuilder.Uri.AbsoluteUri;
}

redirect_uri はアプリに登録したものしか許可されません。

state は CSRF 対策のランダムな文字列です。(~500文字)
認可コードを取得する際に一致しているか確認します。

code_challenge, code_challenge_method は PKCE 用のパラメーターです。
code_challenge_methodS256 または plain が指定できますが S256 が推奨されます。

https://datatracker.ietf.org/doc/html/rfc7636#section-4.2

   plain
      code_challenge = code_verifier

   S256
      code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))

   If the client is capable of using "S256", it MUST use "S256", as
   "S256" is Mandatory To Implement (MTI) on the server.  Clients are
   permitted to use "plain" only if they cannot support "S256" for some
   technical reason and know via out-of-band configuration that the
   server supports "plain".

今回はお試しなので plain を使いました。(なので S256 で変換するコードはありません)
code_verifier の値をそのまま code_challenge に入れています。

code_verifier は43~128文字のランダムな文字列です。

https://datatracker.ietf.org/doc/html/rfc7636#section-4.1

アプリにアクセスを許可

認可 URL へブラウザでアクセスしてアプリにアクセスを許可します。

new[] { Scope.TweetRead, Scope.TweetWrite, Scope.UsersRead } の場合。
昔みたいに怖い文言が並んだりしませんね。

追加で Scope.OfflineAccess も付与しておくと無期限にでき、refresh_token が取得できます。

今回は単なる動作検証なので手動でブラウザへアクセスしました。
プログラムでやる場合 WinRT だと WebAuthenticationBroker が使えたんですが、 .NET MAUI ではどうするのがいいんでしょうか?ご存知の方いたら教えていただけると助かります。

認可コードを取得

アプリにアクセスを許可すると redirect_uri に指定した URL へ飛ばされます。
この URL から(state が一致しているか確認した上で)認可コードを取得します。

public string RetrieveAuthorizationCode(string callbackedUri, string state)
{
    var uri = new Uri(callbackedUri);
    var query = HttpUtility.ParseQueryString(uri.Query);
    if (query.Get("state") != state)
    {
        throw new InvalidDataException("state is not valid.");
    }
    return query.Get("code");
}

認可コードの期限

認可コードの有効期限が30秒しかないようなので発行されてから30秒以内にアクセストークンを取得する必要があります。

Authorization code
This allows an application to hit APIs on behalf of users. Known as the auth_code. The auth_code has a time limit of 30 seconds once the App owner receives an approved auth_code from the user. You will have to exchange it with an access token within 30 seconds, or the auth_code will expire.

期限切れになると以下のエラーが返ってきます。
プログラムで実行する分にはさほど問題ないでしょうが検証中に手動でやってると嵌りがち。

{
  "error": "invalid_request",
  "error_description": "Value passed for the authorization code was invalid."
}

アクセストークンの取得

クライアント ID とクライアントシークレットを : で繋げたものを base64 エンコードしてベーシック認証します。

public async Task<string> GetAccessToken(string clientId, string clientSecret, string redirectUri, string code, string verifier)
{
    var parameters = new Dictionary<string, string>()
    {
        { "code", code },
        { "grant_type", "authorization_code" },
        { "redirect_uri", redirectUri },
        { "code_verifier", verifier },
    };
    var content = new FormUrlEncodedContent(parameters);
    var request = new HttpRequestMessage(HttpMethod.Post, "https://api.twitter.com/2/oauth2/token");
    request.Headers.Authorization = new AuthenticationHeaderValue(
        "Basic",
        Convert.ToBase64String(Encoding.ASCII.GetBytes($"{clientId}:{clientSecret}"))
    );
    request.Content = content;
    var response = await HttpClient.SendAsync(request).ConfigureAwait(false);
    return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}

各 API へリクエスト

認証はデフォルトヘッダーを設定した方がいいかもしれませんが今回はそれぞれでやってます。
そのため SendAsync を使ったやや冗長なコードになっています。

デフォルトヘッダーを使用する場合
HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

ツイートを取得

https://developer.twitter.com/en/docs/twitter-api/tweets/lookup/api-reference/get-tweets-id

public async Task<string> GetTweet(string accessToken, long id)
{
    var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.twitter.com/2/tweets/{id}");
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    var response = await HttpClient.SendAsync(request).ConfigureAwait(false);
    return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}

ツイートする

https://developer.twitter.com/en/docs/twitter-api/tweets/manage-tweets/api-reference/post-tweets

public async Task<string> PostTweet(string accessToken, string text)
{
    var parameters = new Dictionary<string, string>()
    {
        { "text", text },
    };
    var json = JsonSerializer.Serialize(parameters);
    var content = new StringContent(json, Encoding.UTF8, MediaTypeNames.Application.Json);
    var request = new HttpRequestMessage(HttpMethod.Post, "https://api.twitter.com/2/tweets");
    request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    request.Content = content;
    var response = await HttpClient.SendAsync(request).ConfigureAwait(false);
    return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}

トークンの更新

public async Task<string> RefreshToken(string clientId, string clientSecret, string refreshToken)
{
    var parameters = new Dictionary<string, string>()
    {
        { "refresh_token", refreshToken },
        { "grant_type", "refresh_token" },
    };
    var content = new FormUrlEncodedContent(parameters);
    var request = new HttpRequestMessage(HttpMethod.Post, "https://api.twitter.com/2/oauth2/token");
    request.Headers.Authorization = new AuthenticationHeaderValue(
        "Basic",
        Convert.ToBase64String(Encoding.ASCII.GetBytes($"{clientId}:{clientSecret}"))
    );
    request.Content = content;
    var response = await HttpClient.SendAsync(request).ConfigureAwait(false);
    return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}

トークンの破棄

public async Task<string> RevokeToken(string clientId, string clientSecret, string accessToken)
{
    var parameters = new Dictionary<string, string>()
    {
        { "token", accessToken },
        { "token_type_hint", "access_token" }
    };
    var content = new FormUrlEncodedContent(parameters);
    var request = new HttpRequestMessage(HttpMethod.Post, "https://api.twitter.com/2/oauth2/revoke");
    request.Headers.Authorization = new AuthenticationHeaderValue(
        "Basic",
        Convert.ToBase64String(Encoding.ASCII.GetBytes($"{clientId}:{clientSecret}"))
    );
    request.Content = content;
    var response = await HttpClient.SendAsync(request).ConfigureAwait(false);
    return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}

ドキュメントには記載がないのですが token_type_hint がないとエラーになります。

{
  "error":"invalid_request",
  "error_description":"Missing required parameter [token_type_hint]."
}

https://openid-foundation-japan.github.io/rfc7009.ja.html#rfc.section.3.1

感想

OAuth 1.0a だとパラメーター並び替えて signature 作ったりで複雑でしたが OAuth 2.0 だと code_challenge を生成するくらいでシンプルですね。
OAuth 2.0 自体も元々(ユーザーに紐付かない)アプリケーション認証として実装されていたものなのでトークンさえ取得できれば各 API へのアクセス方法はさほど変わらないのではないかと思います。

Discussion

雪猫雪猫

C# だと関係ありませんがブラウザ JavaScript で実装するときは Twitter API が CORS に対応していないのでアクセストークンを取得するリクエスト (POST https://api.twitter.com/2/oauth2/token) だけサーバーで処理する必要があります。
クライアントのタイプも Confidential Client を選択します。
https://twittercommunity.com/t/cors-error-in-oauth2-token/163898/3