pyronaut create example.micronaut.micronautguide --features=security
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.
-
Download and unzip the source
4. Writing the Application
Create an application using the Pyronaut CLI (Command Line Interface) or Pyronaut Launch
4.1. API Key Repository
To keep things simple, populate API keys via configuration.
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.
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.
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.
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.
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.
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.
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.
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.
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:
[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). |