Micronaut Security API Key

Learn how to secure a Pyronaut application using an API Key.

1. Getting Started

In this guide, you will create a Pyronaut application written in Python 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

  • GraalPy installed and the Pyronaut CLI available locally

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.

4. Writing the Application

Create an application using the Pyronaut CLI (Command Line Interface) or Pyronaut Launch

pyronaut create example.micronaut.micronautguide --features=security

4.1. API Key Repository

To keep things simple, populate API keys via configuration.

src/example/micronaut/api_key_configuration.py
from dataclasses import dataclass

from micronaut.context.annotation import EachProperty
from micronaut.serde.annotation import Serdeable

@Serdeable
@EachProperty("api-keys")  (1)
@dataclass
class ApiKeyConfiguration:
    name: str
    key: str
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.

src/example/micronaut/api_key_repository.py
from abc import ABC, abstractmethod

class ApiKeyRepository(ABC):  (1)
    @abstractmethod
    def find_by_api_key(self, api_key: str) -> str | None:
        pass
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.

src/example/micronaut/api_key_repository_impl.py
from jakarta.inject import Singleton

from .api_key_configuration import ApiKeyConfiguration
from .api_key_repository import ApiKeyRepository

@Singleton  (1)
class ApiKeyRepositoryImpl(ApiKeyRepository):
    def __init__(self, api_keys: list[ApiKeyConfiguration]):
        self.keys = {configuration.key: configuration.name for configuration in api_keys}

    def find_by_api_key(self, api_key: str) -> str | None:
        return self.keys.get(api_key)
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.

src/example/micronaut/api_key_token_reader.py
from jakarta.inject import Singleton
from micronaut.http import HttpRequest
from micronaut.security.token.reader import TokenReader

@Singleton  (1)
class ApiKeyTokenReader(TokenReader):  (2)
    def findToken(self, request: HttpRequest):
        return request.getHeaders().findFirst("X-API-KEY")
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 by using the ApiKeyRepository.

src/example/micronaut/api_key_token_validator.py
from jakarta.inject import Singleton
from micronaut.core.async_.publisher import Publishers
from micronaut.security.authentication import Authentication
from micronaut.security.token.validator import TokenValidator
from org.reactivestreams import Publisher

from .api_key_repository import ApiKeyRepository

@Singleton  (1)
class ApiKeyTokenValidator(TokenValidator):
    def __init__(self, api_key_repository: ApiKeyRepository):  (2)
        self.api_key_repository = api_key_repository

    def validateToken(self, token: str, request) -> Publisher:
        if request is None or not request.getPath().startswith("/api"):  (3)
            return Publishers.empty()
        name = self.api_key_repository.find_by_api_key(token)
        if name is None:
            return Publishers.empty()
        return Publishers.just(Authentication.build(name))
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.

src/example/micronaut/api_controller.py
from java.security import Principal
from micronaut.http import MediaType
from micronaut.http.annotation import Get, Produces
from micronaut.security.annotation import Secured
from micronaut.security.rules import SecurityRule

@Produces(MediaType.TEXT_PLAIN)  (2)
@Secured(SecurityRule.IS_AUTHENTICATED)  (4)
@Get("/api")   (1) (3)
def index(principal: Principal) -> str:  (5)
    return f"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.

tests/example/micronaut/test_api_controller.py
import pytest
import requests
from pyronaut.test import MicronautTest, micronaut_test_fixture

@pytest.fixture
def my_context(request):
    fixture = micronaut_test_fixture(  (2)
        request,
        MicronautTest(
            properties={  (1)
                "api-keys.companyA.key": "XXX",
                "api-keys.companyA.name": "John",
                "api-keys.companyB.key": "YYY",
                "api-keys.companyB.name": "Paul",
            }
        ),
    )
    yield fixture
    fixture.stop()


@pytest.fixture
def client(my_context):
    return requests.with_context(my_context)  (3)


def create_request(client, api_key: str):
    return client.get(
        "/api",
        headers={
            "Accept": "text/plain",
            "X-API-KEY": api_key,
        },
    )


def test_api_is_secured(client):
    response = client.get("/api", headers={"Accept": "text/plain"})

    assert response.status_code == 401


def test_api_not_accessible_if_wrong_key(client):
    response = create_request(client, "ZZZ")

    assert response.status_code == 401


def test_api_is_accessible_with_an_api_key(client):
    response = create_request(client, "XXX")

    assert response.status_code == 200
    assert response.text == "Hello John"

    response = create_request(client, "YYY")

    assert response.status_code == 200
    assert response.text == "Hello Paul"
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.

src/example/micronaut/app_controller.py
from micronaut.http import MediaType
from micronaut.http.annotation import Get, Produces
from micronaut.security.annotation import Secured
from micronaut.security.rules import SecurityRule

@Produces(MediaType.TEXT_PLAIN)  (2)
@Secured(SecurityRule.IS_AUTHENTICATED)  (3)
@Get("/app")   (1) (4)
def index() -> str:
    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.

tests/example/micronaut/test_app_controller.py
import pytest
import requests
from pyronaut.test import MicronautTest, micronaut_test_fixture

@pytest.fixture
def my_context(request):
    fixture = micronaut_test_fixture(  (2)
        request,
        MicronautTest(
            properties={  (1)
                "api-keys.companyA.key": "XXX",
                "api-keys.companyA.name": "John",
            }
        ),
    )
    yield fixture
    fixture.stop()

@pytest.fixture
def client(my_context):
    return requests.with_context(my_context)  (3)

def test_app_is_secured(client):
    response = client.get("/app", headers={"Accept": "text/plain"})
    assert response.status_code == 401

def test_api_key_not_valid_for_top_secret(client):
    response = client.get(
        "/app",
        headers={
            "Accept": "text/plain",
            "X-API-KEY": "XXX",
        },
    )

    assert response.status_code == 401
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:

pyronaut install
pyronaut validate-config
pyronaut test

5.1. Resources

Set configuration to have an API available when you run your application:

config/application.toml
[api-keys.sdelamo]
name = "Sergio"
key = "FOOBAR"

6. Running the Application

To run the application, use the pyronaut dev 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/api

7. Next Steps

See the Micronaut security documentation to learn more.

8. License

All guides are released with an Apache License 2.0 for the code and a Creative Commons Attribution 4.0 license for the writing and media (images).