Keycloakでトークンイントロスペクションが失敗する原因は？

※ この記事は個人ブログに投稿していた内容と同一です。

# はじめに

Keycloakは、オープンソースのシングルサインオン（SSO）ソフトウェアです。OpenID ConnectとOAuth2.0のプロトコルをサポートし、アプリケーションに対して認証および認可を行うための機能を提供します。

トークンイントロスペクションは、OpenID ConnectおよびOAuth2.0のプロトコルで使用されるトークンを検証する仕様です。この仕様は、トークンが現在アクティブかどうかなどをトークンのメタデータをもとに判別し、そのトークンに関するJSONドキュメントを返します。

https://datatracker.ietf.org/doc/html/rfc7662

Keycloakにもこのトークンイントロスペクションのエンドポイントが提供されています。わたしの環境（V21.0）では、Realm settings → GeneralにあるOpenID Endpoint Configurationというリンクから、トークンイントロスペクションエンドポイントのURLを確認することができます。

![OpenIDEndpointConfiguration.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/594132/f80e062d-e554-f87a-6b30-8beab0d54966.png)

# Postmanでアクセストークンを検証する

DockerでKeycloakを立て、API開発ツールであるPostmanを使ってアクセストークンの取得と検証を行いました。ポート番号を18080にマッピングしたため、localhost:18080がベースのURLになります。

アクセストークンの検証に成功すると、以下のようなレスポンスが返ってきます。

```json
{
    "exp": 1687480374,
    "iat": 1687480314,
    "jti": "ad218545-9469-4b8d-a279-0d8f7ccea34a",
    "iss": "http://localhost:18080/realms/master",
    "aud": [
        "master-realm",
        "account"
    ],
    "sub": "3f21276c-bcfb-4715-b88b-dbd68b672022",
    "typ": "Bearer",
    "azp": "practice",
    "session_state": "4bc0894d-902f-4c99-907c-f86fee308c22",
    "preferred_username": "admin",
    "email_verified": false,
    "acr": "1",
    "allowed-origins": [
        "/*"
    ],
    "realm_access": {
        "roles": [
            "create-realm",
            "default-roles-master",
            "offline_access",
            "admin",
            "uma_authorization"
        ]
    },
    "resource_access": {
        "master-realm": {
            "roles": [
                "view-realm",
                "view-identity-providers",
                "manage-identity-providers",
                "impersonation",
                "create-client",
                "manage-users",
                "query-realms",
                "view-authorization",
                "query-clients",
                "query-users",
                "manage-events",
                "manage-realm",
                "view-events",
                "view-users",
                "view-clients",
                "manage-authorization",
                "manage-clients",
                "query-groups"
            ]
        },
        "account": {
            "roles": [
                "manage-account",
                "manage-account-links",
                "view-profile"
            ]
        }
    },
    "scope": "email profile",
    "sid": "4bc0894d-902f-4c99-907c-f86fee308c22",
    "client_id": "practice",
    "username": "admin",
    "active": true
}
```

# アプリケーションで検証してみると

続いて、Dockerで立てたNode.jsのWebアプリケーションでアクセストークンの検証を行いました。サンプルコードが以下になります。なお、`${authSettings.baseUrl}`の部分には、keycloak:8080/realms/masterが入ります。

```typescript
export const tokenIntrospection = (
  req: Request,
  _: Response,
  next: NextFunction
) => {
  (async () => {
    // ヘッダーからトークンを取得
    const authorizationHeader = req.headers.authorization;
    if (
      !authorizationHeader ||
      authorizationHeader.startsWith("Bearer ") === false
    )
      throw new Exception("トークンが見つかりません", 401);

    const token = authorizationHeader.replace("Bearer ", "");

    // トークンを認証サーバーに送信し、有効かどうかを確認
    const authSettings = config.authSettings;
    const response = await axios.post(
      `${authSettings.baseUrl}/protocol/openid-connect/token/introspect`,
      {
        token,
        client_id: authSettings.clientId,
        client_secret: authSettings.clientSecret,
        token_hint: "access_token",
      },
      {
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
      }
    );

    const data = response.data;
    if (data.active === false) throw new Exception("トークンが無効です", 401);

    // 有効なトークンであれば、ユーザー情報をリクエストに付与
    req.user = {
      id: data.sub,
    };

    next();
  })().catch(next);
};
```

アクセストークンを取得するエンドポイントをNode.jsでまだ実装していなかったため、**トークンはPostmanで取得**し、その**検証はNode.js側**で行ってみました。

結果は失敗、常にトークンが非アクティブと判定されました。

```json
{
  "active": false
}
```

# 原因を探る

わたしはコンテナからだと検証できないのでは、と仮設を立て、Node.jsのアプリケーションをローカルで動かしてみました。`${authSettings.baseUrl}`のホスト名の部分をlocalhost:18080に変更し、再度Postmanでアクセストークンを取得、ローカルで動いているNode.jsで検証を行いました。

結果は成功、Postmanで取得・検証した時と同じようにトークンのメタデータを取得することができました。

## 本当にコンテナからでは検証できないのか

本当にコンテナからでは検証できないのか確認するために、コンテナ内でアクセストークンを取得し、Postman側とコンテナのNode.js側それぞれで検証を行いました。

結果は、Postman側では失敗し、コンテナのNode.js側では成功しました。

## 検証結果のまとめ

|  | localhost:18080で取得 | keycloak:8080で取得 |
|:-:|:-:|:-:|
|  **localhost:18080で検証** | ○ | × |
| **keycloak:8080で検証**  | × |○ |

# 結論

上記の実験結果から、どうやらアクセストークンを取得した時と同じURLで検証を行う必要があるようです。この仕様に関して、以下の投稿を発見しました。

https://stackoverflow.com/questions/53721588/keycloak-token-introspection-always-fails-with-activefalse

> You need to make sure that you introspect the token using the same DNS hostname/port as the request. Unfortunately that's a not widely documented "feature" of Keycloak... So use:

**リクエストした時と同じホスト名/ポート**でトークンをイントロスペクションする必要があると書いてあります。
今回の場合ですと、localhost:18080で取得したトークンをコンテナ内でイントロスペクションした為、失敗してしまったようです。

<details><summary>セッション</summary>

Keycloakでは、ログインに成功するとログインセッションを管理するための「セッション」が作成されます。ホストマシンでログインしてもコンテナ内のKeycloakではセッションが作成されなかったことが直接的な原因ではないかなと...

</details>

トークンイントロスペクションが常に失敗してしまう場合は、今回のようにURLが異なっていることが原因なのかもしれません。