Skip to content

Commit

Permalink
Feature: oauth update client api (#1020)
Browse files Browse the repository at this point in the history
* fix/change annotations for configs

* feat: oauth2 auth API - WIP

* fix: hidefromdashboard to oauth_provider service url configs

* feat: OAuthAPI input parsing, basic flow

* feat: first test in progress

* fix: review fixes

* feat: tables for oauth in sqlite

* fix: remove unnecessary tables

* fix: store only the necessary data in the client table

* feat: oauth client - app exists check in db, a few tests

* fix: review fixes

* fix: review fixes

* feat: new configs for handling errors from hydra

* feat: using the new configs for oauth provider

* fix: CHANGELOG

* fix: tests for the new Util method

* fix: changelog changes

* fix: changelog migration section fix

* fix: fixing repeated header handling in HttpRequest#sendGETRequestWithResponseHeaders

* fix: more tests for oauth auth

* fix: review fix - more checks for the oauth config validity

* fix: review fix - throwing expection if there is no location header in response from hydra

* fix: review fix - renamed exception

* feat: oauth2 register client API

* feat: oauth2 get clients API

* feat: OAuth2 DELETE clients API

* fix: following the already existing response pattern with the oauth2 apis

* fix: renaming exception to be more expressive

* fix: review fixes

* feat: oauth2 client update support

* fix: using BadRequestException instead of custom format for hydra invalid input errors

* fix: renaming method

* fix: remove unused constant

* fix: returning 500 when oauth update client goes wrong

* fix: fixing test after changing error response

* chore: removing TODO from code
  • Loading branch information
tamassoltesz authored Aug 5, 2024
1 parent 7e98ed2 commit aee7e88
Show file tree
Hide file tree
Showing 11 changed files with 445 additions and 26 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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`
- Introduces PATCH capabilities for core (receiving and sending PATCH requests)
- Adds PATCH `/recipe/oauth/clients` for OAuth2 client update

### Migration
TODO: after plugin support
Expand Down
31 changes: 27 additions & 4 deletions src/main/java/io/supertokens/httpRequest/HttpRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,9 @@
import io.supertokens.Main;

import java.io.*;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.net.*;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -263,6 +262,30 @@ public static <T> T sendJsonPUTRequest(Main main, String requestID, String url,
return sendJsonRequest(main, requestID, url, requestBody, connectionTimeoutMS, readTimeoutMS, version, "PUT");
}

public static <T> T sendJsonPATCHRequest(Main main, String url, JsonElement requestBody)
throws IOException, HttpResponseException, InterruptedException {

HttpClient client = null;

String body = requestBody.toString();
java.net.http.HttpRequest rawRequest = java.net.http.HttpRequest.newBuilder()
.uri(URI.create(url))
.method("PATCH", java.net.http.HttpRequest.BodyPublishers.ofString(body))
.build();
client = HttpClient.newHttpClient();
HttpResponse<String> response = client.send(rawRequest, HttpResponse.BodyHandlers.ofString());

int responseCode = response.statusCode();

if (responseCode < STATUS_CODE_ERROR_THRESHOLD) {
if (!isJsonValid(response.body().toString())) {
return (T) response.body().toString();
}
return (T) (new JsonParser().parse(response.body().toString()));
}
throw new HttpResponseException(responseCode, response.body().toString());
}

public static <T> T sendJsonDELETERequest(Main main, String requestID, String url, JsonElement requestBody,
int connectionTimeoutMS, int readTimeoutMS, Integer version)
throws IOException, HttpResponseException {
Expand Down
60 changes: 58 additions & 2 deletions src/main/java/io/supertokens/oauth/OAuth.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package io.supertokens.oauth;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
Expand Down Expand Up @@ -103,7 +104,7 @@ 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,
OAuthClientRegisterInvalidInputException,
OAuthAPIInvalidInputException,
NoSuchAlgorithmException, StorageQueryException {

OAuthStorage oauthStorage = StorageUtils.getOAuthStorage(storage);
Expand All @@ -130,7 +131,7 @@ public static JsonObject registerOAuthClient(Main main, AppIdentifier appIdentif
} else {
//other error from hydra, like invalid content in json. Throw exception
throw createCustomExceptionFromHttpResponseException(
e, OAuthClientRegisterInvalidInputException.class);
e, OAuthAPIInvalidInputException.class);
}
} catch (NoSuchMethodException | InvocationTargetException | InstantiationException |
IllegalAccessException ex) {
Expand Down Expand Up @@ -191,6 +192,61 @@ public static void deleteOAuthClient(Main main, AppIdentifier appIdentifier, Sto
}
}

public static JsonObject updateOauthClient(Main main, AppIdentifier appIdentifier, Storage storage, JsonObject paramsFromSdk)
throws TenantOrAppNotFoundException, InvalidConfigException, StorageQueryException,
InvocationTargetException, NoSuchMethodException, InstantiationException,
IllegalAccessException, OAuthClientNotFoundException, OAuthAPIInvalidInputException,
OAuthClientUpdateException {

OAuthStorage oauthStorage = StorageUtils.getOAuthStorage(storage);
String adminOAuthProviderServiceUrl = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOAuthProviderAdminServiceUrl();

String clientId = paramsFromSdk.get("clientId").getAsString();

if (!oauthStorage.doesClientIdExistForThisApp(appIdentifier, clientId)) {
throw new OAuthClientNotFoundException("Unable to locate the resource", "");
} else {
JsonArray hydraInput = translateIncomingDataToHydraUpdateFormat(paramsFromSdk);
try {
JsonObject updatedClient = HttpRequest.sendJsonPATCHRequest(main, adminOAuthProviderServiceUrl + HYDRA_CLIENTS_ENDPOINT+ "/" + clientId, hydraInput);
return formatResponseForSDK(updatedClient);
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
} catch (HttpResponseException e) {
int responseStatusCode = e.statusCode;
switch (responseStatusCode){
case 400 -> throw createCustomExceptionFromHttpResponseException(e, OAuthAPIInvalidInputException.class);
case 404 -> throw createCustomExceptionFromHttpResponseException(e, OAuthClientNotFoundException.class);
case 500 -> throw createCustomExceptionFromHttpResponseException(e, OAuthClientUpdateException.class); // hydra is not so helpful with the error messages at this endpoint..
default -> throw new RuntimeException(e);
}
}
}
}

private static JsonArray translateIncomingDataToHydraUpdateFormat(JsonObject input){
JsonArray hydraPatchFormat = new JsonArray();
for (Map.Entry<String, JsonElement> changeIt : input.entrySet()) {
if (changeIt.getKey().equals("clientId")) {
continue; // we are not updating clientIds!
}
hydraPatchFormat.add(translateToHydraPatch(changeIt.getKey(),changeIt.getValue()));
}

return hydraPatchFormat;
}

private static JsonObject translateToHydraPatch(String elementName, JsonElement newValue){
JsonObject patchFormat = new JsonObject();
String hydraElementName = Utils.camelCaseToSnakeCase(elementName);
patchFormat.addProperty("from", "/" + hydraElementName);
patchFormat.addProperty("path", "/" + hydraElementName);
patchFormat.addProperty("op", "replace"); // What was sent by the sdk should be handled as a complete new value for the property
patchFormat.add("value", newValue);

return patchFormat;
}

private static <T extends OAuthException> T createCustomExceptionFromHttpResponseException(HttpResponseException exception, Class<T> customExceptionClass)
throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
String errorMessage = exception.rawMessage;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@

import java.io.Serial;

public class OAuthClientRegisterInvalidInputException extends OAuthException{
public class OAuthAPIInvalidInputException extends OAuthException{

@Serial
private static final long serialVersionUID = 665027786586190611L;

public OAuthClientRegisterInvalidInputException(String error, String errorDescription) {
public OAuthAPIInvalidInputException(String error, String errorDescription) {
super(error, errorDescription);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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 OAuthClientUpdateException extends OAuthException{
@Serial
private static final long serialVersionUID = -5191044905397936167L;

public OAuthClientUpdateException(String error, String errorDescription) {
super(error, errorDescription);
}
}
4 changes: 1 addition & 3 deletions src/main/java/io/supertokens/webserver/InputParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
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;

Expand Down Expand Up @@ -239,7 +238,7 @@ public static Integer parseIntOrThrowError(JsonObject element, String fieldName,

}

public static List<String> collectAllMissingRequiredFieldsOrThrowError(JsonObject input, List<String> requiredFields)
public static void throwErrorOnMissingRequiredField(JsonObject input, List<String> requiredFields)
throws ServletException {
List<String> missingFields = new ArrayList<>();
for(String requiredField : requiredFields){
Expand All @@ -252,6 +251,5 @@ public static List<String> collectAllMissingRequiredFieldsOrThrowError(JsonObjec
throw new ServletException(new WebserverAPI.BadRequestException("Field name `" + String.join("','", missingFields)
+ "` is missing in JSON input"));
}
return missingFields;
}
}
11 changes: 10 additions & 1 deletion src/main/java/io/supertokens/webserver/WebserverAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ protected void doTrace(HttpServletRequest req, HttpServletResponse resp) throws
this.sendTextResponse(405, "Method not supported", resp);
}

protected void doPatch(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException{
this.sendTextResponse(405, "Method not supported", resp);
}

private void assertThatVersionIsCompatible(SemVer version) throws ServletException {
if (version == null) {
throw new ServletException(new BadRequestException("cdi-version not provided"));
Expand Down Expand Up @@ -486,7 +490,12 @@ protected void service(HttpServletRequest req, HttpServletResponse resp) throws
Logging.info(main, tenantIdentifier,
"API called: " + req.getRequestURI() + ". Method: " + req.getMethod(), false);
}
super.service(req, resp);
String method = req.getMethod();
if(method.equals("PATCH")){
this.doPatch(req, resp);
} else {
super.service(req, resp);
}

} catch (Exception e) {
Logging.error(main, tenantIdentifier,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public String getPath() {
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {

JsonObject input = InputParser.parseJsonObjectOrThrowError(req);
InputParser.collectAllMissingRequiredFieldsOrThrowError(input, REQUIRED_FIELDS_FOR_POST);
InputParser.throwErrorOnMissingRequiredField(input, REQUIRED_FIELDS_FOR_POST);

try {
AppIdentifier appIdentifier = getAppIdentifier(req);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
import io.supertokens.multitenancy.exception.BadPermissionException;
import io.supertokens.oauth.OAuth;
import io.supertokens.oauth.exceptions.OAuthClientNotFoundException;
import io.supertokens.oauth.exceptions.OAuthClientRegisterInvalidInputException;
import io.supertokens.oauth.exceptions.OAuthAPIInvalidInputException;
import io.supertokens.oauth.exceptions.OAuthClientUpdateException;
import io.supertokens.oauth.exceptions.OAuthException;
import io.supertokens.pluginInterface.RECIPE_ID;
import io.supertokens.pluginInterface.Storage;
Expand All @@ -37,6 +38,7 @@

import java.io.IOException;
import java.io.Serial;
import java.lang.reflect.InvocationTargetException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.List;
Expand All @@ -47,7 +49,9 @@ public class OAuthClientsAPI extends WebserverAPI {
private static final long serialVersionUID = -4482427281337641246L;

private static final List<String> REQUIRED_INPUT_FIELDS_FOR_POST = Arrays.asList(new String[]{"clientName", "scope"});
private static final List<String> REQUIRED_INPUT_FIELDS_FOR_PATCH = Arrays.asList(new String[]{"clientId"});
public static final String OAUTH2_CLIENT_NOT_FOUND_ERROR = "OAUTH2_CLIENT_NOT_FOUND_ERROR";
public static final String OAUTH2_CLIENT_UPDATE_ERROR = "OAUTH2_CLIENT_UPDATE_ERROR";

@Override
public String getPath() {
Expand All @@ -61,7 +65,7 @@ public OAuthClientsAPI(Main main){
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {

JsonObject input = InputParser.parseJsonObjectOrThrowError(req);
InputParser.collectAllMissingRequiredFieldsOrThrowError(input, REQUIRED_INPUT_FIELDS_FOR_POST);
InputParser.throwErrorOnMissingRequiredField(input, REQUIRED_INPUT_FIELDS_FOR_POST);

try {
AppIdentifier appIdentifier = getAppIdentifier(req);
Expand All @@ -73,7 +77,7 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws I
postResponseBody.add("client", response);
sendJsonResponse(200, postResponseBody, resp);

} catch (OAuthClientRegisterInvalidInputException registerException) {
} catch (OAuthAPIInvalidInputException registerException) {

throw new ServletException(new BadRequestException(registerException.error + " - " + registerException.errorDescription));

Expand All @@ -99,8 +103,7 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IO
sendJsonResponse(200, response, resp);

} catch (OAuthClientNotFoundException e) {
JsonObject errorResponse = createJsonFromException(e);
errorResponse.addProperty("status", OAUTH2_CLIENT_NOT_FOUND_ERROR);
JsonObject errorResponse = createJsonFromException(e, OAUTH2_CLIENT_NOT_FOUND_ERROR);
sendJsonResponse(200, errorResponse, resp);

} catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException
Expand All @@ -125,8 +128,7 @@ protected void doDelete(HttpServletRequest req, HttpServletResponse resp) throws
sendJsonResponse(200, responseBody, resp);

} catch (OAuthClientNotFoundException e) {
JsonObject errorResponse = createJsonFromException(e);
errorResponse.addProperty("status", OAUTH2_CLIENT_NOT_FOUND_ERROR);
JsonObject errorResponse = createJsonFromException(e, OAUTH2_CLIENT_NOT_FOUND_ERROR);
sendJsonResponse(200, errorResponse, resp);

} catch (TenantOrAppNotFoundException | InvalidConfigException | BadPermissionException
Expand All @@ -135,11 +137,43 @@ protected void doDelete(HttpServletRequest req, HttpServletResponse resp) throws
}
}

private JsonObject createJsonFromException(OAuthException exception){
@Override
protected void doPatch(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException {
JsonObject input = InputParser.parseJsonObjectOrThrowError(req);
InputParser.throwErrorOnMissingRequiredField(input, REQUIRED_INPUT_FIELDS_FOR_PATCH);

try {
AppIdentifier appIdentifier = getAppIdentifier(req);
Storage storage = enforcePublicTenantAndGetPublicTenantStorage(req);

JsonObject response = OAuth.updateOauthClient(super.main, appIdentifier, storage, input);
JsonObject postResponseBody = new JsonObject();
postResponseBody.addProperty("status", "OK");
postResponseBody.add("client", response);
sendJsonResponse(200, postResponseBody, resp);

} catch (OAuthAPIInvalidInputException exception) {
throw new ServletException(new BadRequestException(exception.error + " - " + exception.errorDescription));
} catch (OAuthClientUpdateException updateException) {
//for errors with the update from hydra, which are not reported back as invalid input errors
throw new ServletException(updateException);

} catch (OAuthClientNotFoundException clientNotFoundException) {
JsonObject errorResponse = createJsonFromException(clientNotFoundException, OAUTH2_CLIENT_NOT_FOUND_ERROR);
sendJsonResponse(200, errorResponse, resp);

} catch (TenantOrAppNotFoundException | InvalidConfigException | StorageQueryException |
InvocationTargetException | NoSuchMethodException | InstantiationException | IllegalAccessException
| BadPermissionException ex) {
throw new ServletException(ex);
}
}

private JsonObject createJsonFromException(OAuthException exception, String status){
JsonObject errorResponse = new JsonObject();
errorResponse.addProperty("error", exception.error);
errorResponse.addProperty("errorDescription", exception.errorDescription);

errorResponse.addProperty("status", status);
return errorResponse;
}
}
Loading

0 comments on commit aee7e88

Please sign in to comment.