Skip to content

Commit c96efdb

Browse files
authored
Add support for certificate credential authentication using JWT assertions (#247)
1 parent fd95c84 commit c96efdb

File tree

5 files changed

+77
-12
lines changed

5 files changed

+77
-12
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,13 @@ In all cases, when registering, make sure your client is set up to use an OAuth
6969
It is also highly recommended to use a scope that will grant "offline" access (i.e., a way to [refresh the OAuth 2.0 authentication token](https://oauth.net/2/refresh-tokens/) without user intervention).
7070
The [sample configuration file](https://github.com/simonrob/email-oauth2-proxy/blob/main/emailproxy.config) provides example scope values for several common providers.
7171

72-
- Office 365: register a new [Microsoft identity application](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app)
72+
- Office 365: register a new [Microsoft identity application](https://learn.microsoft.com/entra/identity-platform/quickstart-register-app)
7373
- Gmail / Google Workspace: register a [Google API desktop app client](https://developers.google.com/identity/protocols/oauth2/native-app)
7474
- AOL and Yahoo Mail (and subproviders such as AT&T) are not currently allowing new client registrations with the OAuth email scope – the only option here is to reuse the credentials from an existing client that does have this permission.
7575

7676
The proxy supports [Google Cloud service accounts](https://cloud.google.com/iam/docs/service-account-overview) for access to Google Workspace Gmail.
77-
It also supports the [client credentials grant (CCG)](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow) and [resource owner password credentials grant (ROPCG)](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth-ropc) OAuth 2.0 flows.
78-
Please note that currently only Office 365 is known to support the CCG and ROPCG methods.
77+
It also supports the [client credentials grant (CCG)](https://learn.microsoft.com/entra/identity-platform/v2-oauth2-client-creds-grant-flow) and [resource owner password credentials grant (ROPCG)](https://learn.microsoft.com/entra/identity-platform/v2-oauth-ropc) OAuth 2.0 flows, and [certificate credentials (JWT)](https://learn.microsoft.com/entra/identity-platform/certificate-credentials).
78+
Please note that currently only Office 365 is known to support the CCG, ROPCG and certificate credentials methods.
7979
See the [sample configuration file](https://github.com/simonrob/email-oauth2-proxy/blob/main/emailproxy.config) for further details.
8080

8181

emailproxy.config

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,10 @@ documentation = Accounts are specified using your email address as the section h
123123
- It is possible to create Office 365 clients that do not require a secret to be sent. If this is the case for your
124124
setup, delete the `client_secret` line from your account's configuration entry (do not leave the default value).
125125

126+
- To use O365 certificate credentials instead of a client secret, delete the `client_secret` line and instead
127+
provide a `jwt_certificate_path` (e.g., /path/to/certificate.pem) and `jwt_key_path` (e.g., /path/to/key.pem).
128+
Further documentation and examples can be found at https://github.com/simonrob/email-oauth2-proxy/pull/247.
129+
126130
- The proxy supports the client credentials grant (CCG) and resource owner password credentials grant (ROPCG) OAuth
127131
2.0 flows (both currently only known to be available for Office 365). To use either of these flows, add an account
128132
entry as normal, but do not add a `permission_url` value (it does not apply, and its absence signals to the proxy to

emailproxy.py

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
__author__ = 'Simon Robinson'
77
__copyright__ = 'Copyright (c) 2024 Simon Robinson'
88
__license__ = 'Apache 2.0'
9-
__version__ = '2024-05-23' # ISO 8601 (YYYY-MM-DD)
9+
__version__ = '2024-05-25' # ISO 8601 (YYYY-MM-DD)
1010
__package_version__ = '.'.join([str(int(i)) for i in __version__.split('-')]) # for pyproject.toml usage only
1111

1212
import abc
@@ -176,6 +176,7 @@ class NSObject:
176176
AUTHENTICATION_TIMEOUT = 600
177177

178178
TOKEN_EXPIRY_MARGIN = 600 # seconds before its expiry to refresh the OAuth 2.0 token
179+
JWT_LIFETIME = 300 # seconds to add to the current time and use for the `exp` value in JWT certificate credentials
179180

180181
LOG_FILE_MAX_SIZE = 32 * 1024 * 1024 # when using a log file, its maximum size in bytes before rollover (0 = no limit)
181182
LOG_FILE_MAX_BACKUPS = 10 # the number of log files to keep when LOG_FILE_MAX_SIZE is exceeded (0 = disable rollover)
@@ -713,6 +714,8 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
713714
client_secret = AppConfig.get_option_with_catch_all_fallback(config, username, 'client_secret')
714715
client_secret_encrypted = AppConfig.get_option_with_catch_all_fallback(config, username,
715716
'client_secret_encrypted')
717+
jwt_certificate_path = AppConfig.get_option_with_catch_all_fallback(config, username, 'jwt_certificate_path')
718+
jwt_key_path = AppConfig.get_option_with_catch_all_fallback(config, username, 'jwt_key_path')
716719

717720
# note that we don't require permission_url here because it is not needed for the client credentials grant flow,
718721
# and likewise for client_secret here because it can be optional for Office 365 configurations
@@ -772,13 +775,55 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
772775
APP_NAME, username)
773776
else:
774777
Log.info('Warning: found both `client_secret_encrypted` and `client_secret` for account', username,
775-
' - the un-encrypted value will be used. Removing the un-encrypted value is recommended')
778+
'- the un-encrypted value will be used. Removing the un-encrypted value is recommended')
779+
780+
# O365 certificate credentials - see: learn.microsoft.com/entra/identity-platform/certificate-credentials
781+
jwt_client_assertion = None
782+
if jwt_certificate_path and jwt_key_path:
783+
if client_secret or client_secret_encrypted:
784+
client_secret_type = '`client_secret%s`' % ('_encrypted' if client_secret_encrypted else '')
785+
Log.info('Warning: found both certificate credentials and', client_secret_type, 'for account',
786+
username, '- the', client_secret_type, 'value will be used. To use certificate',
787+
'credentials, remove the client secret value')
788+
789+
else:
790+
try:
791+
# noinspection PyUnresolvedReferences
792+
import jwt
793+
except ImportError:
794+
return False, ('Unable to load jwt, which is a requirement when using certificate credentials '
795+
'(`jwt_` options). Please run `python -m pip install -r requirements-core.txt`')
796+
import uuid
797+
from cryptography import x509
798+
from cryptography.hazmat.primitives import serialization
799+
800+
try:
801+
jwt_now = datetime.datetime.now(datetime.timezone.utc)
802+
jwt_certificate_fingerprint = x509.load_pem_x509_certificate(
803+
pathlib.Path(jwt_certificate_path).read_bytes()).fingerprint(hashes.SHA256())
804+
jwt_client_assertion = jwt.encode(
805+
{
806+
'aud': token_url,
807+
'exp': jwt_now + datetime.timedelta(seconds=JWT_LIFETIME),
808+
'iss': client_id,
809+
'jti': str(uuid.uuid4()),
810+
'nbf': jwt_now,
811+
'sub': client_id
812+
},
813+
serialization.load_pem_private_key(pathlib.Path(jwt_key_path).read_bytes(), password=None),
814+
algorithm='RS256',
815+
headers={
816+
'x5t#S256': base64.urlsafe_b64encode(jwt_certificate_fingerprint).decode('utf-8')
817+
})
818+
except FileNotFoundError:
819+
return (False, 'Unable to create credentials assertion for account %s - please check that the '
820+
'`jwt_certificate_path` and `jwt_key_path` values are correct' % username)
776821

777822
if access_token or refresh_token: # if possible, refresh the existing token(s)
778823
if not access_token or access_token_expiry - current_time < TOKEN_EXPIRY_MARGIN:
779824
if refresh_token:
780825
response = OAuth2Helper.refresh_oauth2_access_token(token_url, client_id, client_secret,
781-
username,
826+
jwt_client_assertion, username,
782827
cryptographer.decrypt(refresh_token))
783828

784829
access_token = response['access_token']
@@ -822,8 +867,9 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
822867
'`permission_url`' % (APP_NAME, username))
823868

824869
response = OAuth2Helper.get_oauth2_authorisation_tokens(token_url, redirect_uri, client_id,
825-
client_secret, auth_result, oauth2_scope,
826-
oauth2_flow, username, password)
870+
client_secret, jwt_client_assertion,
871+
auth_result, oauth2_scope, oauth2_flow,
872+
username, password)
827873

828874
if AppConfig.get_global('encrypt_client_secret_on_first_use', fallback=False):
829875
if client_secret:
@@ -1051,8 +1097,8 @@ def get_oauth2_authorisation_code(permission_url, redirect_uri, redirect_listen_
10511097
time.sleep(1)
10521098

10531099
@staticmethod
1054-
def get_oauth2_authorisation_tokens(token_url, redirect_uri, client_id, client_secret, authorisation_code,
1055-
oauth2_scope, oauth2_flow, username, password):
1100+
def get_oauth2_authorisation_tokens(token_url, redirect_uri, client_id, client_secret, jwt_client_assertion,
1101+
authorisation_code, oauth2_scope, oauth2_flow, username, password):
10561102
"""Requests OAuth 2.0 access and refresh tokens from token_url using the given client_id, client_secret,
10571103
authorisation_code and redirect_uri, returning a dict with 'access_token', 'expires_in', and 'refresh_token'
10581104
on success, or throwing an exception on failure (e.g., HTTP 400)"""
@@ -1064,6 +1110,12 @@ def get_oauth2_authorisation_tokens(token_url, redirect_uri, client_id, client_s
10641110
'redirect_uri': redirect_uri, 'grant_type': oauth2_flow}
10651111
if not client_secret:
10661112
del params['client_secret'] # client secret can be optional for O365, but we don't want a None entry
1113+
1114+
# certificate credentials are only used when no client secret is provided
1115+
if jwt_client_assertion:
1116+
params['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
1117+
params['client_assertion'] = jwt_client_assertion
1118+
10671119
if oauth2_flow != 'authorization_code':
10681120
del params['code'] # CCG/ROPCG flows have no code, but we need the scope and (for ROPCG) username+password
10691121
params['scope'] = oauth2_scope
@@ -1115,13 +1167,19 @@ def get_service_account_authorisation_token(key_type, key_path_or_contents, oaut
11151167
return {'access_token': credentials.token, 'expires_in': int(credentials.expiry.timestamp() - time.time())}
11161168

11171169
@staticmethod
1118-
def refresh_oauth2_access_token(token_url, client_id, client_secret, username, refresh_token):
1170+
def refresh_oauth2_access_token(token_url, client_id, client_secret, jwt_client_assertion, username, refresh_token):
11191171
"""Obtains a new access token from token_url using the given client_id, client_secret and refresh token,
11201172
returning a dict with 'access_token', 'expires_in', and 'refresh_token' on success; exception on failure"""
11211173
params = {'client_id': client_id, 'client_secret': client_secret, 'refresh_token': refresh_token,
11221174
'grant_type': 'refresh_token'}
11231175
if not client_secret:
11241176
del params['client_secret'] # client secret can be optional for O365, but we don't want a None entry
1177+
1178+
# certificate credentials are only used when no client secret is provided
1179+
if jwt_client_assertion:
1180+
params['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
1181+
params['client_assertion'] = jwt_client_assertion
1182+
11251183
try:
11261184
response = urllib.request.urlopen(
11271185
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"] # core requirements are needed for version detection when building for PyPI, which requires importing (but not running) the script on `ubuntu-latest`
33
build-backend = "setuptools.build_meta"
44

55
[project]

requirements-core.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ pyoslog>=0.3.0; sys_platform == 'darwin'
1313

1414
# required only if using the --external-auth option in --no-gui mode
1515
prompt_toolkit
16+
17+
# required only if using JWT certificate credentials (O365)
18+
pyjwt>=2.4

0 commit comments

Comments
 (0)