Skip to content

Commit e5d22f7

Browse files
authored
Merge pull request #16 from stackitcloud/feature/service-account-authentication
allow service account authentication
2 parents 46ea888 + 6dcfcc6 commit e5d22f7

File tree

5 files changed

+269
-23
lines changed

5 files changed

+269
-23
lines changed

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@ certificates. The subsequent section delineates the pertinent arguments and thei
2828

2929
| Argument | Example Value | Description |
3030
|-------------------------------------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
31-
| `--authenticator` | dns-stackit | Engages the STACKIT authenticator mechanism. This must be configured as dns-stackit. (Mandatory) |
32-
| `--dns-stackit-credentials` | ./credentials.ini | Denotes the directory path to the credentials file for STACKIT DNS. This document must encapsulate the dns_stackit_auth_token and dns_stackit_project_id variables. (Mandatory) |
33-
| `--dns-stackit-propagation-seconds` | 900 | Configures the delay prior to initiating the DNS record query. A 900-second interval (equivalent to 15 minutes) is recommended. (Default: 900) |
31+
| `--authenticator` | dns-stackit | Engages the STACKIT authenticator mechanism. This must be configured as dns-stackit. (Mandatory) |
32+
| `--dns-stackit-project-id` | '8a4c68b1-586a-4534-aa0c-9f8c12334a76' | Sets the STACKIT project id if the service account authentication is used. (Recommended)|
33+
| `--dns-stackit-service-account` | ./service-account.pem | Denotes the directory path to the STACKIT service account file. (Recommended) |
34+
| `--dns-stackit-credentials` | ./credentials.ini | Denotes the directory path to the credentials file for STACKIT DNS. This document must encapsulate the dns_stackit_auth_token and dns_stackit_project_id variables. |
35+
| `--dns-stackit-propagation-seconds` | 900 | Configures the delay prior to initiating the DNS record query. A 900-second interval (equivalent to 15 minutes) is recommended. (Default: 900) |
36+
Either the --dns-stackit-credentials flag or the --dns-stackit-service-account and --dns-stackit-project-id flags are mandatory.
3437

3538
### Example
3639

@@ -65,6 +68,11 @@ It's crucial to replace "your_token_here" and "your_project_id_here" placeholder
6568
authentication token and project ID. The token's associated service account necessitates project membership privileges
6669
for record set creation.
6770

71+
### Authentication via STACKIT service account
72+
73+
The service account allows the user to use a long lived authentication which generates short lived tokens. To setup a service account refer to the [service account documentation](https://docs.stackit.cloud/stackit/en/create-a-service-account-134415839.html).
74+
It's important to also set the --dns-stackit-project-id flag to the corresponding STACKIT project when using a service account.
75+
6876
## Test Procedures
6977

7078
- Unit Testing:

certbot_dns_stackit/stackit.py

Lines changed: 127 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import logging
22
from dataclasses import dataclass
3-
from typing import Optional, List, Callable
4-
3+
from typing import Optional, List, Callable, TypedDict
4+
import jwt
5+
import jwt.help
6+
import json
7+
import time
8+
import uuid
59
import requests
10+
611
from certbot import errors
712
from certbot.plugins import dns_common
813

@@ -25,6 +30,25 @@ class RRSet:
2530
records: List[Record]
2631

2732

33+
class ServiceFileCredentials(TypedDict):
34+
"""
35+
Represents the credentials obtained from a service file for authentication.
36+
37+
Attributes:
38+
iss (str): The issuer of the token, typically the email address of the service account.
39+
sub (str): The subject of the token, usually the same as `iss` unless acting on behalf of another user.
40+
aud (str): The audience for the token, indicating the intended recipient, usually the authentication URL.
41+
kid (str): The key ID used for identifying the private key corresponding to the public key.
42+
privateKey (str): The private key used to sign the authentication token.
43+
"""
44+
45+
iss: str
46+
sub: str
47+
aud: str
48+
kid: str
49+
privateKey: str
50+
51+
2852
class _StackitClient(object):
2953
"""
3054
A client to interact with the STACKIT DNS API.
@@ -137,12 +161,12 @@ def _get_zone_id(self, domain: str) -> str:
137161
:param domain: The domain (zone dnsName) for which the zone ID is needed.
138162
:return: The ID of the zone.
139163
"""
140-
parts = domain.split('.')
164+
parts = domain.split(".")
141165

142166
# we are searching for the best matching zone. We can do that by iterating over the parts of the domain
143167
# from left to right.
144168
for i in range(len(parts)):
145-
subdomain = '.'.join(parts[i:])
169+
subdomain = ".".join(parts[i:])
146170
res = requests.get(
147171
f"{self.base_url}/v1/projects/{self.project_id}/zones?dnsName[eq]={subdomain}&active[eq]=true",
148172
headers=self.headers,
@@ -227,12 +251,16 @@ class Authenticator(dns_common.DNSAuthenticator):
227251
228252
Attributes:
229253
credentials: A configuration object that holds STACKIT API credentials.
254+
service_account: A configuration object that holds the service account file path.
230255
"""
231256

232257
def __init__(self, *args, **kwargs):
233258
"""Initialize the Authenticator by calling the parent's init method."""
234259
super(Authenticator, self).__init__(*args, **kwargs)
235260

261+
self.credentials = None
262+
self.service_account = None
263+
236264
@classmethod
237265
def add_parser_arguments(cls, add: Callable, **kwargs):
238266
"""
@@ -244,20 +272,25 @@ def add_parser_arguments(cls, add: Callable, **kwargs):
244272
super(Authenticator, cls).add_parser_arguments(
245273
add, default_propagation_seconds=900
246274
)
275+
add("service-account", help="Service account file path")
247276
add("credentials", help="STACKIT credentials INI file.")
277+
add("project-id", help="STACKIT project ID")
248278

249279
def _setup_credentials(self):
250-
"""Set up and configure the STACKIT credentials."""
251-
self.credentials = self._configure_credentials(
252-
"credentials",
253-
"STACKIT credentials for the STACKIT DNS API",
254-
{
255-
"project_id": "Specifies the project id of the STACKIT project.",
256-
"auth_token": "Defines the authentication token for the STACKIT DNS API. Keep in mind that the "
257-
"service account to this token need to have project edit permissions as we create txt "
258-
"records in the zone",
259-
},
260-
)
280+
"""Set up and configure the STACKIT credentials based on provided input."""
281+
if self.conf("service_account") is not None:
282+
self.service_account = self.conf("service_account")
283+
else:
284+
self.credentials = self._configure_credentials(
285+
"credentials",
286+
"STACKIT credentials for the STACKIT DNS API",
287+
{
288+
"project_id": "Specifies the project id of the STACKIT project.",
289+
"auth_token": "Defines the authentication token for the STACKIT DNS API. Keep in mind that the "
290+
"service account to this token need to have project edit permissions as we create txt "
291+
"records in the zone",
292+
},
293+
)
261294

262295
def _perform(self, domain: str, validation_name: str, validation: str):
263296
"""
@@ -281,16 +314,92 @@ def _cleanup(self, domain: str, validation_name: str, validation: str):
281314

282315
def _get_stackit_client(self) -> _StackitClient:
283316
"""
284-
Instantiate and return a StackitClient object.
317+
Instantiate and return a StackitClient object based on the authentication method.
285318
286-
:return: A _StackitClient instance to interact with the STACKIT DNS API.
319+
:return: A StackitClient object.
287320
"""
288321
base_url = "https://dns.api.stackit.cloud"
289-
if self.credentials.conf("base_url") is not None:
322+
if self.credentials and self.credentials.conf("base_url") is not None:
290323
base_url = self.credentials.conf("base_url")
291324

325+
if self.service_account is not None:
326+
access_token = self._generate_jwt_token(self.conf("service_account"))
327+
if access_token:
328+
return _StackitClient(access_token, self.conf("project-id"), base_url)
292329
return _StackitClient(
293330
self.credentials.conf("auth_token"),
294331
self.credentials.conf("project_id"),
295332
base_url,
296333
)
334+
335+
def _load_service_file(self, file_path: str) -> Optional[ServiceFileCredentials]:
336+
"""
337+
Load service file credentials from a specified file path.
338+
339+
:param file_path: The path to the service account file.
340+
:return: Service file credentials if the file is found and valid, None otherwise.
341+
"""
342+
try:
343+
with open(file_path, "r") as file:
344+
return json.load(file)["credentials"]
345+
except FileNotFoundError:
346+
logging.error(f"File not found: {file_path}")
347+
return None
348+
349+
def _generate_jwt(self, credentials: ServiceFileCredentials) -> str:
350+
"""
351+
Generate a JWT token using the provided service file credentials.
352+
353+
:param credentials: The service file credentials.
354+
:return: A JWT token as a string.
355+
"""
356+
payload = {
357+
"iss": credentials["iss"],
358+
"sub": credentials["sub"],
359+
"aud": credentials["aud"],
360+
"exp": int(time.time()) + 900,
361+
"iat": int(time.time()),
362+
"jti": str(uuid.uuid4()),
363+
}
364+
headers = {"kid": credentials["kid"]}
365+
return jwt.encode(
366+
payload, credentials["privateKey"], algorithm="RS512", headers=headers # nosemgrep "privateKey" is just the key for the dictionary
367+
)
368+
369+
def _request_access_token(self, jwt_token: str) -> str:
370+
"""
371+
Request an access token using a JWT token.
372+
373+
:param jwt_token: The JWT token used to request the access token.
374+
:return: An access token if the request is successful, None otherwise.
375+
"""
376+
data = {
377+
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
378+
"assertion": jwt_token,
379+
}
380+
try:
381+
response = requests.post(
382+
"https://service-account.api.stackit.cloud/token",
383+
data=data,
384+
headers={"Content-Type": "application/x-www-form-urlencoded"},
385+
)
386+
response.raise_for_status()
387+
return response.json().get("access_token")
388+
except requests.exceptions.RequestException as e:
389+
raise errors.PluginError(f"Failed to request access token: {e}")
390+
391+
def _generate_jwt_token(self, file_path: str) -> Optional[str]:
392+
"""
393+
Generate a JWT token and request an access token using the service file at the given path.
394+
395+
:param file_path: The path to the service account file.
396+
:return: An access token if the process is successful, None otherwise.
397+
"""
398+
credentials = self._load_service_file(file_path)
399+
if credentials is None:
400+
raise errors.PluginError("Failed to load service file credentials.")
401+
jwt_token = self._generate_jwt(credentials)
402+
bearer = self._request_access_token(jwt_token)
403+
if bearer is None:
404+
raise errors.PluginError("Could not obtain access token.")
405+
return bearer

certbot_dns_stackit/test_stackit.py

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import unittest
2-
from unittest.mock import patch, Mock
2+
from unittest.mock import patch, Mock, mock_open
3+
import json
4+
import jwt
5+
from requests.models import Response
6+
from requests.exceptions import HTTPError
37

48
from certbot import errors
59
from certbot_dns_stackit.stackit import _StackitClient, RRSet, Record, Authenticator
@@ -214,8 +218,24 @@ def setUp(self):
214218
mock_name = Mock()
215219
self.authenticator = Authenticator(mock_config, mock_name)
216220

221+
@patch.object(Authenticator, "conf")
217222
@patch.object(Authenticator, "_configure_credentials")
218-
def test_setup_credentials(self, mock_configure_credentials):
223+
def test_setup_credentials_with_service_account(
224+
self, mock_configure_credentials, mock_conf
225+
):
226+
mock_conf.return_value = "service_account_value"
227+
228+
self.authenticator._setup_credentials()
229+
230+
mock_configure_credentials.assert_not_called()
231+
self.assertEqual(self.authenticator.service_account, "service_account_value")
232+
233+
@patch.object(Authenticator, "conf")
234+
@patch.object(Authenticator, "_configure_credentials")
235+
def test_setup_credentials_without_service_account(
236+
self, mock_configure_credentials, mock_conf
237+
):
238+
mock_conf.return_value = None
219239
mock_creds = Mock()
220240
mock_configure_credentials.return_value = mock_creds
221241

@@ -261,6 +281,113 @@ def test_cleanup(self, mock_get_client):
261281
"test_domain", "validation_name_test", "validation_test"
262282
)
263283

284+
@patch(
285+
"builtins.open",
286+
new_callable=mock_open,
287+
read_data='{"credentials": {"iss": "test_iss", "sub": "test_sub", "aud": "test_aud", "kid": "test_kid", "privateKey": "test_private_key"}}',
288+
)
289+
@patch("json.load", lambda x: json.loads(x.read()))
290+
def test_load_service_file(self, mock_load_service_file):
291+
expected_credentials = {
292+
"iss": "test_iss",
293+
"sub": "test_sub",
294+
"aud": "test_aud",
295+
"kid": "test_kid",
296+
"privateKey": "test_private_key",
297+
}
298+
299+
credentials = self.authenticator._load_service_file("dummy_path")
300+
self.assertEqual(credentials, expected_credentials)
301+
302+
@patch("builtins.open", side_effect=FileNotFoundError())
303+
@patch("logging.error")
304+
def test_load_service_file_not_found(self, mock_log, mock_file):
305+
result = self.authenticator._load_service_file("nonexistent_path")
306+
307+
self.assertIsNone(result)
308+
mock_log.assert_called()
309+
310+
@patch("jwt.encode")
311+
def test_generate_jwt(self, mock_jwt_encode):
312+
credentials = {
313+
"iss": "issuer",
314+
"sub": "subject",
315+
"aud": "audience",
316+
"kid": "key_id",
317+
"privateKey": "private_key",
318+
}
319+
320+
self.authenticator._generate_jwt(credentials)
321+
mock_jwt_encode.assert_called()
322+
323+
def test_generate_jwt_fail(self):
324+
credentials = {
325+
"iss": "issuer",
326+
"sub": "subject",
327+
"aud": "audience",
328+
"kid": "key_id",
329+
"privateKey": "not_a_valid_key",
330+
}
331+
332+
with self.assertRaises(jwt.exceptions.InvalidKeyError):
333+
token = self.authenticator._generate_jwt(credentials)
334+
self.assertIsNone(token)
335+
336+
@patch("requests.post")
337+
def test_request_access_token_success(self, mock_post):
338+
mock_response = mock_post.return_value
339+
mock_response.raise_for_status = lambda: None
340+
mock_response.json.return_value = {"access_token": "mocked_access_token"}
341+
342+
result = self.authenticator._request_access_token("jwt_token_example")
343+
344+
mock_post.assert_called_once_with(
345+
"https://service-account.api.stackit.cloud/token",
346+
data={
347+
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
348+
"assertion": "jwt_token_example",
349+
},
350+
headers={"Content-Type": "application/x-www-form-urlencoded"},
351+
)
352+
self.assertEqual(result, "mocked_access_token")
353+
354+
@patch("requests.post")
355+
def test_request_access_token_failure_raises_http_error(self, mock_post):
356+
mock_response = Response()
357+
mock_response.status_code = 403
358+
mock_post.return_value = mock_response
359+
mock_response.raise_for_status = lambda: (_ for _ in ()).throw(HTTPError())
360+
361+
with self.assertRaises(errors.PluginError):
362+
self.authenticator._request_access_token("jwt_token_example")
363+
mock_post.assert_called_once()
364+
365+
@patch(
366+
"builtins.open",
367+
new_callable=mock_open,
368+
read_data='{"credentials": {"iss": "test_iss", "sub": "test_sub", "aud": "test_aud", "kid": "test_kid", "privateKey": "test_private_key"}}',
369+
)
370+
@patch.object(Authenticator, "_request_access_token")
371+
@patch.object(Authenticator, "_generate_jwt")
372+
@patch.object(Authenticator, "_load_service_file")
373+
def test_generate_jwt_token_success(
374+
self,
375+
mock_load_service_file,
376+
mock_generate_jwt,
377+
mock_request_access_token,
378+
mock_open,
379+
):
380+
mock_load_service_file.return_value = {"dummy": "credentials"}
381+
mock_generate_jwt.return_value = "jwt_token_example"
382+
mock_request_access_token.return_value = "access_token_example"
383+
384+
result = self.authenticator._generate_jwt_token("path/to/service/file")
385+
386+
self.assertEqual(result, "access_token_example")
387+
mock_load_service_file.assert_called_once_with("path/to/service/file")
388+
mock_generate_jwt.assert_called_once_with({"dummy": "credentials"})
389+
mock_request_access_token.assert_called_once_with("jwt_token_example")
390+
264391

265392
if __name__ == "__main__":
266393
unittest.main()

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ install_requires =
5151
black
5252
click==8.1.7
5353
coverage
54+
PyJWT==2.9.0
5455

5556
[options.entry_points]
5657
certbot.plugins =

0 commit comments

Comments
 (0)