This is an automated email from the ASF dual-hosted git repository.

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 385b80a6e fix(jwe-decrypt): accept JWE tokens that authenticate the 
protected header (#13889)
385b80a6e is described below

commit 385b80a6ed1b38b8df3f4f2515601f70a78f2c68
Author: AlinsRan <[email protected]>
AuthorDate: Tue Sep 1 14:42:27 2026 +0800

    fix(jwe-decrypt): accept JWE tokens that authenticate the protected header 
(#13889)
---
 apisix/plugins/jwe-decrypt.lua        |  30 +++++-
 docs/en/latest/plugins/jwe-decrypt.md |  20 ++--
 docs/zh/latest/plugins/jwe-decrypt.md |   6 +-
 t/plugin/jwe-decrypt.t                | 167 ++++++++++++++++++++++++++++++++++
 4 files changed, 211 insertions(+), 12 deletions(-)

diff --git a/apisix/plugins/jwe-decrypt.lua b/apisix/plugins/jwe-decrypt.lua
index bdaee8425..3a7a150a1 100644
--- a/apisix/plugins/jwe-decrypt.lua
+++ b/apisix/plugins/jwe-decrypt.lua
@@ -138,6 +138,17 @@ local function load_jwe_token(jwe_token)
 end
 
 
+-- the plugin only implements direct encryption with A256GCM; reject a token
+-- that asks for anything else instead of failing later with a decrypt error
+local function unsupported_header(header_obj)
+    if header_obj.alg ~= nil and header_obj.alg ~= "dir" then
+        return true
+    end
+
+    return header_obj.enc ~= nil and header_obj.enc ~= "A256GCM"
+end
+
+
 local function jwe_decrypt_with_obj(o, consumer)
     local secret = get_secret(consumer.auth_conf)
     if not secret then
@@ -160,7 +171,20 @@ local function jwe_decrypt_with_obj(o, consumer)
         return nil, err
     end
 
-    return aes_default:decrypt(ciphertext, tag)
+    -- RFC 7516 authenticates the encoded protected header as the AES-GCM
+    -- additional authenticated data, which is what JWE libraries produce
+    local decrypted, decrypt_err = aes_default:decrypt(ciphertext, tag, 
o.header)
+    if decrypted then
+        return decrypted
+    end
+
+    -- tokens built the way APISIX used to build them carry no AAD
+    local plaintext, legacy_err = aes_default:decrypt(ciphertext, tag)
+    if not plaintext then
+        return nil, decrypt_err or legacy_err
+    end
+
+    return plaintext
 end
 
 
@@ -212,6 +236,10 @@ function _M.rewrite(conf, ctx)
         return 400, { message = "missing kid in JWE token" }
     end
 
+    if unsupported_header(jwe_obj.header_obj) then
+        return 400, { message = "unsupported alg or enc in JWE token" }
+    end
+
     local consumer = get_consumer(jwe_obj.header_obj.kid)
     if not consumer then
         return 400, { message = "invalid kid in JWE token" }
diff --git a/docs/en/latest/plugins/jwe-decrypt.md 
b/docs/en/latest/plugins/jwe-decrypt.md
index 78a8947b3..135090753 100644
--- a/docs/en/latest/plugins/jwe-decrypt.md
+++ b/docs/en/latest/plugins/jwe-decrypt.md
@@ -39,11 +39,11 @@ import TabItem from '@theme/TabItem';
 
 The `jwe-decrypt` Plugin reads a five-part compact token from a request 
header, selects a [Consumer](../terminology/consumer.md) by the token's `kid`, 
decrypts the ciphertext with AES-256-GCM, and writes the plaintext to a 
configured header before proxying the request. You can enable the Plugin on 
APISIX [Routes](../terminology/route.md) or 
[Services](../terminology/service.md).
 
-The token resembles [JWE Compact 
Serialization](https://datatracker.ietf.org/doc/html/rfc7516#section-3.1), but 
the current Plugin uses a Plugin-specific format. Configure a 32-byte 
decryption secret on the Consumer.
+The token uses [JWE Compact 
Serialization](https://datatracker.ietf.org/doc/html/rfc7516#section-3.1) with 
the `dir` key management algorithm and the `A256GCM` content encryption 
algorithm, so a token produced by a standard JWE library is accepted. Configure 
a 32-byte decryption secret on the Consumer.
 
 :::warning
 
-The current implementation reads `kid` from the decoded header but does not 
validate the `alg` or `enc` fields and does not use the protected-header 
segment as AES-GCM additional authenticated data (AAD). Standard RFC 7516 JWE 
libraries are therefore not directly interoperable. Generate tokens with the 
exact format described below, use a fixed trusted token generator, and do not 
treat header fields as authenticated.
+For backward compatibility, the Plugin also accepts a token whose ciphertext 
was encrypted without the protected header as AES-GCM additional authenticated 
data (AAD), which is how APISIX itself used to generate them. The header of 
such a token, including its `kid`, is not authenticated. Use a trusted token 
generator, and prefer a JWE library that follows RFC 7516 so that the header is 
covered by the AAD.
 
 :::
 
@@ -69,7 +69,7 @@ The decrypted plaintext is forwarded in a request header. For 
sensitive plaintex
 | -------------- | ------- | -------- | ------------- | ------------ | 
---------------------------------------------------------------------------------------------------------------------------------
 |
 | header         | string  | True     | Authorization |              | The 
header to get the token from.                                                   
                                              |
 | forward_header | string  | True     | Authorization |              | Name of 
the header that passes the plaintext to the Upstream.                           
                                          |
-| strict         | boolean | False    | true          |              | If 
true, return a 403 error when the encrypted plugin token is missing. If false, 
continue when the token is not found.           |
+| strict         | boolean | False    | true          |              | If 
true, return a 403 error when the JWE token is missing. If false, continue when 
the token is not found.                        |
 
 ## Examples
 
@@ -87,7 +87,7 @@ admin_key=$(yq '.deployment.admin.admin_key[0].key' 
conf/config.yaml | sed 's/"/
 
 ### Create a Consumer with the Decryption Key
 
-The following example demonstrates how to create a Consumer with the 
decryption key and generate an encrypted plugin token for it.
+The following example demonstrates how to create a Consumer with the 
decryption key and generate a JWE token for it.
 
 Create a Consumer with `jwe-decrypt` and configure the decryption key:
 
@@ -172,13 +172,15 @@ kubectl apply -f jwe-consumer-ic.yaml
 </TabItem>
 </Tabs>
 
-To generate a token for the Consumer, encrypt the payload offline with 
AES-256-GCM without protected-header AAD, using the Consumer secret as the key. 
Standard RFC 7516 libraries normally authenticate the protected header as AAD 
and are not directly interoperable with this Plugin. Use the following exact 
token structure:
+To generate a JWE token for the Consumer, use any JWE library that supports 
direct encryption with `A256GCM`, with the Consumer secret as the key. The 
token structure is:
 
 ```text
 base64url(header).<empty>.base64url(iv).base64url(ciphertext).base64url(tag)
 ```
 
-where the header is `{"alg":"dir","enc":"A256GCM","kid":"<consumer-key>"}`. 
The fields describe the intended algorithm and identify the Consumer, but the 
current Plugin does not authenticate or validate them. The IV must be unique 
and randomly generated for every token; never reuse an IV with the same key.
+where the header is `{"alg":"dir","enc":"A256GCM","kid":"<consumer-key>"}`; 
`alg` and `enc` are rejected if they are set to anything else. The IV must be 
unique and randomly generated for every token; never reuse an IV with the same 
key.
+
+As [RFC 7516](https://datatracker.ietf.org/doc/html/rfc7516#section-5.1) 
requires, a JWE library authenticates the encoded protected header as the 
AES-GCM additional authenticated data (AAD), which makes the `kid` 
tamper-proof. Tokens encrypted without AAD, such as the ones APISIX itself used 
to generate, are still accepted for backward compatibility.
 
 For example, the following token encrypts the payload 
`{"uid":10000,"uname":"test"}` for the Consumer key `jack-key` with the secret 
configured above:
 
@@ -186,9 +188,9 @@ For example, the following token encrypts the payload 
`{"uid":10000,"uname":"tes
 
eyJraWQiOiJqYWNrLWtleSIsImFsZyI6ImRpciIsImVuYyI6IkEyNTZHQ00ifQ..vi29KBCQKcVmPwTT.VToyPMFbq-ZY05MIpntP1N3AmYeq3zELQ0B6iQ.vuTPG2ODc-DjUTjNCzfA2A
 ```
 
-### Decrypt Data from the Plugin Token
+### Decrypt Data with JWE
 
-The following example demonstrates how to decrypt the plugin token generated 
above.
+The following example demonstrates how to decrypt the JWE token generated 
above.
 
 Create a Route with `jwe-decrypt` to decrypt the authorization header:
 
@@ -320,7 +322,7 @@ kubectl apply -f jwe-decrypt-ic.yaml
 </TabItem>
 </Tabs>
 
-Send a request to the Route with the encrypted plugin token in the 
`Authorization` header:
+Send a request to the Route with the JWE encrypted data in the `Authorization` 
header:
 
 ```shell
 curl "http://127.0.0.1:9080/anything/jwe"; -H 'Authorization: 
eyJraWQiOiJqYWNrLWtleSIsImFsZyI6ImRpciIsImVuYyI6IkEyNTZHQ00ifQ..vi29KBCQKcVmPwTT.VToyPMFbq-ZY05MIpntP1N3AmYeq3zELQ0B6iQ.vuTPG2ODc-DjUTjNCzfA2A'
diff --git a/docs/zh/latest/plugins/jwe-decrypt.md 
b/docs/zh/latest/plugins/jwe-decrypt.md
index eb6fcf9b9..b0cbe51e9 100644
--- a/docs/zh/latest/plugins/jwe-decrypt.md
+++ b/docs/zh/latest/plugins/jwe-decrypt.md
@@ -160,13 +160,15 @@ kubectl apply -f jwe-consumer-ic.yaml
 </TabItem>
 </Tabs>
 
-要为消费者生成 JWE 令牌,可使用任意 AES-256-GCM 库离线加密 payload,加密密钥为消费者的 secret。令牌结构如下:
+要为消费者生成 JWE 令牌,可使用任意支持 `A256GCM` 直接加密的 JWE 库,加密密钥为消费者的 secret。令牌结构如下:
 
 ```text
 base64url(header).<empty>.base64url(iv).base64url(ciphertext).base64url(tag)
 ```
 
-其中 header 为 `{"alg":"dir","enc":"A256GCM","kid":"<consumer-key>"}`。每个令牌的 IV 
必须唯一且随机生成,切勿在同一密钥下复用 IV。
+其中 header 为 `{"alg":"dir","enc":"A256GCM","kid":"<consumer-key>"}`,`alg` 与 
`enc` 若为其他值则会被拒绝。每个令牌的 IV 必须唯一且随机生成,切勿在同一密钥下复用 IV。
+
+按 [RFC 7516](https://datatracker.ietf.org/doc/html/rfc7516#section-5.1) 
的要求,JWE 库会将编码后的 protected header 作为 AES-GCM 的附加认证数据(AAD)参与认证,从而使 `kid` 
不可篡改。为保持向后兼容,未使用 AAD 加密的令牌(例如 APISIX 早期自行生成的令牌)仍然可以正常解密。
 
 例如,以下令牌使用上面配置的 secret,为消费者密钥 `jack-key` 加密了 payload 
`{"uid":10000,"uname":"test"}`:
 
diff --git a/t/plugin/jwe-decrypt.t b/t/plugin/jwe-decrypt.t
index 53f407c98..deb68a3fb 100644
--- a/t/plugin/jwe-decrypt.t
+++ b/t/plugin/jwe-decrypt.t
@@ -551,6 +551,26 @@ fo4XKdZ1xSrIZyms4q2BwPrW5lMpls9qqy5tiAk2esc=
                 return
             end
 
+            -- shares the secret of jwe_fail_user, so swapping a token kid to
+            -- this consumer isolates the AAD check from a key mismatch
+            code = t('/apisix/admin/consumers',
+                ngx.HTTP_PUT,
+                [[{
+                    "username": "jwe_fail_twin",
+                    "plugins": {
+                        "jwe-decrypt": {
+                            "key": "jwe-fail-key-twin",
+                            "secret": "12345678901234567890123456789012"
+                        }
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+                ngx.say("failed to add consumer")
+                return
+            end
+
             code = t('/apisix/admin/routes/10',
                 ngx.HTTP_PUT,
                 [[{
@@ -762,3 +782,150 @@ status: 400
     }
 --- response_body
 status: 400
+
+
+
+=== TEST 31: RFC 7516 token authenticating the protected header is accepted
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+
+            -- generated with an independent JWE producer (python 
cryptography),
+            -- so the tag covers the encoded protected header as the AES-GCM 
AAD
+            local token = 
"eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiandlLWZhaWwta2V5In0."
+                          .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.KaxbSD-kuYBVck03POSk7w"
+
+            local code = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
+                           { Authorization = "Bearer " .. token })
+            ngx.say("status: ", code)
+        }
+    }
+--- response_body
+status: 200
+
+
+
+=== TEST 32: token without AAD is still accepted
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+
+            -- same payload, encrypted the way APISIX used to generate tokens
+            local token = 
"eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiandlLWZhaWwta2V5In0."
+                          .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.rNt131nG5wMvUD1KXbwLGA"
+
+            local code = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
+                           { Authorization = "Bearer " .. token })
+            ngx.say("status: ", code)
+        }
+    }
+--- response_body
+status: 200
+
+
+
+=== TEST 33: replacing the kid of an RFC 7516 token is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+
+            -- the TEST 31 token with its kid changed to jwe-fail-key-twin,
+            -- which holds the same secret: decryption can only fail because
+            -- the tag no longer covers the header
+            local token = 
"eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjoiandlLWZhaWwta2V5LXR3aW4ifQ."
+                          .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.KaxbSD-kuYBVck03POSk7w"
+
+            local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
+                                 { Authorization = "Bearer " .. token })
+            ngx.say("status: ", code, " body: ", ((body or ""):gsub("%s+$", 
"")))
+        }
+    }
+--- response_body
+status: 400 body: {"message":"failed to decrypt JWE token"}
+
+
+
+=== TEST 34: unsupported alg is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local token = 
"eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZHQ00iLCJraWQiOiJqd2UtZmFpbC1rZXkifQ."
+                          .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.7QVBNAw7GFOQRLCtZWtdsA"
+
+            local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
+                                 { Authorization = "Bearer " .. token })
+            ngx.say("status: ", code, " body: ", ((body or ""):gsub("%s+$", 
"")))
+        }
+    }
+--- response_body
+status: 400 body: {"message":"unsupported alg or enc in JWE token"}
+
+
+
+=== TEST 35: unsupported enc is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local token = 
"eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4R0NNIiwia2lkIjoiandlLWZhaWwta2V5In0."
+                          .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.99OmOTEx2wPsqhsx0FjM8Q"
+
+            local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
+                                 { Authorization = "Bearer " .. token })
+            ngx.say("status: ", code, " body: ", ((body or ""):gsub("%s+$", 
"")))
+        }
+    }
+--- response_body
+status: 400 body: {"message":"unsupported alg or enc in JWE token"}
+
+
+
+=== TEST 36: token whose header omits alg and enc is still accepted
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+
+            -- the plugin never read alg or enc before, so a token minted with
+            -- a minimal header keeps working; only a header naming another
+            -- algorithm is rejected
+            local token = "eyJraWQiOiJqd2UtZmFpbC1rZXkifQ."
+                          .. ".MTIzNDU2Nzg5MDEy.6JeRgm0.rNt131nG5wMvUD1KXbwLGA"
+
+            local code = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
+                           { Authorization = "Bearer " .. token })
+            ngx.say("status: ", code)
+        }
+    }
+--- response_body
+status: 200
+
+
+
+=== TEST 37: header whose alg is a JSON false is rejected
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin").test
+            local core = require("apisix.core")
+            local enc = require("ngx.base64").encode_base64url
+
+            -- a present but non-string alg is not an omitted alg, so the
+            -- backward compatible path must not swallow it
+            local header = enc(core.json.encode({
+                alg = false, enc = "A256GCM", kid = "jwe-fail-key",
+            }))
+            local token = header .. ".." .. enc("123456789012") .. "."
+                          .. enc("undecryptable") .. "." .. 
enc("0123456789abcdef")
+
+            local code, body = t('/jwe-decrypt-fail', ngx.HTTP_GET, nil, nil,
+                                 { Authorization = "Bearer " .. token })
+            ngx.say("status: ", code, " body: ", ((body or ""):gsub("%s+$", 
"")))
+        }
+    }
+--- response_body
+status: 400 body: {"message":"unsupported alg or enc in JWE token"}

Reply via email to