mn create-app example.micronaut.micronautguide \
--features=yaml,security,graalvm \
--build=gradle \
--lang=java \
--test=junit
Micronaut Security API Key
Learn how to secure a Micronaut application using an API Key.
Authors: Sergio del Amo
Micronaut Version: 4.6.3
1. Getting Started
In this guide, you will create a Micronaut application written in Java and secure it with an API Key.
2. What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE (e.g. IntelliJ IDEA)
-
JDK 17 or greater installed with
JAVA_HOME
configured appropriately
3. Solution
We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.
-
Download and unzip the source
4. Writing the Application
Create an application using the Micronaut Command Line Interface or with Micronaut Launch.
If you don’t specify the --build argument, Gradle with the Kotlin DSL is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.If you don’t specify the --test argument, JUnit is used for Java and Kotlin, and Spock is used for Groovy.
|
The previous command creates a Micronaut application with the default package example.micronaut
in a directory named micronautguide
.
If you use Micronaut Launch, select Micronaut Application as application type and add yaml
, security
, and graalvm
features.
If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features, and apply those changes to your application. |
4.1. ApiKey Repository
To keep things simple, populate API keys via configuration.
package example.micronaut;
import io.micronaut.context.annotation.EachProperty;
import jakarta.annotation.Nonnull;
@EachProperty("api-keys") (1)
public interface ApiKeyConfiguration {
@Nonnull
String getName();
@Nonnull
String getKey();
}
1 | The @EachProperty annotation creates a ConfigurationProperties bean for each sub-property within the given name. |
Create a functional interface to retrieve a user given an API Key.
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
import jakarta.validation.constraints.NotBlank;
import java.security.Principal;
import java.util.Optional;
@FunctionalInterface (1)
public interface ApiKeyRepository {
@NonNull
Optional<Principal> findByApiKey(@NonNull @NotBlank String apiKey);
}
1 | An interface with one abstract method declaration is known as a functional interface. The compiler verifies that all interfaces annotated with @FunctionInterface really contain one and only one abstract method. |
Create an implementation of ApiKeyRepository
which uses the API Keys stored in configuration.
package example.micronaut;
import jakarta.inject.Singleton;
import io.micronaut.core.annotation.NonNull;
import jakarta.validation.constraints.NotBlank;
import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.List;
@Singleton (1)
class ApiKeyRepositoryImpl implements ApiKeyRepository {
private final Map<String, Principal> keys;
ApiKeyRepositoryImpl(List<ApiKeyConfiguration> apiKeys) {
keys = new HashMap<>();
for (ApiKeyConfiguration configuration : apiKeys) {
keys.put(configuration.getKey(), configuration::getName);
}
System.out.println("Keys #" + keys.keySet().size());
}
@Override
@NonNull
public Optional<Principal> findByApiKey(@NonNull @NotBlank String apiKey) {
return Optional.ofNullable(keys.get(apiKey));
}
}
1 | Use jakarta.inject.Singleton to designate a class as a singleton. |
4.2. Token Reader
Micronaut Security is flexible. You define a bean of type TokenReader to read tokens from a custom HTTP Header such as X-API-Key
.
package example.micronaut;
import io.micronaut.security.token.reader.HttpHeaderTokenReader;
import jakarta.inject.Singleton;
@Singleton (1)
public class ApiKeyTokenReader extends HttpHeaderTokenReader { (2)
private static final String X_API_TOKEN = "X-API-KEY";
@Override
protected String getPrefix() {
return null;
}
@Override
protected String getHeaderName() {
return X_API_TOKEN;
}
}
1 | Use jakarta.inject.Singleton to designate a class as a singleton. |
2 | HttpHeaderTokenReader is a convenient abstract class to ease reading a token from an HTTP Header. |
4.3. Token Validator
Define a bean of type TokenValidator to validate the API Keys leveraging the ApiKeyRepository
.
package example.micronaut;
import io.micronaut.core.async.publisher.Publishers;
import io.micronaut.http.HttpRequest;
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.token.validator.TokenValidator;
import jakarta.inject.Singleton;
import org.reactivestreams.Publisher;
@Singleton (1)
class ApiKeyTokenValidator implements TokenValidator<HttpRequest<?>> {
private final ApiKeyRepository apiKeyRepository;
ApiKeyTokenValidator(ApiKeyRepository apiKeyRepository) { (2)
this.apiKeyRepository = apiKeyRepository;
}
@Override
public Publisher<Authentication> validateToken(String token, HttpRequest<?> request) {
if (request == null || !request.getPath().startsWith("/api")) { (3)
return Publishers.empty();
}
return apiKeyRepository.findByApiKey(token)
.map(principal -> Authentication.build(principal.getName()))
.map(Publishers::just).orElseGet(Publishers::empty);
}
}
1 | Use jakarta.inject.Singleton to designate a class as a singleton. |
2 | Use constructor injection to inject a bean of type ApiKeyRepository . |
3 | You can restrict the validity of an API Key to specific paths. |
4.4. Controllers
To test the application create several controllers.
package example.micronaut;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Produces;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
import java.security.Principal;
@Controller("/api") (1)
class ApiController {
@Produces(MediaType.TEXT_PLAIN) (2)
@Get (3)
@Secured(SecurityRule.IS_AUTHENTICATED) (4)
String index(Principal principal) { (5)
return "Hello " + principal.getName();
}
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /api . |
2 | Set the response content-type to text/plain with the @Produces annotation. |
3 | The @Get annotation maps the index method to an HTTP GET request on / . |
4 | Annotate with io.micronaut.security.Secured to configure secured access. The SecurityRule.IS_AUTHENTICATED expression allows only access to authenticated users. |
5 | You can bind java.security.Principal as a method’s parameter in a controller. |
Create tests that verify /api
returns 401 if no API Key is present or the request contains a wrong API Key.
package example.micronaut;
import io.micronaut.context.annotation.Property;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.MediaType;
import io.micronaut.http.client.BlockingHttpClient;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@Property(name = "api-keys.companyA.key", value = "XXX") (1)
@Property(name = "api-keys.companyA.name", value = "John") (1)
@Property(name = "api-keys.companyB.key", value = "YYY") (1)
@Property(name = "api-keys.companyB.name", value = "Paul") (1)
@MicronautTest (2)
class ApiControllerTest {
@Inject
@Client("/")
HttpClient httpClient; (3)
@Test
void apiIsSecured() {
BlockingHttpClient client = httpClient.toBlocking();
HttpRequest<?> request = HttpRequest.GET("/api").accept(MediaType.TEXT_PLAIN);
Executable e = () -> client.exchange(request);
HttpClientResponseException thrown = assertThrows(HttpClientResponseException.class, e);
assertEquals(HttpStatus.UNAUTHORIZED, thrown.getStatus());
}
@Test
void apiNotAccessibleIfWrongKey() {
BlockingHttpClient client = httpClient.toBlocking();
HttpRequest<?> request = createRequest("ZZZ");
Executable e = () -> client.exchange(request);
HttpClientResponseException thrown = assertThrows(HttpClientResponseException.class, e);
assertEquals(HttpStatus.UNAUTHORIZED, thrown.getStatus());
}
@Test
void apiIsAccessibleWithAnApiKey() {
BlockingHttpClient client = httpClient.toBlocking();
HttpResponse<String> response = assertDoesNotThrow(() -> client.exchange(createRequest("XXX"), String.class));
assertEquals(HttpStatus.OK, response.getStatus());
Optional<String> body = response.getBody();
assertTrue(body.isPresent());
assertEquals("Hello John", body.get());
response = assertDoesNotThrow(() -> client.exchange(createRequest("YYY"), String.class));
assertEquals(HttpStatus.OK, response.getStatus());
body = response.getBody();
assertTrue(body.isPresent());
assertEquals("Hello Paul", body.get());
}
private static HttpRequest<?> createRequest(String apiKey) {
return HttpRequest.GET("/api")
.accept(MediaType.TEXT_PLAIN)
.header("X-API-KEY", apiKey);
}
}
1 | Annotate the class with @Property to supply configuration to the test. |
2 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
3 | Inject the HttpClient bean and point it to the embedded server. |
Create a controller whose path does not match the ApiKeyTokenValidator
.
package example.micronaut;
import io.micronaut.http.MediaType;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Produces;
import io.micronaut.security.annotation.Secured;
import io.micronaut.security.rules.SecurityRule;
@Controller("/app") (1)
class AppController {
@Produces(MediaType.TEXT_PLAIN) (2)
@Secured(SecurityRule.IS_AUTHENTICATED) (3)
@Get (4)
String index() {
return "Top Secret";
}
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /app . |
2 | Set the response content-type to text/plain with the @Produces annotation. |
3 | Annotate with io.micronaut.security.Secured to configure secured access. The SecurityRule.IS_AUTHENTICATED expression allows only access to authenticated users. |
4 | The @Get annotation maps the index method to an HTTP GET request on / . |
Create tests that verify /app
returns 401 if no API Key is present or even when the request contains a valid API Key.
package example.micronaut;
import io.micronaut.context.annotation.Property;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.MediaType;
import io.micronaut.http.client.BlockingHttpClient;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@Property(name = "api-keys.companyA.key", value = "XXX") (1)
@Property(name = "api-keys.companyA.name", value = "John") (1)
@MicronautTest (2)
class AppControllerTest {
@Inject
@Client("/")
HttpClient httpClient; (3)
@Test
void appIsSecured() {
BlockingHttpClient client = httpClient.toBlocking();
HttpRequest<?> request = HttpRequest.GET("/app").accept(MediaType.TEXT_PLAIN);
Executable e = () -> client.exchange(request);
HttpClientResponseException thrown = assertThrows(HttpClientResponseException.class, e);
assertEquals(HttpStatus.UNAUTHORIZED, thrown.getStatus());
}
@Test
void apiKeyNotValidForTopSecret() {
BlockingHttpClient client = httpClient.toBlocking();
HttpRequest<?> request = HttpRequest.GET("/app").accept(MediaType.TEXT_PLAIN).header("X-API-KEY", "XXX");
Executable e = () -> client.exchange(request);
HttpClientResponseException thrown = assertThrows(HttpClientResponseException.class, e);
assertEquals(HttpStatus.UNAUTHORIZED, thrown.getStatus());
}
}
1 | Annotate the class with @Property to supply configuration to the test. |
2 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
3 | Inject the HttpClient bean and point it to the embedded server. |
5. Testing the Application
To run the tests:
./gradlew test
Then open build/reports/tests/test/index.html
in a browser to see the results.
5.1. Resources
Set configuration to have an API available when you run your application:
api-keys:
sdelamo:
name: Sergio
key: FOOBAR
6. Running the Application
To run the application, use the ./gradlew run
command, which starts the application on port 8080.
To test the running application, issue a GET request to localhost:8080 providing a valid API Key:
curl -i --header "Accept: text/plain" --header "X-API-KEY: FOOBAR" localhost:8080
7. Generate a Micronaut Application Native Executable with GraalVM
We will use GraalVM, the polyglot embeddable virtual machine, to generate a native executable of our Micronaut application.
Compiling native executables ahead of time with GraalVM improves startup time and reduces the memory footprint of JVM-based applications.
Only Java and Kotlin projects support using GraalVM’s native-image tool. Groovy relies heavily on reflection, which is only partially supported by GraalVM.
|
7.1. GraalVM installation
sdk install java 21.0.5-graal
sdk use java 21.0.5-graal
For installation on Windows, or for manual installation on Linux or Mac, see the GraalVM Getting Started documentation.
The previous command installs Oracle GraalVM, which is free to use in production and free to redistribute, at no cost, under the GraalVM Free Terms and Conditions.
Alternatively, you can use the GraalVM Community Edition:
sdk install java 21.0.2-graalce
sdk use java 21.0.2-graalce
7.2. Native executable generation
To generate a native executable using Gradle, run:
./gradlew nativeCompile
The native executable is created in build/native/nativeCompile
directory and can be run with build/native/nativeCompile/micronautguide
.
It is possible to customize the name of the native executable or pass additional parameters to GraalVM:
graalvmNative {
binaries {
main {
imageName.set('mn-graalvm-application') (1)
buildArgs.add('--verbose') (2)
}
}
}
1 | The native executable name will now be mn-graalvm-application |
2 | It is possible to pass extra arguments to build the native executable |
To test the native executable, issue a GET request to localhost:8080 providing a valid API Key:
curl -i --header "Accept: text/plain" --header "X-API-KEY: FOOBAR" localhost:8080
8. Next steps
See the Micronaut security documentation to learn more.
9. Help with the Micronaut Framework
The Micronaut Foundation sponsored the creation of this Guide. A variety of consulting and support services are available.
10. License
All guides are released with an Apache license 2.0 license for the code and a Creative Commons Attribution 4.0 license for the writing and media (images…). |