Skip to content

Commit 7504841

Browse files
committed
feat(webauthn): allow native Android origins
following the format: android:apk-key-hash:<base64Url-string-without-padding-of-fingerprint>
1 parent e2ce582 commit 7504841

File tree

6 files changed

+309
-6
lines changed

6 files changed

+309
-6
lines changed

src/main/java/io/supertokens/webauthn/validator/OptionsValidator.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@ public static void validateOptions(String origin, String rpId, Long timeout, Str
4040
}
4141

4242
private static void validateOrigin(String origin, String rpId) throws InvalidWebauthNOptionsException {
43+
// Support Android origins (android:apk-key-hash:<encoded SHA 256 fingerprint>)
44+
if (origin.startsWith("android:apk-key-hash:")) {
45+
String hash = origin.substring("android:apk-key-hash:".length());
46+
47+
// Validate that the hash is not empty
48+
if (hash.isEmpty()) {
49+
throw new InvalidWebauthNOptionsException("Android origin must contain a valid base64 hash");
50+
}
51+
52+
// Accept URL-safe base64 (A-Za-z0-9-_ only)
53+
if (!hash.matches("^[A-Za-z0-9\\-_]+$")) {
54+
throw new InvalidWebauthNOptionsException("Android origin hash must be valid URL-safe base64");
55+
}
56+
57+
// Validate length: SHA256 is 32 bytes, base64-urlsafe encoding is 43 chars
58+
if (hash.length() != 43) {
59+
throw new InvalidWebauthNOptionsException("Android origin hash must be 43 characters (base64 of signing certificate's SHA 256 fingerprint)");
60+
}
61+
62+
return;
63+
}
64+
65+
// Validate standard HTTP(S) origins
4366
try {
4467
URL originUrl = new URL(origin);
4568
if (!originUrl.getHost().endsWith(rpId)) {
@@ -100,4 +123,4 @@ private static void validateUserPresence(Boolean userPresence) throws InvalidWeb
100123
throw new InvalidWebauthNOptionsException("userPresence can't be null");
101124
}
102125
}
103-
}
126+
}

src/test/java/io/supertokens/test/webauthn/Utils.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,15 @@ public static JsonObject registerCredentialForUser(Main main, String email, Stri
102102
}
103103

104104
public static JsonObject registerOptions(Main main, String email) throws HttpResponseException, IOException {
105+
return registerOptions(main, email, "http://example.com");
106+
}
107+
108+
public static JsonObject registerOptions(Main main, String email, String origin) throws HttpResponseException, IOException {
105109
JsonObject requestBody = new JsonObject();
106110
requestBody.addProperty("email",email);
107111
requestBody.addProperty("relyingPartyName","supertokens.com");
108112
requestBody.addProperty("relyingPartyId","example.com");
109-
requestBody.addProperty("origin","http://example.com");
113+
requestBody.addProperty("origin", origin);
110114
requestBody.addProperty("timeout",10000);
111115

112116
JsonObject response = HttpRequestForTesting.sendJsonPOSTRequest(main, "",
@@ -117,10 +121,14 @@ public static JsonObject registerOptions(Main main, String email) throws HttpRes
117121
}
118122

119123
public static JsonObject signInOptions(Main main) throws HttpResponseException, IOException {
124+
return signInOptions(main, "http://example.com");
125+
}
126+
127+
public static JsonObject signInOptions(Main main, String origin) throws HttpResponseException, IOException {
120128
JsonObject requestBody = new JsonObject();
121129
requestBody.addProperty("relyingPartyName","supertokens.com");
122130
requestBody.addProperty("relyingPartyId","example.com");
123-
requestBody.addProperty("origin","http://example.com");
131+
requestBody.addProperty("origin", origin);
124132
requestBody.addProperty("timeout",10000);
125133
requestBody.addProperty("userVerification","preferred");
126134
requestBody.addProperty("userPresence",false);
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
package io.supertokens.test.webauthn.api;
2+
3+
import com.google.gson.JsonObject;
4+
import io.supertokens.ProcessState;
5+
import io.supertokens.pluginInterface.STORAGE_TYPE;
6+
import io.supertokens.storageLayer.StorageLayer;
7+
import io.supertokens.test.TestingProcessManager;
8+
import io.supertokens.test.Utils;
9+
import io.supertokens.test.httpRequest.HttpRequestForTesting;
10+
import io.supertokens.test.httpRequest.HttpResponseException;
11+
import io.supertokens.utils.SemVer;
12+
import org.junit.AfterClass;
13+
import org.junit.Before;
14+
import org.junit.Rule;
15+
import org.junit.Test;
16+
import org.junit.rules.TestRule;
17+
18+
import static org.junit.Assert.*;
19+
20+
public class TestAndroidOriginValidation {
21+
@Rule
22+
public TestRule watchman = Utils.getOnFailure();
23+
24+
@Rule
25+
public TestRule retryFlaky = Utils.retryFlakyTest();
26+
27+
@AfterClass
28+
public static void afterTesting() {
29+
Utils.afterTesting();
30+
}
31+
32+
@Before
33+
public void beforeEach() {
34+
Utils.reset();
35+
}
36+
37+
@Test
38+
public void testValidAndroidOrigin() throws Exception {
39+
String[] args = {"../"};
40+
41+
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
42+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
43+
44+
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
45+
return;
46+
}
47+
48+
JsonObject req = new JsonObject();
49+
req.addProperty("email", "[email protected]");
50+
req.addProperty("relyingPartyName", "Example");
51+
req.addProperty("relyingPartyId", "example.com");
52+
req.addProperty("origin", "android:apk-key-hash:47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU");
53+
54+
try {
55+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
56+
"http://localhost:3567/recipe/webauthn/options/register", req, 1000, 1000, null,
57+
SemVer.v5_3.get(), "webauthn");
58+
assertEquals("OK", resp.get("status").getAsString());
59+
} catch (HttpResponseException e) {
60+
fail("Valid Android origin should be accepted: " + e.getMessage());
61+
}
62+
63+
process.kill();
64+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
65+
}
66+
67+
@Test
68+
public void testValidAndroidOriginWithAlternativeHash() throws Exception {
69+
String[] args = {"../"};
70+
71+
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
72+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
73+
74+
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
75+
return;
76+
}
77+
78+
JsonObject req = new JsonObject();
79+
req.addProperty("email", "[email protected]");
80+
req.addProperty("relyingPartyName", "Example");
81+
req.addProperty("relyingPartyId", "example.com");
82+
req.addProperty("origin", "android:apk-key-hash:sYUC8p5I9SxqFernBPHmDxz_YVZXmVJdW8s-m3RTTqE");
83+
84+
try {
85+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
86+
"http://localhost:3567/recipe/webauthn/options/register", req, 1000, 1000, null,
87+
SemVer.v5_3.get(), "webauthn");
88+
assertEquals("OK", resp.get("status").getAsString());
89+
} catch (HttpResponseException e) {
90+
fail("Valid Android origin with alternative hash should be accepted: " + e.getMessage());
91+
}
92+
93+
process.kill();
94+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
95+
}
96+
97+
@Test
98+
public void testAndroidOriginWithEmptyHash() throws Exception {
99+
String[] args = {"../"};
100+
101+
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
102+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
103+
104+
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
105+
return;
106+
}
107+
108+
JsonObject req = new JsonObject();
109+
req.addProperty("email", "[email protected]");
110+
req.addProperty("relyingPartyName", "Example");
111+
req.addProperty("relyingPartyId", "example.com");
112+
req.addProperty("origin", "android:apk-key-hash:");
113+
114+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
115+
"http://localhost:3567/recipe/webauthn/options/register", req, 1000, 1000, null,
116+
SemVer.v5_3.get(), "webauthn");
117+
assertEquals("INVALID_OPTIONS_ERROR", resp.get("status").getAsString());
118+
assertTrue(resp.get("reason").getAsString().contains("Android origin must contain a valid base64 hash"));
119+
120+
process.kill();
121+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
122+
}
123+
124+
@Test
125+
public void testAndroidOriginWithInvalidCharacters() throws Exception {
126+
String[] args = {"../"};
127+
128+
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
129+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
130+
131+
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
132+
return;
133+
}
134+
135+
JsonObject req = new JsonObject();
136+
req.addProperty("email", "[email protected]");
137+
req.addProperty("relyingPartyName", "Example");
138+
req.addProperty("relyingPartyId", "example.com");
139+
req.addProperty("origin", "android:apk-key-hash:invalid@hash#with$special!");
140+
141+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
142+
"http://localhost:3567/recipe/webauthn/options/register", req, 1000, 1000, null,
143+
SemVer.v5_3.get(), "webauthn");
144+
assertEquals("INVALID_OPTIONS_ERROR", resp.get("status").getAsString());
145+
assertTrue(resp.get("reason").getAsString().contains("Android origin hash must be valid URL-safe base64"));
146+
147+
process.kill();
148+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
149+
}
150+
151+
@Test
152+
public void testAndroidOriginWithInvalidLength() throws Exception {
153+
String[] args = {"../"};
154+
155+
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
156+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
157+
158+
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
159+
return;
160+
}
161+
162+
JsonObject req = new JsonObject();
163+
req.addProperty("email", "[email protected]");
164+
req.addProperty("relyingPartyName", "Example");
165+
req.addProperty("relyingPartyId", "example.com");
166+
req.addProperty("origin", "android:apk-key-hash:abc");
167+
168+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
169+
"http://localhost:3567/recipe/webauthn/options/register", req, 1000, 1000, null,
170+
SemVer.v5_3.get(), "webauthn");
171+
assertEquals("INVALID_OPTIONS_ERROR", resp.get("status").getAsString());
172+
assertTrue(resp.get("reason").getAsString().contains("Android origin hash must be 43 characters"));
173+
174+
process.kill();
175+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
176+
}
177+
178+
@Test
179+
public void testAndroidOriginForSignInOptions() throws Exception {
180+
String[] args = {"../"};
181+
182+
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
183+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
184+
185+
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
186+
return;
187+
}
188+
189+
JsonObject req = new JsonObject();
190+
req.addProperty("relyingPartyName", "Example");
191+
req.addProperty("relyingPartyId", "example.com");
192+
req.addProperty("origin", "android:apk-key-hash:47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU");
193+
req.addProperty("timeout", 10000);
194+
req.addProperty("userVerification", "preferred");
195+
req.addProperty("userPresence", false);
196+
197+
try {
198+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
199+
"http://localhost:3567/recipe/webauthn/options/signin", req, 1000, 1000, null,
200+
SemVer.v5_3.get(), "webauthn");
201+
assertEquals("OK", resp.get("status").getAsString());
202+
} catch (HttpResponseException e) {
203+
fail("Valid Android origin should be accepted for signin options: " + e.getMessage());
204+
}
205+
206+
process.kill();
207+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
208+
}
209+
210+
@Test
211+
public void testMixedOriginsSupport() throws Exception {
212+
String[] args = {"../"};
213+
214+
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
215+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
216+
217+
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
218+
return;
219+
}
220+
221+
// Test that regular HTTP origins still work
222+
JsonObject req1 = new JsonObject();
223+
req1.addProperty("email", "[email protected]");
224+
req1.addProperty("relyingPartyName", "Example");
225+
req1.addProperty("relyingPartyId", "example.com");
226+
req1.addProperty("origin", "http://example.com");
227+
228+
try {
229+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
230+
"http://localhost:3567/recipe/webauthn/options/register", req1, 1000, 1000, null,
231+
SemVer.v5_3.get(), "webauthn");
232+
assertEquals("OK", resp.get("status").getAsString());
233+
} catch (HttpResponseException e) {
234+
fail("Regular HTTP origin should still work: " + e.getMessage());
235+
}
236+
237+
// Test that HTTPS origins still work
238+
JsonObject req2 = new JsonObject();
239+
req2.addProperty("email", "[email protected]");
240+
req2.addProperty("relyingPartyName", "Example");
241+
req2.addProperty("relyingPartyId", "example.com");
242+
req2.addProperty("origin", "https://example.com");
243+
244+
try {
245+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
246+
"http://localhost:3567/recipe/webauthn/options/register", req2, 1000, 1000, null,
247+
SemVer.v5_3.get(), "webauthn");
248+
assertEquals("OK", resp.get("status").getAsString());
249+
} catch (HttpResponseException e) {
250+
fail("Regular HTTPS origin should still work: " + e.getMessage());
251+
}
252+
253+
// Test that Android origins work
254+
JsonObject req3 = new JsonObject();
255+
req3.addProperty("email", "[email protected]");
256+
req3.addProperty("relyingPartyName", "Example");
257+
req3.addProperty("relyingPartyId", "example.com");
258+
req3.addProperty("origin", "android:apk-key-hash:47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU");
259+
260+
try {
261+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
262+
"http://localhost:3567/recipe/webauthn/options/register", req3, 1000, 1000, null,
263+
SemVer.v5_3.get(), "webauthn");
264+
assertEquals("OK", resp.get("status").getAsString());
265+
} catch (HttpResponseException e) {
266+
fail("Android origin should work alongside HTTP(S) origins: " + e.getMessage());
267+
}
268+
269+
process.kill();
270+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
271+
}
272+
}

src/test/java/io/supertokens/test/webauthn/api/TestCredentialsRegisterAPI_5_3.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,4 +351,4 @@ private void checkResponseStructure(JsonObject resp) throws Exception {
351351
assertTrue(resp.has("relyingPartyName"));
352352
assertTrue(resp.has("recipeUserId"));
353353
}
354-
}
354+
}

src/test/java/io/supertokens/test/webauthn/api/TestSignInAPI_5_3.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,4 +376,4 @@ private void checkResponseStructure(JsonObject resp) throws Exception {
376376

377377
assertTrue(resp.has("recipeUserId"));
378378
}
379-
}
379+
}

src/test/java/io/supertokens/test/webauthn/api/TestSignUpWithCredentialRegisterAPI_5_3.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,4 +286,4 @@ private void checkResponseStructure(JsonObject resp) throws Exception {
286286
assertTrue(resp.has("relyingPartyName"));
287287
assertTrue(resp.has("recipeUserId"));
288288
}
289-
}
289+
}

0 commit comments

Comments
 (0)