使用 PAR 和 DPoP 保护 OIDC
推送式授权请求(PAR)允许 APISIX 将授权请求参数直接发送到身份提供商,使这些参数不会出现在浏览器重定向中。DPoP 发送方约束令牌会将 APISIX 收到的访问令牌绑定到 APISIX 持有的密钥。APISIX 调用令牌端点和请求用户信息时,会发送使用该密钥签名的证明。令牌端点必须先验证此证明,才会签发 DPoP 绑定访问令牌。攻击者即使窃取了访问令牌,没有该密钥也无法在用户信息端点重放令牌。
在 OpenID Connect(OIDC)授权码流程中,APISIX 和 Keycloak 可以单独启用 PAR、DPoP、授权码交换证明 密钥(PKCE)和私钥 JWT,也可以同时启用这四项功能。全部启用后,openid-connect 插件会使用签名的客户端断言向 Keycloak 的 PAR 端点和令牌端点进行身份认证,而不是使用共享客户端密钥。
在此流程中,APISIX 是 OIDC 客户端并持有 DPoP 密钥。DPoP 保护 APISIX 与 Keycloak 交互时使用的访问令牌,但不会让 APISIX 验证外部客户端调用受保护路由时提供的 DPoP 证明。由于本指南将 APISIX 配置为机密客户端,Keycloak 会通过客户端身份认证保护刷新令牌,而不会将刷新令牌绑定到 DPoP 密钥。
流程工作原理
私钥 JWT 证明和 DPoP 证明分别使用独立的密钥对。客户端身份认证密钥用于在 PAR 端点和令牌端点证明 APISIX 的身份;DPoP 密钥则将授权码和访问令牌绑定到 APISIX。
前置条件
- 按照快速入门教程启动 APISIX。
- 安装 OpenSSL、Node.js 18 或更高版本以及 jq。
- 如果使用 ADC 示例,请安装 ADC。
- 完成设置 Keycloak 单点登录中从配置 Keycloak到获取发现端点的操作。该页面会启动 Keycloak 26.7.1,并创建本指南会复用的
quickstart-realm、apisix-quickstart-client、quickstart-user和OIDC_DISCOVERY值。
生成签名密钥
为客户端身份认证和 DPoP 分别使用独立的密钥对。Keycloak 使用客户端身份认证证书验证客户端断言。APISIX 使用 DPoP 密钥证明持有该密钥,Keycloak 则将签发的访问令牌绑定到其公钥指纹。
创建工作目录,并为客户端身份认证生成 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-quickstart-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 客户端仍使用共享密钥进行身份认证,并将 PKCE 和 DPoP 保持为可选。请更新同一个 apisix-quickstart-client,使 Keycloak 强制执行 APISIX 路由所使用的保护措施:
- 在 Keycloak Admin Console 中,选择 Clients > apisix-quickstart-client > Settings。
- 在 Capability config 中,保持 Client authentication 和 Standard flow 启用。开启 Require PKCE 和 Require DPoP bound tokens,然后选择 Save。
- 打开 Credentials 标签页。将 Client Authenticator 设置为 Signed JWT,然后选择 Save。
- 打开 Keys 标签页,选择 Import certificate,然后导入
oidc-keys/client-assertion.crt。
Keycloak 会在 OIDC 发现文档中发布其 PAR 端点,因此无需在 APISIX 中另行配置 PAR 端点。
配置 APISIX
设置 OIDC 客户端 ID,然后创建一个启用了 openid-connect 插件的路由。该路由将 accept_unsupported_alg 设置为 false,使 APISIX 拒绝使用不受支持签名算法的 ID 令牌。本示例中的 Keycloak 使用受支持的 RS256 算法签名 ID 令牌。
export OIDC_CLIENT_ID=apisix-quickstart-client
- Admin API
- ADC
导出 Admin API Key,并将密钥文件转换为可插入 Admin API 请求的 JSON 值:
export ADMIN_API_KEY="replace-with-your-admin-api-key"
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/*",
"plugins": {
"openid-connect": {
"bearer_only": false,
"client_id": "$OIDC_CLIENT_ID",
"discovery": "$OIDC_DISCOVERY",
"scope": "openid profile email",
"redirect_uri": "http://localhost:9080/anything/callback",
"accept_unsupported_alg": 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": "f86cf31663a9c9fa0a28c2cc78badef1"
}
}
},
"upstream": {
"type": "roundrobin",
"nodes": {
"httpbin.org:80": 1
}
}
}
EOF
❶ use_pkce:设置为 true,以便在授权期间发送 S256 PKCE 质询。
❷ token_endpoint_auth_method:设置为 private_key_jwt。APISIX 使用 client_rsa_private_key 对令牌端点的客户端断 言进行签名。
❸ client_jwt_assertion_alg:必须与密钥类型以及 Keycloak 接受的算法一致。上面生成的 RSA 密钥使用 RS256。
❹ par:将 enabled 设置为 true,将 endpoint_auth_method 设置为 private_key_jwt。APISIX 会将授权参数发送到发现文档中的 PAR 端点,浏览器重定向则包含返回的 request_uri。
❺ dpop:将 enabled 设置为 true,以便 APISIX 向令牌端点发送 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: httpbin Service
routes:
- uris:
- /anything/*
name: oidc-par-dpop
plugins:
openid-connect:
bearer_only: false
client_id: ${OIDC_CLIENT_ID}
discovery: ${OIDC_DISCOVERY}
scope: openid profile email
redirect_uri: "http://localhost:9080/anything/callback"
accept_unsupported_alg: 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: "f86cf31663a9c9fa0a28c2cc78badef1"
upstream:
type: roundrobin
nodes:
- host: httpbin.org
port: 80
weight: 1
❶ use_pkce:设置为 true,以便在授权期间发送 S256 PKCE 质询。
❷ token_endpoint_auth_method:设置为 private_key_jwt。APISIX 使用 client_rsa_private_key 对令牌端点的客户端断言进行签名。
❸ client_jwt_assertion_alg:必须与密钥类型以及 Keycloak 接受的算法一致。上面生成的 RSA 密钥使用 RS256。
❹ par:将 enabled 设置为 true,将 endpoint_auth_method 设置为 private_key_jwt。APISIX 会将授权参数发送到发现文档中的 PAR 端点,浏览器重定向则包含返回的 request_uri。
❺ dpop:将 enabled 设置为 true,以便 APISIX 向令牌端点发送 DPoP 证明,并在请求用户信息时发送 DPoP 证明。public_jwk 必须与 private_key 匹配,且不得包含私钥字段。请将输出的 JWK 字段粘贴到 public_jwk 中。
将配置同步到 APISIX:
adc sync -f adc.yaml
验证 OIDC 流程
首先检查 PAR 重定向,然后完成浏览器登录,并检查访问令牌中的 DPoP 确认声明。
验证 PAR 重定向
请求受保护路由时不要跟随重定向,以便检查 Location 响应头:
curl -sS -D - -o /dev/null "http://localhost:9080/anything/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-quickstart-client&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A...
出现 request_uri 表明 APISIX 已将授权请求推送到 Keycloak,而不是将完整的请求参数放入浏览器重定向中。
登录并验证 DPoP 绑定令牌
完成 PAR 重定向后,在浏览器的无痕窗口中打开 http://localhost:9080/anything/test。使用用户名 quickstart-user 和密码 quickstart-user-pass 登录。Keycloak 返回授权码,APISIX 将其交换为令牌,然后将请求转发到 httpbin.org。你应该收到包含请求详细信息的 HTTP/1.1 200 OK 响应。确认 JSON 响应的 headers 下包含 X-Access-Token 和 X-Userinfo。复制 X-Access-Token 的值,然后将其导出:
export ACCESS_TOKEN="replace-with-the-x-access-token-value"
计算所配置公共 JWK 的 RFC 7638 指纹,并将其与访问令牌的确认声明进行比较:
node --input-type=module <<'EOF'
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const jwk = JSON.parse(readFileSync("oidc-keys/dpop-public-jwk.json", "utf8"));
const canonicalJwk = JSON.stringify({
crv: jwk.crv,
kty: jwk.kty,
x: jwk.x,
y: jwk.y,
});
const expected = createHash("sha256")
.update(canonicalJwk)
.digest("base64url");
const payload = process.env.ACCESS_TOKEN.split(".")[1];
const claims = JSON.parse(Buffer.from(payload, "base64url"));
const actual = claims.cnf?.jkt;
console.log(`Configured key thumbprint: ${expected}`);
console.log(`Access token cnf.jkt: ${actual}`);
if (!actual || actual !== expected) {
throw new Error("The access token is not bound to the configured DPoP key");
}
console.log("DPoP key binding verified");
EOF
你应该收到类似如下的响应:
Configured key thumbprint: afk5TWQB3rBQgvRKUcw0xCv2pWfTYI5w3yYJcTjosEQ
Access token cnf.jkt: afk5TWQB3rBQgvRKUcw0xCv2pWfTYI5w3yYJcTjosEQ
DPoP key binding verified
指纹匹配表明 Keycloak 已将访问令牌绑定到 APISIX 中配置的 DPoP 密钥。X-Userinfo 值表明 APISIX 从 Keycloak 请求用户信息时使用了有效的 DPoP 证明。向 Keycloak 发送的用户信息请求如果省略该证明或使用其他密钥,则会返回 401 Unauthorized。APISIX 还会拒绝 token_type 不是 DPoP 的令牌响应,因此流程成功完成也表明 Keycloak 未将访问令牌降级为 Bearer 令牌。
排查 DPoP 问题
令牌端点返回 Bearer 令牌
如果 APISIX 错误日志包含 token endpoint returned an access token without token_type DPoP,请确认已为 Keycloak 客户端启用 Require DPoP bound tokens。启用 DPoP 后,APISIX 会拒绝未将 token_type 声明为 DPoP 的令牌响应。
身份提供商拒绝证明
当发现文档列出 dpop_signing_alg_values_supported 时,APISIX 会检查其中是否包含 dpop.signing_alg。如果身份提供商拒绝证明,请确认 APISIX 与身份提供商的时钟已同步。此外,请确认 APISIX 通过发现文档中发布的 URL 访问令牌端点和用户信息端点。DPoP 证明 会绑定到请求方法、端点 URL 和创建时间。
身份提供商可以要求使用新 nonce 来防止证明重放。收到带有 DPoP-Nonce 响应头的 400 或 401 响应后,APISIX 会自动创建新证明并重试一次令牌请求。收到带有该响应头的 401 响应后,APISIX 也会重试一次用户信息请求。APISIX 不会进行第二次重试;请查看其错误日志,了解身份提供商返回的响应。
生产环境准备
本地 Keycloak 快速入门使用 HTTP。生产环境中,请为身份提供商使用 HTTPS,并保持 ssl_verify 启用,使 APISIX 验证其证书。DPoP 会对访问令牌施加发送方约束,但不能提供传输机密性,也不能取代通过服务器身份认证的 TLS。
将 DPoP 私钥存储在密钥管理器中,并限制对该私钥的访问。轮换密钥时,请注意现有访问令牌仍保留旧公钥的指纹。请根据访问令牌的生命周期和会话续订策略协调密钥轮换,不要假设现有令牌会重新绑定到新密钥。
后续步骤
openid-connect 插件参考文档介绍了其余 PAR、DPoP 和客户端断言选项。