Auth0 SPAの認証を理解しながら、ミニマムのモック認証APIコンテナを作ってみる

この記事は 弁護士ドットコム [Advent Calendar 2022](https://qiita.com/advent-calendar/2022/bengo4com) の 2 日目の記事です。

Auth0を本番サービスで採用したのですが、開発環境やCIで動かすe2eテストで毎回Auth0のテナントにアクセスされると困ります。モックコンテナにしたいですが、公式では提供していなさそうです。

そこでAuth0のリクエストを分析し、OpenID Connectの仕様を確認して、Auth0認証もどきを作成することでかなり理解が進みました。

Auth0のモック認証APIの全体コードは下記にあります。はりぼての雑な作りなので、本番で動かすのは危険です。

https://github.com/komtaki/auth0-spa-mock

情報が少なかったのでまとめたので、Auth0で開発する際の参考になれば幸いです。JWT認証については勉強中なので、間違っているところがあれば教えてください。

# 三文まとめ

- 認証をモックするのに必要なエンドポイントは3つ。
- 必要なのはトークンを取得するための2つのエンドポイントと公開鍵を提供するエンドポイント
- RS256の秘密鍵でトークンに署名し、使う側は公開鍵で検証する

# Auth0のSPA + API構成での認証

基本はOAuth2のAuthorization Code Flowに沿って認証されます。

エンドポイントの仕様は下記にまとまっています。

https://auth0.com/docs/authenticate/protocols/oauth#how-response-mode-works

## リクエストの流れ

>![auth-sequence-auth-code.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525723/f4bf7ff5-dc04-cb83-501f-f373641d0bb6.png)
>出典: [Authorization Code Flow](https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow)

1. ユーザーが `ログイン` をクリックします
2. Auth0 の SDK は、ユーザーを Auth0 Authorization Server `/authorize` にリダイレクトします
3. Auth0 承認サーバーは、ユーザーをログインおよび承認プロンプトにリダイレクトします。
4. ユーザーは、構成されたログイン オプションの 1 つを使用して認証します。
5. Auth0 認可サーバーは、ユーザーを認可付きのアプリケーションにリダイレクトします。
6. Auth0 の SDK はこれ codeを Auth0 Authorization Server に送信します `/oauth/token` アプリケーションのクライアント ID とクライアント シークレットと共に。
7. Auth0 認可サーバーは、コード、クライアント ID、およびクライアント シークレットを検証します。
8. Auth0 認可サーバーは、ID トークンとアクセス トークンで応答します。
9. アプリケーションは、アクセス トークンを使用して API を呼び出し、ユーザーに関する情報にアクセスできます。
10. API は要求されたデータで応答します。

:::note
- 認証するのが `/authorize` エンドポイント、トークンの取得が `/oauth/token`エンドポイントです。
- Auth0へのリクエストはiframeから行われるものと、iframe外通常のJSから実行されるものの2種類があります。
:::

## RS256

Auth0 では、JSON Web トークン (JWT) に署名するために、`RS256` と `HS256` の 2 つのアルゴリズムがサポートされてます。`HS256` はクライアントのデフォルトで、`RS256` は API のデフォルトです。

JWT に署名する場合の推奨されるアルゴリズムは `RS256` なので、 `RS256`で進めます。

:::note
RS256 (SHA-256 を使用した RSA 署名) は、公開鍵と秘密鍵のペアを使う非対称アルゴリズムです。ID プロバイダーには、署名を生成するための秘密鍵があり、JWT の受信者は公開鍵を使用して JWT 署名を検証します。
:::

# 仕組みと実装

公式の説明はこちらで丁寧に説明されています。

https://auth0.com/docs/authenticate/protocols/oauth#how-response-mode-works

まずモック用のコンテナでは、公開鍵と秘密鍵を作成します。その後、JWTの署名の処理や公開鍵を公開するエンドポイントを作っていきます。

JWTの署名にはAuth0の[jsonwebtoken](https://github.com/auth0/node-jsonwebtoken)を使い、APIサーバーには[express](https://expressjs.com/)を使っています。

## 鍵の作成

まずOpenSSL コマンドを用いて、RSA 公開鍵暗号方式の秘密鍵を作成します。パスフレーズはなしでOKです。その後、自分の秘密鍵で自己署名した証明書を作成します。


```sh
openssl genrsa 2048 > private.key
openssl req -new -key private.Key -out server.csr
openssl x509 -in server.csr -out public.crt -req -signkey private.key -days 3650
```

## アプリの初期化

バリデーションには[express-openapi-validator](https://www.npmjs.com/package/express-openapi-validator)を使っています。なので、Controllerにリクエストが来た時には、バリデーションが完了しています。

HTMLのレンダリングにはテンプレートエンジンの[ejs](https://ejs.co/)を使っています。

```/app/src/index.ts
import express, { Request, Response } from "express";
import cors from "cors";
import bodyParser from "body-parser";
import morgan from "morgan";
import path from "path";
import AuthorizeController from "./controllers/AuthorizeController";
import OAuthTokenController from "./controllers/OAuthTokenController";
import * as OpenApiValidator from "express-openapi-validator";

const app = express();

app
  .options("*", cors())
  .use(morgan("combined"))
  .use(cors())
  .use(bodyParser.json())
  .use(bodyParser.urlencoded({ extended: true }))
  .use(express.static("public"));

// OpenAPIバリデーションの設定
app.use(
  OpenApiValidator.middleware({
    apiSpec: "./openapi.yml",
    validateRequests: true,
    validateResponses: true,
  })
);

app.use((err: any, req: Request, res: Response, next: any) => {
  res.status(err.status || 500).json({
    message: err.message,
    errors: err.errors,
  });
  next()
});

// テンプレートエンジン登録
app
  .set("views", path.join(__dirname, "/views"))
  .set("view engine", "ejs");

// Controller登録
app.post("/oauth/token", OAuthTokenController.post);
app.get("/authorize", AuthorizeController.get);

app.listen(80, () => {
  console.log("Auth0-Mock-Server listening on port 80!");
});
```

## GET: /.well-known/jwks.json

Auth0 は各テナントの JWKS エンドポイントを公開しています。デフォルトは　`https://your-tenant.auth0.com/.well-known/jwks.json` です。ここにはテナントのすべての Auth0 発行 JWT に署名するために使用される JWK が含まれます。

下記がモックサーバーのリクエストの結果です。

```/.well-known/jwks.json
{
  keys: [
    {
      alg: "RS256",
      kty: "RSA",
      use: "sig",
      n: "vMPd9fkWPVbz-pc5YouWVleBRWdm-3fFYfFS3hjxRe6MSUfgPbGKk9fPJ2atJRGBDtXAmLiZ-bLbaXpkvECdO9Bdoj7bjPT_dgDybPxtctbtp9Z3MKQgVCC31RdWrfMEbKhWWqj-vKeXG8V2ve5ryrObGEPENwDmjIeBOzdSVkgla1U8iBjGBKuddwMAQhL83CCumKO-WijJJmHWGjVjaFasYUfRLVrPkcrigrwHVxJyk8zGRhyuBxUckq8CTqUOdbKoh_oaEnTgGaFQgK_h11RRqn96tPpi5BECqPAKl1umCIuSxf2pCoGW9m3bxNgD-Mak3P1Fjw4RS8cLgpgAqQ",
      e: "AQAB",
      kid: "xxxxx",
      x5t: "xxxxx",
      x5c: ["MIICljCCAX4CCQDBUFX+I486qjANBgkqhkiG9w0BAQUFADANMQswCQYDVQQGEwJKUDAeFw0yMjExMTEwNjUzMjlaFw0zMjExMDgwNjUzMjlaMA0xCzAJBgNVBAYTAkpQMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1mXHcSQCM/0mnb1sVT66r9V91A8fsc/IN/C5Ci4hU7VsXchMAM7Hg/OmDWHNHxl94VmqVrI2NJPt9PeNGc7vB8+9A4Olsco4RoNBCQEUbSJX+KDqmkScBGLE8RkwV824xyr56ZFp5P6N1bZoizd59K2Z0PEI4/Xx9Znh/O5L/VRNW/ogh2jcK4vUVru9ywGpm9Jc1h28ZFmuuzlVbEjVrTDgo8hvOS4EpBZFJ+0CM9om3c2RzESXrlUlxyDStwPHNkmS4x27+bziNT4x7WAe2Y86nsewh1t0sZlOkcMZ+3O2kl9EZOTRQ0nY9Qis2875J8xeYP8uFdIIt/zpWiXerQIDAQABMA0GCSqGSIb3DQEBBQUAA4IBAQCC2tSrBckZlgNRZkt1e/9nRjaaa6sVCMqMGzD5ezuJsgKCPUG/DHCKNnlo8tDp7pxQK5eosh49koJDpsMWmqAruAqZ9bgyhLF3CUyz3VUvAGGGx8Cuqd8HEm8WFhDpyTcr1dlinkodoqiYc/1OQTc0KsnK/NOkeE7eUqtC9S1bKsncOgZQnYvhQm1l1mEdi8SuE5FqTZqT//ezJ3JD95vKam6KzZ4mjRr28dg5W59q1aUn36x+fcPW4GxOIlMphzKv6rdKxNHlrCuA7ANIqmdWQLkTRkz6mBYEJXnaKcK9Fjk/iOEjMGhHUxCw5utmIK3/GNPvHtd81xoGlF10Dfyo"]
    }
   ]
}
```

### 各プロパティの仕様

|プロパティ名|目的|
| -- | -- |
| alg | キーのアルゴリズム |
| kty | 鍵のタイプ |
| use | キーの使用方法。sigは署名の検証を表します。|
| x5c | x509 証明書チェーン |
| e   | 標準の pem の指数 |
| n   | 標準の pem のモジュラス |
| kid | キーの一意の識別子 |
| x5t | x.509 証明書の拇印 (SHA-1 拇印) |

RFC7517をベースに一部プロパティを増やしているようです。

https://tex2e.github.io/rfc-translater/html/rfc7517.html

### JWKS エンドポイントを使用した JWT の検証

APIで検証する流れは下記のようになります。

1. このエンドポイントにリクエストして、JWKS(鍵)を取得します。
2. リクエストの　`Authorization`ヘッダーから JWT を抽出します。
3. JWT をデコードし、ヘッダーから`kid`プロパティを取得します。
4. 1で取得したJWKSから、一致する`kid` プロパティを持つ署名検証キーを見つけます。
5. `x5c` プロパティを使用して、JWT 署名の検証に使用される証明書を作成します。
6. JWT に想定されるオーディエンス、発行者、有効期限などが含まれていることを確認します。

:::note
この後実装するトークンの署名の時の`kid`とJWTSの`kid`を合わせる必要があります。また、公開鍵のデータは `x5c`に含まれます。
:::

### 実装

expressの実行前に静的ファイルを`/app/public/.well-known/jwks.json`に吐き出します。

実装する時には、動作に影響のないe, nなどは適当にダミーを入れてます。

```/app/src/utils/jwt.ts
import fs from "fs";
import jsonwebtoken from "jsonwebtoken";

const privateKey = fs.readFileSync("/app/config/private.key");

class JWT {
  static readonly KID = "xxxxx";

  static readonly alg = "RS256";

  static sign = (params: any) =>
    jsonwebtoken.sign(params, privateKey, {
      algorithm: JWT.alg,
      // ヘッダーにkidを含める
      header: {
        alg: JWT.alg,
        typ: "JWT",
        kid: JWT.KID,
      },
    });
}

export default JWT;

```

```/app/bin/createPubJWTKey.ts
import fs from "fs";
import JWT from "../src/utils/jwt";

// 公開鍵を読み込んで改行を削除しフォーマットする
const pubKeyText = fs
  .readFileSync("/app/config/public.crt", "utf8")
  .replace(/\r?\n/g, '')
  .replace('-----BEGIN CERTIFICATE-----', '')
  .replace('-----END CERTIFICATE-----', '');

// Jsonのbodyを作る
const result = {
  keys: [
    {
      alg: JWT.alg,
      kty: "RSA",
      use: "sig",
      n: "vMPd9fkWPVbz-pc5YouWVleBRWdm-3fFYfFS3hjxRe6MSUfgPbGKk9fPJ2atJRGBDtXAmLiZ-bLbaXpkvECdO9Bdoj7bjPT_dgDybPxtctbtp9Z3MKQgVCC31RdWrfMEbKhWWqj-vKeXG8V2ve5ryrObGEPENwDmjIeBOzdSVkgla1U8iBjGBKuddwMAQhL83CCumKO-WijJJmHWGjVjaFasYUfRLVrPkcrigrwHVxJyk8zGRhyuBxUckq8CTqUOdbKoh_oaEnTgGaFQgK_h11RRqn96tPpi5BECqPAKl1umCIuSxf2pCoGW9m3bxNgD-Mak3P1Fjw4RS8cLgpgAqQ",
      e: "AQAB",
      kid: JWT.KID,
      x5t: JWT.KID,
      x5c: [pubKeyText],
    },
  ],
};

const dirPath = "/app/public/.well-known";

// ディレクトリがなければ作成
if(!fs.existsSync(dirPath)) {
    fs.mkdirSync(dirPath,  { recursive: true })
}

// jsonファイルを出力
fs.writeFileSync(`${dirPath}/jwks.json`, JSON.stringify(result));
```

## GET: /authorize

### Web Messaging APIでのサイレント認証

クエリーパラメーターの`response_mode`が、`query`ならばredirectしてクエリーパラメータで結果を返し、`web_message`ならばWeb Messaging API を使ったHTMLを返します。

response_modeの`web_message` は 画面を表示させないためのオプションのprompt=noneと一緒に使われるSPAでサイレント認証を実現する仕組みです。

サイレント認証する場合、下記のような流れになります。

1. ユーザに見えない iframe を生成する
2. iframe から /authorize へリクエストしてHTMLのレスポンスを受け取る
3. レスポンスのHTMLを使って認可コードを Web Message 経由で取得

こちらが非常にわかりやすいです。

https://qiita.com/ksakiyama134/items/bb5a97bf7762b4a87e0d

### Cookie

Auth0では、さまざまなcookieが払い出されます。

|名前|目的|
| -- | -- |
|auth0 |Auth0 セッション層を実装するために使用されます。|
|auth0_compat | sameSite=Noneこの属性をサポートしていないブラウザーでのシングル サインオン用のフォールバック Cookie |
|did | 攻撃保護のためのデバイス識別|
|did_compat | sameSite=None属性をサポートしていないブラウザーで異常を取得するためのフォールバック Cookie |


シングルサインや攻撃防御の代表的なCookieは上記ですが、詳しくはこちらを確認ください。

https://auth0.com/docs/manage-users/cookies/authentication-api-cookies

今回のモックコンテナではセッションの維持のため雑に固定値で付与します。localhost:1080とかで動かすと、PORT番号の指定が必要になるので使いづらくなるので。

### 実装

クエリーパラメーターの`response_mode`を見て、`query`ならばクエリーパラメータをつけてredirectして、`web_message`ならばWeb Messaging API を使ったHTMLを返します。

```/app/src/controllers/AuthorizeController.ts
import { Request, Response } from "express";
import TokenCache from "../models/TokenCache";

class AuthorizeController {
  static get = (req: Request, res: Response) => {
    const currentDate = new Date();
    currentDate.setDate(currentDate.getDate() + 3);

    const cache = new TokenCache();
    // tokenを発行する時につかうので、node-cacheに雑に保存します。
    cache.set({
      nonce: req.query["nonce"] as string,
      scope: req.query["scope"] as string,
      audience: req.query["audience"] as string || ''
    })

    const params = {
      code: "ZtVJQyXf9eOOQJi5h_xpY6CSgEcr092_3TTWT28zsy42z",
      state: req.query!["state"] as string,
    };

    // HTMLで返すか、クエリーで返すか分岐します。
    switch (req.query["response_mode"]) {
      case "query":
        return AuthorizeController.createRedirectResponse(params, req, res);
      case "web_message":
        return AuthorizeController.createHTMLResponse(params, req, res);
      default:
        return res.status(400);
    }
  };

  private static createRedirectResponse = (
    params: { code: string; state: string },
    req: Request,
    res: Response
  ) => {
    const urlSearchParam = new URLSearchParams(params).toString();
    res.redirect(`${req.query!["redirect_uri"]}?${urlSearchParam.toString()}`);
  };

  private static createHTMLResponse = (
    params: { code: string; state: string },
    req: Request,
    res: Response
  ) => {
    const redirectUrl = new URL(req.query!["redirect_uri"] as string)
    const targetOrigin = redirectUrl.origin
    res.render('index', {...params, targetOrigin })
  };
}

export default AuthorizeController;
```


```/app/src/views/index.ejs
<!DOCTYPE html>
<html>
  <head>
    <title>Authorization Response</title>
  </head>
  <body>
    <script type="text/javascript">
      (function (window, document) {
        var targetOrigin = "<%= targetOrigin %>";
        var webMessageRequest = {};
        var authorizationResponse = {
          type: "authorization_response",
          response: {
            code: "<%= code %>",
            state: "<%= state %>",
          },
        };
        var mainWin = window.opener ? window.opener : window.parent;
        if (
          webMessageRequest["web_message_uri"] &&
          webMessageRequest["web_message_target"]
        ) {
          window.addEventListener("message", function (evt) {
            if (evt.origin != targetOrigin) return;
            switch (evt.data.type) {
              case "relay_response":
                var messageTargetWindow =
                  evt.source.frames[webMessageRequest["web_message_target"]];
                if (messageTargetWindow) {
                  messageTargetWindow.postMessage(
                    authorizationResponse,
                    webMessageRequest["web_message_uri"]
                  );
                  window.close();
                }
                break;
            }
          });
          mainWin.postMessage(
            {
              type: "relay_request",
            },
            targetOrigin
          );
        } else {
          mainWin.postMessage(authorizationResponse, targetOrigin);
        }
      })(this, this.document);
    </script>
  </body>
</html>

```

## POST: /oauth/token/

### レスポンス

token取得のエンドポイントです。`access_token`と`id_token`がふくまれます.

モックコンテナでのレスポンスのイメージはこのようになります。


```json
{
  access_token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Inh4eHh4In0.eyJodHRwOi8vbG9jYWxob3N0OjEwODAvZW1haWwiOiJiZW5nbzRAZXhhbXBsZS5jb20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjEwODAvIiwic3ViIjoiYXV0aDB8NjM0Njc3YTA1YjA0Mjk4MDFlZTA1YmU1IiwiYXVkIjpbImh0dHA6Ly9sb2NhbGhvc3Q6MTgwODAiLCJ4eHgiXSwiaWF0IjoxNjY5ODcyNTUxLCJleHAiOjE2Njk5NTg5NTEsImF6cCI6Inh4eHgiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGVtYWlsIiwicGVybWlzc2lvbnMiOltdfQ.uIVsA3RVPEAvICih8rZBlGLtIgQDj_FW2o5M1YSB4yryd_Bjd7gnTB7EcR45dh7FcBlTXI48CznZ6WmMA5TMaALR7Ptp604q-sPgepEodcLPvl_jgAnq0vMb0ITLlcfA0tisknjoQ0OT32cSy8rnbc5YTU7lX9hv84nRd5zWBLKUZS_oqlMMXXXPsHSoDsi0uzL7WON2NKFd2HkxxqfTbJmXLULhOfaKSBl3Tlwrf2QhFm3ymQzmtkNQh6Zqw5Vbn5OIq-ZMCipQ_ccIYTFjQrGdYmy-P92a3opN4O8RX5is8CCB9rP5pqNZCoo2dZ58OgtB-8yIlhP-JFglzaiL-g",
  expires_in: 86400,
  id_token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Inh4eHh4In0.eyJuaWNrbmFtZSI6IiIsIm5hbWUiOiJiZW5nbzRAZXhhbXBsZS5jb20iLCJwaWN0dXJlIjoiIiwidXBkYXRlZF9hdCI6IjIwMjItMTEtMTBUMDg6MzQ6NTQuMzY4WiIsImVtYWlsIjoiYmVuZ280QGV4YW1wbGUuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6MTA4MC8iLCJzdWIiOiJhdXRoMHw2MzQ2NzdhMDViMDQyOTgwMWVlMDViZTUiLCJhdWQiOiJ4eHh4IiwiaWF0IjoxNjY5ODcyNTUxLCJleHAiOjE2Njk5NTg5NTEsInNpZCI6ImtNQXNhb1B0bG5zZXZuVFNmRDg3R1Q1eDNCLXd4SWJsIiwibm9uY2UiOiJlRGczVlRWVWFHbHhZVVp5T0hCbGJETnFRa0ZtVTB4d1RIVlBja0Y0UlM1cVJHZG9SWEk1V2s1TVJ3PT0ifQ.MH9lQgAukP8-zy85RxsRqgI5zM399_eDE2vqFMnmNXq0t5Kfi0kRp0OF8q_j8R3b_ONQZIRZeW9atCHOupJtWKhVYyMY5ZM2REH5t1xBq4oDuuE4mNhTL5eQ08zU8kwRkduNVMTXfmy2aNfVKRx52tbuw3nZWrM7rf9c1v4HtJj37AGe-Xp_c7ksqkdfZ7AxKwqWU5QV0M-cm5e3ufv5NpzCkSFdYly4aW9bKQy3RmW2aHPAbmmWoavFIweYeOnasSHKH4V0ghelwI9WUr7FANW_W-QRUTRWG8hwY3menYoz1gISYlIaZcw1TzMfGMw3yWF6JfdOFE_BkDdGb21yyQ",
  scope: "openid profile email",
  token_type:  "Bearer",
}
```

id_tokenやaccesstokenの詳しい仕組みはこちらが詳しいです。

https://qiita.com/TakahikoKawasaki/items/8f0e422c7edd2d220e06#6-json-web-token-jwt

もちろん[jwt.io](https://jwt.io/)でも、トークンはデコードできます。

![スクリーンショット 2022-12-01 14.32.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/525723/e68ac405-32fd-100e-b48b-f1ecc4cc9217.png)



### 実装

環境変数で、メルアドやissuerを差し込んでいます。

```/app/src/controllers/OAuthTokenController.ts
import { Request, Response } from "express";
import TokenCache from "../models/TokenCache";

class AuthorizeController {
  get = (req: Request, res: Response) => {
    const currentDate = new Date();
    currentDate.setDate(currentDate.getDate() + 3);

    // authorizeで保存した値を復元
    const cache = new TokenCache();
    cache.set({
      nonce: req.query["nonce"] as string,
      scope: req.query["scope"] as string,
      audience: req.query["audience"] as string || ''
    })

    const params = {
      code: "ZtVJQyXf9eOOQJi5h_xpY6CSgEcr092_3TTWT28zsy42z",
      state: req.query!["state"] as string,
    };

    switch (req.query["response_mode"]) {
      case "query":
        return AuthorizeController.createRedirectResponse(params, req, res);
      case "web_message":
        return AuthorizeController.createHTMLResponse(params, req, res);
      default:
        return res.status(400);
    }
  };

  private static createRedirectResponse = (
    params: { code: string; state: string },
    req: Request,
    res: Response
  ) => {
    const urlSearchParam = new URLSearchParams(params).toString();
    res.redirect(`${req.query!["redirect_uri"]}?${urlSearchParam.toString()}`);
  };

  private static createHTMLResponse = (
    params: { code: string; state: string },
    req: Request,
    res: Response
  ) => {
    const redirectUrl = new URL(req.query!["redirect_uri"] as string)
    const targetOrigin = redirectUrl.origin
    res.render('index', {...params, targetOrigin })
  };
}

export default AuthorizeController;
```


# まとめ

Auth0のリクエストの仕組みがわかったのではないでしょうか？

SDKを使って認証するだけだと簡単ですが、中がどうなっているのかわかるとできることは大きいです。今回、Auth0の仕様をきちんと確認したことでかなり理解が進みました。

早くAuth0が公式のローカル用やCIで使いやすいダミーコンテナを出してくれることを祈ります。

明日は @Koichi さんです！よろしくおねがいします！


# 参考

下記を参考にさせていただきました。ありがとうございます。 :bow:

- [OpenSSL で RSA の秘密鍵を作成する方法](https://weblabo.oscasierra.net/openssl-genrsa-secret-1/)
- [OAuth & OpenID Connect 関連仕様まとめ - Qiita](https://qiita.com/TakahikoKawasaki/items/185d34814eb9f7ac7ef3)
- [RS256 と HS256 ってなにが違うの - Qiita](https://qiita.com/satour/items/e68afe3de6267cebcfea)
- [response_mode=web_messageを調べた
](https://qiita.com/ksakiyama134/items/bb5a97bf7762b4a87e0d)
- [Navigating RS256 and JWKS - Auth0 Blog](https://auth0.com/blog/navigating-rs256-and-jwks/)
- [Where should the 'state' nonce be generated and checked? - Auth0 Community](https://community.auth0.com/t/where-should-the-state-nonce-be-generated-and-checked/9317/1)