Keycloakの認可機能の使い方（OAuth2スコープ編）

[前回](https://qiita.com/unhurried/items/78feead6063b698e933a)のKeycloak認可機能の動作確認（RBACでの認可判定）に続き、OAuth 2.0のスコープを使った認可ポリシーの設定と認可判定の動作を確認した。

今回のアクセス制御では、リソースサーバーへのアクセスを表すOAuth 2.0スコープを定義し、リソースサーバーの呼び出し時に指定されたアクセストークンにこのスコープが含まれる場合にアクセスを許可する。（クライアントは認可リクエストにてリソースサーバーのスコープを指定してアクセストークンを取得する必要がある。）

#### 検査環境

基本的には[前回](https://qiita.com/unhurried/items/78feead6063b698e933a)同様であるが、JavaScript-Based PolicyをAdmin REST APIで登録できるようにするために `upload_scripts` Featureを有効化している。

※ 本番運用では `upload_scripts` Featureは非推奨となっており、jarファイルにスクリプトを含めてデプロイする方法が推奨されている。（[参考](https://www.keycloak.org/docs/latest/server_development/#create-a-jar-with-the-scripts-to-deploy)）

```yaml
version: '3'
services:
  curl_jq:
    tty: true
    build:
      context: .
  keycloak:
    image: quay.io/keycloak/keycloak:11.0.2
    ports:
      - 8080:8080
    environment:
      KEYCLOAK_USER: admin
      KEYCLOAK_PASSWORD: admin
      JAVA_OPTS: -Dkeycloak.profile.feature.upload_scripts=enabled
```

#### 動作確認手順・結果

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

  ```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）と保護対象となるリソース（resource-1）を登録する。
  
  ```bash
  RESOURCE_SERVER_CLIENT_ID=resource-server-1
  RESOURCE_NAME=resource-1
  POLICY_NAME=policy-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",
    "publicClient": false,
    "serviceAccountsEnabled": true,
    "authorizationServicesEnabled": true,
    "authorizationSettings": {
      "resources": [{ "name": "${RESOURCE_NAME}", "displayName": "Resource 1" }]
    }
  }
  EOF
  )
  echo RESOURCE_SERVER_ID = ${RESOURCE_SERVER_ID}
  ```

*  クライアントスコープ（client-scope-1）を登録する。

  ※ KeycloakではOAuth 2.0（OpenID Connect）のスコープをクライアントスコープというモデルで扱う。（リソースを細分化するためのモデルであるスコープとは異なることに注意。）
  
  ```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"
  }
  EOF
  )
  echo CLIENT_SCOPE_ID = $CLIENT_SCOPE_ID
  ```
  
* クライアントスコープ（client-scope-1）が認可されているかを検証するJavaScriptポリシー（policy-1）を登録する。

  ※ RBACではビルトインのロールベースポリシーを利用できるが、クライアントスコープを判定するビルトインポリシーは提供されていないため、JavaScriptでポリシーを記述する必要がある。（より簡単に設定ができないかは[Keycloakコミュニティに質問中](https://keycloak.discourse.group/t/creating-an-authorization-policy-based-on-client-scopes/4836)）

  ```bash
  POLICY_NAME=policy-1
  get-token
  curl -s -X POST \
    "${BASE_URI}/admin/realms/master/clients/${RESOURCE_SERVER_ID}/authz/resource-server/policy/js" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    --data-binary @- << EOF | jq .
  {
    "name":"${POLICY_NAME}",
    "type": "js",
    "logic": "POSITIVE",
    "decisionStrategy": "UNANIMOUS",
    "code": "var requiredScope = \"${CLIENT_SCOPE_NAME}\";\rvar attributes = \$evaluation.getContext().getIdentity().getAttributes();\rvar scope = attributes.getValue('scope').asString(0);\r\nif (scope.split(\" \").indexOf(requiredScope) >= 0) {\r  \$evaluation.grant();\r} else {\r  \$evaluation.deny();\r}"
  }
  EOF
  ```

  登録したJavaScriptポリシーは以下の通り：

  ```javascript
  var requiredScope = "client-scope-1";
  var attributes = $evaluation.getContext().getIdentity().getAttributes();
  var scope = attributes.getValue('scope').asString(0);
  if (scope.split(" ").indexOf(requiredScope) >= 0) {
    $evaluation.grant();
  } else {
    $evaluation.deny();
  }
  ```
  
* 登録したリソース（resource-1）とポリシー（policy-1）を紐づけるパーミッション（permission-1）を設定する。

  ```bash
  get-token
  curl -s -X POST \
    "${BASE_URI}/admin/realms/master/clients/${RESOURCE_SERVER_ID}/authz/resource-server/permission/resource" \
    -H "Authorization: Bearer ${ACCESS_TOKEN}" \
    -H "Content-Type: application/json" \
    --data-binary @- << EOF | jq .
  {
    "type": "resource",
    "logic": "POSITIVE",
    "decisionStrategy": "UNANIMOUS",
    "name": "permission-1",
    "resources": ["${RESOURCE_NAME}"],
    "policies": ["${POLICY_NAME}"]
  }
  EOF
  ```

* リソースの利用者となるクライアント（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}
  ```


* ユーザー（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
  ```


* この状態ではクライアントにClient Scopeが割り当てられていないため、アクセストークンの発行でエラーになる。

  ※ 今回の検証では、アクセストークンをcurlで取得するために、Authorization Code Flowではなく、簡易的にPassword Flowを利用している。

  ```bash
  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'
  # => { "error": "invalid_scope", "error_description": "Invalid scopes: scope-1" }
  ```

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

  ※ クライアントスコープの割り当てにはDefault（scopeパラメータを指定しなくても予め認可されるもの）Optional（scopeパラメータに指定できるもの）の2種類がある。

  ```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 .
  ```
  
* scopeパラメータに何も指定せず取得したアクセストークンでは認可判定NGとなる。

  ```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" \
    -H "Authorization: Bearer ${CLIENT_ACCESS_TOKEN}" \
    -d "grant_type=urn:ietf:params:oauth:grant-type:uma-ticket" \
    -d "response_mode=decision" \
    -d "audience=resource-server-1" \
    -d "permission=resource-1" \
    | jq .
  # => { "error": "access_denied", "error_description": "not_authorized" }
  ```

* scopeパラメータにクライアントスコープ（client-scope-1）を指定して取得したアクセストークンでは認可判定OKとなる。

  ```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" \
    -H "Authorization: Bearer ${CLIENT_ACCESS_TOKEN}" \
    -d "grant_type=urn:ietf:params:oauth:grant-type:uma-ticket" \
    -d "response_mode=decision" \
    -d "audience=resource-server-1" \
    -d "permission=resource-1" \
    | jq .
  # => { "result": true }
  ```


#### 参考

* Keycloakドキュメント
  * [Server Administration](https://www.keycloak.org/docs/latest/server_admin/index.html)
  * [Server Container Image](https://github.com/keycloak/keycloak-containers/blob/11.0.2/server/README.md)
  * [Authorization Services](https://www.keycloak.org/docs/latest/authorization_services/index.html)
  * [Administration REST API](https://www.keycloak.org/docs-api/11.0/rest-api/index.html)