Scope DPoP nonces per request instead of mutating shared opts#567
Conversation
DPoP nonces were cached on the opts table (opts.dpop_token_nonce / opts.dpop_userinfo_nonce). When a deployment defines opts once at module/init scope and reuses it across requests, this is shared mutable state across concurrent requests; the `or opts.dpop_token_nonce` seed in the refresh path could carry one session's nonce into another request. Move the transient, per-exchange nonce into request-scoped ngx.ctx via openidc_get_dpop_nonce/openidc_set_dpop_nonce. The durable per-user token nonce continues to live in the session (the legitimate cross-request store), and opts.dpop_nonce remains a static, caller-provided seed, so no optimization is lost. Add a regression test driving two independent sessions through one server with a shared opts table (new share_oidc_opts harness flag) and asserting the second session's token request does not inherit the first's nonce. The test fails against the previous opts-mutating code and passes now. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a concurrency issue where DPoP nonces could leak across requests when deployments reuse a single opts table (module/init-scoped), by moving transient DPoP nonce state out of opts and into request-scoped storage. It also extends the test harness to model shared opts tables across requests and adds a regression test for the nonce leak.
Changes:
- Store per-exchange DPoP nonces in request-scoped state (instead of mutating
opts.dpop_*_nonce). - Add a test harness flag to reuse a single
optstable across requests. - Add a regression test ensuring two independent sessions don’t share token-request DPoP nonces.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
lib/resty/openidc.lua |
Moves transient DPoP nonce handling from shared opts into request-scoped storage. |
tests/spec/test_support.lua |
Adds share_oidc_opts support to reuse the same opts table across requests in tests. |
tests/spec/dpop_spec.lua |
Adds a regression test for cross-session nonce leakage when opts is shared. |
ChangeLog |
Documents the behavioral/security fix for scoping DPoP nonces to a request. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Add share_oidc_opts to the start_server custom_config option list so test authors can discover it. Comment-only change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Hi @zandbelt , Thanks for putting this together. The approach looks good to me: keeping the transient DPoP nonce in The regression test also nicely covers the shared-opts deployment pattern that exposed the issue. Would you be comfortable with me merging this? |
|
yes, feel free to merge; also contact me at my e-mail to receive a luarocks API key for uploading a new release when you deem it ready |
|
Hi @zandbelt , Thank you. I will email you when it is ready to release. |
Summary
DPoP nonces were cached on the
optstable (opts.dpop_token_nonce/opts.dpop_userinfo_nonce). When a deployment definesoptsonce at module/init scope and reuses it across requests — a common, documented pattern — that is shared mutable state across concurrent requests in a worker. The sharpest edge was the refresh-path seed:The
or opts.dpop_token_noncefallback means that if the current user's session has no nonce, the request inherits whatever nonce the previous request left onopts— i.e. one user's DPoP nonce bleeding into another user's token request.The durable, per-user nonce already lives in the session (
session:set/get("dpop_token_nonce")); theopts.dpop_*_noncefields were only a transient bridge from session →call_token_endpoint. This PR moves that transient state into request-scopedngx.ctxvia two small helpers (openidc_get_dpop_nonce/openidc_set_dpop_nonce). The session persistence and the static, caller-providedopts.dpop_nonceseed are untouched, so no legitimate cross-request optimization is lost — the session still carries the token nonce between requests.Regression test
Added a test driving two independent sessions through one server and asserting the second session's token request does not inherit the first's nonce.
Because the test harness rebuilds
optsper request, the shared-optsleak can't be reproduced as-is, so the test uses a new opt-inshare_oidc_optsharness flag (default off; all other tests unaffected) that reuses a singleoptstable across requests, modeling the "define opts once" deployment. The test fails against the previous opts-mutating code (second_payload.nonce == "session-one-nonce") and passes now.Checklist
docker build -f tests/Dockerfile . -t lua-resty-openidc/testdocker run -t --rm lua-resty-openidc/test:latest—550 successes / 0 failures / 0 errors / 1 pending(the pending is the pre-existing upstream test)ChangeLogis updated