diff --git a/src/main/java/io/supertokens/webauthn/validator/OptionsValidator.java b/src/main/java/io/supertokens/webauthn/validator/OptionsValidator.java index c72a69f19..223b5b882 100644 --- a/src/main/java/io/supertokens/webauthn/validator/OptionsValidator.java +++ b/src/main/java/io/supertokens/webauthn/validator/OptionsValidator.java @@ -22,6 +22,7 @@ import java.net.MalformedURLException; import java.net.URL; +import java.util.Base64; import java.util.List; public class OptionsValidator { @@ -40,6 +41,36 @@ public static void validateOptions(String origin, String rpId, Long timeout, Str } private static void validateOrigin(String origin, String rpId) throws InvalidWebauthNOptionsException { + // Support Android origins (android:apk-key-hash:) + if (origin.startsWith("android:apk-key-hash:")) { + String hash = origin.substring("android:apk-key-hash:".length()); + + // Validate that the hash is not empty + if (hash.isEmpty()) { + throw new InvalidWebauthNOptionsException("Android origin must contain a valid base64 hash"); + } + + // Validate base64 characters first before checking length + try { + Base64.getUrlDecoder().decode(hash); + } catch (IllegalArgumentException error) { + throw new InvalidWebauthNOptionsException("Android origin hash must be valid URL-safe base64 (no padding)"); + } + + // SHA-256 fingerprint in base64url (no padding) is always 43 characters and decodes to 32 bytes + if (hash.length() != 43) { + throw new InvalidWebauthNOptionsException("Android origin hash must be 43 characters (base64url SHA-256)"); + } + + // Verify it decodes to exactly 32 bytes (SHA-256) + if (Base64.getUrlDecoder().decode(hash).length != 32) { + throw new InvalidWebauthNOptionsException("Android origin hash must decode to 32 bytes (SHA-256)"); + } + + return; + } + + // Validate standard HTTP(S) origins try { URL originUrl = new URL(origin); if (!originUrl.getHost().endsWith(rpId)) { @@ -100,4 +131,4 @@ private static void validateUserPresence(Boolean userPresence) throws InvalidWeb throw new InvalidWebauthNOptionsException("userPresence can't be null"); } } -} \ No newline at end of file +} diff --git a/src/test/java/io/supertokens/test/webauthn/Utils.java b/src/test/java/io/supertokens/test/webauthn/Utils.java index 66f2a2a6d..92486d59d 100644 --- a/src/test/java/io/supertokens/test/webauthn/Utils.java +++ b/src/test/java/io/supertokens/test/webauthn/Utils.java @@ -102,11 +102,15 @@ public static JsonObject registerCredentialForUser(Main main, String email, Stri } public static JsonObject registerOptions(Main main, String email) throws HttpResponseException, IOException { + return registerOptions(main, email, "http://example.com"); + } + + public static JsonObject registerOptions(Main main, String email, String origin) throws HttpResponseException, IOException { JsonObject requestBody = new JsonObject(); requestBody.addProperty("email",email); requestBody.addProperty("relyingPartyName","supertokens.com"); requestBody.addProperty("relyingPartyId","example.com"); - requestBody.addProperty("origin","http://example.com"); + requestBody.addProperty("origin", origin); requestBody.addProperty("timeout",10000); JsonObject response = HttpRequestForTesting.sendJsonPOSTRequest(main, "", @@ -117,10 +121,14 @@ public static JsonObject registerOptions(Main main, String email) throws HttpRes } public static JsonObject signInOptions(Main main) throws HttpResponseException, IOException { + return signInOptions(main, "http://example.com"); + } + + public static JsonObject signInOptions(Main main, String origin) throws HttpResponseException, IOException { JsonObject requestBody = new JsonObject(); requestBody.addProperty("relyingPartyName","supertokens.com"); requestBody.addProperty("relyingPartyId","example.com"); - requestBody.addProperty("origin","http://example.com"); + requestBody.addProperty("origin", origin); requestBody.addProperty("timeout",10000); requestBody.addProperty("userVerification","preferred"); requestBody.addProperty("userPresence",false); diff --git a/src/test/java/io/supertokens/test/webauthn/api/TestAndroidOriginValidation.java b/src/test/java/io/supertokens/test/webauthn/api/TestAndroidOriginValidation.java new file mode 100644 index 000000000..4f26a94e3 --- /dev/null +++ b/src/test/java/io/supertokens/test/webauthn/api/TestAndroidOriginValidation.java @@ -0,0 +1,272 @@ +package io.supertokens.test.webauthn.api; + +import com.google.gson.JsonObject; +import io.supertokens.ProcessState; +import io.supertokens.pluginInterface.STORAGE_TYPE; +import io.supertokens.storageLayer.StorageLayer; +import io.supertokens.test.TestingProcessManager; +import io.supertokens.test.Utils; +import io.supertokens.test.httpRequest.HttpRequestForTesting; +import io.supertokens.test.httpRequest.HttpResponseException; +import io.supertokens.utils.SemVer; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; + +import static org.junit.Assert.*; + +public class TestAndroidOriginValidation { + @Rule + public TestRule watchman = Utils.getOnFailure(); + + @Rule + public TestRule retryFlaky = Utils.retryFlakyTest(); + + @AfterClass + public static void afterTesting() { + Utils.afterTesting(); + } + + @Before + public void beforeEach() { + Utils.reset(); + } + + @Test + public void testValidAndroidOrigin() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { + return; + } + + JsonObject req = new JsonObject(); + req.addProperty("email", "test@example.com"); + req.addProperty("relyingPartyName", "Example"); + req.addProperty("relyingPartyId", "example.com"); + req.addProperty("origin", "android:apk-key-hash:47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"); + + try { + JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/webauthn/options/register", req, 1000, 1000, null, + SemVer.v5_3.get(), "webauthn"); + assertEquals("OK", resp.get("status").getAsString()); + } catch (HttpResponseException e) { + fail("Valid Android origin should be accepted: " + e.getMessage()); + } + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void testValidAndroidOriginWithAlternativeHash() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { + return; + } + + JsonObject req = new JsonObject(); + req.addProperty("email", "test@example.com"); + req.addProperty("relyingPartyName", "Example"); + req.addProperty("relyingPartyId", "example.com"); + req.addProperty("origin", "android:apk-key-hash:sYUC8p5I9SxqFernBPHmDxz_YVZXmVJdW8s-m3RTTqE"); + + try { + JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/webauthn/options/register", req, 1000, 1000, null, + SemVer.v5_3.get(), "webauthn"); + assertEquals("OK", resp.get("status").getAsString()); + } catch (HttpResponseException e) { + fail("Valid Android origin with alternative hash should be accepted: " + e.getMessage()); + } + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void testAndroidOriginWithEmptyHash() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { + return; + } + + JsonObject req = new JsonObject(); + req.addProperty("email", "test@example.com"); + req.addProperty("relyingPartyName", "Example"); + req.addProperty("relyingPartyId", "example.com"); + req.addProperty("origin", "android:apk-key-hash:"); + + JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/webauthn/options/register", req, 1000, 1000, null, + SemVer.v5_3.get(), "webauthn"); + assertEquals("INVALID_OPTIONS_ERROR", resp.get("status").getAsString()); + assertTrue(resp.get("reason").getAsString().contains("Android origin must contain a valid base64 hash")); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void testAndroidOriginWithInvalidCharacters() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { + return; + } + + JsonObject req = new JsonObject(); + req.addProperty("email", "test@example.com"); + req.addProperty("relyingPartyName", "Example"); + req.addProperty("relyingPartyId", "example.com"); + req.addProperty("origin", "android:apk-key-hash:invalid@hash#with$special!"); + + JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/webauthn/options/register", req, 1000, 1000, null, + SemVer.v5_3.get(), "webauthn"); + assertEquals("INVALID_OPTIONS_ERROR", resp.get("status").getAsString()); + assertTrue(resp.get("reason").getAsString().contains("Android origin hash must be valid URL-safe base64")); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void testAndroidOriginWithInvalidLength() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { + return; + } + + JsonObject req = new JsonObject(); + req.addProperty("email", "test@example.com"); + req.addProperty("relyingPartyName", "Example"); + req.addProperty("relyingPartyId", "example.com"); + req.addProperty("origin", "android:apk-key-hash:abc"); + + JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/webauthn/options/register", req, 1000, 1000, null, + SemVer.v5_3.get(), "webauthn"); + assertEquals("INVALID_OPTIONS_ERROR", resp.get("status").getAsString()); + assertTrue(resp.get("reason").getAsString().contains("Android origin hash must be 43 characters")); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void testAndroidOriginForSignInOptions() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { + return; + } + + JsonObject req = new JsonObject(); + req.addProperty("relyingPartyName", "Example"); + req.addProperty("relyingPartyId", "example.com"); + req.addProperty("origin", "android:apk-key-hash:47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"); + req.addProperty("timeout", 10000); + req.addProperty("userVerification", "preferred"); + req.addProperty("userPresence", false); + + try { + JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/webauthn/options/signin", req, 1000, 1000, null, + SemVer.v5_3.get(), "webauthn"); + assertEquals("OK", resp.get("status").getAsString()); + } catch (HttpResponseException e) { + fail("Valid Android origin should be accepted for signin options: " + e.getMessage()); + } + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void testMixedOriginsSupport() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { + return; + } + + // Test that regular HTTP origins still work + JsonObject req1 = new JsonObject(); + req1.addProperty("email", "test1@example.com"); + req1.addProperty("relyingPartyName", "Example"); + req1.addProperty("relyingPartyId", "example.com"); + req1.addProperty("origin", "http://example.com"); + + try { + JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/webauthn/options/register", req1, 1000, 1000, null, + SemVer.v5_3.get(), "webauthn"); + assertEquals("OK", resp.get("status").getAsString()); + } catch (HttpResponseException e) { + fail("Regular HTTP origin should still work: " + e.getMessage()); + } + + // Test that HTTPS origins still work + JsonObject req2 = new JsonObject(); + req2.addProperty("email", "test2@example.com"); + req2.addProperty("relyingPartyName", "Example"); + req2.addProperty("relyingPartyId", "example.com"); + req2.addProperty("origin", "https://example.com"); + + try { + JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/webauthn/options/register", req2, 1000, 1000, null, + SemVer.v5_3.get(), "webauthn"); + assertEquals("OK", resp.get("status").getAsString()); + } catch (HttpResponseException e) { + fail("Regular HTTPS origin should still work: " + e.getMessage()); + } + + // Test that Android origins work + JsonObject req3 = new JsonObject(); + req3.addProperty("email", "test3@example.com"); + req3.addProperty("relyingPartyName", "Example"); + req3.addProperty("relyingPartyId", "example.com"); + req3.addProperty("origin", "android:apk-key-hash:47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"); + + try { + JsonObject resp = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/webauthn/options/register", req3, 1000, 1000, null, + SemVer.v5_3.get(), "webauthn"); + assertEquals("OK", resp.get("status").getAsString()); + } catch (HttpResponseException e) { + fail("Android origin should work alongside HTTP(S) origins: " + e.getMessage()); + } + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } +} diff --git a/src/test/java/io/supertokens/test/webauthn/api/TestCredentialsRegisterAPI_5_3.java b/src/test/java/io/supertokens/test/webauthn/api/TestCredentialsRegisterAPI_5_3.java index e98578a8e..c0ba35e00 100644 --- a/src/test/java/io/supertokens/test/webauthn/api/TestCredentialsRegisterAPI_5_3.java +++ b/src/test/java/io/supertokens/test/webauthn/api/TestCredentialsRegisterAPI_5_3.java @@ -351,4 +351,4 @@ private void checkResponseStructure(JsonObject resp) throws Exception { assertTrue(resp.has("relyingPartyName")); assertTrue(resp.has("recipeUserId")); } -} \ No newline at end of file +} diff --git a/src/test/java/io/supertokens/test/webauthn/api/TestSignInAPI_5_3.java b/src/test/java/io/supertokens/test/webauthn/api/TestSignInAPI_5_3.java index 520146d70..2fe3db363 100644 --- a/src/test/java/io/supertokens/test/webauthn/api/TestSignInAPI_5_3.java +++ b/src/test/java/io/supertokens/test/webauthn/api/TestSignInAPI_5_3.java @@ -376,4 +376,4 @@ private void checkResponseStructure(JsonObject resp) throws Exception { assertTrue(resp.has("recipeUserId")); } -} \ No newline at end of file +} diff --git a/src/test/java/io/supertokens/test/webauthn/api/TestSignUpWithCredentialRegisterAPI_5_3.java b/src/test/java/io/supertokens/test/webauthn/api/TestSignUpWithCredentialRegisterAPI_5_3.java index aae7d15ae..0e32a2232 100644 --- a/src/test/java/io/supertokens/test/webauthn/api/TestSignUpWithCredentialRegisterAPI_5_3.java +++ b/src/test/java/io/supertokens/test/webauthn/api/TestSignUpWithCredentialRegisterAPI_5_3.java @@ -286,4 +286,4 @@ private void checkResponseStructure(JsonObject resp) throws Exception { assertTrue(resp.has("relyingPartyName")); assertTrue(resp.has("recipeUserId")); } -} \ No newline at end of file +}