KeycloakのAudience Support機能の使い方

Keycloakでは、OpenID Connectで発行したアクセストークンを使ってResource Server（バックエンドAPI）の認証認可を行うことができる。

この認証認可において、Resource Serverに渡したアクセストークンが別のResource Serverの呼び出しに再利用されることを防ぐために、アクセストークン（KeycloakではJWT形式で発行される）のaudience（audクレーム）に設定する値を制御する[Audience Support](https://www.keycloak.org/docs/latest/server_admin/index.html#_audience)という機能が提供されている。（アクセストークンに含めるaudienceを制限し、各Resource Serverでは認可時に自分のクライアントがアクセストークンのaudienceに含まれているか検証することで、アクセストークンの利用できる範囲を絞る仕組み。）

この機能の説明を読んでもあまり使い方が理解できなかったため、実際に動作を確認した。

#### まとめ

* Audience Supportでは以下の2つの方法でアクセストークンに設定するaudienceを制御できる。
  * ユーザーもしくはクライアントに設定したロールに基づいて自動設定する。（実際にはAudience Resolve Protocol Mapperにより設定される。）
  * OpenID Connect（OAuth 2.0）のscopeパラメータに指定された値に対応するaudienceを設定する。（実際にはClient Scopeに設定するProtocol Mapperでトークンにaudienceを追加する。）
* 利用範囲を狭めたアクセストークンを取得するために必要な手順を考えると、ロールベースの場合は事前にロールをユーザーもしくはクライアントに割り当てておく必要があり、scopeパラメータの場合はAuthorization Code Flowではリダイレクトが発生する。
* ログイン後にResource Serverごとにこの操作を行うというのは理想的ではないため、この機能はあくまでアクセストークンへのaudienceの設定に使い、利用範囲を狭めたトークンの取得には[OAuth 2.0 Token Exchange](https://tools.ietf.org/html/rfc8693)（[Keycloakではプレビューの段階](https://www.keycloak.org/docs/latest/securing_apps/#_token-exchange)であるが）を使うのが良い。

#### 下準備

検査環境は[Keycloakの認可機能の使い方（RBAC編）](https://qiita.com/unhurried/items/78feead6063b698e933a)と同じものを利用している。

* 管理者用アクセストークンを取得する関数を用意する。

  ```bash
  BASE_URI=http://keycloak:8080/auth
  get-token() {
    ACCESS_TOKEN=$(curl -s -X POST \
      "${BASE_URI}/realms/master/protocol/openid-connect/token" \
      -d "client_id=admin-cli" \
      -d "username=admin" \
      -d "password=admin" \
      -d "grant_type=password" \
      | jq -r '.access_token')
    echo ACCESS_TOKEN = ${ACCESS_TOKEN}
  }
  ```

* リソースサーバー（resource-server-1）を登録する。

  ```bash
  RESOURCE_SERVER_CLIENT_ID=resource-server-1
  get-token
  RESOURCE_SERVER_ID=$(curl -s -X POST \
    "${BASE_URI}/admin/realms/master/clients" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    -D - \
    --data-binary @- << EOF | grep -oP '(?<=clients/).+(?=\r)'
  {
    "clientId": "${RESOURCE_SERVER_CLIENT_ID}",
    "protocol": "openid-connect",
    "bearerOnly": true,
    "publicClient": false
  }
  EOF
  )
  echo RESOURCE_SERVER_ID = ${RESOURCE_SERVER_ID}
  RESOURCE_SERVER_CLIENT_SECRET=$(curl -s \
    "${BASE_URI}/admin/realms/master/clients/${RESOURCE_SERVER_ID}"/client-secret \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" | jq -r .value)
  echo RESOURCE_SERVER_CLIENT_SECRET = ${RESOURCE_SERVER_CLIENT_SECRET}
  ```

* リソースサーバー（resource-server-1）にクライアントロール（client-role-1）を設定する。

  ```bash
  CLIENT_ROLE_NAME=client-role-1
  get-token
  CLIENT_ROLE_URI=$(curl -s -X POST \
    "${BASE_URI}/admin/realms/master/clients/${RESOURCE_SERVER_ID}/roles" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    -D - \
    --data-binary @- << EOF | grep -oP '(?<=Location: ).+(?=\r)'
  {
    "name": "${CLIENT_ROLE_NAME}"
  }
  EOF
  )
  echo CLIENT_ROLE_URI = ${CLIENT_ROLE_URI}
  CLIENT_ROLE_ID=$(curl -s ${CLIENT_ROLE_URI} \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    | jq -r .id)
  echo CLIENT_ROLE_ID = ${CLIENT_ROLE_ID}
  ```

* トークン発行時にリソースサーバー（resource-server-1）をaudienceに設定するProtocol Mapperを紐づけた、クライアントスコープ（client-scope-1）を登録する。

  ```bash
  CLIENT_SCOPE_NAME=client-scope-1
  get-token
  CLIENT_SCOPE_ID=$(curl -s -X POST \
    "${BASE_URI}/admin/realms/master/client-scopes" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    -D - \
    --data-binary @- << EOF | grep -oP '(?<=client-scopes/).+(?=\r)'
  {
    "name": "${CLIENT_SCOPE_NAME}",
    "protocol": "openid-connect",
    "protocolMappers": [{
      "name": "protocol-mapper-1",
      "protocol": "openid-connect",
      "protocolMapper":"oidc-audience-mapper",
      "config": {
        "id.token.claim": "false",
        "access.token.claim": "true",
        "included.client.audience": "resource-server-1"
      }
    }]
  }
  EOF
  )
  echo CLIENT_SCOPE_ID = $CLIENT_SCOPE_ID
  ```


* リソースの利用者となるクライアント（client-1）を登録する。

  ```bash
  CLIENT_ID=client-1
  get-token
  ID_OF_CLIENT=$(curl -s -X POST \
    "${BASE_URI}/admin/realms/master/clients" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    -D - \
    --data-binary @- << EOF | grep -oP '(?<=clients/).+(?=\r)'
  {
    "clientId": "${CLIENT_ID}",
    "protocol": "openid-connect",
    "publicClient": true
  }
  EOF
  )
  echo ID_OF_CLIENT = ${ID_OF_CLIENT}
  ```


* クライアント（client-1）にクライアントスコープ（client-scope-1）をOptionalとして割り当てる。

  ```bash
  get-token
  curl -s -X PUT \
    "${BASE_URI}/admin/realms/master/clients/${ID_OF_CLIENT}/optional-client-scopes/$CLIENT_SCOPE_ID" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" | jq .
  ```

* ユーザー（user-1）を登録してパスワードを設定する。

  ```bash
  USER_NAME=user-1
  USER_PASSWORD=password
  get-token
  USER_ID=$(curl -s -X POST \
    "${BASE_URI}/admin/realms/master/users" \
    -D - \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    --data-binary @- << EOF | grep -oP '(?<=users/).+(?=\r)'
  {
    "username": "${USER_NAME}",
    "enabled": true
  }
  EOF
  )
  echo USER_ID = ${USER_ID}
  get-token
  curl -s -X PUT \
    "${BASE_URI}/admin/realms/master/users/${USER_ID}/reset-password" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    --data-binary @- << EOF
  {
    "type": "password",
    "value": "${USER_PASSWORD}",
    "temporary": false
  }
  EOF
  ```

#### 動作検証


* 現状の設定のままアクセストークンを発行すると、audクレームにはデフォルトのaccountのみが指定される。

  ```bash
  CLIENT_ACCESS_TOKEN=$(curl -s -X POST \
    "${BASE_URI}/realms/master/protocol/openid-connect/token" \
    -d "grant_type=password" \
    -d "client_id=${CLIENT_ID}" \
    -d "username=${USER_NAME}" \
    -d "password=${USER_PASSWORD}" \
    | jq -r '.access_token'
  )	
  echo CLIENT_ACCESS_TOKEN = ${CLIENT_ACCESS_TOKEN}
  curl -s -X POST \
    "${BASE_URI}/realms/master/protocol/openid-connect/token/introspect" \
    -u "${RESOURCE_SERVER_CLIENT_ID}:${RESOURCE_SERVER_CLIENT_SECRET}" \
    -d "token_type_hint=access_token" \
    -d "token=${CLIENT_ACCESS_TOKEN}" | jq .
  # => { "aud": "account", ... }
  ```


* scopeパラメータにClient Scope（client-scope-1）を指定すると、Protocol Mapperによってリソースサーバー（resource-server-1）がaudクレームに追加される。

  ```bash
  CLIENT_ACCESS_TOKEN=$(curl -s -X POST \
    "${BASE_URI}/realms/master/protocol/openid-connect/token" \
    -d "grant_type=password" \
    -d "client_id=${CLIENT_ID}" \
    -d "username=${USER_NAME}" \
    -d "password=${USER_PASSWORD}" \
    -d "scope=${CLIENT_SCOPE_NAME}" \
    | jq -r '.access_token'
  )	
  echo CLIENT_ACCESS_TOKEN = ${CLIENT_ACCESS_TOKEN}
  curl -s -X POST \
    "${BASE_URI}/realms/master/protocol/openid-connect/token/introspect" \
    -u "${RESOURCE_SERVER_CLIENT_ID}:${RESOURCE_SERVER_CLIENT_SECRET}" \
    -d "token_type_hint=access_token" \
    -d "token=${CLIENT_ACCESS_TOKEN}" | jq .
  # => { "aud": ["resource-server-1",　"account"], ... }
  ```


* もしくは、ユーザー（user-1）にロール（role-1）を割り当てると、自動設定（Audience Resolve Protocol Mapper）によりリソースサーバー（resource-server-1）がaudクレームに追加される。

  ```bash
  get-token
  curl -s -X POST \
    "${BASE_URI}/admin/realms/master/users/${USER_ID}/role-mappings/clients/${RESOURCE_SERVER_ID}" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    --data-binary @- << EOF | jq .
  [{
    "id": "${CLIENT_ROLE_ID}",
    "name": "${CLIENT_ROLE_NAME}"
  }]
  EOF
  CLIENT_ACCESS_TOKEN=$(curl -s -X POST \
    "${BASE_URI}/realms/master/protocol/openid-connect/token" \
    -d "grant_type=password" \
    -d "client_id=${CLIENT_ID}" \
    -d "username=${USER_NAME}" \
    -d "password=${USER_PASSWORD}" \
    | jq -r '.access_token'
  )	
  echo CLIENT_ACCESS_TOKEN = ${CLIENT_ACCESS_TOKEN}
  curl -s -X POST \
    "${BASE_URI}/realms/master/protocol/openid-connect/token/introspect" \
    -u "${RESOURCE_SERVER_CLIENT_ID}:${RESOURCE_SERVER_CLIENT_SECRET}" \
    -d "token_type_hint=access_token" \
    -d "token=${CLIENT_ACCESS_TOKEN}" | jq .
  # => { "aud": ["resource-server-1",　"account"], ... }
  ```