|
4 | 4 | 2.0 authentication. Designed for apps/clients that don't support OAuth 2.0 but need to connect to modern servers."""
|
5 | 5 |
|
6 | 6 | __author__ = 'Simon Robinson'
|
7 |
| -__copyright__ = 'Copyright (c) 2024 Simon Robinson' |
| 7 | +__copyright__ = 'Copyright (c) 2025 Simon Robinson' |
8 | 8 | __license__ = 'Apache 2.0'
|
9 |
| -__package_version__ = '2025.6.24' # for pyproject.toml usage only - needs to be ast.literal_eval() compatible |
| 9 | +__package_version__ = '2025.6.25' # for pyproject.toml usage only - needs to be ast.literal_eval() compatible |
10 | 10 | __version__ = '-'.join('%02d' % int(part) for part in __package_version__.split('.')) # ISO 8601 (YYYY-MM-DD)
|
11 | 11 |
|
12 | 12 | import abc
|
@@ -752,6 +752,7 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
|
752 | 752 | client_secret = AppConfig.get_option_with_catch_all_fallback(config, username, 'client_secret')
|
753 | 753 | client_secret_encrypted = AppConfig.get_option_with_catch_all_fallback(config, username,
|
754 | 754 | 'client_secret_encrypted')
|
| 755 | + use_pkce = AppConfig.get_option_with_catch_all_fallback(config, username, 'use_pkce', fallback=False) |
755 | 756 | jwt_certificate_path = AppConfig.get_option_with_catch_all_fallback(config, username, 'jwt_certificate_path')
|
756 | 757 | jwt_key_path = AppConfig.get_option_with_catch_all_fallback(config, username, 'jwt_key_path')
|
757 | 758 |
|
@@ -781,13 +782,6 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
|
781 | 782 | access_token_expiry = config.getint(username, 'access_token_expiry', fallback=current_time)
|
782 | 783 | refresh_token = config.get(username, 'refresh_token', fallback=None)
|
783 | 784 |
|
784 |
| - code_verifier = None |
785 |
| - code_challenge = None |
786 |
| - if client_secret == 'pkce': # special value to enable PKCE code challenge |
787 |
| - code_verifier = OAuth2Helper.generate_code_verifier() |
788 |
| - code_challenge = OAuth2Helper.generate_code_challenge(code_verifier) |
789 |
| - client_secret = None |
790 |
| - |
791 | 785 | # try reloading remotely cached tokens if possible
|
792 | 786 | if not access_token and CACHE_STORE != CONFIG_FILE_PATH and reload_remote_accounts:
|
793 | 787 | AppConfig.unload()
|
@@ -896,10 +890,17 @@ def get_oauth2_credentials(username, password, reload_remote_accounts=True):
|
896 | 890 |
|
897 | 891 | if not access_token:
|
898 | 892 | auth_result = None
|
| 893 | + code_verifier = None |
| 894 | + code_challenge = None |
| 895 | + |
899 | 896 | if permission_url: # O365 CCG/ROPCG and Google service accounts skip authorisation; no permission_url
|
900 | 897 | if oauth2_flow != 'device': # the device flow is a poll-based method with asynchronous interaction
|
901 | 898 | oauth2_flow = 'authorization_code'
|
902 | 899 |
|
| 900 | + if oauth2_flow == 'authorization_code' and use_pkce: |
| 901 | + code_verifier = OAuth2Helper.generate_code_verifier() |
| 902 | + code_challenge = OAuth2Helper.generate_code_challenge(code_verifier) |
| 903 | + |
903 | 904 | permission_url = OAuth2Helper.construct_oauth2_permission_url(permission_url, redirect_uri,
|
904 | 905 | client_id, oauth2_scope, username,
|
905 | 906 | state, code_challenge)
|
@@ -1099,7 +1100,7 @@ def construct_oauth2_permission_url(permission_url, redirect_uri, client_id, sco
|
1099 | 1100 | 'access_type': 'offline', 'login_hint': username}
|
1100 | 1101 | if state:
|
1101 | 1102 | params['state'] = state
|
1102 |
| - if code_challenge: |
| 1103 | + if code_challenge: # PKCE; see RFC 7636 |
1103 | 1104 | params['code_challenge'] = code_challenge
|
1104 | 1105 | params['code_challenge_method'] = 'S256'
|
1105 | 1106 | if not redirect_uri: # unlike other interactive flows, DAG doesn't involve a (known) final redirect
|
@@ -1213,13 +1214,13 @@ def get_oauth2_authorisation_tokens(token_url, redirect_uri, client_id, client_s
|
1213 | 1214 | params = {'client_id': client_id, 'client_secret': client_secret, 'code': authorisation_result,
|
1214 | 1215 | 'redirect_uri': redirect_uri, 'grant_type': oauth2_flow}
|
1215 | 1216 | expires_in = AUTHENTICATION_TIMEOUT
|
| 1217 | + |
| 1218 | + if code_verifier: |
| 1219 | + params['code_verifier'] = code_verifier # PKCE; see RFC 7636 |
| 1220 | + |
1216 | 1221 | if not client_secret:
|
1217 | 1222 | del params['client_secret'] # client secret can be optional for O365, but we don't want a None entry
|
1218 | 1223 |
|
1219 |
| - # the code verifier is only used when we don't have a secret (or, rather, the config file secret is `pkce`) |
1220 |
| - if code_verifier: |
1221 |
| - params['code_verifier'] = code_verifier |
1222 |
| - |
1223 | 1224 | # certificate credentials are only used when no client secret is provided
|
1224 | 1225 | if jwt_client_assertion:
|
1225 | 1226 | params['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
0 commit comments