Skip to content

Commit 7559e89

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

File tree

3 files changed

+338
-2
lines changed

3 files changed

+338
-2
lines changed

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,35 @@ 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:<base64-hash>)
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+
// Validate base64 format (alphanumeric, +, /, = and proper length)
53+
if (!hash.matches("^[A-Za-z0-9+/]+=*$")) {
54+
throw new InvalidWebauthNOptionsException("Android origin hash must be valid base64");
55+
}
56+
57+
// Validate base64 padding (length must be multiple of 4 when considering padding)
58+
if (hash.length() % 4 != 0) {
59+
throw new InvalidWebauthNOptionsException("Android origin hash has invalid base64 padding");
60+
}
61+
62+
// Validate that padding is only at the end
63+
int paddingIndex = hash.indexOf('=');
64+
if (paddingIndex != -1 && paddingIndex < hash.length() - 2) {
65+
throw new InvalidWebauthNOptionsException("Android origin hash has invalid base64 padding");
66+
}
67+
68+
return;
69+
}
70+
71+
// Validate standard HTTP/HTTPS origins
4372
try {
4473
URL originUrl = new URL(origin);
4574
if (!originUrl.getHost().endsWith(rpId)) {

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: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
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:Lir5oIjf552K/XN4bTul0VS2aiE=");
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 testValidAndroidOriginWithLongerHash() 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:dGVzdHRlc3R0ZXN0dGVzdHRlc3Q0NTY3ODk=");
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 longer 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 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 has invalid base64 padding"));
173+
174+
process.kill();
175+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
176+
}
177+
178+
@Test
179+
public void testAndroidOriginWithInvalidPadding() 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("email", "[email protected]");
191+
req.addProperty("relyingPartyName", "Example");
192+
req.addProperty("relyingPartyId", "example.com");
193+
req.addProperty("origin", "android:apk-key-hash:test=abc");
194+
195+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
196+
"http://localhost:3567/recipe/webauthn/options/register", req, 1000, 1000, null,
197+
SemVer.v5_3.get(), "webauthn");
198+
assertEquals("INVALID_OPTIONS_ERROR", resp.get("status").getAsString());
199+
assertTrue(resp.get("reason").getAsString().contains("Android origin hash must be valid base64"));
200+
201+
process.kill();
202+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
203+
}
204+
205+
@Test
206+
public void testAndroidOriginForSignInOptions() throws Exception {
207+
String[] args = {"../"};
208+
209+
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
210+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
211+
212+
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
213+
return;
214+
}
215+
216+
JsonObject req = new JsonObject();
217+
req.addProperty("relyingPartyName", "Example");
218+
req.addProperty("relyingPartyId", "example.com");
219+
req.addProperty("origin", "android:apk-key-hash:Lir5oIjf552K/XN4bTul0VS2aiE=");
220+
req.addProperty("timeout", 10000);
221+
req.addProperty("userVerification", "preferred");
222+
req.addProperty("userPresence", false);
223+
224+
try {
225+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
226+
"http://localhost:3567/recipe/webauthn/options/signin", req, 1000, 1000, null,
227+
SemVer.v5_3.get(), "webauthn");
228+
assertEquals("OK", resp.get("status").getAsString());
229+
} catch (HttpResponseException e) {
230+
fail("Valid Android origin should be accepted for signin options: " + e.getMessage());
231+
}
232+
233+
process.kill();
234+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
235+
}
236+
237+
@Test
238+
public void testMixedOriginsSupport() throws Exception {
239+
String[] args = {"../"};
240+
241+
TestingProcessManager.TestingProcess process = TestingProcessManager.start(args);
242+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED));
243+
244+
if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) {
245+
return;
246+
}
247+
248+
// Test that regular HTTP origins still work
249+
JsonObject req1 = new JsonObject();
250+
req1.addProperty("email", "[email protected]");
251+
req1.addProperty("relyingPartyName", "Example");
252+
req1.addProperty("relyingPartyId", "example.com");
253+
req1.addProperty("origin", "http://example.com");
254+
255+
try {
256+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
257+
"http://localhost:3567/recipe/webauthn/options/register", req1, 1000, 1000, null,
258+
SemVer.v5_3.get(), "webauthn");
259+
assertEquals("OK", resp.get("status").getAsString());
260+
} catch (HttpResponseException e) {
261+
fail("Regular HTTP origin should still work: " + e.getMessage());
262+
}
263+
264+
// Test that HTTPS origins still work
265+
JsonObject req2 = new JsonObject();
266+
req2.addProperty("email", "[email protected]");
267+
req2.addProperty("relyingPartyName", "Example");
268+
req2.addProperty("relyingPartyId", "example.com");
269+
req2.addProperty("origin", "https://example.com");
270+
271+
try {
272+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
273+
"http://localhost:3567/recipe/webauthn/options/register", req2, 1000, 1000, null,
274+
SemVer.v5_3.get(), "webauthn");
275+
assertEquals("OK", resp.get("status").getAsString());
276+
} catch (HttpResponseException e) {
277+
fail("Regular HTTPS origin should still work: " + e.getMessage());
278+
}
279+
280+
// Test that Android origins work
281+
JsonObject req3 = new JsonObject();
282+
req3.addProperty("email", "[email protected]");
283+
req3.addProperty("relyingPartyName", "Example");
284+
req3.addProperty("relyingPartyId", "example.com");
285+
req3.addProperty("origin", "android:apk-key-hash:Lir5oIjf552K/XN4bTul0VS2aiE=");
286+
287+
try {
288+
JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "",
289+
"http://localhost:3567/recipe/webauthn/options/register", req3, 1000, 1000, null,
290+
SemVer.v5_3.get(), "webauthn");
291+
assertEquals("OK", resp.get("status").getAsString());
292+
} catch (HttpResponseException e) {
293+
fail("Android origin should work alongside HTTP/HTTPS origins: " + e.getMessage());
294+
}
295+
296+
process.kill();
297+
assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));
298+
}
299+
}

0 commit comments

Comments
 (0)