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/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/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/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/beans/auth/HqUserDetailsBean.java b/src/main/java/org/commcare/formplayer/beans/auth/HqUserDetailsBean.java index bee81b825..e6d2691c4 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() { } @@ -47,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/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 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/auth/SessionAuthTests.java b/src/test/java/org/commcare/formplayer/auth/SessionAuthTests.java index 366eaafab..b385eaf46 100644 --- a/src/test/java/org/commcare/formplayer/auth/SessionAuthTests.java +++ b/src/test/java/org/commcare/formplayer/auth/SessionAuthTests.java @@ -97,6 +97,47 @@ public void testEndpoint_WithFullAuth_Succeeds() throws Exception { this.testEndpoint(builder, status().isOk()); } + /** + * A public web apps request (public header + public cookie, no session cookie) authenticates + * via a {@link PublicSessionCredential} carrying the public_form_session_key. + */ + @Test + public void testEndpoint_WithPublicSession_Succeeds() throws Exception { + String sessionKey = "public-key-abc"; + mockValidPublicAuth(sessionKey); + MockHttpServletRequestBuilder builder = getRequestBuilder(FULL_AUTH_BODY) + .header(Constants.PUBLIC_FORM_SESSION_HEADER, Constants.PUBLIC_FORM_SESSION_HEADER_VALUE) + .cookie(new Cookie(Constants.PUBLIC_FORM_SESSION_COOKIE_NAME, sessionKey)); + this.testEndpoint(builder, status().isOk()); + } + + /** + * The public header is only honored when its value is exactly "true". A different value with no + * session cookie is not a recognized session and must not authenticate. + */ + @Test + public void testEndpoint_PublicHeaderWrongValue_NoSessionCookie_Fails() throws Exception { + MockHttpServletRequestBuilder builder = getRequestBuilder(FULL_AUTH_BODY) + .header(Constants.PUBLIC_FORM_SESSION_HEADER, "false") + .cookie(new Cookie(Constants.PUBLIC_FORM_SESSION_COOKIE_NAME, "public-key-abc")); + this.testEndpoint(builder, status().isForbidden()); + } + + /** + * When both signals are present, the explicit public header routes to the public credential + * rather than the Django session cookie. + */ + @Test + public void testEndpoint_PublicHeaderAndSessionCookie_PrefersPublicCredential() throws Exception { + String sessionKey = "public-key-abc"; + mockValidPublicAuth(sessionKey); + MockHttpServletRequestBuilder builder = getRequestBuilder(FULL_AUTH_BODY) + .header(Constants.PUBLIC_FORM_SESSION_HEADER, Constants.PUBLIC_FORM_SESSION_HEADER_VALUE) + .cookie(new Cookie(Constants.PUBLIC_FORM_SESSION_COOKIE_NAME, sessionKey)) + .cookie(new Cookie(Constants.POSTGRES_DJANGO_SESSION_ID, "123")); + this.testEndpoint(builder, status().isOk()); + } + @Test public void testMultipartEndpointWithFullAuth_WithAnyHmacAuth_Succeeds() throws Exception { String sessionId = "123"; @@ -115,6 +156,13 @@ private void mockValidAuth(String sessionId) { ); } + private void mockValidPublicAuth(String sessionKey) { + PublicTokenMatcher matcher = new PublicTokenMatcher(DOMAIN, USERNAME, sessionKey); + when(userDetailsService.loadUserDetails(argThat(matcher))).thenReturn( + new HqUserDetailsBean(DOMAIN, USERNAME) + ); + } + private void testEndpoint(MockHttpServletRequestBuilder requestBuilder, ResultMatcher... matchers) throws Exception { ResultActions actions = mvc.perform(requestBuilder) @@ -156,4 +204,30 @@ public boolean matches(PreAuthenticatedAuthenticationToken token) { && sessionId.equals(this.sessionId); } } + + private class PublicTokenMatcher implements ArgumentMatcher { + 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..9a2f3500b 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,54 @@ 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"}); + assertThat(details.isPublicSession()).isTrue(); + } + + @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"); + } } diff --git a/src/test/java/org/commcare/formplayer/tests/HqUserDetailsTests.java b/src/test/java/org/commcare/formplayer/tests/HqUserDetailsTests.java index dd086490d..229549d47 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,52 @@ 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(); + + // 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( 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();