Skip to content

Commit 2213974

Browse files
committed
Implements certificate credential authentication using JWT assertions
1 parent 6ef9d4f commit 2213974

File tree

3 files changed

+50
-5
lines changed

3 files changed

+50
-5
lines changed

emailproxy.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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'),

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[build-system]
2-
requires = ["setuptools>=61.0", "pyasyncore; python_version >= '3.12'", "cryptography"] # core requirements are needed for version detection, which requires importing the script
2+
requires = ["setuptools>=61.0", "pyasyncore; python_version >= '3.12'", "cryptography", "pyjwt"] # core requirements are needed for version detection, which requires importing the script
33
build-backend = "setuptools.build_meta"
44

55
[project]

requirements-core.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
# 2.2 or later required for MultiFernet support
66
cryptography>=2.2
7+
pyjwt[crypto]>=2.5
78

89
# provide the previously standard library module `asyncore`, removed in Python 3.12 (https://peps.python.org/pep-0594/)
910
pyasyncore; python_version >= '3.12'

0 commit comments

Comments
 (0)