11import logging
22from 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
59import requests
10+
611from certbot import errors
712from 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+
2852class _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
0 commit comments