Skip to content

Commit 859a6bb

Browse files
Florian SandelFlorian Sandel
authored andcommitted
allow service account authentication
1 parent 46ea888 commit 859a6bb

File tree

4 files changed

+234
-18
lines changed

4 files changed

+234
-18
lines changed

certbot_dns_stackit/stackit.py

Lines changed: 119 additions & 16 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.
@@ -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,86 @@ 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(payload, credentials['privateKey'], algorithm='RS512', headers=headers)
366+
367+
def _request_access_token(self, jwt_token: str) -> str:
368+
"""
369+
Request an access token using a JWT token.
370+
371+
:param jwt_token: The JWT token used to request the access token.
372+
:return: An access token if the request is successful, None otherwise.
373+
"""
374+
data = {
375+
'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
376+
'assertion': jwt_token
377+
}
378+
try:
379+
response = requests.post('https://service-account.api.stackit.cloud/token', data=data, headers={'Content-Type': 'application/x-www-form-urlencoded'})
380+
response.raise_for_status()
381+
return response.json().get('access_token')
382+
except requests.exceptions.RequestException as e:
383+
raise errors.PluginError(f"Failed to request access token: {e}")
384+
385+
def _generate_jwt_token(self, file_path: str) -> Optional[str]:
386+
"""
387+
Generate a JWT token and request an access token using the service file at the given path.
388+
389+
:param file_path: The path to the service account file.
390+
:return: An access token if the process is successful, None otherwise.
391+
"""
392+
credentials = self._load_service_file(file_path)
393+
if credentials is None:
394+
raise errors.PluginError("Failed to load service file credentials.")
395+
jwt_token = self._generate_jwt(credentials)
396+
bearer = self._request_access_token(jwt_token)
397+
if bearer is None:
398+
raise errors.PluginError("Could not obtain access token.")
399+
return bearer

certbot_dns_stackit/test_stackit.py

Lines changed: 113 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,13 +218,30 @@ 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(self, mock_configure_credentials, mock_conf):
224+
# Simulate `service_account` being set
225+
mock_conf.return_value = 'service_account_value'
226+
227+
self.authenticator._setup_credentials()
228+
229+
# Assert _configure_credentials was not called
230+
mock_configure_credentials.assert_not_called()
231+
# Assert service_account is set correctly
232+
self.assertEqual(self.authenticator.service_account, 'service_account_value')
233+
234+
@patch.object(Authenticator, "conf")
235+
@patch.object(Authenticator, "_configure_credentials")
236+
def test_setup_credentials_without_service_account(self, mock_configure_credentials, mock_conf):
237+
# Simulate `service_account` not being set
238+
mock_conf.return_value = None
219239
mock_creds = Mock()
220240
mock_configure_credentials.return_value = mock_creds
221241

222242
self.authenticator._setup_credentials()
223243

244+
# Assert _configure_credentials was called with the correct arguments
224245
mock_configure_credentials.assert_called_once_with(
225246
"credentials",
226247
"STACKIT credentials for the STACKIT DNS API",
@@ -231,6 +252,7 @@ def test_setup_credentials(self, mock_configure_credentials):
231252
"records in the zone",
232253
},
233254
)
255+
# Assert credentials are set correctly
234256
self.assertEqual(self.authenticator.credentials, mock_creds)
235257

236258
@patch.object(Authenticator, "_get_stackit_client")
@@ -261,6 +283,95 @@ def test_cleanup(self, mock_get_client):
261283
"test_domain", "validation_name_test", "validation_test"
262284
)
263285

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

265376
if __name__ == "__main__":
266377
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
5455

5556
[options.entry_points]
5657
certbot.plugins =

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"black",
2121
"click==8.1.7",
2222
"coverage",
23+
"PyJWT"
2324
]
2425

2526
# read the contents of your README file

0 commit comments

Comments
 (0)