跳到主要内容
版本:3.19.0

使用 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 路由使用的保护措施:

  1. 在 Keycloak Admin Console 中选择 Clients,创建 OpenID Connect 客户端 apisix-par-dpop-client,然后打开其 Settings。
  2. 在 Valid redirect URIs 下选择 Add valid redirect URIs,然后输入 http://localhost:9080/anything/par/callback。
  3. 在 Capability config 中开启 Client authentication 和 Require PKCE,保持 Standard flow 启用,将 PKCE 方法选择为 S256,开启 Require DPoP bound tokens,然后选择 Save。
  4. 打开 Credentials 标签页。将 Client Authenticator 设置为 Signed JWT,然后选择 Save。
  5. 打开 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 请求的 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 匹配,且不得包含私钥字段。

验证 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 和客户端断言选项。