使用 PAR 和 DPoP 保护 OIDC
推送式授权请求(PAR)允许 APISIX 将授权请 求参数直接发送到身份提供商,使这些参数不会出现在浏览器重定向中。DPoP 发送方约束 Token会将 APISIX 收到的访问 Token 绑定到 APISIX 持有的密钥。APISIX 调用 Token 端点和请求用户信息时,会发送使用该密钥签名的证明。Token 端点必须先验证此证明,才会签发 DPoP 绑定访问 Token。攻击者即使窃取了访问 Token,没有该密钥也无法在用户信息端点重放 Token。
在 OpenID Connect(OIDC)授权码流程中,APISIX 和 Keycloak 可以单独启用 PAR、DPoP、授权码交换证明密钥(PKCE)和私钥 JWT,也可以同时启用这四项功能。全部启用后,openid-connect 插件会使用签名的客户端断言向 Keycloak 的 PAR 端点和 Token 端点进行身份认证,而不是使用共享客户端密钥。
在此流程中,APISIX 是 OIDC 客户端并持有 DPoP 密钥。DPoP 保护 APISIX 与 Keycloak 交互时使用的访问 Token,但不会让 APISIX 验证外部客户端调用受保护路由时提供的 DPoP 证明。由于本指南将 APISIX 配置为机密客户端,Keycloak 会通过客户端身份认证保护刷新 Token,而不会将刷新 Token 绑定到 DPoP 密钥。
流程工作原理
私钥 JWT 证明和 DPoP 证明分别使用独立的密钥对。客户端身份认证密钥用于在 PAR 端点和 Token 端点证明 APISIX 的身份;DPoP 密钥则将授权码和访问 Token 绑定到 APISIX。
前 置条件
- 按照快速入门教程启动 APISIX。
- 安装 OpenSSL、Node.js 18 或更高版本以及 jq。
- 如果使用 ADC 示例,请安装 ADC。
- 完成设置 Keycloak 单点登录中从配置 Keycloak到保存 OIDC 配置的操作。该页面会启动 Keycloak 26.7.3,并创建本指南会复用的
quickstart-realm、apisix-quickstart-client、quickstart-user和KEYCLOAK_DISCOVERY值。 - 如果使用 Admin API 示例且启用了身份验证,请将管理员角色的密钥导出为
ADMIN_API_KEY。请在deployment.admin.admin_key下配置该密钥。Docker 快速入门为了便于本地评估而禁用了身份验证;生产环境中请保持启用。
生成签名密钥
为客户端身份认证和 DPoP 分别使用独立的密钥对。Keycloak 使用客户端身份认证证书验证客 户端断言。APISIX 使用 DPoP 密钥证明持有该密钥,Keycloak 则将签发的访问 Token 绑定到其公钥指纹。
创建工作目录,并为客户端身份认证生成 RSA 私钥和自签名证书:
umask 077
mkdir -p oidc-keys
openssl genpkey -algorithm RSA \
-pkeyopt rsa_keygen_bits:2048 \
-out oidc-keys/client-assertion-private.pem
openssl req -new -x509 \
-key oidc-keys/client-assertion-private.pem \
-out oidc-keys/client-assertion.crt \
-days 365 \
-subj "/CN=apisix-par-dpop-client"
为 DPoP 生成 EC P-256 密钥,并将其公钥导出为 JWK:
node --input-type=module <<'EOF'
import { generateKeyPairSync } from "node:crypto";
import { writeFileSync } from "node:fs";
const { privateKey, publicKey } = generateKeyPairSync("ec", {
namedCurve: "P-256",
});
writeFileSync(
"oidc-keys/dpop-private.pem",
privateKey.export({ format: "pem", type: "pkcs8" }),
);
writeFileSync(
"oidc-keys/dpop-public-jwk.json",
`${JSON.stringify(publicKey.export({ format: "jwk" }), null, 2)}\n`,
);
EOF
umask 会将生成的密钥文件限制为仅当前用户可访问。请妥善保管两个私钥。生产部署中,请从 APISIX Secret 加载私钥,不要将其直接存储在路由配置中。
配置 Keycloak 客户端
在同一个 Keycloak Realm 中创建一个名为 apisix-par-dpop-client 的独立 OpenID Connect 客户端。保持客户端独立可以避免更改基本 SSO 示例所使用的客户端。按如下步骤为它启用 APISIX 路由使用的保护措施:
- 在 Keycloak Admin Console 中选择 Clients,创建 OpenID Connect 客户端
apisix-par-dpop-client,然后打开其 Settings。 - 在 Valid redirect URIs 下选择 Add valid redirect URIs,然后输入
http://localhost:9080/anything/par/callback。 - 在 Capability config 中开启 Client authentication 和 Require PKCE,保持 Standard flow 启用,将 PKCE 方法选择为 S256,开启 Require DPoP bound tokens,然后选择 Save。
- 打开 Credentials 标签页。将 Client Authenticator 设置为 Signed JWT,然后选择 Save。
- 打开 Keys 标签页,选择 Import certificate 和 Certificate PEM,然后导入公钥证书
oidc-keys/client-assertion.crt。私钥应保留在网关侧。
Keycloak 会在 OIDC 发现文档中发布其 PAR 端点,因此无需在 APISIX 中另行配置 PAR 端点。
选择任一配置标签页前,请设置专用客户端 ID 并生成新的会话密钥:
export KEYCLOAK_CLIENT_ID="apisix-par-dpop-client"
export APISIX_SESSION_SECRET="$(openssl rand -hex 32)"
配置 APISIX
在 Docker 快速入门网络中启动本地回显上游。验证步骤只会将用户信息发送到这个受控容器,而不会发送给公共服务:
docker run -d \
--name oidc-dpop-verifier \
--network apisix-quickstart-net \
mccutchen/go-httpbin:v2.15.0
创建一个启用了 openid-connect 插件的路由。该路由将 accept_unsupported_alg 设置为 false,使 APISIX 拒绝使用不受支持签名算法的 ID Token。本示例中的 Keycloak 使用受支持的 RS256 算法签名 ID Token。
- Admin API
- ADC
将密钥文件转换为可插入 Admin API 请求的 JSON 值:
export OIDC_CLIENT_PRIVATE_KEY_JSON="$(jq -Rs . < oidc-keys/client-assertion-private.pem)"
export OIDC_DPOP_PRIVATE_KEY_JSON="$(jq -Rs . < oidc-keys/dpop-private.pem)"
export OIDC_DPOP_PUBLIC_JWK_JSON="$(jq -c . < oidc-keys/dpop-public-jwk.json)"
创建路由:
curl -i "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: $ADMIN_API_KEY" \
--data-binary @- <<EOF
{
"id": "oidc-par-dpop",
"uri": "/anything/par/*",
"plugins": {
"openid-connect": {
"bearer_only": false,
"client_id": "$KEYCLOAK_CLIENT_ID",
"discovery": "$KEYCLOAK_DISCOVERY",
"scope": "openid profile email",
"redirect_uri": "http://localhost:9080/anything/par/callback",
"accept_unsupported_alg": false,
"set_access_token_header": false,
"set_id_token_header": false,
"set_userinfo_header": true,
"set_refresh_token_header": false,
"use_pkce": true,
"token_endpoint_auth_method": "private_key_jwt",
"client_rsa_private_key": $OIDC_CLIENT_PRIVATE_KEY_JSON,
"client_jwt_assertion_alg": "RS256",
"par": {
"enabled": true,
"endpoint_auth_method": "private_key_jwt"
},
"dpop": {
"enabled": true,
"signing_alg": "ES256",
"private_key": $OIDC_DPOP_PRIVATE_KEY_JSON,
"public_jwk": $OIDC_DPOP_PUBLIC_JWK_JSON
},
"session": {
"secret": "$APISIX_SESSION_SECRET"
}
},
"proxy-rewrite": {
"headers": {"remove": ["Authorization", "Cookie"]}
}
},
"upstream": {
"type": "roundrobin",
"nodes": {
"oidc-dpop-verifier:8080": 1
}
}
}
EOF
❶ use_pkce:设置为 true,以便在授权期间发送 S256 PKCE 质询。
❷ token_endpoint_auth_method:设置为 private_key_jwt。APISIX 使用 client_rsa_private_key 对 Token 端点的客户端断言进行签名。
❸ client_jwt_assertion_alg:必须与密钥类型以及 Keycloak 接受的算法一致。上面生成的 RSA 密钥使用 RS256。
❹ par:将 enabled 设置为 true,将 endpoint_auth_method 设置为 private_key_jwt。APISIX 会将授权参数发送到发现文档中的 PAR 端点,浏览器重定向则包含返回的 request_uri。
❺ dpop:将 enabled 设置为 true,以便 APISIX 向 Token 端点发送 DPoP 证明,并在请求用户信息时发送 DPoP 证明。public_jwk 必须与 private_key 匹配,且不得包含私钥字段。
将 PEM 文件加载到环境变量中,然后输出 DPoP 公共 JWK,以便复制到 dpop.public_jwk:
export OIDC_CLIENT_PRIVATE_KEY="$(cat oidc-keys/client-assertion-private.pem)"
export OIDC_DPOP_PRIVATE_KEY="$(cat oidc-keys/dpop-private.pem)"
jq '{kty, crv, x, y}' oidc-keys/dpop-public-jwk.json
创建路由:
services:
- name: keycloak-par-dpop
routes:
- uris:
- /anything/par/*
name: oidc-par-dpop
plugins:
openid-connect:
bearer_only: false
client_id: ${KEYCLOAK_CLIENT_ID}
discovery: ${KEYCLOAK_DISCOVERY}
scope: openid profile email
redirect_uri: "http://localhost:9080/anything/par/callback"
accept_unsupported_alg: false
set_access_token_header: false
set_id_token_header: false
set_userinfo_header: true
set_refresh_token_header: false
use_pkce: true
token_endpoint_auth_method: private_key_jwt
client_rsa_private_key: ${OIDC_CLIENT_PRIVATE_KEY}
client_jwt_assertion_alg: RS256
par:
enabled: true
endpoint_auth_method: private_key_jwt
dpop:
enabled: true
signing_alg: ES256
private_key: ${OIDC_DPOP_PRIVATE_KEY}
public_jwk:
kty: EC
crv: P-256
x: replace-with-x
y: replace-with-y
session:
secret: ${APISIX_SESSION_SECRET}
proxy-rewrite:
headers:
remove:
- Authorization
- Cookie
upstream:
type: roundrobin
nodes:
- host: oidc-dpop-verifier
port: 8080
weight: 1
❶ use_pkce:设置为 true,以便在授权期间发送 S256 PKCE 质询。
❷ token_endpoint_auth_method:设置为 private_key_jwt。APISIX 使用 client_rsa_private_key 对 Token 端点的客户端断言进行签名。
❸ client_jwt_assertion_alg:必须与密钥类型以及 Keycloak 接受的算法一致。上面生成的 RSA 密钥使用 RS256。
❹ par:将 enabled 设置为 true,将 endpoint_auth_method 设置为 private_key_jwt。APISIX 会将授权参数发送到发现文档中的 PAR 端点,浏览器重定向则包含返回的 request_uri。
❺ dpop:将 enabled 设置为 true,以便 APISIX 向 Token 端点发送 DPoP 证明,并在请求用户信息时发送 DPoP 证明。public_jwk 必须与 private_key 匹配,且不得包含私钥字段。请将输出的 JWK 字段粘贴到 public_jwk 中。
ADC 会将服务作为期望状态进行协调。标签选择器将此示例限制在其自身带标签的资源内。请先预览限定范围的变更,并确认其中没有意外更新或删除:
adc diff -f adc.yaml \
--include-resource-type service \
--label-selector docs-example=keycloak-par-dpop
同步已审查的服务配置:
adc sync -f adc.yaml \
--include-resource-type service \
--label-selector docs-example=keycloak-par-dpop
验证 OIDC 流程
首先检查 PAR 重定向,然后完成登录,并验证凭证未被转发到本地验证上游。
验证 PAR 重定向
请求受保护路由时不要跟随重定向,以便检查 Location 响应头:
curl -sS -D - -o /dev/null "http://localhost:9080/anything/par/test"
你应该收到 HTTP/1.1 302 响应。Location 响应头应包含 request_uri,类似如下:
Location: http://192.168.42.145:8080/realms/quickstart-realm/protocol/openid-connect/auth?client_id=apisix-par-dpop-client&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A...
出现 request_uri 表明 APISIX 已将授权请求推送到 Keycloak,而不是将完整的请求参数放入浏览器重定向中。
验证登录和 DPoP 强制执行
完成 PAR 重定向后,在浏览器的无痕窗口中打开 http://localhost:9080/anything/par/test。使用 Keycloak 测试用户登录。Keycloak 返回授权码,APISIX 将其交换为 Token,然后已通过身份认证的请求会到达本地回显上游。
验证响应是否成功。回显的请求头应包含 X-Userinfo,但不应包含 Authorization、Cookie、X-Access-Token、X-Id-Token 或 X-Refresh-Token。
只有在 Keycloak 接受 UserInfo 请求后,APISIX 才会添加 X-Userinfo。Keycloak 会检查 APISIX 的证明是否使用 Token 的 cnf.jkt 声明所标识的密钥,以及该证明是否绑定到相应访问 Token。APISIX 还会拒绝 token_type 不是 DPoP 的 Token 响应。
缺少所需证明或使用其他密钥的用户信息请求会被 Keycloak 拒绝。本指南保持禁用访问 Token 转发,并仅将用户信息发送到本地验证容器。除非受信任的上游需要这些声明,否则请将 set_userinfo_header 恢复为 false。有关 nonce 和签名失败的处理方法,请参阅下方的故障排除部分。
排查 DPoP 问题
Token 端点返回 Bearer Token
如果 APISIX 错误日志包含 token endpoint returned an access token without token_type DPoP,请确认已为 Keycloak 客户端启用 Require DPoP bound tokens。启用 DPoP 后,APISIX 会拒绝未将 token_type 声明为 DPoP 的 Token 响应。
身份提供商拒绝证明
当发现文档列出 dpop_signing_alg_values_supported 时,APISIX 会检查其中是否包含 dpop.signing_alg。如果身份提供商拒绝证明,请确认 APISIX 与身份提供商的时钟 已同步。此外,请确认 APISIX 通过发现文档中发布的 URL 访问 Token 端点和用户信息端点。DPoP 证明会绑定到请求方法、端点 URL 和创建时间。
身份提供商可以要求使用新 nonce 来防止证明重放。收到带有 DPoP-Nonce 响应头的 400 或 401 响应后,APISIX 会自动创建新证明并重试一次 Token 请求。收到带有该响应头的 401 响应后,APISIX 也会重试一次用户信息请求。APISIX 不会进行第二次重试;请查看其错误日志,了解身份提供商返回的响应。
生产环境准备
本地 Keycloak 快速入门使用 HTTP。生产环境中,请为身份提供商使用 HTTPS,并保持 ssl_verify 启用,使 APISIX 验证其证书。DPoP 会对访问 Token 施加发送方约束,但不能提供传输机密性,也不能取代通过服务器身份认证的 TLS。
将 DPoP 私钥存储在密钥管理器中,并限制对该私钥的访问。轮换密钥时,请注意现有访问 Token 仍保留旧公钥的指纹。请根据访问 Token 的生命周期和会话续订策略协调密钥轮换,不要假设现有 Token 会重新绑定到新密钥。
后续步骤
openid-connect 插件参考文档介绍了其余 PAR、DPoP 和客户端断言选项。