@@ -713,6 +713,8 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
713713 client_secret = AppConfig .get_option_with_catch_all_fallback (config , username , 'client_secret' )
714714 client_secret_encrypted = AppConfig .get_option_with_catch_all_fallback (config , username ,
715715 'client_secret_encrypted' )
716+ jwt_cert = AppConfig .get_option_with_catch_all_fallback (config , username , 'jwt_cert' )
717+ jwt_private_key = AppConfig .get_option_with_catch_all_fallback (config , username , 'jwt_private_key' )
716718
717719 # note that we don't require permission_url here because it is not needed for the client credentials grant flow,
718720 # and likewise for client_secret here because it can be optional for Office 365 configurations
@@ -773,11 +775,43 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
773775 else :
774776 Log .info ('Warning: found both `client_secret_encrypted` and `client_secret` for account' , username ,
775777 ' - the un-encrypted value will be used. Removing the un-encrypted value is recommended' )
778+
779+ jwt_token_assertion = None
780+ if jwt_cert and jwt_private_key :
781+ if not client_secret and not client_secret_encrypted :
782+ from cryptography import x509
783+ from cryptography .hazmat .primitives import serialization
784+ import jwt
785+ import uuid
786+
787+ with open (jwt_cert , "rb" ) as certfile :
788+ cert = x509 .load_pem_x509_certificate (certfile .read (), backend = default_backend ())
789+ cert_fingerprint = cert .fingerprint (hashes .SHA256 ())
790+
791+ with open (jwt_private_key , "rb" ) as keyfile :
792+ private_key = serialization .load_pem_private_key (keyfile .read (), password = None , backend = default_backend ())
793+
794+ now = datetime .datetime .now (tz = datetime .timezone .utc )
795+
796+ jwt_token_assertion = jwt .encode ({
797+ "exp" : now + datetime .timedelta (0 , 120 ),
798+ "aud" : token_url ,
799+ "iss" : client_id ,
800+ "jti" : str (uuid .uuid4 ()),
801+ "nbf" : now ,
802+ "sub" : client_id ,
803+ "iat" : now
804+ }, private_key , algorithm = "RS256" , headers = {
805+ "x5t#S256" : base64 .urlsafe_b64encode (cert_fingerprint ).decode ("ascii" )
806+ })
807+ else :
808+ Log .info ('Warning: found both `cert`/`private_key` and `client_secret[_encrypted]` for account' , username ,
809+ ' - the client secret value will be used. Removing the certificate/private key is recommended' )
776810
777811 if access_token or refresh_token : # if possible, refresh the existing token(s)
778812 if not access_token or access_token_expiry - current_time < TOKEN_EXPIRY_MARGIN :
779813 if refresh_token :
780- response = OAuth2Helper .refresh_oauth2_access_token (token_url , client_id , client_secret ,
814+ response = OAuth2Helper .refresh_oauth2_access_token (token_url , client_id , client_secret , jwt_token_assertion ,
781815 cryptographer .decrypt (refresh_token ))
782816
783817 access_token = response ['access_token' ]
@@ -821,7 +855,7 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
821855 '`permission_url`' % (APP_NAME , username ))
822856
823857 response = OAuth2Helper .get_oauth2_authorisation_tokens (token_url , redirect_uri , client_id ,
824- client_secret , auth_result , oauth2_scope ,
858+ client_secret , jwt_token_assertion , auth_result , oauth2_scope ,
825859 oauth2_flow , username , password )
826860
827861 if AppConfig .get_global ('encrypt_client_secret_on_first_use' , fallback = False ):
@@ -1044,7 +1078,7 @@ def get_oauth2_authorisation_code(permission_url, redirect_uri, redirect_listen_
10441078 time .sleep (1 )
10451079
10461080 @staticmethod
1047- def get_oauth2_authorisation_tokens (token_url , redirect_uri , client_id , client_secret , authorisation_code ,
1081+ def get_oauth2_authorisation_tokens (token_url , redirect_uri , client_id , client_secret , jwt_assertion , authorisation_code ,
10481082 oauth2_scope , oauth2_flow , username , password ):
10491083 """Requests OAuth 2.0 access and refresh tokens from token_url using the given client_id, client_secret,
10501084 authorisation_code and redirect_uri, returning a dict with 'access_token', 'expires_in', and 'refresh_token'
@@ -1057,6 +1091,11 @@ def get_oauth2_authorisation_tokens(token_url, redirect_uri, client_id, client_s
10571091 'redirect_uri' : redirect_uri , 'grant_type' : oauth2_flow }
10581092 if not client_secret :
10591093 del params ['client_secret' ] # client secret can be optional for O365, but we don't want a None entry
1094+
1095+ if jwt_assertion :
1096+ params ['client_assertion_type' ] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
1097+ params ['client_assertion' ] = jwt_assertion
1098+
10601099 if oauth2_flow != 'authorization_code' :
10611100 del params ['code' ] # CCG/ROPCG flows have no code, but we need the scope and (for ROPCG) username+password
10621101 params ['scope' ] = oauth2_scope
@@ -1103,13 +1142,18 @@ def get_service_account_authorisation_token(key_type, key_path_or_contents, oaut
11031142 return {'access_token' : credentials .token , 'expires_in' : int (credentials .expiry .timestamp () - time .time ())}
11041143
11051144 @staticmethod
1106- def refresh_oauth2_access_token (token_url , client_id , client_secret , refresh_token ):
1145+ def refresh_oauth2_access_token (token_url , client_id , client_secret , jwt_assertion , refresh_token ):
11071146 """Obtains a new access token from token_url using the given client_id, client_secret and refresh token,
11081147 returning a dict with 'access_token', 'expires_in', and 'refresh_token' on success; exception on failure"""
11091148 params = {'client_id' : client_id , 'client_secret' : client_secret , 'refresh_token' : refresh_token ,
11101149 'grant_type' : 'refresh_token' }
11111150 if not client_secret :
11121151 del params ['client_secret' ] # client secret can be optional for O365, but we don't want a None entry
1152+
1153+ if jwt_assertion :
1154+ params ['client_assertion_type' ] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
1155+ params ['client_assertion' ] = jwt_assertion
1156+
11131157 try :
11141158 response = urllib .request .urlopen (
11151159 urllib .request .Request (token_url , data = urllib .parse .urlencode (params ).encode ('utf-8' ),
0 commit comments