Repository: kestra-io/kestra (java)

Issue:
Ensure that OSS username in basic auth is a valid email address
### Feature description

OSS has no "user" object, it's just in the kestra configuration.yml so there's some extra work required to ensure this is a valid email address

cc @Skraye 

Pull request:
Basic authentication saved in Repository
closes #2795

part of #2584

Patch:
diff --git a/core/src/main/java/io/kestra/core/utils/AuthUtils.java b/core/src/main/java/io/kestra/core/utils/AuthUtils.java
new file mode 100644
index 000000000..24397350e
--- /dev/null
+++ b/core/src/main/java/io/kestra/core/utils/AuthUtils.java
@@ -0,0 +1,19 @@
+package io.kestra.core.utils;
+
+import com.google.common.hash.Hashing;
+import org.apache.commons.lang3.RandomStringUtils;
+
+import java.nio.charset.StandardCharsets;
+
+public class AuthUtils {
+    public static String encodePassword(String salt, String password)  {
+        return Hashing
+            .sha512()
+            .hashString(salt + "|" + password, StandardCharsets.UTF_8)
+            .toString();
+    }
+
+    public static String generateSalt() {
+        return RandomStringUtils.random(32, true, true);
+    }
+}
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/MiscController.java b/webserver/src/main/java/io/kestra/webserver/controllers/MiscController.java
index b6651c81c..41b3a65f1 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/MiscController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/MiscController.java
@@ -8,6 +8,7 @@ import io.kestra.core.repositories.TemplateRepositoryInterface;
 import io.kestra.core.services.CollectorService;
 import io.kestra.core.services.InstanceService;
 import io.kestra.core.utils.VersionProvider;
+import io.kestra.webserver.services.BasicAuthService;
 import io.micronaut.core.annotation.Nullable;
 import io.micronaut.http.HttpResponse;
 import io.micronaut.http.annotation.Controller;
@@ -41,6 +42,9 @@ public class MiscController {
     @Inject
     CollectorService collectorService;
 
+    @Inject
+    BasicAuthService basicAuthService;
+
     @Inject
     Optional<TemplateRepositoryInterface> templateRepository;
 
@@ -82,7 +86,7 @@ public class MiscController {
                 .initial(this.initialPreviewRows)
                 .max(this.maxPreviewRows)
                 .build()
-            );
+            ).isOauthEnabled(basicAuthService.isEnabled());
 
         if (this.environmentName != null || this.environmentColor != null) {
             builder.environment(
@@ -123,6 +127,8 @@ public class MiscController {
         Environment environment;
 
         Preview preview;
+
+        Boolean isOauthEnabled;
     }
 
     @Value
diff --git a/webserver/src/main/java/io/kestra/webserver/filter/AuthenticationFilter.java b/webserver/src/main/java/io/kestra/webserver/filter/AuthenticationFilter.java
index fca8a8a60..6893025ec 100644
--- a/webserver/src/main/java/io/kestra/webserver/filter/AuthenticationFilter.java
+++ b/webserver/src/main/java/io/kestra/webserver/filter/AuthenticationFilter.java
@@ -1,7 +1,8 @@
 package io.kestra.webserver.filter;
 
+import io.kestra.core.utils.AuthUtils;
+import io.kestra.webserver.services.BasicAuthService;
 import io.micronaut.context.annotation.Requires;
-import io.micronaut.context.annotation.Value;
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.HttpResponse;
 import io.micronaut.http.MutableHttpResponse;
@@ -13,35 +14,28 @@ import io.micronaut.web.router.MethodBasedRouteMatch;
 import io.micronaut.web.router.RouteMatch;
 import io.micronaut.web.router.RouteMatchUtils;
 import io.reactivex.Flowable;
+import jakarta.inject.Inject;
 import org.reactivestreams.Publisher;
 
 import java.util.Base64;
-import java.util.List;
 import java.util.Optional;
 
 @Filter("/**")
-@Requires(property = "kestra.server.basic-auth.enabled", value = "true")
+@Requires(property = "kestra.server-type", pattern = "(WEBSERVER|STANDALONE)")
 public class AuthenticationFilter implements HttpServerFilter {
     private static final String PREFIX = "Basic";
 
-    @Value("${kestra.server.basic-auth.username}")
-    private String username;
-
-    @Value("${kestra.server.basic-auth.password}")
-    private String password;
-
-    @Value("${kestra.server.basic-auth.realm:Kestra}")
-    private String realm;
-
-    @Value("${kestra.server.basic-auth.open-urls:[]}")
-    private List<String> openUrls;
-
-    @Value("${endpoints.all.port:8085}")
-    private int managementPort;
+    @Inject
+    private BasicAuthService basicAuthService;
 
     @Override
     public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, ServerFilterChain chain) {
-        boolean isOpenUrl = this.openUrls
+        if (!basicAuthService.isEnabled()) {
+            return chain.proceed(request);
+        }
+
+        BasicAuthService.SaltedBasicAuthConfiguration basicAuthConfiguration = this.basicAuthService.configuration();
+        boolean isOpenUrl = basicAuthConfiguration.getOpenUrls()
             .stream()
             .anyMatch(s -> request.getPath().startsWith(s));
 
@@ -56,10 +50,10 @@ public class AuthenticationFilter implements HttpServerFilter {
             .map(cred -> BasicAuth.from(cred.substring(PREFIX.length() + 1)));
 
         if (basicAuth.isEmpty() ||
-            !basicAuth.get().username().equals(username) ||
-            !basicAuth.get().password().equals(password)
+            !basicAuth.get().username().equals(basicAuthConfiguration.getUsername()) ||
+            !AuthUtils.encodePassword(basicAuthConfiguration.getSalt(), basicAuth.get().password()).equals(basicAuthConfiguration.getPassword())
         ) {
-            return Flowable.just(HttpResponse.unauthorized().header("WWW-Authenticate", PREFIX + " realm=" + realm));
+            return Flowable.just(HttpResponse.unauthorized().header("WWW-Authenticate", PREFIX + " realm=" + basicAuthConfiguration.getRealm()));
         }
 
         return Flowable.fromPublisher(chain.proceed(request));
@@ -68,7 +62,7 @@ public class AuthenticationFilter implements HttpServerFilter {
     @SuppressWarnings("rawtypes")
     private boolean isManagementEndpoint(HttpRequest<?> request) {
         Optional<RouteMatch> routeMatch = RouteMatchUtils.findRouteMatch(request);
-        if (routeMatch.isPresent() && routeMatch.get() instanceof MethodBasedRouteMatch<?,?> method) {
+        if (routeMatch.isPresent() && routeMatch.get() instanceof MethodBasedRouteMatch<?, ?> method) {
             return method.getAnnotation(Endpoint.class) != null;
         }
         return false;
diff --git a/webserver/src/main/java/io/kestra/webserver/listeners/OssAuthListener.java b/webserver/src/main/java/io/kestra/webserver/listeners/OssAuthListener.java
new file mode 100644
index 000000000..1762ae871
--- /dev/null
+++ b/webserver/src/main/java/io/kestra/webserver/listeners/OssAuthListener.java
@@ -0,0 +1,28 @@
+package io.kestra.webserver.listeners;
+
+import io.kestra.webserver.models.events.OssAuthEvent;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.client.HttpClient;
+import io.micronaut.http.client.annotation.Client;
+import io.micronaut.retry.event.RetryEvent;
+import io.micronaut.runtime.event.annotation.EventListener;
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+import lombok.extern.slf4j.Slf4j;
+
+@Singleton
+@Slf4j
+public class OssAuthListener {
+    @Inject
+    @Client("api")
+    private HttpClient httpClient;
+
+    @EventListener
+    void onOssAuth(final OssAuthEvent event) {
+        httpClient.toBlocking().exchange(HttpRequest.POST(
+            "/v1/reports/events",
+            event
+        ));
+    }
+}
+
diff --git a/webserver/src/main/java/io/kestra/webserver/models/events/Event.java b/webserver/src/main/java/io/kestra/webserver/models/events/Event.java
new file mode 100644
index 000000000..7c6f35b5b
--- /dev/null
+++ b/webserver/src/main/java/io/kestra/webserver/models/events/Event.java
@@ -0,0 +1,24 @@
+package io.kestra.webserver.models.events;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import javax.validation.constraints.NotNull;
+import java.time.Instant;
+
+@Getter
+@AllArgsConstructor
+public class Event {
+    @NotNull
+    protected EventType type;
+
+    @NotNull
+    protected String iid;
+
+    @NotNull
+    protected Instant date;
+
+    public enum EventType {
+        OSS_AUTH
+    }
+}
diff --git a/webserver/src/main/java/io/kestra/webserver/models/events/OssAuthEvent.java b/webserver/src/main/java/io/kestra/webserver/models/events/OssAuthEvent.java
new file mode 100644
index 000000000..2f92b1a5a
--- /dev/null
+++ b/webserver/src/main/java/io/kestra/webserver/models/events/OssAuthEvent.java
@@ -0,0 +1,26 @@
+package io.kestra.webserver.models.events;
+
+import io.micronaut.core.annotation.Introspected;
+import lombok.Builder;
+import lombok.Getter;
+
+import javax.validation.constraints.NotNull;
+import java.time.Instant;
+
+@Getter
+@Introspected
+public class OssAuthEvent extends Event {
+    private final OssAuth ossAuth;
+
+    @Builder
+    public OssAuthEvent(@NotNull OssAuth ossAuth, @NotNull String iid, @NotNull Instant date) {
+        super(EventType.OSS_AUTH, iid, date);
+        this.ossAuth = ossAuth;
+    }
+
+    @Getter
+    @Builder
+    public static class OssAuth {
+        private final String email;
+    }
+}
diff --git a/webserver/src/main/java/io/kestra/webserver/services/BasicAuthService.java b/webserver/src/main/java/io/kestra/webserver/services/BasicAuthService.java
new file mode 100644
index 000000000..83d05eea4
--- /dev/null
+++ b/webserver/src/main/java/io/kestra/webserver/services/BasicAuthService.java
@@ -0,0 +1,184 @@
+package io.kestra.webserver.services;
+
+import com.google.common.annotations.VisibleForTesting;
+import io.kestra.core.models.Setting;
+import io.kestra.core.repositories.SettingRepositoryInterface;
+import io.kestra.core.serializers.JacksonMapper;
+import io.kestra.core.services.InstanceService;
+import io.kestra.core.utils.AuthUtils;
+import io.kestra.webserver.models.events.OssAuthEvent;
+import io.micronaut.context.annotation.ConfigurationInject;
+import io.micronaut.context.annotation.ConfigurationProperties;
+import io.micronaut.context.annotation.Context;
+import io.micronaut.context.annotation.Requires;
+import io.micronaut.context.event.ApplicationEventPublisher;
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
+import lombok.*;
+
+import javax.annotation.Nullable;
+import javax.annotation.PostConstruct;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+// Force an eager crash of the app in case of misconfigured basic authentication (e.g. invalid username)
+@Context
+@Singleton
+@Requires(property = "kestra.server-type", pattern = "(WEBSERVER|STANDALONE)")
+public class BasicAuthService {
+    public static final String BASIC_AUTH_SETTINGS_KEY = "kestra.server.basic-auth";
+    private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9_!#$%&’*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$");
+
+    @Inject
+    private SettingRepositoryInterface settingRepository;
+
+    @Inject
+    private BasicAuthConfiguration basicAuthConfiguration;
+
+    @Inject
+    private InstanceService instanceService;
+
+    @Inject
+    private ApplicationEventPublisher<OssAuthEvent> ossAuthEventPublisher;
+
+    @PostConstruct
+    private void init() {
+        BasicAuthConfiguration configuration = this.configuration();
+        if (configuration == null && Boolean.TRUE.equals(this.basicAuthConfiguration.getEnabled())) {
+            this.save(this.basicAuthConfiguration);
+        }
+    }
+
+    public boolean isEnabled() {
+        BasicAuthConfiguration basicAuthConfiguration = configuration();
+        if (basicAuthConfiguration == null) {
+            return false;
+        }
+
+        return Boolean.TRUE.equals(basicAuthConfiguration.getEnabled()) && basicAuthConfiguration.getUsername() != null && basicAuthConfiguration.getPassword() != null;
+    }
+
+    public void save(BasicAuthConfiguration basicAuthConfiguration) {
+        if (basicAuthConfiguration.getUsername() != null && !EMAIL_PATTERN.matcher(basicAuthConfiguration.getUsername()).matches()) {
+            throw new IllegalArgumentException("Invalid username for Basic Authentication. Please provide a valid email address.");
+        }
+
+        if (basicAuthConfiguration.getPassword() == null) {
+            throw new IllegalArgumentException("No password set for Basic Authentication. Please provide a password.");
+        }
+
+        SaltedBasicAuthConfiguration previousConfiguration = this.configuration();
+        String salt = previousConfiguration == null
+            ? null
+            : previousConfiguration.getSalt();
+        SaltedBasicAuthConfiguration saltedNewConfiguration = new SaltedBasicAuthConfiguration(
+            salt,
+            basicAuthConfiguration
+        );
+        if (!saltedNewConfiguration.equals(previousConfiguration)) {
+            settingRepository.save(
+                Setting.builder()
+                    .key(BASIC_AUTH_SETTINGS_KEY)
+                    .value(saltedNewConfiguration)
+                    .build()
+            );
+
+            ossAuthEventPublisher.publishEventAsync(
+                OssAuthEvent.builder()
+                    .iid(instanceService.fetch())
+                    .date(Instant.now())
+                    .ossAuth(OssAuthEvent.OssAuth.builder()
+                        .email(basicAuthConfiguration.getUsername())
+                        .build()
+                    ).build()
+            );
+        }
+    }
+
+    public void unsecure() {
+        BasicAuthConfiguration configuration = configuration();
+        if (configuration == null || Boolean.FALSE.equals(configuration.getEnabled())) {
+            return;
+        }
+
+        settingRepository.save(Setting.builder()
+            .key(BASIC_AUTH_SETTINGS_KEY)
+            .value(configuration.withEnabled(false))
+            .build());
+    }
+
+    public SaltedBasicAuthConfiguration configuration() {
+        return settingRepository.findByKey(BASIC_AUTH_SETTINGS_KEY)
+            .map(Setting::getValue)
+            .map(value -> JacksonMapper.toMap(value, SaltedBasicAuthConfiguration.class))
+            .orElse(null);
+    }
+
+    @Getter
+    @NoArgsConstructor
+    @EqualsAndHashCode
+    @ConfigurationProperties("kestra.server.basic-auth")
+    public static class BasicAuthConfiguration {
+        @With
+        private Boolean enabled;
+        private String username;
+        protected String password;
+        private String realm;
+        private List<String> openUrls;
+
+        @SuppressWarnings("MnInjectionPoints")
+        @ConfigurationInject
+        public BasicAuthConfiguration(
+            @Nullable Boolean enabled,
+            @Nullable String username,
+            @Nullable String password,
+            @Nullable String realm,
+            @Nullable List<String> openUrls
+        ) {
+            this.enabled = enabled;
+            this.username = username;
+            this.password = password;
+            this.realm = Optional.ofNullable(realm).orElse("Kestra");
+            this.openUrls = Optional.ofNullable(openUrls).orElse(Collections.emptyList());
+        }
+
+        public BasicAuthConfiguration(BasicAuthConfiguration basicAuthConfiguration) {
+            if (basicAuthConfiguration != null) {
+                this.enabled = basicAuthConfiguration.getEnabled();
+                this.username = basicAuthConfiguration.getUsername();
+                this.password = basicAuthConfiguration.getPassword();
+                this.realm = basicAuthConfiguration.getRealm();
+                this.openUrls = basicAuthConfiguration.getOpenUrls();
+            }
+        }
+
+        @VisibleForTesting
+        BasicAuthConfiguration withUsernamePassword(String username, String password) {
+            return new BasicAuthConfiguration(
+                this.enabled,
+                username,
+                password,
+                this.realm,
+                this.openUrls
+            );
+        }
+    }
+
+    @Getter
+    @AllArgsConstructor
+    @EqualsAndHashCode(callSuper = true)
+    public static class SaltedBasicAuthConfiguration extends BasicAuthConfiguration {
+        private final String salt;
+
+        public SaltedBasicAuthConfiguration(String salt, BasicAuthConfiguration basicAuthConfiguration) {
+            super(basicAuthConfiguration);
+            this.salt = salt == null
+                ? AuthUtils.generateSalt()
+                : salt;
+            this.password = AuthUtils.encodePassword(this.salt, basicAuthConfiguration.getPassword());
+        }
+    }
+}
\ No newline at end of file


Test patch:
diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/MiscControllerSecuredTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/MiscControllerSecuredTest.java
new file mode 100644
index 000000000..2630ded69
--- /dev/null
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/MiscControllerSecuredTest.java
@@ -0,0 +1,34 @@
+package io.kestra.webserver.controllers;
+
+import io.kestra.webserver.services.BasicAuthService;
+import io.micronaut.context.annotation.Property;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.client.annotation.Client;
+import io.micronaut.rxjava2.http.client.RxHttpClient;
+import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
+import jakarta.inject.Inject;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+@MicronautTest(transactional = false)
+@Property(name = "kestra.server.basic-auth.enabled", value = "true")
+class MiscControllerSecuredTest {
+    @Inject
+    @Client("/")
+    RxHttpClient client;
+
+    @Inject
+    private BasicAuthService.BasicAuthConfiguration basicAuthConfiguration;
+
+    @Test
+    void configuration() {
+        var response = client.toBlocking().retrieve(HttpRequest.GET("/api/v1/configs").basicAuth(
+            basicAuthConfiguration.getUsername(),
+            basicAuthConfiguration.getPassword()
+        ), MiscController.Configuration.class);
+
+        assertThat(response.getIsOauthEnabled(), is(true));
+    }
+}
\ No newline at end of file
diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/MiscControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/MiscControllerTest.java
index d9767c091..90740b6c2 100644
--- a/webserver/src/test/java/io/kestra/webserver/controllers/MiscControllerTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/MiscControllerTest.java
@@ -30,5 +30,6 @@ class MiscControllerTest extends JdbcH2ControllerTest {
         assertThat(response.getUuid(), notNullValue());
         assertThat(response.getIsTaskRunEnabled(), is(false));
         assertThat(response.getIsAnonymousUsageEnabled(), is(true));
+        assertThat(response.getIsOauthEnabled(), is(false));
     }
 }
\ No newline at end of file
diff --git a/webserver/src/test/java/io/kestra/webserver/filter/AuthenticationFilterTest.java b/webserver/src/test/java/io/kestra/webserver/filter/AuthenticationFilterTest.java
index 6210f6c96..c795f2ce8 100644
--- a/webserver/src/test/java/io/kestra/webserver/filter/AuthenticationFilterTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/filter/AuthenticationFilterTest.java
@@ -1,8 +1,6 @@
 package io.kestra.webserver.filter;
 
-import io.kestra.core.models.Setting;
-import io.kestra.core.repositories.SettingRepositoryInterface;
-import io.kestra.webserver.controllers.h2.JdbcH2ControllerTest;
+import io.kestra.webserver.services.BasicAuthService;
 import io.micronaut.context.annotation.Property;
 import io.micronaut.context.annotation.Value;
 import io.micronaut.http.HttpRequest;
@@ -10,30 +8,23 @@ import io.micronaut.http.HttpStatus;
 import io.micronaut.http.client.annotation.Client;
 import io.micronaut.http.client.exceptions.HttpClientResponseException;
 import io.micronaut.rxjava2.http.client.RxHttpClient;
-import io.micronaut.test.annotation.MockBean;
+import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
 import jakarta.inject.Inject;
 import org.junit.jupiter.api.Test;
 
-import javax.validation.ConstraintViolationException;
-import java.util.Collections;
-import java.util.List;
-import java.util.Optional;
-
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.is;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
+@MicronautTest(transactional = false)
 @Property(name = "kestra.server.basic-auth.enabled", value = "true")
-class AuthenticationFilterTest extends JdbcH2ControllerTest {
+class AuthenticationFilterTest {
     @Inject
     @Client("/")
     private RxHttpClient client;
 
-    @Value("${kestra.server.basic-auth.username}")
-    private String username;
-
-    @Value("${kestra.server.basic-auth.password}")
-    private String password;
+    @Inject
+    private BasicAuthService.BasicAuthConfiguration basicAuthConfiguration;
 
     @Test
     void testUnauthorized() {
@@ -60,7 +51,10 @@ class AuthenticationFilterTest extends JdbcH2ControllerTest {
     @Test
     void testAuthenticated() {
         var response = client.toBlocking()
-            .exchange(HttpRequest.GET("/api/v1/configs").basicAuth(username, password));
+            .exchange(HttpRequest.GET("/api/v1/configs").basicAuth(
+                basicAuthConfiguration.getUsername(),
+                basicAuthConfiguration.getPassword()
+            ));
 
         assertThat(response.getStatus(), is(HttpStatus.OK));
     }
diff --git a/webserver/src/test/java/io/kestra/webserver/services/BasicAuthServiceTest.java b/webserver/src/test/java/io/kestra/webserver/services/BasicAuthServiceTest.java
new file mode 100644
index 000000000..84aca12e8
--- /dev/null
+++ b/webserver/src/test/java/io/kestra/webserver/services/BasicAuthServiceTest.java
@@ -0,0 +1,134 @@
+package io.kestra.webserver.services;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import io.kestra.core.models.Setting;
+import io.kestra.core.repositories.SettingRepositoryInterface;
+import io.kestra.core.serializers.JacksonMapper;
+import io.kestra.core.services.InstanceService;
+import io.kestra.core.utils.Await;
+import io.kestra.webserver.models.events.Event;
+import io.micronaut.context.ApplicationContext;
+import io.micronaut.context.env.Environment;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.TimeoutException;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+@WireMockTest(httpPort = 28181)
+class BasicAuthServiceTest {
+    private BasicAuthService basicAuthService;
+
+    private BasicAuthService.BasicAuthConfiguration basicAuthConfiguration;
+
+    private SettingRepositoryInterface settingRepositoryInterface;
+
+    private InstanceService instanceService;
+
+    private ApplicationContext ctx;
+
+    @BeforeEach
+    void mockEventsAndStartApp() {
+        stubFor(
+            post(urlMatching("/v1/reports/events"))
+                .willReturn(aResponse().withStatus(200))
+        );
+        ctx = ApplicationContext.run(Map.of("kestra.server.basic-auth.enabled", "true"), Environment.TEST);
+
+        basicAuthService = ctx.getBean(BasicAuthService.class);
+        basicAuthConfiguration = ctx.getBean(BasicAuthService.BasicAuthConfiguration.class);
+        settingRepositoryInterface = ctx.getBean(SettingRepositoryInterface.class);
+        instanceService = ctx.getBean(InstanceService.class);
+    }
+
+    @AfterEach
+    void stopApp() {
+        settingRepositoryInterface.delete(Setting.builder().key(BasicAuthService.BASIC_AUTH_SETTINGS_KEY).build());
+
+        ctx.stop();
+    }
+
+    @Test
+    void initFromYamlConfig() throws TimeoutException {
+        assertThat(basicAuthService.isEnabled(), is(true));
+
+        assertConfigurationMatchesApplicationYaml();
+
+        awaitOssAuthEventApiCall("admin@kestra.io");
+    }
+
+    @Test
+    void secure() throws TimeoutException {
+        IllegalArgumentException illegalArgumentException = Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> basicAuthService.save(basicAuthConfiguration.withUsernamePassword("not-an-email", "password"))
+        );
+
+        assertThat(illegalArgumentException.getMessage(), is("Invalid username for Basic Authentication. Please provide a valid email address."));
+
+        assertConfigurationMatchesApplicationYaml();
+
+        basicAuthService.save(basicAuthConfiguration.withUsernamePassword("some@email.com", "password"));
+        awaitOssAuthEventApiCall("some@email.com");
+    }
+
+    @Test
+    void unsecure() {
+        assertThat(basicAuthService.isEnabled(), is(true));
+        BasicAuthService.SaltedBasicAuthConfiguration previousConfiguration = basicAuthService.configuration();
+
+        basicAuthService.unsecure();
+
+        assertThat(basicAuthService.isEnabled(), is(false));
+        BasicAuthService.SaltedBasicAuthConfiguration newConfiguration = basicAuthService.configuration();
+
+
+        assertThat(newConfiguration.getEnabled(), is(false));
+        assertThat(newConfiguration.getUsername(), is(previousConfiguration.getUsername()));
+        assertThat(newConfiguration.getPassword(), is(previousConfiguration.getPassword()));
+        assertThat(newConfiguration.getRealm(), is(previousConfiguration.getRealm()));
+        assertThat(newConfiguration.getOpenUrls(), is(previousConfiguration.getOpenUrls()));
+    }
+
+    private void assertConfigurationMatchesApplicationYaml() {
+        BasicAuthService.SaltedBasicAuthConfiguration actualConfiguration = basicAuthService.configuration();
+        BasicAuthService.SaltedBasicAuthConfiguration applicationYamlConfiguration = new BasicAuthService.SaltedBasicAuthConfiguration(
+            actualConfiguration.getSalt(),
+            basicAuthConfiguration
+        );
+        assertThat(actualConfiguration, is(applicationYamlConfiguration));
+
+        Optional<Setting> maybeSetting = settingRepositoryInterface.findByKey(BasicAuthService.BASIC_AUTH_SETTINGS_KEY);
+        assertThat(maybeSetting.isPresent(), is(true));
+        assertThat(maybeSetting.get().getValue(), is(JacksonMapper.toMap(applicationYamlConfiguration)));
+    }
+
+    private void awaitOssAuthEventApiCall(String email) throws TimeoutException {
+        Await.until(() -> {
+            try {
+                verify(
+                    1,
+                    postRequestedFor(urlEqualTo("/v1/reports/events"))
+                        .withRequestBody(
+                            and(
+                                matchingJsonPath("$.iid", equalTo(instanceService.fetch())),
+                                matchingJsonPath("$.type", equalTo(Event.EventType.OSS_AUTH.name())),
+                                matchingJsonPath("$.ossAuth.email", equalTo(email))
+                            )
+                        )
+                );
+                return true;
+            } catch (AssertionError e) {
+                return false;
+            }
+        }, Duration.ofMillis(100), Duration.ofSeconds(10));
+    }
+}
diff --git a/webserver/src/test/resources/application-test.yml b/webserver/src/test/resources/application-test.yml
index a218d5628..78298d0de 100644
--- a/webserver/src/test/resources/application-test.yml
+++ b/webserver/src/test/resources/application-test.yml
@@ -43,7 +43,7 @@ kestra:
       enabled: false
     basic-auth:
       enabled: false
-      username: admin
+      username: admin@kestra.io
       password: kestra
       open-urls:
         - "/ping"