【Node.js】Google認証でOAuth 2.0とOpenID Connect学んでみた

OAuth 2.0およびOpenID Connectの勉強のため、Google OAuth 2.0 API を使って、Node.jsでGoogle認証を行うクライアントアプリを実装してみました。Googleの認可/Idpサーバを使用し、トークンエンドポイントから`アクセストークン`と`IDトークン`を取得するところまで実装しています。

https://github.com/fcf-koga/google-oauth2client-node

この記事では、OAuth 2.0 の認可コードフローに対応したクライアントアプリを実装しながら学んだことを整理しておきます。

## シーケンス図
今回実装した、OpenID Connectのクライアントアプリのシーケンス図です。
基本的なOAuth 2.0の「認可コードフロー」になっています。

```mermaid
sequenceDiagram
    Actor U as ユーザー
    participant A as ブラウザ
    participant B as [クライアントアプリ]
    participant D as [Google]<br/>ディスカバリEP
    participant E as [Google]<br/>認可EP
    participant F as [Google]<br/>トークンEP
    U->>A: start
    activate A
    A->>B: GET /
    deactivate A
    activate B
    B-->>A: 
    deactivate B
    activate A
    rect rgb(240,255,240)
    A->>B: GET /oath2
    deactivate A
    activate B
    B->>D: ディスカバリドキュメントを取得
    activate D
    D-->>B: 
    deactivate D
    B-->>B: 認可EP URIを取得
    B-->>B: 認可リクエストクエリパラメータ設定<br/>（client_id、response_type:"code"、scope:["openid email profile"]、redirect_uri、prompt: "select_account"）
    B-->>A: 認可EP へ302リダイレクト
    deactivate B
    activate A
    A->>E: 認可リクエスト（GET）
    deactivate A
    activate E
    E-->>A: Google同意画面をレスポンス
    deactivate E
    activate A
    U->>A: 認可＋認可操作
    A->>E: 認可
    activate E
    E-->>A: 
    deactivate E
    end
    rect rgb(240,255,255)
    A->>B: GET /callback
    deactivate A
    activate B
    B->>B: リクエストクエリパラメータから認可コード※取得
    B->>D: ディスカバリドキュメントを取得
    activate D
    D-->>B: 
    deactivate D
    B-->>B: トークンEP URIを取得
    B-->>B: トークンリクエストボディ設定<br/>（code※、client_id、client_secret、redirect_uri、grant_type: "authorization_code"）
    B->>F: トークンリクエスト（POST）
    activate F
    F-->>B: 
    deactivate F
    B->>B: レスポンスからトークン情報取得<br/>（access_token、expires_in、scope、token_type、id_token）
    B-->>A: 
    deactivate B
    activate A
    end
    A->>U: end
    deactivate A
```
背景が薄い緑の部分が認可リクエストに関する処理、薄い水色がトークンリクエストに関する処理になります。
## 学習メモ

### OAuth 2.0 クライアント設定
OAuth 2.0 ではクライアントアプリを識別するためのクライアント情報の登録が必要となります。Google OAuth 2.0 APIにおけるクライアント設定は[Google API Console](https://console.cloud.google.com/apis/dashboard?hl=ja&project=fluent-imprint-344112)でクライアント情報を設定します。

クライアントを設定すると`client_id`と`client_secret`が払い出されます。この情報は主に認可サーバでのクライアント認証で使用されます。

また、認可サーバからのリダイレクト先として承認するURIをクライアントへ設定する必要があります。今回のリダイレクト先URIは`http://localhost:8080/callback`としました。

```Node.js
const REDIRECT_URI = "http://localhost:8080/callback"
const CLIENT_ID = "クライアントID"
const CLIENT_SECRET = "シークレットID"
```

### 認可リクエスト
認可リクエストは認可エンドポイントへ下記のようなリクエストを送信します。

```
  GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz
      &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
  Host: server.example.com
```

Googleはディスカバリドキュメント（`.well-known/openid-configuration`）を公開しているので、この中の`authorization_endpoint`パラメータから認可エンドポイントURIを取得します。

https://developers.google.com/identity/openid-connect/openid-connect?hl=ja#discovery


また、認可リクエストはユーザーエージェントを介して送信する必要があるため、認可リクエストURLへのリダイレクトをブラウザに返します。

```Node.js
  // ディスカバリドキュメントから認可エンドポイントを取得する。
  const resp = await fetch(DISCOVERY_ENDPOINT);
  const provider_cfg = await resp.json();
  const authorization_endpoint = provider_cfg['authorization_endpoint']

  // 認可リクエスト用のパラメータの設定
  const params = {
    client_id: CLIENT_ID,
    response_type: "code",
    scope: ["openid email profile"],
    redirect_uri: REDIRECT_URI,
    prompt: "select_account",
  };

  const query = new URLSearchParams(params);
  const request_url = authorization_endpoint + `?${query}`

  // ブラウザにリダイレクトを返す
  response.writeHead(302, { 'Location': request_url })
  response.end()
  ```
  
リクエストで指定するパラメータについては下記を参考にしました。
- [4.1.1.  認可リクエスト](https://openid-foundation-japan.github.io/rfc6749.ja.html#:~:text=%C2%A0TOC-,4.1.1.%C2%A0%20%E8%AA%8D%E5%8F%AF%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88,-%E3%82%AF%E3%83%A9%E3%82%A4%E3%82%A2%E3%83%B3%E3%83%88%E3%81%AF%2C)

| パラメータ | 必須 | 設定値例 | 備考 |
|:-|:-:|:-|:-|
| client_id  | ◯ | `"クライアントID"`  | クライアント設定で払い出されたクライアント識別子を指定  |
|  response_type | ◯ |  `"code"`  | 認可コードフローの場合は`"code"`を指定  |
|  scope | - | `"openid" "email" "profile"`  | スコープはスペース区切りで指定。値の順序は考慮しない。  |
|  redirect_uri | - |  `"http://localhost:8080/callback"`  | 絶対URIで指定。クライアントに設定した「承認済みリダイレクトURI」と完全一致しなければならない。
|  state | - | `"xyz"` | 設定が推奨されている。認可リクエストに含めた`state`の値と後述の認可レスポンスで返された`state`の値が同一であることを確認することでCSRF対策が行える。 |

### 認可レスポンス
認可リクエストを送信後、ユーザーの認証+認可操作が正常に完了すると、下記のような認可レスポンスがブラウザに返されます。

```
  HTTP/1.1 302 Found
  Location: http://localhost:8080/callback?code=SplxlOBeZQQYbYS6WxSbIA
            &state=xyz
```

リダイレクト先としては認可リクエストで`redirect_uri`に指定したURIが設定され、クエリパラメータに`code`が含まれていることがわかります。

この`code`が認可サーバから返された認可コードです。認可コードはユーザーの認証・認可が正常に実施されたことを表しており、この`code`の値をトークンエンドポイントのリクエストパラメータに含めることで、アクセストークンと交換することができます。

レスポンスの各パラメータ項目の説明は下記となります。

| パラメータ | 必須 | 設定値例 | 備考 |
|:-|:-:|:-|:-|
| code  | ◯  | `SplxlOBeZQQYbYS6WxSbIA` | アクセストークンと交換するための短命なコード。有効期限は最大でも10分が推奨されている。  |
| state | - |  `xyz` |認可リクエストで`state`が含まれていた場合、必須。|

### トークンリクエスト
認可レスポンスで受け取った認可コードをトークンエンドポイント送信します。
```
  POST /token HTTP/1.1
  Host: server.example.com
  Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
  Content-Type: application/x-www-form-urlencoded

  grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
  &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb
```
Googleのディスカバリドキュメントの`token_endpoint`パラメータからトークンエンドポイントURIを取得します。

```Node.js
  //リクエストパラメータから認可コードを取得する
  const query = url.parse(request.url).query;
  const code = querystring.parse(query).code;

  // ディスカバリドキュメントからトークンエンドポイントを取得する。
  const resp = await fetch(DISCOVERY_ENDPOINT);
  const provider_cfg = await resp.json();
  const authorization_endpoint = provider_cfg['token_endpoint']

  // リクエストボディの設定
  const body = new URLSearchParams({
    "code": code,
    "client_id": CLIENT_ID,
    "client_secret": CLIENT_SECRET,
    "redirect_uri": REDIRECT_URI,
    "grant_type": "authorization_code",
  });

  const resp_token = await fetch(authorization_endpoint, {
    method: "POST",
    headers:{
      "Content-Type":"application/x-www-form-urlencoded",
    },
    body,
  })
  const token = await resp_token.json();
```
リクエストに含める各パラメータの説明は下記を参考にしました。
- [4.1.3.  アクセストークンリクエスト](https://openid-foundation-japan.github.io/rfc6749.ja.html#:~:text=%C2%A0TOC-,4.1.3.%C2%A0%20%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88,-%E3%82%AF%E3%83%A9%E3%82%A4%E3%82%A2%E3%83%B3%E3%83%88%E3%81%AF%E3%83%88%E3%83%BC%E3%82%AF)

| パラメータ | 必須 | 設定値例 | 備考 |
|:-|:-:|:-|:-|
| grant_type  | ◯ | `"authorization_code"`  |  認可コードフローであることを表す |
|  code | ◯ |  `"xyz"`  | 認可サーバーから受け取った認可コード  |
|  redirect_uri | - | `"http://localhost:8080/callback"`  | 認可リクエストで`redirect_uri`が含まれていた場合、必須。  |
|  client_id | - |  `"クライアントID"`  | クライアント認証済みの場合、指定は必須ではない。

### トークンレスポンス
トークンリクエストを送信後、下記のような認可レスポンスがJSON形式で返されます。これで、アクセストークンとIDトークンの取得ができました。
```
  HTTP/1.1 200 OK
  Content-Type: application/json;charset=UTF-8
  Cache-Control: no-store
  Pragma: no-cache

{
  access_token: 'ya29.〜',
  expires_in: 3599,
  scope: 'https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid',
  token_type: 'Bearer',
  id_token: 'eyJ〜'
}
  ```
---

まだまだOAuth 2.0 やOpenID Connectについて学習途中で、誤った記載があるかもですが、学んだことを整理しました。
（もし、誤りがある場合は指摘いただけると助かります）

以上です。

## 参考記事
- [RFC 6749 The OAuth 2.0 Authorization Framework](https://openid-foundation-japan.github.io/rfc6749.ja.html)
- [RFC 9068 JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html)
- [Google Identity OpenID Connect](https://developers.google.com/identity/openid-connect/openid-connect?hl=ja)
- [IDトークンが分かれば OpenID Connect が分かる](https://qiita.com/TakahikoKawasaki/items/8f0e422c7edd2d220e06)