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 10 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
5 changes: 3 additions & 2 deletions src/main/java/io/supertokens/config/CoreConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -275,15 +275,16 @@ 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
@HideFromDashboard
@ConfigDescription(
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
"If specified, the core uses this URL to connect to the OAuth provider admin service.")
private String oauth_provider_admin_service_url = null;
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/io/supertokens/httpRequest/HttpRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,12 @@ public static <T> T sendGETRequestWithResponseHeaders(Main main, String requestI
if (version != null) {
con.setRequestProperty("api-version", version + "");
}

con.setInstanceFollowRedirects(false);
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
int responseCode = con.getResponseCode();

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

Expand Down
22 changes: 21 additions & 1 deletion src/main/java/io/supertokens/inmemorydb/Start.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.sqlStorage.OAuthSQLStorage;
import io.supertokens.pluginInterface.passwordless.PasswordlessCode;
import io.supertokens.pluginInterface.passwordless.PasswordlessDevice;
import io.supertokens.pluginInterface.passwordless.exception.*;
Expand Down Expand Up @@ -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, OAuthSQLStorage {

private static final Object appenderLock = new Object();
private static final String APP_ID_KEY_NAME = "app_id";
Expand Down Expand Up @@ -3007,4 +3008,23 @@ public int countUsersThatHaveMoreThanOneLoginMethodOrTOTPEnabledAndActiveSince(A
throw new StorageQueryException(e);
}
}

@Override
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 {
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
try {
OAuthQueries.insertClientIdForAppId(this, clientId, appIdentifier);
} catch (SQLException e) {
throw new StorageQueryException(e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,6 @@ public String getDashboardUsersTable() {
public String getDashboardSessionsTable() {
return "dashboard_user_sessions";
}

public String getOAuthClientTable(){ return "oauth_clients"; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,10 @@ 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);
}
}


Expand Down
67 changes: 67 additions & 0 deletions src/main/java/io/supertokens/inmemorydb/queries/OAuthQueries.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* 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;
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 {

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',"
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
+ "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);";
// @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);
});
}

}
92 changes: 77 additions & 15 deletions src/main/java/io/supertokens/oauth/OAuth.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,38 +18,100 @@

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.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.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.*;

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 {
// 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
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, OAuthAuthException, StorageQueryException,
TenantOrAppNotFoundException {

OAuthStorage oauthStorage = StorageUtils.getOAuthStorage(storage);

String redirectTo = null;
List<String> cookies = null;

String publicOAuthProviderServiceUrl = Config.getConfig(appIdentifier.getAsPublicTenantIdentifier(), main).getOAuthProviderPublicServiceUrl();

if (!oauthStorage.doesClientIdExistForThisApp(appIdentifier, clientId)) {
redirectTo = Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl() +
"/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<String, String> queryParamsForHydra = constructHydraRequestParamsForAuthorizationGETAPICall(clientId, redirectURI, responseType, scope, state);
Map<String, String> responseHeaders = new HashMap<>();

//TODO maybe check response status code? Have to modify sendGetRequest.. for that
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
HttpRequest.sendGETRequestWithResponseHeaders(main, "", Config.getBaseConfig(main).getOAuthProviderPublicServiceUrl() + "/oauth2/auth", queryParamsForHydra, 10000, 10000, null, responseHeaders);

rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
if(!responseHeaders.isEmpty() && responseHeaders.containsKey(LOCATION_HEADER_NAME)) {
String locationHeaderValue = responseHeaders.get(LOCATION_HEADER_NAME);

if (locationHeaderValue.contains(publicOAuthProviderServiceUrl)){
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}");
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
} else {
redirectTo = locationHeaderValue;
}
}
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
if(responseHeaders.containsKey(COOKIES_HEADER_NAME)){
String allCookies = responseHeaders.get(COOKIES_HEADER_NAME);

cookies = Collections.singletonList(allCookies);
}
}

// TODO: parse url resposne and send appropriate reply from this API.
return new OAuthAuthResponse(redirectTo, cookies);
}

private static Map<String, String> constructHydraRequestParamsForAuthorizationGETAPICall(String clientId,
String redirectURI, String responseType, String scope, String state) {
Map<String, String> 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);
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
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);
}
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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 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;
rishabhpoddar marked this conversation as resolved.
Show resolved Hide resolved
this.errorDescription = errorDescription;
}

}
3 changes: 3 additions & 0 deletions src/main/java/io/supertokens/webserver/Webserver.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.OAuthAuthAPI;
import io.supertokens.webserver.api.passwordless.*;
import io.supertokens.webserver.api.session.*;
import io.supertokens.webserver.api.thirdparty.GetUsersByEmailAPI;
Expand Down Expand Up @@ -267,6 +268,8 @@ private void setupRoutes() {
addAPI(new RequestStatsAPI(main));
addAPI(new GetTenantCoreConfigForDashboardAPI(main));

addAPI(new OAuthAuthAPI(main));

StandardContext context = tomcatReference.getContext();
Tomcat tomcat = tomcatReference.getTomcat();

Expand Down
Loading
Loading