Pac4jModuleRegistry: no hook for matchers/authorizers; non-rotating CSRF token expires after 4 h and is never renewed #62

Open
opened 2026-09-20 10:46:06 +00:00 by claude · 0 comments
Member

Found while enabling pac4j CSRF in a downstream HTMX app (MEDECA-348). There are two independent items. Each can be fixed without the other.

Line numbers refer to main at b6d4157c. Pac4jCsrfSupport.scala is unchanged between v0.1.23, v0.1.24 and main.

Item 1: Pac4jModuleRegistry.wrapModule gives no way to set matchers or authorizers

Pac4jHttpSecurity.secure accepts authorizers and matchers (server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala:116-118, also :76-77). Pac4jModuleRegistry.wrapModule calls pac4jSecurity.secure(clients = clients) only (.../pac4j/Pac4jModuleRegistry.scala:24). The only overridable hook is def clients: Option[String] = None (:15).

A project that needs an explicit security policy must copy the whole wrapModule body (:17-35) into an override to change one call. There are valid reasons for an explicit policy:

  • to name csrfToken/csrfCheck without taking pac4j's default securityHeaders matchers
  • to turn a check off for a transition period
  • to test with a DirectClient, because pac4j's default adds csrfCheck only for an IndirectClient

The copy then drifts from the parent without notice. MEDECA has carried such a copy since its CSRF was turned off.

Proposal: add def matchers: Option[String] = None and def authorizers: Option[String] = None beside clients, and pass them through at :24. The None defaults keep today's behaviour. Add one sentence to docs/CSRF_GUIDE.md that names the hooks.

Item 2: installNonRotatingMatcher yields a token that stops working 4 hours after the session's first secured request

Pac4jCsrfSupport.installNonRotatingMatcher creates a stock DefaultCsrfTokenGenerator and calls only setRotateTokens(false) (.../pac4j/Pac4jCsrfSupport.scala:68-79).

In pac4j-core 6.3.2, the generator writes CSRF_TOKEN_EXPIRATION_DATE only inside if (optCurrentToken.isEmpty() || rotateTokens) (DefaultCsrfTokenGenerator.java:44-51). The TTL is 4*60*60 (:26). With rotation off, the expiry is set once and never extended. CsrfAuthorizer rejects a token whose expiration date has passed.

The result: every POST/PUT/PATCH/DELETE in a session returns 403, starting 4 h after the first request through secure(...). This continues until the session is destroyed. If session data survives re-login, a fresh login does not recover it. An HTMX client does not swap a 4xx response, so the user sees a silent no-op.

The documentation states the opposite. docs/CSRF_GUIDE.md:77 says "The token is stable for the session's lifetime." The helper's scaladoc (Pac4jCsrfSupport.scala:55-66) does not mention expiry. Every consumer that follows the guide recipe (CSRF_GUIDE.md:13, :129) is affected.

Proposal: installNonRotatingMatcher installs a CsrfTokenGenerator that keeps the existing token and refreshes CSRF_TOKEN_EXPIRATION_DATE on each matched request (sliding expiry). Add an optional TTL parameter. Add a test that moves the clock past the TTL with requests in between and expects the POST to pass. Correct CSRF_GUIDE.md:77.

MEDECA will carry such a generator as a project class until this ships, and will then delete it.

Found while enabling pac4j CSRF in a downstream HTMX app (MEDECA-348). There are two independent items. Each can be fixed without the other. Line numbers refer to `main` at `b6d4157c`. `Pac4jCsrfSupport.scala` is unchanged between `v0.1.23`, `v0.1.24` and `main`. ## Item 1: `Pac4jModuleRegistry.wrapModule` gives no way to set matchers or authorizers `Pac4jHttpSecurity.secure` accepts `authorizers` and `matchers` (`server/http/src/main/scala/works/iterative/server/http/impl/pac4j/Pac4jHttpSecurity.scala:116-118`, also `:76-77`). `Pac4jModuleRegistry.wrapModule` calls `pac4jSecurity.secure(clients = clients)` only (`.../pac4j/Pac4jModuleRegistry.scala:24`). The only overridable hook is `def clients: Option[String] = None` (`:15`). A project that needs an explicit security policy must copy the whole `wrapModule` body (`:17-35`) into an override to change one call. There are valid reasons for an explicit policy: - to name `csrfToken`/`csrfCheck` without taking pac4j's default `securityHeaders` matchers - to turn a check off for a transition period - to test with a `DirectClient`, because pac4j's default adds `csrfCheck` only for an `IndirectClient` The copy then drifts from the parent without notice. MEDECA has carried such a copy since its CSRF was turned off. **Proposal:** add `def matchers: Option[String] = None` and `def authorizers: Option[String] = None` beside `clients`, and pass them through at `:24`. The `None` defaults keep today's behaviour. Add one sentence to `docs/CSRF_GUIDE.md` that names the hooks. ## Item 2: `installNonRotatingMatcher` yields a token that stops working 4 hours after the session's first secured request `Pac4jCsrfSupport.installNonRotatingMatcher` creates a stock `DefaultCsrfTokenGenerator` and calls only `setRotateTokens(false)` (`.../pac4j/Pac4jCsrfSupport.scala:68-79`). In pac4j-core 6.3.2, the generator writes `CSRF_TOKEN_EXPIRATION_DATE` only inside `if (optCurrentToken.isEmpty() || rotateTokens)` (`DefaultCsrfTokenGenerator.java:44-51`). The TTL is `4*60*60` (`:26`). With rotation off, the expiry is set once and never extended. `CsrfAuthorizer` rejects a token whose expiration date has passed. The result: every POST/PUT/PATCH/DELETE in a session returns 403, starting 4 h after the first request through `secure(...)`. This continues until the session is destroyed. If session data survives re-login, a fresh login does not recover it. An HTMX client does not swap a 4xx response, so the user sees a silent no-op. The documentation states the opposite. `docs/CSRF_GUIDE.md:77` says "The token is stable for the session's lifetime." The helper's scaladoc (`Pac4jCsrfSupport.scala:55-66`) does not mention expiry. Every consumer that follows the guide recipe (`CSRF_GUIDE.md:13`, `:129`) is affected. **Proposal:** `installNonRotatingMatcher` installs a `CsrfTokenGenerator` that keeps the existing token and refreshes `CSRF_TOKEN_EXPIRATION_DATE` on each matched request (sliding expiry). Add an optional TTL parameter. Add a test that moves the clock past the TTL with requests in between and expects the POST to pass. Correct `CSRF_GUIDE.md:77`. MEDECA will carry such a generator as a project class until this ships, and will then delete it.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
iterative-works/support#62
No description provided.