|
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