Skip to content

Commit 622f098

Browse files
authored
Support resource field for non-standard ROPCG; normalise expires_in (#351)
* Add an `oauth2_resource` field to support a non-standard variant of the ROPCG flow (currently only known to be used in 21Vianet's version of O365) * Always cast `expires_in` to integer to work around cases where it is returned as a string
1 parent 8570f52 commit 622f098

2 files changed

Lines changed: 34 additions & 12 deletions

File tree

emailproxy.config

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,10 @@ documentation = Accounts are specified using your email address as the section h
153153
2.0 flows (both currently only known to be available for Office 365). To use either of these flows, add an account
154154
entry as normal, but do not add a `permission_url` value (it does not apply, and its absence signals to the proxy to
155155
use the appropriate token retrieval mechanism). For CCG, set `oauth2_scope = https://outlook.office365.com/.default`
156-
and `oauth2_flow = client_credentials`. For ROPCG, set `oauth2_flow = password` (and use a standard scope value). An
157-
example is given for both methods towards the end of the sample account entries below.
156+
and `oauth2_flow = client_credentials`. For ROPCG, set `oauth2_flow = password` (and use a standard scope value).
157+
If you are using Microsoft services delivered by a regional provider (such as 21Vianet) that uses a non-standard
158+
ROPCG variant, remove the `oauth2_scope` value and instead use `oauth2_resource`. Examples are given for each of
159+
these cases towards the end of the sample account entries below.
158160

159161
- WARNING: Please note that by default the CCG flow has essentially no local access control when creating new
160162
accounts (no user consent is involved, so the proxy cannot validate login attempts unless an account entry
@@ -271,14 +273,22 @@ oauth2_flow = client_credentials
271273
client_id = *** your client id here ***
272274
client_secret = *** your client secret here (remove this entire line if a secret is not required) ***
273275

274-
[ropcg.flow.configured.address@your-tenant.com]
276+
[ropcg.flow.scope.configured.address@your-tenant.com]
275277
documentation = *** note: this is an advanced O365 account example; in most cases you want the version above instead ***
276278
token_url = https://login.microsoftonline.com/*** your tenant id here ***/oauth2/v2.0/token
277279
oauth2_scope = https://outlook.office365.com/IMAP.AccessAsUser.All https://outlook.office365.com/POP.AccessAsUser.All https://outlook.office365.com/SMTP.Send offline_access
278280
oauth2_flow = password
279281
client_id = *** your client id here ***
280282
client_secret = *** your client secret here (remove this entire line if a secret is not required) ***
281283

284+
[ropcg.flow.resource.configured.address@your-tenant.com]
285+
documentation = *** note: this is an advanced O365 account example specific to providers that use a non-standard version of the ROPCG flow (such as 21Vianet); in most cases you want the version above instead ***
286+
token_url = https://login.partner.microsoftonline.cn/*** your tenant id here ***/oauth2/token
287+
oauth2_resource = https://partner.outlook.cn
288+
oauth2_flow = password
289+
client_id = *** your client id here ***
290+
client_secret = *** your client secret here (remove this entire line if a secret is not required) ***
291+
282292
[service.account.accessible.address@your-domain.com]
283293
documentation = *** note: this is an advanced Google account example; in most cases you want the version above instead ***
284294
token_url = https://oauth2.googleapis.com/token

emailproxy.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,7 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
739739
permission_url = AppConfig.get_option_with_catch_all_fallback(config, username, 'permission_url')
740740
token_url = AppConfig.get_option_with_catch_all_fallback(config, username, 'token_url')
741741
oauth2_scope = AppConfig.get_option_with_catch_all_fallback(config, username, 'oauth2_scope')
742+
oauth2_resource = AppConfig.get_option_with_catch_all_fallback(config, username, 'oauth2_resource')
742743
oauth2_flow = AppConfig.get_option_with_catch_all_fallback(config, username, 'oauth2_flow')
743744
redirect_uri = AppConfig.get_option_with_catch_all_fallback(config, username, 'redirect_uri')
744745
redirect_listen_address = AppConfig.get_option_with_catch_all_fallback(config, username,
@@ -751,8 +752,9 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
751752
jwt_key_path = AppConfig.get_option_with_catch_all_fallback(config, username, 'jwt_key_path')
752753

753754
# because the proxy supports a wide range of OAuth 2.0 flows, in addition to the token_url we only mandate the
754-
# core parameters that are required by all methods: oauth2_scope and client_id
755-
if not (token_url and oauth2_scope and client_id):
755+
# core parameters that are required by all methods: client_id and oauth2_scope (or, for non-standard ROPCG,
756+
# currently only known to be used by 21Vianet, oauth2_resource instead of oauth2_scope - see GitHub #351)
757+
if not (token_url and client_id and ((oauth2_flow == 'password' and oauth2_resource) or oauth2_scope)):
756758
Log.error('Proxy config file entry incomplete for account', username, '- aborting login')
757759
return (False, '%s: Incomplete config file entry found for account %s - please make sure all required '
758760
'fields are added (at least token_url, oauth2_scope and client_id)' % (APP_NAME, username))
@@ -863,7 +865,7 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
863865

864866
access_token = response['access_token']
865867
config.set(username, 'access_token', cryptographer.encrypt(access_token))
866-
config.set(username, 'access_token_expiry', str(current_time + response['expires_in']))
868+
config.set(username, 'access_token_expiry', str(current_time + int(response['expires_in'])))
867869
if 'refresh_token' in response:
868870
config.set(username, 'refresh_token', cryptographer.encrypt(response['refresh_token']))
869871
AppConfig.save()
@@ -907,8 +909,8 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
907909
# note: get_oauth2_authorisation_tokens may be a blocking call (DAG flow retries until user code entry)
908910
response = OAuth2Helper.get_oauth2_authorisation_tokens(token_url, redirect_uri, client_id,
909911
client_secret, jwt_client_assertion,
910-
auth_result, oauth2_scope, oauth2_flow,
911-
username, password)
912+
auth_result, oauth2_scope, oauth2_resource,
913+
oauth2_flow, username, password)
912914

913915
if AppConfig.get_global('encrypt_client_secret_on_first_use', fallback=False):
914916
if client_secret:
@@ -924,7 +926,7 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
924926
config.set(username, 'token_salt', cryptographer.salt)
925927
config.set(username, 'token_iterations', str(cryptographer.iterations))
926928
config.set(username, 'access_token', cryptographer.encrypt(access_token))
927-
config.set(username, 'access_token_expiry', str(current_time + response['expires_in']))
929+
config.set(username, 'access_token_expiry', str(current_time + int(response['expires_in'])))
928930

929931
if 'refresh_token' in response:
930932
config.set(username, 'refresh_token', cryptographer.encrypt(response['refresh_token']))
@@ -1169,11 +1171,14 @@ def get_oauth2_authorisation_code(permission_url, redirect_uri, redirect_listen_
11691171
@staticmethod
11701172
# pylint: disable-next=too-many-positional-arguments
11711173
def get_oauth2_authorisation_tokens(token_url, redirect_uri, client_id, client_secret, jwt_client_assertion,
1172-
authorisation_result, oauth2_scope, oauth2_flow, username, password):
1174+
authorisation_result, oauth2_scope, oauth2_resource, oauth2_flow, username,
1175+
password):
11731176
"""Requests OAuth 2.0 access and refresh tokens from token_url using the given client_id, client_secret,
11741177
authorisation_code and redirect_uri, returning a dict with 'access_token', 'expires_in', and 'refresh_token'
11751178
on success, or throwing an exception on failure (e.g., HTTP 400)"""
1176-
if oauth2_flow == 'service_account': # service accounts are slightly different, and are handled separately
1179+
1180+
# service accounts are slightly different, and are handled separately
1181+
if oauth2_flow == 'service_account':
11771182
return OAuth2Helper.get_service_account_authorisation_token(client_id, client_secret, oauth2_scope,
11781183
username)
11791184

@@ -1199,11 +1204,18 @@ def get_oauth2_authorisation_tokens(token_url, redirect_uri, client_id, client_s
11991204
if oauth2_flow == 'device':
12001205
params['grant_type'] = 'urn:ietf:params:oauth:grant-type:device_code'
12011206
params['device_code'] = authorisation_result['device_code']
1202-
expires_in = authorisation_result['expires_in']
1207+
expires_in = int(authorisation_result['expires_in'])
12031208
authorisation_result['interval'] = authorisation_result.get('interval', 5) # see RFC 8628, Section 3.2
12041209
elif oauth2_flow == 'password':
12051210
params['username'] = username
12061211
params['password'] = password
1212+
# there is at least one non-standard implementation of ROPCG that uses a resource parameter instead of a
1213+
# scope (e.g., 21Vianet's version of O365; see GitHub #351) - note that it is not known whether this is
1214+
# always instead of the scope value or potentially in addition to it, so we only remove if not specified
1215+
if oauth2_resource:
1216+
params['resource'] = oauth2_resource
1217+
if not oauth2_scope:
1218+
del params['scope']
12071219
if not redirect_uri:
12081220
del params['redirect_uri'] # redirect_uri is not typically required in non-code flows; remove if empty
12091221

0 commit comments

Comments
 (0)