Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,13 @@ from python_serviceplatformen.models.message import (
Message, MessageHeader, Sender, Recipient, MessageBody, MainDocument, File
)

kombit_access = KombitAccess(cvr="55133018", cert_path=r"C:\Users\az68933\Desktop\SF1601\Certificate.pem")
kombit_access = KombitAccess(cvr="55133018", cert_path=r"C:\somewhere\Certificate.pem")

m = Message(
messageHeader=MessageHeader(
messageType="DIGITALPOST",
messageUUID=str(uuid.uuid4()),
label="Digital Post test message",
mandatory=False,
legalNotification=False,
sender=Sender(
senderID="12345678",
idType="CVR",
Expand Down Expand Up @@ -113,11 +111,37 @@ m = Message(
digital_post.send_message("Digital Post", m, kombit_access)
```

**Note**: If you want to trace the message later in Beskedfordeleren you should save the value of
`Message.MessageHeader.messageUUID`.

### Recipes

The message module also contains a few static helper functions to construct simple messages. These are not meant to
be all encompassing but to help as a starting point.

## Beskedfordeler

This library supports retrieving messages from the Beskedfordeler using the AMQP BeskedHent service.

**Note:** Remember to explicitly set the Beskedfordeler to use BeskedHent instead of BeskedFåTilsendt in the admin module.

```python
from python_serviceplatformen.authentication import KombitAccess
from python_serviceplatformen import message_broker

kombit_access = KombitAccess(cvr="55133018", cert_path=r"C:\somewhere\Certificate.pem")
queue_id="fc42c3fd-a8d6-4a12-afc2-3ccb82d69994"

for message in message_broker.iterate_queue_messages(queue_id, kombit_access):
print(message.decode())
```

Messages can by default only be read once and will be removed from the server after retrieving,
so remember to store the information you need.

Alternatively you can set `auto_acknowledge=False` on the `message_broker.iterate_queue_messages` function to
keep messages in the queue. Keep in mind that Kombit wants the queue to be as empty as possible at all times.

## Tests

This project contains automated tests in the "tests" folder.
Expand Down
13 changes: 12 additions & 1 deletion changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [3.1.0] - 2025-10-08

### Added

- Added integration to Kombit's BeskedFordeler (message broker).

### Changed

- Added SAML token cache to KombitAccess.

## [3.0.1] - 2025-08-27

### Fixed
Expand Down Expand Up @@ -47,7 +57,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Module for authenticating towards Kombit's Serviceplatform API.
- Function for checking if someone is registered for Digital Post or NemSMS.

[Unreleased]: https://github.com/itk-dev-rpa/python-serviceplatformen/compare/3.0.1...HEAD
[Unreleased]: https://github.com/itk-dev-rpa/python-serviceplatformen/compare/3.1.0...HEAD
[3.1.0]: https://github.com/itk-dev-rpa/python-serviceplatformen/releases/tag/3.1.0
[3.0.1]: https://github.com/itk-dev-rpa/python-serviceplatformen/releases/tag/3.0.1
[3.0.0]: https://github.com/itk-dev-rpa/python-serviceplatformen/releases/tag/3.0.0
[2.1.0]: https://github.com/itk-dev-rpa/python-serviceplatformen/releases/tag/2.1.0
Expand Down
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "python_serviceplatformen"
version = "3.0.1"
version = "3.1.0"
authors = [
{ name="ITK Development", email="itk-rpa@mkb.aarhus.dk" },
]
Expand All @@ -16,9 +16,10 @@ classifiers = [
"License :: OSI Approved :: MIT License",
]
dependencies = [
"requests == 2.*",
"requests==2.*",
"cryptography",
"xmlschema"
"xmlschema",
"pika==1.3.*",
]

[project.urls]
Expand Down
60 changes: 48 additions & 12 deletions python_serviceplatformen/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

from datetime import datetime, timedelta
from dataclasses import dataclass

import requests
from requests.exceptions import HTTPError
Expand All @@ -11,12 +12,20 @@
from cryptography.hazmat.primitives import serialization


@dataclass
class Token:
"""Dataclass representing a token with an expiry time."""
value: str
expiry_time: datetime


# pylint: disable-next=too-few-public-methods
class KombitAccess:
"""An object that handles access to the Kombit api."""
cvr: str
cert_path: str
_access_tokens: dict[str, (str, datetime)]
_access_tokens: dict[str, Token]
_saml_tokens: dict[str, Token]
test: bool
environment: str

Expand All @@ -31,6 +40,7 @@ def __init__(self, cvr: str, cert_path: str, test: bool = False) -> None:
self.cvr = cvr
self.cert_path = cert_path
self._access_tokens = {}
self._saml_tokens = {}
self.test = test

if test:
Expand All @@ -44,27 +54,49 @@ def get_access_token(self, entity_id: str) -> str:

Args:
entity_id: The entity id of the endpoint.
test: Whether to use the test api or not.

Returns:
An access token to be used in api calls.

Raises:
HTTPError: If an access token couldn't be obtained for the given entity id.
ValueError: If an access token couldn't be obtained for the given entity id.
"""
if entity_id in self._access_tokens and datetime.now() < self._access_tokens[entity_id][1]:
return self._access_tokens[entity_id][0]
if entity_id in self._access_tokens and datetime.now() < self._access_tokens[entity_id].expiry_time:
return self._access_tokens[entity_id].value

try:
saml_token = _get_saml_token(self.cvr, self.cert_path, entity_id, test=self.test)
saml_token = self.get_saml_token(entity_id)
access_token = _get_access_token(saml_token, self.cert_path, test=self.test)
self._access_tokens[entity_id] = access_token
return access_token[0]
return access_token.value
except HTTPError as exc:
raise ValueError(f"Couldn't obtain access token for {entity_id}: {exc.response.text}") from exc

def get_saml_token(self, entity_id: str) -> str:
"""Get a SAML to the api endpoint with the given entity id.
If a valid SAML token already exists for the endpoint it is reused.

Args:
entity_id: The entity id of the endpoint.

Raises:
ValueError: If a SAML token couldn't be obtained for the given entity id.

Returns:
The SAML token as a string
"""
if entity_id in self._saml_tokens and datetime.now() < self._saml_tokens[entity_id].expiry_time:
return self._saml_tokens[entity_id].value

def _get_saml_token(cvr: str, cert_path: str, entity_id: str, test: bool) -> str:
try:
saml_token = _get_saml_token(self.cvr, self.cert_path, entity_id, test=self.test)
self._saml_tokens[entity_id] = saml_token
return saml_token.value
except HTTPError as exc:
raise ValueError(f"Couldn't obtain SAML token for {entity_id}: {exc.response.text}") from exc


def _get_saml_token(cvr: str, cert_path: str, entity_id: str, test: bool) -> Token:
"""Get a SAML token for the endpoint with the given entity id.

Args:
Expand All @@ -74,7 +106,7 @@ def _get_saml_token(cvr: str, cert_path: str, entity_id: str, test: bool) -> str
test: Whether to use the test api or not.

Returns:
A SAML token as a string.
A tuple of the SAML token as a string and the expiry time.
"""
use_key = _extract_first_certificate(cert_path)

Expand All @@ -101,11 +133,15 @@ def _get_saml_token(cvr: str, cert_path: str, entity_id: str, test: bool) -> str

response = requests.post(url, json=payload, cert=cert_path, timeout=10)
response.raise_for_status()
response = response.json()

saml_token = response['RequestedSecurityToken']['Assertion']
expiry_time = datetime.strptime(response["Lifetime"], "%m/%d/%Y %H:%M:%S") - timedelta(minutes=5)

return response.json()['RequestedSecurityToken']['Assertion']
return Token(saml_token, expiry_time)


def _get_access_token(saml_token: str, cert_path: str, test: bool) -> tuple[str, datetime]:
def _get_access_token(saml_token: str, cert_path: str, test: bool) -> Token:
"""Get an access token for the given SAML context.

Args:
Expand All @@ -132,7 +168,7 @@ def _get_access_token(saml_token: str, cert_path: str, test: bool) -> tuple[str,
access_token = f"{response['token_type']} {response['access_token']}"
expiry_time = datetime.now() + timedelta(seconds=response['expires_in']) - timedelta(minutes=5)

return access_token, expiry_time
return Token(access_token, expiry_time)


def _extract_first_certificate(pem_file: str) -> str:
Expand Down
87 changes: 87 additions & 0 deletions python_serviceplatformen/message_broker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""This module contains helper function to Kombit's Beskedfordeler SF1461.
https://digitaliseringskataloget.dk/integration/sf1461
"""

import base64
import ssl
from collections.abc import Generator

import pika
from pika.credentials import ExternalCredentials

from python_serviceplatformen.authentication import KombitAccess

PORT = 5671
VIRTUAL_HOST = "BF"
PROD_HOST = "beskedfordeler.stoettesystemerne.dk"
TEST_HOST = "beskedfordeler.eksterntest-stoettesystemerne.dk"


class TokenCredentials(ExternalCredentials):
"""A class for handling AMQP authorization using an
external token.
"""
def __init__(self, token: bytes):
super().__init__()
self.token = token

def response_for(self, start):
"""Handle AMQP auth challenge."""
return self.TYPE, self.token


def _setup_pika_params(kombit_access: KombitAccess) -> pika.ConnectionParameters:
"""Setup parameters used by Pika to connect to the AMQP server.

Args:
kombit_access: The KombitAccess object used to authenticate.

Returns:
A pika.ConnectionsParameters object that can be used to connect.
"""
saml_token = kombit_access.get_saml_token("http://entityid.kombit.dk/service/bfo_modtag/2")
saml_decoded = base64.b64decode(saml_token)

if kombit_access.test:
host = TEST_HOST
else:
host = PROD_HOST

ssl_context = ssl.create_default_context()
ssl_options = pika.SSLOptions(context=ssl_context, server_hostname=host)
credentials = TokenCredentials(token=saml_decoded)

return pika.ConnectionParameters(
host=host,
port=PORT,
virtual_host=VIRTUAL_HOST,
ssl_options=ssl_options,
credentials=credentials
)


def iterate_queue_messages(queue_id: str, kombit_access: KombitAccess, auto_acknowledge: bool = True) -> Generator[bytes]:
"""Iterate over message in the given queue.
Messages are retrieved one at a time from the queue server.
If auto_acknowledge is true messages are removed from the queue server
and cannot be retrieved again.

Args:
queue_id: The id of the queue to get messages from. Most likely a UUID.
kombit_access: The KombitAccess object used for authentication.
auto_acknowledge: Whether to mark messages as read after receiving them. Defaults to True.

Yields:
The message body as bytes.
"""
params = _setup_pika_params(kombit_access)

with pika.BlockingConnection(parameters=params) as connection:
channel = connection.channel()

while True:
method_frame, header_frame, body = channel.basic_get(queue_id, auto_ack=auto_acknowledge)
if not any((method_frame, header_frame, body)):
return # Queue is empty

yield body
29 changes: 21 additions & 8 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,27 @@ def test_kombit_access(self):
cert_path = os.environ["KOMBIT_TEST_CERT_PATH"]
ka = KombitAccess(cvr=cvr, cert_path=cert_path, test=True)

# Test getting a token
token = ka.get_access_token("http://entityid.kombit.dk/service/postforespoerg/1")
self.assertIsInstance(token, str)
self.assertGreater(len(token), 0)

# Test reuse of token
token2 = ka.get_access_token("http://entityid.kombit.dk/service/postforespoerg/1")
self.assertEqual(token, token2)
# Test getting a SAML token
saml_token = ka.get_saml_token("http://entityid.kombit.dk/service/postforespoerg/1")
self.assertIsInstance(saml_token, str)
self.assertGreater(len(saml_token), 0)

# Test reuse of SAML token
saml_token2 = ka.get_saml_token("http://entityid.kombit.dk/service/postforespoerg/1")
self.assertEqual(saml_token, saml_token2)

# Test getting an access token
access_token = ka.get_access_token("http://entityid.kombit.dk/service/postforespoerg/1")
self.assertIsInstance(access_token, str)
self.assertGreater(len(access_token), 0)

# Test reuse of access token
access_token2 = ka.get_access_token("http://entityid.kombit.dk/service/postforespoerg/1")
self.assertEqual(access_token, access_token2)

# Test getting a nonsense token
with self.assertRaises(ValueError):
ka.get_saml_token("FooBar")

# Test getting a nonsense token
with self.assertRaises(ValueError):
Expand Down
Loading