GitHub の OAuth 認証を受けて発行された認証トークンを受け取る処理の実装方法

# GitHub の OAuth 認証を受けて発行された認証トークンを受け取る処理の実装方法

GitHub の情報を使う CLI アプリケーションを作成したくなり、手始めに GitHub へ認証するまでの処理を実装しました。実装上の細かい部分ではまりどころが多かったので、実装方法について記事化しました。

使用言語：TypeScript

実際のコード：https://github.com/kikugawa-shoma/learning-sandbox-pub/tree/main/typescript/github-oauth-sample

:::note alert
個人で使う CLI アプリでの認証を想定しています。
作成した CLI アプリを配布するようなケースは想定していません。(そのような場合は Client ID の取り扱いにさらなる考慮が必要になると思われます。)
:::

## OAuth 認証について軽く知る

OAuth については以下の記事が分かりやすかったです。
https://qiita.com/TakahikoKawasaki/items/e37caf50776e00e733be

詳細な技術仕様を知る必要がある場合は[RFC6749](https://datatracker.ietf.org/doc/html/rfc6749)を参照すると良いです。しかし今回の GitHub の OAuth 認証を受ける実装をする上では RFC レベルの詳細な仕様を知っておかなくとも可能でした。

## 実装すべきことを確認

[GitHub のドキュメントの OAuth アプリの承認のページ](https://docs.github.com/ja/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps)の説明に沿って実装を進めていくことになります。今回作成したいものは web アプリではなく CLI アプリなので、このページの中でも特に[デバイスフロー](https://docs.github.com/ja/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow)の項の説明に沿って実装をします。web アプリケーションフローとの違いは、認証フローの中でアプリのユーザーに、そのアプリがユーザーの GitHub のリソースへのアクセスを許可することを求めるステップがあるのですが、そのステップの際に web アプリケーションだとブラウザから直接リダイレクトさせることができ(CLI アプリだとブラウザ上では動いていないので直接的なリダイレクトをさせることは不可能)、この部分の説明がデバイスフローと少し異なるので項が分かれているものと思われますが、web アプリケーションフローとデバイスフローは本質的には同じです。

## 前準備:OAuth アプリを GitHub への登録

[GitHub のドキュメントの OAuth アプリの作成](https://docs.github.com/ja/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)の説明に沿って進めます。

この手順により Client ID を取得できます。この ID を認証時に GitHub へ渡すことにより、認証を受けようとしているアプリを GitHub 側が(一意に)特定するために使われます。

GUI での操作になりますがドキュメント通りに進めれば難なく完了できると思います。
注意点としては CLI アプリケーションでは不必要な`Homepage URL`と`Authorization callback URL`の入力が必須のフォームとなっていることです。適当な URL(`http://localhost`など)を設定します。

以下の記事が画像付きで説明されていて読みやすかったです。

https://maku.blog/p/ubkt3ai/

## ステップ 1: アプリケーションによる GitHub からのデバイス及びユーザ検証コードの要求

[公式ドキュメントの説明箇所](https://docs.github.com/ja/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#step-1-app-requests-the-device-and-user-verification-codes-from-github)を参考に実装します。

fetch API を使って`https://github.com/login/device/code`へ POST リクエストを送ります。以下の点に注意してください。

- HTTP ヘッダに`Content-Type: application/json`を指定すること。(これを指定しないと 404 エラーレスポンスが返されます。POST 先の URL が間違っているかと誤認するので注意です。)
- HTTP ヘッダに`Accept: application/json`を指定します。これにより JSON 形式のレポンすを受け取ることができます。マストではないですが、JSON だと後続の処理で扱いやすいので特に理由がなければ指定しておくとよいです。
- HTTP ボディに前準備で発行した Client ID とアプリケーションで必要な権限を指定します。以下のコード中で Client ID は環境変数から読むようにしています。そのために、`.env.local`ファイルを用意し、`node --env-fiel .env.local build/src/main.js`コマンドで環境変数ファイルを指定して実行しています。

```typescript:main.ts
const getUserVerifyCodeFromGithub = async () => {
  const VERIFICATION_URL = "https://github.com/login/device/code";

  const method = "POST";
  const body = {
    client_id: process.env.CLIENT_ID ?? "",
    scope: "repo",
  };
  const headers = {
    "Content-Type": "application/json",
    Accept: "application/json",
  };

  type LoginResponse = {
    device_code: string;
    user_code: string;
    verification_uri: string;
    expires_in: number;
    interval: number;
  };

  const response = await fetch(VERIFICATION_URL, {
    method,
    headers,
    body: JSON.stringify(body),
  });
  return (await response.json()) as LoginResponse;
};
```

```dot:.env.local
CLIENT_ID=XXXXXXXXXX
```

## ステップ 2: ブラウザでユーザコードの入力をユーザに促す

[公式ドキュメントの説明箇所](https://docs.github.com/ja/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#step-2-prompt-the-user-to-enter-the-user-code-in-a-browser)を参考に実装します。
ここはステップ 1 の POST リクエストのレスポンスで受け取った`user_code`をユーザーに提示して、`https://github.com/login/device`のページ上でこの`user_code`を入力するようにお願いするメッセージを出力するだけなので実装は簡単です。特に注意するポイントもないです。

```typescript:main.ts
const outputVerificationMessage = ({
  verificationUri,
  userCode,
}: {
  verificationUri: string;
  userCode: string;
}) => {
  console.log(`Please access ${verificationUri}.`);
  console.log(`And input code "${userCode}".`);
  console.log("After finished above steps, please press Enter key.");
};
```

## ステップ 3: ユーザがデバイスを認証したかアプリケーション側で確認

[公式ドキュメントの説明箇所](https://docs.github.com/ja/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#step-3-app-polls-github-to-check-if-the-user-authorized-the-device)を参考に実装します。

やることはユーザーが step2 で指示した操作を完了したか確認することです。（確認していたらアプリケーションコード中でアクセストークンを取得できるようになります。）この確認は`https://github.com/login/oauth/access_token`へ POST リクエストを送ることにより行います。

ドキュメントの説明だと上記のエンドポイントをポーリング（一定の時間間隔で何度も HTTP リクエストを送る）するように書いてありますが、今回は step2 の操作をユーザーが完了した時点で、ユーザーに Enter キーを入力してもらい、これをトリガーに上記のエンドポイントへリクエストを送るように実装しました。キー入力を確認するのに[keypress パッケージ](https://www.npmjs.com/package/keypress)を使いました。

Enter key の入力が確認されたあとは POST リクエストを送ります。step1 と同様に fetch API を使っています。ユーザーが Step2 の操作を完了している場合は 200 ステータスコードでレスポンスが返され、このレスポンスにアクセストークンが入っています。この POST リクエスト送信時に以下の点に注意してください。

- step1 と同様に HTTP ヘッダを指定する。(特に`Content-Type`ヘッダの指定を忘れると同じように 404 ステータスコードで返されるのでエラーの原因を誤認しやすいことに注意)

- HTTP ボディの`device_code`フィールドには step1 のレスポンスに詰まっている`device_code`を指定する。

```typescript:main.ts
const keypress = require("keypress");

// Enter keyが入力されると解決されるPromiseを返す関数
const listenEnterKeyPress = async () => {
  const promise = new Promise((resolve) => {
    process.stdin.on("keypress", function (_, key) {
      console.log('got "keypress"', key);
      if (key && key.ctrl && key.name == "c") {
        process.stdin.pause();
      }
      if (key && key.name == "return") {
        resolve("");
      }
    });
    process.stdin.setRawMode(true);
    process.stdin.resume();
  });
  await promise;
};

// https://www.npmjs.com/package/keypressへPOSTリクエストを送る関数
const confirmHasBeenAuthorized = async ({
  cliendId,
  deviceCode,
  grantType,
}: {
  cliendId: string;
  deviceCode: string;
  grantType: string;
}) => {

  const URL_TO_RECEIVE_ACCESS_TOKEN = "https://github.com/login/oauth/access_token"

  const body = {
    client_id: cliendId,
    device_code: deviceCode,
    grant_type: grantType,
  };
  const headers = {
    Accept: "application/json",
    "Content-Type": "application/json",
  };
  const method = "POST";
  const response = await fetch(URL_TO_RECEIVE_ACCESS_TOKEN, {
    method,
    headers,
    body: JSON.stringify(body),
  });

  type AuthorizeInfo = {
    access_token: string;
    token_type: string;
    scope: string;
  };
  return (await response.json()) as AuthorizeInfo;
};

const main = async () => {

  // ...
  // 前半部省略
  // ...

  // ユーザーの認証・認可操作 & Enterキー入力待ち
  await listenEnterKeyPress();

  // 認可されたか確認
  const { access_token } = await confirmHasBeenAuthorized({
    cliendId: process.env.CLIENT_ID ?? "",
    deviceCode: verifyInfo.device_code,
    grantType: `urn:ietf:params:oauth:grant-type:device_code`,
  });
};

main();
```

## 最終的なコード

```typescript:main.ts
const keypress = require("keypress");

keypress(process.stdin);

const getUserVerifyCodeFromGithub = async () => {
  const VERIFICATION_URL = "https://github.com/login/device/code";

  const method = "POST";
  const body = {
    client_id: process.env.CLIENT_ID ?? "",
    scope: "repo",
  };
  const headers = {
    // "Content-Type": "application/json",
    Accept: "application/json",
  };

  type LoginResponse = {
    device_code: string;
    user_code: string;
    verification_uri: string;
    expires_in: number;
    interval: number;
  };

  const response = await fetch(VERIFICATION_URL, {
    method,
    headers,
    body: JSON.stringify(body),
  });
  return (await response.json()) as LoginResponse;
};

const outputVerificationMessage = ({
  verificationUri,
  userCode,
}: {
  verificationUri: string;
  userCode: string;
}) => {
  console.log(`Please access ${verificationUri}.`);
  console.log(`And input code "${userCode}".`);
  console.log("After finished above steps, please press Enter key.");
};

const listenEnterKeyPress = async () => {
  const promise = new Promise((resolve) => {
    process.stdin.on("keypress", function (_, key) {
      console.log('got "keypress"', key);
      if (key && key.ctrl && key.name == "c") {
        process.stdin.pause();
      }
      if (key && key.name == "return") {
        resolve("");
      }
    });
    process.stdin.setRawMode(true);
    process.stdin.resume();
  });
  await promise;
};

const confirmHasBeenAuthorized = async ({
  cliendId,
  deviceCode,
  grantType,
}: {
  cliendId: string;
  deviceCode: string;
  grantType: string;
}) => {
  const URL_TO_RECEIVE_ACCESS_TOKEN = "https://github.com/login/oauth/access_token"

  const body = {
    client_id: cliendId,
    device_code: deviceCode,
    grant_type: grantType,
  };
  const headers = {
    Accept: "application/json",
    "Content-Type": "application/json",
  };
  const method = "POST";

  const response = await fetch(URL_TO_RECEIVE_ACCESS_TOKEN, {
    method,
    headers,
    body: JSON.stringify(body),
  });

  type AuthorizeInfo = {
    access_token: string;
    token_type: string;
    scope: string;
  };
  return (await response.json()) as AuthorizeInfo;
};

const main = async () => {
  // Step1:GitHubへデバイス及びユーザ検証コードを要求
  const verifyInfo = await getUserVerifyCodeFromGithub();

  // Step2:ユーザーへの認証・認可操作の指示メッセージ出力
  outputVerificationMessage({
    verificationUri: verifyInfo.verification_uri,
    userCode: verifyInfo.user_code,
  });

  // Step3-1:ユーザーの認証・認可操作 & Enterキー入力待ち
  await listenEnterKeyPress();

  // Step3-2:認可されたか確認
  const { access_token } = await confirmHasBeenAuthorized({
    cliendId: process.env.CLIENT_ID ?? "",
    deviceCode: verifyInfo.device_code,
    grantType: `urn:ietf:params:oauth:grant-type:device_code`,
  });
};

main();
```