diff --git a/build.gradle b/build.gradle index 022cf70b0..16b4e068d 100644 --- a/build.gradle +++ b/build.gradle @@ -74,6 +74,9 @@ dependencies { // https://mvnrepository.com/artifact/com.googlecode.libphonenumber/libphonenumber/ implementation group: 'com.googlecode.libphonenumber', name: 'libphonenumber', version: '8.13.25' + // https://mvnrepository.com/artifact/com.webauthn4j/webauthn4j-core + implementation group: 'com.webauthn4j', name: 'webauthn4j-core', version: '0.28.3.RELEASE' + compileOnly project(":supertokens-plugin-interface") testImplementation project(":supertokens-plugin-interface") diff --git a/implementationDependencies.json b/implementationDependencies.json index ec4da266a..1a18f9555 100644 --- a/implementationDependencies.json +++ b/implementationDependencies.json @@ -115,6 +115,11 @@ "jar": "https://repo1.maven.org/maven2/com/googlecode/libphonenumber/libphonenumber/8.13.25/libphonenumber-8.13.25.jar", "name": "Libphonenumber 8.13.25", "src": "https://repo1.maven.org/maven2/com/googlecode/libphonenumber/libphonenumber/8.13.25/libphonenumber-8.13.25-sources.jar" + }, + { + "jar": "https://repo1.maven.org/maven2/com/webauthn4j/webauthn4j-core/0.28.3.RELEASE/webauthn4j-core-0.28.3.RELEASE.jar", + "name": "webauthn4j-core 0.28.3.RELEASE", + "src": "https://repo1.maven.org/maven2/com/webauthn4j/webauthn4j-core/0.28.3.RELEASE/webauthn4j-core-0.28.3.RELEASE-sources.jar" } ] } \ No newline at end of file diff --git a/src/main/java/io/supertokens/inmemorydb/Start.java b/src/main/java/io/supertokens/inmemorydb/Start.java index 9a978dff9..e50d88bc5 100644 --- a/src/main/java/io/supertokens/inmemorydb/Start.java +++ b/src/main/java/io/supertokens/inmemorydb/Start.java @@ -92,6 +92,9 @@ import io.supertokens.pluginInterface.userroles.exception.DuplicateUserRoleMappingException; import io.supertokens.pluginInterface.userroles.exception.UnknownRoleException; import io.supertokens.pluginInterface.userroles.sqlStorage.UserRolesSQLStorage; +import io.supertokens.pluginInterface.webauthn.WebAuthNOptions; +import io.supertokens.pluginInterface.webauthn.WebAuthNStorage; +import io.supertokens.pluginInterface.webauthn.WebAuthNStoredCredential; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.TestOnly; import org.sqlite.SQLiteException; @@ -110,7 +113,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, OAuthStorage, WebAuthNStorage { private static final Object appenderLock = new Object(); private static final String ACCESS_TOKEN_SIGNING_KEY_NAME = "access_token_signing_key"; @@ -3292,4 +3295,39 @@ public boolean isOAuthTokenRevokedByJTI(AppIdentifier appIdentifier, String gid, throw new StorageQueryException(e); } } + + @Override + public WebAuthNStoredCredential saveCredentials(TenantIdentifier tenantIdentifier, WebAuthNStoredCredential credential) + throws StorageQueryException { + try { + return WebAuthNQueries.saveCredential(this, tenantIdentifier, credential); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + @Override + public WebAuthNOptions saveGeneratedOptions(TenantIdentifier tenantIdentifier, WebAuthNOptions optionsToSave) throws StorageQueryException { + try { + return WebAuthNQueries.saveOptions(this, tenantIdentifier, optionsToSave); + } catch (SQLException e) { + throw new StorageQueryException(e); + } + } + + @Override + public WebAuthNOptions loadOptionsById(TenantIdentifier tenantIdentifier, String optionsId) + throws StorageQueryException { + try { + return WebAuthNQueries.loadOptionsById(this, tenantIdentifier, optionsId); + } catch (SQLException e){ + throw new StorageQueryException(e); + } + } + + @Override + public AuthRecipeUserInfo signUp(TenantIdentifier tenantIdentifier, String userId, String email, + String relyingPartyId) throws StorageQueryException { + return null; // TODO + } } diff --git a/src/main/java/io/supertokens/inmemorydb/config/SQLiteConfig.java b/src/main/java/io/supertokens/inmemorydb/config/SQLiteConfig.java index 0790898dc..397b3b987 100644 --- a/src/main/java/io/supertokens/inmemorydb/config/SQLiteConfig.java +++ b/src/main/java/io/supertokens/inmemorydb/config/SQLiteConfig.java @@ -184,4 +184,12 @@ public String getOAuthSessionsTable() { public String getOAuthLogoutChallengesTable() { return "oauth_logout_challenges"; } + + public String getWebAuthNUsersTable(){ return "webauthn_users";} + + public String getWebAuthNUserToTenantTable(){ return "webauthn_user_to_tenant"; } + + public String getWebAuthNGeneratedOptionsTable() { return "webauthn_generated_options"; } + + public String getWebAuthNCredentialsTable() { return "webauthn_credentials"; } } diff --git a/src/main/java/io/supertokens/inmemorydb/queries/GeneralQueries.java b/src/main/java/io/supertokens/inmemorydb/queries/GeneralQueries.java index eb2fe4809..30d18c47e 100644 --- a/src/main/java/io/supertokens/inmemorydb/queries/GeneralQueries.java +++ b/src/main/java/io/supertokens/inmemorydb/queries/GeneralQueries.java @@ -457,6 +457,28 @@ public static void createTablesIfNotExists(Start start, Main main) throws SQLExc // index update(start, OAuthQueries.getQueryToCreateOAuthLogoutChallengesTimeCreatedIndex(start), NO_OP_SETTER); } + + if(!doesTableExists(start, Config.getConfig(start).getWebAuthNUsersTable())){ + getInstance(main).addState(CREATING_NEW_TABLE, null); + update(start, WebAuthNQueries.getQueryToCreateWebAuthNUsersTable(start), NO_OP_SETTER); + } + + if(!doesTableExists(start, Config.getConfig(start).getWebAuthNUserToTenantTable())){ + getInstance(main).addState(CREATING_NEW_TABLE, null); + update(start, WebAuthNQueries.getQueryToCreateWebAuthNUsersToTenantTable(start), NO_OP_SETTER); + } + + if(!doesTableExists(start, Config.getConfig(start).getWebAuthNGeneratedOptionsTable())){ + getInstance(main).addState(CREATING_NEW_TABLE, null); + update(start, WebAuthNQueries.getQueryToCreateWebAuthNGeneratedOptionsTable(start), NO_OP_SETTER); + //index + update(start, WebAuthNQueries.getQueryToCreateWebAuthNChallengeExpiresIndex(start), NO_OP_SETTER); + } + + if(!doesTableExists(start, Config.getConfig(start).getWebAuthNCredentialsTable())){ + getInstance(main).addState(CREATING_NEW_TABLE, null); + update(start, WebAuthNQueries.getQueryToCreateWebAuthNCredentialsTable(start), NO_OP_SETTER); + } } public static void setKeyValue_Transaction(Start start, Connection con, TenantIdentifier tenantIdentifier, diff --git a/src/main/java/io/supertokens/inmemorydb/queries/WebAuthNQueries.java b/src/main/java/io/supertokens/inmemorydb/queries/WebAuthNQueries.java new file mode 100644 index 000000000..86eaadd4e --- /dev/null +++ b/src/main/java/io/supertokens/inmemorydb/queries/WebAuthNQueries.java @@ -0,0 +1,297 @@ +/* + * 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.RowMapper; +import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; +import io.supertokens.pluginInterface.webauthn.WebAuthNOptions; +import io.supertokens.pluginInterface.webauthn.WebAuthNStoredCredential; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static io.supertokens.inmemorydb.QueryExecutorTemplate.execute; +import static io.supertokens.inmemorydb.QueryExecutorTemplate.update; +import static io.supertokens.inmemorydb.config.Config.getConfig; +import static io.supertokens.pluginInterface.RECIPE_ID.PASSWORDLESS; +import static io.supertokens.pluginInterface.RECIPE_ID.WEBAUTHN; + +public class WebAuthNQueries { + + public static String getQueryToCreateWebAuthNUsersTable(Start start){ + return "CREATE TABLE IF NOT EXISTS " + Config.getConfig(start).getWebAuthNUsersTable() + "(" + + " app_id VARCHAR(64) DEFAULT 'public' NOT NULL," + + " user_id CHAR(36) NOT NULL," + + " email VARCHAR(256) NOT NULL," + + " rp_id VARCHAR(256) NOT NULL," + + " time_joined BIGINT UNSIGNED NOT NULL," + + " CONSTRAINT webauthn_users_pkey PRIMARY KEY (app_id, user_id), " + + " CONSTRAINT webauthn_users_to_app_id_fkey " + + " FOREIGN KEY (app_id, user_id) REFERENCES " + Config.getConfig(start).getAppIdToUserIdTable() + + " (app_id, user_id) ON DELETE CASCADE " + + ");"; + } + + public static String getQueryToCreateWebAuthNUsersToTenantTable(Start start){ + return "CREATE TABLE IF NOT EXISTS " + Config.getConfig(start).getWebAuthNUserToTenantTable() +" (" + + " app_id VARCHAR(64) DEFAULT 'public' NOT NULL," + + " tenant_id VARCHAR(64) DEFAULT 'public' NOT NULL," + + " user_id CHAR(36) NOT NULL," + + " email VARCHAR(256) NOT NULL," + + " CONSTRAINT webauthn_user_to_tenant_email_key UNIQUE (app_id, tenant_id, email)," + + " CONSTRAINT webauthn_user_to_tenant_pkey PRIMARY KEY (app_id, tenant_id, user_id)," + + " CONSTRAINT webauthn_user_to_tenant_user_id_fkey FOREIGN KEY (app_id, tenant_id, user_id) " + + " REFERENCES "+ Config.getConfig(start).getUsersTable()+" (app_id, tenant_id, user_id) on delete CASCADE" + + ");"; + } + + public static String getQueryToCreateWebAuthNGeneratedOptionsTable(Start start){ + return "CREATE TABLE IF NOT EXISTS " + Config.getConfig(start).getWebAuthNGeneratedOptionsTable() + "(" + + " app_id VARCHAR(64) DEFAULT 'public' NOT NULL," + + " tenant_id VARCHAR(64) DEFAULT 'public' NOT NULL," + + " id CHAR(36) NOT NULL," + + " challenge VARCHAR(256) NOT NULL," + + " email VARCHAR(256)," + + " rp_id VARCHAR(256) NOT NULL," + + " rp_name VARCHAR(256) NOT NULL," + + " origin VARCHAR(256) NOT NULL," + + " expires_at BIGINT UNSIGNED NOT NULL," + + " created_at BIGINT UNSIGNED NOT NULL," + + " CONSTRAINT webauthn_user_challenges_pkey PRIMARY KEY (app_id, tenant_id, id)," + + " CONSTRAINT webauthn_user_challenges_tenant_id_fkey FOREIGN KEY (app_id, tenant_id) " + + " REFERENCES " + Config.getConfig(start).getTenantsTable() + " (app_id, tenant_id) ON DELETE CASCADE" + + ");"; + } + + public static String getQueryToCreateWebAuthNChallengeExpiresIndex(Start start) { + return "CREATE INDEX webauthn_user_challenges_expires_at_index ON " + + Config.getConfig(start).getWebAuthNGeneratedOptionsTable() + + " (app_id, tenant_id, expires_at);"; + } + + public static String getQueryToCreateWebAuthNCredentialsTable(Start start){ + return "CREATE TABLE IF NOT EXISTS "+ Config.getConfig(start).getWebAuthNCredentialsTable() + "(" + + " id VARCHAR(256) NOT NULL," + + " app_id VARCHAR(64) DEFAULT 'public'," + + " rp_id VARCHAR(256)," + + " user_id CHAR(36)," + + " counter BIGINT NOT NULL," + + " public_key BLOB NOT NULL," + //planned as bytea, which is not supported by sqlite + " transports TEXT NOT NULL," + // planned as TEXT[], which is not supported by sqlite + " created_at BIGINT NOT NULL," + + " updated_at BIGINT NOT NULL," + + " CONSTRAINT webauthn_user_credentials_pkey PRIMARY KEY (app_id, rp_id, id)," + + " CONSTRAINT webauthn_user_credentials_webauthn_user_id_fkey FOREIGN KEY (app_id, user_id) REFERENCES " + + Config.getConfig(start).getWebAuthNUsersTable() + " (app_id, user_id) ON DELETE CASCADE" + + ");"; + } + + public static WebAuthNOptions saveOptions(Start start, TenantIdentifier tenantIdentifier, WebAuthNOptions options) + throws SQLException, StorageQueryException { + String INSERT = "INSERT INTO " + Config.getConfig(start).getWebAuthNGeneratedOptionsTable() + + " (app_id, tenant_id, id, challenge, email, rp_id, origin, expires_at, created_at, rp_name) " + + " VALUES (?,?,?,?,?,?,?,?,?, ?);"; + + update(start, INSERT, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); + pst.setString(3, options.generatedOptionsId); + pst.setString(4, options.challenge); + pst.setString(5, options.userEmail); + pst.setString(6, options.relyingPartyId); + pst.setString(7, options.origin); + pst.setLong(8, options.expiresAt); + pst.setLong(9, options.createdAt); + pst.setString(10, options.relyingPartyName); + }); + + return options; + } + + public static WebAuthNOptions loadOptionsById(Start start, TenantIdentifier tenantIdentifier, String optionsId) + throws SQLException, StorageQueryException { + String QUERY = "SELECT * FROM " + Config.getConfig(start).getWebAuthNGeneratedOptionsTable() + + " WHERE app_id = ? AND tenant_id = ? and id = ?"; + return execute(start, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); + pst.setString(3, optionsId); + }, result -> { + if(result.next()){ + return WebAuthNOptionsRowMapper.getInstance().mapOrThrow(result); // we are expecting one or zero results + } + return null; + }); + } + + public static WebAuthNStoredCredential loadCredential(Start start, TenantIdentifier tenantIdentifier, String credentialId) + throws SQLException, StorageQueryException { + String QUERY = "SELECT * FROM " + Config.getConfig(start).getWebAuthNCredentialsTable() + + " WHERE app_id = ? AND id = ?"; + return execute(start, QUERY, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, credentialId); + }, result -> { + if(result.next()){ + return WebAuthnStoredCredentialRowMapper.getInstance().mapOrThrow(result); // we are expecting one or zero results + } + return null; + }); + } + + public static WebAuthNStoredCredential saveCredential(Start start, TenantIdentifier tenantIdentifier, WebAuthNStoredCredential credential) + throws SQLException, StorageQueryException { + String INSERT = "INSERT INTO " + Config.getConfig(start).getWebAuthNCredentialsTable() + + " (id, app_id, rp_id, user_id, counter, public_key, transports, created_at, updated_at) " + + " VALUES (?,?,?,?,?,?,?,?,?);"; + + update(start, INSERT, pst -> { + pst.setString(1, credential.id); + pst.setString(2, credential.appId); + pst.setString(3, credential.rpId); + pst.setString(4, credential.userId); + pst.setLong(5, credential.counter); + pst.setBytes(6, credential.publicKey); + pst.setString(7, credential.transports); + pst.setLong(8, credential.createdAt); + pst.setLong(9, credential.updatedAt); + }); + + return credential; + } + + public static AuthRecipeUserInfo signUp(Start start, TenantIdentifier tenantIdentifier, String userId, String email, + String relyingPartyId) + throws StorageTransactionLogicException, StorageQueryException { + long timeJoined = System.currentTimeMillis(); + start.startTransaction(transactionConnection -> { + try { + Connection sqlCon = (Connection) transactionConnection.getConnection(); + + // app_id_to_user_id + String insertAppIdToUserId = "INSERT INTO " + getConfig(start).getAppIdToUserIdTable() + + "(app_id, user_id, primary_or_recipe_user_id, recipe_id)" + " VALUES(?, ?, ?, ?)"; + update(sqlCon, insertAppIdToUserId, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, userId); + pst.setString(3, userId); + pst.setString(4, WEBAUTHN.toString()); + }); + + // all_auth_recipe_users + String insertAllAuthRecipeUsers = "INSERT INTO " + getConfig(start).getUsersTable() + + + "(app_id, tenant_id, user_id, primary_or_recipe_user_id, recipe_id, time_joined, " + + "primary_or_recipe_user_time_joined)" + + " VALUES(?, ?, ?, ?, ?, ?, ?)"; + update(sqlCon, insertAllAuthRecipeUsers, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); + pst.setString(3, userId); + pst.setString(4, userId); + pst.setString(5, PASSWORDLESS.toString()); + pst.setLong(6, timeJoined); + pst.setLong(7, timeJoined); + }); + + // webauthn_user_to_tenant + String insertWebauthNUsersToTenant = + "INSERT INTO " + Config.getConfig(start).getWebAuthNUserToTenantTable() + + " (app_id, tenant_id, user_id, email) " + + " VALUES (?,?,?,?);"; + + update(sqlCon, insertWebauthNUsersToTenant, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, tenantIdentifier.getTenantId()); + pst.setString(3, userId); + pst.setString(4, email); + }); + + // webauthn_users + String insertWebauthNUsers = "INSERT INTO " + Config.getConfig(start).getWebAuthNUsersTable() + + " (app_id, user_id, email, rp_id, time_joined) " + + " VALUES (?,?,?,?,?);"; + + update(sqlCon, insertWebauthNUsers, pst -> { + pst.setString(1, tenantIdentifier.getAppId()); + pst.setString(2, userId); + pst.setString(3, email); + pst.setString(4, relyingPartyId); + pst.setLong(5, timeJoined); + }); + } catch (SQLException throwables) { + throw new StorageTransactionLogicException(throwables); + } + return null; + }); + + + return null; // TODO + } + + private static class WebAuthnStoredCredentialRowMapper implements RowMapper { + private static final WebAuthnStoredCredentialRowMapper INSTANCE = new WebAuthnStoredCredentialRowMapper(); + + public static WebAuthnStoredCredentialRowMapper getInstance() { + return INSTANCE; + } + + @Override + public WebAuthNStoredCredential map(ResultSet rs) throws Exception { + WebAuthNStoredCredential result = new WebAuthNStoredCredential(); + result.id = rs.getString("id"); + result.appId = rs.getString("app_id"); + result.rpId = rs.getString("rp_id"); + result.userId = rs.getString("user_id"); + result.counter = rs.getLong("counter"); + result.publicKey = rs.getBytes("public_key"); + result.transports = rs.getString("transports"); + result.createdAt = rs.getLong("created_at"); + result.updatedAt = rs.getLong("updated_at"); + return result; + } + } + + private static class WebAuthNOptionsRowMapper implements RowMapper { + private static final WebAuthNOptionsRowMapper INSTANCE = new WebAuthNOptionsRowMapper(); + + public static WebAuthNOptionsRowMapper getInstance() { + return INSTANCE; + } + + @Override + public WebAuthNOptions map(ResultSet rs) throws Exception { + WebAuthNOptions result = new WebAuthNOptions(); + result.timeout = rs.getLong("timeout"); + result.expiresAt = rs.getLong("expires_at"); + result.createdAt = rs.getLong("created_at"); + result.relyingPartyId = rs.getString("rp_id"); + result.origin = rs.getString("origin"); + result.challenge = rs.getString("challenge"); + result.userEmail = rs.getString("email"); + result.generatedOptionsId = rs.getString("id"); + result.relyingPartyName = rs.getString("rp_name"); + return result; + } + } +} diff --git a/src/main/java/io/supertokens/webauthn/WebAuthN.java b/src/main/java/io/supertokens/webauthn/WebAuthN.java new file mode 100644 index 000000000..e83245aec --- /dev/null +++ b/src/main/java/io/supertokens/webauthn/WebAuthN.java @@ -0,0 +1,294 @@ +/* + * 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.webauthn; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.webauthn4j.WebAuthnManager; +import com.webauthn4j.converter.AttestedCredentialDataConverter; +import com.webauthn4j.converter.util.ObjectConverter; +import com.webauthn4j.data.*; +import com.webauthn4j.data.attestation.statement.COSEAlgorithmIdentifier; +import com.webauthn4j.data.client.Origin; +import com.webauthn4j.data.client.challenge.Challenge; +import com.webauthn4j.server.ServerProperty; +import io.supertokens.pluginInterface.Storage; +import io.supertokens.pluginInterface.authRecipe.AuthRecipeStorage; +import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; +import io.supertokens.pluginInterface.dashboard.exceptions.UserIdNotFoundException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; +import io.supertokens.pluginInterface.webauthn.WebAuthNOptions; +import io.supertokens.pluginInterface.webauthn.WebAuthNStorage; +import io.supertokens.pluginInterface.webauthn.WebAuthNStoredCredential; +import io.supertokens.utils.Utils; +import org.jetbrains.annotations.NotNull; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Random; + +public class WebAuthN { + + public static JsonObject generateOptions(TenantIdentifier tenantIdentifier, Storage storage, String email, String displayName, String relyingPartyName, String relyingPartyId, + String origin, Long timeout, String attestation, String residentKey, + String userVerificitaion, JsonArray supportedAlgorithmIds) + throws StorageQueryException, UserIdNotFoundException { + + PublicKeyCredentialRpEntity relyingPartyEntity = new PublicKeyCredentialRpEntity(relyingPartyId, relyingPartyName); + + String id = null; + AuthRecipeStorage authStorage = (AuthRecipeStorage) storage; + AuthRecipeUserInfo[] usersWithEmail = authStorage.listPrimaryUsersByEmail(tenantIdentifier, email); + if(usersWithEmail.length > 0) { + id = usersWithEmail[0].getSupertokensUserId(); + } + + if(id == null){ + throw new UserIdNotFoundException(); + } + + PublicKeyCredentialUserEntity userEntity = new PublicKeyCredentialUserEntity(id.getBytes(StandardCharsets.UTF_8), email, displayName); + + Challenge challenge = getChallenge(); + + List credentialParameters = new ArrayList<>(); + for(int i = 0; i< supportedAlgorithmIds.size(); i++){ + JsonElement supportedAlgoId = supportedAlgorithmIds.get(i); + COSEAlgorithmIdentifier algorithmIdentifier = COSEAlgorithmIdentifier.create(supportedAlgoId.getAsLong()); + PublicKeyCredentialParameters param = new PublicKeyCredentialParameters(PublicKeyCredentialType.PUBLIC_KEY, algorithmIdentifier); + credentialParameters.add(param); + }; + + AuthenticatorSelectionCriteria authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria(null, null, + ResidentKeyRequirement.create(residentKey), UserVerificationRequirement.create(userVerificitaion) ); + + AttestationConveyancePreference attestationConveyancePreference = AttestationConveyancePreference.create(attestation); + + PublicKeyCredentialCreationOptions options = new PublicKeyCredentialCreationOptions(relyingPartyEntity, + userEntity, challenge, credentialParameters, timeout, null, authenticatorSelectionCriteria, + null, attestationConveyancePreference, null); + + String optionsId = Utils.getUUID(); + + WebAuthNOptions savedOptions = saveGeneratedOptions(tenantIdentifier, storage, options.getChallenge(), options.getTimeout(), + options.getRp().getId(), origin, email, optionsId); + + return createResponseFromOptions(options, optionsId, savedOptions.createdAt, savedOptions.expiresAt); + } + + public static JsonObject generateSignInOptions(TenantIdentifier tenantIdentifier, Storage storage, + String relyingPartyId, String origin, Long timeout, + String userVerification) + throws StorageQueryException, UserIdNotFoundException { + + Challenge challenge = getChallenge(); + + String optionsId = Utils.getUUID(); + + saveGeneratedOptions(tenantIdentifier, storage, challenge, timeout, relyingPartyId, origin, + null, optionsId); // TODO is it sure that the email should be null? ask Victor + + JsonObject response = new JsonObject(); + response.addProperty("webauthnGeneratedOptionsId", optionsId); + response.addProperty("rpId", relyingPartyId); + response.addProperty("challenge", Base64.getEncoder().encodeToString(challenge.getValue())); + response.addProperty("timeout", timeout); + response.addProperty("userVerification", userVerification); + + return response; + } + + @NotNull + private static Challenge getChallenge() { + Challenge challenge = new Challenge() { + private final byte[] challenge = new byte[32]; + + { //initializer block + new Random().nextBytes(challenge); + } + + @NotNull + @Override + public byte[] getValue() { + return challenge; + } + }; + return challenge; + } + + public static WebauthNSaveCredentialResponse registerCredentials(Storage storage, TenantIdentifier tenantIdentifier, String recipeUserId, + String optionsId, String credentialId, String registrationResponseJson) + throws Exception { + + WebAuthNStorage webAuthNStorage = (WebAuthNStorage) storage; + WebAuthNOptions generatedOptions = webAuthNStorage.loadOptionsById(tenantIdentifier, optionsId); + + long now = System.currentTimeMillis(); + if(generatedOptions.expiresAt < now) { + throw new Exception("expired"); // TODO make it some meaningful exception + } + if(!generatedOptions.origin.contains(generatedOptions.relyingPartyId)) { // This seems to be bullshit. TODO + throw new Exception("not valid origin"); // TODO make it some meaningful exception + } + + WebAuthnManager nonStrictWebAuthnManager = WebAuthnManager.createNonStrictWebAuthnManager(); + RegistrationData registrationData = nonStrictWebAuthnManager.parseRegistrationResponseJSON(registrationResponseJson); + + RegistrationParameters registrationParameters = getRegistrationParameters(generatedOptions); + + RegistrationData verifiedRegistrationData = nonStrictWebAuthnManager.verify(registrationData, + registrationParameters); + + WebAuthNStoredCredential credentialToSave = mapRegistrationDataToStoredCredential(verifiedRegistrationData, + recipeUserId, credentialId, generatedOptions.userEmail, generatedOptions.relyingPartyId, tenantIdentifier); + + WebAuthNStoredCredential savedCredential = webAuthNStorage.saveCredentials(tenantIdentifier, credentialToSave); + + return mapStoredCredentialToResponse(savedCredential, generatedOptions.userEmail, + generatedOptions.relyingPartyName); + } + + private static WebAuthNStoredCredential mapRegistrationDataToStoredCredential(RegistrationData verifiedRegistrationData, + String userId, String credentialId, String userEmail, + String relyingPartyId, TenantIdentifier tenantIdentifier) { + ObjectConverter objectConverter = new ObjectConverter(); + WebAuthNStoredCredential storedCredential = new WebAuthNStoredCredential(); + storedCredential.id = credentialId; + storedCredential.appId = tenantIdentifier.getAppId(); + storedCredential.rpId = relyingPartyId; + storedCredential.userId = userId; + storedCredential.counter = verifiedRegistrationData.getAttestationObject().getAuthenticatorData().getSignCount(); + AttestedCredentialDataConverter attestedCredentialDataConverter = new AttestedCredentialDataConverter(objectConverter); + storedCredential.publicKey = attestedCredentialDataConverter.convert(verifiedRegistrationData.getAttestationObject().getAuthenticatorData() + .getAttestedCredentialData()); + storedCredential.transports = objectConverter.getJsonConverter().writeValueAsString(verifiedRegistrationData.getTransports()); + storedCredential.createdAt = System.currentTimeMillis(); + storedCredential.updatedAt = storedCredential.createdAt; + + return storedCredential; + } + + @NotNull + private static RegistrationParameters getRegistrationParameters(WebAuthNOptions generatedOptions) { + List pubKeyCredParams = null; //TODO: Specify the same value as the pubKeyCredParams provided in PublicKeyCredentialCreationOptions + boolean userVerificationRequired = false; + boolean userPresenceRequired = true; + + RegistrationParameters registrationParameters = new RegistrationParameters( + new ServerProperty(new Origin(generatedOptions.origin), generatedOptions.relyingPartyId, + new Challenge() { + @NotNull + @Override + public byte[] getValue() { + return generatedOptions.challenge.getBytes(StandardCharsets.UTF_8); + } + }), + pubKeyCredParams, + userVerificationRequired, + userPresenceRequired + ); + return registrationParameters; + } + + private static JsonObject createResponseFromOptions(PublicKeyCredentialCreationOptions options, String id, + long createdAt, long expiresAt) { + JsonObject response = new JsonObject(); + response.addProperty("webauthGeneratedOptionsId", id); + + JsonObject rp = new JsonObject(); + rp.addProperty("id", options.getRp().getId()); + rp. addProperty("name", options.getRp().getName()); + response.add("rp", rp); + + JsonObject user = new JsonObject(); + user.addProperty("id", new String(options.getUser().getId())); + user.addProperty("name", options.getUser().getName()); + user.addProperty("displayName", options.getUser().getDisplayName()); + response.add("user", user); + + response.addProperty("timeout", options.getTimeout()); + response.addProperty("challenge", Base64.getEncoder().encodeToString(options.getChallenge().getValue())); + response.addProperty("attestation", options.getAttestation().getValue()); + + response.addProperty("createdAt", createdAt); + response.addProperty("expiresAt", expiresAt); + + JsonArray pubKeyCredParams = new JsonArray(); + for(PublicKeyCredentialParameters params : options.getPubKeyCredParams()){ + JsonObject pubKeyParam = new JsonObject(); + pubKeyParam.addProperty("alg", params.getAlg().getValue()); + pubKeyParam.addProperty("type", params.getType().getValue()); // should be always public-key + pubKeyCredParams.add(pubKeyParam); + } + response.add("pubKeyCredParams",pubKeyCredParams); + + JsonArray excludeCredentials = new JsonArray(); + if(options.getExcludeCredentials() != null){ + for(PublicKeyCredentialDescriptor exclude : options.getExcludeCredentials()){ + JsonObject excl = new JsonObject(); + excl.addProperty("id", new String(exclude.getId())); + JsonArray transports = new JsonArray(); + for(AuthenticatorTransport transp: exclude.getTransports()){ + transports.add(new JsonPrimitive(transp.getValue())); + } + excl.add("transport", transports); + excludeCredentials.add(excl); + } + } + response.add("excludeCredentials", excludeCredentials); + + JsonObject authenticatorSelection = new JsonObject(); + authenticatorSelection.addProperty("requireResidentKey", options.getAuthenticatorSelection().isRequireResidentKey() == null ? false : options.getAuthenticatorSelection().isRequireResidentKey()); + authenticatorSelection.addProperty("residentKey", options.getAuthenticatorSelection().getResidentKey().getValue()); + authenticatorSelection.addProperty("userVerification", options.getAuthenticatorSelection().getUserVerification().getValue()); + + response.add("authenticatorSelection", authenticatorSelection); + return response; + } + + private static WebAuthNOptions saveGeneratedOptions(TenantIdentifier tenantIdentifier, Storage storage, Challenge challenge, + Long timeout, String relyinPartyId, String origin, String userEmail, String id) + throws StorageQueryException { + WebAuthNStorage webAuthNStorage = (WebAuthNStorage) storage; + WebAuthNOptions savableOptions = new WebAuthNOptions(); + savableOptions.generatedOptionsId = id; + savableOptions.challenge = Base64.getEncoder().encodeToString(challenge.getValue()); + savableOptions.origin = origin; + savableOptions.timeout = timeout; + savableOptions.createdAt = System.currentTimeMillis(); + savableOptions.expiresAt = savableOptions.createdAt + savableOptions.timeout; + savableOptions.relyingPartyId = relyinPartyId; + savableOptions.userEmail = userEmail; + return webAuthNStorage.saveGeneratedOptions(tenantIdentifier, savableOptions); + } + + private static WebauthNSaveCredentialResponse mapStoredCredentialToResponse(WebAuthNStoredCredential credential, + String email, String relyingPartyName){ + WebauthNSaveCredentialResponse response = new WebauthNSaveCredentialResponse(); + response.email = email; + response.webauthnCredentialId = credential.id; + response.recipeUserId = credential.userId; + response.relyingPartyId = credential.rpId; + response.relyingPartyName = relyingPartyName; + return response; + } +} diff --git a/src/main/java/io/supertokens/webauthn/WebauthNSaveCredentialResponse.java b/src/main/java/io/supertokens/webauthn/WebauthNSaveCredentialResponse.java new file mode 100644 index 000000000..232966fe0 --- /dev/null +++ b/src/main/java/io/supertokens/webauthn/WebauthNSaveCredentialResponse.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025, 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.webauthn; + +public class WebauthNSaveCredentialResponse { + public String webauthnCredentialId; + public String recipeUserId; + public String email; + public String relyingPartyId; + public String relyingPartyName; +} diff --git a/src/main/java/io/supertokens/webserver/Webserver.java b/src/main/java/io/supertokens/webserver/Webserver.java index 9a2940898..419222901 100644 --- a/src/main/java/io/supertokens/webserver/Webserver.java +++ b/src/main/java/io/supertokens/webserver/Webserver.java @@ -51,6 +51,9 @@ import io.supertokens.webserver.api.usermetadata.RemoveUserMetadataAPI; import io.supertokens.webserver.api.usermetadata.UserMetadataAPI; import io.supertokens.webserver.api.userroles.*; +import io.supertokens.webserver.api.webauthn.CredentialsRegisterAPI; +import io.supertokens.webserver.api.webauthn.OptionsRegisterAPI; +import io.supertokens.webserver.api.webauthn.SignInOptionsAPI; import org.apache.catalina.LifecycleException; import org.apache.catalina.LifecycleState; import org.apache.catalina.connector.Connector; @@ -301,6 +304,11 @@ private void setupRoutes() { addAPI(new RevokeOAuthSessionAPI(main)); addAPI(new OAuthLogoutAPI(main)); + //webauthn + addAPI(new OptionsRegisterAPI(main)); + addAPI(new SignInOptionsAPI(main)); + addAPI(new CredentialsRegisterAPI(main)); + StandardContext context = tomcatReference.getContext(); Tomcat tomcat = tomcatReference.getTomcat(); diff --git a/src/main/java/io/supertokens/webserver/WebserverAPI.java b/src/main/java/io/supertokens/webserver/WebserverAPI.java index 6533d267d..d43434420 100644 --- a/src/main/java/io/supertokens/webserver/WebserverAPI.java +++ b/src/main/java/io/supertokens/webserver/WebserverAPI.java @@ -29,7 +29,8 @@ import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.emailpassword.exceptions.UnknownUserIdException; import io.supertokens.pluginInterface.exceptions.StorageQueryException; -import io.supertokens.pluginInterface.multitenancy.*; +import io.supertokens.pluginInterface.multitenancy.AppIdentifier; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.useridmapping.UserIdType; @@ -254,7 +255,7 @@ protected boolean checkAPIKey(HttpServletRequest req) { return true; } - private String getTenantId(HttpServletRequest req) { + private String getTenantId(HttpServletRequest req) { String path = req.getServletPath().toLowerCase(); String apiPath = getPath().toLowerCase(); if (!apiPath.startsWith("/")) { diff --git a/src/main/java/io/supertokens/webserver/api/webauthn/CredentialsRegisterAPI.java b/src/main/java/io/supertokens/webserver/api/webauthn/CredentialsRegisterAPI.java new file mode 100644 index 000000000..e5caadbdc --- /dev/null +++ b/src/main/java/io/supertokens/webserver/api/webauthn/CredentialsRegisterAPI.java @@ -0,0 +1,80 @@ +/* + * 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.webauthn; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import io.supertokens.Main; +import io.supertokens.pluginInterface.Storage; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.webauthn.WebAuthN; +import io.supertokens.webauthn.WebauthNSaveCredentialResponse; +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; + +public class CredentialsRegisterAPI extends WebserverAPI { + + public CredentialsRegisterAPI(Main main) { + super(main, "webauthn"); + } + + @Override + public String getPath() { + return "/recipe/webauthn/user/credential/register"; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { + try { + TenantIdentifier tenantIdentifier = getTenantIdentifier(req); + Storage storage = getTenantStorage(req); + + JsonObject input = InputParser.parseJsonObjectOrThrowError(req); + String recipeUserId = InputParser.parseStringOrThrowError(input, "recipeUserId", false); + String webauthGeneratedOptionsId = InputParser.parseStringOrThrowError(input, "webauthGeneratedOptionsId", false); + JsonObject credentialsData = InputParser.parseJsonObjectOrThrowError(input, "credential", false); + String credentialsDataString = new Gson().toJson(credentialsData); + String credentialId = InputParser.parseStringOrThrowError(credentialsData, "id", false); + + WebauthNSaveCredentialResponse savedCredential = WebAuthN + .registerCredentials(storage, tenantIdentifier, recipeUserId, credentialId, + webauthGeneratedOptionsId, credentialsDataString); + + JsonObject result = new JsonObject(); + result.addProperty("status", "OK"); + result.addProperty("webauthnCredentialId", savedCredential.webauthnCredentialId); + result.addProperty("recipeUserId", savedCredential.recipeUserId); + result.addProperty("email", savedCredential.email); + result.addProperty("relyingPartyId", savedCredential.relyingPartyId); + result.addProperty("relyingPartyName", savedCredential.relyingPartyName); + + } catch (TenantOrAppNotFoundException e) { + throw new ServletException(e); + } catch (StorageQueryException e) { + throw new RuntimeException(e); + } catch (Exception e) { // TODO: make this more specific + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/io/supertokens/webserver/api/webauthn/OptionsRegisterAPI.java b/src/main/java/io/supertokens/webserver/api/webauthn/OptionsRegisterAPI.java new file mode 100644 index 000000000..a3bfe371f --- /dev/null +++ b/src/main/java/io/supertokens/webserver/api/webauthn/OptionsRegisterAPI.java @@ -0,0 +1,117 @@ +/* + * 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.webauthn; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import io.supertokens.Main; +import io.supertokens.pluginInterface.Storage; +import io.supertokens.pluginInterface.dashboard.exceptions.UserIdNotFoundException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.utils.Utils; +import io.supertokens.webauthn.WebAuthN; +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; + +public class OptionsRegisterAPI extends WebserverAPI { + + public OptionsRegisterAPI(Main main) { + super(main, "webauthn"); + } + + @Override + public String getPath() { + return "/recipe/webauthn/options/register"; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { + JsonObject input = InputParser.parseJsonObjectOrThrowError(req); + + try { + TenantIdentifier tenantIdentifier = getTenantIdentifier(req); + Storage storage = getTenantStorage(req); + + String email = InputParser.parseStringOrThrowError(input, "email", false); + email = Utils.normaliseEmail(email); + + String displayName = InputParser.parseStringOrThrowError(input, "displayName", true); + if(displayName == null || displayName.equals("")){ + displayName = email; + } + String relyingPartyName = InputParser.parseStringOrThrowError(input, "relyingPartyName", false); + String relyingPartyId = InputParser.parseStringOrThrowError(input, "relyingPartyId", false); + String origin = InputParser.parseStringOrThrowError(input, "origin", false); + + Long timeout = InputParser.parseLongOrThrowError(input, "timeout", true); + if(timeout == null) { + timeout = 6000L; + } + + String attestation = InputParser.parseStringOrThrowError(input, "attestation", true); + if(attestation == null || attestation.equals("")){ + attestation = "none"; + } + + String residentKey = InputParser.parseStringOrThrowError(input, "residentKey", true); + if(residentKey == null || residentKey.equals("")){ + residentKey = "required"; + } + + String userVerificitaion = InputParser.parseStringOrThrowError(input, "userVerificitaion", true); + if(userVerificitaion == null || userVerificitaion.equals("")){ + userVerificitaion = "preferred"; + } + + JsonArray supportedAlgorithmIds = InputParser.parseArrayOrThrowError(input, "supportedAlgorithmIds", true); + if(supportedAlgorithmIds == null || supportedAlgorithmIds.isJsonNull()) { + supportedAlgorithmIds = new JsonArray(); + + JsonPrimitive min8 = new JsonPrimitive(-8); + JsonPrimitive min7 = new JsonPrimitive(-7); + JsonPrimitive min257 = new JsonPrimitive(-257); + supportedAlgorithmIds.add(min8); + supportedAlgorithmIds.add(min7); + supportedAlgorithmIds.add(min257); + } + + JsonObject response = WebAuthN.generateOptions(tenantIdentifier, storage, email, displayName, relyingPartyName, relyingPartyId, origin, timeout, attestation, residentKey, + userVerificitaion, supportedAlgorithmIds); + + + response.addProperty("status", "OK"); + super.sendJsonResponse(200, response, resp); + + } catch (TenantOrAppNotFoundException e) { + throw new ServletException(e); //will be handled by WebserverAPI + } catch (StorageQueryException e) { + throw new RuntimeException(e); + } catch (UserIdNotFoundException e) { + JsonObject response = new JsonObject(); + response.addProperty("error", "USER_WITH_EMAIL_NOT_FOUND_ERROR"); + super.sendJsonResponse(400, response, resp); + } + } +} diff --git a/src/main/java/io/supertokens/webserver/api/webauthn/SignInOptionsAPI.java b/src/main/java/io/supertokens/webserver/api/webauthn/SignInOptionsAPI.java new file mode 100644 index 000000000..23bed71cf --- /dev/null +++ b/src/main/java/io/supertokens/webserver/api/webauthn/SignInOptionsAPI.java @@ -0,0 +1,87 @@ +/* + * 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.webauthn; + +import com.google.gson.JsonObject; +import io.supertokens.Main; +import io.supertokens.pluginInterface.Storage; +import io.supertokens.pluginInterface.dashboard.exceptions.UserIdNotFoundException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.webauthn.WebAuthN; +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; + +public class SignInOptionsAPI extends WebserverAPI { + + + public SignInOptionsAPI(Main main) { + super(main, "webauthn"); + } + + @Override + public String getPath() { + return "/recipe/webauthn/options/signin"; + } + + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException, ServletException { + JsonObject input = InputParser.parseJsonObjectOrThrowError(req); + + try { + TenantIdentifier tenantIdentifier = getTenantIdentifier(req); + Storage storage = getTenantStorage(req); + + String relyingPartyId = InputParser.parseStringOrThrowError(input, "relyingPartyId", false); + String origin = InputParser.parseStringOrThrowError(input, "origin", false); + + Long timeout = InputParser.parseLongOrThrowError(input, "timeout", true); + if(timeout == null) { + timeout = 6000L; + } + + String userVerificitaion = InputParser.parseStringOrThrowError(input, "userVerificitaion", true); + if(userVerificitaion == null || userVerificitaion.equals("")){ + userVerificitaion = "preferred"; + } + + + JsonObject response = WebAuthN.generateSignInOptions(tenantIdentifier, storage, relyingPartyId, origin, timeout, + userVerificitaion); + + + response.addProperty("status", "OK"); + super.sendJsonResponse(200, response, resp); + + } catch (TenantOrAppNotFoundException e) { + throw new ServletException(e); //will be handled by WebserverAPI + } catch (StorageQueryException e) { + throw new RuntimeException(e); + } catch (UserIdNotFoundException e) { + JsonObject response = new JsonObject(); + response.addProperty("error", "USER_WITH_EMAIL_NOT_FOUND_ERROR"); + super.sendJsonResponse(400, response, resp); + } + } +} diff --git a/src/test/java/io/supertokens/test/webauthn/WebAuthNFlowTest.java b/src/test/java/io/supertokens/test/webauthn/WebAuthNFlowTest.java new file mode 100644 index 000000000..39ec87564 --- /dev/null +++ b/src/test/java/io/supertokens/test/webauthn/WebAuthNFlowTest.java @@ -0,0 +1,109 @@ +/* + * 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.webauthn; + +import com.google.gson.JsonObject; +import io.supertokens.ProcessState; +import io.supertokens.emailpassword.EmailPassword; +import io.supertokens.featureflag.EE_FEATURES; +import io.supertokens.featureflag.FeatureFlagTestContent; +import io.supertokens.pluginInterface.STORAGE_TYPE; +import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; +import io.supertokens.storageLayer.StorageLayer; +import io.supertokens.test.TestingProcessManager; +import io.supertokens.test.Utils; +import io.supertokens.test.httpRequest.HttpRequestForTesting; +import io.supertokens.utils.SemVer; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TestRule; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.Assert.assertNotNull; + +public class WebAuthNFlowTest { + + @Rule + public TestRule watchman = Utils.getOnFailure(); + + @AfterClass + public static void afterTesting() { + Utils.afterTesting(); + } + + @Before + public void beforeEach() { + Utils.reset(); + } + + @Test + public void optionsRegisterAPITest() throws Exception { + String[] args = {"../"}; + TestingProcessManager.TestingProcess process = TestingProcessManager.start(args, false); + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{ + EE_FEATURES.ACCOUNT_LINKING, EE_FEATURES.MULTI_TENANCY}); + process.startProcess(); + assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STARTED)); + + if (StorageLayer.getStorage(process.getProcess()).getType() != STORAGE_TYPE.SQL) { + return; + } + TenantIdentifier tenantIdentifier = new TenantIdentifier(null, null, null); + AuthRecipeUserInfo user = EmailPassword.signUp(process.getProcess(), "test@test.com", "password"); + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("email","test@test.com"); + requestBody.addProperty("relyingPartyName","supertokens.com"); + requestBody.addProperty("relyingPartyId","supertokens.com"); + requestBody.addProperty("origin","supertokens.com"); + + JsonObject response = HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/recipe/webauthn/options/register", + requestBody, 10000, 1000, null, SemVer.v5_2.get(), null); + + System.out.println(response.toString()); + + String generatedOptionsId = response.get("webauthGeneratedOptionsId").getAsString(); + + String credentialId = Base64.getEncoder().encodeToString(io.supertokens.utils.Utils.getUUID().getBytes( + StandardCharsets.UTF_8)); + System.out.println("CredentialId = " + credentialId); + + String clientDataJson = "{'challenge': " + response.get("challenge").getAsString() + + ", 'crossorigin': " + false + + ", 'origin': 'supertokens.com'" + + ", 'type': 'webauthn.create'" + +"}"; + + JsonObject credential = new JsonObject(); + credential.addProperty("id", credentialId); + JsonObject credentialResponse = new JsonObject(); + credentialResponse.addProperty("clientDataJson", Base64.getUrlEncoder().encodeToString(clientDataJson.getBytes( + StandardCharsets.UTF_8))); + credential.add("response", credentialResponse); + + System.out.println(credential); + + + + } +}