Skip to content

Commit 0388456

Browse files
nospameclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 0e9f605 commit 0388456

5 files changed

Lines changed: 198 additions & 1 deletion

File tree

src/main/java/org/commcare/formplayer/aspects/UserRestoreAspect.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
import io.sentry.Sentry;
66
import org.commcare.formplayer.auth.DjangoAuth;
77
import org.commcare.formplayer.auth.HqAuth;
8+
import org.commcare.formplayer.auth.PublicFormSessionAuth;
89
import org.commcare.formplayer.beans.AuthenticatedRequestBean;
910
import org.commcare.formplayer.beans.SessionRequestBean;
11+
import org.commcare.formplayer.beans.auth.HqUserDetailsBean;
1012
import org.commcare.formplayer.objects.SerializableFormSession;
13+
import org.commcare.formplayer.util.RequestUtils;
1114
import org.apache.commons.logging.Log;
1215
import org.apache.commons.logging.LogFactory;
1316
import org.aspectj.lang.JoinPoint;
@@ -23,6 +26,7 @@
2326
import org.commcare.formplayer.services.RestoreFactory;
2427

2528
import java.util.Arrays;
29+
import java.util.Optional;
2630

2731
import datadog.trace.api.interceptor.MutableSpan;
2832

@@ -115,7 +119,15 @@ public void closeRestoreFactory(JoinPoint joinPoint) throws Throwable {
115119
restoreFactory.getSQLiteDB().closeConnection();
116120
}
117121

118-
private HqAuth getHqAuth(String sessionToken) {
122+
// Package-private for testing.
123+
HqAuth getHqAuth(String sessionToken) {
124+
// A public web apps session has no Django sessionid; authenticate its outbound HQ calls
125+
// with the public session key instead. Gate this on the HMAC-authenticated `public` field
126+
// from HQ's session_details response, never on the client-supplied header.
127+
Optional<HqUserDetailsBean> userDetails = RequestUtils.getUserDetails();
128+
if (userDetails.isPresent() && userDetails.get().isPublicSession()) {
129+
return new PublicFormSessionAuth(userDetails.get().getAuthToken());
130+
}
119131
if (sessionToken != null) {
120132
return new DjangoAuth(sessionToken);
121133
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package org.commcare.formplayer.auth;
2+
3+
import org.commcare.formplayer.util.Constants;
4+
import org.springframework.http.HttpHeaders;
5+
import org.springframework.util.Assert;
6+
7+
/**
8+
* {@link HqAuth} for a public web apps session.
9+
*
10+
* Emits the credential pair HQ requires to recognize a public session on its receiver/restore
11+
* endpoints: the {@code public_form_session_key} cookie carrying the session key together with the
12+
* {@code CommCare-Public-Session: true} header.
13+
*/
14+
public class PublicFormSessionAuth implements HqAuth {
15+
16+
private final String sessionKey;
17+
18+
public PublicFormSessionAuth(String sessionKey) {
19+
Assert.hasText(sessionKey, "A public form session key is required");
20+
this.sessionKey = sessionKey;
21+
}
22+
23+
@Override
24+
public HttpHeaders getAuthHeaders() {
25+
return new HttpHeaders() {
26+
{
27+
add("Cookie", Constants.PUBLIC_FORM_SESSION_COOKIE_NAME + "=" + sessionKey);
28+
add(Constants.PUBLIC_FORM_SESSION_HEADER, Constants.PUBLIC_FORM_SESSION_HEADER_VALUE);
29+
}
30+
};
31+
}
32+
33+
@Override
34+
public String toString() {
35+
return "PublicFormSessionAuth";
36+
}
37+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package org.commcare.formplayer.aspects;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNull;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
7+
import org.commcare.formplayer.auth.DjangoAuth;
8+
import org.commcare.formplayer.auth.HqAuth;
9+
import org.commcare.formplayer.auth.PublicFormSessionAuth;
10+
import org.commcare.formplayer.beans.auth.HqUserDetailsBean;
11+
import org.commcare.formplayer.util.RequestUtils;
12+
import org.junit.jupiter.api.Test;
13+
import org.mockito.MockedStatic;
14+
import org.mockito.Mockito;
15+
16+
import java.util.Optional;
17+
18+
/**
19+
* Unit tests for {@link UserRestoreAspect#getHqAuth} credential selection, in particular the
20+
* choice between a Django session and a public web apps session.
21+
*/
22+
public class UserRestoreAspectTest {
23+
24+
private final UserRestoreAspect aspect = new UserRestoreAspect();
25+
26+
private HqUserDetailsBean bean(boolean isPublicSession, String authToken) {
27+
HqUserDetailsBean bean = new HqUserDetailsBean("domain", new String[]{"domain"}, "user",
28+
false, new String[]{}, new String[]{});
29+
bean.setPublicSession(isPublicSession);
30+
bean.setAuthToken(authToken);
31+
return bean;
32+
}
33+
34+
@Test
35+
public void publicSession_usesPublicFormSessionAuthWithTheSessionKey() {
36+
try (MockedStatic<RequestUtils> mocked = Mockito.mockStatic(RequestUtils.class)) {
37+
mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.of(bean(true, "pkey")));
38+
39+
HqAuth auth = aspect.getHqAuth(null);
40+
41+
assertTrue(auth instanceof PublicFormSessionAuth);
42+
assertEquals("public_form_session_key=pkey", auth.getAuthHeaders().getFirst("Cookie"));
43+
}
44+
}
45+
46+
@Test
47+
public void publicSession_winsEvenWhenASessionidCookieIsAlsoPresent() {
48+
try (MockedStatic<RequestUtils> mocked = Mockito.mockStatic(RequestUtils.class)) {
49+
mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.of(bean(true, "pkey")));
50+
51+
// Both signals present: the public credential must win, matching inbound selection.
52+
HqAuth auth = aspect.getHqAuth("sessionid-value");
53+
54+
assertTrue(auth instanceof PublicFormSessionAuth);
55+
}
56+
}
57+
58+
@Test
59+
public void regularSession_usesDjangoAuth() {
60+
try (MockedStatic<RequestUtils> mocked = Mockito.mockStatic(RequestUtils.class)) {
61+
mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.of(bean(false, null)));
62+
63+
HqAuth auth = aspect.getHqAuth("sessionid-value");
64+
65+
assertTrue(auth instanceof DjangoAuth);
66+
}
67+
}
68+
69+
@Test
70+
public void noUserDetailsWithSessionToken_usesDjangoAuth() {
71+
try (MockedStatic<RequestUtils> mocked = Mockito.mockStatic(RequestUtils.class)) {
72+
mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.empty());
73+
74+
HqAuth auth = aspect.getHqAuth("sessionid-value");
75+
76+
assertTrue(auth instanceof DjangoAuth);
77+
}
78+
}
79+
80+
@Test
81+
public void noUserDetailsNoSessionToken_returnsNull() {
82+
try (MockedStatic<RequestUtils> mocked = Mockito.mockStatic(RequestUtils.class)) {
83+
mocked.when(RequestUtils::getUserDetails).thenReturn(Optional.empty());
84+
85+
// SMS requests have neither a public session nor a sessionid cookie.
86+
assertNull(aspect.getHqAuth(null));
87+
}
88+
}
89+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package org.commcare.formplayer.auth;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertThrows;
6+
7+
import org.junit.jupiter.api.Test;
8+
import org.springframework.http.HttpHeaders;
9+
10+
public class PublicFormSessionAuthTest {
11+
12+
@Test
13+
public void getAuthHeaders_emitsPublicCookieAndHeaderOnly() {
14+
HttpHeaders headers = new PublicFormSessionAuth("session-key-123").getAuthHeaders();
15+
16+
assertEquals("public_form_session_key=session-key-123", headers.getFirst("Cookie"));
17+
assertEquals("true", headers.getFirst("CommCare-Public-Session"));
18+
19+
// Exactly the two public headers — no Django sessionid/Authorization leaks out.
20+
assertEquals(2, headers.size());
21+
assertFalse(headers.containsKey("sessionid"));
22+
assertFalse(headers.containsKey("Authorization"));
23+
}
24+
25+
@Test
26+
public void toString_doesNotLeakTheKey() {
27+
assertFalse(new PublicFormSessionAuth("super-secret-key").toString().contains("super-secret-key"));
28+
}
29+
30+
@Test
31+
public void constructor_rejectsMissingKey() {
32+
assertThrows(IllegalArgumentException.class, () -> new PublicFormSessionAuth(null));
33+
assertThrows(IllegalArgumentException.class, () -> new PublicFormSessionAuth(""));
34+
}
35+
}

src/test/java/org/commcare/formplayer/tests/RestoreFactoryTest.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import org.commcare.cases.util.CaseDBUtils;
1414
import org.commcare.formplayer.auth.DjangoAuth;
15+
import org.commcare.formplayer.auth.PublicFormSessionAuth;
1516
import org.commcare.formplayer.beans.AuthenticatedRequestBean;
1617
import org.commcare.formplayer.configuration.CacheConfiguration;
1718
import org.commcare.formplayer.junit.RestoreFactoryAnswer;
@@ -265,6 +266,29 @@ public void testGetRequestHeaders() {
265266
);
266267
}
267268

269+
@Test
270+
public void testGetRequestHeaders_PublicSession() {
271+
String syncToken = "synctoken";
272+
Mockito.doReturn(syncToken).when(restoreFactorySpy).getSyncToken();
273+
// A public web apps session authenticates outbound calls with the public session key.
274+
restoreFactorySpy.setHqAuth(new PublicFormSessionAuth("pkey"));
275+
276+
HttpHeaders headers = restoreFactorySpy.getRequestHeaders(null);
277+
278+
assertEquals(6, headers.size());
279+
validateHeaders(headers, Arrays.asList(
280+
hasEntry("Cookie", singletonList("public_form_session_key=pkey")),
281+
hasEntry("CommCare-Public-Session", singletonList("true")),
282+
hasEntry("X-OpenRosa-Version", singletonList("3.0")),
283+
hasEntry("X-OpenRosa-DeviceId", singletonList("WebAppsLogin")),
284+
hasEntry("X-CommCareHQ-LastSyncToken", singletonList(syncToken)),
285+
hasEntry(equalTo("X-CommCareHQ-Origin-Token"), new ValueIsUUID()))
286+
);
287+
// The Django sessionid must never travel on a public session's outbound calls.
288+
Assertions.assertFalse(headers.containsKey("sessionid"));
289+
Assertions.assertFalse(headers.containsKey("Authorization"));
290+
}
291+
268292
@Test
269293
public void testGetRequestHeaders_HmacAuth() throws Exception {
270294
mockHmacRequest();

0 commit comments

Comments
 (0)