From 1bc72f9dffc4e0e2eed01d0af652e5305df2b73c Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Mon, 29 Jul 2024 13:44:59 +0200 Subject: [PATCH 01/30] fix/change annotations for configs --- src/main/java/io/supertokens/config/CoreConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/supertokens/config/CoreConfig.java b/src/main/java/io/supertokens/config/CoreConfig.java index fbbdb82e5..dcba70d73 100644 --- a/src/main/java/io/supertokens/config/CoreConfig.java +++ b/src/main/java/io/supertokens/config/CoreConfig.java @@ -275,14 +275,14 @@ public class CoreConfig { " address.") private String ip_deny_regex = null; - @ConfigYamlOnly + @NotConflictingInApp @JsonProperty @HideFromDashboard @ConfigDescription( "If specified, the core uses this URL to connect to the OAuth provider public service.") private String oauth_provider_public_service_url = null; - @ConfigYamlOnly + @NotConflictingInApp @JsonProperty @ConfigDescription( "If specified, the core uses this URL to connect to the OAuth provider admin service.") From 8a330684147a2439a9342d0c78245aac3bd907ec Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Mon, 29 Jul 2024 18:07:52 +0200 Subject: [PATCH 02/30] feat: oauth2 auth API - WIP --- .../java/io/supertokens/inmemorydb/Start.java | 8 +- src/main/java/io/supertokens/oauth/OAuth.java | 68 +++++++++++++--- .../oauth/exceptions/OAuthException.java | 21 +++++ .../io/supertokens/webserver/Webserver.java | 3 + .../webserver/api/oauth/OAuthAPI.java | 79 +++++++++++++++++++ 5 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 src/main/java/io/supertokens/oauth/exceptions/OAuthException.java create mode 100644 src/main/java/io/supertokens/webserver/api/oauth/OAuthAPI.java diff --git a/src/main/java/io/supertokens/inmemorydb/Start.java b/src/main/java/io/supertokens/inmemorydb/Start.java index 4398107bb..c4b199d8c 100644 --- a/src/main/java/io/supertokens/inmemorydb/Start.java +++ b/src/main/java/io/supertokens/inmemorydb/Start.java @@ -55,6 +55,7 @@ import io.supertokens.pluginInterface.multitenancy.exceptions.DuplicateThirdPartyIdException; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.pluginInterface.multitenancy.sqlStorage.MultitenancySQLStorage; +import io.supertokens.pluginInterface.oauth.OAuthStorage; import io.supertokens.pluginInterface.passwordless.PasswordlessCode; import io.supertokens.pluginInterface.passwordless.PasswordlessDevice; import io.supertokens.pluginInterface.passwordless.exception.*; @@ -102,7 +103,7 @@ public class Start implements SessionSQLStorage, EmailPasswordSQLStorage, EmailVerificationSQLStorage, ThirdPartySQLStorage, JWTRecipeSQLStorage, PasswordlessSQLStorage, UserMetadataSQLStorage, UserRolesSQLStorage, UserIdMappingStorage, UserIdMappingSQLStorage, MultitenancyStorage, MultitenancySQLStorage, TOTPSQLStorage, ActiveUsersStorage, - ActiveUsersSQLStorage, DashboardSQLStorage, AuthRecipeSQLStorage { + ActiveUsersSQLStorage, DashboardSQLStorage, AuthRecipeSQLStorage, OAuthStorage { private static final Object appenderLock = new Object(); private static final String APP_ID_KEY_NAME = "app_id"; @@ -3007,4 +3008,9 @@ public int countUsersThatHaveMoreThanOneLoginMethodOrTOTPEnabledAndActiveSince(A throw new StorageQueryException(e); } } + + @Override + public boolean doesClientIdExistForThisApp(AppIdentifier appIdentifier, String clientId) { + return false; + } } diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index 075ff1b96..63c583d34 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -16,40 +16,88 @@ package io.supertokens.oauth; +import com.google.gson.JsonObject; import io.supertokens.Main; import io.supertokens.config.Config; +import io.supertokens.httpRequest.HttpRequest; +import io.supertokens.httpRequest.HttpResponseException; +import io.supertokens.multitenancy.Multitenancy; +import io.supertokens.oauth.exceptions.OAuthException; import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.StorageUtils; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.pluginInterface.oauth.OAuthAuthResponse; import io.supertokens.pluginInterface.oauth.OAuthStorage; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + public class OAuth { - public static void getAuthorizationUrl(Main main, AppIdentifier appIdentifier, Storage storage, String clientId, - String redirectURI, String responseType, String scope, String state) - throws InvalidConfigException { + private static final String LOCATION_HEADER_NAME = "Location"; + private static final String COOKIES_HEADER_NAME = "Set-Cookie"; + + public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier appIdentifier, Storage storage, String clientId, + String redirectURI, String responseType, String scope, String state) + throws InvalidConfigException, HttpResponseException, IOException, OAuthException, StorageQueryException, + TenantOrAppNotFoundException { // TODO: // - validate that client_id is present for this tenant - // - call hydra - // - if location header is: - // - localhost:3000, then we redirect to apiDomain - // - public url for hydra, then we throw a 400 error with the right json - // - else we redirect back to the client OAuthStorage oauthStorage = StorageUtils.getOAuthStorage(storage); String redirectTo = null; + List cookies = null; + + String publicOAuthProviderServiceUrl = Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl(); if (!oauthStorage.doesClientIdExistForThisApp(appIdentifier, clientId)) { - redirectTo = Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl() + + redirectTo = publicOAuthProviderServiceUrl + "/oauth2/fallbacks/error?error=invalid_client&error_description=Client+authentication+failed+%28e" + ".g.%2C+unknown+client%2C+no+client+authentication+included%2C+or+unsupported+authentication" + "+method%29.+The+requested+OAuth+2.0+Client+does+not+exist."; } else { // we query hydra + Map queryParamsForHydra = constructHydraRequestParams(clientId, redirectURI, responseType, scope, state); + Map responseHeaders = new HashMap<>(); + HttpRequest.sendGETRequestWithResponseHeaders(main, null, Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl(), queryParamsForHydra, 20, 400, null, responseHeaders); // TODO is there some kind of config for the timeouts? + + if(null != responseHeaders && responseHeaders.keySet().contains(LOCATION_HEADER_NAME)) { + String locationHeaderValue = responseHeaders.get(LOCATION_HEADER_NAME); + + if (locationHeaderValue.equals(publicOAuthProviderServiceUrl)){ + throw new OAuthException(); + } + + if (locationHeaderValue.equals("localhost:3000")) { + redirectTo = Multitenancy.getAPIDomain(storage, appIdentifier); + } else { + redirectTo = locationHeaderValue; + } + } + if(responseHeaders.containsKey(COOKIES_HEADER_NAME)){ + String allCookies = responseHeaders.get(COOKIES_HEADER_NAME); + cookies = Arrays.asList(allCookies.split(";")); + } } - // TODO: parse url resposne and send appropriate reply from this API. + return new OAuthAuthResponse(redirectTo, cookies); + } + + private static Map constructHydraRequestParams(String clientId, + String redirectURI, String responseType, String scope, String state) { + Map queryParamsForHydra = new HashMap<>(); + queryParamsForHydra.put("clientId", clientId); + queryParamsForHydra.put("redirectURI", redirectURI); + queryParamsForHydra.put("scope", scope); + queryParamsForHydra.put("responseType", responseType); + queryParamsForHydra.put("state", state); + return queryParamsForHydra; } } diff --git a/src/main/java/io/supertokens/oauth/exceptions/OAuthException.java b/src/main/java/io/supertokens/oauth/exceptions/OAuthException.java new file mode 100644 index 000000000..2cc528d43 --- /dev/null +++ b/src/main/java/io/supertokens/oauth/exceptions/OAuthException.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.oauth.exceptions; + +public class OAuthException extends Exception{ + private static final long serialVersionUID = 1836718299845759897L; +} diff --git a/src/main/java/io/supertokens/webserver/Webserver.java b/src/main/java/io/supertokens/webserver/Webserver.java index 2861588fd..b5516db69 100644 --- a/src/main/java/io/supertokens/webserver/Webserver.java +++ b/src/main/java/io/supertokens/webserver/Webserver.java @@ -39,6 +39,7 @@ import io.supertokens.webserver.api.multitenancy.*; import io.supertokens.webserver.api.multitenancy.thirdparty.CreateOrUpdateThirdPartyConfigAPI; import io.supertokens.webserver.api.multitenancy.thirdparty.RemoveThirdPartyConfigAPI; +import io.supertokens.webserver.api.oauth.OAuthAPI; import io.supertokens.webserver.api.passwordless.*; import io.supertokens.webserver.api.session.*; import io.supertokens.webserver.api.thirdparty.GetUsersByEmailAPI; @@ -267,6 +268,8 @@ private void setupRoutes() { addAPI(new RequestStatsAPI(main)); addAPI(new GetTenantCoreConfigForDashboardAPI(main)); + addAPI(new OAuthAPI(main)); + StandardContext context = tomcatReference.getContext(); Tomcat tomcat = tomcatReference.getTomcat(); diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAPI.java new file mode 100644 index 000000000..b4919e9c4 --- /dev/null +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAPI.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.webserver.api.oauth; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.supertokens.Main; +import io.supertokens.httpRequest.HttpResponseException; +import io.supertokens.oauth.OAuth; +import io.supertokens.oauth.exceptions.OAuthException; +import io.supertokens.pluginInterface.RECIPE_ID; +import io.supertokens.pluginInterface.exceptions.InvalidConfigException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.pluginInterface.oauth.OAuthAuthResponse; +import io.supertokens.webserver.WebserverAPI; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.io.Serial; + +public class OAuthAPI extends WebserverAPI { + @Serial + private static final long serialVersionUID = -8734479943734920904L; + + public OAuthAPI(Main main) { + super(main, RECIPE_ID.OAUTH.toString()); + } + + @Override + public String getPath() { + return "oauth/auth"; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { + // TODO Work in progress! + try { + OAuthAuthResponse authResponse = OAuth.getAuthorizationUrl(null, null,null, "a685663d-1b5d-4a70-b7f7-025ff2e2d7a4", "http://localhost.com:3031/auth/callback/ory", "code", "profile", "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BDv%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD"); + JsonObject response = new JsonObject(); + response.addProperty("redirectTo", authResponse.redirectTo); + + if(authResponse.cookies != null) { + Gson gson = new Gson(); + String cookiesAsJson = gson.toJson(authResponse.cookies); + response.addProperty("cookies", cookiesAsJson); + } + + } catch (InvalidConfigException e) { + throw new RuntimeException(e); + } catch (HttpResponseException e) { + throw new RuntimeException(e); + } catch (OAuthException e) { + throw new RuntimeException(e); + } catch (StorageQueryException e) { + throw new RuntimeException(e); + } catch (TenantOrAppNotFoundException e) { + throw new RuntimeException(e); + } + } +} From d358a3350fd2c42069bed780b3e30ca9c928a8f9 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Mon, 29 Jul 2024 18:22:09 +0200 Subject: [PATCH 03/30] fix: hidefromdashboard to oauth_provider service url configs --- src/main/java/io/supertokens/config/CoreConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/io/supertokens/config/CoreConfig.java b/src/main/java/io/supertokens/config/CoreConfig.java index dcba70d73..130ccb0d5 100644 --- a/src/main/java/io/supertokens/config/CoreConfig.java +++ b/src/main/java/io/supertokens/config/CoreConfig.java @@ -284,6 +284,7 @@ public class CoreConfig { @NotConflictingInApp @JsonProperty + @HideFromDashboard @ConfigDescription( "If specified, the core uses this URL to connect to the OAuth provider admin service.") private String oauth_provider_admin_service_url = null; From 282b889cb43fc32ef189cf433c006faff2913b31 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Mon, 29 Jul 2024 19:27:20 +0200 Subject: [PATCH 04/30] feat: OAuthAPI input parsing, basic flow --- src/main/java/io/supertokens/oauth/OAuth.java | 4 +- .../webserver/api/oauth/OAuthAPI.java | 41 ++++++++++++++----- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index 63c583d34..5304fbd18 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -68,7 +68,7 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app Map responseHeaders = new HashMap<>(); HttpRequest.sendGETRequestWithResponseHeaders(main, null, Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl(), queryParamsForHydra, 20, 400, null, responseHeaders); // TODO is there some kind of config for the timeouts? - if(null != responseHeaders && responseHeaders.keySet().contains(LOCATION_HEADER_NAME)) { + if(!responseHeaders.isEmpty() && responseHeaders.containsKey(LOCATION_HEADER_NAME)) { String locationHeaderValue = responseHeaders.get(LOCATION_HEADER_NAME); if (locationHeaderValue.equals(publicOAuthProviderServiceUrl)){ @@ -83,7 +83,7 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app } if(responseHeaders.containsKey(COOKIES_HEADER_NAME)){ String allCookies = responseHeaders.get(COOKIES_HEADER_NAME); - cookies = Arrays.asList(allCookies.split(";")); + cookies = Arrays.asList(allCookies.split("; ")); } } diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAPI.java index b4919e9c4..618dd55b0 100644 --- a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAPI.java +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAPI.java @@ -25,14 +25,19 @@ import io.supertokens.oauth.OAuth; import io.supertokens.oauth.exceptions.OAuthException; import io.supertokens.pluginInterface.RECIPE_ID; +import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.AppIdentifier; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.pluginInterface.oauth.OAuthAuthResponse; +import io.supertokens.webserver.InputParser; import io.supertokens.webserver.WebserverAPI; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.apache.catalina.Store; import java.io.IOException; import java.io.Serial; @@ -53,27 +58,41 @@ public String getPath() { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { // TODO Work in progress! + + JsonObject input = InputParser.parseJsonObjectOrThrowError(req); + String clientId = InputParser.parseStringOrThrowError(input, "clientId", false); + String redirectUri = InputParser.parseStringOrThrowError(input, "redirectUri", false); + String responseType = InputParser.parseStringOrThrowError(input, "responseType", false); + String scope = InputParser.parseStringOrThrowError(input, "scope", false); + String state = InputParser.parseStringOrThrowError(input, "state", false); + try { - OAuthAuthResponse authResponse = OAuth.getAuthorizationUrl(null, null,null, "a685663d-1b5d-4a70-b7f7-025ff2e2d7a4", "http://localhost.com:3031/auth/callback/ory", "code", "profile", "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BDv%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD"); + AppIdentifier appIdentifier = getAppIdentifier(req); + Storage storage = getTenantStorage(req); + + OAuthAuthResponse authResponse = OAuth.getAuthorizationUrl(super.main, appIdentifier, storage, + clientId, redirectUri, responseType, scope, state); JsonObject response = new JsonObject(); response.addProperty("redirectTo", authResponse.redirectTo); - if(authResponse.cookies != null) { + if (authResponse.cookies != null) { Gson gson = new Gson(); String cookiesAsJson = gson.toJson(authResponse.cookies); response.addProperty("cookies", cookiesAsJson); } - } catch (InvalidConfigException e) { - throw new RuntimeException(e); - } catch (HttpResponseException e) { - throw new RuntimeException(e); + super.sendJsonResponse(200, response, resp); + } catch (OAuthException e) { - throw new RuntimeException(e); - } catch (StorageQueryException e) { - throw new RuntimeException(e); - } catch (TenantOrAppNotFoundException e) { - throw new RuntimeException(e); + + JsonObject errorResponse = new JsonObject(); + //TODO what is a good content here? + + super.sendJsonResponse(400, errorResponse, resp); + + } catch (TenantOrAppNotFoundException | InvalidConfigException | HttpResponseException | + StorageQueryException e) { + throw new ServletException(e); } } } From 4076bf8f564d7b2a8bb38a0cc2f2647f17f97a93 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Mon, 29 Jul 2024 20:10:30 +0200 Subject: [PATCH 05/30] feat: first test in progress --- .../test/oauth/api/OAuthAPITest.java | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/test/java/io/supertokens/test/oauth/api/OAuthAPITest.java diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthAPITest.java new file mode 100644 index 000000000..47d240b6a --- /dev/null +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthAPITest.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.test.oauth.api; + +import io.supertokens.ProcessState; +import io.supertokens.featureflag.EE_FEATURES; +import io.supertokens.featureflag.FeatureFlagTestContent; +import io.supertokens.httpRequest.HttpResponseException; +import io.supertokens.oauth.OAuth; +import io.supertokens.oauth.exceptions.OAuthException; +import io.supertokens.pluginInterface.STORAGE_TYPE; +import io.supertokens.pluginInterface.exceptions.InvalidConfigException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.pluginInterface.oauth.OAuthAuthResponse; +import io.supertokens.pluginInterface.oauth.OAuthStorage; +import io.supertokens.storageLayer.StorageLayer; +import io.supertokens.test.TestingProcessManager; +import io.supertokens.test.Utils; +import io.supertokens.webserver.InputParser; +import org.junit.*; +import org.junit.rules.TestRule; + +import java.io.IOException; + +import static org.junit.Assert.assertNotNull; + +public class OAuthAPITest { + TestingProcessManager.TestingProcess process; + + @Rule + public TestRule watchman = Utils.getOnFailure(); + + @AfterClass + public static void afterTesting() { + Utils.afterTesting(); + } + + @Before + public void beforeEach() throws InterruptedException { + Utils.reset(); + String[] args = {"../"}; + + this.process = TestingProcessManager.start(args); + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.OAUTH}); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { //TODO check if this is true here also + return; + } + } + + @After + public void afterEach() throws InterruptedException { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void test() + throws StorageQueryException, OAuthException, HttpResponseException, TenantOrAppNotFoundException, + InvalidConfigException, IOException { + + String clientId = "a685663d-1b5d-4a70-b7f7-025ff2e2d7a4"; + String redirectUri = "http://localhost.com:3031/auth/callback/ory"; + String responseType = "code"; + String scope = "profile"; + String state = "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BDv%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD"; + + OAuthStorage oAuthStorage = (OAuthStorage) StorageLayer.getStorage(process.getProcess()); + + OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), null, oAuthStorage, clientId, redirectUri, responseType, scope, state); + + System.out.println(response); + System.out.println(response.redirectTo); + + for(String cooke : response.cookies){ + System.out.println(cooke); + } + + assertNotNull(response); + assertNotNull(response.redirectTo); + assertNotNull(response.cookies); + } + +} From b9c408a8bd3e338cdaecb745d6cd94b566e1af66 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Tue, 30 Jul 2024 11:19:10 +0200 Subject: [PATCH 06/30] fix: review fixes --- .../supertokens/httpRequest/HttpRequest.java | 2 +- .../java/io/supertokens/inmemorydb/Start.java | 3 +- src/main/java/io/supertokens/oauth/OAuth.java | 42 +++++++++++++------ ...Exception.java => OAuthAuthException.java} | 11 ++++- .../io/supertokens/webserver/Webserver.java | 4 +- .../{OAuthAPI.java => OAuthAuthAPI.java} | 33 +++++++-------- .../test/oauth/api/OAuthAPITest.java | 15 +++---- 7 files changed, 66 insertions(+), 44 deletions(-) rename src/main/java/io/supertokens/oauth/exceptions/{OAuthException.java => OAuthAuthException.java} (75%) rename src/main/java/io/supertokens/webserver/api/oauth/{OAuthAPI.java => OAuthAuthAPI.java} (78%) diff --git a/src/main/java/io/supertokens/httpRequest/HttpRequest.java b/src/main/java/io/supertokens/httpRequest/HttpRequest.java index 7ae1b5843..5f52d3acb 100644 --- a/src/main/java/io/supertokens/httpRequest/HttpRequest.java +++ b/src/main/java/io/supertokens/httpRequest/HttpRequest.java @@ -157,7 +157,7 @@ public static T sendGETRequestWithResponseHeaders(Main main, String requestI con.getHeaderFields().forEach((key, value) -> { if (key != null) { - responseHeaders.put(key, value.get(0)); + responseHeaders.put(key, value.get(0)); // TODO why the first element only? What happens with Set-Cookie headers? (Those are repeated if there are multiple cookies) } }); diff --git a/src/main/java/io/supertokens/inmemorydb/Start.java b/src/main/java/io/supertokens/inmemorydb/Start.java index c4b199d8c..589f525c1 100644 --- a/src/main/java/io/supertokens/inmemorydb/Start.java +++ b/src/main/java/io/supertokens/inmemorydb/Start.java @@ -56,6 +56,7 @@ import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.pluginInterface.multitenancy.sqlStorage.MultitenancySQLStorage; import io.supertokens.pluginInterface.oauth.OAuthStorage; +import io.supertokens.pluginInterface.oauth.sqlStorage.OAuthSQLStorage; import io.supertokens.pluginInterface.passwordless.PasswordlessCode; import io.supertokens.pluginInterface.passwordless.PasswordlessDevice; import io.supertokens.pluginInterface.passwordless.exception.*; @@ -103,7 +104,7 @@ public class Start implements SessionSQLStorage, EmailPasswordSQLStorage, EmailVerificationSQLStorage, ThirdPartySQLStorage, JWTRecipeSQLStorage, PasswordlessSQLStorage, UserMetadataSQLStorage, UserRolesSQLStorage, UserIdMappingStorage, UserIdMappingSQLStorage, MultitenancyStorage, MultitenancySQLStorage, TOTPSQLStorage, ActiveUsersStorage, - ActiveUsersSQLStorage, DashboardSQLStorage, AuthRecipeSQLStorage, OAuthStorage { + ActiveUsersSQLStorage, DashboardSQLStorage, AuthRecipeSQLStorage, OAuthSQLStorage { private static final Object appenderLock = new Object(); private static final String APP_ID_KEY_NAME = "app_id"; diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index 5304fbd18..9c191ef00 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -16,13 +16,11 @@ package io.supertokens.oauth; -import com.google.gson.JsonObject; import io.supertokens.Main; import io.supertokens.config.Config; import io.supertokens.httpRequest.HttpRequest; import io.supertokens.httpRequest.HttpResponseException; -import io.supertokens.multitenancy.Multitenancy; -import io.supertokens.oauth.exceptions.OAuthException; +import io.supertokens.oauth.exceptions.OAuthAuthException; import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.StorageUtils; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; @@ -42,10 +40,13 @@ public class OAuth { private static final String LOCATION_HEADER_NAME = "Location"; private static final String COOKIES_HEADER_NAME = "Set-Cookie"; + private static final String ERROR_LITERAL = "error="; + private static final String ERROR_DESCRIPTION_LITERAL = "error_description="; + public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier appIdentifier, Storage storage, String clientId, String redirectURI, String responseType, String scope, String state) - throws InvalidConfigException, HttpResponseException, IOException, OAuthException, StorageQueryException, + throws InvalidConfigException, HttpResponseException, IOException, OAuthAuthException, StorageQueryException, TenantOrAppNotFoundException { // TODO: // - validate that client_id is present for this tenant @@ -55,7 +56,7 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app String redirectTo = null; List cookies = null; - String publicOAuthProviderServiceUrl = Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl(); + String publicOAuthProviderServiceUrl = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOAuthProviderPublicServiceUrl(); if (!oauthStorage.doesClientIdExistForThisApp(appIdentifier, clientId)) { redirectTo = publicOAuthProviderServiceUrl + @@ -64,25 +65,30 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app "+method%29.+The+requested+OAuth+2.0+Client+does+not+exist."; } else { // we query hydra - Map queryParamsForHydra = constructHydraRequestParams(clientId, redirectURI, responseType, scope, state); + Map queryParamsForHydra = constructHydraRequestParamsForAuthorizationGETAPICall(clientId, redirectURI, responseType, scope, state); Map responseHeaders = new HashMap<>(); - HttpRequest.sendGETRequestWithResponseHeaders(main, null, Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl(), queryParamsForHydra, 20, 400, null, responseHeaders); // TODO is there some kind of config for the timeouts? + + //TODO maybe check response status code? Have to modify sendGetRequest.. for that + HttpRequest.sendGETRequestWithResponseHeaders(main, "", Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl(), queryParamsForHydra, 10000, 10000, null, responseHeaders); if(!responseHeaders.isEmpty() && responseHeaders.containsKey(LOCATION_HEADER_NAME)) { String locationHeaderValue = responseHeaders.get(LOCATION_HEADER_NAME); - if (locationHeaderValue.equals(publicOAuthProviderServiceUrl)){ - throw new OAuthException(); + if (locationHeaderValue.contains(publicOAuthProviderServiceUrl)){ + String error = getValueOfQueryParam(locationHeaderValue, ERROR_LITERAL); + String errorDescription = getValueOfQueryParam(locationHeaderValue, ERROR_DESCRIPTION_LITERAL); + throw new OAuthAuthException(error, errorDescription); } - if (locationHeaderValue.equals("localhost:3000")) { - redirectTo = Multitenancy.getAPIDomain(storage, appIdentifier); + if (locationHeaderValue.contains("localhost:3000")) { + redirectTo = locationHeaderValue.replace("localhost:3000", "{apiDomain}"); } else { redirectTo = locationHeaderValue; } } if(responseHeaders.containsKey(COOKIES_HEADER_NAME)){ String allCookies = responseHeaders.get(COOKIES_HEADER_NAME); + cookies = Arrays.asList(allCookies.split("; ")); } } @@ -90,8 +96,8 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app return new OAuthAuthResponse(redirectTo, cookies); } - private static Map constructHydraRequestParams(String clientId, - String redirectURI, String responseType, String scope, String state) { + private static Map constructHydraRequestParamsForAuthorizationGETAPICall(String clientId, + String redirectURI, String responseType, String scope, String state) { Map queryParamsForHydra = new HashMap<>(); queryParamsForHydra.put("clientId", clientId); queryParamsForHydra.put("redirectURI", redirectURI); @@ -100,4 +106,14 @@ private static Map constructHydraRequestParams(String clientId, queryParamsForHydra.put("state", state); return queryParamsForHydra; } + + private static String getValueOfQueryParam(String url, String queryParam){ + String valueOfQueryParam = ""; + if(!queryParam.endsWith("=")){ + queryParam = queryParam + "="; + } + int startIndex = url.indexOf(queryParam) + queryParam.length(); // start after the '=' sign + valueOfQueryParam = url.substring(startIndex, url.indexOf("&", startIndex)); // substring the url from the '=' to the next '&' + return valueOfQueryParam; + } } diff --git a/src/main/java/io/supertokens/oauth/exceptions/OAuthException.java b/src/main/java/io/supertokens/oauth/exceptions/OAuthAuthException.java similarity index 75% rename from src/main/java/io/supertokens/oauth/exceptions/OAuthException.java rename to src/main/java/io/supertokens/oauth/exceptions/OAuthAuthException.java index 2cc528d43..981947a0b 100644 --- a/src/main/java/io/supertokens/oauth/exceptions/OAuthException.java +++ b/src/main/java/io/supertokens/oauth/exceptions/OAuthAuthException.java @@ -16,6 +16,15 @@ package io.supertokens.oauth.exceptions; -public class OAuthException extends Exception{ +public class OAuthAuthException extends Exception{ private static final long serialVersionUID = 1836718299845759897L; + + public final String error; + public final String errorDescription; + + public OAuthAuthException(String error, String errorDescription) { + this.error = error; + this.errorDescription = errorDescription; + } + } diff --git a/src/main/java/io/supertokens/webserver/Webserver.java b/src/main/java/io/supertokens/webserver/Webserver.java index b5516db69..0401397be 100644 --- a/src/main/java/io/supertokens/webserver/Webserver.java +++ b/src/main/java/io/supertokens/webserver/Webserver.java @@ -39,7 +39,7 @@ import io.supertokens.webserver.api.multitenancy.*; import io.supertokens.webserver.api.multitenancy.thirdparty.CreateOrUpdateThirdPartyConfigAPI; import io.supertokens.webserver.api.multitenancy.thirdparty.RemoveThirdPartyConfigAPI; -import io.supertokens.webserver.api.oauth.OAuthAPI; +import io.supertokens.webserver.api.oauth.OAuthAuthAPI; import io.supertokens.webserver.api.passwordless.*; import io.supertokens.webserver.api.session.*; import io.supertokens.webserver.api.thirdparty.GetUsersByEmailAPI; @@ -268,7 +268,7 @@ private void setupRoutes() { addAPI(new RequestStatsAPI(main)); addAPI(new GetTenantCoreConfigForDashboardAPI(main)); - addAPI(new OAuthAPI(main)); + addAPI(new OAuthAuthAPI(main)); StandardContext context = tomcatReference.getContext(); Tomcat tomcat = tomcatReference.getTomcat(); diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java similarity index 78% rename from src/main/java/io/supertokens/webserver/api/oauth/OAuthAPI.java rename to src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java index 618dd55b0..f5b06362f 100644 --- a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAPI.java +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java @@ -16,20 +16,17 @@ package io.supertokens.webserver.api.oauth; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; +import com.google.gson.*; import io.supertokens.Main; import io.supertokens.httpRequest.HttpResponseException; +import io.supertokens.multitenancy.exception.BadPermissionException; import io.supertokens.oauth.OAuth; -import io.supertokens.oauth.exceptions.OAuthException; +import io.supertokens.oauth.exceptions.OAuthAuthException; import io.supertokens.pluginInterface.RECIPE_ID; import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; -import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.pluginInterface.oauth.OAuthAuthResponse; import io.supertokens.webserver.InputParser; @@ -37,22 +34,21 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.apache.catalina.Store; import java.io.IOException; import java.io.Serial; -public class OAuthAPI extends WebserverAPI { +public class OAuthAuthAPI extends WebserverAPI { @Serial private static final long serialVersionUID = -8734479943734920904L; - public OAuthAPI(Main main) { + public OAuthAuthAPI(Main main) { super(main, RECIPE_ID.OAUTH.toString()); } @Override public String getPath() { - return "oauth/auth"; + return "recipe/oauth/auth"; } @Override @@ -68,30 +64,33 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I try { AppIdentifier appIdentifier = getAppIdentifier(req); - Storage storage = getTenantStorage(req); + Storage storage = enforcePublicTenantAndGetPublicTenantStorage(req); OAuthAuthResponse authResponse = OAuth.getAuthorizationUrl(super.main, appIdentifier, storage, clientId, redirectUri, responseType, scope, state); JsonObject response = new JsonObject(); response.addProperty("redirectTo", authResponse.redirectTo); + JsonArray jsonCookies = new JsonArray(); if (authResponse.cookies != null) { - Gson gson = new Gson(); - String cookiesAsJson = gson.toJson(authResponse.cookies); - response.addProperty("cookies", cookiesAsJson); + for(String cookie : authResponse.cookies){ + jsonCookies.add(new JsonPrimitive(cookie)); + } } + response.add("cookies", jsonCookies); super.sendJsonResponse(200, response, resp); - } catch (OAuthException e) { + } catch (OAuthAuthException authException) { JsonObject errorResponse = new JsonObject(); - //TODO what is a good content here? + errorResponse.addProperty("error", authException.error); + errorResponse.addProperty("error_description", authException.errorDescription); super.sendJsonResponse(400, errorResponse, resp); } catch (TenantOrAppNotFoundException | InvalidConfigException | HttpResponseException | - StorageQueryException e) { + StorageQueryException | BadPermissionException e) { throw new ServletException(e); } } diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthAPITest.java index 47d240b6a..fecbd09fc 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthAPITest.java @@ -21,17 +21,17 @@ import io.supertokens.featureflag.FeatureFlagTestContent; import io.supertokens.httpRequest.HttpResponseException; import io.supertokens.oauth.OAuth; -import io.supertokens.oauth.exceptions.OAuthException; +import io.supertokens.oauth.exceptions.OAuthAuthException; import io.supertokens.pluginInterface.STORAGE_TYPE; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.pluginInterface.oauth.OAuthAuthResponse; import io.supertokens.pluginInterface.oauth.OAuthStorage; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.test.TestingProcessManager; import io.supertokens.test.Utils; -import io.supertokens.webserver.InputParser; import org.junit.*; import org.junit.rules.TestRule; @@ -72,9 +72,10 @@ public void afterEach() throws InterruptedException { assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); } + // TODO rename this! @Test - public void test() - throws StorageQueryException, OAuthException, HttpResponseException, TenantOrAppNotFoundException, + public void testHappyPath() + throws StorageQueryException, OAuthAuthException, HttpResponseException, TenantOrAppNotFoundException, InvalidConfigException, IOException { String clientId = "a685663d-1b5d-4a70-b7f7-025ff2e2d7a4"; @@ -85,15 +86,11 @@ public void test() OAuthStorage oAuthStorage = (OAuthStorage) StorageLayer.getStorage(process.getProcess()); - OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), null, oAuthStorage, clientId, redirectUri, responseType, scope, state); + OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier(null, null), oAuthStorage, clientId, redirectUri, responseType, scope, state); System.out.println(response); System.out.println(response.redirectTo); - for(String cooke : response.cookies){ - System.out.println(cooke); - } - assertNotNull(response); assertNotNull(response.redirectTo); assertNotNull(response.cookies); From 24523bd29ab3829cfb098c2478861210b486e033 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Tue, 30 Jul 2024 13:28:31 +0200 Subject: [PATCH 07/30] feat: tables for oauth in sqlite --- .../inmemorydb/config/SQLiteConfig.java | 10 ++ .../inmemorydb/queries/GeneralQueries.java | 25 ++++ .../inmemorydb/queries/OAuthQueries.java | 122 ++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java diff --git a/src/main/java/io/supertokens/inmemorydb/config/SQLiteConfig.java b/src/main/java/io/supertokens/inmemorydb/config/SQLiteConfig.java index ee8c43241..5b4edb08b 100644 --- a/src/main/java/io/supertokens/inmemorydb/config/SQLiteConfig.java +++ b/src/main/java/io/supertokens/inmemorydb/config/SQLiteConfig.java @@ -164,4 +164,14 @@ public String getDashboardUsersTable() { public String getDashboardSessionsTable() { return "dashboard_user_sessions"; } + + public String getOAuthClientTable(){ return "oauth_clients"; } + + public String getOAuthScopesTable() { return "oauth_scopes"; } + + public String getOAuthClientAllowedScopesTable() { return "oauth_client_allowed_scopes"; } + + public String getOAuthAuthcodeTable() { return "oauth_auth_codes"; } + + public String getOAuthTokenTable() { return "oauth_tokens"; } } diff --git a/src/main/java/io/supertokens/inmemorydb/queries/GeneralQueries.java b/src/main/java/io/supertokens/inmemorydb/queries/GeneralQueries.java index 57b11eafd..e303fa6e5 100644 --- a/src/main/java/io/supertokens/inmemorydb/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/inmemorydb/queries/GeneralQueries.java @@ -423,6 +423,31 @@ public static void createTablesIfNotExists(Start start, Main main) throws SQLExc update(start, TOTPQueries.getQueryToCreateUsedCodesExpiryTimeIndex(start), NO_OP_SETTER); } + if (!doesTableExists(start, Config.getConfig(start).getOAuthClientTable())) { + getInstance(main).addState(CREATING_NEW_TABLE, null); + update(start, OAuthQueries.getQueryToCreateOAuthClientTable(start), NO_OP_SETTER); + } + + if (!doesTableExists(start, Config.getConfig(start).getOAuthAuthcodeTable())) { + getInstance(main).addState(CREATING_NEW_TABLE, null); + update(start, OAuthQueries.getQueryToCreateOAuthAuthcodeTable(start), NO_OP_SETTER); + } + + if (!doesTableExists(start, Config.getConfig(start).getOAuthScopesTable())) { + getInstance(main).addState(CREATING_NEW_TABLE, null); + update(start, OAuthQueries.getQueryToCreateOAuthScopesTable(start), NO_OP_SETTER); + } + + if (!doesTableExists(start, Config.getConfig(start).getOAuthTokenTable())) { + getInstance(main).addState(CREATING_NEW_TABLE, null); + update(start, OAuthQueries.getQueryToCreateOAuthTokenTable(start), NO_OP_SETTER); + } + + if (!doesTableExists(start, Config.getConfig(start).getOAuthClientAllowedScopesTable())) { + getInstance(main).addState(CREATING_NEW_TABLE, null); + update(start, OAuthQueries.getQueryToCreateOAuthClientAllowedScopesTable(start), NO_OP_SETTER); + } + } diff --git a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java new file mode 100644 index 000000000..f76e39dd2 --- /dev/null +++ b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.inmemorydb.queries; + +import io.supertokens.inmemorydb.Start; +import io.supertokens.inmemorydb.config.Config; + +public class OAuthQueries { + + public static String getQueryToCreateOAuthClientTable(Start start) { + String oAuth2ClientTable = Config.getConfig(start).getOAuthClientTable(); + // @formatter:off + return "CREATE TABLE IF NOT EXISTS " + oAuth2ClientTable + " (" + + "app_id VARCHAR(64) DEFAULT 'public'," + + "client_id VARCHAR(128) NOT NULL," + + "name TEXT NOT NULL," + + "client_secret_hash VARCHAR(128) NOT NULL," + + "redirect_uris TEXT NOT NULL," + + "created_at_ms BIGINT NOT NULL," + + "updated_at_ms BIGINT NOT NULL," + + " PRIMARY KEY (app_id, client_id)," + + " FOREIGN KEY(app_id) REFERENCES " + Config.getConfig(start).getAppsTable() + "(app_id) ON DELETE CASCADE);"; + // @formatter:on + } + + public static String getQueryToCreateOAuthScopesTable(Start start) { + String oAuth2ScopesTable = Config.getConfig(start).getOAuthScopesTable(); + // @formatter:off + return "CREATE TABLE IF NOT EXISTS " + oAuth2ScopesTable + " (" + + "app_id VARCHAR(64) DEFAULT 'public'," + + "scope TEXT NOT NULL," + + " PRIMARY KEY (app_id, scope)," + + " FOREIGN KEY(app_id) REFERENCES " + Config.getConfig(start).getAppsTable() + + " (app_id) ON DELETE CASCADE);"; + // @formatter:on + } + + public static String getQueryToCreateOAuthClientAllowedScopesTable(Start start) { + String oAuth2ClientAllowedScopesTable = Config.getConfig(start).getOAuthClientAllowedScopesTable(); + // @formatter:off + return "CREATE TABLE IF NOT EXISTS " + oAuth2ClientAllowedScopesTable + " (" + + "app_id VARCHAR(64) DEFAULT 'public'," + + "client_id VARCHAR(128) NOT NULL," + + "scope TEXT NOT NULL," + + "requires_consent BOOLEAN NOT NULL," + + " PRIMARY KEY(app_id, client_id, scope)," + + " FOREIGN KEY(app_id,client_id) REFERENCES " + Config.getConfig(start).getOAuthClientTable() + + "(app_id, client_id) ON DELETE CASCADE," + + " FOREIGN KEY(app_id, scope) REFERENCES " + Config.getConfig(start).getOAuthScopesTable() + + "(app_id, scope) ON DELETE CASCADE);"; + // @formatter:on + } + + public static String getQueryToCreateOAuthAuthcodeTable(Start start) { + String oAuth2AuthcodeTable = Config.getConfig(start).getOAuthAuthcodeTable(); + // @formatter:off + return "CREATE TABLE IF NOT EXISTS " + oAuth2AuthcodeTable + " (" + + "app_id VARCHAR(64) DEFAULT 'public'," + + "tenant_id VARCHAR(64) DEFAULT 'public'," + + "authorization_code_hash VARCHAR(128) NOT NULL, " + + "session_handle VARCHAR(255) NOT NULL," + + "client_id VARCHAR(128) NOT NULL," + + "created_at_ms BIGINT NOT NULL," + + "expires_at_ms BIGINT NOT NULL," + + "scopes TEXT NOT NULL," + // In an ideal scenario, the 'scopes' field should be a foreign key referencing the 'scope' field in + // the 'oauth2_scope' table, + // containing an array of scopes. However, in this case, the scopes are not directly linked + // to the 'oauth2_scope' table's 'scope' field. + // This deliberate design choice ensures that any changes or deletions made to scopes in the 'oauth2_scope' table + // do not affect existing OAuth2 codes or cause unexpected disruptions in users' sessions. + + "redirect_uri TEXT NOT NULL," + + "access_type VARCHAR(10) NOT NULL," + + "code_challenge VARCHAR(128)," + + "code_challenge_method VARCHAR(10)," + + " PRIMARY KEY (app_id, tenant_id, authorization_code_hash)," + + " FOREIGN KEY(app_id, client_id) REFERENCES " + Config.getConfig(start).getOAuthClientTable() + + "(app_id, client_id) ON DELETE CASCADE," + + " FOREIGN KEY(app_id, tenant_id, session_handle) REFERENCES " + + Config.getConfig(start).getSessionInfoTable() + "(app_id, tenant_id, session_handle) ON DELETE CASCADE );"; + // @formatter:on + } + + public static String getQueryToCreateOAuthTokenTable(Start start) { + String oAuth2TokenTable = Config.getConfig(start).getOAuthTokenTable(); + // @formatter:off + return "CREATE TABLE IF NOT EXISTS " + oAuth2TokenTable + " (" + + "id CHAR(36) NOT NULL," + + "app_id VARCHAR(64) DEFAULT 'public'," + + "tenant_id VARCHAR(64) DEFAULT 'public'," + + "client_id VARCHAR(128) NOT NULL," + + "session_handle VARCHAR(255)," + + "scopes TEXT NOT NULL," + + "access_token_hash VARCHAR(128) NOT NULL, " + + "refresh_token_hash VARCHAR(128), " + + "created_at_ms BIGINT NOT NULL," + + "last_updated_at_ms BIGINT NOT NULL," + + "access_token_expires_at_ms BIGINT NOT NULL," + + "refresh_token_expires_at_ms BIGINT," + + " PRIMARY KEY (app_id, tenant_id, id)," + + " FOREIGN KEY(app_id, client_id) REFERENCES " + Config.getConfig(start).getOAuthClientTable() + + "(app_id, client_id) ON DELETE CASCADE," + + " FOREIGN KEY(app_id, tenant_id, session_handle) REFERENCES " + Config.getConfig(start).getSessionInfoTable() + + "(app_id, tenant_id, session_handle) ON DELETE CASCADE);"; + // @formatter:on + } + +} From c969ef1577b910ec63f6f17f0441561adee54023 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Tue, 30 Jul 2024 13:52:46 +0200 Subject: [PATCH 08/30] fix: remove unnecessary tables --- .../inmemorydb/config/SQLiteConfig.java | 8 -- .../inmemorydb/queries/GeneralQueries.java | 21 ----- .../inmemorydb/queries/OAuthQueries.java | 83 ------------------- 3 files changed, 112 deletions(-) diff --git a/src/main/java/io/supertokens/inmemorydb/config/SQLiteConfig.java b/src/main/java/io/supertokens/inmemorydb/config/SQLiteConfig.java index 5b4edb08b..bc969dc6f 100644 --- a/src/main/java/io/supertokens/inmemorydb/config/SQLiteConfig.java +++ b/src/main/java/io/supertokens/inmemorydb/config/SQLiteConfig.java @@ -166,12 +166,4 @@ public String getDashboardSessionsTable() { } public String getOAuthClientTable(){ return "oauth_clients"; } - - public String getOAuthScopesTable() { return "oauth_scopes"; } - - public String getOAuthClientAllowedScopesTable() { return "oauth_client_allowed_scopes"; } - - public String getOAuthAuthcodeTable() { return "oauth_auth_codes"; } - - public String getOAuthTokenTable() { return "oauth_tokens"; } } diff --git a/src/main/java/io/supertokens/inmemorydb/queries/GeneralQueries.java b/src/main/java/io/supertokens/inmemorydb/queries/GeneralQueries.java index e303fa6e5..665511467 100644 --- a/src/main/java/io/supertokens/inmemorydb/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/inmemorydb/queries/GeneralQueries.java @@ -427,27 +427,6 @@ public static void createTablesIfNotExists(Start start, Main main) throws SQLExc getInstance(main).addState(CREATING_NEW_TABLE, null); update(start, OAuthQueries.getQueryToCreateOAuthClientTable(start), NO_OP_SETTER); } - - if (!doesTableExists(start, Config.getConfig(start).getOAuthAuthcodeTable())) { - getInstance(main).addState(CREATING_NEW_TABLE, null); - update(start, OAuthQueries.getQueryToCreateOAuthAuthcodeTable(start), NO_OP_SETTER); - } - - if (!doesTableExists(start, Config.getConfig(start).getOAuthScopesTable())) { - getInstance(main).addState(CREATING_NEW_TABLE, null); - update(start, OAuthQueries.getQueryToCreateOAuthScopesTable(start), NO_OP_SETTER); - } - - if (!doesTableExists(start, Config.getConfig(start).getOAuthTokenTable())) { - getInstance(main).addState(CREATING_NEW_TABLE, null); - update(start, OAuthQueries.getQueryToCreateOAuthTokenTable(start), NO_OP_SETTER); - } - - if (!doesTableExists(start, Config.getConfig(start).getOAuthClientAllowedScopesTable())) { - getInstance(main).addState(CREATING_NEW_TABLE, null); - update(start, OAuthQueries.getQueryToCreateOAuthClientAllowedScopesTable(start), NO_OP_SETTER); - } - } diff --git a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java index f76e39dd2..3cb0c94b4 100644 --- a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java +++ b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java @@ -36,87 +36,4 @@ public static String getQueryToCreateOAuthClientTable(Start start) { + " FOREIGN KEY(app_id) REFERENCES " + Config.getConfig(start).getAppsTable() + "(app_id) ON DELETE CASCADE);"; // @formatter:on } - - public static String getQueryToCreateOAuthScopesTable(Start start) { - String oAuth2ScopesTable = Config.getConfig(start).getOAuthScopesTable(); - // @formatter:off - return "CREATE TABLE IF NOT EXISTS " + oAuth2ScopesTable + " (" - + "app_id VARCHAR(64) DEFAULT 'public'," - + "scope TEXT NOT NULL," - + " PRIMARY KEY (app_id, scope)," - + " FOREIGN KEY(app_id) REFERENCES " + Config.getConfig(start).getAppsTable() + - " (app_id) ON DELETE CASCADE);"; - // @formatter:on - } - - public static String getQueryToCreateOAuthClientAllowedScopesTable(Start start) { - String oAuth2ClientAllowedScopesTable = Config.getConfig(start).getOAuthClientAllowedScopesTable(); - // @formatter:off - return "CREATE TABLE IF NOT EXISTS " + oAuth2ClientAllowedScopesTable + " (" - + "app_id VARCHAR(64) DEFAULT 'public'," - + "client_id VARCHAR(128) NOT NULL," - + "scope TEXT NOT NULL," - + "requires_consent BOOLEAN NOT NULL," - + " PRIMARY KEY(app_id, client_id, scope)," - + " FOREIGN KEY(app_id,client_id) REFERENCES " + Config.getConfig(start).getOAuthClientTable() - + "(app_id, client_id) ON DELETE CASCADE," - + " FOREIGN KEY(app_id, scope) REFERENCES " + Config.getConfig(start).getOAuthScopesTable() - + "(app_id, scope) ON DELETE CASCADE);"; - // @formatter:on - } - - public static String getQueryToCreateOAuthAuthcodeTable(Start start) { - String oAuth2AuthcodeTable = Config.getConfig(start).getOAuthAuthcodeTable(); - // @formatter:off - return "CREATE TABLE IF NOT EXISTS " + oAuth2AuthcodeTable + " (" - + "app_id VARCHAR(64) DEFAULT 'public'," - + "tenant_id VARCHAR(64) DEFAULT 'public'," - + "authorization_code_hash VARCHAR(128) NOT NULL, " - + "session_handle VARCHAR(255) NOT NULL," - + "client_id VARCHAR(128) NOT NULL," - + "created_at_ms BIGINT NOT NULL," - + "expires_at_ms BIGINT NOT NULL," - + "scopes TEXT NOT NULL," - // In an ideal scenario, the 'scopes' field should be a foreign key referencing the 'scope' field in - // the 'oauth2_scope' table, - // containing an array of scopes. However, in this case, the scopes are not directly linked - // to the 'oauth2_scope' table's 'scope' field. - // This deliberate design choice ensures that any changes or deletions made to scopes in the 'oauth2_scope' table - // do not affect existing OAuth2 codes or cause unexpected disruptions in users' sessions. - + "redirect_uri TEXT NOT NULL," - + "access_type VARCHAR(10) NOT NULL," - + "code_challenge VARCHAR(128)," - + "code_challenge_method VARCHAR(10)," - + " PRIMARY KEY (app_id, tenant_id, authorization_code_hash)," - + " FOREIGN KEY(app_id, client_id) REFERENCES " + Config.getConfig(start).getOAuthClientTable() - + "(app_id, client_id) ON DELETE CASCADE," - + " FOREIGN KEY(app_id, tenant_id, session_handle) REFERENCES " - + Config.getConfig(start).getSessionInfoTable() + "(app_id, tenant_id, session_handle) ON DELETE CASCADE );"; - // @formatter:on - } - - public static String getQueryToCreateOAuthTokenTable(Start start) { - String oAuth2TokenTable = Config.getConfig(start).getOAuthTokenTable(); - // @formatter:off - return "CREATE TABLE IF NOT EXISTS " + oAuth2TokenTable + " (" - + "id CHAR(36) NOT NULL," - + "app_id VARCHAR(64) DEFAULT 'public'," - + "tenant_id VARCHAR(64) DEFAULT 'public'," - + "client_id VARCHAR(128) NOT NULL," - + "session_handle VARCHAR(255)," - + "scopes TEXT NOT NULL," - + "access_token_hash VARCHAR(128) NOT NULL, " - + "refresh_token_hash VARCHAR(128), " - + "created_at_ms BIGINT NOT NULL," - + "last_updated_at_ms BIGINT NOT NULL," - + "access_token_expires_at_ms BIGINT NOT NULL," - + "refresh_token_expires_at_ms BIGINT," - + " PRIMARY KEY (app_id, tenant_id, id)," - + " FOREIGN KEY(app_id, client_id) REFERENCES " + Config.getConfig(start).getOAuthClientTable() - + "(app_id, client_id) ON DELETE CASCADE," - + " FOREIGN KEY(app_id, tenant_id, session_handle) REFERENCES " + Config.getConfig(start).getSessionInfoTable() - + "(app_id, tenant_id, session_handle) ON DELETE CASCADE);"; - // @formatter:on - } - } From c03d6c4d29f43d5a2738506639dc3853f8536d73 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Tue, 30 Jul 2024 13:53:51 +0200 Subject: [PATCH 09/30] fix: store only the necessary data in the client table --- .../java/io/supertokens/inmemorydb/queries/OAuthQueries.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java index 3cb0c94b4..8c224f15c 100644 --- a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java +++ b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java @@ -27,11 +27,6 @@ public static String getQueryToCreateOAuthClientTable(Start start) { return "CREATE TABLE IF NOT EXISTS " + oAuth2ClientTable + " (" + "app_id VARCHAR(64) DEFAULT 'public'," + "client_id VARCHAR(128) NOT NULL," - + "name TEXT NOT NULL," - + "client_secret_hash VARCHAR(128) NOT NULL," - + "redirect_uris TEXT NOT NULL," - + "created_at_ms BIGINT NOT NULL," - + "updated_at_ms BIGINT NOT NULL," + " PRIMARY KEY (app_id, client_id)," + " FOREIGN KEY(app_id) REFERENCES " + Config.getConfig(start).getAppsTable() + "(app_id) ON DELETE CASCADE);"; // @formatter:on From c740082acefadc6ac10923fd84c4e4933dc2ecc6 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Tue, 30 Jul 2024 16:23:37 +0200 Subject: [PATCH 10/30] feat: oauth client - app exists check in db, a few tests --- .../supertokens/httpRequest/HttpRequest.java | 2 +- .../java/io/supertokens/inmemorydb/Start.java | 19 ++- .../inmemorydb/queries/OAuthQueries.java | 33 ++++ src/main/java/io/supertokens/oauth/OAuth.java | 36 ++--- .../test/oauth/api/OAuthAPITest.java | 99 ------------ .../test/oauth/api/OAuthAuthAPITest.java | 152 ++++++++++++++++++ 6 files changed, 219 insertions(+), 122 deletions(-) delete mode 100644 src/test/java/io/supertokens/test/oauth/api/OAuthAPITest.java create mode 100644 src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java diff --git a/src/main/java/io/supertokens/httpRequest/HttpRequest.java b/src/main/java/io/supertokens/httpRequest/HttpRequest.java index 5f52d3acb..201b5dfba 100644 --- a/src/main/java/io/supertokens/httpRequest/HttpRequest.java +++ b/src/main/java/io/supertokens/httpRequest/HttpRequest.java @@ -152,7 +152,7 @@ public static T sendGETRequestWithResponseHeaders(Main main, String requestI if (version != null) { con.setRequestProperty("api-version", version + ""); } - + con.setInstanceFollowRedirects(false); int responseCode = con.getResponseCode(); con.getHeaderFields().forEach((key, value) -> { diff --git a/src/main/java/io/supertokens/inmemorydb/Start.java b/src/main/java/io/supertokens/inmemorydb/Start.java index 589f525c1..dcf3c868b 100644 --- a/src/main/java/io/supertokens/inmemorydb/Start.java +++ b/src/main/java/io/supertokens/inmemorydb/Start.java @@ -55,7 +55,6 @@ import io.supertokens.pluginInterface.multitenancy.exceptions.DuplicateThirdPartyIdException; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.pluginInterface.multitenancy.sqlStorage.MultitenancySQLStorage; -import io.supertokens.pluginInterface.oauth.OAuthStorage; import io.supertokens.pluginInterface.oauth.sqlStorage.OAuthSQLStorage; import io.supertokens.pluginInterface.passwordless.PasswordlessCode; import io.supertokens.pluginInterface.passwordless.PasswordlessDevice; @@ -3011,7 +3010,21 @@ public int countUsersThatHaveMoreThanOneLoginMethodOrTOTPEnabledAndActiveSince(A } @Override - public boolean doesClientIdExistForThisApp(AppIdentifier appIdentifier, String clientId) { - return false; + public boolean doesClientIdExistForThisApp(AppIdentifier appIdentifier, String clientId) + throws StorageQueryException { + try { + return OAuthQueries.isClientIdForAppId(this, clientId, appIdentifier); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + @Override + public void addClientForApp(AppIdentifier appIdentifier, String clientId) throws StorageQueryException { + try { + OAuthQueries.insertClientIdForAppId(this, clientId, appIdentifier); + } catch (SQLException e) { + throw new StorageQueryException(e); + } } } diff --git a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java index 8c224f15c..72a5f3b10 100644 --- a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java +++ b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java @@ -18,6 +18,17 @@ import io.supertokens.inmemorydb.Start; import io.supertokens.inmemorydb.config.Config; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.AppIdentifier; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import static io.supertokens.inmemorydb.QueryExecutorTemplate.execute; +import static io.supertokens.inmemorydb.QueryExecutorTemplate.update; public class OAuthQueries { @@ -31,4 +42,26 @@ public static String getQueryToCreateOAuthClientTable(Start start) { + " FOREIGN KEY(app_id) REFERENCES " + Config.getConfig(start).getAppsTable() + "(app_id) ON DELETE CASCADE);"; // @formatter:on } + + public static boolean isClientIdForAppId(Start start, String clientId, AppIdentifier appIdentifier) + throws SQLException, StorageQueryException { + String QUERY = "SELECT app_id FROM " + Config.getConfig(start).getOAuthClientTable() + + " WHERE client_id = ? AND app_id = ?"; + + return execute(start, QUERY, pst -> { + pst.setString(1, clientId); + pst.setString(2, appIdentifier.getAppId()); + }, ResultSet::next); + } + + public static void insertClientIdForAppId(Start start, String clientId, AppIdentifier appIdentifier) + throws SQLException, StorageQueryException { + String INSERT = "INSERT INTO " + Config.getConfig(start).getOAuthClientTable() + + "(app_id, client_id) VALUES(?, ?)"; + update(start, INSERT, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, clientId); + }); + } + } diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index 9c191ef00..39fe65ee4 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -31,10 +31,9 @@ import io.supertokens.pluginInterface.oauth.OAuthStorage; import java.io.IOException; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.*; public class OAuth { @@ -48,8 +47,6 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app String redirectURI, String responseType, String scope, String state) throws InvalidConfigException, HttpResponseException, IOException, OAuthAuthException, StorageQueryException, TenantOrAppNotFoundException { - // TODO: - // - validate that client_id is present for this tenant OAuthStorage oauthStorage = StorageUtils.getOAuthStorage(storage); @@ -59,17 +56,14 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app String publicOAuthProviderServiceUrl = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOAuthProviderPublicServiceUrl(); if (!oauthStorage.doesClientIdExistForThisApp(appIdentifier, clientId)) { - redirectTo = publicOAuthProviderServiceUrl + - "/oauth2/fallbacks/error?error=invalid_client&error_description=Client+authentication+failed+%28e" + - ".g.%2C+unknown+client%2C+no+client+authentication+included%2C+or+unsupported+authentication" + - "+method%29.+The+requested+OAuth+2.0+Client+does+not+exist."; + throw new OAuthAuthException("invalid_client", "Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). The requested OAuth 2.0 Client does not exist."); } else { // we query hydra Map queryParamsForHydra = constructHydraRequestParamsForAuthorizationGETAPICall(clientId, redirectURI, responseType, scope, state); Map responseHeaders = new HashMap<>(); //TODO maybe check response status code? Have to modify sendGetRequest.. for that - HttpRequest.sendGETRequestWithResponseHeaders(main, "", Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl(), queryParamsForHydra, 10000, 10000, null, responseHeaders); + HttpRequest.sendGETRequestWithResponseHeaders(main, "", Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl() + "/oauth2/auth", queryParamsForHydra, 10000, 10000, null, responseHeaders); if(!responseHeaders.isEmpty() && responseHeaders.containsKey(LOCATION_HEADER_NAME)) { String locationHeaderValue = responseHeaders.get(LOCATION_HEADER_NAME); @@ -80,8 +74,8 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app throw new OAuthAuthException(error, errorDescription); } - if (locationHeaderValue.contains("localhost:3000")) { - redirectTo = locationHeaderValue.replace("localhost:3000", "{apiDomain}"); + if (locationHeaderValue.contains("localhost:3000") || locationHeaderValue.contains("127.0.0.1:3000")) { + redirectTo = locationHeaderValue.replace("localhost:3000", "{apiDomain}").replace("127.0.0.1:3000", "{apiDomain}"); } else { redirectTo = locationHeaderValue; } @@ -89,7 +83,7 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app if(responseHeaders.containsKey(COOKIES_HEADER_NAME)){ String allCookies = responseHeaders.get(COOKIES_HEADER_NAME); - cookies = Arrays.asList(allCookies.split("; ")); + cookies = Collections.singletonList(allCookies); } } @@ -99,10 +93,10 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app private static Map constructHydraRequestParamsForAuthorizationGETAPICall(String clientId, String redirectURI, String responseType, String scope, String state) { Map queryParamsForHydra = new HashMap<>(); - queryParamsForHydra.put("clientId", clientId); - queryParamsForHydra.put("redirectURI", redirectURI); + queryParamsForHydra.put("client_id", clientId); + queryParamsForHydra.put("redirect_uri", redirectURI); queryParamsForHydra.put("scope", scope); - queryParamsForHydra.put("responseType", responseType); + queryParamsForHydra.put("response_type", responseType); queryParamsForHydra.put("state", state); return queryParamsForHydra; } @@ -113,7 +107,11 @@ private static String getValueOfQueryParam(String url, String queryParam){ queryParam = queryParam + "="; } int startIndex = url.indexOf(queryParam) + queryParam.length(); // start after the '=' sign - valueOfQueryParam = url.substring(startIndex, url.indexOf("&", startIndex)); // substring the url from the '=' to the next '&' - return valueOfQueryParam; + int endIndex = url.indexOf("&", startIndex); + if (endIndex == -1){ + endIndex = url.length(); + } + valueOfQueryParam = url.substring(startIndex, endIndex); // substring the url from the '=' to the next '&' or to the end of the url if there are no more &s + return URLDecoder.decode(valueOfQueryParam, StandardCharsets.UTF_8); } } diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthAPITest.java deleted file mode 100644 index fecbd09fc..000000000 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthAPITest.java +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. - * - * This software is licensed under the Apache License, Version 2.0 (the - * "License") as published by the Apache Software Foundation. - * - * You may not use this file except in compliance with the License. You may - * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the - * License for the specific language governing permissions and limitations - * under the License. - */ - -package io.supertokens.test.oauth.api; - -import io.supertokens.ProcessState; -import io.supertokens.featureflag.EE_FEATURES; -import io.supertokens.featureflag.FeatureFlagTestContent; -import io.supertokens.httpRequest.HttpResponseException; -import io.supertokens.oauth.OAuth; -import io.supertokens.oauth.exceptions.OAuthAuthException; -import io.supertokens.pluginInterface.STORAGE_TYPE; -import io.supertokens.pluginInterface.exceptions.InvalidConfigException; -import io.supertokens.pluginInterface.exceptions.StorageQueryException; -import io.supertokens.pluginInterface.multitenancy.AppIdentifier; -import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; -import io.supertokens.pluginInterface.oauth.OAuthAuthResponse; -import io.supertokens.pluginInterface.oauth.OAuthStorage; -import io.supertokens.storageLayer.StorageLayer; -import io.supertokens.test.TestingProcessManager; -import io.supertokens.test.Utils; -import org.junit.*; -import org.junit.rules.TestRule; - -import java.io.IOException; - -import static org.junit.Assert.assertNotNull; - -public class OAuthAPITest { - TestingProcessManager.TestingProcess process; - - @Rule - public TestRule watchman = Utils.getOnFailure(); - - @AfterClass - public static void afterTesting() { - Utils.afterTesting(); - } - - @Before - public void beforeEach() throws InterruptedException { - Utils.reset(); - String[] args = {"../"}; - - this.process = TestingProcessManager.start(args); - FeatureFlagTestContent.getInstance(process.getProcess()) - .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.OAUTH}); - process.startProcess(); - assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); - - if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { //TODO check if this is true here also - return; - } - } - - @After - public void afterEach() throws InterruptedException { - process.kill(); - assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); - } - - // TODO rename this! - @Test - public void testHappyPath() - throws StorageQueryException, OAuthAuthException, HttpResponseException, TenantOrAppNotFoundException, - InvalidConfigException, IOException { - - String clientId = "a685663d-1b5d-4a70-b7f7-025ff2e2d7a4"; - String redirectUri = "http://localhost.com:3031/auth/callback/ory"; - String responseType = "code"; - String scope = "profile"; - String state = "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BDv%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD"; - - OAuthStorage oAuthStorage = (OAuthStorage) StorageLayer.getStorage(process.getProcess()); - - OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier(null, null), oAuthStorage, clientId, redirectUri, responseType, scope, state); - - System.out.println(response); - System.out.println(response.redirectTo); - - assertNotNull(response); - assertNotNull(response.redirectTo); - assertNotNull(response.cookies); - } - -} diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java new file mode 100644 index 000000000..9a4bb346e --- /dev/null +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.test.oauth.api; + +import io.supertokens.ProcessState; +import io.supertokens.featureflag.EE_FEATURES; +import io.supertokens.featureflag.FeatureFlagTestContent; +import io.supertokens.httpRequest.HttpResponseException; +import io.supertokens.oauth.OAuth; +import io.supertokens.oauth.exceptions.OAuthAuthException; +import io.supertokens.pluginInterface.STORAGE_TYPE; +import io.supertokens.pluginInterface.exceptions.InvalidConfigException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.AppIdentifier; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.pluginInterface.oauth.OAuthAuthResponse; +import io.supertokens.pluginInterface.oauth.sqlStorage.OAuthSQLStorage; +import io.supertokens.storageLayer.StorageLayer; +import io.supertokens.test.TestingProcessManager; +import io.supertokens.test.Utils; +import org.junit.*; +import org.junit.rules.TestRule; + +import java.io.IOException; +import java.util.Collections; + +import static org.junit.Assert.*; + +public class OAuthAuthAPITest { + TestingProcessManager.TestingProcess process; + + @Rule + public TestRule watchman = Utils.getOnFailure(); + + @AfterClass + public static void afterTesting() { + Utils.afterTesting(); + } + + @Before + public void beforeEach() throws InterruptedException { + Utils.reset(); + String[] args = {"../"}; + + this.process = TestingProcessManager.start(args); + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.OAUTH}); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + } + + @After + public void afterEach() throws InterruptedException { + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + + @Test + public void testLocalhostChangedToApiDomain() + throws StorageQueryException, OAuthAuthException, HttpResponseException, TenantOrAppNotFoundException, + InvalidConfigException, IOException { + + String clientId = "6030f07e-c8ef-4289-80c9-c18e0bf4f679"; + String redirectUri = "http://localhost.com:3031/auth/callback/ory"; + String responseType = "code"; + String scope = "profile"; + String state = "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BDv%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD"; + + OAuthSQLStorage oAuthStorage = (OAuthSQLStorage) StorageLayer.getStorage(process.getProcess()); + + AppIdentifier testApp = new AppIdentifier("", ""); + oAuthStorage.addClientForApp(testApp, clientId); + + OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), oAuthStorage, clientId, redirectUri, responseType, scope, state); + + assertNotNull(response); + assertNotNull(response.redirectTo); + assertNotNull(response.cookies); + + assertTrue(response.redirectTo.startsWith("http://{apiDomain}/login?login_challenge=")); + assertTrue(response.cookies.get(0).startsWith("ory_hydra_login_csrf_dev_134972871=")); + } + + @Test + public void testCalledWithWrongClientIdNotInST_exceptionThrown() + throws StorageQueryException { + + String clientId = "Not-Existing-In-Client-App-Table"; + String redirectUri = "http://localhost.com:3031/auth/callback/ory"; + String responseType = "code"; + String scope = "profile"; + String state = "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BDv%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD"; + + OAuthSQLStorage oAuthStorage = (OAuthSQLStorage) StorageLayer.getStorage(process.getProcess()); + + AppIdentifier testApp = new AppIdentifier("", ""); + oAuthStorage.addClientForApp(testApp, clientId); + + OAuthAuthException thrown = assertThrows(OAuthAuthException.class, () -> { + + OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), oAuthStorage, clientId, redirectUri, responseType, scope, state); + }); + + String expectedError = "invalid_client"; + String expectedDescription = "Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). The requested OAuth 2.0 Client does not exist."; + + assertEquals(expectedError, thrown.error); + assertEquals(expectedDescription, thrown.errorDescription); + } + + @Test + public void testCalledWithWrongClientIdNotInHydraButInST_exceptionThrown() + throws StorageQueryException { + + String clientId = "6030f07e-c8ef-4289-80c9-c18e0bf4f679NotInHydra"; + String redirectUri = "http://localhost.com:3031/auth/callback/ory"; + String responseType = "code"; + String scope = "profile"; + String state = "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BDv%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD"; + + OAuthSQLStorage oAuthStorage = (OAuthSQLStorage) StorageLayer.getStorage(process.getProcess()); + + AppIdentifier testApp = new AppIdentifier("", ""); + oAuthStorage.addClientForApp(testApp, clientId); + + OAuthAuthException thrown = assertThrows(OAuthAuthException.class, () -> { + + OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), oAuthStorage, clientId, redirectUri, responseType, scope, state); + }); + + String expectedError = "invalid_client"; + String expectedDescription = "Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). The requested OAuth 2.0 Client does not exist."; + + assertEquals(expectedError, thrown.error); + assertEquals(expectedDescription, thrown.errorDescription); + } + +} From c4c64381e6bc9f248d5e449f510916a2dd396b4b Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Wed, 31 Jul 2024 08:58:30 +0200 Subject: [PATCH 11/30] fix: review fixes --- .../inmemorydb/queries/OAuthQueries.java | 2 +- src/main/java/io/supertokens/oauth/OAuth.java | 1 - .../supertokens/oauth/OAuthAuthResponse.java | 34 +++++++++++++++++++ .../webserver/api/oauth/OAuthAuthAPI.java | 4 +-- .../test/oauth/api/OAuthAuthAPITest.java | 4 +-- 5 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 src/main/java/io/supertokens/oauth/OAuthAuthResponse.java diff --git a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java index 72a5f3b10..d3f68f1fa 100644 --- a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java +++ b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java @@ -36,7 +36,7 @@ public static String getQueryToCreateOAuthClientTable(Start start) { String oAuth2ClientTable = Config.getConfig(start).getOAuthClientTable(); // @formatter:off return "CREATE TABLE IF NOT EXISTS " + oAuth2ClientTable + " (" - + "app_id VARCHAR(64) DEFAULT 'public'," + + "app_id VARCHAR(64)," + "client_id VARCHAR(128) NOT NULL," + " PRIMARY KEY (app_id, client_id)," + " FOREIGN KEY(app_id) REFERENCES " + Config.getConfig(start).getAppsTable() + "(app_id) ON DELETE CASCADE);"; diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index 39fe65ee4..5111c5787 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -27,7 +27,6 @@ import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; -import io.supertokens.pluginInterface.oauth.OAuthAuthResponse; import io.supertokens.pluginInterface.oauth.OAuthStorage; import java.io.IOException; diff --git a/src/main/java/io/supertokens/oauth/OAuthAuthResponse.java b/src/main/java/io/supertokens/oauth/OAuthAuthResponse.java new file mode 100644 index 000000000..c3f42b143 --- /dev/null +++ b/src/main/java/io/supertokens/oauth/OAuthAuthResponse.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.oauth; + +import java.util.List; + +public class OAuthAuthResponse { + public final String redirectTo; + public final List cookies; + + public OAuthAuthResponse(String redirectTo, List cookies) { + this.redirectTo = redirectTo; + this.cookies = cookies; + } + + @Override + public String toString() { + return "redirectTo: " + redirectTo; + } +} diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java index f5b06362f..8dd654000 100644 --- a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java @@ -28,7 +28,7 @@ import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; -import io.supertokens.pluginInterface.oauth.OAuthAuthResponse; +import io.supertokens.oauth.OAuthAuthResponse; import io.supertokens.webserver.InputParser; import io.supertokens.webserver.WebserverAPI; import jakarta.servlet.ServletException; @@ -48,7 +48,7 @@ public OAuthAuthAPI(Main main) { @Override public String getPath() { - return "recipe/oauth/auth"; + return "/recipe/oauth/auth"; } @Override diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java index 9a4bb346e..ce75e366b 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java @@ -22,12 +22,11 @@ import io.supertokens.httpRequest.HttpResponseException; import io.supertokens.oauth.OAuth; import io.supertokens.oauth.exceptions.OAuthAuthException; -import io.supertokens.pluginInterface.STORAGE_TYPE; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; -import io.supertokens.pluginInterface.oauth.OAuthAuthResponse; +import io.supertokens.oauth.OAuthAuthResponse; import io.supertokens.pluginInterface.oauth.sqlStorage.OAuthSQLStorage; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.test.TestingProcessManager; @@ -36,7 +35,6 @@ import org.junit.rules.TestRule; import java.io.IOException; -import java.util.Collections; import static org.junit.Assert.*; From b84e90186c80a93f905c662d0486c0fcfe25f085 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Wed, 31 Jul 2024 09:27:55 +0200 Subject: [PATCH 12/30] fix: review fixes --- .../java/io/supertokens/httpRequest/HttpRequest.java | 4 ++-- src/main/java/io/supertokens/inmemorydb/Start.java | 12 +++++++++++- src/main/java/io/supertokens/oauth/OAuth.java | 2 +- .../java/io/supertokens/test/JWKSPublicAPITest.java | 4 ++-- .../supertokens/test/oauth/api/OAuthAuthAPITest.java | 7 ++++--- 5 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/main/java/io/supertokens/httpRequest/HttpRequest.java b/src/main/java/io/supertokens/httpRequest/HttpRequest.java index 201b5dfba..2bb76f401 100644 --- a/src/main/java/io/supertokens/httpRequest/HttpRequest.java +++ b/src/main/java/io/supertokens/httpRequest/HttpRequest.java @@ -126,7 +126,7 @@ public static T sendGETRequest(Main main, String requestID, String url, Map< public static T sendGETRequestWithResponseHeaders(Main main, String requestID, String url, Map params, int connectionTimeoutMS, int readTimeoutMS, Integer version, - Map responseHeaders) + Map responseHeaders, boolean followRedirects) throws IOException, HttpResponseException { StringBuilder paramBuilder = new StringBuilder(); @@ -152,7 +152,7 @@ public static T sendGETRequestWithResponseHeaders(Main main, String requestI if (version != null) { con.setRequestProperty("api-version", version + ""); } - con.setInstanceFollowRedirects(false); + con.setInstanceFollowRedirects(followRedirects); int responseCode = con.getResponseCode(); con.getHeaderFields().forEach((key, value) -> { diff --git a/src/main/java/io/supertokens/inmemorydb/Start.java b/src/main/java/io/supertokens/inmemorydb/Start.java index dcf3c868b..412f00f0e 100644 --- a/src/main/java/io/supertokens/inmemorydb/Start.java +++ b/src/main/java/io/supertokens/inmemorydb/Start.java @@ -55,6 +55,7 @@ import io.supertokens.pluginInterface.multitenancy.exceptions.DuplicateThirdPartyIdException; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.pluginInterface.multitenancy.sqlStorage.MultitenancySQLStorage; +import io.supertokens.pluginInterface.oauth.exceptions.ClientAlreadyExistsForAppException; import io.supertokens.pluginInterface.oauth.sqlStorage.OAuthSQLStorage; import io.supertokens.pluginInterface.passwordless.PasswordlessCode; import io.supertokens.pluginInterface.passwordless.PasswordlessDevice; @@ -3020,10 +3021,19 @@ public boolean doesClientIdExistForThisApp(AppIdentifier appIdentifier, String c } @Override - public void addClientForApp(AppIdentifier appIdentifier, String clientId) throws StorageQueryException { + public void addClientForApp(AppIdentifier appIdentifier, String clientId) + throws StorageQueryException, ClientAlreadyExistsForAppException { try { OAuthQueries.insertClientIdForAppId(this, clientId, appIdentifier); } catch (SQLException e) { + + SQLiteConfig config = Config.getConfig(this); + String serverErrorMessage = e.getMessage(); + + if (isPrimaryKeyError(serverErrorMessage, config.getOAuthClientTable(), + new String[]{"app_id", "client_id"})) { + throw new ClientAlreadyExistsForAppException(); + } throw new StorageQueryException(e); } } diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index 5111c5787..fdab97b5d 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -62,7 +62,7 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app Map responseHeaders = new HashMap<>(); //TODO maybe check response status code? Have to modify sendGetRequest.. for that - HttpRequest.sendGETRequestWithResponseHeaders(main, "", Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl() + "/oauth2/auth", queryParamsForHydra, 10000, 10000, null, responseHeaders); + HttpRequest.sendGETRequestWithResponseHeaders(main, "", Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl() + "/oauth2/auth", queryParamsForHydra, 10000, 10000, null, responseHeaders, false); if(!responseHeaders.isEmpty() && responseHeaders.containsKey(LOCATION_HEADER_NAME)) { String locationHeaderValue = responseHeaders.get(LOCATION_HEADER_NAME); diff --git a/src/test/java/io/supertokens/test/JWKSPublicAPITest.java b/src/test/java/io/supertokens/test/JWKSPublicAPITest.java index fd9cc53d4..e6ba51721 100644 --- a/src/test/java/io/supertokens/test/JWKSPublicAPITest.java +++ b/src/test/java/io/supertokens/test/JWKSPublicAPITest.java @@ -82,7 +82,7 @@ public void testCacheControlValue() throws Exception { Map responseHeaders = new HashMap<>(); JsonObject response = HttpRequest.sendGETRequestWithResponseHeaders(process.getProcess(), "", "http://localhost:3567/.well-known/jwks.json", null, - 1000, 1000, null, responseHeaders); + 1000, 1000, null, responseHeaders, true); assertEquals(response.entrySet().size(), 1); @@ -97,7 +97,7 @@ public void testCacheControlValue() throws Exception { response = HttpRequest.sendGETRequestWithResponseHeaders(process.getProcess(), "", "http://localhost:3567/.well-known/jwks.json", null, - 1000, 1000, null, responseHeaders); + 1000, 1000, null, responseHeaders, true); assertEquals(response.entrySet().size(), 1); diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java index ce75e366b..8cf0caf7c 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java @@ -27,6 +27,7 @@ import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.oauth.OAuthAuthResponse; +import io.supertokens.pluginInterface.oauth.exceptions.ClientAlreadyExistsForAppException; import io.supertokens.pluginInterface.oauth.sqlStorage.OAuthSQLStorage; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.test.TestingProcessManager; @@ -70,7 +71,7 @@ public void afterEach() throws InterruptedException { @Test public void testLocalhostChangedToApiDomain() throws StorageQueryException, OAuthAuthException, HttpResponseException, TenantOrAppNotFoundException, - InvalidConfigException, IOException { + InvalidConfigException, IOException, ClientAlreadyExistsForAppException { String clientId = "6030f07e-c8ef-4289-80c9-c18e0bf4f679"; String redirectUri = "http://localhost.com:3031/auth/callback/ory"; @@ -95,7 +96,7 @@ public void testLocalhostChangedToApiDomain() @Test public void testCalledWithWrongClientIdNotInST_exceptionThrown() - throws StorageQueryException { + throws StorageQueryException, ClientAlreadyExistsForAppException { String clientId = "Not-Existing-In-Client-App-Table"; String redirectUri = "http://localhost.com:3031/auth/callback/ory"; @@ -122,7 +123,7 @@ public void testCalledWithWrongClientIdNotInST_exceptionThrown() @Test public void testCalledWithWrongClientIdNotInHydraButInST_exceptionThrown() - throws StorageQueryException { + throws StorageQueryException, ClientAlreadyExistsForAppException { String clientId = "6030f07e-c8ef-4289-80c9-c18e0bf4f679NotInHydra"; String redirectUri = "http://localhost.com:3031/auth/callback/ory"; From fc08d8c79fb53bbbf2ef5452efb77f20f482ff2b Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Wed, 31 Jul 2024 13:29:53 +0200 Subject: [PATCH 13/30] feat: new configs for handling errors from hydra --- config.yaml | 8 +++ devConfig.yaml | 11 ++- .../io/supertokens/config/CoreConfig.java | 70 ++++++++++++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/config.yaml b/config.yaml index 75eed74c5..ec890e826 100644 --- a/config.yaml +++ b/config.yaml @@ -159,3 +159,11 @@ core_config_version: 0 # (OPTIONAL | Default: null) string value. If specified, the core uses this URL to connect to the OAuth provider admin # service. # oauth_provider_admin_service_url: + +# (OPTIONAL | Default: http://localhost:3000) string value. If specified, the core uses this URL replace the default +# consent and login URLs to {apiDomain}. +# oauth_provider_consent_login_base_url: + +# (OPTIONAL | Default: oauth_provider_public_service_url) If specified, the core uses this URL to parse responses from the oauth provider when +# the oauth provider's internal address differs from the known public provider address. +# oauth_provider_url_configured_in_hydra: diff --git a/devConfig.yaml b/devConfig.yaml index d7153d2f7..468752aa5 100644 --- a/devConfig.yaml +++ b/devConfig.yaml @@ -158,4 +158,13 @@ disable_telemetry: true # (OPTIONAL | Default: null) string value. If specified, the core uses this URL to connect to the OAuth provider admin # service. -# oauth_provider_admin_service_url: \ No newline at end of file +# oauth_provider_admin_service_url: + + +# (OPTIONAL | Default: http://localhost:3000) string value. If specified, the core uses this URL replace the default +# consent and login URLs to {apiDomain}. +# oauth_provider_consent_login_base_url: + +# (OPTIONAL | Default: oauth_provider_public_service_url) If specified, the core uses this URL to parse responses from the oauth provider when +# the oauth provider's internal address differs from the known public provider address. +# oauth_provider_url_configured_in_hydra: \ No newline at end of file diff --git a/src/main/java/io/supertokens/config/CoreConfig.java b/src/main/java/io/supertokens/config/CoreConfig.java index 130ccb0d5..b59437be8 100644 --- a/src/main/java/io/supertokens/config/CoreConfig.java +++ b/src/main/java/io/supertokens/config/CoreConfig.java @@ -41,6 +41,8 @@ import java.io.File; import java.io.IOException; import java.lang.reflect.Field; +import java.net.MalformedURLException; +import java.net.URL; import java.util.*; import java.util.regex.PatternSyntaxException; @@ -63,7 +65,9 @@ public class CoreConfig { "ip_allow_regex", "ip_deny_regex", "oauth_provider_public_service_url", - "oauth_provider_admin_service_url" + "oauth_provider_admin_service_url", + "oauth_provider_consent_login_base_url", + "oauth_provider_url_configured_in_hydra" }; @IgnoreForAnnotationCheck @@ -289,6 +293,22 @@ public class CoreConfig { "If specified, the core uses this URL to connect to the OAuth provider admin service.") private String oauth_provider_admin_service_url = null; + @NotConflictingInApp + @JsonProperty + @HideFromDashboard + @ConfigDescription( + "If specified, the core uses this URL replace the default consent and login URLs to {apiDomain}. Defaults to 'http://localhost:3000'") + private String oauth_provider_consent_login_base_url = "http://localhost:3000"; + + @NotConflictingInApp + @JsonProperty + @HideFromDashboard + @ConfigDescription( + "If specified, the core uses this URL to parse responses from the oauth provider when the oauth provider's internal address differs from the known public provider address. Defaults to the oauth_provider_public_service_url") + private String oauth_provider_url_configured_in_hydra = oauth_provider_public_service_url; + + + @ConfigYamlOnly @JsonProperty @ConfigDescription( @@ -348,6 +368,20 @@ public String getOAuthProviderAdminServiceUrl() throws InvalidConfigException { return oauth_provider_admin_service_url; } + public String getOauthProviderConsentLoginBaseUrl() throws InvalidConfigException { + if(oauth_provider_consent_login_base_url == null){ + throw new InvalidConfigException("oauth_provider_consent_login_base_url is not set"); + } + return oauth_provider_consent_login_base_url; + } + + public String getOauthProviderUrlConfiguredInHydra() throws InvalidConfigException { + if(oauth_provider_url_configured_in_hydra == null) { + throw new InvalidConfigException("oauth_provider_url_configured_in_hydra is not set"); + } + return oauth_provider_url_configured_in_hydra; + } + public String getIpAllowRegex() { return ip_allow_regex; } @@ -834,6 +868,40 @@ void normalizeAndValidate(Main main, boolean includeConfigFilePath) throws Inval } } + if(oauth_provider_public_service_url != null) { + try { + URL url = new URL(oauth_provider_public_service_url); + } catch (MalformedURLException malformedURLException){ + throw new InvalidConfigException("oauth_provider_public_service_url is not a valid URL"); + } + } + + if(oauth_provider_admin_service_url != null) { + try { + URL url = new URL(oauth_provider_admin_service_url); + } catch (MalformedURLException malformedURLException){ + throw new InvalidConfigException("oauth_provider_admin_service_url is not a valid URL"); + } + } + + if(oauth_provider_consent_login_base_url != null) { + try { + URL url = new URL(oauth_provider_consent_login_base_url); + } catch (MalformedURLException malformedURLException){ + throw new InvalidConfigException("oauth_provider_consent_login_base_url is not a valid URL"); + } + } + + if(oauth_provider_url_configured_in_hydra != null) { + try { + URL url = new URL(oauth_provider_url_configured_in_hydra); + } catch (MalformedURLException malformedURLException){ + throw new InvalidConfigException("oauth_provider_url_configured_in_hydra is not a valid URL"); + } + } + + + isNormalizedAndValid = true; } From 31164efeb8628498b42336b67b3950e615fb3126 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Wed, 31 Jul 2024 13:58:37 +0200 Subject: [PATCH 14/30] feat: using the new configs for oauth provider --- .../io/supertokens/config/CoreConfig.java | 5 ++- src/main/java/io/supertokens/oauth/OAuth.java | 16 +++++---- .../oauth/exceptions/OAuthAuthException.java | 1 + src/main/java/io/supertokens/utils/Utils.java | 17 +++++++++ .../webserver/api/oauth/OAuthAuthAPI.java | 2 -- .../test/oauth/api/OAuthAuthAPITest.java | 36 ++++++++++++++++--- 6 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/main/java/io/supertokens/config/CoreConfig.java b/src/main/java/io/supertokens/config/CoreConfig.java index b59437be8..fa0a2eb0e 100644 --- a/src/main/java/io/supertokens/config/CoreConfig.java +++ b/src/main/java/io/supertokens/config/CoreConfig.java @@ -892,7 +892,10 @@ void normalizeAndValidate(Main main, boolean includeConfigFilePath) throws Inval } } - if(oauth_provider_url_configured_in_hydra != null) { + + if(oauth_provider_url_configured_in_hydra == null) { + oauth_provider_url_configured_in_hydra = oauth_provider_public_service_url; + } else { try { URL url = new URL(oauth_provider_url_configured_in_hydra); } catch (MalformedURLException malformedURLException){ diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index fdab97b5d..bebdeddcf 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -28,6 +28,7 @@ import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.pluginInterface.oauth.OAuthStorage; +import io.supertokens.utils.Utils; import java.io.IOException; import java.net.URLDecoder; @@ -41,6 +42,8 @@ public class OAuth { private static final String ERROR_LITERAL = "error="; private static final String ERROR_DESCRIPTION_LITERAL = "error_description="; + private static final String HYDRA_AUTH_ENDPOINT = "/oauth2/auth"; + public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier appIdentifier, Storage storage, String clientId, String redirectURI, String responseType, String scope, String state) @@ -53,6 +56,8 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app List cookies = null; String publicOAuthProviderServiceUrl = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOAuthProviderPublicServiceUrl(); + String hydraInternalAddress = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOauthProviderUrlConfiguredInHydra(); + String hydraBaseUrlForConsentAndLogin = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOauthProviderConsentLoginBaseUrl(); if (!oauthStorage.doesClientIdExistForThisApp(appIdentifier, clientId)) { throw new OAuthAuthException("invalid_client", "Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). The requested OAuth 2.0 Client does not exist."); @@ -62,19 +67,18 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app Map responseHeaders = new HashMap<>(); //TODO maybe check response status code? Have to modify sendGetRequest.. for that - HttpRequest.sendGETRequestWithResponseHeaders(main, "", Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl() + "/oauth2/auth", queryParamsForHydra, 10000, 10000, null, responseHeaders, false); + HttpRequest.sendGETRequestWithResponseHeaders(main, "", Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl() + HYDRA_AUTH_ENDPOINT, queryParamsForHydra, 10000, 10000, null, responseHeaders, false); if(!responseHeaders.isEmpty() && responseHeaders.containsKey(LOCATION_HEADER_NAME)) { String locationHeaderValue = responseHeaders.get(LOCATION_HEADER_NAME); - - if (locationHeaderValue.contains(publicOAuthProviderServiceUrl)){ + if(Utils.containsUrl(locationHeaderValue, hydraInternalAddress, true)){ String error = getValueOfQueryParam(locationHeaderValue, ERROR_LITERAL); String errorDescription = getValueOfQueryParam(locationHeaderValue, ERROR_DESCRIPTION_LITERAL); throw new OAuthAuthException(error, errorDescription); } - if (locationHeaderValue.contains("localhost:3000") || locationHeaderValue.contains("127.0.0.1:3000")) { - redirectTo = locationHeaderValue.replace("localhost:3000", "{apiDomain}").replace("127.0.0.1:3000", "{apiDomain}"); + if(Utils.containsUrl(locationHeaderValue, hydraBaseUrlForConsentAndLogin, true)){ + redirectTo = locationHeaderValue.replace(hydraBaseUrlForConsentAndLogin, "{apiDomain}"); } else { redirectTo = locationHeaderValue; } @@ -113,4 +117,4 @@ private static String getValueOfQueryParam(String url, String queryParam){ valueOfQueryParam = url.substring(startIndex, endIndex); // substring the url from the '=' to the next '&' or to the end of the url if there are no more &s return URLDecoder.decode(valueOfQueryParam, StandardCharsets.UTF_8); } -} +;} diff --git a/src/main/java/io/supertokens/oauth/exceptions/OAuthAuthException.java b/src/main/java/io/supertokens/oauth/exceptions/OAuthAuthException.java index 981947a0b..2c2a92983 100644 --- a/src/main/java/io/supertokens/oauth/exceptions/OAuthAuthException.java +++ b/src/main/java/io/supertokens/oauth/exceptions/OAuthAuthException.java @@ -23,6 +23,7 @@ public class OAuthAuthException extends Exception{ public final String errorDescription; public OAuthAuthException(String error, String errorDescription) { + super(error); this.error = error; this.errorDescription = errorDescription; } diff --git a/src/main/java/io/supertokens/utils/Utils.java b/src/main/java/io/supertokens/utils/Utils.java index ecd3a0479..3d380c857 100644 --- a/src/main/java/io/supertokens/utils/Utils.java +++ b/src/main/java/io/supertokens/utils/Utils.java @@ -43,6 +43,8 @@ import java.io.ByteArrayOutputStream; import java.io.PrintStream; import java.math.BigInteger; +import java.net.MalformedURLException; +import java.net.URL; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.*; @@ -427,4 +429,19 @@ public static JsonObject addLegacySigningKeyInfos(AppIdentifier appIdentifier, M public static JsonElement toJsonTreeWithNulls(Object src) { return new GsonBuilder().serializeNulls().create().toJsonTree(src); } + + public static boolean containsUrl(String urlToCheckIfContains, String whatItContains, boolean careForProtocol) + throws MalformedURLException { + URL urlToCheck = new URL(urlToCheckIfContains); + URL urlToLookFor = new URL(whatItContains); + + String originalHost = urlToCheck.getHost() + urlToCheck.getPort(); + String wantedHost = urlToLookFor.getHost() + urlToLookFor.getPort(); + if (careForProtocol){ + originalHost = urlToCheck.getProtocol() + originalHost; + wantedHost = urlToCheck.getProtocol() + wantedHost; + } + + return originalHost.equals(wantedHost); + } } diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java index 8dd654000..83ccb5edd 100644 --- a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java @@ -53,8 +53,6 @@ public String getPath() { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { - // TODO Work in progress! - JsonObject input = InputParser.parseJsonObjectOrThrowError(req); String clientId = InputParser.parseStringOrThrowError(input, "clientId", false); String redirectUri = InputParser.parseStringOrThrowError(input, "redirectUri", false); diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java index 8cf0caf7c..daa22e427 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java @@ -53,15 +53,14 @@ public static void afterTesting() { @Before public void beforeEach() throws InterruptedException { Utils.reset(); + String[] args = {"../"}; this.process = TestingProcessManager.start(args); - FeatureFlagTestContent.getInstance(process.getProcess()) - .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.OAUTH}); - process.startProcess(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); } + @After public void afterEach() throws InterruptedException { process.kill(); @@ -89,8 +88,8 @@ public void testLocalhostChangedToApiDomain() assertNotNull(response); assertNotNull(response.redirectTo); assertNotNull(response.cookies); - - assertTrue(response.redirectTo.startsWith("http://{apiDomain}/login?login_challenge=")); + + assertTrue(response.redirectTo.startsWith("{apiDomain}/login?login_challenge=")); assertTrue(response.cookies.get(0).startsWith("ory_hydra_login_csrf_dev_134972871=")); } @@ -148,4 +147,31 @@ public void testCalledWithWrongClientIdNotInHydraButInST_exceptionThrown() assertEquals(expectedDescription, thrown.errorDescription); } + @Test + public void testCalledWithWrongRedirectUrl_exceptionThrown() + throws StorageQueryException, ClientAlreadyExistsForAppException { + + String clientId = "6030f07e-c8ef-4289-80c9-c18e0bf4f679"; + String redirectUri = "http://localhost.com:3031/auth/callback/ory_not_the_registered_one"; + String responseType = "code"; + String scope = "profile"; + String state = "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BDv%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD"; + + OAuthSQLStorage oAuthStorage = (OAuthSQLStorage) StorageLayer.getStorage(process.getProcess()); + + AppIdentifier testApp = new AppIdentifier("", ""); + oAuthStorage.addClientForApp(testApp, clientId); + + OAuthAuthException thrown = assertThrows(OAuthAuthException.class, () -> { + + OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), oAuthStorage, clientId, redirectUri, responseType, scope, state); + }); + + String expectedError = "invalid_request"; + String expectedDescription = "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. The 'redirect_uri' parameter does not match any of the OAuth 2.0 Client's pre-registered redirect urls."; + + assertEquals(expectedError, thrown.error); + assertEquals(expectedDescription, thrown.errorDescription); + } + } From 75ceba0543075cbc5fe35300221e2247bd662369 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Wed, 31 Jul 2024 14:09:38 +0200 Subject: [PATCH 15/30] fix: CHANGELOG --- CHANGELOG.md | 7 +++++++ .../io/supertokens/test/oauth/api/OAuthAuthAPITest.java | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b49a96441..7f2da84e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Added new feature in license key: `OAUTH` - Adds new core config: - `oauth_provider_public_service_url` + - `oauth_provider_admin_service_url` + - `oauth_provider_consent_login_base_url` + - `oauth_provider_url_configured_in_hydra` +- Adds GET `/recipe/oauth/auth` for OAuth2 auth flow support + +### Db schema changes +- Creates new table `oauth_clients` ## [9.1.1] -2024-07-24 diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java index daa22e427..391769b3d 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java @@ -88,7 +88,7 @@ public void testLocalhostChangedToApiDomain() assertNotNull(response); assertNotNull(response.redirectTo); assertNotNull(response.cookies); - + assertTrue(response.redirectTo.startsWith("{apiDomain}/login?login_challenge=")); assertTrue(response.cookies.get(0).startsWith("ory_hydra_login_csrf_dev_134972871=")); } From b6bb95f016d5af1b23fd0758eb83f23259b93124 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Wed, 31 Jul 2024 14:21:14 +0200 Subject: [PATCH 16/30] fix: tests for the new Util method --- .../java/io/supertokens/test/UtilsTest.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/test/java/io/supertokens/test/UtilsTest.java b/src/test/java/io/supertokens/test/UtilsTest.java index b30d91169..405cdaef3 100644 --- a/src/test/java/io/supertokens/test/UtilsTest.java +++ b/src/test/java/io/supertokens/test/UtilsTest.java @@ -22,6 +22,8 @@ import org.junit.Test; import org.junit.rules.TestRule; +import java.net.MalformedURLException; + import static org.junit.Assert.*; public class UtilsTest { @@ -104,4 +106,63 @@ public void testNormalizeWhitespacePhoneNumber() { String inputPhoneNumber = " "; assertEquals("", io.supertokens.utils.Utils.normalizeIfPhoneNumber(inputPhoneNumber)); } + + @Test + public void testUrlContainsWithoutSlash() throws MalformedURLException { + String itShouldContain = "http://127.0.0.1/fallback/error?somerandom=123"; + String thisShouldBeContained = "http://127.0.0.1"; + + assertTrue(io.supertokens.utils.Utils.containsUrl(itShouldContain,thisShouldBeContained, true)); + } + + @Test + public void testUrlContainsWithSlash() throws MalformedURLException { + String itShouldContain = "http://127.0.0.1/fallback/error?somerandom=123"; + String thisShouldBeContained = "http://127.0.0.1/"; + + assertTrue(io.supertokens.utils.Utils.containsUrl(itShouldContain,thisShouldBeContained, true)); + } + + @Test + public void testUrlContainsWithPort() throws MalformedURLException { + String itShouldContain = "http://127.0.0.1:3000/fallback/error?somerandom=123"; + String thisShouldBeContained = "http://127.0.0.1:3000/"; + + assertTrue(io.supertokens.utils.Utils.containsUrl(itShouldContain,thisShouldBeContained, true)); + } + + @Test + public void testUrlContainsWithAndWithoutPort() throws MalformedURLException { + String itShouldContain = "http://127.0.0.1:3000/fallback/error?somerandom=123"; + String thisShouldBeContained = "http://127.0.0.1"; + + assertFalse(io.supertokens.utils.Utils.containsUrl(itShouldContain,thisShouldBeContained, true)); + } + + @Test + public void testUrlContainsWithoutAndWithPort() throws MalformedURLException { + String itShouldContain = "http://127.0.0.1/fallback/error?somerandom=123"; + String thisShouldBeContained = "http://127.0.0.1:4444"; + + assertFalse(io.supertokens.utils.Utils.containsUrl(itShouldContain,thisShouldBeContained, true)); + } + + @Test + public void testUrlContainsWhenProtocolDoesntMatter() throws MalformedURLException { + String itShouldContain = "https://127.0.0.1/fallback/error?somerandom=123"; + String thisShouldBeContained = "http://127.0.0.1"; + + assertTrue(io.supertokens.utils.Utils.containsUrl(itShouldContain,thisShouldBeContained, false)); + } + + @Test + public void testUrlContainsCaseSensitive() throws MalformedURLException { + String itShouldContain = "http://littlecat/fallback/error?somerandom=123"; + String thisShouldBeContained = "http://littleCat"; + + assertFalse(io.supertokens.utils.Utils.containsUrl(itShouldContain,thisShouldBeContained, false)); + } + + + } From df6cb85d7fcfb1f182987e23bf926ba610291b49 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Wed, 31 Jul 2024 15:00:25 +0200 Subject: [PATCH 17/30] fix: changelog changes --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f2da84e1..5106dc194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `oauth_provider_admin_service_url` - `oauth_provider_consent_login_base_url` - `oauth_provider_url_configured_in_hydra` -- Adds GET `/recipe/oauth/auth` for OAuth2 auth flow support - -### Db schema changes +- Adds POST `/recipe/oauth/auth` for OAuth2 auth flow support - Creates new table `oauth_clients` From 5f6c78e395c1288f5da885bfd86c12fb6ad4fc37 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Wed, 31 Jul 2024 15:14:35 +0200 Subject: [PATCH 18/30] fix: changelog migration section fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5106dc194..c889db5d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Adds POST `/recipe/oauth/auth` for OAuth2 auth flow support - Creates new table `oauth_clients` +### Migration +TODO: after plugin support ## [9.1.1] -2024-07-24 From 85b45f89b6bddf350382ab7870d9024eea16c145 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Wed, 31 Jul 2024 15:39:03 +0200 Subject: [PATCH 19/30] fix: fixing repeated header handling in HttpRequest#sendGETRequestWithResponseHeaders --- .../supertokens/httpRequest/HttpRequest.java | 5 +- src/main/java/io/supertokens/oauth/OAuth.java | 8 +-- .../io/supertokens/test/HttpRequestTest.java | 56 +++++++++++++++++++ .../supertokens/test/JWKSPublicAPITest.java | 7 ++- 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/main/java/io/supertokens/httpRequest/HttpRequest.java b/src/main/java/io/supertokens/httpRequest/HttpRequest.java index 2bb76f401..98112031a 100644 --- a/src/main/java/io/supertokens/httpRequest/HttpRequest.java +++ b/src/main/java/io/supertokens/httpRequest/HttpRequest.java @@ -26,6 +26,7 @@ import java.net.URL; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Map; public class HttpRequest { @@ -126,7 +127,7 @@ public static T sendGETRequest(Main main, String requestID, String url, Map< public static T sendGETRequestWithResponseHeaders(Main main, String requestID, String url, Map params, int connectionTimeoutMS, int readTimeoutMS, Integer version, - Map responseHeaders, boolean followRedirects) + Map> responseHeaders, boolean followRedirects) throws IOException, HttpResponseException { StringBuilder paramBuilder = new StringBuilder(); @@ -157,7 +158,7 @@ public static T sendGETRequestWithResponseHeaders(Main main, String requestI con.getHeaderFields().forEach((key, value) -> { if (key != null) { - responseHeaders.put(key, value.get(0)); // TODO why the first element only? What happens with Set-Cookie headers? (Those are repeated if there are multiple cookies) + responseHeaders.put(key, value); } }); diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index bebdeddcf..32c9babd0 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -64,13 +64,13 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app } else { // we query hydra Map queryParamsForHydra = constructHydraRequestParamsForAuthorizationGETAPICall(clientId, redirectURI, responseType, scope, state); - Map responseHeaders = new HashMap<>(); + Map> responseHeaders = new HashMap<>(); //TODO maybe check response status code? Have to modify sendGetRequest.. for that HttpRequest.sendGETRequestWithResponseHeaders(main, "", Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl() + HYDRA_AUTH_ENDPOINT, queryParamsForHydra, 10000, 10000, null, responseHeaders, false); if(!responseHeaders.isEmpty() && responseHeaders.containsKey(LOCATION_HEADER_NAME)) { - String locationHeaderValue = responseHeaders.get(LOCATION_HEADER_NAME); + String locationHeaderValue = responseHeaders.get(LOCATION_HEADER_NAME).get(0); if(Utils.containsUrl(locationHeaderValue, hydraInternalAddress, true)){ String error = getValueOfQueryParam(locationHeaderValue, ERROR_LITERAL); String errorDescription = getValueOfQueryParam(locationHeaderValue, ERROR_DESCRIPTION_LITERAL); @@ -84,9 +84,7 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app } } if(responseHeaders.containsKey(COOKIES_HEADER_NAME)){ - String allCookies = responseHeaders.get(COOKIES_HEADER_NAME); - - cookies = Collections.singletonList(allCookies); + cookies = responseHeaders.get(COOKIES_HEADER_NAME); } } diff --git a/src/test/java/io/supertokens/test/HttpRequestTest.java b/src/test/java/io/supertokens/test/HttpRequestTest.java index 973d37818..98213b3ab 100644 --- a/src/test/java/io/supertokens/test/HttpRequestTest.java +++ b/src/test/java/io/supertokens/test/HttpRequestTest.java @@ -23,6 +23,7 @@ import io.supertokens.httpRequest.HttpResponseException; import io.supertokens.webserver.Webserver; import io.supertokens.webserver.WebserverAPI; +import jakarta.servlet.http.Cookie; import org.junit.AfterClass; import org.junit.Before; import org.junit.Rule; @@ -34,7 +35,10 @@ import java.io.BufferedReader; import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import static org.junit.Assert.*; @@ -735,4 +739,56 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IO process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); } + + @Test + public void getRequestTestWithHeaders() throws Exception { + String[] args = {"../"}; + + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + // api to check getRequestWithParams + Webserver.getInstance(process.getProcess()).addAPI(new WebserverAPI(process.getProcess(), "") { + + private static final long serialVersionUID = 1L; + + @Override + protected boolean checkAPIKey(HttpServletRequest req) { + return false; + } + + @Override + public String getPath() { + return "/getTestWithHeaders"; + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + Cookie cookie1 = new Cookie("someValue", "value"); + Cookie cookie2 = new Cookie("someValue2", "value2"); + resp.setHeader("SomeNameForHeader", "someValueForHeader"); + resp.addCookie(cookie1); + resp.addCookie(cookie2); + super.sendTextResponse(200, "200", resp); + } + + }); + + HashMap> responseHeaders = new HashMap<>(); + + { + String response = HttpRequest.sendGETRequestWithResponseHeaders(process.getProcess(), "", + "http://localhost:3567/getTestWithHeaders", null, 1000, 1000, null, responseHeaders, true); + assertEquals(response, "200"); + assertTrue(responseHeaders.containsKey("SomeNameForHeader")); + assertEquals(responseHeaders.get("SomeNameForHeader"), Collections.singletonList("someValueForHeader")); + assertTrue(responseHeaders.containsKey("Set-Cookie")); + assertEquals(2, responseHeaders.get("Set-Cookie").size()); + assertTrue(responseHeaders.get("Set-Cookie").contains("someValue=value")); + assertTrue(responseHeaders.get("Set-Cookie").contains("someValue2=value2")); + } + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } } diff --git a/src/test/java/io/supertokens/test/JWKSPublicAPITest.java b/src/test/java/io/supertokens/test/JWKSPublicAPITest.java index e6ba51721..a62c7c4f7 100644 --- a/src/test/java/io/supertokens/test/JWKSPublicAPITest.java +++ b/src/test/java/io/supertokens/test/JWKSPublicAPITest.java @@ -27,6 +27,7 @@ import org.junit.rules.TestRule; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -79,7 +80,7 @@ public void testCacheControlValue() throws Exception { assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); // check regular output - Map responseHeaders = new HashMap<>(); + Map> responseHeaders = new HashMap<>(); JsonObject response = HttpRequest.sendGETRequestWithResponseHeaders(process.getProcess(), "", "http://localhost:3567/.well-known/jwks.json", null, 1000, 1000, null, responseHeaders, true); @@ -90,7 +91,7 @@ public void testCacheControlValue() throws Exception { JsonArray keys = response.get("keys").getAsJsonArray(); assertEquals(keys.size(), 2); - long maxAge = getMaxAgeValue(responseHeaders.get("Cache-Control")); + long maxAge = getMaxAgeValue(responseHeaders.get("Cache-Control").get(0)); assertTrue(maxAge >= 3538 && maxAge <= 3540); Thread.sleep(2000); @@ -105,7 +106,7 @@ public void testCacheControlValue() throws Exception { keys = response.get("keys").getAsJsonArray(); assertEquals(keys.size(), 2); - long newMaxAge = getMaxAgeValue(responseHeaders.get("Cache-Control")); + long newMaxAge = getMaxAgeValue(responseHeaders.get("Cache-Control").get(0)); assertTrue(maxAge - newMaxAge >= 2 && maxAge - newMaxAge <= 3); process.kill(); From bb09c56c51c6887ff00188f62866d536c59f3632 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Wed, 31 Jul 2024 16:28:51 +0200 Subject: [PATCH 20/30] fix: more tests for oauth auth --- src/main/java/io/supertokens/oauth/OAuth.java | 3 +- .../test/oauth/api/OAuthAuthAPITest.java | 96 ++++++++++++++++++- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index 32c9babd0..b4b6b294f 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -66,8 +66,7 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app Map queryParamsForHydra = constructHydraRequestParamsForAuthorizationGETAPICall(clientId, redirectURI, responseType, scope, state); Map> responseHeaders = new HashMap<>(); - //TODO maybe check response status code? Have to modify sendGetRequest.. for that - HttpRequest.sendGETRequestWithResponseHeaders(main, "", Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl() + HYDRA_AUTH_ENDPOINT, queryParamsForHydra, 10000, 10000, null, responseHeaders, false); + HttpRequest.sendGETRequestWithResponseHeaders(main, "", publicOAuthProviderServiceUrl + HYDRA_AUTH_ENDPOINT, queryParamsForHydra, 10000, 10000, null, responseHeaders, false); if(!responseHeaders.isEmpty() && responseHeaders.containsKey(LOCATION_HEADER_NAME)) { String locationHeaderValue = responseHeaders.get(LOCATION_HEADER_NAME).get(0); diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java index 391769b3d..630947427 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java @@ -16,12 +16,18 @@ package io.supertokens.test.oauth.api; +import com.google.gson.JsonObject; import io.supertokens.ProcessState; +import io.supertokens.authRecipe.AuthRecipe; +import io.supertokens.emailpassword.EmailPassword; import io.supertokens.featureflag.EE_FEATURES; import io.supertokens.featureflag.FeatureFlagTestContent; import io.supertokens.httpRequest.HttpResponseException; import io.supertokens.oauth.OAuth; import io.supertokens.oauth.exceptions.OAuthAuthException; +import io.supertokens.pluginInterface.RECIPE_ID; +import io.supertokens.pluginInterface.STORAGE_TYPE; +import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; @@ -32,6 +38,10 @@ import io.supertokens.storageLayer.StorageLayer; import io.supertokens.test.TestingProcessManager; import io.supertokens.test.Utils; +import io.supertokens.test.httpRequest.HttpRequestForTesting; +import io.supertokens.useridmapping.UserIdMapping; +import io.supertokens.useridmapping.UserIdType; +import io.supertokens.utils.SemVer; import org.junit.*; import org.junit.rules.TestRule; @@ -70,7 +80,8 @@ public void afterEach() throws InterruptedException { @Test public void testLocalhostChangedToApiDomain() throws StorageQueryException, OAuthAuthException, HttpResponseException, TenantOrAppNotFoundException, - InvalidConfigException, IOException, ClientAlreadyExistsForAppException { + InvalidConfigException, IOException, ClientAlreadyExistsForAppException, + io.supertokens.test.httpRequest.HttpResponseException { String clientId = "6030f07e-c8ef-4289-80c9-c18e0bf4f679"; String redirectUri = "http://localhost.com:3031/auth/callback/ory"; @@ -91,11 +102,32 @@ public void testLocalhostChangedToApiDomain() assertTrue(response.redirectTo.startsWith("{apiDomain}/login?login_challenge=")); assertTrue(response.cookies.get(0).startsWith("ory_hydra_login_csrf_dev_134972871=")); + + + { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("clientId", clientId); + requestBody.addProperty("redirectUri", redirectUri); + requestBody.addProperty("responseType", responseType); + requestBody.addProperty("scope", scope); + requestBody.addProperty("state", state); + + JsonObject actualResponse = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/oauth/auth", requestBody, 1000, 1000, null, + null, RECIPE_ID.OAUTH.toString()); + + + assertTrue(actualResponse.has("redirectTo")); + assertTrue(actualResponse.has("cookies")); + assertTrue(actualResponse.get("redirectTo").getAsString().startsWith("{apiDomain}/login?login_challenge=")); + assertEquals(1, actualResponse.getAsJsonArray("cookies").size()); + assertTrue(actualResponse.getAsJsonArray("cookies").get(0).getAsString().startsWith("ory_hydra_login_csrf_dev_134972871=")); + } } @Test public void testCalledWithWrongClientIdNotInST_exceptionThrown() - throws StorageQueryException, ClientAlreadyExistsForAppException { + throws StorageQueryException, ClientAlreadyExistsForAppException, IOException { String clientId = "Not-Existing-In-Client-App-Table"; String redirectUri = "http://localhost.com:3031/auth/callback/ory"; @@ -118,6 +150,26 @@ public void testCalledWithWrongClientIdNotInST_exceptionThrown() assertEquals(expectedError, thrown.error); assertEquals(expectedDescription, thrown.errorDescription); + + { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("clientId", clientId); + requestBody.addProperty("redirectUri", redirectUri); + requestBody.addProperty("responseType", responseType); + requestBody.addProperty("scope", scope); + requestBody.addProperty("state", state); + + assertThrows(io.supertokens.test.httpRequest.HttpResponseException.class, () -> { + JsonObject actualResponse = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/oauth/auth", requestBody, 1000, 1000, null, + null, RECIPE_ID.OAUTH.toString()); + + assertTrue(actualResponse.has("error")); + assertTrue(actualResponse.has("error_description")); + assertEquals(expectedError,actualResponse.get("error").getAsString()); + assertEquals(expectedDescription, actualResponse.get("error_description").getAsString()); + }); + } } @Test @@ -145,6 +197,26 @@ public void testCalledWithWrongClientIdNotInHydraButInST_exceptionThrown() assertEquals(expectedError, thrown.error); assertEquals(expectedDescription, thrown.errorDescription); + + { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("clientId", clientId); + requestBody.addProperty("redirectUri", redirectUri); + requestBody.addProperty("responseType", responseType); + requestBody.addProperty("scope", scope); + requestBody.addProperty("state", state); + + assertThrows(io.supertokens.test.httpRequest.HttpResponseException.class, () -> { + JsonObject actualResponse = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/oauth/auth", requestBody, 1000, 1000, null, + null, RECIPE_ID.OAUTH.toString()); + + assertTrue(actualResponse.has("error")); + assertTrue(actualResponse.has("error_description")); + assertEquals(expectedError,actualResponse.get("error").getAsString()); + assertEquals(expectedDescription, actualResponse.get("error_description").getAsString()); + }); + } } @Test @@ -172,6 +244,26 @@ public void testCalledWithWrongRedirectUrl_exceptionThrown() assertEquals(expectedError, thrown.error); assertEquals(expectedDescription, thrown.errorDescription); + + { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("clientId", clientId); + requestBody.addProperty("redirectUri", redirectUri); + requestBody.addProperty("responseType", responseType); + requestBody.addProperty("scope", scope); + requestBody.addProperty("state", state); + + assertThrows(io.supertokens.test.httpRequest.HttpResponseException.class, () -> { + JsonObject actualResponse = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/oauth/auth", requestBody, 1000, 1000, null, + null, RECIPE_ID.OAUTH.toString()); + + assertTrue(actualResponse.has("error")); + assertTrue(actualResponse.has("error_description")); + assertEquals(expectedError,actualResponse.get("error").getAsString()); + assertEquals(expectedDescription, actualResponse.get("error_description").getAsString()); + }); + } } } From 1e5015ceb08c06f0cf27815ea60db506ed7045fd Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Thu, 1 Aug 2024 09:18:53 +0200 Subject: [PATCH 21/30] fix: review fix - more checks for the oauth config validity --- .../io/supertokens/config/CoreConfig.java | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/supertokens/config/CoreConfig.java b/src/main/java/io/supertokens/config/CoreConfig.java index fa0a2eb0e..1b82d78af 100644 --- a/src/main/java/io/supertokens/config/CoreConfig.java +++ b/src/main/java/io/supertokens/config/CoreConfig.java @@ -305,7 +305,7 @@ public class CoreConfig { @HideFromDashboard @ConfigDescription( "If specified, the core uses this URL to parse responses from the oauth provider when the oauth provider's internal address differs from the known public provider address. Defaults to the oauth_provider_public_service_url") - private String oauth_provider_url_configured_in_hydra = oauth_provider_public_service_url; + private String oauth_provider_url_configured_in_hydra; @@ -903,7 +903,10 @@ void normalizeAndValidate(Main main, boolean includeConfigFilePath) throws Inval } } - + List configsTogetherSet = Arrays.asList(oauth_provider_public_service_url, oauth_provider_admin_service_url, oauth_provider_consent_login_base_url); + if(isAnySet(configsTogetherSet) && !isAllSet(configsTogetherSet)) { + throw new InvalidConfigException("If any of the following is set, all of them has to be set: oauth_provider_public_service_url, oauth_provider_admin_service_url, oauth_provider_consent_login_base_url"); + } isNormalizedAndValid = true; } @@ -1035,4 +1038,24 @@ void assertThatConfigFromSameAppIdAreNotConflicting(CoreConfig other) throws Inv public String getMaxCDIVersion() { return this.supertokens_max_cdi_version; } + + private boolean isAnySet(List configs){ + for (String config : configs){ + if(config!=null){ + return true; + } + } + return false; + } + + private boolean isAllSet(List configs) { + boolean foundNotSet = false; + for(String config: configs){ + if(config == null){ + foundNotSet = true; + break; + } + } + return !foundNotSet; + } } From 955b1069d98341e3046dd7aacc702e807a73fde8 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Thu, 1 Aug 2024 09:25:44 +0200 Subject: [PATCH 22/30] fix: review fix - throwing expection if there is no location header in response from hydra --- src/main/java/io/supertokens/oauth/OAuth.java | 2 ++ .../io/supertokens/test/oauth/api/OAuthAuthAPITest.java | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index b4b6b294f..5f1b7982e 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -81,6 +81,8 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app } else { redirectTo = locationHeaderValue; } + } else { + throw new RuntimeException("Unexpected answer from Oauth Provider"); } if(responseHeaders.containsKey(COOKIES_HEADER_NAME)){ cookies = responseHeaders.get(COOKIES_HEADER_NAME); diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java index 630947427..e4385b49d 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java @@ -236,7 +236,8 @@ public void testCalledWithWrongRedirectUrl_exceptionThrown() OAuthAuthException thrown = assertThrows(OAuthAuthException.class, () -> { - OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), oAuthStorage, clientId, redirectUri, responseType, scope, state); + OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), + oAuthStorage, clientId, redirectUri, responseType, scope, state); }); String expectedError = "invalid_request"; @@ -260,10 +261,9 @@ public void testCalledWithWrongRedirectUrl_exceptionThrown() assertTrue(actualResponse.has("error")); assertTrue(actualResponse.has("error_description")); - assertEquals(expectedError,actualResponse.get("error").getAsString()); + assertEquals(expectedError, actualResponse.get("error").getAsString()); assertEquals(expectedDescription, actualResponse.get("error_description").getAsString()); }); } } - } From 2893b19da05b322b589bac5d105376d7019ada7b Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Thu, 1 Aug 2024 09:27:52 +0200 Subject: [PATCH 23/30] fix: review fix - renamed exception --- .../java/io/supertokens/inmemorydb/Start.java | 6 +++--- .../test/oauth/api/OAuthAuthAPITest.java | 19 +++++-------------- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/main/java/io/supertokens/inmemorydb/Start.java b/src/main/java/io/supertokens/inmemorydb/Start.java index 412f00f0e..b56fb9602 100644 --- a/src/main/java/io/supertokens/inmemorydb/Start.java +++ b/src/main/java/io/supertokens/inmemorydb/Start.java @@ -55,7 +55,7 @@ import io.supertokens.pluginInterface.multitenancy.exceptions.DuplicateThirdPartyIdException; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.pluginInterface.multitenancy.sqlStorage.MultitenancySQLStorage; -import io.supertokens.pluginInterface.oauth.exceptions.ClientAlreadyExistsForAppException; +import io.supertokens.pluginInterface.oauth.exceptions.OAuth2ClientAlreadyExistsForAppException; import io.supertokens.pluginInterface.oauth.sqlStorage.OAuthSQLStorage; import io.supertokens.pluginInterface.passwordless.PasswordlessCode; import io.supertokens.pluginInterface.passwordless.PasswordlessDevice; @@ -3022,7 +3022,7 @@ public boolean doesClientIdExistForThisApp(AppIdentifier appIdentifier, String c @Override public void addClientForApp(AppIdentifier appIdentifier, String clientId) - throws StorageQueryException, ClientAlreadyExistsForAppException { + throws StorageQueryException, OAuth2ClientAlreadyExistsForAppException { try { OAuthQueries.insertClientIdForAppId(this, clientId, appIdentifier); } catch (SQLException e) { @@ -3032,7 +3032,7 @@ public void addClientForApp(AppIdentifier appIdentifier, String clientId) if (isPrimaryKeyError(serverErrorMessage, config.getOAuthClientTable(), new String[]{"app_id", "client_id"})) { - throw new ClientAlreadyExistsForAppException(); + throw new OAuth2ClientAlreadyExistsForAppException(); } throw new StorageQueryException(e); } diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java index e4385b49d..94f4e42a3 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java @@ -18,30 +18,21 @@ import com.google.gson.JsonObject; import io.supertokens.ProcessState; -import io.supertokens.authRecipe.AuthRecipe; -import io.supertokens.emailpassword.EmailPassword; -import io.supertokens.featureflag.EE_FEATURES; -import io.supertokens.featureflag.FeatureFlagTestContent; import io.supertokens.httpRequest.HttpResponseException; import io.supertokens.oauth.OAuth; import io.supertokens.oauth.exceptions.OAuthAuthException; import io.supertokens.pluginInterface.RECIPE_ID; -import io.supertokens.pluginInterface.STORAGE_TYPE; -import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.oauth.OAuthAuthResponse; -import io.supertokens.pluginInterface.oauth.exceptions.ClientAlreadyExistsForAppException; +import io.supertokens.pluginInterface.oauth.exceptions.OAuth2ClientAlreadyExistsForAppException; import io.supertokens.pluginInterface.oauth.sqlStorage.OAuthSQLStorage; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.test.TestingProcessManager; import io.supertokens.test.Utils; import io.supertokens.test.httpRequest.HttpRequestForTesting; -import io.supertokens.useridmapping.UserIdMapping; -import io.supertokens.useridmapping.UserIdType; -import io.supertokens.utils.SemVer; import org.junit.*; import org.junit.rules.TestRule; @@ -80,7 +71,7 @@ public void afterEach() throws InterruptedException { @Test public void testLocalhostChangedToApiDomain() throws StorageQueryException, OAuthAuthException, HttpResponseException, TenantOrAppNotFoundException, - InvalidConfigException, IOException, ClientAlreadyExistsForAppException, + InvalidConfigException, IOException, OAuth2ClientAlreadyExistsForAppException, io.supertokens.test.httpRequest.HttpResponseException { String clientId = "6030f07e-c8ef-4289-80c9-c18e0bf4f679"; @@ -127,7 +118,7 @@ public void testLocalhostChangedToApiDomain() @Test public void testCalledWithWrongClientIdNotInST_exceptionThrown() - throws StorageQueryException, ClientAlreadyExistsForAppException, IOException { + throws StorageQueryException, OAuth2ClientAlreadyExistsForAppException, IOException { String clientId = "Not-Existing-In-Client-App-Table"; String redirectUri = "http://localhost.com:3031/auth/callback/ory"; @@ -174,7 +165,7 @@ public void testCalledWithWrongClientIdNotInST_exceptionThrown() @Test public void testCalledWithWrongClientIdNotInHydraButInST_exceptionThrown() - throws StorageQueryException, ClientAlreadyExistsForAppException { + throws StorageQueryException, OAuth2ClientAlreadyExistsForAppException { String clientId = "6030f07e-c8ef-4289-80c9-c18e0bf4f679NotInHydra"; String redirectUri = "http://localhost.com:3031/auth/callback/ory"; @@ -221,7 +212,7 @@ public void testCalledWithWrongClientIdNotInHydraButInST_exceptionThrown() @Test public void testCalledWithWrongRedirectUrl_exceptionThrown() - throws StorageQueryException, ClientAlreadyExistsForAppException { + throws StorageQueryException, OAuth2ClientAlreadyExistsForAppException { String clientId = "6030f07e-c8ef-4289-80c9-c18e0bf4f679"; String redirectUri = "http://localhost.com:3031/auth/callback/ory_not_the_registered_one"; From d14267fe5bd8f0088b7214f87658ce7878cc8f25 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Thu, 1 Aug 2024 15:14:21 +0200 Subject: [PATCH 24/30] feat: oauth2 register client API --- CHANGELOG.md | 1 + .../httpRequest/HttpResponseException.java | 2 + .../java/io/supertokens/inmemorydb/Start.java | 9 + .../inmemorydb/queries/OAuthQueries.java | 12 +- src/main/java/io/supertokens/oauth/OAuth.java | 77 +++++++- .../oauth/exceptions/OAuthAuthException.java | 10 +- .../OAuthClientRegisterException.java | 29 +++ .../oauth/exceptions/OAuthException.java | 33 ++++ src/main/java/io/supertokens/utils/Utils.java | 18 ++ .../io/supertokens/webserver/Webserver.java | 2 + .../api/oauth/OAuthRegisterClientAPI.java | 115 +++++++++++ .../oauth/api/OAuthRegisterClientAPITest.java | 185 ++++++++++++++++++ 12 files changed, 481 insertions(+), 12 deletions(-) create mode 100644 src/main/java/io/supertokens/oauth/exceptions/OAuthClientRegisterException.java create mode 100644 src/main/java/io/supertokens/oauth/exceptions/OAuthException.java create mode 100644 src/main/java/io/supertokens/webserver/api/oauth/OAuthRegisterClientAPI.java create mode 100644 src/test/java/io/supertokens/test/oauth/api/OAuthRegisterClientAPITest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c889db5d0..c30194034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `oauth_provider_consent_login_base_url` - `oauth_provider_url_configured_in_hydra` - Adds POST `/recipe/oauth/auth` for OAuth2 auth flow support +- Adds POST `/recipe/oauth/registerclient` for OAuth2 client registration - Creates new table `oauth_clients` ### Migration diff --git a/src/main/java/io/supertokens/httpRequest/HttpResponseException.java b/src/main/java/io/supertokens/httpRequest/HttpResponseException.java index 257757fe1..10686c518 100644 --- a/src/main/java/io/supertokens/httpRequest/HttpResponseException.java +++ b/src/main/java/io/supertokens/httpRequest/HttpResponseException.java @@ -21,9 +21,11 @@ public class HttpResponseException extends Exception { private static final long serialVersionUID = 1L; public final int statusCode; + public final String rawMessage; HttpResponseException(int statusCode, String message) { super("Http error. Status Code: " + statusCode + ". Message: " + message); this.statusCode = statusCode; + this.rawMessage = message; } } diff --git a/src/main/java/io/supertokens/inmemorydb/Start.java b/src/main/java/io/supertokens/inmemorydb/Start.java index b56fb9602..71cc8a704 100644 --- a/src/main/java/io/supertokens/inmemorydb/Start.java +++ b/src/main/java/io/supertokens/inmemorydb/Start.java @@ -3037,4 +3037,13 @@ public void addClientForApp(AppIdentifier appIdentifier, String clientId) throw new StorageQueryException(e); } } + + @Override + public boolean isClientIdAlreadyExists(String clientId) throws StorageQueryException { + try { + return OAuthQueries.isClientIdAlreadyExists(this, clientId); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } } diff --git a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java index d3f68f1fa..927088127 100644 --- a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java +++ b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java @@ -21,11 +21,8 @@ import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; -import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.ArrayList; -import java.util.List; import static io.supertokens.inmemorydb.QueryExecutorTemplate.execute; import static io.supertokens.inmemorydb.QueryExecutorTemplate.update; @@ -64,4 +61,13 @@ public static void insertClientIdForAppId(Start start, String clientId, AppIdent }); } + public static boolean isClientIdAlreadyExists(Start start, String clientId) + throws SQLException, StorageQueryException { + String QUERY = "SELECT client_id FROM " + Config.getConfig(start).getOAuthClientTable() + + " WHERE client_id = ?"; + return execute(start, QUERY, pst -> { + pst.setString(1, clientId); + }, ResultSet::next); + } + } diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index 5f1b7982e..55a7928e2 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -16,11 +16,15 @@ package io.supertokens.oauth; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import io.supertokens.Main; import io.supertokens.config.Config; import io.supertokens.httpRequest.HttpRequest; import io.supertokens.httpRequest.HttpResponseException; import io.supertokens.oauth.exceptions.OAuthAuthException; +import io.supertokens.oauth.exceptions.OAuthClientRegisterException; import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.StorageUtils; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; @@ -28,11 +32,14 @@ import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.pluginInterface.oauth.OAuthStorage; +import io.supertokens.pluginInterface.oauth.exceptions.OAuth2ClientAlreadyExistsForAppException; import io.supertokens.utils.Utils; import java.io.IOException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; import java.util.*; public class OAuth { @@ -43,7 +50,7 @@ public class OAuth { private static final String ERROR_DESCRIPTION_LITERAL = "error_description="; private static final String HYDRA_AUTH_ENDPOINT = "/oauth2/auth"; - + private static final String HYDRA_CLIENTS_ENDPOINT = "/admin/clients"; public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier appIdentifier, Storage storage, String clientId, String redirectURI, String responseType, String scope, String state) @@ -92,6 +99,74 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app return new OAuthAuthResponse(redirectTo, cookies); } + //This more or less acts as a pass-through for the sdks, apart from camelCase <-> snake_case key transformation and setting a few default values + public static JsonObject registerOAuthClient(Main main, AppIdentifier appIdentifier, Storage storage, JsonObject paramsFromSdk) + throws TenantOrAppNotFoundException, InvalidConfigException, IOException, OAuthClientRegisterException, + NoSuchAlgorithmException, StorageQueryException { + + OAuthStorage oauthStorage = StorageUtils.getOAuthStorage(storage); + String adminOAuthProviderServiceUrl = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOAuthProviderAdminServiceUrl(); + + byte[] idBaseBytes = new byte[48]; + + while(true){ + new SecureRandom().nextBytes(idBaseBytes); + String clientId = "supertokens_" + Utils.hashSHA256Base64UrlSafe(idBaseBytes); + try { + if(oauthStorage.isClientIdAlreadyExists(clientId)){ + continue; // restart + } + + JsonObject hydraRequestBody = constructHydraRequestParamsForRegisterClientPOST(paramsFromSdk, clientId); + JsonObject hydraResponse = HttpRequest.sendJsonPOSTRequest(main, "", adminOAuthProviderServiceUrl + HYDRA_CLIENTS_ENDPOINT, hydraRequestBody, 10000, 10000, null); + + oauthStorage.addClientForApp(appIdentifier, clientId); + + return formatResponseForSDK(hydraResponse); //sdk expects everything from hydra in camelCase + } catch (HttpResponseException e) { + String errorMessage = e.rawMessage; + JsonObject errorResponse = (JsonObject) new JsonParser().parse(errorMessage); + String error = errorResponse.get("error").getAsString(); + String errorDescription = errorResponse.get("error_description").getAsString(); + throw new OAuthClientRegisterException(error, errorDescription); + } catch (OAuth2ClientAlreadyExistsForAppException e) { + //in theory, this is unreachable. We are registering new clients here, so this should not happen. + throw new RuntimeException(e); + } + } + + } + + private static JsonObject constructHydraRequestParamsForRegisterClientPOST(JsonObject paramsFromSdk, String generatedClientId){ + JsonObject requestBody = new JsonObject(); + + //translating camelCase keys to snakeCase keys + for (Map.Entry jsonEntry : paramsFromSdk.entrySet()){ + requestBody.add(Utils.camelCaseToSnakeCase(jsonEntry.getKey()), jsonEntry.getValue()); + } + + //add client_id + requestBody.addProperty("client_id", generatedClientId); + + //setting other non-changing defaults + requestBody.addProperty("access_token_strategy", "jwt"); + requestBody.addProperty("skip_consent", true); + requestBody.addProperty("subject_type", "public"); + + return requestBody; + } + + private static JsonObject formatResponseForSDK(JsonObject response) { + JsonObject formattedResponse = new JsonObject(); + + //translating snake_case keys to camelCase keys + for (Map.Entry jsonEntry : response.entrySet()){ + formattedResponse.add(Utils.snakeCaseToCamelCase(jsonEntry.getKey()), jsonEntry.getValue()); + } + + return formattedResponse; + } + private static Map constructHydraRequestParamsForAuthorizationGETAPICall(String clientId, String redirectURI, String responseType, String scope, String state) { Map queryParamsForHydra = new HashMap<>(); diff --git a/src/main/java/io/supertokens/oauth/exceptions/OAuthAuthException.java b/src/main/java/io/supertokens/oauth/exceptions/OAuthAuthException.java index 2c2a92983..ba850d287 100644 --- a/src/main/java/io/supertokens/oauth/exceptions/OAuthAuthException.java +++ b/src/main/java/io/supertokens/oauth/exceptions/OAuthAuthException.java @@ -16,16 +16,10 @@ package io.supertokens.oauth.exceptions; -public class OAuthAuthException extends Exception{ +public class OAuthAuthException extends OAuthException{ private static final long serialVersionUID = 1836718299845759897L; - public final String error; - public final String errorDescription; - public OAuthAuthException(String error, String errorDescription) { - super(error); - this.error = error; - this.errorDescription = errorDescription; + super(error, errorDescription); } - } diff --git a/src/main/java/io/supertokens/oauth/exceptions/OAuthClientRegisterException.java b/src/main/java/io/supertokens/oauth/exceptions/OAuthClientRegisterException.java new file mode 100644 index 000000000..f7e0ff9b5 --- /dev/null +++ b/src/main/java/io/supertokens/oauth/exceptions/OAuthClientRegisterException.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.oauth.exceptions; + +import java.io.Serial; + +public class OAuthClientRegisterException extends OAuthException{ + + @Serial + private static final long serialVersionUID = 665027786586190611L; + + public OAuthClientRegisterException(String error, String errorDescription) { + super(error, errorDescription); + } +} diff --git a/src/main/java/io/supertokens/oauth/exceptions/OAuthException.java b/src/main/java/io/supertokens/oauth/exceptions/OAuthException.java new file mode 100644 index 000000000..890cb02cb --- /dev/null +++ b/src/main/java/io/supertokens/oauth/exceptions/OAuthException.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.oauth.exceptions; + +import java.io.Serial; + +public class OAuthException extends Exception{ + @Serial + private static final long serialVersionUID = 1836718299845759897L; + + public final String error; + public final String errorDescription; + + public OAuthException(String error, String errorDescription){ + super(error); + this.error = error; + this.errorDescription = errorDescription; + } +} diff --git a/src/main/java/io/supertokens/utils/Utils.java b/src/main/java/io/supertokens/utils/Utils.java index 3d380c857..903036289 100644 --- a/src/main/java/io/supertokens/utils/Utils.java +++ b/src/main/java/io/supertokens/utils/Utils.java @@ -57,6 +57,7 @@ import java.util.Base64.Encoder; import java.util.List; import java.util.UUID; +import java.util.regex.Pattern; public class Utils { @@ -444,4 +445,21 @@ public static boolean containsUrl(String urlToCheckIfContains, String whatItCont return originalHost.equals(wantedHost); } + + public static String camelCaseToSnakeCase(String toSnakeCase) { + String regex = "([a-z])([A-Z]+)"; + String replacement = "$1_$2"; + toSnakeCase = toSnakeCase + .replaceAll( + regex, replacement) + .toLowerCase(); + return toSnakeCase; + } + + public static String snakeCaseToCamelCase(String toCamelCase) { + toCamelCase = Pattern.compile("_([a-z])") + .matcher(toCamelCase) + .replaceAll(m -> m.group(1).toUpperCase()); + return toCamelCase; + } } diff --git a/src/main/java/io/supertokens/webserver/Webserver.java b/src/main/java/io/supertokens/webserver/Webserver.java index 0401397be..6384205f8 100644 --- a/src/main/java/io/supertokens/webserver/Webserver.java +++ b/src/main/java/io/supertokens/webserver/Webserver.java @@ -40,6 +40,7 @@ import io.supertokens.webserver.api.multitenancy.thirdparty.CreateOrUpdateThirdPartyConfigAPI; import io.supertokens.webserver.api.multitenancy.thirdparty.RemoveThirdPartyConfigAPI; import io.supertokens.webserver.api.oauth.OAuthAuthAPI; +import io.supertokens.webserver.api.oauth.OAuthRegisterClientAPI; import io.supertokens.webserver.api.passwordless.*; import io.supertokens.webserver.api.session.*; import io.supertokens.webserver.api.thirdparty.GetUsersByEmailAPI; @@ -269,6 +270,7 @@ private void setupRoutes() { addAPI(new GetTenantCoreConfigForDashboardAPI(main)); addAPI(new OAuthAuthAPI(main)); + addAPI(new OAuthRegisterClientAPI(main)); StandardContext context = tomcatReference.getContext(); Tomcat tomcat = tomcatReference.getTomcat(); diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthRegisterClientAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthRegisterClientAPI.java new file mode 100644 index 000000000..05f36ef04 --- /dev/null +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthRegisterClientAPI.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.webserver.api.oauth; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import io.supertokens.Main; +import io.supertokens.multitenancy.exception.BadPermissionException; +import io.supertokens.oauth.OAuth; +import io.supertokens.oauth.exceptions.OAuthClientRegisterException; +import io.supertokens.pluginInterface.RECIPE_ID; +import io.supertokens.pluginInterface.Storage; +import io.supertokens.pluginInterface.exceptions.InvalidConfigException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.AppIdentifier; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.webserver.InputParser; +import io.supertokens.webserver.WebserverAPI; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.io.Serial; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public class OAuthRegisterClientAPI extends WebserverAPI { + + @Serial + private static final long serialVersionUID = -4482427281337641246L; + + private static final List ALLOWED_INPUT_FIELDS = Arrays.asList(new String[]{"clientName","scope", "redirectUris", "allowedCorsOrigins", "authorizationCodeGrantAccessTokenLifespan", "authorizationCodeGrantIdTokenLifespan", "authorizationCodeGrantRefreshTokenLifespan", + "clientCredentialsGrantAccessTokenLifespan","implicitGrantAccessTokenLifespan","implicitGrantIdTokenLifespan","refreshTokenGrantAccessTokenLifespan","refreshTokenGrantIdTokenLifespan","refreshTokenGrantRefreshTokenLifespan","tokenEndpointAuthMethod","audience", + "grantTypes","responseTypes","clientUri","logoUri","policyUri","tosUri","metadata"}); + private static final List REQUIRED_INPUT_FIELDS = Arrays.asList(new String[]{"clientName", "scope"}); + + @Override + public String getPath() { + return "/recipe/oauth/registerclient"; + } + public OAuthRegisterClientAPI(Main main){ + super(main, RECIPE_ID.OAUTH.toString()); + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { + JsonObject input = InputParser.parseJsonObjectOrThrowError(req); + + boolean containsAllRequired = containsAllRequiredFields(input); + boolean containsMoreThanAllowed = containsMoreThanAllowed(input); + if(!containsAllRequired || containsMoreThanAllowed){ + throw new ServletException(new WebserverAPI.BadRequestException("Invalid Json Input")); + } + + try { + AppIdentifier appIdentifier = getAppIdentifier(req); + Storage storage = enforcePublicTenantAndGetPublicTenantStorage(req); + + JsonObject response = OAuth.registerOAuthClient(super.main, appIdentifier, storage, input); + sendJsonResponse(200, response, resp); + + } catch (OAuthClientRegisterException registerException) { + + JsonObject errorResponse = new JsonObject(); + errorResponse.addProperty("error", registerException.error); + errorResponse.addProperty("error_description", registerException.errorDescription); + + sendJsonResponse(400, errorResponse, resp); + + } catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException + | NoSuchAlgorithmException | StorageQueryException e) { + throw new ServletException(e); + } + } + + private boolean containsAllRequiredFields(JsonObject input){ + boolean foundMissing = false; + for(String requiredField : OAuthRegisterClientAPI.REQUIRED_INPUT_FIELDS){ + if(input.get(requiredField) == null || input.get(requiredField).isJsonNull() || + input.get(requiredField).getAsString().isEmpty()){ + foundMissing = true; + break; + } + } + return !foundMissing; + } + + private boolean containsMoreThanAllowed(JsonObject input) { + boolean containsMore = false; + for(Map.Entry jsonEntry : input.entrySet()){ + if(!OAuthRegisterClientAPI.ALLOWED_INPUT_FIELDS.contains(jsonEntry.getKey())){ + containsMore = true; + break; + } + } + return containsMore; + } +} diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthRegisterClientAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthRegisterClientAPITest.java new file mode 100644 index 000000000..a95ceac1f --- /dev/null +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthRegisterClientAPITest.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.test.oauth.api; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import io.supertokens.ProcessState; +import io.supertokens.pluginInterface.RECIPE_ID; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.oauth.sqlStorage.OAuthSQLStorage; +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 org.junit.*; +import org.junit.rules.TestRule; + +import java.io.IOException; + +import static org.junit.Assert.*; +import static org.junit.Assert.assertTrue; + +public class OAuthRegisterClientAPITest { + TestingProcessManager.TestingProcess process; + + @Rule + public TestRule watchman = Utils.getOnFailure(); + + @AfterClass + public static void afterTesting() { + Utils.afterTesting(); + } + + @Before + public void beforeEach() throws InterruptedException { + Utils.reset(); + } + + @Test + public void testClientRegisteredForApp() + throws HttpResponseException, IOException, StorageQueryException, InterruptedException { + + String[] args = {"../"}; + this.process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + String clientName = "jozef"; + String scope = "profile"; + + OAuthSQLStorage oAuthStorage = (OAuthSQLStorage) StorageLayer.getStorage(process.getProcess()); + + + { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("clientName", clientName); + requestBody.addProperty("scope", scope); + + JsonArray grantTypes = new JsonArray(); + grantTypes.add(new JsonPrimitive("refresh_token")); + grantTypes.add(new JsonPrimitive("authorization_code")); + requestBody.add("grantTypes", grantTypes); + + JsonArray responseTypes = new JsonArray(); + responseTypes.add(new JsonPrimitive("code")); + responseTypes.add(new JsonPrimitive("id_token")); + requestBody.add("responseTypes", responseTypes); + + JsonObject actualResponse = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/oauth/registerclient", requestBody, 1000, 1000, null, + null, RECIPE_ID.OAUTH.toString()); + + assertTrue(actualResponse.has("clientSecret")); + assertTrue(actualResponse.has("clientId")); + + String clientId = actualResponse.get("clientId").getAsString(); + + boolean isClientAlreadyExists = oAuthStorage.isClientIdAlreadyExists(clientId); + assertTrue(isClientAlreadyExists); + + boolean clientShouldntExists = oAuthStorage.isClientIdAlreadyExists(clientId + "someRandomStringHere"); + assertFalse(clientShouldntExists); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } + + @Test + public void testMissingRequiredField_throwsException() throws InterruptedException { + + String[] args = {"../"}; + this.process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + String clientName = "jozef"; + //notice missing 'scope' field! + + + { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("clientName", clientName); + //notice missing 'scope' field + + JsonArray grantTypes = new JsonArray(); + grantTypes.add(new JsonPrimitive("refresh_token")); + grantTypes.add(new JsonPrimitive("authorization_code")); + requestBody.add("grantTypes", grantTypes); + + JsonArray responseTypes = new JsonArray(); + responseTypes.add(new JsonPrimitive("code")); + responseTypes.add(new JsonPrimitive("id_token")); + requestBody.add("responseTypes", responseTypes); + + io.supertokens.test.httpRequest.HttpResponseException expected = assertThrows(io.supertokens.test.httpRequest.HttpResponseException.class, () -> { + HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/oauth/registerclient", requestBody, 1000, 1000, null, + null, RECIPE_ID.OAUTH.toString()); + }); + + assertEquals(400, expected.statusCode); + assertEquals("Http error. Status Code: 400. Message: Invalid Json Input", expected.getMessage()); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } + + @Test + public void testMoreFieldThanAllowed_throwsException() + throws InterruptedException { + + String[] args = {"../"}; + this.process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + String clientName = "jozef"; + String scope = "scope"; + String maliciousAttempt = "giveMeAllYourBelongings!"; //here! + + { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("clientName", clientName); + requestBody.addProperty("scope", scope); + requestBody.addProperty("dontMindMe", maliciousAttempt); //here! + + JsonArray grantTypes = new JsonArray(); + grantTypes.add(new JsonPrimitive("refresh_token")); + grantTypes.add(new JsonPrimitive("authorization_code")); + requestBody.add("grantTypes", grantTypes); + + JsonArray responseTypes = new JsonArray(); + responseTypes.add(new JsonPrimitive("code")); + responseTypes.add(new JsonPrimitive("id_token")); + requestBody.add("responseTypes", responseTypes); + + io.supertokens.test.httpRequest.HttpResponseException expected = assertThrows(io.supertokens.test.httpRequest.HttpResponseException.class, () -> { + HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/oauth/registerclient", requestBody, 1000, 1000, null, + null, RECIPE_ID.OAUTH.toString()); + }); + + assertEquals(400, expected.statusCode); + assertEquals("Http error. Status Code: 400. Message: Invalid Json Input", expected.getMessage()); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } + } +} From f5db9b23521e332361e021af37b57440e5e167d9 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Thu, 1 Aug 2024 16:29:14 +0200 Subject: [PATCH 25/30] feat: oauth2 get clients API --- CHANGELOG.md | 3 +- src/main/java/io/supertokens/oauth/OAuth.java | 46 +++++++++++++-- .../exceptions/OAuthClientException.java | 29 ++++++++++ .../io/supertokens/webserver/Webserver.java | 4 +- ...terClientAPI.java => OAuthClientsAPI.java} | 46 ++++++++++++--- ...tAPITest.java => OAuthClientsAPITest.java} | 57 +++++++++++++++++-- 6 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 src/main/java/io/supertokens/oauth/exceptions/OAuthClientException.java rename src/main/java/io/supertokens/webserver/api/oauth/{OAuthRegisterClientAPI.java => OAuthClientsAPI.java} (73%) rename src/test/java/io/supertokens/test/oauth/api/{OAuthRegisterClientAPITest.java => OAuthClientsAPITest.java} (73%) diff --git a/CHANGELOG.md b/CHANGELOG.md index c30194034..c2ec22d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `oauth_provider_consent_login_base_url` - `oauth_provider_url_configured_in_hydra` - Adds POST `/recipe/oauth/auth` for OAuth2 auth flow support -- Adds POST `/recipe/oauth/registerclient` for OAuth2 client registration +- Adds POST `/recipe/oauth/clients` for OAuth2 client registration +- Adds GET `/recipe/oauth/clients?clientId=example_id` for loading OAuth2 client - Creates new table `oauth_clients` ### Migration diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index 55a7928e2..0dced5e52 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -24,7 +24,9 @@ import io.supertokens.httpRequest.HttpRequest; import io.supertokens.httpRequest.HttpResponseException; import io.supertokens.oauth.exceptions.OAuthAuthException; +import io.supertokens.oauth.exceptions.OAuthClientException; import io.supertokens.oauth.exceptions.OAuthClientRegisterException; +import io.supertokens.oauth.exceptions.OAuthException; import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.StorageUtils; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; @@ -36,6 +38,7 @@ import io.supertokens.utils.Utils; import java.io.IOException; +import java.lang.reflect.InvocationTargetException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; @@ -124,17 +127,50 @@ public static JsonObject registerOAuthClient(Main main, AppIdentifier appIdentif return formatResponseForSDK(hydraResponse); //sdk expects everything from hydra in camelCase } catch (HttpResponseException e) { - String errorMessage = e.rawMessage; - JsonObject errorResponse = (JsonObject) new JsonParser().parse(errorMessage); - String error = errorResponse.get("error").getAsString(); - String errorDescription = errorResponse.get("error_description").getAsString(); - throw new OAuthClientRegisterException(error, errorDescription); + try { + throw createCustomExceptionFromHttpResponseException(e, OAuthClientRegisterException.class); + } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | + IllegalAccessException ex) { + throw new RuntimeException(ex); + } } catch (OAuth2ClientAlreadyExistsForAppException e) { //in theory, this is unreachable. We are registering new clients here, so this should not happen. throw new RuntimeException(e); } } + } + + public static JsonObject loadOAuthClient(Main main, AppIdentifier appIdentifier, Storage storage, String clientId) + throws TenantOrAppNotFoundException, OAuthClientException, InvalidConfigException, StorageQueryException, + IOException { + OAuthStorage oauthStorage = StorageUtils.getOAuthStorage(storage); + + String adminOAuthProviderServiceUrl = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOAuthProviderAdminServiceUrl(); + + if (!oauthStorage.doesClientIdExistForThisApp(appIdentifier, clientId)) { + throw new OAuthClientException("Unable to locate the resource", ""); + } else { + try { + JsonObject hydraResponse = HttpRequest.sendGETRequest(main, "", adminOAuthProviderServiceUrl + HYDRA_CLIENTS_ENDPOINT + "/" + clientId, null, 10000, 10000, null); + return formatResponseForSDK(hydraResponse); + } catch (HttpResponseException e) { + try { + throw createCustomExceptionFromHttpResponseException(e, OAuthClientException.class); + } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | + IllegalAccessException ex) { + throw new RuntimeException("Something went really wrong!"); + } + } + } + } + private static T createCustomExceptionFromHttpResponseException(HttpResponseException exception, Class customExceptionClass) + throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { + String errorMessage = exception.rawMessage; + JsonObject errorResponse = (JsonObject) new JsonParser().parse(errorMessage); + String error = errorResponse.get("error").getAsString(); + String errorDescription = errorResponse.get("error_description").getAsString(); + return customExceptionClass.getDeclaredConstructor(String.class, String.class).newInstance(error, errorDescription); } private static JsonObject constructHydraRequestParamsForRegisterClientPOST(JsonObject paramsFromSdk, String generatedClientId){ diff --git a/src/main/java/io/supertokens/oauth/exceptions/OAuthClientException.java b/src/main/java/io/supertokens/oauth/exceptions/OAuthClientException.java new file mode 100644 index 000000000..eee0504b5 --- /dev/null +++ b/src/main/java/io/supertokens/oauth/exceptions/OAuthClientException.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package io.supertokens.oauth.exceptions; + +import java.io.Serial; + +public class OAuthClientException extends OAuthException{ + + @Serial + private static final long serialVersionUID = -140335439416174384L; + + public OAuthClientException(String error, String errorDescription) { + super(error, errorDescription); + } +} diff --git a/src/main/java/io/supertokens/webserver/Webserver.java b/src/main/java/io/supertokens/webserver/Webserver.java index 6384205f8..45c262a64 100644 --- a/src/main/java/io/supertokens/webserver/Webserver.java +++ b/src/main/java/io/supertokens/webserver/Webserver.java @@ -40,7 +40,7 @@ import io.supertokens.webserver.api.multitenancy.thirdparty.CreateOrUpdateThirdPartyConfigAPI; import io.supertokens.webserver.api.multitenancy.thirdparty.RemoveThirdPartyConfigAPI; import io.supertokens.webserver.api.oauth.OAuthAuthAPI; -import io.supertokens.webserver.api.oauth.OAuthRegisterClientAPI; +import io.supertokens.webserver.api.oauth.OAuthClientsAPI; import io.supertokens.webserver.api.passwordless.*; import io.supertokens.webserver.api.session.*; import io.supertokens.webserver.api.thirdparty.GetUsersByEmailAPI; @@ -270,7 +270,7 @@ private void setupRoutes() { addAPI(new GetTenantCoreConfigForDashboardAPI(main)); addAPI(new OAuthAuthAPI(main)); - addAPI(new OAuthRegisterClientAPI(main)); + addAPI(new OAuthClientsAPI(main)); StandardContext context = tomcatReference.getContext(); Tomcat tomcat = tomcatReference.getTomcat(); diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthRegisterClientAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java similarity index 73% rename from src/main/java/io/supertokens/webserver/api/oauth/OAuthRegisterClientAPI.java rename to src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java index 05f36ef04..8e4ea4657 100644 --- a/src/main/java/io/supertokens/webserver/api/oauth/OAuthRegisterClientAPI.java +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java @@ -21,7 +21,9 @@ import io.supertokens.Main; import io.supertokens.multitenancy.exception.BadPermissionException; import io.supertokens.oauth.OAuth; +import io.supertokens.oauth.exceptions.OAuthClientException; import io.supertokens.oauth.exceptions.OAuthClientRegisterException; +import io.supertokens.oauth.exceptions.OAuthException; import io.supertokens.pluginInterface.RECIPE_ID; import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; @@ -41,7 +43,7 @@ import java.util.List; import java.util.Map; -public class OAuthRegisterClientAPI extends WebserverAPI { +public class OAuthClientsAPI extends WebserverAPI { @Serial private static final long serialVersionUID = -4482427281337641246L; @@ -53,9 +55,9 @@ public class OAuthRegisterClientAPI extends WebserverAPI { @Override public String getPath() { - return "/recipe/oauth/registerclient"; + return "/recipe/oauth/clients"; } - public OAuthRegisterClientAPI(Main main){ + public OAuthClientsAPI(Main main){ super(main, RECIPE_ID.OAUTH.toString()); } @@ -78,10 +80,7 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I } catch (OAuthClientRegisterException registerException) { - JsonObject errorResponse = new JsonObject(); - errorResponse.addProperty("error", registerException.error); - errorResponse.addProperty("error_description", registerException.errorDescription); - + JsonObject errorResponse = createJsonFromException(registerException); sendJsonResponse(400, errorResponse, resp); } catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException @@ -90,9 +89,38 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I } } + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { + String clientId = InputParser.getQueryParamOrThrowError(req, "clientId", false); + + try { + AppIdentifier appIdentifier = getAppIdentifier(req); + Storage storage = enforcePublicTenantAndGetPublicTenantStorage(req); + + JsonObject response = OAuth.loadOAuthClient(main, appIdentifier, storage, clientId); + sendJsonResponse(200, response, resp); + + } catch (OAuthClientException e) { + JsonObject errorResponse = createJsonFromException(e); + sendJsonResponse(400, errorResponse, resp); + + } catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException + | StorageQueryException e){ + throw new ServletException(e); + } + } + + private JsonObject createJsonFromException(OAuthException exception){ + JsonObject errorResponse = new JsonObject(); + errorResponse.addProperty("error", exception.error); + errorResponse.addProperty("error_description", exception.errorDescription); + + return errorResponse; + } + private boolean containsAllRequiredFields(JsonObject input){ boolean foundMissing = false; - for(String requiredField : OAuthRegisterClientAPI.REQUIRED_INPUT_FIELDS){ + for(String requiredField : OAuthClientsAPI.REQUIRED_INPUT_FIELDS){ if(input.get(requiredField) == null || input.get(requiredField).isJsonNull() || input.get(requiredField).getAsString().isEmpty()){ foundMissing = true; @@ -105,7 +133,7 @@ private boolean containsAllRequiredFields(JsonObject input){ private boolean containsMoreThanAllowed(JsonObject input) { boolean containsMore = false; for(Map.Entry jsonEntry : input.entrySet()){ - if(!OAuthRegisterClientAPI.ALLOWED_INPUT_FIELDS.contains(jsonEntry.getKey())){ + if(!OAuthClientsAPI.ALLOWED_INPUT_FIELDS.contains(jsonEntry.getKey())){ containsMore = true; break; } diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthRegisterClientAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java similarity index 73% rename from src/test/java/io/supertokens/test/oauth/api/OAuthRegisterClientAPITest.java rename to src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java index a95ceac1f..4d637ae8f 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthRegisterClientAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java @@ -20,6 +20,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import io.supertokens.ProcessState; +import io.supertokens.httpRequest.HttpRequest; import io.supertokens.pluginInterface.RECIPE_ID; import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.oauth.sqlStorage.OAuthSQLStorage; @@ -32,11 +33,13 @@ import org.junit.rules.TestRule; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; import static org.junit.Assert.*; import static org.junit.Assert.assertTrue; -public class OAuthRegisterClientAPITest { +public class OAuthClientsAPITest { TestingProcessManager.TestingProcess process; @Rule @@ -54,7 +57,8 @@ public void beforeEach() throws InterruptedException { @Test public void testClientRegisteredForApp() - throws HttpResponseException, IOException, StorageQueryException, InterruptedException { + throws HttpResponseException, IOException, StorageQueryException, InterruptedException, + io.supertokens.httpRequest.HttpResponseException { String[] args = {"../"}; this.process = TestingProcessManager.start(args); @@ -82,7 +86,7 @@ public void testClientRegisteredForApp() requestBody.add("responseTypes", responseTypes); JsonObject actualResponse = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", - "http://localhost:3567/recipe/oauth/registerclient", requestBody, 1000, 1000, null, + "http://localhost:3567/recipe/oauth/clients", requestBody, 1000, 1000, null, null, RECIPE_ID.OAUTH.toString()); assertTrue(actualResponse.has("clientSecret")); @@ -96,6 +100,15 @@ public void testClientRegisteredForApp() boolean clientShouldntExists = oAuthStorage.isClientIdAlreadyExists(clientId + "someRandomStringHere"); assertFalse(clientShouldntExists); + Map queryParams = new HashMap<>(); + queryParams.put("clientId", actualResponse.get("clientId").getAsString()); + JsonObject loadedClient = HttpRequest.sendGETRequest(process.getProcess(), "", + "http://localhost:3567/recipe/oauth/clients", queryParams,10000,10000, null); + + assertTrue(loadedClient.has("clientId")); + assertFalse(loadedClient.has("clientSecret")); //this should only be sent when registering + assertEquals(clientId, loadedClient.get("clientId").getAsString()); + process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); } @@ -129,7 +142,7 @@ public void testMissingRequiredField_throwsException() throws InterruptedExcept io.supertokens.test.httpRequest.HttpResponseException expected = assertThrows(io.supertokens.test.httpRequest.HttpResponseException.class, () -> { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", - "http://localhost:3567/recipe/oauth/registerclient", requestBody, 1000, 1000, null, + "http://localhost:3567/recipe/oauth/clients", requestBody, 1000, 1000, null, null, RECIPE_ID.OAUTH.toString()); }); @@ -171,7 +184,7 @@ public void testMoreFieldThanAllowed_throwsException() io.supertokens.test.httpRequest.HttpResponseException expected = assertThrows(io.supertokens.test.httpRequest.HttpResponseException.class, () -> { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", - "http://localhost:3567/recipe/oauth/registerclient", requestBody, 1000, 1000, null, + "http://localhost:3567/recipe/oauth/clients", requestBody, 1000, 1000, null, null, RECIPE_ID.OAUTH.toString()); }); @@ -182,4 +195,38 @@ public void testMoreFieldThanAllowed_throwsException() assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); } } + + + @Test + public void testGETClientNotExisting_returnsError() + throws StorageQueryException, InterruptedException { + + String[] args = {"../"}; + this.process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + String clientId = "not-an-existing-one"; + + OAuthSQLStorage oAuthStorage = (OAuthSQLStorage) StorageLayer.getStorage(process.getProcess()); + + + boolean isClientAlreadyExists = oAuthStorage.isClientIdAlreadyExists(clientId); + assertFalse(isClientAlreadyExists); + + boolean clientShouldntExists = oAuthStorage.isClientIdAlreadyExists(clientId + "someRandomStringHere"); + assertFalse(clientShouldntExists); + + Map queryParams = new HashMap<>(); + queryParams.put("clientId", clientId); + io.supertokens.httpRequest.HttpResponseException expected = assertThrows(io.supertokens.httpRequest.HttpResponseException.class, () -> { + + HttpRequest.sendGETRequest(process.getProcess(), "", + "http://localhost:3567/recipe/oauth/clients", queryParams, 10000, 10000, null); + }); + assertEquals(400, expected.statusCode); + assertEquals("Http error. Status Code: 400. Message: {\"error\":\"Unable to locate the resource\",\"error_description\":\"\"}", expected.getMessage()); + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); + } } From 3e3e5cff5e87ce13d9654306cb67e5183f084ef6 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Thu, 1 Aug 2024 17:36:53 +0200 Subject: [PATCH 26/30] feat: OAuth2 DELETE clients API --- CHANGELOG.md | 1 + .../java/io/supertokens/inmemorydb/Start.java | 9 +++++++ .../inmemorydb/queries/OAuthQueries.java | 10 ++++++++ src/main/java/io/supertokens/oauth/OAuth.java | 25 +++++++++++++++++++ .../webserver/api/oauth/OAuthClientsAPI.java | 25 +++++++++++++++++++ .../test/oauth/api/OAuthClientsAPITest.java | 12 +++++++++ 6 files changed, 82 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ec22d1d..ca1fa9b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Adds POST `/recipe/oauth/auth` for OAuth2 auth flow support - Adds POST `/recipe/oauth/clients` for OAuth2 client registration - Adds GET `/recipe/oauth/clients?clientId=example_id` for loading OAuth2 client +- Adds DELETE `/recipe/oauth/clients` for deleting OAuth2 Clients - Creates new table `oauth_clients` ### Migration diff --git a/src/main/java/io/supertokens/inmemorydb/Start.java b/src/main/java/io/supertokens/inmemorydb/Start.java index 71cc8a704..4b54b72c3 100644 --- a/src/main/java/io/supertokens/inmemorydb/Start.java +++ b/src/main/java/io/supertokens/inmemorydb/Start.java @@ -3046,4 +3046,13 @@ public boolean isClientIdAlreadyExists(String clientId) throws StorageQueryExcep throw new StorageQueryException(e); } } + + @Override + public void removeAppClientAssociation(AppIdentifier appIdentifier, String clientId) throws StorageQueryException { + try { + OAuthQueries.deleteClientIdForAppId(this, clientId, appIdentifier); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } } diff --git a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java index 927088127..96e2909f1 100644 --- a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java +++ b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java @@ -61,6 +61,16 @@ public static void insertClientIdForAppId(Start start, String clientId, AppIdent }); } + public static void deleteClientIdForAppId(Start start, String clientId, AppIdentifier appIdentifier) + throws SQLException, StorageQueryException { + String DELETE = "DELETE FROM " + Config.getConfig(start).getOAuthClientTable() + + " WHERE app_id = ? AND client_id = ?"; + update(start, DELETE, pst -> { + pst.setString(1, appIdentifier.getAppId()); + pst.setString(2, clientId); + }); + } + public static boolean isClientIdAlreadyExists(Start start, String clientId) throws SQLException, StorageQueryException { String QUERY = "SELECT client_id FROM " + Config.getConfig(start).getOAuthClientTable() + diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index 0dced5e52..d1f8f7667 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -164,6 +164,31 @@ public static JsonObject loadOAuthClient(Main main, AppIdentifier appIdentifier, } } + public static void deleteOAuthClient(Main main, AppIdentifier appIdentifier, Storage storage, String clientId) + throws TenantOrAppNotFoundException, OAuthClientException, InvalidConfigException, StorageQueryException, + IOException { + OAuthStorage oauthStorage = StorageUtils.getOAuthStorage(storage); + + String adminOAuthProviderServiceUrl = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOAuthProviderAdminServiceUrl(); + + if (!oauthStorage.doesClientIdExistForThisApp(appIdentifier, clientId)) { + throw new OAuthClientException("Unable to locate the resource", ""); + } else { + try { + HttpRequest.sendJsonDELETERequest(main, "", adminOAuthProviderServiceUrl + HYDRA_CLIENTS_ENDPOINT + "/" + clientId, null, 10000, 10000, null); + + oauthStorage.removeAppClientAssociation(appIdentifier, clientId); + } catch (HttpResponseException e) { + try { + throw createCustomExceptionFromHttpResponseException(e, OAuthClientException.class); + } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | + IllegalAccessException ex) { + throw new RuntimeException("Something went really wrong!"); + } + } + } + } + private static T createCustomExceptionFromHttpResponseException(HttpResponseException exception, Class customExceptionClass) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { String errorMessage = exception.rawMessage; diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java index 8e4ea4657..6b9de0ac7 100644 --- a/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java @@ -110,6 +110,31 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IO } } + @Override + protected void doDelete(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { + JsonObject requestBody = InputParser.parseJsonObjectOrThrowError(req); + + String clientId = InputParser.parseStringOrThrowError(requestBody, "clientId", false); + + try { + AppIdentifier appIdentifier = getAppIdentifier(req); + Storage storage = enforcePublicTenantAndGetPublicTenantStorage(req); + + OAuth.deleteOAuthClient(main, appIdentifier, storage, clientId); + JsonObject responseBody = new JsonObject(); + responseBody.addProperty("status", "OK"); + sendJsonResponse(200, responseBody, resp); + + } catch (OAuthClientException e) { + JsonObject errorResponse = createJsonFromException(e); + sendJsonResponse(400, errorResponse, resp); + + } catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException + | StorageQueryException e){ + throw new ServletException(e); + } + } + private JsonObject createJsonFromException(OAuthException exception){ JsonObject errorResponse = new JsonObject(); errorResponse.addProperty("error", exception.error); diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java index 4d637ae8f..cee8ca3e8 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java @@ -109,6 +109,18 @@ public void testClientRegisteredForApp() assertFalse(loadedClient.has("clientSecret")); //this should only be sent when registering assertEquals(clientId, loadedClient.get("clientId").getAsString()); + {//delete client + JsonObject deleteRequestBody = new JsonObject(); + deleteRequestBody.addProperty("clientId", clientId); + JsonObject deleteResponse = HttpRequestForTesting.sendJsonDELETERequest(process.getProcess(), "", + "http://localhost:3567/recipe/oauth/clients", deleteRequestBody, 1000, 1000, null, + null, RECIPE_ID.OAUTH.toString()); + + assertTrue(deleteResponse.isJsonObject()); + assertEquals("OK", deleteResponse.get("status").getAsString()); //empty response + + } + process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); } From 2ac627e173d9a4e3f3332710bdecf98e50c5e863 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Fri, 2 Aug 2024 15:36:20 +0200 Subject: [PATCH 27/30] fix: following the already existing response pattern with the oauth2 apis --- src/main/java/io/supertokens/oauth/OAuth.java | 57 +++--- ...java => OAuthClientNotFoundException.java} | 7 +- .../io/supertokens/webserver/InputParser.java | 18 ++ .../webserver/api/oauth/OAuthAuthAPI.java | 22 ++- .../webserver/api/oauth/OAuthClientsAPI.java | 68 +++---- .../test/oauth/api/OAuthAuthAPITest.java | 184 ++++++++++-------- .../test/oauth/api/OAuthClientsAPITest.java | 47 +++-- 7 files changed, 216 insertions(+), 187 deletions(-) rename src/main/java/io/supertokens/oauth/exceptions/{OAuthClientException.java => OAuthClientNotFoundException.java} (79%) diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index d1f8f7667..6c5f7a42d 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -23,10 +23,7 @@ import io.supertokens.config.Config; import io.supertokens.httpRequest.HttpRequest; import io.supertokens.httpRequest.HttpResponseException; -import io.supertokens.oauth.exceptions.OAuthAuthException; -import io.supertokens.oauth.exceptions.OAuthClientException; -import io.supertokens.oauth.exceptions.OAuthClientRegisterException; -import io.supertokens.oauth.exceptions.OAuthException; +import io.supertokens.oauth.exceptions.*; import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.StorageUtils; import io.supertokens.pluginInterface.exceptions.InvalidConfigException; @@ -55,8 +52,7 @@ public class OAuth { private static final String HYDRA_AUTH_ENDPOINT = "/oauth2/auth"; private static final String HYDRA_CLIENTS_ENDPOINT = "/admin/clients"; - public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier appIdentifier, Storage storage, String clientId, - String redirectURI, String responseType, String scope, String state) + public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier appIdentifier, Storage storage, JsonObject paramsFromSdk) throws InvalidConfigException, HttpResponseException, IOException, OAuthAuthException, StorageQueryException, TenantOrAppNotFoundException { @@ -69,11 +65,13 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app String hydraInternalAddress = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOauthProviderUrlConfiguredInHydra(); String hydraBaseUrlForConsentAndLogin = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOauthProviderConsentLoginBaseUrl(); + String clientId = paramsFromSdk.get("clientId").getAsString(); + if (!oauthStorage.doesClientIdExistForThisApp(appIdentifier, clientId)) { throw new OAuthAuthException("invalid_client", "Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). The requested OAuth 2.0 Client does not exist."); } else { // we query hydra - Map queryParamsForHydra = constructHydraRequestParamsForAuthorizationGETAPICall(clientId, redirectURI, responseType, scope, state); + Map queryParamsForHydra = constructHydraRequestParamsForAuthorizationGETAPICall(paramsFromSdk); Map> responseHeaders = new HashMap<>(); HttpRequest.sendGETRequestWithResponseHeaders(main, "", publicOAuthProviderServiceUrl + HYDRA_AUTH_ENDPOINT, queryParamsForHydra, 10000, 10000, null, responseHeaders, false); @@ -117,7 +115,7 @@ public static JsonObject registerOAuthClient(Main main, AppIdentifier appIdentif String clientId = "supertokens_" + Utils.hashSHA256Base64UrlSafe(idBaseBytes); try { if(oauthStorage.isClientIdAlreadyExists(clientId)){ - continue; // restart + continue; // restart, don't even try with Id which we know exists in hydra } JsonObject hydraRequestBody = constructHydraRequestParamsForRegisterClientPOST(paramsFromSdk, clientId); @@ -128,7 +126,14 @@ public static JsonObject registerOAuthClient(Main main, AppIdentifier appIdentif return formatResponseForSDK(hydraResponse); //sdk expects everything from hydra in camelCase } catch (HttpResponseException e) { try { - throw createCustomExceptionFromHttpResponseException(e, OAuthClientRegisterException.class); + if (e.statusCode == 409){ + //no-op + //client with id already exists, silently retry with different Id + } else { + //other error from hydra, like invalid content in json. Throw exception + throw createCustomExceptionFromHttpResponseException( + e, OAuthClientRegisterException.class); + } } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException ex) { throw new RuntimeException(ex); @@ -141,49 +146,48 @@ public static JsonObject registerOAuthClient(Main main, AppIdentifier appIdentif } public static JsonObject loadOAuthClient(Main main, AppIdentifier appIdentifier, Storage storage, String clientId) - throws TenantOrAppNotFoundException, OAuthClientException, InvalidConfigException, StorageQueryException, - IOException { + throws TenantOrAppNotFoundException, InvalidConfigException, StorageQueryException, + IOException, OAuthClientNotFoundException { OAuthStorage oauthStorage = StorageUtils.getOAuthStorage(storage); String adminOAuthProviderServiceUrl = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOAuthProviderAdminServiceUrl(); if (!oauthStorage.doesClientIdExistForThisApp(appIdentifier, clientId)) { - throw new OAuthClientException("Unable to locate the resource", ""); + throw new OAuthClientNotFoundException("Unable to locate the resource", ""); } else { try { JsonObject hydraResponse = HttpRequest.sendGETRequest(main, "", adminOAuthProviderServiceUrl + HYDRA_CLIENTS_ENDPOINT + "/" + clientId, null, 10000, 10000, null); return formatResponseForSDK(hydraResponse); } catch (HttpResponseException e) { try { - throw createCustomExceptionFromHttpResponseException(e, OAuthClientException.class); + throw createCustomExceptionFromHttpResponseException(e, OAuthClientNotFoundException.class); } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException ex) { - throw new RuntimeException("Something went really wrong!"); + throw new RuntimeException(ex); } } } } public static void deleteOAuthClient(Main main, AppIdentifier appIdentifier, Storage storage, String clientId) - throws TenantOrAppNotFoundException, OAuthClientException, InvalidConfigException, StorageQueryException, - IOException { + throws TenantOrAppNotFoundException, InvalidConfigException, StorageQueryException, + IOException, OAuthClientNotFoundException { OAuthStorage oauthStorage = StorageUtils.getOAuthStorage(storage); String adminOAuthProviderServiceUrl = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOAuthProviderAdminServiceUrl(); if (!oauthStorage.doesClientIdExistForThisApp(appIdentifier, clientId)) { - throw new OAuthClientException("Unable to locate the resource", ""); + throw new OAuthClientNotFoundException("Unable to locate the resource", ""); } else { try { - HttpRequest.sendJsonDELETERequest(main, "", adminOAuthProviderServiceUrl + HYDRA_CLIENTS_ENDPOINT + "/" + clientId, null, 10000, 10000, null); - oauthStorage.removeAppClientAssociation(appIdentifier, clientId); + HttpRequest.sendJsonDELETERequest(main, "", adminOAuthProviderServiceUrl + HYDRA_CLIENTS_ENDPOINT + "/" + clientId, null, 10000, 10000, null); } catch (HttpResponseException e) { try { - throw createCustomExceptionFromHttpResponseException(e, OAuthClientException.class); + throw createCustomExceptionFromHttpResponseException(e, OAuthClientNotFoundException.class); } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException ex) { - throw new RuntimeException("Something went really wrong!"); + throw new RuntimeException(ex); } } } @@ -228,14 +232,11 @@ private static JsonObject formatResponseForSDK(JsonObject response) { return formattedResponse; } - private static Map constructHydraRequestParamsForAuthorizationGETAPICall(String clientId, - String redirectURI, String responseType, String scope, String state) { + private static Map constructHydraRequestParamsForAuthorizationGETAPICall(JsonObject inputFromSdk) { Map queryParamsForHydra = new HashMap<>(); - queryParamsForHydra.put("client_id", clientId); - queryParamsForHydra.put("redirect_uri", redirectURI); - queryParamsForHydra.put("scope", scope); - queryParamsForHydra.put("response_type", responseType); - queryParamsForHydra.put("state", state); + for(Map.Entry jsonElement : inputFromSdk.entrySet()){ + queryParamsForHydra.put(Utils.camelCaseToSnakeCase(jsonElement.getKey()), jsonElement.getValue().getAsString()); + } return queryParamsForHydra; } diff --git a/src/main/java/io/supertokens/oauth/exceptions/OAuthClientException.java b/src/main/java/io/supertokens/oauth/exceptions/OAuthClientNotFoundException.java similarity index 79% rename from src/main/java/io/supertokens/oauth/exceptions/OAuthClientException.java rename to src/main/java/io/supertokens/oauth/exceptions/OAuthClientNotFoundException.java index eee0504b5..77b3df4f7 100644 --- a/src/main/java/io/supertokens/oauth/exceptions/OAuthClientException.java +++ b/src/main/java/io/supertokens/oauth/exceptions/OAuthClientNotFoundException.java @@ -18,12 +18,11 @@ import java.io.Serial; -public class OAuthClientException extends OAuthException{ - +public class OAuthClientNotFoundException extends OAuthException{ @Serial - private static final long serialVersionUID = -140335439416174384L; + private static final long serialVersionUID = 1412853176388698991L; - public OAuthClientException(String error, String errorDescription) { + public OAuthClientNotFoundException(String error, String errorDescription) { super(error, errorDescription); } } diff --git a/src/main/java/io/supertokens/webserver/InputParser.java b/src/main/java/io/supertokens/webserver/InputParser.java index b10028746..7b9b6338d 100644 --- a/src/main/java/io/supertokens/webserver/InputParser.java +++ b/src/main/java/io/supertokens/webserver/InputParser.java @@ -21,12 +21,15 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import io.supertokens.webserver.api.oauth.OAuthClientsAPI; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import java.io.BufferedReader; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; public class InputParser { public static JsonObject parseJsonObjectOrThrowError(HttpServletRequest request) @@ -236,4 +239,19 @@ public static Integer parseIntOrThrowError(JsonObject element, String fieldName, } + public static List collectAllMissingRequiredFieldsAndThrowError(JsonObject input, List requiredFields) + throws ServletException { + List missingFields = new ArrayList<>(); + for(String requiredField : requiredFields){ + if(input.get(requiredField) == null || input.get(requiredField).isJsonNull() || + input.get(requiredField).getAsString().isEmpty()){ + missingFields.add(requiredField); + } + } + if(!missingFields.isEmpty()){ + throw new ServletException(new WebserverAPI.BadRequestException("Field name `" + String.join("','", missingFields) + + "` is missing in JSON input")); + } + return missingFields; + } } diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java index 83ccb5edd..d71f39a1b 100644 --- a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java @@ -37,6 +37,8 @@ import java.io.IOException; import java.io.Serial; +import java.util.Arrays; +import java.util.List; public class OAuthAuthAPI extends WebserverAPI { @Serial @@ -46,6 +48,9 @@ public OAuthAuthAPI(Main main) { super(main, RECIPE_ID.OAUTH.toString()); } + private static final List REQUIRED_FIELDS_FOR_POST = Arrays.asList(new String[]{"clientId", "scope", + "state", "redirectUri", "responseType"}); + @Override public String getPath() { return "/recipe/oauth/auth"; @@ -53,19 +58,16 @@ public String getPath() { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { + JsonObject input = InputParser.parseJsonObjectOrThrowError(req); - String clientId = InputParser.parseStringOrThrowError(input, "clientId", false); - String redirectUri = InputParser.parseStringOrThrowError(input, "redirectUri", false); - String responseType = InputParser.parseStringOrThrowError(input, "responseType", false); - String scope = InputParser.parseStringOrThrowError(input, "scope", false); - String state = InputParser.parseStringOrThrowError(input, "state", false); + InputParser.collectAllMissingRequiredFieldsAndThrowError(input, REQUIRED_FIELDS_FOR_POST); try { AppIdentifier appIdentifier = getAppIdentifier(req); Storage storage = enforcePublicTenantAndGetPublicTenantStorage(req); OAuthAuthResponse authResponse = OAuth.getAuthorizationUrl(super.main, appIdentifier, storage, - clientId, redirectUri, responseType, scope, state); + input); JsonObject response = new JsonObject(); response.addProperty("redirectTo", authResponse.redirectTo); @@ -76,16 +78,16 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I } } response.add("cookies", jsonCookies); - + response.addProperty("status", "OK"); super.sendJsonResponse(200, response, resp); } catch (OAuthAuthException authException) { JsonObject errorResponse = new JsonObject(); errorResponse.addProperty("error", authException.error); - errorResponse.addProperty("error_description", authException.errorDescription); - - super.sendJsonResponse(400, errorResponse, resp); + errorResponse.addProperty("errorDescription", authException.errorDescription); + errorResponse.addProperty("status", "OAUTH2_AUTH_ERROR"); + super.sendJsonResponse(200, errorResponse, resp); } catch (TenantOrAppNotFoundException | InvalidConfigException | HttpResponseException | StorageQueryException | BadPermissionException e) { diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java index 6b9de0ac7..bbfe26f71 100644 --- a/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java @@ -16,12 +16,11 @@ package io.supertokens.webserver.api.oauth; -import com.google.gson.JsonElement; import com.google.gson.JsonObject; import io.supertokens.Main; import io.supertokens.multitenancy.exception.BadPermissionException; import io.supertokens.oauth.OAuth; -import io.supertokens.oauth.exceptions.OAuthClientException; +import io.supertokens.oauth.exceptions.OAuthClientNotFoundException; import io.supertokens.oauth.exceptions.OAuthClientRegisterException; import io.supertokens.oauth.exceptions.OAuthException; import io.supertokens.pluginInterface.RECIPE_ID; @@ -39,19 +38,17 @@ import java.io.IOException; import java.io.Serial; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Map; public class OAuthClientsAPI extends WebserverAPI { @Serial private static final long serialVersionUID = -4482427281337641246L; - private static final List ALLOWED_INPUT_FIELDS = Arrays.asList(new String[]{"clientName","scope", "redirectUris", "allowedCorsOrigins", "authorizationCodeGrantAccessTokenLifespan", "authorizationCodeGrantIdTokenLifespan", "authorizationCodeGrantRefreshTokenLifespan", - "clientCredentialsGrantAccessTokenLifespan","implicitGrantAccessTokenLifespan","implicitGrantIdTokenLifespan","refreshTokenGrantAccessTokenLifespan","refreshTokenGrantIdTokenLifespan","refreshTokenGrantRefreshTokenLifespan","tokenEndpointAuthMethod","audience", - "grantTypes","responseTypes","clientUri","logoUri","policyUri","tosUri","metadata"}); - private static final List REQUIRED_INPUT_FIELDS = Arrays.asList(new String[]{"clientName", "scope"}); + private static final List REQUIRED_INPUT_FIELDS_FOR_POST = Arrays.asList(new String[]{"clientName", "scope"}); + public static final String OAUTH_2_CLIENT_NOT_FOUND = "OAUTH2_CLIENT_NOT_FOUND"; @Override public String getPath() { @@ -63,25 +60,25 @@ public OAuthClientsAPI(Main main){ @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { - JsonObject input = InputParser.parseJsonObjectOrThrowError(req); - boolean containsAllRequired = containsAllRequiredFields(input); - boolean containsMoreThanAllowed = containsMoreThanAllowed(input); - if(!containsAllRequired || containsMoreThanAllowed){ - throw new ServletException(new WebserverAPI.BadRequestException("Invalid Json Input")); - } + JsonObject input = InputParser.parseJsonObjectOrThrowError(req); + InputParser.collectAllMissingRequiredFieldsAndThrowError(input, REQUIRED_INPUT_FIELDS_FOR_POST); try { AppIdentifier appIdentifier = getAppIdentifier(req); Storage storage = enforcePublicTenantAndGetPublicTenantStorage(req); JsonObject response = OAuth.registerOAuthClient(super.main, appIdentifier, storage, input); - sendJsonResponse(200, response, resp); + JsonObject postResponseBody = new JsonObject(); + postResponseBody.addProperty("status", "OK"); + postResponseBody.add("client", response); + sendJsonResponse(200, postResponseBody, resp); } catch (OAuthClientRegisterException registerException) { JsonObject errorResponse = createJsonFromException(registerException); - sendJsonResponse(400, errorResponse, resp); + errorResponse.addProperty("status", "INVALID_INPUT"); + sendJsonResponse(200, errorResponse, resp); } catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException | NoSuchAlgorithmException | StorageQueryException e) { @@ -97,12 +94,17 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IO AppIdentifier appIdentifier = getAppIdentifier(req); Storage storage = enforcePublicTenantAndGetPublicTenantStorage(req); - JsonObject response = OAuth.loadOAuthClient(main, appIdentifier, storage, clientId); + JsonObject client = OAuth.loadOAuthClient(main, appIdentifier, storage, clientId); + + JsonObject response = new JsonObject(); + response.addProperty("status", "OK"); + response.add("client", client); sendJsonResponse(200, response, resp); - } catch (OAuthClientException e) { + } catch (OAuthClientNotFoundException e) { JsonObject errorResponse = createJsonFromException(e); - sendJsonResponse(400, errorResponse, resp); + errorResponse.addProperty("status", OAUTH_2_CLIENT_NOT_FOUND); + sendJsonResponse(200, errorResponse, resp); } catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException | StorageQueryException e){ @@ -125,9 +127,10 @@ protected void doDelete(HttpServletRequest req, HttpServletResponse resp) throws responseBody.addProperty("status", "OK"); sendJsonResponse(200, responseBody, resp); - } catch (OAuthClientException e) { + } catch (OAuthClientNotFoundException e) { JsonObject errorResponse = createJsonFromException(e); - sendJsonResponse(400, errorResponse, resp); + errorResponse.addProperty("status", OAUTH_2_CLIENT_NOT_FOUND); + sendJsonResponse(200, errorResponse, resp); } catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException | StorageQueryException e){ @@ -138,31 +141,8 @@ protected void doDelete(HttpServletRequest req, HttpServletResponse resp) throws private JsonObject createJsonFromException(OAuthException exception){ JsonObject errorResponse = new JsonObject(); errorResponse.addProperty("error", exception.error); - errorResponse.addProperty("error_description", exception.errorDescription); + errorResponse.addProperty("errorDescription", exception.errorDescription); return errorResponse; } - - private boolean containsAllRequiredFields(JsonObject input){ - boolean foundMissing = false; - for(String requiredField : OAuthClientsAPI.REQUIRED_INPUT_FIELDS){ - if(input.get(requiredField) == null || input.get(requiredField).isJsonNull() || - input.get(requiredField).getAsString().isEmpty()){ - foundMissing = true; - break; - } - } - return !foundMissing; - } - - private boolean containsMoreThanAllowed(JsonObject input) { - boolean containsMore = false; - for(Map.Entry jsonEntry : input.entrySet()){ - if(!OAuthClientsAPI.ALLOWED_INPUT_FIELDS.contains(jsonEntry.getKey())){ - containsMore = true; - break; - } - } - return containsMore; - } } diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java index 94f4e42a3..424574468 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthAuthAPITest.java @@ -54,25 +54,19 @@ public static void afterTesting() { @Before public void beforeEach() throws InterruptedException { Utils.reset(); - - String[] args = {"../"}; - - this.process = TestingProcessManager.start(args); - assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); } - @After - public void afterEach() throws InterruptedException { - process.kill(); - assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); - } - @Test public void testLocalhostChangedToApiDomain() throws StorageQueryException, OAuthAuthException, HttpResponseException, TenantOrAppNotFoundException, InvalidConfigException, IOException, OAuth2ClientAlreadyExistsForAppException, - io.supertokens.test.httpRequest.HttpResponseException { + io.supertokens.test.httpRequest.HttpResponseException, InterruptedException { + + String[] args = {"../"}; + + this.process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); String clientId = "6030f07e-c8ef-4289-80c9-c18e0bf4f679"; String redirectUri = "http://localhost.com:3031/auth/callback/ory"; @@ -85,7 +79,15 @@ public void testLocalhostChangedToApiDomain() AppIdentifier testApp = new AppIdentifier("", ""); oAuthStorage.addClientForApp(testApp, clientId); - OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), oAuthStorage, clientId, redirectUri, responseType, scope, state); + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("clientId", clientId); + requestBody.addProperty("redirectUri", redirectUri); + requestBody.addProperty("responseType", responseType); + requestBody.addProperty("scope", scope); + requestBody.addProperty("state", state); + + OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), + oAuthStorage, requestBody); assertNotNull(response); assertNotNull(response.redirectTo); @@ -95,30 +97,34 @@ public void testLocalhostChangedToApiDomain() assertTrue(response.cookies.get(0).startsWith("ory_hydra_login_csrf_dev_134972871=")); - { - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("clientId", clientId); - requestBody.addProperty("redirectUri", redirectUri); - requestBody.addProperty("responseType", responseType); - requestBody.addProperty("scope", scope); - requestBody.addProperty("state", state); + { JsonObject actualResponse = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/recipe/oauth/auth", requestBody, 1000, 1000, null, null, RECIPE_ID.OAUTH.toString()); - + assertEquals("OK", actualResponse.get("status").getAsString()); assertTrue(actualResponse.has("redirectTo")); assertTrue(actualResponse.has("cookies")); assertTrue(actualResponse.get("redirectTo").getAsString().startsWith("{apiDomain}/login?login_challenge=")); assertEquals(1, actualResponse.getAsJsonArray("cookies").size()); assertTrue(actualResponse.getAsJsonArray("cookies").get(0).getAsString().startsWith("ory_hydra_login_csrf_dev_134972871=")); } + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); } @Test public void testCalledWithWrongClientIdNotInST_exceptionThrown() - throws StorageQueryException, OAuth2ClientAlreadyExistsForAppException, IOException { + throws StorageQueryException, OAuth2ClientAlreadyExistsForAppException, IOException, + io.supertokens.test.httpRequest.HttpResponseException, InterruptedException { + + + String[] args = {"../"}; + + this.process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); String clientId = "Not-Existing-In-Client-App-Table"; String redirectUri = "http://localhost.com:3031/auth/callback/ory"; @@ -126,6 +132,13 @@ public void testCalledWithWrongClientIdNotInST_exceptionThrown() String scope = "profile"; String state = "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BDv%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD"; + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("clientId", clientId); + requestBody.addProperty("redirectUri", redirectUri); + requestBody.addProperty("responseType", responseType); + requestBody.addProperty("scope", scope); + requestBody.addProperty("state", state); + OAuthSQLStorage oAuthStorage = (OAuthSQLStorage) StorageLayer.getStorage(process.getProcess()); AppIdentifier testApp = new AppIdentifier("", ""); @@ -133,7 +146,8 @@ public void testCalledWithWrongClientIdNotInST_exceptionThrown() OAuthAuthException thrown = assertThrows(OAuthAuthException.class, () -> { - OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), oAuthStorage, clientId, redirectUri, responseType, scope, state); + OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), + oAuthStorage, requestBody); }); String expectedError = "invalid_client"; @@ -143,29 +157,31 @@ public void testCalledWithWrongClientIdNotInST_exceptionThrown() assertEquals(expectedDescription, thrown.errorDescription); { - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("clientId", clientId); - requestBody.addProperty("redirectUri", redirectUri); - requestBody.addProperty("responseType", responseType); - requestBody.addProperty("scope", scope); - requestBody.addProperty("state", state); - - assertThrows(io.supertokens.test.httpRequest.HttpResponseException.class, () -> { JsonObject actualResponse = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", - "http://localhost:3567/recipe/oauth/auth", requestBody, 1000, 1000, null, - null, RECIPE_ID.OAUTH.toString()); - - assertTrue(actualResponse.has("error")); - assertTrue(actualResponse.has("error_description")); - assertEquals(expectedError,actualResponse.get("error").getAsString()); - assertEquals(expectedDescription, actualResponse.get("error_description").getAsString()); - }); + "http://localhost:3567/recipe/oauth/auth", requestBody, 1000, 1000, null, + null, RECIPE_ID.OAUTH.toString()); + + assertEquals("OAUTH2_AUTH_ERROR", actualResponse.get("status").getAsString()); + assertTrue(actualResponse.has("error")); + assertTrue(actualResponse.has("errorDescription")); + assertEquals(expectedError,actualResponse.get("error").getAsString()); + assertEquals(expectedDescription, actualResponse.get("errorDescription").getAsString()); } + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); } @Test public void testCalledWithWrongClientIdNotInHydraButInST_exceptionThrown() - throws StorageQueryException, OAuth2ClientAlreadyExistsForAppException { + throws StorageQueryException, OAuth2ClientAlreadyExistsForAppException, + io.supertokens.test.httpRequest.HttpResponseException, IOException, InterruptedException { + + + String[] args = {"../"}; + + this.process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); String clientId = "6030f07e-c8ef-4289-80c9-c18e0bf4f679NotInHydra"; String redirectUri = "http://localhost.com:3031/auth/callback/ory"; @@ -175,12 +191,20 @@ public void testCalledWithWrongClientIdNotInHydraButInST_exceptionThrown() OAuthSQLStorage oAuthStorage = (OAuthSQLStorage) StorageLayer.getStorage(process.getProcess()); + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("clientId", clientId); + requestBody.addProperty("redirectUri", redirectUri); + requestBody.addProperty("responseType", responseType); + requestBody.addProperty("scope", scope); + requestBody.addProperty("state", state); + AppIdentifier testApp = new AppIdentifier("", ""); oAuthStorage.addClientForApp(testApp, clientId); OAuthAuthException thrown = assertThrows(OAuthAuthException.class, () -> { - OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), oAuthStorage, clientId, redirectUri, responseType, scope, state); + OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), + oAuthStorage, requestBody); }); String expectedError = "invalid_client"; @@ -190,29 +214,32 @@ public void testCalledWithWrongClientIdNotInHydraButInST_exceptionThrown() assertEquals(expectedDescription, thrown.errorDescription); { - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("clientId", clientId); - requestBody.addProperty("redirectUri", redirectUri); - requestBody.addProperty("responseType", responseType); - requestBody.addProperty("scope", scope); - requestBody.addProperty("state", state); - - assertThrows(io.supertokens.test.httpRequest.HttpResponseException.class, () -> { - JsonObject actualResponse = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", - "http://localhost:3567/recipe/oauth/auth", requestBody, 1000, 1000, null, - null, RECIPE_ID.OAUTH.toString()); - - assertTrue(actualResponse.has("error")); - assertTrue(actualResponse.has("error_description")); - assertEquals(expectedError,actualResponse.get("error").getAsString()); - assertEquals(expectedDescription, actualResponse.get("error_description").getAsString()); - }); + JsonObject actualResponse = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/oauth/auth", requestBody, 1000, 1000, null, + null, RECIPE_ID.OAUTH.toString()); + + assertEquals("OAUTH2_AUTH_ERROR", actualResponse.get("status").getAsString()); + assertTrue(actualResponse.has("error")); + assertTrue(actualResponse.has("errorDescription")); + assertEquals(expectedError,actualResponse.get("error").getAsString()); + assertEquals(expectedDescription, actualResponse.get("errorDescription").getAsString()); + } + + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); } @Test public void testCalledWithWrongRedirectUrl_exceptionThrown() - throws StorageQueryException, OAuth2ClientAlreadyExistsForAppException { + throws StorageQueryException, OAuth2ClientAlreadyExistsForAppException, + io.supertokens.test.httpRequest.HttpResponseException, IOException, InterruptedException { + + + String[] args = {"../"}; + + this.process = TestingProcessManager.start(args); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); String clientId = "6030f07e-c8ef-4289-80c9-c18e0bf4f679"; String redirectUri = "http://localhost.com:3031/auth/callback/ory_not_the_registered_one"; @@ -220,6 +247,13 @@ public void testCalledWithWrongRedirectUrl_exceptionThrown() String scope = "profile"; String state = "%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BDv%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD%EF%BF%BD"; + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("clientId", clientId); + requestBody.addProperty("redirectUri", redirectUri); + requestBody.addProperty("responseType", responseType); + requestBody.addProperty("scope", scope); + requestBody.addProperty("state", state); + OAuthSQLStorage oAuthStorage = (OAuthSQLStorage) StorageLayer.getStorage(process.getProcess()); AppIdentifier testApp = new AppIdentifier("", ""); @@ -228,7 +262,7 @@ public void testCalledWithWrongRedirectUrl_exceptionThrown() OAuthAuthException thrown = assertThrows(OAuthAuthException.class, () -> { OAuthAuthResponse response = OAuth.getAuthorizationUrl(process.getProcess(), new AppIdentifier("", ""), - oAuthStorage, clientId, redirectUri, responseType, scope, state); + oAuthStorage, requestBody); }); String expectedError = "invalid_request"; @@ -238,23 +272,19 @@ public void testCalledWithWrongRedirectUrl_exceptionThrown() assertEquals(expectedDescription, thrown.errorDescription); { - JsonObject requestBody = new JsonObject(); - requestBody.addProperty("clientId", clientId); - requestBody.addProperty("redirectUri", redirectUri); - requestBody.addProperty("responseType", responseType); - requestBody.addProperty("scope", scope); - requestBody.addProperty("state", state); - - assertThrows(io.supertokens.test.httpRequest.HttpResponseException.class, () -> { - JsonObject actualResponse = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", - "http://localhost:3567/recipe/oauth/auth", requestBody, 1000, 1000, null, - null, RECIPE_ID.OAUTH.toString()); - - assertTrue(actualResponse.has("error")); - assertTrue(actualResponse.has("error_description")); - assertEquals(expectedError, actualResponse.get("error").getAsString()); - assertEquals(expectedDescription, actualResponse.get("error_description").getAsString()); - }); + + JsonObject actualResponse = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/oauth/auth", requestBody, 1000, 1000, null, + null, RECIPE_ID.OAUTH.toString()); + + assertEquals("OAUTH2_AUTH_ERROR", actualResponse.get("status").getAsString()); + assertTrue(actualResponse.has("error")); + assertTrue(actualResponse.has("errorDescription")); + assertEquals(expectedError, actualResponse.get("error").getAsString()); + assertEquals(expectedDescription, actualResponse.get("errorDescription").getAsString()); + } + process.kill(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); } } diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java index cee8ca3e8..dcd1f781f 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java @@ -89,10 +89,13 @@ public void testClientRegisteredForApp() "http://localhost:3567/recipe/oauth/clients", requestBody, 1000, 1000, null, null, RECIPE_ID.OAUTH.toString()); - assertTrue(actualResponse.has("clientSecret")); - assertTrue(actualResponse.has("clientId")); + assertTrue(actualResponse.has("client")); + JsonObject client = actualResponse.get("client").getAsJsonObject(); - String clientId = actualResponse.get("clientId").getAsString(); + assertTrue(client.has("clientSecret")); + assertTrue(client.has("clientId")); + + String clientId = client.get("clientId").getAsString(); boolean isClientAlreadyExists = oAuthStorage.isClientIdAlreadyExists(clientId); assertTrue(isClientAlreadyExists); @@ -101,13 +104,14 @@ public void testClientRegisteredForApp() assertFalse(clientShouldntExists); Map queryParams = new HashMap<>(); - queryParams.put("clientId", actualResponse.get("clientId").getAsString()); + queryParams.put("clientId", client.get("clientId").getAsString()); JsonObject loadedClient = HttpRequest.sendGETRequest(process.getProcess(), "", "http://localhost:3567/recipe/oauth/clients", queryParams,10000,10000, null); - assertTrue(loadedClient.has("clientId")); - assertFalse(loadedClient.has("clientSecret")); //this should only be sent when registering - assertEquals(clientId, loadedClient.get("clientId").getAsString()); + assertTrue(loadedClient.has("client")); + JsonObject loadedClientJson = loadedClient.get("client").getAsJsonObject(); + assertFalse(loadedClientJson.has("clientSecret")); //this should only be sent when registering + assertEquals(clientId, loadedClientJson.get("clientId").getAsString()); {//delete client JsonObject deleteRequestBody = new JsonObject(); @@ -136,7 +140,6 @@ public void testMissingRequiredField_throwsException() throws InterruptedExcept String clientName = "jozef"; //notice missing 'scope' field! - { JsonObject requestBody = new JsonObject(); requestBody.addProperty("clientName", clientName); @@ -159,7 +162,7 @@ public void testMissingRequiredField_throwsException() throws InterruptedExcept }); assertEquals(400, expected.statusCode); - assertEquals("Http error. Status Code: 400. Message: Invalid Json Input", expected.getMessage()); + assertEquals("Http error. Status Code: 400. Message: Field name `scope` is missing in JSON input", expected.getMessage()); process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); @@ -167,8 +170,8 @@ public void testMissingRequiredField_throwsException() throws InterruptedExcept } @Test - public void testMoreFieldThanAllowed_throwsException() - throws InterruptedException { + public void testMoreFieldAreIgnored() + throws InterruptedException, HttpResponseException, IOException { String[] args = {"../"}; this.process = TestingProcessManager.start(args); @@ -194,15 +197,12 @@ public void testMoreFieldThanAllowed_throwsException() responseTypes.add(new JsonPrimitive("id_token")); requestBody.add("responseTypes", responseTypes); - io.supertokens.test.httpRequest.HttpResponseException expected = assertThrows(io.supertokens.test.httpRequest.HttpResponseException.class, () -> { - HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + JsonObject response = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/recipe/oauth/clients", requestBody, 1000, 1000, null, null, RECIPE_ID.OAUTH.toString()); - }); - - assertEquals(400, expected.statusCode); - assertEquals("Http error. Status Code: 400. Message: Invalid Json Input", expected.getMessage()); + assertEquals("OK", response.get("status").getAsString()); + assertTrue(response.has("client")); process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); } @@ -211,7 +211,8 @@ public void testMoreFieldThanAllowed_throwsException() @Test public void testGETClientNotExisting_returnsError() - throws StorageQueryException, InterruptedException { + throws StorageQueryException, InterruptedException, io.supertokens.httpRequest.HttpResponseException, + IOException { String[] args = {"../"}; this.process = TestingProcessManager.start(args); @@ -230,13 +231,11 @@ public void testGETClientNotExisting_returnsError() Map queryParams = new HashMap<>(); queryParams.put("clientId", clientId); - io.supertokens.httpRequest.HttpResponseException expected = assertThrows(io.supertokens.httpRequest.HttpResponseException.class, () -> { - - HttpRequest.sendGETRequest(process.getProcess(), "", + JsonObject response = HttpRequest.sendGETRequest(process.getProcess(), "", "http://localhost:3567/recipe/oauth/clients", queryParams, 10000, 10000, null); - }); - assertEquals(400, expected.statusCode); - assertEquals("Http error. Status Code: 400. Message: {\"error\":\"Unable to locate the resource\",\"error_description\":\"\"}", expected.getMessage()); + + assertEquals("OAUTH2_CLIENT_NOT_FOUND", response.get("status").getAsString()); + assertEquals("Unable to locate the resource", response.get("error").getAsString()); process.kill(); assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED)); From 70443952795ba10005022e31e23d8e196fbf0037 Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Fri, 2 Aug 2024 15:40:34 +0200 Subject: [PATCH 28/30] fix: renaming exception to be more expressive --- src/main/java/io/supertokens/oauth/OAuth.java | 5 +++-- ...on.java => OAuthClientRegisterInvalidInputException.java} | 4 ++-- .../io/supertokens/webserver/api/oauth/OAuthClientsAPI.java | 5 ++--- 3 files changed, 7 insertions(+), 7 deletions(-) rename src/main/java/io/supertokens/oauth/exceptions/{OAuthClientRegisterException.java => OAuthClientRegisterInvalidInputException.java} (84%) diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index 6c5f7a42d..f6476283d 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -102,7 +102,8 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app //This more or less acts as a pass-through for the sdks, apart from camelCase <-> snake_case key transformation and setting a few default values public static JsonObject registerOAuthClient(Main main, AppIdentifier appIdentifier, Storage storage, JsonObject paramsFromSdk) - throws TenantOrAppNotFoundException, InvalidConfigException, IOException, OAuthClientRegisterException, + throws TenantOrAppNotFoundException, InvalidConfigException, IOException, + OAuthClientRegisterInvalidInputException, NoSuchAlgorithmException, StorageQueryException { OAuthStorage oauthStorage = StorageUtils.getOAuthStorage(storage); @@ -132,7 +133,7 @@ public static JsonObject registerOAuthClient(Main main, AppIdentifier appIdentif } else { //other error from hydra, like invalid content in json. Throw exception throw createCustomExceptionFromHttpResponseException( - e, OAuthClientRegisterException.class); + e, OAuthClientRegisterInvalidInputException.class); } } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException ex) { diff --git a/src/main/java/io/supertokens/oauth/exceptions/OAuthClientRegisterException.java b/src/main/java/io/supertokens/oauth/exceptions/OAuthClientRegisterInvalidInputException.java similarity index 84% rename from src/main/java/io/supertokens/oauth/exceptions/OAuthClientRegisterException.java rename to src/main/java/io/supertokens/oauth/exceptions/OAuthClientRegisterInvalidInputException.java index f7e0ff9b5..7b6247687 100644 --- a/src/main/java/io/supertokens/oauth/exceptions/OAuthClientRegisterException.java +++ b/src/main/java/io/supertokens/oauth/exceptions/OAuthClientRegisterInvalidInputException.java @@ -18,12 +18,12 @@ import java.io.Serial; -public class OAuthClientRegisterException extends OAuthException{ +public class OAuthClientRegisterInvalidInputException extends OAuthException{ @Serial private static final long serialVersionUID = 665027786586190611L; - public OAuthClientRegisterException(String error, String errorDescription) { + public OAuthClientRegisterInvalidInputException(String error, String errorDescription) { super(error, errorDescription); } } diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java index bbfe26f71..27a16de34 100644 --- a/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java @@ -21,7 +21,7 @@ import io.supertokens.multitenancy.exception.BadPermissionException; import io.supertokens.oauth.OAuth; import io.supertokens.oauth.exceptions.OAuthClientNotFoundException; -import io.supertokens.oauth.exceptions.OAuthClientRegisterException; +import io.supertokens.oauth.exceptions.OAuthClientRegisterInvalidInputException; import io.supertokens.oauth.exceptions.OAuthException; import io.supertokens.pluginInterface.RECIPE_ID; import io.supertokens.pluginInterface.Storage; @@ -38,7 +38,6 @@ import java.io.IOException; import java.io.Serial; import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -74,7 +73,7 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I postResponseBody.add("client", response); sendJsonResponse(200, postResponseBody, resp); - } catch (OAuthClientRegisterException registerException) { + } catch (OAuthClientRegisterInvalidInputException registerException) { JsonObject errorResponse = createJsonFromException(registerException); errorResponse.addProperty("status", "INVALID_INPUT"); From a58f329acc90ec0ad30d6bad60219956cdd5241e Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Mon, 5 Aug 2024 11:25:31 +0200 Subject: [PATCH 29/30] fix: review fixes --- .../java/io/supertokens/inmemorydb/Start.java | 13 +--- .../inmemorydb/queries/OAuthQueries.java | 14 +--- src/main/java/io/supertokens/oauth/OAuth.java | 3 - src/main/java/io/supertokens/utils/Utils.java | 22 +++--- .../io/supertokens/webserver/InputParser.java | 2 +- .../webserver/api/oauth/OAuthAuthAPI.java | 5 +- .../webserver/api/oauth/OAuthClientsAPI.java | 12 +-- .../java/io/supertokens/test/UtilsTest.java | 75 +++++++++++++++++++ .../test/oauth/api/OAuthClientsAPITest.java | 22 +----- 9 files changed, 105 insertions(+), 63 deletions(-) diff --git a/src/main/java/io/supertokens/inmemorydb/Start.java b/src/main/java/io/supertokens/inmemorydb/Start.java index 4b54b72c3..f0f928a6b 100644 --- a/src/main/java/io/supertokens/inmemorydb/Start.java +++ b/src/main/java/io/supertokens/inmemorydb/Start.java @@ -3039,18 +3039,9 @@ public void addClientForApp(AppIdentifier appIdentifier, String clientId) } @Override - public boolean isClientIdAlreadyExists(String clientId) throws StorageQueryException { + public boolean removeAppClientAssociation(AppIdentifier appIdentifier, String clientId) throws StorageQueryException { try { - return OAuthQueries.isClientIdAlreadyExists(this, clientId); - } catch (SQLException e) { - throw new StorageQueryException(e); - } - } - - @Override - public void removeAppClientAssociation(AppIdentifier appIdentifier, String clientId) throws StorageQueryException { - try { - OAuthQueries.deleteClientIdForAppId(this, clientId, appIdentifier); + return OAuthQueries.deleteClientIdForAppId(this, clientId, appIdentifier); } catch (SQLException e) { throw new StorageQueryException(e); } diff --git a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java index 96e2909f1..f0441cbe2 100644 --- a/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java +++ b/src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java @@ -61,23 +61,15 @@ public static void insertClientIdForAppId(Start start, String clientId, AppIdent }); } - public static void deleteClientIdForAppId(Start start, String clientId, AppIdentifier appIdentifier) + public static boolean deleteClientIdForAppId(Start start, String clientId, AppIdentifier appIdentifier) throws SQLException, StorageQueryException { String DELETE = "DELETE FROM " + Config.getConfig(start).getOAuthClientTable() + " WHERE app_id = ? AND client_id = ?"; - update(start, DELETE, pst -> { + int numberOfRow = update(start, DELETE, pst -> { pst.setString(1, appIdentifier.getAppId()); pst.setString(2, clientId); }); - } - - public static boolean isClientIdAlreadyExists(Start start, String clientId) - throws SQLException, StorageQueryException { - String QUERY = "SELECT client_id FROM " + Config.getConfig(start).getOAuthClientTable() + - " WHERE client_id = ?"; - return execute(start, QUERY, pst -> { - pst.setString(1, clientId); - }, ResultSet::next); + return numberOfRow > 0; } } diff --git a/src/main/java/io/supertokens/oauth/OAuth.java b/src/main/java/io/supertokens/oauth/OAuth.java index f6476283d..be578630c 100644 --- a/src/main/java/io/supertokens/oauth/OAuth.java +++ b/src/main/java/io/supertokens/oauth/OAuth.java @@ -115,9 +115,6 @@ public static JsonObject registerOAuthClient(Main main, AppIdentifier appIdentif new SecureRandom().nextBytes(idBaseBytes); String clientId = "supertokens_" + Utils.hashSHA256Base64UrlSafe(idBaseBytes); try { - if(oauthStorage.isClientIdAlreadyExists(clientId)){ - continue; // restart, don't even try with Id which we know exists in hydra - } JsonObject hydraRequestBody = constructHydraRequestParamsForRegisterClientPOST(paramsFromSdk, clientId); JsonObject hydraResponse = HttpRequest.sendJsonPOSTRequest(main, "", adminOAuthProviderServiceUrl + HYDRA_CLIENTS_ENDPOINT, hydraRequestBody, 10000, 10000, null); diff --git a/src/main/java/io/supertokens/utils/Utils.java b/src/main/java/io/supertokens/utils/Utils.java index 903036289..07ea2e5ff 100644 --- a/src/main/java/io/supertokens/utils/Utils.java +++ b/src/main/java/io/supertokens/utils/Utils.java @@ -447,19 +447,23 @@ public static boolean containsUrl(String urlToCheckIfContains, String whatItCont } public static String camelCaseToSnakeCase(String toSnakeCase) { - String regex = "([a-z])([A-Z]+)"; - String replacement = "$1_$2"; - toSnakeCase = toSnakeCase - .replaceAll( - regex, replacement) - .toLowerCase(); + if (toSnakeCase != null) { + String regex = "([a-z])([A-Z]+)"; + String replacement = "$1_$2"; + toSnakeCase = toSnakeCase + .replaceAll( + regex, replacement) + .toLowerCase(); + } return toSnakeCase; } public static String snakeCaseToCamelCase(String toCamelCase) { - toCamelCase = Pattern.compile("_([a-z])") - .matcher(toCamelCase) - .replaceAll(m -> m.group(1).toUpperCase()); + if(toCamelCase != null) { + toCamelCase = Pattern.compile("_([a-z])") + .matcher(toCamelCase) + .replaceAll(m -> m.group(1).toUpperCase()); + } return toCamelCase; } } diff --git a/src/main/java/io/supertokens/webserver/InputParser.java b/src/main/java/io/supertokens/webserver/InputParser.java index 7b9b6338d..d27bd7a84 100644 --- a/src/main/java/io/supertokens/webserver/InputParser.java +++ b/src/main/java/io/supertokens/webserver/InputParser.java @@ -239,7 +239,7 @@ public static Integer parseIntOrThrowError(JsonObject element, String fieldName, } - public static List collectAllMissingRequiredFieldsAndThrowError(JsonObject input, List requiredFields) + public static List collectAllMissingRequiredFieldsOrThrowError(JsonObject input, List requiredFields) throws ServletException { List missingFields = new ArrayList<>(); for(String requiredField : requiredFields){ diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java index d71f39a1b..60a484e0a 100644 --- a/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthAuthAPI.java @@ -48,8 +48,7 @@ public OAuthAuthAPI(Main main) { super(main, RECIPE_ID.OAUTH.toString()); } - private static final List REQUIRED_FIELDS_FOR_POST = Arrays.asList(new String[]{"clientId", "scope", - "state", "redirectUri", "responseType"}); + private static final List REQUIRED_FIELDS_FOR_POST = Arrays.asList(new String[]{"clientId", "responseType"}); @Override public String getPath() { @@ -60,7 +59,7 @@ public String getPath() { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { JsonObject input = InputParser.parseJsonObjectOrThrowError(req); - InputParser.collectAllMissingRequiredFieldsAndThrowError(input, REQUIRED_FIELDS_FOR_POST); + InputParser.collectAllMissingRequiredFieldsOrThrowError(input, REQUIRED_FIELDS_FOR_POST); try { AppIdentifier appIdentifier = getAppIdentifier(req); diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java index 27a16de34..8a6433f02 100644 --- a/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java @@ -47,7 +47,7 @@ public class OAuthClientsAPI extends WebserverAPI { private static final long serialVersionUID = -4482427281337641246L; private static final List REQUIRED_INPUT_FIELDS_FOR_POST = Arrays.asList(new String[]{"clientName", "scope"}); - public static final String OAUTH_2_CLIENT_NOT_FOUND = "OAUTH2_CLIENT_NOT_FOUND"; + public static final String OAUTH2_CLIENT_NOT_FOUND_ERROR = "OAUTH2_CLIENT_NOT_FOUND_ERROR"; @Override public String getPath() { @@ -61,7 +61,7 @@ public OAuthClientsAPI(Main main){ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { JsonObject input = InputParser.parseJsonObjectOrThrowError(req); - InputParser.collectAllMissingRequiredFieldsAndThrowError(input, REQUIRED_INPUT_FIELDS_FOR_POST); + InputParser.collectAllMissingRequiredFieldsOrThrowError(input, REQUIRED_INPUT_FIELDS_FOR_POST); try { AppIdentifier appIdentifier = getAppIdentifier(req); @@ -76,8 +76,8 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I } catch (OAuthClientRegisterInvalidInputException registerException) { JsonObject errorResponse = createJsonFromException(registerException); - errorResponse.addProperty("status", "INVALID_INPUT"); - sendJsonResponse(200, errorResponse, resp); + errorResponse.addProperty("status", "INVALID_INPUT_ERROR"); + sendJsonResponse(400, errorResponse, resp); } catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException | NoSuchAlgorithmException | StorageQueryException e) { @@ -102,7 +102,7 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IO } catch (OAuthClientNotFoundException e) { JsonObject errorResponse = createJsonFromException(e); - errorResponse.addProperty("status", OAUTH_2_CLIENT_NOT_FOUND); + errorResponse.addProperty("status", OAUTH2_CLIENT_NOT_FOUND_ERROR); sendJsonResponse(200, errorResponse, resp); } catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException @@ -128,7 +128,7 @@ protected void doDelete(HttpServletRequest req, HttpServletResponse resp) throws } catch (OAuthClientNotFoundException e) { JsonObject errorResponse = createJsonFromException(e); - errorResponse.addProperty("status", OAUTH_2_CLIENT_NOT_FOUND); + errorResponse.addProperty("status", OAUTH2_CLIENT_NOT_FOUND_ERROR); sendJsonResponse(200, errorResponse, resp); } catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException diff --git a/src/test/java/io/supertokens/test/UtilsTest.java b/src/test/java/io/supertokens/test/UtilsTest.java index 405cdaef3..707c4e792 100644 --- a/src/test/java/io/supertokens/test/UtilsTest.java +++ b/src/test/java/io/supertokens/test/UtilsTest.java @@ -163,6 +163,81 @@ public void testUrlContainsCaseSensitive() throws MalformedURLException { assertFalse(io.supertokens.utils.Utils.containsUrl(itShouldContain,thisShouldBeContained, false)); } + @Test + public void testSnakeCaseToCamelCase() { + String original = "snake_case"; + String expectedResult = "snakeCase"; + String gotResult = io.supertokens.utils.Utils.snakeCaseToCamelCase(original); + assertEquals(expectedResult, gotResult); + } + @Test + public void testSnakeCaseToCamelCaseStartingWithSnake() { + String original = "_snake_case"; + String expectedResult = "SnakeCase"; + String gotResult = io.supertokens.utils.Utils.snakeCaseToCamelCase(original); + assertEquals(expectedResult, gotResult); + } + + @Test + public void testSnakeCaseToCamelCaseWithNull() { + String original = null; + String expectedResult = null; + String gotResult = io.supertokens.utils.Utils.snakeCaseToCamelCase(original); + assertEquals(expectedResult, gotResult); + } + + @Test + public void testSnakeCaseToCamelCaseNothingToDo() { + String original = "snake"; + String expectedResult = "snake"; + String gotResult = io.supertokens.utils.Utils.snakeCaseToCamelCase(original); + assertEquals(expectedResult, gotResult); + } + + @Test + public void testSnakeCaseToCamelCaseMultipleSnakes() { + String original = "snake_case_multiple_snakes"; + String expectedResult = "snakeCaseMultipleSnakes"; + String gotResult = io.supertokens.utils.Utils.snakeCaseToCamelCase(original); + assertEquals(expectedResult, gotResult); + } + @Test + public void testCamelCaseToSnakeCase() { + String original = "camelCased"; + String expectedResult = "camel_cased"; + String gotResult = io.supertokens.utils.Utils.camelCaseToSnakeCase(original); + assertEquals(expectedResult, gotResult); + } + + @Test + public void testCamelCaseToSnakeCaseBigInitial() { + String original = "CamelCased"; + String expectedResult = "camel_cased"; + String gotResult = io.supertokens.utils.Utils.camelCaseToSnakeCase(original); + assertEquals(expectedResult, gotResult); + } + + @Test + public void testCamelCaseToSnakeCaseWithNull() { + String original = null; + String expectedResult = null; + String gotResult = io.supertokens.utils.Utils.camelCaseToSnakeCase(original); + assertEquals(expectedResult, gotResult); + } + @Test + public void testCamelCaseToSnakeCaseMultipleCamels() { + String original = "multipleCamelsInCamelCase"; + String expectedResult = "multiple_camels_in_camel_case"; + String gotResult = io.supertokens.utils.Utils.camelCaseToSnakeCase(original); + assertEquals(expectedResult, gotResult); + } + @Test + public void testCamelCaseToSnakeCaseNothingToDo() { + String original = "nothing"; + String expectedResult = "nothing"; + String gotResult = io.supertokens.utils.Utils.camelCaseToSnakeCase(original); + assertEquals(expectedResult, gotResult); + } } diff --git a/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java b/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java index dcd1f781f..ce00c91be 100644 --- a/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java +++ b/src/test/java/io/supertokens/test/oauth/api/OAuthClientsAPITest.java @@ -22,7 +22,6 @@ import io.supertokens.ProcessState; import io.supertokens.httpRequest.HttpRequest; import io.supertokens.pluginInterface.RECIPE_ID; -import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.oauth.sqlStorage.OAuthSQLStorage; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.test.TestingProcessManager; @@ -57,7 +56,7 @@ public void beforeEach() throws InterruptedException { @Test public void testClientRegisteredForApp() - throws HttpResponseException, IOException, StorageQueryException, InterruptedException, + throws HttpResponseException, IOException, InterruptedException, io.supertokens.httpRequest.HttpResponseException { String[] args = {"../"}; @@ -97,12 +96,6 @@ public void testClientRegisteredForApp() String clientId = client.get("clientId").getAsString(); - boolean isClientAlreadyExists = oAuthStorage.isClientIdAlreadyExists(clientId); - assertTrue(isClientAlreadyExists); - - boolean clientShouldntExists = oAuthStorage.isClientIdAlreadyExists(clientId + "someRandomStringHere"); - assertFalse(clientShouldntExists); - Map queryParams = new HashMap<>(); queryParams.put("clientId", client.get("clientId").getAsString()); JsonObject loadedClient = HttpRequest.sendGETRequest(process.getProcess(), "", @@ -211,7 +204,7 @@ public void testMoreFieldAreIgnored() @Test public void testGETClientNotExisting_returnsError() - throws StorageQueryException, InterruptedException, io.supertokens.httpRequest.HttpResponseException, + throws InterruptedException, io.supertokens.httpRequest.HttpResponseException, IOException { String[] args = {"../"}; @@ -220,21 +213,12 @@ public void testGETClientNotExisting_returnsError() String clientId = "not-an-existing-one"; - OAuthSQLStorage oAuthStorage = (OAuthSQLStorage) StorageLayer.getStorage(process.getProcess()); - - - boolean isClientAlreadyExists = oAuthStorage.isClientIdAlreadyExists(clientId); - assertFalse(isClientAlreadyExists); - - boolean clientShouldntExists = oAuthStorage.isClientIdAlreadyExists(clientId + "someRandomStringHere"); - assertFalse(clientShouldntExists); - Map queryParams = new HashMap<>(); queryParams.put("clientId", clientId); JsonObject response = HttpRequest.sendGETRequest(process.getProcess(), "", "http://localhost:3567/recipe/oauth/clients", queryParams, 10000, 10000, null); - assertEquals("OAUTH2_CLIENT_NOT_FOUND", response.get("status").getAsString()); + assertEquals("OAUTH2_CLIENT_NOT_FOUND_ERROR", response.get("status").getAsString()); assertEquals("Unable to locate the resource", response.get("error").getAsString()); process.kill(); From ce52518e1f9b1691ceb858360e731e13e68ad99f Mon Sep 17 00:00:00 2001 From: Tamas Soltesz Date: Mon, 5 Aug 2024 19:30:49 +0200 Subject: [PATCH 30/30] fix: using BadRequestException instead of custom format for hydra invalid input errors --- .../io/supertokens/webserver/api/oauth/OAuthClientsAPI.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java b/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java index 8a6433f02..60a04b037 100644 --- a/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java +++ b/src/main/java/io/supertokens/webserver/api/oauth/OAuthClientsAPI.java @@ -75,9 +75,7 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I } catch (OAuthClientRegisterInvalidInputException registerException) { - JsonObject errorResponse = createJsonFromException(registerException); - errorResponse.addProperty("status", "INVALID_INPUT_ERROR"); - sendJsonResponse(400, errorResponse, resp); + throw new ServletException(new BadRequestException(registerException.error + " - " + registerException.errorDescription)); } catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException | NoSuchAlgorithmException | StorageQueryException e) {