💭

デスクトップアプリ(C#/.NET6)でTwitter API認証(OAuth2 PKCE)するまで

2022/08/14に公開
2

タイトルについて、一筋縄ではいかなかったので備忘録。
問題と思っていたところがただの誤字だったので、記事修正しました。
自戒も含め、単に備忘録として残しておきます。

OAuth2 with PKCE

詳細は省きます。(なぜなら説明できるほど理解できていないので)
Twitter開発者ページにも説明があるので、こちらを参照ください。

認証の流れ

こちらに、TwitterAPIにおける認証の手順が説明されています。
ありがたいですね。参考にさせていただきましょう。

Step1. 認証URLの作成

アプリケーションを使うユーザーが、アプリケーションに対してアカウントの使用を許可するためのページを生成します。

こういうやつですね。
ここで言われてるのは、次節で使うための変数を作りましょうということです。

scope

アプリケーションがユーザーに要求する、認証アカウントに対する権限です。
このページに一覧があります。これをスペース区切りにしてエンコード(="%20"区切りで連結)した形でURLパラメータに加えます。
当然ですが、scopeの有無で利用できるAPIが異なります。変にscope全盛りとかすると怖いアプリ扱いされたりもしますので、目的に沿って最小限の選択をしましょう。

state

認証コードを受け取る際、認証コードが正しいものかを確認するための文字列となります。詳細は後述します。
内容は、最大500文字のランダムな文字列です。URLのパラメータに埋め込むので、特殊文字列は避け、英数字にしておけば問題ないでしょう。

static string GenerateRandomString(int inLength)
{
    const string chars = "abcdefghijklmnopqrstuvwxyz123456789";
    var randomizer = new Random();
    var result = new char[inLength];
    for (int i = 0; i < inLength; i++)
	result[i] = chars[randomizer.Next(chars.Length)];

    return new string(result);
}

code_verifier

後述の、code_challengeを生成するための文字列です。
内容は、43~128文字の文字列です。stateと同じく、URLのパラメータに埋め込むので、特殊文字列は避け、英数字にしておきましょう。(前述のGenerateRandomStringをそのまま使えます)

code_challenge

認証する際にはcode_challengeを送り、認証コードからアクセストークンを取得するためにはcode_verifierを送ります。これにより、認証するアプリケーションと、アクセストークンを取得するアプリケーションが同一であることを確認する、といったイメージでいいと思います。code_challengeからcode_verifierへの変換はできない?から、code_challengeを先に送信し、その元となったcode_verifierを送信できるアプリケーションは信頼できる、といった感じでしょうか?
code_challengeの生成方法には2種類あります。
1つ目はplainで、code_verifierをそのまま使います。変換せずに使うのでセキュリティもクソもないため、よほどな理由がなければもう一つの方を使いましょう。
2つ目はS256で、いわゆるSHA256ハッシュです。その上で、URLパラメータなので、特定の特殊文字をなんやかんや変換して使うようです。

static string GenerateCodeS256Challenge(string inCodeVerifier)
{
    var sha256 = SHA256.Create();
    var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(inCodeVerifier));
    var b64Hash = Convert.ToBase64String(hash);
    var code = b64Hash
		.Replace("+", "-")
		.Replace("/", "_")
		.Replace("=", "")
		.Replace("+", "")
		.Replace("$", "");

    return code;
}

Step2. 認証

ユーザーに認証URLを案内し、アプリケーションを許可してもらいます。
今回はブラウザを起動して、そちらで認証してもらう形にしました。

var url = "https://twitter.com/i/oauth2/authorize";
url = $"{url}?response_type=code&client_id={inClientID}&redirect_uri={inRedirectUrl}&scope={scope}&state={state}&code_challenge={challenge}&code_challenge_method={challengeMethod}";

Process.Start(new ProcessStartInfo
{
    FileName = url,
    UseShellExecute = true,
});

前項で説明していなかったパラメータについてですが、

  • client_id: TwitterDevelopersでアプリ登録後に得られるやつ
  • redirect_url: TwitterDevelopersで設定したやつ(http://localhost:8080/とかでOK)
  • challenge_method: code_challengeの生成方法("plain"か"S256")

さて、ユーザーが認証した結果は、指定したredirect_urlに対してパラメータが渡される形で得られます。上記のコードでブラウザが開き、認証ボタンをクリックすると、以下のURLに遷移すると思います。
{redirect_url}?state=xxxxxxxxx&code=yyyyyyyy
redirect_urlは、認証URLに含まれるredirect_urlの値と読み替えてください。また、stateとcodeは実行ごとに変わるでしょう。
はて、じゃあその遷移した先のページのURLをどう受け取ればいいの?

こちらのページを参考にさせていただきました。
あらかじめリダイレクト先のURLで待ち受けることができるようです。

static async Task<NameValueCollection> WaitPageResponseAsync(string inUrl, string inRedirectUrl, CancellationToken inCancellationToken)
{
    var http = new HttpListener();
    http.Prefixes.Add(inRedirectUrl);
    http.Start();

    Process.Start(new ProcessStartInfo
    {
	FileName = inUrl,
	UseShellExecute = true,
    });

    var page = "<html><body>Please return to the app.</body></html>";
    var contextTask = http.GetContextAsync();
    await contextTask.WaitAsync(inCancellationToken);
    var context = contextTask.Result;
    var response = context.Response;
    var buffer = Encoding.UTF8.GetBytes(page);
    response.ContentLength64 = buffer.Length;
    var responseOutput = response.OutputStream;
    await responseOutput.WriteAsync(buffer, 0, buffer.Length);
    responseOutput.Close();
    http.Stop();

    return context.Request.QueryString;
}

var url = "https://twitter.com/i/oauth2/authorize";
url = $"{url}?response_type=code&client_id={inClientID}&redirect_uri={inRedirectUrl}&scope={scope}&state={state}&code_challenge={challenge}&code_challenge_method={challengeMethod}";

var response = await WaitPageResponse(url, inRedirectUrl, inCancellationToken);

var state = response.Get("state");
var code = response.Get("code");

page変数は、ユーザーが認証した後に遷移するリンクのコンテンツを設定できるようです。
認証したら自動でタブを閉じるようにしたいなーと思って、色々調べて以下のHTMLを設定してみましたが、うまくいきませんでした。まあいいや。

<html>
<body onload="open(location, '_self').close();">
</body>
</html>

ともあれ、これで認証コード(code)を得ることができました。
stateが自分の用意したものと異なる場合は、認証コードも自分で使えるものではありません。(code_verifierがマッチしなくなるのかな?)ので、これはチェックするようにしましょう。

Step3. アクセストークンの取得

Step2で得られた認証コードを使い、アクセストークンをいただきます。このアクセストークンを使うことで、Twitterの各APIを利用できます。
指定のURLへパラメータを含めれば、レスポンスとして返ってきます。簡単ですね。

static async Task<Dictionary<string, string>> RequestTokenAsync(HttpClient inHttpClient
            , string inClientID, string inClientSecret, string inRedirectUrl
            , string inCode, string inCodeVerifier)
{
    var url = "https://api.twitter.com/2/oauth2/token";
    var request = new HttpRequestMessage(HttpMethod.Post, url);
    // アプリの設定でPublicClient (NativeAppかSinglePageApp)を選択している場合は、
    // Authorizationの指定は不要
    request.Headers.Authorization = new AuthenticationHeaderValue(
	    "Basic",
	    Convert.ToBase64String(Encoding.UTF8.GetBytes($"{inClientID}:{inClientSecret}"))
	);
    request.Content = new FormUrlEncodedContent(new Dictionary<string, string> {
	    { "code", inCode },
	    { "grant_type", "authorization_code" },
	    { "client_id" , inClientID },
	    { "redirect_uri", inRedirectUrl },
	    { "code_verifier", inCodeVerifier },
	});

    var response = await inHttpClient.SendAsync(request);
    var json = await response.Content.ReadAsStringAsync();

    return JsonConvert.DeserializeObject<Dictionary<string, string>>(json)!;
}

ところがどっこい
これは何度試しても成功しませんでした。
ステータスコードは400(Bad Request)、得られたレスポンスの内容は、

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

調べてみたのですが、認証コードが時間切れ(とか、無効な場合)に得られるエラーと同じみたいでした。
認証コードには時間制限があり、ユーザーが認証してコードが発行されてから30秒以内にアクセストークンを取得する必要があります。ですが、コード上で処理を完結している以上、そんなに時間がかかるはずもなく、TwitterDevelopersForumに質問なんか投げてみましたが返答なく、結局原因が分かりませんでした。

{ "redirect_uri", inRedirectUrl }

とするべきところ、

{ "redirect_url", inRedirectUrl }

としていました。これでは取得できませんね……。
コメントにてご指摘いただいた @oipest 様、ありがとうございました。

Step3. アクセストークンの取得、とりあえずの対応

上記のForumへの質問へ自己レスで回答を送っていますが、
色々試したところ、curlでコマンドを打てばすんなりアクセストークンが得られました。

腑に落ちませんが、じゃあそこだけはcurlに仕事してもらいましょう。
curlを実行するbatファイルを作成します。

@set CODE=%1
@set CLIENT_ID=%2
@set REDIRECT_URL=%3
@set CODE_VERIFIER=%4

@curl --location --request POST https://api.twitter.com/2/oauth2/token --header 'Content-Type: application/x-www-form-urlencoded' --data-urlencode "code=%CODE%" --data-urlencode "grant_type=authorization_code" --data-urlencode "client_id=%CLIENT_ID%" --data-urlencode "redirect_uri=%REDIRECT_URL%" --data-urlencode "code_verifier=%CODE_VERIFIER%"

あとは、これを呼び出し、結果を受け取ればOKです。

class AccessToken
{
    [JsonProperty("expires_in")]
    public int ExpiresInSeconds { get; private set; } = 0;
    [JsonProperty("access_token")]
    public string BearerToken { get; private set; } = string.Empty;
    [JsonProperty("scope")]
    public string Scope { get; private set;} = string.Empty;
    [JsonProperty("refresh_token")]
    public string RefreshToken { get; private set; } = string.Empty;
}

var filepath = "path_to_batch";
if (!File.Exists(filepath))
    throw new FileNotFoundException("token script not found.", filepath);

var psInfo = new ProcessStartInfo();
psInfo.FileName = filepath;
psInfo.Arguments = $"{code} {inClientID} {inRedirectUrl} {codeVerifier}";
psInfo.CreateNoWindow = true;
psInfo.UseShellExecute = false;
psInfo.RedirectStandardOutput = true;

var process = Process.Start(psInfo)!;

var json = process.StandardOutput.ReadLine()!;
var token = JsonConvert.DeserializeObject<AccessToken>(json)!;

process.Kill();

単にAPIを呼ぶ形よりは時間もかかりますが、仕方ない。

Step4. アクセストークンを使ったAPIの呼び出し

省略します。
API呼び出し時のAuthorizationHeaderにアクセストークンを設定するだけです。

Step5. アクセストークンの更新

アクセストークンには使用期限があります。アクセストークンを受け取った際、expired_inというパラメータを受け取りますが、この秒数が過ぎると使えなくなります。面倒ですね。
認証URLの作成時に、scopeにoffline.accessを指定しておくと、アクセストークンを更新できるようになります。(指定しない場合は再生成、つまりまたブラウザで許可してもらう必要がある?未確認です)

static async Task<AccessToken> RefreshTokenAsync(HttpClient inHttpClient, string inClientID, string inRefreshToken)
{
    var url = "https://api.twitter.com/2/oauth2/token";
    var request = new HttpRequestMessage(HttpMethod.Post, url);
    request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
    {
	{ "grant_type", "refresh_token" },
	{ "refresh_token", inRefreshToken },
	{ "client_id", inClientID },
    });

    var response = await inHttpClient.SendAsync(request);
    var json = await response.Content.ReadAsStringAsync();

    return JsonConvert.DeserializeObject<AccessToken>(json)!;
}

URLは、アクセストークンの取得に使ったものと同じで、パラメータが異なります。
refresh_tokenは、アクセストークンの取得時に得られますので控えておきましょう。

アクセストークンの取得時は失敗したのですが、更新時には成功しました。
なにが違うんだろう……。

Step6. アクセストークンの破棄

アクセストークンが要らなくなったら、アクセストークンを破棄しましょう。
特に特別なことはなく、APIに情報を送れば大丈夫です。

static async Task RevokeTokenAsync(HttpClient inHttpClient, string inClientID, string inAccessToken)
{
    var url = "https://api.twitter.com/2/oauth2/revoke";
    var request = new HttpRequestMessage(HttpMethod.Post, url);
    request.Content = new FormUrlEncodedContent(new Dictionary<string, string>
    {
	{ "token_type_hint", "access_token" },
	{ "token", inAccessToken },
	{ "client_id", inClientID },
    });

    var response = await inHttpClient.SendAsync(request);
    var json = await response.Content.ReadAsStringAsync();
}

レスポンスには、破棄できたかどうかの情報が含まれているので、必要であれば利用しましょう。

おわりに

以上となります。
いろいろと妥協がありましたが、特に誰かに使われる予定のないアプリ制作なのでヨシ、といったところです。
そもそもTwitterAPI使うんだったらWebアプリとかでしょうし、そちらなら本記事のような問題はないんでしょうか。

Discussion

oipestoipest

「Step3. アクセストークンの取得、ができない」は「redirect_url」ではなく「redirect_uri」にしたらどうでしょうか。

たまごぉたまごぉ

ご指摘ありがとうございます。

お恥ずかしいことにご指摘の通りで、誤字でした。修正したところ、無事にトークンの取得に成功しました。
どうしてcurlでは成功するのかと思っていたら、そちらでは"uri"にしていたようです……。