Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,29 @@ 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:<base64Url-string-without-padding-of-fingerprint>)
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");
}

// Accept URL-safe base64 (A-Za-z0-9-_ only)
if (!hash.matches("^[A-Za-z0-9\\-_]+$")) {
throw new InvalidWebauthNOptionsException("Android origin hash must be valid URL-safe base64");
}

// Validate length: SHA256 is 32 bytes, base64-urlsafe encoding is 43 chars
if (hash.length() != 43) {
throw new InvalidWebauthNOptionsException("Android origin hash must be 43 characters (base64 of signing certificate's SHA 256 fingerprint)");
}

return;
}

// Validate standard HTTP(S) origins
try {
URL originUrl = new URL(origin);
if (!originUrl.getHost().endsWith(rpId)) {
Expand Down Expand Up @@ -100,4 +123,4 @@ private static void validateUserPresence(Boolean userPresence) throws InvalidWeb
throw new InvalidWebauthNOptionsException("userPresence can't be null");
}
}
}
}
12 changes: 10 additions & 2 deletions src/test/java/io/supertokens/test/webauthn/Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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, "",
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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", "[email protected]");
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", "[email protected]");
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", "[email protected]");
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", "[email protected]");
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", "[email protected]");
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", "[email protected]");
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", "[email protected]");
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", "[email protected]");
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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -351,4 +351,4 @@ private void checkResponseStructure(JsonObject resp) throws Exception {
assertTrue(resp.has("relyingPartyName"));
assertTrue(resp.has("recipeUserId"));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -376,4 +376,4 @@ private void checkResponseStructure(JsonObject resp) throws Exception {

assertTrue(resp.has("recipeUserId"));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -286,4 +286,4 @@ private void checkResponseStructure(JsonObject resp) throws Exception {
assertTrue(resp.has("relyingPartyName"));
assertTrue(resp.has("recipeUserId"));
}
}
}