跳到主要内容

使用 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。

前置条件

生成签名密钥

为客户端身份认证和 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 路由所使用的保护措施:

  1. 在 Keycloak Admin Console 中,选择 Clients > apisix-quickstart-client > Settings
  2. Capability config 中,保持 Client authenticationStandard flow 启用。开启 Require PKCERequire DPoP bound tokens,然后选择 Save
  3. 打开 Credentials 标签页。将 Client Authenticator 设置为 Signed JWT,然后选择 Save
  4. 打开 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 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 匹配,且不得包含私钥字段。

验证 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-TokenX-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 响应头的 400401 响应后,APISIX 会自动创建新证明并重试一次令牌请求。收到带有该响应头的 401 响应后,APISIX 也会重试一次用户信息请求。APISIX 不会进行第二次重试;请查看其错误日志,了解身份提供商返回的响应。

生产环境准备

本地 Keycloak 快速入门使用 HTTP。生产环境中,请为身份提供商使用 HTTPS,并保持 ssl_verify 启用,使 APISIX 验证其证书。DPoP 会对访问令牌施加发送方约束,但不能提供传输机密性,也不能取代通过服务器身份认证的 TLS。

将 DPoP 私钥存储在密钥管理器中,并限制对该私钥的访问。轮换密钥时,请注意现有访问令牌仍保留旧公钥的指纹。请根据访问令牌的生命周期和会话续订策略协调密钥轮换,不要假设现有令牌会重新绑定到新密钥。

后续步骤

openid-connect 插件参考文档介绍了其余 PAR、DPoP 和客户端断言选项。