Public Webforms: Authentication - #1791
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #1791 +/- ##
============================================
+ Coverage 70.09% 70.28% +0.18%
- Complexity 2028 2046 +18
============================================
Files 257 259 +2
Lines 8008 8039 +31
Branches 762 767 +5
============================================
+ Hits 5613 5650 +37
+ Misses 2112 2104 -8
- Partials 283 285 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
When a request carries the `CommCare-Public-Session: true` header and a `public_form_session_key` cookie, the session auth filter now produces a typed PublicSessionCredential instead of the Django sessionid string, and HqUserDetailsService sends it to HQ's session_details endpoint as `publicSessionKey` rather than `sessionId`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HQ's session_details response marks a public web apps session (one-time link) with a JSON `public` field. Add a boolean publicSession field mapped via @JsonProperty("public"). Uses a primitive boolean so a missing field defaults to false, which matters because the bean is @JsonIgnoreProperties(ignoreUnknown = true) and would otherwise silently drop it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When HqUserDetailsBean.publicSession is true, isAuthorized() no longer requires the request's username to equal the bean's username. Public web apps sessions authenticate via a single-use key that HQ validates server-to-server, and their username is a synthetic per-session string (not a real account), so echoing it is not a meaningful membership control. The requested domain is still required to be the session's domain (domains.contains(domain)). This keeps a session key from being replayed against a different domain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For a public web apps session, formplayer's outbound calls to HQ must send the `public_form_session_key` cookie together with the `CommCare-Public-Session: true` header, and must NOT send the Django `sessionid`. - New `PublicFormSessionAuth` (an `HqAuth`) emits exactly that cookie+header pair and nothing else; its key is guarded and never logged. - `UserRestoreAspect.getHqAuth` now selects the credential for the request: if the authenticated user is a public session it returns a `PublicFormSessionAuth` built from the session key, otherwise the existing `DjangoAuth`/null. Gated on the HMAC-authenticated `public` field (`isPublicSession()`), never on the client-supplied header; the public credential is preferred when both signals are present. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5e70e2f to
0388456
Compare
| } else { | ||
| userDetails = getUserDetails(principal.getDomain(), (String) credentials); | ||
| } | ||
| if (!userDetails.isAuthorized(principal.getDomain(), principal.getUsername())) { |
There was a problem hiding this comment.
Nit: I believe at this point userDetails already knows whether this is a public session, I'd suggested having a separate method to do the domain check, rather then insideisAuthorized. The signature entails checking whether a user is authorized to access a domain.
| private final String sessionKey; | ||
|
|
||
| public PublicFormSessionAuth(String sessionKey) { | ||
| Assert.hasText(sessionKey, "A public form session key is required"); |
There was a problem hiding this comment.
Good check to have, but I'd move this before we instantiate HqAuth rather than after, unless there's a specific reason it needs to live here? Also, this throws an IllegalArgumentException, which likely won't surface as an auth failure, did you confirm it's caught and mapped to the right error path so the user sees an appropriate error message rather than a generic exception?
Technical Summary
SAAS-19925, SAAS-19926, SAAS-19927
unauthenticated recipient fill a single pre-designated form). This PR is the authentication layer
only — recognizing and authenticating a public session, inbound and outbound.
CommCareSessionAuthFilter: a request with theCommCare-Public-Session: trueheader and apublic_form_session_keycookie is wrapped as aPublicSessionCredential.HqUserDetailsService: public sessions send the key aspublicSessionKey(notsessionId) tosession_details.HqUserDetailsBean: new HMAC-authoritativepublicfield;isAuthorizeddrops thesynthetic-username check for public sessions but still requires the request domain to match.
PublicFormSessionAuth: authenticates formplayer's outbound HQ calls with the session key,selected in
UserRestoreAspect#getHqAuth.CommCare-Public-Sessionheader is only a credential-routing hint —trust comes solely from HQ's
publicfield, returned over the HMAC-authenticatedsession_detailscall.
Code and PR description written or co-written by AI and edited by human. Review by commit.
Safety Assurance
Safety story
Purely additive and gated behind a new session type. The existing Django-cookie and HMAC auth paths
are untouched; the new branches only fire when the public header + cookie are present and HQ
confirms
public. No public session can authenticate until HQ issues one-time links. Noexisting data is read or written differently for non-public requests.
Automated test coverage
SessionAuthTests— credential routing, header-vs-cookie precedence, failure when the key is absent.HqUserDetailsServiceTests—publicSessionKeywire format vssessionId.HqUserDetailsTests— domain-onlyisAuthorizedfor public sessions,publicdeserialization.PublicFormSessionAuthTest,UserRestoreAspectTest— outbound-auth selection.QA Plan
Public Webforms will get end-to-end QA before its release.
Special deploy instructions
Though public form sessions are not yet being created in HQ, this PR should not be deployed without changes from #1792, which add required security checks to public form sessions.
Rollback instructions
Review