diff --git a/src/main/java/io/supertokens/pluginInterface/bulkimport/BulkImportStorage.java b/src/main/java/io/supertokens/pluginInterface/bulkimport/BulkImportStorage.java new file mode 100644 index 00000000..5467a60e --- /dev/null +++ b/src/main/java/io/supertokens/pluginInterface/bulkimport/BulkImportStorage.java @@ -0,0 +1,28 @@ +package io.supertokens.pluginInterface.bulkimport; + +import java.util.ArrayList; + +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.AppIdentifier; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.pluginInterface.nonAuthRecipe.NonAuthRecipeStorage; + +public interface BulkImportStorage extends NonAuthRecipeStorage { + /** + * Add users to the bulk_import_users table + */ + void addBulkImportUsers(AppIdentifier appIdentifier, ArrayList users) + throws StorageQueryException, TenantOrAppNotFoundException; + + /** + * Get users from the bulk_import_users table + */ + // void getBulkImportUsers(AppIdentifier appIdentifier, @Nullable String status, @Nonnull Integer limit, @Nullable String bulkImportUserId) + // throws StorageQueryException; + + /** + * Delete users by id from the bulk_import_users table + */ + // void deleteBulkImportUsers(AppIdentifier appIdentifier, @Nullable ArrayList bulkImportUserIds) + // throws StorageQueryException; +} diff --git a/src/main/java/io/supertokens/pluginInterface/bulkimport/BulkImportUser.java b/src/main/java/io/supertokens/pluginInterface/bulkimport/BulkImportUser.java new file mode 100644 index 00000000..dbc03b3b --- /dev/null +++ b/src/main/java/io/supertokens/pluginInterface/bulkimport/BulkImportUser.java @@ -0,0 +1,316 @@ +/* + * 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.pluginInterface.bulkimport; + +import java.util.List; +import java.util.UUID; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import io.supertokens.pluginInterface.bulkimport.exceptions.InvalidBulkImportDataException; + +import java.util.ArrayList; +import java.util.Arrays; + +public class BulkImportUser { + public String id; + public JsonObject userData; + public String externalUserId; + public JsonObject userMetadata; + public List userRoles; + public List totpDevices; + public List loginMethods; + public ArrayList errors = new ArrayList<>(); + + public BulkImportUser(JsonObject userData, ArrayList validTenantIds, String id) throws InvalidBulkImportDataException { + this.id = id != null ? id : UUID.randomUUID().toString(); + this.userData = userData; + this.externalUserId = parseAndValidateField(userData, "externalUserId", ValueType.STRING, false, String.class, + "."); + this.userMetadata = parseAndValidateField(userData, "userMetadata", ValueType.OBJECT, false, JsonObject.class, + "."); + this.userRoles = getParsedUserRoles(userData); + this.totpDevices = getParsedTotpDevices(userData); + this.loginMethods = getParsedLoginMethods(userData, validTenantIds); + + if (errors.size() > 0) { + throw new InvalidBulkImportDataException(errors); + } + } + + public String toString() { + return this.userData.toString(); + } + + private ArrayList getParsedUserRoles(JsonObject userData) { + JsonArray jsonUserRoles = parseAndValidateField(userData, "roles", ValueType.ARRAY_OF_STRING, false, + JsonArray.class, "."); + + if (jsonUserRoles == null) { + return null; + } + + ArrayList userRoles = new ArrayList<>(); + jsonUserRoles.forEach(role -> userRoles.add(role.getAsString())); + return userRoles; + } + + private ArrayList getParsedTotpDevices(JsonObject userData) { + JsonArray jsonTotpDevices = parseAndValidateField(userData, "totp", ValueType.ARRAY_OF_OBJECT, false, + JsonArray.class, "."); + if (jsonTotpDevices == null) { + return null; + } + + ArrayList totpDevices = new ArrayList<>(); + for (JsonElement jsonTotpDevice : jsonTotpDevices) { + totpDevices.add(new TotpDevice(jsonTotpDevice.getAsJsonObject())); + } + return totpDevices; + } + + private ArrayList getParsedLoginMethods(JsonObject userData, ArrayList validTenantIds) { + JsonArray jsonLoginMethods = parseAndValidateField(userData, "loginMethods", ValueType.ARRAY_OF_OBJECT, true, + JsonArray.class, "."); + + if (jsonLoginMethods == null) { + return new ArrayList<>(); + } + + if (jsonLoginMethods.size() == 0) { + errors.add("At least one loginMethod is required."); + return new ArrayList<>(); + } + + Boolean hasPrimaryLoginMethod = false; + + ArrayList loginMethods = new ArrayList<>(); + for (JsonElement jsonLoginMethod : jsonLoginMethods) { + JsonObject jsonLoginMethodObj = jsonLoginMethod.getAsJsonObject(); + + if (validateJsonFieldType(jsonLoginMethodObj, "isPrimary", ValueType.BOOLEAN)) { + if (jsonLoginMethodObj.get("isPrimary").getAsBoolean()) { + if (hasPrimaryLoginMethod) { + errors.add("No two loginMethods can have isPrimary as true."); + } + hasPrimaryLoginMethod = true; + } + } + + loginMethods.add(new LoginMethod(jsonLoginMethodObj)); + } + + return loginMethods; + } + + @SuppressWarnings("unchecked") + private T parseAndValidateField(JsonObject jsonObject, String key, ValueType expectedType, boolean isRequired, + Class targetType, String errorSuffix) { + if (jsonObject.has(key)) { + if (validateJsonFieldType(jsonObject, key, expectedType)) { + T value; + switch (expectedType) { + case STRING: + value = (T) jsonObject.get(key).getAsString(); + break; + case NUMBER: + value = (T) jsonObject.get(key).getAsNumber(); + break; + case BOOLEAN: + Boolean boolValue = jsonObject.get(key).getAsBoolean(); + value = (T) boolValue; + break; + case OBJECT: + value = (T) jsonObject.get(key).getAsJsonObject(); + break; + case ARRAY_OF_OBJECT, ARRAY_OF_STRING: + value = (T) jsonObject.get(key).getAsJsonArray(); + break; + default: + value = null; + break; + } + if (value != null) { + return targetType.cast(value); + } else { + errors.add(key + " should be of type " + getTypeForErrorMessage(expectedType) + errorSuffix); + } + } else { + errors.add(key + " should be of type " + getTypeForErrorMessage(expectedType) + errorSuffix); + } + } else if (isRequired) { + errors.add(key + " is required" + errorSuffix); + } + return null; + } + + public enum ValueType { + STRING, + NUMBER, + BOOLEAN, + OBJECT, + ARRAY_OF_STRING, + ARRAY_OF_OBJECT + } + + private String getTypeForErrorMessage(ValueType type) { + return switch (type) { + case STRING -> "string"; + case NUMBER -> "number"; + case BOOLEAN -> "boolean"; + case OBJECT -> "object"; + case ARRAY_OF_STRING -> "array of string"; + case ARRAY_OF_OBJECT -> "array of object"; + }; + } + + private Boolean validateJsonFieldType(JsonObject jsonObject, String key, ValueType expectedType) { + if (jsonObject.has(key)) { + return switch (expectedType) { + case STRING -> jsonObject.get(key).isJsonPrimitive() && jsonObject.getAsJsonPrimitive(key).isString(); + case NUMBER -> jsonObject.get(key).isJsonPrimitive() && jsonObject.getAsJsonPrimitive(key).isNumber(); + case BOOLEAN -> jsonObject.get(key).isJsonPrimitive() && jsonObject.getAsJsonPrimitive(key).isBoolean(); + case OBJECT -> jsonObject.get(key).isJsonObject(); + case ARRAY_OF_OBJECT, ARRAY_OF_STRING -> jsonObject.get(key).isJsonArray() + && validateArrayElements(jsonObject.getAsJsonArray(key), expectedType); + default -> false; + }; + } + return false; + } + + private boolean validateArrayElements(JsonArray array, ValueType expectedType) { + List elements = new ArrayList<>(); + array.forEach(elements::add); + + return switch (expectedType) { + case ARRAY_OF_OBJECT -> elements.stream().allMatch(JsonElement::isJsonObject); + case ARRAY_OF_STRING -> + elements.stream().allMatch(el -> el.isJsonPrimitive() && el.getAsJsonPrimitive().isString()); + default -> false; + }; + } + + public class TotpDevice { + public String secretKey; + public Number period; + public Number skew; + public String deviceName; + + public TotpDevice(JsonObject jsonTotpDevice) { + this.secretKey = parseAndValidateField(jsonTotpDevice, "secretKey", ValueType.STRING, true, String.class, + " for a totp device."); + this.period = parseAndValidateField(jsonTotpDevice, "period", ValueType.NUMBER, true, Number.class, + " for a totp device."); + this.skew = parseAndValidateField(jsonTotpDevice, "skew", ValueType.NUMBER, true, Number.class, + " for a totp device."); + this.deviceName = parseAndValidateField(jsonTotpDevice, "deviceName", ValueType.STRING, false, String.class, + " for a totp device."); + } + } + + public class LoginMethod { + public String tenantId; + public Boolean isVerified; + public Boolean isPrimary; + public long timeJoinedInMSSinceEpoch; + public String recipeId; + + public EmailPasswordLoginMethod emailPasswordLoginMethod; + public ThirdPartyLoginMethod thirdPartyLoginMethod; + public PasswordlessLoginMethod passwordlessLoginMethod; + + public LoginMethod(JsonObject jsonLoginMethod) { + this.recipeId = parseAndValidateField(jsonLoginMethod, "recipeId", ValueType.STRING, true, String.class, + " for a loginMethod."); + this.tenantId = parseAndValidateField(jsonLoginMethod, "tenantId", ValueType.STRING, false, String.class, + " for a loginMethod."); + this.isVerified = parseAndValidateField(jsonLoginMethod, "isVerified", ValueType.BOOLEAN, false, + Boolean.class, " for a loginMethod."); + this.isPrimary = parseAndValidateField(jsonLoginMethod, "isPrimary", ValueType.BOOLEAN, false, + Boolean.class, " for a loginMethod."); + Number timeJoined = parseAndValidateField(jsonLoginMethod, "timeJoinedInMSSinceEpoch", ValueType.NUMBER, + false, Number.class, " for a loginMethod"); + this.timeJoinedInMSSinceEpoch = timeJoined != null ? timeJoined.longValue() : 0; + + if ("emailpassword".equals(this.recipeId)) { + this.emailPasswordLoginMethod = new EmailPasswordLoginMethod(jsonLoginMethod); + } else if ("thirdparty".equals(this.recipeId)) { + this.thirdPartyLoginMethod = new ThirdPartyLoginMethod(jsonLoginMethod); + } else if ("passwordless".equals(this.recipeId)) { + this.passwordlessLoginMethod = new PasswordlessLoginMethod(jsonLoginMethod); + } else if (this.recipeId != null) { + errors.add( + "Invalid recipeId for loginMethod. Pass one of emailpassword, thirdparty or, passwordless!"); + } + } + + public class EmailPasswordLoginMethod { + public String email; + public String passwordHash; + public String hashingAlgorithm; + + public EmailPasswordLoginMethod(JsonObject jsonLoginMethod) { + this.email = parseAndValidateField(jsonLoginMethod, "email", ValueType.STRING, true, String.class, + " for an emailpassword recipe."); + this.passwordHash = parseAndValidateField(jsonLoginMethod, "passwordHash", ValueType.STRING, true, + String.class, " for an emailpassword recipe."); + this.hashingAlgorithm = parseAndValidateField(jsonLoginMethod, "hashingAlgorithm", ValueType.STRING, + true, String.class, " for an emailpassword recipe."); + + if (this.hashingAlgorithm != null && !Arrays.asList("bcrypt", "argon2", "firebase_scrypt").contains(hashingAlgorithm)) { + errors.add( + "Invalid hashingAlgorithm for emailpassword recipe. Pass one of bcrypt, argon2 or, firebase_scrypt!"); + } + } + } + + public class ThirdPartyLoginMethod { + public String email; + public String thirdPartyId; + public String thirdPartyUserId; + + public ThirdPartyLoginMethod(JsonObject jsonObject) { + this.email = parseAndValidateField(jsonObject, "email", ValueType.STRING, true, String.class, + " for a thirdparty recipe."); + this.thirdPartyId = parseAndValidateField(jsonObject, "thirdPartyId", ValueType.STRING, true, + String.class, " for a thirdparty recipe."); + this.thirdPartyUserId = parseAndValidateField(jsonObject, "thirdPartyUserId", ValueType.STRING, true, + String.class, " for a thirdparty recipe."); + } + } + + public class PasswordlessLoginMethod { + public String email; + public String phoneNumber; + + public PasswordlessLoginMethod(JsonObject jsonObject) { + this.email = parseAndValidateField(jsonObject, "email", ValueType.STRING, false, String.class, + " for a passwordless recipe."); + this.phoneNumber = parseAndValidateField(jsonObject, "phoneNumber", ValueType.STRING, false, + String.class, " for a passwordless recipe."); + + if ((email != null && email.isEmpty()) && (phoneNumber != null && phoneNumber.isEmpty())) { + errors.add( + "Either email or phoneNumber is required for a passwordless recipe."); + } + } + } + } +} diff --git a/src/main/java/io/supertokens/pluginInterface/bulkimport/exceptions/InvalidBulkImportDataException.java b/src/main/java/io/supertokens/pluginInterface/bulkimport/exceptions/InvalidBulkImportDataException.java new file mode 100644 index 00000000..86eed1fa --- /dev/null +++ b/src/main/java/io/supertokens/pluginInterface/bulkimport/exceptions/InvalidBulkImportDataException.java @@ -0,0 +1,35 @@ +/* + * 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.pluginInterface.bulkimport.exceptions; + +import java.util.ArrayList; + +public class InvalidBulkImportDataException extends Exception { + private static final long serialVersionUID = 1L; + public ArrayList errors; + + public InvalidBulkImportDataException(ArrayList errors) { + super("Data has missing or invalid fields. Please check the errors field for more details."); + this.errors = errors; + } + + public void addError(String error) { + this.errors.add(error); + } +} + + diff --git a/src/main/java/io/supertokens/pluginInterface/multitenancy/AppIdentifierWithStorage.java b/src/main/java/io/supertokens/pluginInterface/multitenancy/AppIdentifierWithStorage.java index 1134d51d..a2e77ae9 100644 --- a/src/main/java/io/supertokens/pluginInterface/multitenancy/AppIdentifierWithStorage.java +++ b/src/main/java/io/supertokens/pluginInterface/multitenancy/AppIdentifierWithStorage.java @@ -20,6 +20,7 @@ import io.supertokens.pluginInterface.STORAGE_TYPE; import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.authRecipe.AuthRecipeStorage; +import io.supertokens.pluginInterface.bulkimport.BulkImportStorage; import io.supertokens.pluginInterface.dashboard.sqlStorage.DashboardSQLStorage; import io.supertokens.pluginInterface.emailpassword.sqlStorage.EmailPasswordSQLStorage; import io.supertokens.pluginInterface.emailverification.sqlStorage.EmailVerificationSQLStorage; @@ -159,4 +160,12 @@ public ActiveUsersStorage getActiveUsersStorage() { } return (ActiveUsersStorage) this.storage; } + + public BulkImportStorage getBulkImportStorage() { + if (this.storage.getType() != STORAGE_TYPE.SQL) { + // we only support SQL for now + throw new UnsupportedOperationException(""); + } + return (BulkImportStorage) this.storage; + } } diff --git a/src/main/java/io/supertokens/pluginInterface/multitenancy/TenantIdentifierWithStorage.java b/src/main/java/io/supertokens/pluginInterface/multitenancy/TenantIdentifierWithStorage.java index 82002f0c..faf24e25 100644 --- a/src/main/java/io/supertokens/pluginInterface/multitenancy/TenantIdentifierWithStorage.java +++ b/src/main/java/io/supertokens/pluginInterface/multitenancy/TenantIdentifierWithStorage.java @@ -19,6 +19,7 @@ import io.supertokens.pluginInterface.STORAGE_TYPE; import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.authRecipe.AuthRecipeStorage; +import io.supertokens.pluginInterface.bulkimport.BulkImportStorage; import io.supertokens.pluginInterface.emailpassword.sqlStorage.EmailPasswordSQLStorage; import io.supertokens.pluginInterface.emailverification.sqlStorage.EmailVerificationSQLStorage; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; @@ -131,4 +132,12 @@ public MultitenancyStorage getMultitenancyStorageWithTargetStorage() { } return (MultitenancyStorage) this.storage; } + + public BulkImportStorage getBulkImportStorage() { + if (this.storage.getType() != STORAGE_TYPE.SQL) { + // we only support SQL for now + throw new UnsupportedOperationException(""); + } + return (BulkImportStorage) this.storage; + } }