From 787a76c15b8e28d707343d3655d1c8d10a0f9498 Mon Sep 17 00:00:00 2001 From: Evan Joseph-Pinero Date: Thu, 9 Jul 2026 13:43:05 -0700 Subject: [PATCH 1/4] Route public web apps session credentials in formplayer auth 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) --- .../auth/CommCareSessionAuthFilter.java | 25 ++++++- .../auth/PublicSessionCredential.java | 15 ++++ .../beans/auth/HqPublicSessionKeyBean.java | 31 ++++++++ .../services/HqUserDetailsService.java | 28 ++++++- .../commcare/formplayer/util/Constants.java | 7 ++ .../formplayer/auth/SessionAuthTests.java | 74 +++++++++++++++++++ .../tests/HqUserDetailsServiceTests.java | 53 +++++++++++++ 7 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/commcare/formplayer/auth/PublicSessionCredential.java create mode 100644 src/main/java/org/commcare/formplayer/beans/auth/HqPublicSessionKeyBean.java diff --git a/src/main/java/org/commcare/formplayer/auth/CommCareSessionAuthFilter.java b/src/main/java/org/commcare/formplayer/auth/CommCareSessionAuthFilter.java index c5074cb3a..aed787313 100644 --- a/src/main/java/org/commcare/formplayer/auth/CommCareSessionAuthFilter.java +++ b/src/main/java/org/commcare/formplayer/auth/CommCareSessionAuthFilter.java @@ -38,7 +38,7 @@ public boolean matches(HttpServletRequest request) { boolean hasCookie = Arrays.stream(request.getCookies()).anyMatch( (cookie) -> Constants.POSTGRES_DJANGO_SESSION_ID.equals(cookie.getName()) ); - if (!hasCookie) { + if (!hasCookie && !isPublicSessionRequest(request)) { return false; } Authentication currentUser = SecurityContextHolder.getContext().getAuthentication(); @@ -49,9 +49,30 @@ public boolean matches(HttpServletRequest request) { @Override protected Object getPreAuthenticatedCredentials(HttpServletRequest request) { + if (isPublicSessionRequest(request)) { + return new PublicSessionCredential( + getCookieValue(request, Constants.PUBLIC_FORM_SESSION_COOKIE_NAME)); + } + return getCookieValue(request, Constants.POSTGRES_DJANGO_SESSION_ID); + } + + /** + * A public web apps request is signaled by the {@code CommCare-Public-Session: true} header + * (a credential-routing hint, not a trust signal) paired with the + * {@code public_form_session_key} cookie carrying the session key. + */ + private static boolean isPublicSessionRequest(HttpServletRequest request) { + if (!Constants.PUBLIC_FORM_SESSION_HEADER_VALUE.equals( + request.getHeader(Constants.PUBLIC_FORM_SESSION_HEADER))) { + return false; + } + return getCookieValue(request, Constants.PUBLIC_FORM_SESSION_COOKIE_NAME) != null; + } + + private static String getCookieValue(HttpServletRequest request, String name) { if (request.getCookies() != null) { for (Cookie cookie : request.getCookies()) { - if (Constants.POSTGRES_DJANGO_SESSION_ID.equals(cookie.getName())) { + if (name.equals(cookie.getName())) { return cookie.getValue(); } } diff --git a/src/main/java/org/commcare/formplayer/auth/PublicSessionCredential.java b/src/main/java/org/commcare/formplayer/auth/PublicSessionCredential.java new file mode 100644 index 000000000..2f714a951 --- /dev/null +++ b/src/main/java/org/commcare/formplayer/auth/PublicSessionCredential.java @@ -0,0 +1,15 @@ +package org.commcare.formplayer.auth; + +import lombok.Value; + +/** + * Typed credential for a public web apps session (one-time link). + * + * Wraps the value of the {@code public_form_session_key} cookie so that the + * {@link org.commcare.formplayer.services.HqUserDetailsService} can distinguish a public session + * from a regular Django session. + */ +@Value +public class PublicSessionCredential { + String sessionKey; +} diff --git a/src/main/java/org/commcare/formplayer/beans/auth/HqPublicSessionKeyBean.java b/src/main/java/org/commcare/formplayer/beans/auth/HqPublicSessionKeyBean.java new file mode 100644 index 000000000..fab3b83b4 --- /dev/null +++ b/src/main/java/org/commcare/formplayer/beans/auth/HqPublicSessionKeyBean.java @@ -0,0 +1,31 @@ +package org.commcare.formplayer.beans.auth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.io.Serializable; + +/** + * HMAC-signed body sent to HQ's session_details endpoint for a public web apps session. + * + * Serializes to {@code {"publicSessionKey": ..., "domain": ...}}. HQ treats a request with a + * truthy {@code publicSessionKey} as a public session lookup, in contrast to + * {@link HqSessionKeyBean} which sends {@code sessionId}. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class HqPublicSessionKeyBean implements Serializable { + private String publicSessionKey; + private String domain; + + public HqPublicSessionKeyBean(String domain, String publicSessionKey) { + this.domain = domain; + this.publicSessionKey = publicSessionKey; + } + + public String getPublicSessionKey() { + return publicSessionKey; + } + + public String getDomain() { + return domain; + } +} diff --git a/src/main/java/org/commcare/formplayer/services/HqUserDetailsService.java b/src/main/java/org/commcare/formplayer/services/HqUserDetailsService.java index 624917e32..c67497790 100644 --- a/src/main/java/org/commcare/formplayer/services/HqUserDetailsService.java +++ b/src/main/java/org/commcare/formplayer/services/HqUserDetailsService.java @@ -1,7 +1,9 @@ package org.commcare.formplayer.services; import com.fasterxml.jackson.databind.ObjectMapper; +import org.commcare.formplayer.auth.PublicSessionCredential; import org.commcare.formplayer.auth.UserDomainPreAuthPrincipal; +import org.commcare.formplayer.beans.auth.HqPublicSessionKeyBean; import org.commcare.formplayer.beans.auth.HqSessionKeyBean; import org.commcare.formplayer.beans.auth.HqUserDetailsBean; import org.commcare.formplayer.exceptions.SessionAuthUnavailableException; @@ -38,10 +40,23 @@ public class HqUserDetailsService implements AuthenticationUserDetailsService { + private String domain; + private String username; + private String sessionKey; + + public PublicTokenMatcher(String domain, String username, String sessionKey) { + this.domain = domain; + this.username = username; + this.sessionKey = sessionKey; + } + + @Override + public boolean matches(PreAuthenticatedAuthenticationToken token) { + if (token == null) { + return false; + } + final UserDomainPreAuthPrincipal principal = + (UserDomainPreAuthPrincipal)token.getPrincipal(); + final Object credentials = token.getCredentials(); + return credentials instanceof PublicSessionCredential + && ((PublicSessionCredential)credentials).getSessionKey().equals(this.sessionKey) + && principal.getDomain().equals(this.domain) + && principal.getUsername().equals(this.username); + } + } } diff --git a/src/test/java/org/commcare/formplayer/tests/HqUserDetailsServiceTests.java b/src/test/java/org/commcare/formplayer/tests/HqUserDetailsServiceTests.java index b2c65b01f..d46f64348 100644 --- a/src/test/java/org/commcare/formplayer/tests/HqUserDetailsServiceTests.java +++ b/src/test/java/org/commcare/formplayer/tests/HqUserDetailsServiceTests.java @@ -10,6 +10,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; +import org.commcare.formplayer.auth.PublicSessionCredential; +import org.commcare.formplayer.auth.UserDomainPreAuthPrincipal; import org.commcare.formplayer.beans.auth.HqUserDetailsBean; import org.commcare.formplayer.exceptions.SessionAuthUnavailableException; import org.commcare.formplayer.repo.FormDefinitionRepo; @@ -32,6 +34,8 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.test.web.client.MockRestServiceServer; @@ -114,4 +118,53 @@ public void noSession() { this.service.getUserDetails("domain", "invalid"); }); } + + @Test + public void whenCallingGetPublicUserDetails_thenClientSendsPublicSessionKey() + throws Exception { + String detailsString = "{" + + "\"domains\":[\"domain\"]," + + "\"djangoUserId\":null," + + "\"username\":\"public@domain\"," + + "\"authToken\":\"pub-key\"," + + "\"superUser\":false," + + "\"public\":true" + + "}"; + + this.server.expect(requestTo(Constants.SESSION_DETAILS_VIEW)) + .andExpect(jsonPath("$.publicSessionKey").value("pub-key")) + .andExpect(jsonPath("$.sessionId").doesNotExist()) + .andExpect(jsonPath("$.domain").value("domain")) + .andRespond(withSuccess(detailsString, MediaType.APPLICATION_JSON)); + + HqUserDetailsBean details = this.service.getPublicUserDetails("domain", "pub-key"); + + assertThat(details.getUsername()).isEqualTo("public@domain"); + assertThat(details.getDomains()).isEqualTo(new String[]{"domain"}); + } + + @Test + public void loadUserDetails_withPublicCredential_routesToPublicSessionKey() + throws Exception { + String detailsString = "{" + + "\"domains\":[\"domain\"]," + + "\"djangoUserId\":null," + + "\"username\":\"citrus\"," + + "\"authToken\":\"pub-key\"," + + "\"superUser\":false," + + "\"public\":true" + + "}"; + + this.server.expect(requestTo(Constants.SESSION_DETAILS_VIEW)) + .andExpect(jsonPath("$.publicSessionKey").value("pub-key")) + .andExpect(jsonPath("$.sessionId").doesNotExist()) + .andRespond(withSuccess(detailsString, MediaType.APPLICATION_JSON)); + + UserDomainPreAuthPrincipal principal = new UserDomainPreAuthPrincipal("citrus", "domain"); + PreAuthenticatedAuthenticationToken token = new PreAuthenticatedAuthenticationToken( + principal, new PublicSessionCredential("pub-key")); + + UserDetails details = this.service.loadUserDetails(token); + assertThat(details.getUsername()).isEqualTo("citrus"); + } } From ec9ec733b022d1a87a60318d207776e4f214c9a7 Mon Sep 17 00:00:00 2001 From: Evan Joseph-Pinero Date: Fri, 10 Jul 2026 09:58:23 -0700 Subject: [PATCH 2/4] Accept HQ `public` field on HqUserDetailsBean 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) --- .../beans/auth/HqUserDetailsBean.java | 6 ++++++ .../tests/HqUserDetailsServiceTests.java | 1 + .../formplayer/tests/HqUserDetailsTests.java | 21 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/src/main/java/org/commcare/formplayer/beans/auth/HqUserDetailsBean.java b/src/main/java/org/commcare/formplayer/beans/auth/HqUserDetailsBean.java index bee81b825..eeac0c245 100644 --- a/src/main/java/org/commcare/formplayer/beans/auth/HqUserDetailsBean.java +++ b/src/main/java/org/commcare/formplayer/beans/auth/HqUserDetailsBean.java @@ -1,6 +1,7 @@ package org.commcare.formplayer.beans.auth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSetter; import org.springframework.security.core.GrantedAuthority; @@ -29,6 +30,11 @@ public class HqUserDetailsBean implements UserDetails { private String[] enabledToggles; private String[] enabledPreviews; + // HQ marks public web apps sessions with a JSON `public` field. `public` is a reserved word, + // so map it to this property. Primitive boolean so missing JSON defaults to false. + @JsonProperty("public") + private boolean publicSession; + public HqUserDetailsBean() { } diff --git a/src/test/java/org/commcare/formplayer/tests/HqUserDetailsServiceTests.java b/src/test/java/org/commcare/formplayer/tests/HqUserDetailsServiceTests.java index d46f64348..9a2f3500b 100644 --- a/src/test/java/org/commcare/formplayer/tests/HqUserDetailsServiceTests.java +++ b/src/test/java/org/commcare/formplayer/tests/HqUserDetailsServiceTests.java @@ -141,6 +141,7 @@ public void whenCallingGetPublicUserDetails_thenClientSendsPublicSessionKey() assertThat(details.getUsername()).isEqualTo("public@domain"); assertThat(details.getDomains()).isEqualTo(new String[]{"domain"}); + assertThat(details.isPublicSession()).isTrue(); } @Test diff --git a/src/test/java/org/commcare/formplayer/tests/HqUserDetailsTests.java b/src/test/java/org/commcare/formplayer/tests/HqUserDetailsTests.java index dd086490d..dc6440250 100644 --- a/src/test/java/org/commcare/formplayer/tests/HqUserDetailsTests.java +++ b/src/test/java/org/commcare/formplayer/tests/HqUserDetailsTests.java @@ -1,5 +1,7 @@ package org.commcare.formplayer.tests; +import com.fasterxml.jackson.databind.ObjectMapper; + import org.commcare.formplayer.beans.auth.FeatureFlagChecker; import org.commcare.formplayer.beans.auth.HqUserDetailsBean; import org.commcare.formplayer.utils.HqUserDetails; @@ -37,6 +39,25 @@ public void testCommCareUserIsAuthorized() { Assertions.assertFalse(user.isAuthorized("domain", "wrong-bilbo")); } + @Test + public void testPublicSessionDeserialization() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + + // HQ sends the reserved word `public` for a public web apps session. + HqUserDetailsBean publicUser = mapper.readValue( + "{\"username\":\"pub\",\"public\":true}", HqUserDetailsBean.class); + Assertions.assertTrue(publicUser.isPublicSession()); + + HqUserDetailsBean regularUser = mapper.readValue( + "{\"username\":\"reg\",\"public\":false}", HqUserDetailsBean.class); + Assertions.assertFalse(regularUser.isPublicSession()); + + // Absent `public` defaults to false (primitive boolean; the bean also ignores unknowns). + HqUserDetailsBean noField = mapper.readValue( + "{\"username\":\"reg\"}", HqUserDetailsBean.class); + Assertions.assertFalse(noField.isPublicSession()); + } + @Test public void testFeatureFlagChecker_isToggleEnabled() { WithHqUserSecurityContextFactory.setSecurityContext( From 0e9f605f94d49b19733b728e05873a09c8cc2aa9 Mon Sep 17 00:00:00 2001 From: Evan Joseph-Pinero Date: Fri, 10 Jul 2026 10:37:33 -0700 Subject: [PATCH 3/4] Skip username authorization for public web apps sessions 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) --- .../beans/auth/HqUserDetailsBean.java | 6 +++++ .../formplayer/tests/HqUserDetailsTests.java | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/main/java/org/commcare/formplayer/beans/auth/HqUserDetailsBean.java b/src/main/java/org/commcare/formplayer/beans/auth/HqUserDetailsBean.java index eeac0c245..e6d2691c4 100644 --- a/src/main/java/org/commcare/formplayer/beans/auth/HqUserDetailsBean.java +++ b/src/main/java/org/commcare/formplayer/beans/auth/HqUserDetailsBean.java @@ -53,6 +53,12 @@ public HqUserDetailsBean(String domain, String[] domains, String username, boole } public boolean isAuthorized(String domain, String username) { + if (publicSession) { + // Public web apps sessions authenticate via a single-use key that HQ has already + // validated and tied to exactly one domain. There is no real HQ account, so the + // per-session username is not a meaningful check. + return Arrays.asList(domains).contains(domain); + } return isSuperUser || Arrays.asList(domains).contains(domain) && this.username.equals( username); } diff --git a/src/test/java/org/commcare/formplayer/tests/HqUserDetailsTests.java b/src/test/java/org/commcare/formplayer/tests/HqUserDetailsTests.java index dc6440250..229549d47 100644 --- a/src/test/java/org/commcare/formplayer/tests/HqUserDetailsTests.java +++ b/src/test/java/org/commcare/formplayer/tests/HqUserDetailsTests.java @@ -39,6 +39,33 @@ public void testCommCareUserIsAuthorized() { Assertions.assertFalse(user.isAuthorized("domain", "wrong-bilbo")); } + @Test + public void testPublicSessionIsAuthorized() { + HqUserDetailsBean publicUser = new HqUserDetailsBean("domain", + new String[]{"domain"}, "public_abc123@domain.commcarehq.org", + false, new String[]{}, new String[]{}); + publicUser.setPublicSession(true); + + // Synthetic username is not checked for a public session... + Assertions.assertTrue(publicUser.isAuthorized("domain", "public_abc123@domain.commcarehq.org")); + Assertions.assertTrue(publicUser.isAuthorized("domain", "some-other-name")); + + // ...but the requested domain must still be the session's domain. + Assertions.assertFalse(publicUser.isAuthorized("other-domain", "public_abc123@domain.commcarehq.org")); + Assertions.assertFalse(publicUser.isAuthorized("other-domain", "some-other-name")); + } + + @Test + public void testNonPublicSessionStillEnforcesUsername() { + // Same shape as the public case but publicSession=false: the username check is enforced. + HqUserDetailsBean regularUser = new HqUserDetailsBean("domain", + new String[]{"domain"}, "real@domain.commcarehq.org", + false, new String[]{}, new String[]{}); + + Assertions.assertTrue(regularUser.isAuthorized("domain", "real@domain.commcarehq.org")); + Assertions.assertFalse(regularUser.isAuthorized("domain", "some-other-name")); + } + @Test public void testPublicSessionDeserialization() throws Exception { ObjectMapper mapper = new ObjectMapper(); From 038845694a3ee06e7bfa009fca21e49b076587ce Mon Sep 17 00:00:00 2001 From: Evan Joseph-Pinero Date: Mon, 20 Jul 2026 13:57:27 -0400 Subject: [PATCH 4/4] Attach public session credentials on outbound HQ calls 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) --- .../formplayer/aspects/UserRestoreAspect.java | 14 ++- .../auth/PublicFormSessionAuth.java | 37 ++++++++ .../aspects/UserRestoreAspectTest.java | 89 +++++++++++++++++++ .../auth/PublicFormSessionAuthTest.java | 35 ++++++++ .../formplayer/tests/RestoreFactoryTest.java | 24 +++++ 5 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/commcare/formplayer/auth/PublicFormSessionAuth.java create mode 100644 src/test/java/org/commcare/formplayer/aspects/UserRestoreAspectTest.java create mode 100644 src/test/java/org/commcare/formplayer/auth/PublicFormSessionAuthTest.java diff --git a/src/main/java/org/commcare/formplayer/aspects/UserRestoreAspect.java b/src/main/java/org/commcare/formplayer/aspects/UserRestoreAspect.java index a61c0e96e..5a9b46082 100644 --- a/src/main/java/org/commcare/formplayer/aspects/UserRestoreAspect.java +++ b/src/main/java/org/commcare/formplayer/aspects/UserRestoreAspect.java @@ -5,9 +5,12 @@ import io.sentry.Sentry; import org.commcare.formplayer.auth.DjangoAuth; import org.commcare.formplayer.auth.HqAuth; +import org.commcare.formplayer.auth.PublicFormSessionAuth; import org.commcare.formplayer.beans.AuthenticatedRequestBean; import org.commcare.formplayer.beans.SessionRequestBean; +import org.commcare.formplayer.beans.auth.HqUserDetailsBean; import org.commcare.formplayer.objects.SerializableFormSession; +import org.commcare.formplayer.util.RequestUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.aspectj.lang.JoinPoint; @@ -23,6 +26,7 @@ import org.commcare.formplayer.services.RestoreFactory; import java.util.Arrays; +import java.util.Optional; import datadog.trace.api.interceptor.MutableSpan; @@ -115,7 +119,15 @@ public void closeRestoreFactory(JoinPoint joinPoint) throws Throwable { restoreFactory.getSQLiteDB().closeConnection(); } - private HqAuth getHqAuth(String sessionToken) { + // Package-private for testing. + HqAuth getHqAuth(String sessionToken) { + // A public web apps session has no Django sessionid; authenticate its outbound HQ calls + // with the public session key instead. Gate this on the HMAC-authenticated `public` field + // from HQ's session_details response, never on the client-supplied header. + Optional userDetails = RequestUtils.getUserDetails(); + if (userDetails.isPresent() && userDetails.get().isPublicSession()) { + return new PublicFormSessionAuth(userDetails.get().getAuthToken()); + } if (sessionToken != null) { return new DjangoAuth(sessionToken); } diff --git a/src/main/java/org/commcare/formplayer/auth/PublicFormSessionAuth.java b/src/main/java/org/commcare/formplayer/auth/PublicFormSessionAuth.java new file mode 100644 index 000000000..661f2dcb3 --- /dev/null +++ b/src/main/java/org/commcare/formplayer/auth/PublicFormSessionAuth.java @@ -0,0 +1,37 @@ +package org.commcare.formplayer.auth; + +import org.commcare.formplayer.util.Constants; +import org.springframework.http.HttpHeaders; +import org.springframework.util.Assert; + +/** + * {@link HqAuth} for a public web apps session. + * + * Emits the credential pair HQ requires to recognize a public session on its receiver/restore + * endpoints: the {@code public_form_session_key} cookie carrying the session key together with the + * {@code CommCare-Public-Session: true} header. + */ +public class PublicFormSessionAuth implements HqAuth { + + private final String sessionKey; + + public PublicFormSessionAuth(String sessionKey) { + Assert.hasText(sessionKey, "A public form session key is required"); + this.sessionKey = sessionKey; + } + + @Override + public HttpHeaders getAuthHeaders() { + return new HttpHeaders() { + { + add("Cookie", Constants.PUBLIC_FORM_SESSION_COOKIE_NAME + "=" + sessionKey); + add(Constants.PUBLIC_FORM_SESSION_HEADER, Constants.PUBLIC_FORM_SESSION_HEADER_VALUE); + } + }; + } + + @Override + public String toString() { + return "PublicFormSessionAuth"; + } +} diff --git a/src/test/java/org/commcare/formplayer/aspects/UserRestoreAspectTest.java b/src/test/java/org/commcare/formplayer/aspects/UserRestoreAspectTest.java new file mode 100644 index 000000000..25159311d --- /dev/null +++ b/src/test/java/org/commcare/formplayer/aspects/UserRestoreAspectTest.java @@ -0,0 +1,89 @@ +package org.commcare.formplayer.aspects; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.commcare.formplayer.auth.DjangoAuth; +import org.commcare.formplayer.auth.HqAuth; +import org.commcare.formplayer.auth.PublicFormSessionAuth; +import org.commcare.formplayer.beans.auth.HqUserDetailsBean; +import org.commcare.formplayer.util.RequestUtils; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.util.Optional; + +/** + * Unit tests for {@link UserRestoreAspect#getHqAuth} credential selection, in particular the + * choice between a Django session and a public web apps session. + */ +public class UserRestoreAspectTest { + + private final UserRestoreAspect aspect = new UserRestoreAspect(); + + private HqUserDetailsBean bean(boolean isPublicSession, String authToken) { + HqUserDetailsBean bean = new HqUserDetailsBean("domain", new String[]{"domain"}, "user", + false, new String[]{}, new String[]{}); + bean.setPublicSession(isPublicSession); + bean.setAuthToken(authToken); + return bean; + } + + @Test + public void publicSession_usesPublicFormSessionAuthWithTheSessionKey() { + try (MockedStatic mocked = Mockito.mockStatic(RequestUtils.class)) { + mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.of(bean(true, "pkey"))); + + HqAuth auth = aspect.getHqAuth(null); + + assertTrue(auth instanceof PublicFormSessionAuth); + assertEquals("public_form_session_key=pkey", auth.getAuthHeaders().getFirst("Cookie")); + } + } + + @Test + public void publicSession_winsEvenWhenASessionidCookieIsAlsoPresent() { + try (MockedStatic mocked = Mockito.mockStatic(RequestUtils.class)) { + mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.of(bean(true, "pkey"))); + + // Both signals present: the public credential must win, matching inbound selection. + HqAuth auth = aspect.getHqAuth("sessionid-value"); + + assertTrue(auth instanceof PublicFormSessionAuth); + } + } + + @Test + public void regularSession_usesDjangoAuth() { + try (MockedStatic mocked = Mockito.mockStatic(RequestUtils.class)) { + mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.of(bean(false, null))); + + HqAuth auth = aspect.getHqAuth("sessionid-value"); + + assertTrue(auth instanceof DjangoAuth); + } + } + + @Test + public void noUserDetailsWithSessionToken_usesDjangoAuth() { + try (MockedStatic mocked = Mockito.mockStatic(RequestUtils.class)) { + mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.empty()); + + HqAuth auth = aspect.getHqAuth("sessionid-value"); + + assertTrue(auth instanceof DjangoAuth); + } + } + + @Test + public void noUserDetailsNoSessionToken_returnsNull() { + try (MockedStatic mocked = Mockito.mockStatic(RequestUtils.class)) { + mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.empty()); + + // SMS requests have neither a public session nor a sessionid cookie. + assertNull(aspect.getHqAuth(null)); + } + } +} diff --git a/src/test/java/org/commcare/formplayer/auth/PublicFormSessionAuthTest.java b/src/test/java/org/commcare/formplayer/auth/PublicFormSessionAuthTest.java new file mode 100644 index 000000000..2b9f4e1ef --- /dev/null +++ b/src/test/java/org/commcare/formplayer/auth/PublicFormSessionAuthTest.java @@ -0,0 +1,35 @@ +package org.commcare.formplayer.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; + +public class PublicFormSessionAuthTest { + + @Test + public void getAuthHeaders_emitsPublicCookieAndHeaderOnly() { + HttpHeaders headers = new PublicFormSessionAuth("session-key-123").getAuthHeaders(); + + assertEquals("public_form_session_key=session-key-123", headers.getFirst("Cookie")); + assertEquals("true", headers.getFirst("CommCare-Public-Session")); + + // Exactly the two public headers — no Django sessionid/Authorization leaks out. + assertEquals(2, headers.size()); + assertFalse(headers.containsKey("sessionid")); + assertFalse(headers.containsKey("Authorization")); + } + + @Test + public void toString_doesNotLeakTheKey() { + assertFalse(new PublicFormSessionAuth("super-secret-key").toString().contains("super-secret-key")); + } + + @Test + public void constructor_rejectsMissingKey() { + assertThrows(IllegalArgumentException.class, () -> new PublicFormSessionAuth(null)); + assertThrows(IllegalArgumentException.class, () -> new PublicFormSessionAuth("")); + } +} diff --git a/src/test/java/org/commcare/formplayer/tests/RestoreFactoryTest.java b/src/test/java/org/commcare/formplayer/tests/RestoreFactoryTest.java index 815998907..fb7df627f 100644 --- a/src/test/java/org/commcare/formplayer/tests/RestoreFactoryTest.java +++ b/src/test/java/org/commcare/formplayer/tests/RestoreFactoryTest.java @@ -12,6 +12,7 @@ import org.commcare.cases.util.CaseDBUtils; import org.commcare.formplayer.auth.DjangoAuth; +import org.commcare.formplayer.auth.PublicFormSessionAuth; import org.commcare.formplayer.beans.AuthenticatedRequestBean; import org.commcare.formplayer.configuration.CacheConfiguration; import org.commcare.formplayer.junit.RestoreFactoryAnswer; @@ -265,6 +266,29 @@ public void testGetRequestHeaders() { ); } + @Test + public void testGetRequestHeaders_PublicSession() { + String syncToken = "synctoken"; + Mockito.doReturn(syncToken).when(restoreFactorySpy).getSyncToken(); + // A public web apps session authenticates outbound calls with the public session key. + restoreFactorySpy.setHqAuth(new PublicFormSessionAuth("pkey")); + + HttpHeaders headers = restoreFactorySpy.getRequestHeaders(null); + + assertEquals(6, headers.size()); + validateHeaders(headers, Arrays.asList( + hasEntry("Cookie", singletonList("public_form_session_key=pkey")), + hasEntry("CommCare-Public-Session", singletonList("true")), + hasEntry("X-OpenRosa-Version", singletonList("3.0")), + hasEntry("X-OpenRosa-DeviceId", singletonList("WebAppsLogin")), + hasEntry("X-CommCareHQ-LastSyncToken", singletonList(syncToken)), + hasEntry(equalTo("X-CommCareHQ-Origin-Token"), new ValueIsUUID())) + ); + // The Django sessionid must never travel on a public session's outbound calls. + Assertions.assertFalse(headers.containsKey("sessionid")); + Assertions.assertFalse(headers.containsKey("Authorization")); + } + @Test public void testGetRequestHeaders_HmacAuth() throws Exception { mockHmacRequest();