Skip to content

Public Webforms authentication and deep-link handling - #1790

Closed
nospame wants to merge 9 commits into
masterfrom
ejp/public-webforms-formplayer
Closed

Public Webforms authentication and deep-link handling#1790
nospame wants to merge 9 commits into
masterfrom
ejp/public-webforms-formplayer

Conversation

@nospame

@nospame nospame commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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_key cookie and a CommCare-Public-Session: true header instead of the Django sessionid, and its session_details lookup is keyed by publicSessionKey. Every trust decision hangs off the HMAC-authenticated public field HQ returns — the client header is only a routing hint and is never trusted on its own.

  • Authorization reuses the existing HqUserDetails path. 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's PublicFormUser.
  • Containment is HQ-authoritative and fail-closed. Both the app build and the session endpoint are taken from the authenticated session details, not the request. The lock aspect overwrites any client-supplied app_id/endpoint_id/endpointArgs and rejects the request if HQ hasn't supplied them, so a valid key cannot be repointed at another form in the domain.
  • Route access is default-deny in the security layer, not per-controller. A public session may only reach get_endpoint, answer, and submit-all; any other route — existing or added later — returns the standard 403 until explicitly allowlisted.
  • Endpoint navigation is decoupled from the SESSION_ENDPOINTS toggle 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_id is 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_details endpoint already implements most of the changes necessary to support this work, but must be modified to include an app_build_id and endpoint_id when 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 public flag 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

  • Credential routing and domain-scoped authorization across public, regular, and superuser sessions.
  • Outbound HQ calls carry the public credential.
  • Route allowlist: 403 on non-allowlisted routes for public sessions; allowed routes reachable; regular sessions unaffected.
  • App/endpoint lock: client-supplied values (including tampered ones) are overwritten with the authoritative values and args cleared; non-public beans left untouched; missing authoritative values fail closed.
  • get_endpoint reachable without a sessionid, and endpoint launch for a public session with the toggle off.
  • Deserialization of the new session_details fields.

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

  • This PR can be deployed after merge with no further considerations.

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

  • This PR can be reverted after deploy with no further considerations.

Review

  • The set of people pinged as reviewers is appropriate for the level of risk of the change.

nospame and others added 9 commits July 20, 2026 16:18
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>
@nospame
nospame marked this pull request as ready for review July 22, 2026 16:20

@kaapstorm kaapstorm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does a session key contain the string "A public form session key is required"? Or am I misunderstanding what's happening here?

@nospame nospame Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kaapstorm

Copy link
Copy Markdown
Contributor

I leant on Claude for a review, and his/its/their feedback:

The core mechanism (crediential routing, HQ-authoritative app/endpoint locking via PublicSessionLockAspect, and a route allowlist in WebSecurityConfig) is well-designed, fails closed, and is thoroughly unit-tested. However, the same rigor that locked down app_id/endpoint_id was not applied to restore identity — the client-supplied restoreAs/username fields still flow unmodified into the outbound HQ restore call, which I independently verified by reading UserRestoreAspect.configureRestoreFactoryRestoreFactory.configure(AuthenticatedRequestBean, auth) (RestoreFactory.java:187-190, which unconditionally does setAsUsername(authenticatedRequestBean.getRestoreAs())). That's a plausible cross-user impersonation/data-exposure path and the standout issue here.

Findings

#: 1
Sev: 🔴
Finding: Public session can request restore-as impersonation via
client-supplied restoreAs/username
Location: UserRestoreAspect.java:87-101, RestoreFactory.java:187-190
────────────────────────────────────────
#: 2
Sev: 🟠
Finding: /validate_form bypasses the public-session route allowlist entirely
Location: WebSecurityConfig.java:57-73,110-121
────────────────────────────────────────
#: 3
Sev: 🟠
Finding: PublicSessionLockAspect — the sole enforcement mechanism — is never
exercised through real Spring AOP wiring; only mocked-JoinPoint unit tests
exist
Location: PublicSessionLockAspectTest.java, BaseTestClass.java,
TestContext.java
────────────────────────────────────────
#: 4
Sev: 🟠
Finding: Aspect silently no-ops (no log/throw) if a future @AppInstall
method's first arg isn't an InstallRequestBean, contradicting its own
fail-closed design
Location: PublicSessionLockAspect.java:34-37
────────────────────────────────────────

Those were the highest-priority issues.

@nospame
nospame marked this pull request as draft July 23, 2026 18:13
@nospame

nospame commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@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.

@nospame

nospame commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Replaced by #1791, #1792, #1793

@nospame nospame closed this Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants