Public Webforms authentication and deep-link handling - #1790
Conversation
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>
A public web apps session authenticates with the public_form_session_key cookie, not the Django sessionid, so the required sessionid @CookieValue on MenuController.navigateToEndpoint (get_endpoint) rejected it at request binding (400) before the handler. Relax that cookie to required=false, as answer/submit-all already do. A cookie-less non-public request is still rejected upstream by the session auth request matcher. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a default-deny route allowlist for public web apps sessions, enforced in the Spring Security authorization layer. getPublicSessionAuthManager grants a request if it is authenticated AND either the principal is not a public session or the path is one of get_endpoint, answer, or submit-all. Every other route is denied with the app's standard 403 before any controller or aspect runs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
For a public web apps session, HQ's session_details response carries the app id and session endpoint that the one-time link is bound to. Add publicAppId (@JsonProperty "commcare_app_id") and publicEndpointId (@JsonProperty "endpoint_id") to the bean. Reference types, so they default to null for non-public responses that omit them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both app_id and endpoint_id in a get_endpoint request are client-supplied, so a valid public session key could otherwise open any app/form in the domain. PublicSessionLockAspect, for a public session, replaces the request's app_id and endpoint_id with the HMAC-authenticated values HQ returned (HqUserDetailsBean.publicAppId/publicEndpointId) and clears endpoint args, confining the session to its one designated form. It fails closed if HQ did not supply those authoritative values rather than trusting the client. Runs before AppInstallAspect (which keys the sandbox DB off the request's app_id), so storage, the MenuSession build, and endpoint navigation all use the authoritative app. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A public web apps session is a one-time deep link into a single endpoint, so endpoint navigation is intrinsic to it. For public sessions, bypass thegate via a new RequestUtils.isPublicSession() predicate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kaapstorm
left a comment
There was a problem hiding this comment.
This looks fine to me. But I'm not familiar enough with this codebase to know whether this is the best way to do this. Keen to hear @shubham1g5 or @avazirna 's opinion.
| 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.
Does a session key contain the string "A public form session key is required"? Or am I misunderstanding what's happening here?
There was a problem hiding this comment.
This is asserting that sessionKey is not null or blank, and "A public form session key is required" is the message thrown if sessionKey is null or blank.
|
I leant on Claude for a review, and his/its/their feedback:
Those were the highest-priority issues. |
|
@kaapstorm Thank you, those are good points and I'll plan to address them, along with a few others real gaps that have come up in doing so. I might end up breaking this into multiple PRs as it's gotten pretty large locally, not quite sure yet. In either case, moving back to draft for now since there will be more changes coming. |
Product Description
Adds formplayer support for public Web Apps sessions — a one-time link that lets a recipient with no HQ account open and submit a single, pre-designated Web Apps form. There is no effect on existing sessions, and this code is not reachable until the coordinated HQ change is deployed and begins issuing public sessions.
Technical Summary
Encompasses several tickets:
SAAS-19925, SAAS-19926, SAAS-19927, SAAS-19928, SAAS-19929, SAAS-19930
A public session authenticates with a
public_form_session_keycookie and aCommCare-Public-Session: trueheader instead of the Djangosessionid, and itssession_detailslookup is keyed bypublicSessionKey. Every trust decision hangs off the HMAC-authenticatedpublicfield HQ returns — the client header is only a routing hint and is never trusted on its own.HqUserDetailspath. A public session skips the per-user username check (there is no real account) but stays confined to the single domain HQ bound the link to, mirroring HQ'sPublicFormUser.app_id/endpoint_id/endpointArgsand rejects the request if HQ hasn't supplied them, so a valid key cannot be repointed at another form in the domain.get_endpoint,answer, andsubmit-all; any other route — existing or added later — returns the standard 403 until explicitly allowlisted.SESSION_ENDPOINTStoggle for public sessions. That toggle only exposes app-builder UI and won't necessarily be enabled for a domain using public webforms; a public session is itself a deep link, so it uses the endpoint machinery directly rather than forcing HQ to enable an unrelated flag.app_build_idis a pinned build doc id, not the canonical app id, so a link always runs the exact build it was published from.HQ dependency
The
session_detailsendpoint already implements most of the changes necessary to support this work, but must be modified to include anapp_build_idandendpoint_idwhen responding to a valid public session request.Code and this PR description were written by AI, reviewed and edited by human. Recommend reviewing by commit.
Safety Assurance
Safety story
Every new path is gated on the HMAC
publicflag and early-returns for normal sessions, so regular traffic is unaffected. Containment fails closed rather than falling back to client-supplied values. The feature is inert until HQ deploys its side and starts returning public session details, which bounds the blast radius to zero for current users. Verified with the full local test suite plus the targeted suites below.Automated test coverage
get_endpointreachable without asessionid, and endpoint launch for a public session with the toggle off.session_detailsfields.QA Plan
End-to-end once the HQ change is live: open a one-time link → land directly on the designated form → submit successfully; confirm the same link cannot be reused to reach any other form/app/domain, and that non-allowlisted endpoints are rejected.
Special deploy instructions
Because this code is unused until changes to allow creation of public webforms and public form sessions are implemented in HQ, this can be deployed at any time before that change, which is still estimated at several weeks out.
Rollback instructions
Review