RFC 9101のJARをKeycloakで試す、requestとrequest_uriによる認可リクエスト保護
JWT-Secured Authorization Request(JAR / RFC 9101)をKeycloakで検証します。通常の認可リクエストが抱える課題を確認し、署名付きRequest Objectをrequestまたはrequest_uriで渡す方法を再現します。
はじめに
OAuth 2.0の認可コードフローでは、ブラウザを介して認可リクエストを認可サーバーへ送ります。
通常の認可リクエストでは、client_id、scope、redirect_uriなどがURLのクエリに含まれます。
この方式は実装しやすい一方、ブラウザや中継機器を通過する認可リクエストに対して、アプリケーション層の完全性、送信元認証、機密性を保証する仕組みを持ちません。
URLに含まれるパラメーターが増えると、URL長の制約やログへの残留も問題になります。
ここで扱う JWT-Secured Authorization Request(JAR) は、認可リクエストを署名付きのJWT(Request Object)として扱う仕様です。
Request Objectをrequestで直接渡す方法と、request_uriで参照渡しする方法があります。
この記事では、Keycloakを認可サーバー、GoアプリケーションをOAuthクライアントとして、次の内容を確認します。
- RS256で署名したRequest Objectを
requestで渡す - Request Objectを一時公開し、
request_uriで渡す - KeycloakでRequest Objectを必須化する
- JWSとJWE、
requestとrequest_uriの効果を分けて整理する
PAR(Pushed Authorization Requests)はこの記事では扱わず、別の記事で扱います。
成果物
https://github.com/kntks/blog-code/tree/main/2026/09/oauth-jar
通常の認可リクエストと課題
Authorization Code Flow
RFC 6749のAuthorization Code GrantにOpenID ConnectとPKCE(RFC 7636)を加えた認可コードフローは、次のようになります。
この例では、ClientがGoアプリケーション、Authorization ServerがKeycloak、User-Agentがブラウザです。
通常の認可リクエストでは、パラメーターをURLのクエリに設定します。
https://server.example.com/authorize? response_type=code& client_id=myapp& redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback& scope=openid& state=...& code_challenge=...ブラウザ経由で発生する課題
RFC 9101は、認可リクエストがユーザーエージェントを通過することで、次の問題が起きると説明しています。RFC 9101 section 1
| 観点 | 通常の認可リクエスト |
|---|---|
| 完全性 | URLパラメーター自体にアプリケーション層の保護がない |
| 送信元認証 | そのパラメーターを誰が作成したかを検証しにくい |
| 機密性 | ブラウザや中継機器から内容を観測されうる |
| URL上の露出 | 認可リクエストのパラメーターがURLに含まれる |
TLSはブラウザと認可サーバーの間の通信を保護しますが、TLSセッションはブラウザで終端します。
ロードバランサーなどの中継機器でTLSが終端されることもあります。
そのため、TLSだけでは認可リクエストをブラウザから認可サーバーまで一貫して保護できません。
JARの基本
Request Object
JARでは、認可リクエストのパラメーターをJWTのクレームとして表現したRequest Objectを使います。
Request Objectには、response_type、client_id、redirect_uri、scope、stateなど、認可リクエストの処理に使うパラメーターを含めます。
Request Objectは次のいずれかでなければなりません。RFC 9101 section 5
- JWSで署名されたJWT
- JWSで署名した後、JWEで暗号化したNested JWT
JWEだけで署名のないRequest Objectにすることはできません。
署名と暗号化を組み合わせる場合は、先にJWS署名を行い、その結果をJWEで暗号化します。RFC 9101 section 4
JWSとJWEの役割
JWSとJWEが提供する性質は分けて考える必要があります。
- JWSによる署名:Request Objectの完全性と送信元認証
- JWEによる暗号化:Request Objectの機密性
したがって、JWSで署名したRequest Objectをrequestで送っても、JWTの内容自体はURL上に現れます。
内容を見えなくするには、JWEによる暗号化が別途必要です。
以前にJWEに関する記事を書きました。
KeycloakのIDトークンをJWEで検証する
Keycloakでサポートされているalgとenc
curl -s http://localhost:8080/realms/myrealm/.well-known/openid-configuration \ | jq '{ request_object_signing_alg_values_supported, request_object_encryption_alg_values_supported, request_object_encryption_enc_values_supported }'{ "request_object_signing_alg_values_supported": [ "PS384", "RS384", "EdDSA", "ES384", "HS256", "HS512", "ES256", "RS256", "HS384", "ES512", "PS256", "PS512", "RS512", "none" ], "request_object_encryption_alg_values_supported": [ "ECDH-ES+A256KW", "ECDH-ES+A192KW", "ECDH-ES+A128KW", "RSA-OAEP", "RSA-OAEP-256", "RSA1_5", "ECDH-ES" ], "request_object_encryption_enc_values_supported": [ "A256GCM", "A192GCM", "A128GCM", "A128CBC-HS256", "A192CBC-HS384", "A256CBC-HS512" ]}requestとrequest_uri
Request Objectの渡し方には、次の2種類があります。
| パラメーター | 渡し方 | 動作 |
|---|---|---|
request | by value | 署名済みJWTを認可リクエストのパラメーターとして直接渡す |
request_uri | by reference | Request Objectを参照するURIを渡し、認可サーバーが取得する |
requestはJWTそのものがURLに載るため、通常のパラメーターよりURLが長くなることもあります。
request_uriを使うと、ブラウザに渡す認可リクエストを短いURIにできます。
ただし、request_uri自体がRequest Objectの機密性や完全性を提供するわけではありません。
取得したRequest ObjectのJWSまたはJWEによって、保護方式が決まります。
認可サーバーがrequest_uriを発行する場合
request_uriは、クライアントがあらかじめRequest Objectを認可サーバーへPOSTし、認可サーバーから受け取ったURIを使うこともできます。
RFC 9101 section 5.2.1では、認可サーバーがRequest ObjectをPOSTするURLをクライアントに提供し、クライアントがRequest URIを取得する例を示しています。
この場合も、認可リクエストのURLにrequest_uriを設定するのはクライアントです。
ただし、URIを用意しているのは認可サーバーなので、認可サーバーはそのURIに対応するRequest Objectを自身の保存領域などから取得します。
RFC 9101では、認可サーバーが提供するURIはURNであると説明されています。
Request Objectのクレーム
このサンプルでは、主に次のクレームを設定します。
| クレーム | 値 |
|---|---|
iss | クライアントID |
aud | 認可サーバーのissuer |
client_id | クライアントID |
redirect_uri | コールバックURL |
response_type | code |
scope | openid |
state | CSRF対策に使うランダム値 |
nonce | IDトークンのリプレイ対策に使うランダム値 |
code_challenge | PKCEのチャレンジ |
認可リクエストでは、次のクエリパラメーターの組み合わせが許可されます。
| 認可リクエストのクエリ | 扱い | 備考 |
|---|---|---|
client_id + request | 許可 | クエリのclient_idは、Request Object内のclient_idと一致しなければならない |
client_id + request_uri | 許可 | request_uriが参照するRequest Object内のclient_idと一致しなければならない |
client_id + request + request_uri | 禁止 | requestとrequest_uriは同時に指定できない |
RFC 9101とOpenID Connect Coreの仕様差
OpenID Connect Core 1.0もRequest Objectを定義しており、RFC 9101のRequest Objectと互換性があります。
ただし、RFC 9101はOAuth 2.0全般を対象とするJARの仕様であり、OpenID Connect CoreはOAuth 2.0に認証機能を追加する仕様です。
そのため、Request Objectを使う場合の必須パラメーターや、外側の認可リクエストとRequest Objectの組み立て方には違いがあります。
以下では、この記事で扱うAuthorization Code Flowを前提に比較します。
必須パラメーターの違い
| 観点 | RFC 9101 | OpenID Connect Core | 根拠 |
|---|---|---|---|
request / request_uri | どちらか一方が必須。両方は指定できない | どちらも任意。OPがサポートしていない場合は対応するエラーを返す | RFC 9101 section 5、OIDC Core section 6 |
client_id | 外側の認可リクエストに必須。Request Object内の値と一致しなければならない | 外側の認可リクエストに必須。Request Object内にある場合は値が一致しなければならない | RFC 9101 section 5、OIDC Core section 6.1 |
response_type | 認可処理で使うパラメーターとしてRequest Objectに含める | 外側のOAuth 2.0形式にも必須。Request Object内にある場合は外側の値と一致しなければならない | RFC 9101 section 4、OIDC Core section 6.1 |
scope | 認可処理で使う場合はRequest Objectに含める | 外側に必須。openidを含めなければならない。Request Object内にあっても外側に必要 | RFC 9101 section 4、OIDC Core section 6.1 |
redirect_uri | 認可処理で使う場合はRequest Objectに含める | Authorization Code Flowでは必須。事前登録済みのURIと完全一致しなければならない | RFC 9101 section 4、OIDC Core section 3.1.2.1 |
state | 使用する場合はRequest Objectに含める | OPTIONALだが推奨。毎回変わる値として外側に置くこともできる | RFC 9101 section 4、OIDC Core section 3.1.2.1 |
nonce | 使用する場合はRequest Objectに含める | OPTIONAL。Request Objectまたは外側に置ける | RFC 9101 section 4、OIDC Core section 3.1.2.1 |
code_challenge | OAuth 2.0の拡張パラメーターとして使用する場合はRequest Objectに含める | OIDC Coreでは定義されていない。PKCE(RFC 7636)のパラメーターとして扱う | RFC 9101 section 4、RFC 7636 |
RFC 9101では、外側の認可リクエストに必要なのはrequestまたはrequest_uriのどちらか一方とclient_idです。
response_type、scope、redirect_uriなど、認可処理で使うパラメーターはRequest Objectに含めます。
一方、OpenID Connect Coreでは、Request Objectを使っても外側のOAuth 2.0形式にresponse_type、client_id、scope=openidを含めなければなりません。
これはRequest Object内に同じ値が存在する場合にも適用されます。
Request Objectの組み立てと値の優先順位
| 観点 | RFC 9101 | OpenID Connect Core | 根拠 |
|---|---|---|---|
| Request Objectに含める範囲 | requestとrequest_uriを除き、認可処理で使うすべてのパラメーター(拡張パラメーターを含む)を含める | 固定パラメーターをRequest Objectに、stateやnonceなどの可変パラメーターを外側に分けられる | RFC 9101 section 4、OIDC Core section 6.1 |
| 外側との重複 | クライアントは重複して送信してもよいが、認可サーバーはRequest Object内の値だけを使う | 両方に存在する場合はRequest Object内の値を使う | RFC 9101 section 5、OIDC Core section 6.3.3 |
| 外側に必須の値 | client_idとrequestまたはrequest_uri | response_type、client_id、scope=openid。Authorization Code Flowでは、組み立て後のリクエストにredirect_uriも必要 | RFC 9101 section 5、OIDC Core section 6.1 |
client_idの不一致 | 外側とRequest Object内の値が異なる場合はエラー | Request Object内にある場合、外側の値と一致しなければならない | RFC 9101 section 6.3、OIDC Core section 6.1 |
request / request_uriのJWT内指定 | Request Objectに含めてはならない | Request Objectに含めてはならない | RFC 9101 section 4、OIDC Core section 6.1 |
両仕様で論理的なパラメーターは同じでも、その値を外側の認可リクエストに置くか、Request Objectに置くかという規則が異なります。
特にOpenID Connectでは、response_typeとclient_idを外側に含め、scopeにはopenidを含める必要があります。
署名・暗号化要件の違い
| 観点 | RFC 9101 | OpenID Connect Core | 根拠 |
|---|---|---|---|
| 最小限の形式 | JWS署名済み、またはJWS署名後にJWE暗号化したNested JWT | 署名済みまたは署名なしのRequest Object | RFC 9101 section 5、OIDC Core section 6.1 |
| JWEのみ | 不可。暗号化する場合も先にJWS署名が必要 | 署名なしでJWE暗号化することも仕様上は可能 | RFC 9101 section 4、OIDC Core section 6.1 |
| 署名検証 | 認可サーバーは署名を検証しなければならない | 署名付きの場合、登録済みのアルゴリズムと鍵で検証する | RFC 9101 section 6.2、OIDC Core section 6.3.2 |
| 署名の強制 | require_signed_request_objectをtrueにすると、署名なしやalg=noneを拒否する。省略時の既定値はfalse | CoreのRequest Object規定では署名なしも許容する | RFC 9101 section 10.5、OIDC Core section 6.1 |
したがって、次の説明はRFC 9101について述べる場合には正しいですが、OpenID Connect Coreにもそのまま適用することはできません。
RFC 9101では、JWEだけで署名のないRequest Objectにすることはできません。JWEを使う場合も、先にJWS署名を行わなければなりません。
OpenID Connect Coreでは、署名なしRequest Objectや、署名なしのJWTをJWEで暗号化したRequest Objectも許容されています。
request_uriの扱い
| 観点 | RFC 9101 | OpenID Connect Core | 根拠 |
|---|---|---|---|
| 渡し方 | URIが参照するリソースからRequest Objectを取得する | 同じ | RFC 9101 section 5.2、OIDC Core section 6.2 |
| URI長 | Request URI全体は512 ASCII文字を超えないことが望ましい | Request URI全体は512 ASCII文字を超えないことが望ましい | RFC 9101 section 5.2、OIDC Core section 6.2 |
| クライアントが用意するURI | https URIが必須 | 原則https。Request ObjectをOPが検証可能な署名で保護している場合は例外 | RFC 9101 section 5.2、OIDC Core section 6.2 |
| 認可サーバーが用意するURI | 認可サーバーから提供された場合はURN | Request Objectを含むリソースを参照するURLとして扱う | RFC 9101 section 5.2、OIDC Core section 6.2 |
| Request Objectの取得 | 原則HTTP GET。安全な別の取得方法も許容 | 原則HTTP GET。認可サーバーは取得結果をキャッシュできる | RFC 9101 section 5.2.3、OIDC Core section 6.2.3 |
| URIの推測対策 | 十分なエントロピーが必要。適切なタイムアウト後の削除を推奨 | 同じ | RFC 9101 section 5.2.1、OIDC Core section 6.2.1 |
| URIの事前登録 | 本文ではrequest_urisの事前登録を定義しない | Dynamic Client Registrationのrequest_urisで事前登録でき、OPは事前登録を要求できる | OIDC Core section 6.2 |
代表的なリクエスト例
RFC 9101のJARでは、外側の認可リクエストにclient_idとrequestを指定し、その他の認可リクエストパラメーターをRequest Objectに含められます。
https://server.example.com/authorize? client_id=myapp& request=<JWS-signed Request Object>Request Objectには、たとえばresponse_type、redirect_uri、scope、state、nonce、code_challengeを含めます。
OpenID ConnectでJARを使う場合は、OIDC Coreの要件により、response_type、client_id、scope=openidを外側に指定します。
Authorization Code Flowで必要なredirect_uriは、この例では外側に指定していますが、Request Objectに含めてリクエストを組み立てることもできます。
https://server.example.com/authorize? response_type=code& client_id=myapp& redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback& scope=openid& request=<Request Object>Request Object内にもresponse_typeやclient_idを含める場合は、外側の値と一致させなければなりません。
RFC 9101とOpenID Connect CoreはRequest Objectを共有しますが、同じ必須条件を定めているわけではありません。
RFC 9101はJARとしてRequest Objectへのパラメーター集約と署名を要求します。
OpenID Connect Coreは、OIDCの認証リクエストとして処理するため、外側のリクエストにも追加の必須パラメーターを要求します。
検証環境とKeycloak設定
この記事で使用するアプリケーションの簡単な構成図です。
前提条件
- DockerとDocker Composeを使用できる
- Terraform、Go、jq、miseを使用できる
http://localhost:8080とhttp://localhost:8081にブラウザでアクセスできる
バージョン
mise.tomlで指定しているバージョンを記載しています。
| 項目 | バージョン |
|---|---|
| macOS | 26.6.2 |
| Keycloak | 26.7.3 |
| Go | 1.27.1 |
| air | 1.67.4 |
| Terraform | 1.16.1 |
| Terraform Provider(Keycloak) | >= 5.6.0 |
RSA鍵の生成
Request Objectを署名するためのRSA鍵ペアを生成します。
cd blog-code/oauth-jar
# 秘密鍵の生成(PKCS#8形式)openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
# 公開鍵の抽出openssl rsa -pubout -in private.pem -out public.pemサンプルリポジトリに含まれる鍵はローカル検証専用です。
公開リポジトリにある秘密鍵を本番環境で使わないでください。
構築手順
KeycloakのClient設定で公開鍵を使うため、Terraformを実行する前に鍵を生成します。
Keycloakを起動します。
cd blog-code/oauth-jardocker compose up -dTerraformでRealm、User、Clientを作成します。
terraform -chdir=terraform initterraform -chdir=terraform planterraform -chdir=terraform applyサンプルアプリケーションを起動します。
air8クライアントの設定
このサンプルでは、Terraformで8つのKeycloakクライアントを作成します。
アプリケーションのトップページ(http://localhost:8081/)には、各クライアントでログインするリンクが表示されます。

クライアントID、クライアントシークレット、コールバックURLには、環境変数を元にしたプロファイル名が付加されます。
たとえば、KC_CLIENT_ID=myappの場合、request-rs256-importのクライアントIDはmyapp-request-rs256-import、コールバックURLはhttp://localhost:8081/callback/request-rs256-importです。
| プロファイル | ログインURL | KeycloakのRequest object required | 署名 | 鍵の検証方法 | 送信されるパラメーター |
|---|---|---|---|---|---|
plain | /login/plain | Not required | なし | なし | 通常の認可パラメーター |
request-rs256-import | /login/request-rs256-import | Request only | RS256 | 公開鍵のImport | client_id + request |
request-rs256-jwks | /login/request-rs256-jwks | Request only | RS256 | jwks.url | client_id + request |
request-hs256 | /login/request-hs256 | Request only | HS256 | クライアントシークレット | client_id + request |
request-uri-rs256-import | /login/request-uri-rs256-import | Request URI only | RS256 | 公開鍵のImport | client_id + request_uri |
request-uri-rs256-jwks | /login/request-uri-rs256-jwks | Request URI only | RS256 | jwks.url | client_id + request_uri |
request-uri-hs256 | /login/request-uri-hs256 | Request URI only | HS256 | クライアントシークレット | client_id + request_uri |
both-rs256-import | /login/both-rs256-import | Request or Request URI | RS256 | 公開鍵のImport | client_id + request + request_uri |
Keycloak管理画面の表示名と、Terraformのextra_configで指定する値は次のように対応します。
| Keycloak管理画面 | Terraformの値 | 意味 |
|---|---|---|
Not required | not required | Request Objectは任意 |
Request or Request URI | request or request_uri | requestまたはrequest_uriが必須 |
Request only | request only | requestが必須 |
Request URI only | request_uri only | request_uriが必須 |
RS256の公開鍵Import方式では、jwt.credential.public.keyにpublic.pemを設定します。
JWKS方式では、use.jwks.url=trueとjwks.urlを設定し、Keycloakがアプリケーションの/jwksエンドポイントから公開鍵を取得します。
request_uriを使うクライアントでは、Keycloakが取得先として許可するURLパターンをrequest.urisに設定します。
構築とログイン
まだ環境を構築していない場合は、上の構築手順でKeycloak、Terraform、アプリケーションを起動します。
サンプルアプリケーションではブラウザで http://localhost:8081 にアクセスすると以下のようなそれぞれのKeyclaokクライアントに対応したログインボタンが画面に表示されます。

次のコマンドで指定したプロファイルが生成する認可リクエストを確認できます。
$ curl -sIL http://localhost:8081/login | grep -E 'Location|HTTP'HTTP/1.1 302 FoundLocation: http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?client_id=myapp-plain&code_challenge=BVmHu4Ok2VsghoqnLdq4s8UBf5GymyOVhYO9AKsrBPg&code_challenge_method=S256&nonce=wzqHjdxKqHt8fy_if-J_5Zt5Bu27MTr9eq2urPur0ls&redirect_uri=http%3A%2F%2Flocalhost%3A8081%2Fcallback%2Fplain&response_type=code&scope=openid&state=jblIL5SBKDavpF-ZB4a3l9di_bk6MxMtEKPmK640uIAHTTP/1.1 200 OK
$ curl -sIL http://localhost:8081/login/request-rs256-import | grep -E 'Location|HTTP'HTTP/1.1 302 FoundLocation: http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?client_id=myapp-request-rs256-import&request=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJteWFwcC1yZXF1ZXN0LXJzMjU2LWltcG9ydCIsImF1ZCI6WyJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL215cmVhbG0iXSwiZXhwIjoxNzg4NzM5OTUwLCJpYXQiOjE3ODg3Mzk2NTAsInJlc3BvbnNlX3R5cGUiOiJjb2RlIiwiY2xpZW50X2lkIjoibXlhcHAtcmVxdWVzdC1yczI1Ni1pbXBvcnQiLCJyZWRpcmVjdF91cmkiOiJodHRwOi8vbG9jYWxob3N0OjgwODEvY2FsbGJhY2svcmVxdWVzdC1yczI1Ni1pbXBvcnQiLCJzY29wZSI6Im9wZW5pZCIsInN0YXRlIjoiR3Z5dlJaajBINEd0cE9jTExrY2xDYzJQNk81WHo2ZW5EREJYN1Q1MTlpSSIsIm5vbmNlIjoiOUMzTlZFbHdDNWdHMWowYXByYlRjMkZOc1RqNElmZmJvVG5ZalNyYk4wNCIsImNvZGVfY2hhbGxlbmdlIjoia19lN2VWQnF3SUhxYmdTaHBZd0FoX0dvaUFRSEFqRFpiQ3psV01yWXJuSSIsImNvZGVfY2hhbGxlbmdlX21ldGhvZCI6IlMyNTYifQ.bPITkplYKh6HMcY7PoZ_hmhhn5DUtL8JWkVW4l1bRhc-NhS2skxPzclJ4FcsMyyzwGsoVJKWsB6hcREril2EbgFcP8_KjsggclvlpK7hnqaeVhI-NUb3DF-1ZXym8RZ1b206I46AMTiiDm_IUXQWHWSY7qF11dIflBAlfSNP8aad-5-1qRgNnlxnDJIf7nnughTBivMROh8k27JawuuY-Y_8moTQvN1Hqp09J9W4NadGsDLJHVZB2NmIgLx1MUAnehbd9vrebVGhNHdIeFN21YKTMk_uuMj82QmMoDaGvel39lTZ61m1aP1BrgWMQ0_rygZFz7oGo2mIRkWiSYo2LwHTTP/1.1 200 OK
$ curl -sIL http://localhost:8081/login/request-rs256-jwks | grep -E 'Location|HTTP'HTTP/1.1 302 FoundLocation: http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?client_id=myapp-request-rs256-jwks&request=eyJhbGciOiJSUzI1NiIsImtpZCI6ImNsaWVudC1rZXktaG9nZSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJteWFwcC1yZXF1ZXN0LXJzMjU2LWp3a3MiLCJhdWQiOlsiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9teXJlYWxtIl0sImV4cCI6MTc4ODc0Mzg4MSwiaWF0IjoxNzg4NzQzNTgxLCJyZXNwb25zZV90eXBlIjoiY29kZSIsImNsaWVudF9pZCI6Im15YXBwLXJlcXVlc3QtcnMyNTYtandrcyIsInJlZGlyZWN0X3VyaSI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MS9jYWxsYmFjay9yZXF1ZXN0LXJzMjU2LWp3a3MiLCJzY29wZSI6Im9wZW5pZCIsInN0YXRlIjoibDhSY1hIOC1FR0dPaXNhNGZmYTVvcUk2VjZVZUMtaHluQ2dLU2RTUk9INCIsIm5vbmNlIjoiRGFURlhKZUUwcXlFcGZjcHYweFFWYWVINkFVYzN1M01oZlFXM0tnTjBKcyIsImNvZGVfY2hhbGxlbmdlIjoiUU5HVE9BTzVTSHZrcnRkbVg5Tkw4S3hNVENVNUR0ZVpLQlo2M012YUVGRSIsImNvZGVfY2hhbGxlbmdlX21ldGhvZCI6IlMyNTYifQ.H3ghw_T36k_VdQDDBjQZgB-SZGctt7q02quKbeyEA8iuRc5KCnrctSSCzajAW3lX9ZzNGGNDZ3tO5xJeJHDFQhXFpC_27RQ_l9uUq6UbRMhG4osZRl8RBZF5d10l1yAcDf_mReUUFYuaKekVarPSSJiBVeYaU5VmMboafEzhyr7RFaqqH3H443U_K1cvFtToC7GCZ_G6kxHC84d1VeQBRo_1J6DMeJ9Qs07taCONlmQcFmploP04I7gqexYYFxj0Fy6Jdm5hM796vw9ulFHzd6mpctzj_4SoVa79ioVIXs5V2IAKl5nSZgFxLtHnowMLDtPfFFZZjmGrV7TJ9b6-XwHTTP/1.1 200 OK
$ curl -sIL http://localhost:8081/login/request-hs256 | grep -E 'Location|HTTP'HTTP/1.1 302 FoundLocation: http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?client_id=myapp-request-hs256&request=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJteWFwcC1yZXF1ZXN0LWhzMjU2IiwiYXVkIjpbImh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9yZWFsbXMvbXlyZWFsbSJdLCJleHAiOjE3ODg3Mzk5OTksImlhdCI6MTc4ODczOTY5OSwicmVzcG9uc2VfdHlwZSI6ImNvZGUiLCJjbGllbnRfaWQiOiJteWFwcC1yZXF1ZXN0LWhzMjU2IiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgxL2NhbGxiYWNrL3JlcXVlc3QtaHMyNTYiLCJzY29wZSI6Im9wZW5pZCIsInN0YXRlIjoiMFB1dkRaMklJX2h4c2lzX1ZWRk9mRUFabVFCeHRZbDdvaGhra2Z5QlpLayIsIm5vbmNlIjoiTWl1UkdkU2xqWEpLQkxpNm1sajdBaS10SHFmREZkdVRUU2pPWjR5VVVsOCIsImNvZGVfY2hhbGxlbmdlIjoic3huS1hadkhpVG9UOE9fNDlYREc2b1RKT3NNQTFHanZUT1RHMFZfaDJ3SSIsImNvZGVfY2hhbGxlbmdlX21ldGhvZCI6IlMyNTYifQ.UE6TB7EeZkyl6nGUyo0pEM_ucdQsX2Hoy8WzF9FjoSoHTTP/1.1 200 OK
$ curl -sIL http://localhost:8081/login/request-uri-rs256-import | grep -E 'Location|HTTP'HTTP/1.1 302 FoundLocation: http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?client_id=myapp-request-uri-rs256-import&request_uri=http%3A%2F%2Fhost.docker.internal%3A8081%2Frequest-objects%2FghYR1CJqcPwWfTDVcW2SesU3LicvO7RoNK_NlfMtWucHTTP/1.1 200 OK
$ curl -sIL http://localhost:8081/login/request-uri-rs256-jwks | grep -E 'Location|HTTP'HTTP/1.1 302 FoundLocation: http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?client_id=myapp-request-uri-rs256-jwks&request_uri=http%3A%2F%2Fhost.docker.internal%3A8081%2Frequest-objects%2F2zDbJx6YRiHe0dery0IejjWHlVxecnSqa0hCXZSQBYEHTTP/1.1 200 OK
$ curl -sIL http://localhost:8081/login/request-uri-hs256 | grep -E 'Location|HTTP'HTTP/1.1 302 FoundLocation: http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?client_id=myapp-request-uri-hs256&request_uri=http%3A%2F%2Fhost.docker.internal%3A8081%2Frequest-objects%2F4f7YpCs4ZqiKSPNlon8bUA0nWi8bKJbbrtoNsEW_vsEHTTP/1.1 200 OK
# request, request_uri同時指定はリダイレクト先で拒否される。$ curl -sIL http://localhost:8081/login/both-rs256-import | grep -E 'Location|HTTP'HTTP/1.1 302 FoundLocation: http://localhost:8080/realms/myrealm/protocol/openid-connect/auth?client_id=myapp-both-rs256-import&request=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJteWFwcC1ib3RoLXJzMjU2LWltcG9ydCIsImF1ZCI6WyJodHRwOi8vbG9jYWxob3N0OjgwODAvcmVhbG1zL215cmVhbG0iXSwiZXhwIjoxNzg4NzM5MTM5LCJpYXQiOjE3ODg3Mzg4MzksInJlc3BvbnNlX3R5cGUiOiJjb2RlIiwiY2xpZW50X2lkIjoibXlhcHAtYm90aC1yczI1Ni1pbXBvcnQiLCJyZWRpcmVjdF91cmkiOiJodHRwOi8vbG9jYWxob3N0OjgwODEvY2FsbGJhY2svYm90aC1yczI1Ni1pbXBvcnQiLCJzY29wZSI6Im9wZW5pZCIsInN0YXRlIjoiTG1HRGNrNXBKMlptRzJZaUZEVzJieE00QWsxRTIxYmY5Y2FmY2xzNjFkZyIsIm5vbmNlIjoiRl9xUGtLamh0QmRlampIY2E5OVRvLVZ0dkViYnFCSEp6VzhiaW9CQkJZUSIsImNvZGVfY2hhbGxlbmdlIjoiSlE0WFBaSWNwSzFXTXBPakZtV2JuUVJMNUVtbEc2OWpFUlpNWXF5b2dDQSIsImNvZGVfY2hhbGxlbmdlX21ldGhvZCI6IlMyNTYifQ.QBMufyTLTVA8cIyji_8UgkbbUwH2fLDP0QlzijVk0fRm3ESDsf6qe8MFdaADu6ZhvD6ImTyZMp0qxDpRRrZRmzdnLkOFD9zy2O19lnZYZvjUJs0LSdHACjENnD33vcT1y87P8T2JNmTJMW0M_QPV7GNghSbrIWQ7HYqmTI_EPf8cvCDohYsFhSQec3wp_EdFnSHh3FxfDMeaW8uTTL7DHatXrbCtUJIlgtutxSgAO33VyU9yFpCtTZe54ZIpsHmNirXcP-wVl5bxswV5wg6NxNfjE248LTj_2g4uvlewDSN52lrESd_N4Cuja0UeHUbVTCuj_TF7nnGCVI0iSuoKWg&request_uri=http%3A%2F%2Fhost.docker.internal%3A8081%2Frequest-objects%2FIb78niX7PYOW5CKtu6Sr_55BmpJ5mTdNdK2o1ZQQpPgHTTP/1.1 400 Bad Request/login/{profile}の{profile}を、検証したいプロファイル名に置き換えます。
ブラウザで同じURLを開くと、Keycloakのログイン画面を表示できます。
プロファイルごとに、Locationヘッダーで確認する内容を示します。
| プロファイル | Locationで確認する内容 |
|---|---|
plain | scope、response_type、redirect_uriなど |
request-rs256-import | requestのJWTヘッダーにあるalg=RS256 |
request-rs256-jwks | requestのJWTヘッダーにあるalg=RS256、kid |
request-hs256 | requestのJWTヘッダーにあるalg=HS256 |
request-uri-rs256-import | request_uriだけが含まれる |
request-uri-rs256-jwks | request_uriだけが含まれる |
request-uri-hs256 | request_uriだけが含まれる |
both-rs256-import | requestとrequest_uriが同時に含まれる |
requestの場合は、LocationヘッダーにJWT全体が含まれます。
JWSは改ざんを検出できますが、署名済みJWTのクレームを隠すものではありません。
一方、request_uriの場合にブラウザへ渡るのは参照URIで、Request Object本体はKeycloakからアプリケーションへのGETで取得されます。
署名鍵の別方式
requestとrequest_uriは、Request Objectの署名方式とは独立した軸です。
Keycloakでは、次のような方式も選択できます。
| 署名方式 | Keycloakが使う鍵 | Keycloakでの設定 |
|---|---|---|
| RS256 | クライアントの公開鍵 | Import、またはjwks_uriでJWKSを指定 |
| HS256 | クライアントのclient_secret | Request Object signature algorithmをHS256に設定 |
request_uriはRequest Objectの渡し方、jwks_uriは署名検証用の公開鍵集合を取得するURLです。
両者は別のパラメーターです。
このサンプルでは、これらの組み合わせを8つのクライアントに分けて比較します。
requestによる検証
Request Objectの生成
Goアプリケーションは、認可サーバーのissuerをaudに設定したRequest Objectを作成します。
now := time.Now()claims := RequestObjectClaims{ RegisteredClaims: jwt.RegisteredClaims{ Issuer: clientID, Audience: jwt.ClaimStrings{issuerURL}, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)), }, ResponseType: "code", ClientID: clientID, RedirectURI: redirectURI, Scope: "openid", State: state, Nonce: nonce, CodeChallenge: challenge, CodeChallengeMethod: "S256",}
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(embeddedPrivateKey)
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signed, err := token.SignedString(privateKey)issにはクライアントID、audにはDiscoveryで取得したKeycloakのissuerを設定します。
署名にはクライアントの秘密鍵を使い、Keycloakは登録済みの公開鍵で検証します。
認可リクエスト
request-rs256-import、request-rs256-jwks、request-hs256でログインを開始します。
レスポンスのLocationヘッダーには、client_idと署名済みJWTを含むrequestが設定されます。
Location: http://localhost:8080/realms/myrealm/protocol/openid-connect/auth? client_id=myapp-request-rs256-import&request=eyJ...<省略>ここで、JWT全体がブラウザのURLに載っていることを確認できます。
JWSは改ざんを検出できますが、署名済みJWTのクレームを隠すものではありません。
request_uriによる検証
Request Objectを参照渡しする
request_uriを使う場合、クライアントはRequest Objectを一時的にホストし、そのURIを認可サーバーへ渡します。
この構成では、KeycloakがURIへGETリクエストを送り、取得したJWTを検証してから認可処理を続けます。
request-uri-rs256-import、request-uri-rs256-jwks、request-uri-hs256でログインを開始します。
KeycloakのRequest object requiredはRequest URI onlyに設定済みで、アプリケーションはプロファイルに応じてrequest_uriを生成します。
http://localhost:8080/realms/myrealm/protocol/openid-connect/auth? client_id=myapp-request-uri-rs256-import& request_uri=http%3A%2F%2Fhost.docker.internal%3A8081%2Frequest-objects%2F...ブラウザに渡るのはclient_idとrequest_uriだけです。
Request Objectの内容は、KeycloakからGoアプリケーションへのGETで取得されます。
request_uriの有効期限と一度きりの利用
request_uriは推測困難なランダム値にし、短い有効期限を設け、取得後に再利用できないようにします。
このサンプルでは、Request Objectを1分間保持し、Keycloakから取得された時点で削除します。
これにより、同じrequest_uriを繰り返し使う動作を防ぎます。
実運用では、複数インスタンス間で共有できる一時ストレージと、適切な有効期限も検討します。
検証するケースと失敗ケース
JARなしのリクエスト
この構成のplainプロファイルは、Request Objectを使わない通常の認可リクエストを確認するためのクライアントです。
Keycloakクライアントの設定でRequest object requiredをNot requiredにしているため、Request Objectを含まない認可リクエストでも拒否されずに処理されます。
未認証の場合は、その後にログイン画面が表示されます。
Request Objectを必須にしたクライアントへRequest Objectを含まない認可リクエストを送ると拒否されます。
このサンプルでは、plainをRequest Objectを必須にするクライアントとは別に設定しています。
これは、JARを必須化しないことで保護を回避されるdowngrade attackへの対策です。RFC 9101 section 10.5
requestとrequest_uriの同時指定
RFC 9101では、requestとrequest_uriを同じ認可リクエストに含めることは禁止されています。RFC 9101 section 5
both-rs256-importを使うと、両方を送信できます。
curl -sI http://localhost:8081/login/both-rs256-import | grep 'Location'Keycloakは次のようなエラーを記録し、ログイン画面を表示しません。
KC-SERVICES0097: Invalid request: java.lang.RuntimeException:Illegal to use both 'request' and 'request_uri' parameters togetherセキュリティ特性と注意点
JARの方式ごとの効果を整理すると、次のようになります。
| 方式 | 完全性と送信元認証 | 機密性 | ブラウザに載る情報 |
|---|---|---|---|
| 通常の認可リクエスト | なし | なし | 認可パラメーター |
JWS + request | ○ | なし | 署名済みJWT |
JWS + request_uri | ○ | なし | request_uri |
JWS + JWE + request | ○ | ○ | 暗号化JWT |
JWS + JWE + request_uri | ○ | ○ | request_uri |
この表のとおり、それぞれの手段が解決する問題は異なります。
- JWSはRequest Objectの改ざん検知と送信元認証を提供する
- JWEを組み合わせるとRequest Objectの機密性も得られる
request_uriはブラウザへ渡すURL上の情報量を減らすrequest_uriそのものは、Request Objectの署名や暗号化の代わりにはならない
RFC 9101では、クエリパラメーターとRequest Objectのクレームが重複する場合でも、認可サーバーはRequest Object内の値を使うことが求められています。RFC 9101 section 5
これは、クエリとRequest Objectの値が異なる場合に、保護されていないクエリ側の値へ切り替えられることを防ぐためです。RFC 9101 section 10.7
RFC 9101 section 10.4: request_uriに関連するリスク
request_uriを使うと、認可サーバーはクライアントが指定したURIへアクセスしてRequest Objectを取得します。
つまり、署名を検証する前に、認可サーバーがクライアント指定のURIへアクセスする段階があります。
この取得処理には、通常の認可リクエストにはないリスクがあります。
予期しない取得先とDDoS
悪意のあるクライアントが、認可サーバーから到達可能な任意のURIをrequest_uriに指定できると、内部ネットワークへのアクセスを誘発するSSRF攻撃につながる可能性があります。
また、巨大なレスポンスや応答に時間のかかるURIを指定することで、認可サーバーのリソースを消費させるDDoSも考えられます。
RFC 9101は、request_uriが予期しない場所を指していないこと、レスポンスのメディアタイプがapplication/oauth-authz-req+jwtであること、取得にタイムアウトを設けること、再帰的なGETを実行しないことを原則として求めています。RFC 9101 section 10.4.1
Keycloakでは、クライアントのValid Request URIsに、取得を許可するRequest ObjectのURIを設定します。
これは、RFC 9101が挙げる「予期しない場所を指していないこと」を確認するための許可リストです。
Keycloakの公式ドキュメントも、この設定の目的をSSRF攻撃の回避と説明しています。 Keycloak Upgrading Guide
このサンプルのように、Request ObjectのURIにランダムなIDを含める場合は、次のようにパスを限定します。
Valid Request URIs:https://client.example.com/request-objects/*ホスト全体を許可する設定や、*だけの設定は避けます。
Valid Request URIsは取得先を制限する設定です。
これだけで、レスポンスサイズの制限、取得タイムアウト、メディアタイプの検証、再帰的な取得の禁止まで保証されるわけではありません。
これらは、認可サーバーの実装やネットワークの出口制御も含めて確認する必要があります。
request_uriの書き換え
request_uri自体には署名がないため、ブラウザ上で別のURIに書き換えられる可能性があります。
書き換え後のURIが攻撃者のRequest Objectや別のクライアントのURIを指すと、意図しない認可リクエストを取得する可能性があります。RFC 9101 section 10.4.2
このリスクに対して、Valid Request URIsは書き換え後の取得先が許可範囲外であれば拒否するために使います。
一方、取得されたRequest Objectの内容が改ざんされていないことは、JWSの署名検証によって確認します。
許可範囲内の別のRequest Objectに書き換えられた場合に備えて、クエリのclient_idとRequest Object内のclient_idが一致することも確認します。
URIの宛先の保護と、取得したRequest Objectの内容の保護は別の問題です。
設定の境界は、次のように確認できます。
| ケース | 期待する結果 |
|---|---|
許可済みの/request-objects/<ランダム値> | KeycloakがRequest Objectを取得し、認可処理を続行する |
| 許可外のパス | Request Objectの取得前に拒否する |
| 許可外のホスト | Request Objectの取得前に拒否する |
なお、今回のサンプルはクライアントがRequest Objectをホストし、KeycloakがそのURIへGETする構成です。
PAR(RFC 9126)では、クライアントが認可リクエストを認可サーバーへ直接送信し、認可サーバーが発行したrequest_uriを後続の認可リクエストで使用します。
そのため、クライアントが外部URIを指定して認可サーバーに取得させる今回の構成とは、request_uriの取得経路とリスクが異なります。RFC 9126
| 方式 | request_uriの取得経路 |
|---|---|
| JAR by-reference | クライアントがホストする外部URIを認可サーバーがGETする |
| PAR | クライアントが認可サーバーへリクエストを送信し、認可サーバーが管理するURIを返す |
まとめ
JARは、認可リクエストをRequest ObjectというJWTにまとめて送る仕組みです。
- JWS署名により、認可リクエストの完全性と送信元認証を保護できる
- JWEを組み合わせると、Request Objectの機密性も保護できる
requestはRequest Objectを直接渡し、request_uriはURIで参照渡しするrequest_uriはURL上の情報量を減らすが、署名や暗号化そのものではない- Keycloakの
Request object requiredで、クライアントごとにJARを必須化できる