Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Oauth - WIP #1018

Merged
merged 30 commits into from
Aug 5, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1bc72f9
fix/change annotations for configs
tamassoltesz Jul 29, 2024
8a33068
feat: oauth2 auth API - WIP
tamassoltesz Jul 29, 2024
d358a33
fix: hidefromdashboard to oauth_provider service url configs
tamassoltesz Jul 29, 2024
282b889
feat: OAuthAPI input parsing, basic flow
tamassoltesz Jul 29, 2024
4076bf8
feat: first test in progress
tamassoltesz Jul 29, 2024
b9c408a
fix: review fixes
tamassoltesz Jul 30, 2024
24523bd
feat: tables for oauth in sqlite
tamassoltesz Jul 30, 2024
c969ef1
fix: remove unnecessary tables
tamassoltesz Jul 30, 2024
c03d6c4
fix: store only the necessary data in the client table
tamassoltesz Jul 30, 2024
c740082
feat: oauth client - app exists check in db, a few tests
tamassoltesz Jul 30, 2024
c4c6438
fix: review fixes
tamassoltesz Jul 31, 2024
b84e901
fix: review fixes
tamassoltesz Jul 31, 2024
fc08d8c
feat: new configs for handling errors from hydra
tamassoltesz Jul 31, 2024
31164ef
feat: using the new configs for oauth provider
tamassoltesz Jul 31, 2024
75ceba0
fix: CHANGELOG
tamassoltesz Jul 31, 2024
b6bb95f
fix: tests for the new Util method
tamassoltesz Jul 31, 2024
df6cb85
fix: changelog changes
tamassoltesz Jul 31, 2024
5f6c78e
fix: changelog migration section fix
tamassoltesz Jul 31, 2024
85b45f8
fix: fixing repeated header handling in HttpRequest#sendGETRequestWit…
tamassoltesz Jul 31, 2024
bb09c56
fix: more tests for oauth auth
tamassoltesz Jul 31, 2024
1e5015c
fix: review fix - more checks for the oauth config validity
tamassoltesz Aug 1, 2024
955b106
fix: review fix - throwing expection if there is no location header i…
tamassoltesz Aug 1, 2024
2893b19
fix: review fix - renamed exception
tamassoltesz Aug 1, 2024
d14267f
feat: oauth2 register client API
tamassoltesz Aug 1, 2024
f5db9b2
feat: oauth2 get clients API
tamassoltesz Aug 1, 2024
3e3e5cf
feat: OAuth2 DELETE clients API
tamassoltesz Aug 1, 2024
2ac627e
fix: following the already existing response pattern with the oauth2 …
tamassoltesz Aug 2, 2024
7044395
fix: renaming exception to be more expressive
tamassoltesz Aug 2, 2024
a58f329
fix: review fixes
tamassoltesz Aug 5, 2024
ce52518
fix: using BadRequestException instead of custom format for hydra inv…
tamassoltesz Aug 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ 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/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
Expand Down
27 changes: 25 additions & 2 deletions src/main/java/io/supertokens/config/CoreConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;



Expand Down Expand Up @@ -903,7 +903,10 @@ void normalizeAndValidate(Main main, boolean includeConfigFilePath) throws Inval
}
}

rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved

List<String> 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;
}
Expand Down Expand Up @@ -1035,4 +1038,24 @@ void assertThatConfigFromSameAppIdAreNotConflicting(CoreConfig other) throws Inv
public String getMaxCDIVersion() {
return this.supertokens_max_cdi_version;
}

private boolean isAnySet(List<String> configs){
for (String config : configs){
if(config!=null){
return true;
}
}
return false;
}

private boolean isAllSet(List<String> configs) {
boolean foundNotSet = false;
for(String config: configs){
if(config == null){
foundNotSet = true;
break;
}
}
return !foundNotSet;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
24 changes: 21 additions & 3 deletions src/main/java/io/supertokens/inmemorydb/Start.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -3032,9 +3032,27 @@ 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);
}
}

@Override
public boolean isClientIdAlreadyExists(String clientId) throws StorageQueryException {
try {
return OAuthQueries.isClientIdAlreadyExists(this, clientId);
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved

@Override
public void removeAppClientAssociation(AppIdentifier appIdentifier, String clientId) throws StorageQueryException {
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
try {
OAuthQueries.deleteClientIdForAppId(this, clientId, appIdentifier);
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
}
22 changes: 19 additions & 3 deletions src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,4 +61,23 @@ 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() +
" WHERE client_id = ?";
return execute(start, QUERY, pst -> {
pst.setString(1, clientId);
}, ResultSet::next);
}

}
140 changes: 139 additions & 1 deletion src/main/java/io/supertokens/oauth/OAuth.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,33 @@

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.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;
import io.supertokens.pluginInterface.exceptions.StorageQueryException;
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.lang.reflect.InvocationTargetException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.*;

public class OAuth {
Expand All @@ -43,7 +53,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)
Expand Down Expand Up @@ -81,6 +91,8 @@ public static OAuthAuthResponse getAuthorizationUrl(Main main, AppIdentifier app
} else {
redirectTo = locationHeaderValue;
}
} else {
throw new RuntimeException("Unexpected answer from Oauth Provider");
}
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
if(responseHeaders.containsKey(COOKIES_HEADER_NAME)){
cookies = responseHeaders.get(COOKIES_HEADER_NAME);
Expand All @@ -90,6 +102,132 @@ 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();
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved

byte[] idBaseBytes = new byte[48];

while(true){
new SecureRandom().nextBytes(idBaseBytes);
String clientId = "supertokens_" + Utils.hashSHA256Base64UrlSafe(idBaseBytes);
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
try {
if(oauthStorage.isClientIdAlreadyExists(clientId)){
continue; // restart
}
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved

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) {
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", "");
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
} 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!");
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
}

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", "");
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
} else {
try {
HttpRequest.sendJsonDELETERequest(main, "", adminOAuthProviderServiceUrl + HYDRA_CLIENTS_ENDPOINT + "/" + clientId, null, 10000, 10000, null);

oauthStorage.removeAppClientAssociation(appIdentifier, clientId);
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
} 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 extends OAuthException> T createCustomExceptionFromHttpResponseException(HttpResponseException exception, Class<T> 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){
JsonObject requestBody = new JsonObject();

//translating camelCase keys to snakeCase keys
for (Map.Entry<String, JsonElement> 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<String, JsonElement> jsonEntry : response.entrySet()){
formattedResponse.add(Utils.snakeCaseToCamelCase(jsonEntry.getKey()), jsonEntry.getValue());
}

return formattedResponse;
}

private static Map<String, String> constructHydraRequestParamsForAuthorizationGETAPICall(String clientId,
String redirectURI, String responseType, String scope, String state) {
Map<String, String> queryParamsForHydra = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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{
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved

@Serial
private static final long serialVersionUID = -140335439416174384L;

public OAuthClientException(String error, String errorDescription) {
super(error, errorDescription);
}
}
Loading
Loading