├── src └── oauthcli │ ├── __init__.py │ ├── clean.py │ ├── providers.py │ └── flow.py ├── .gitignore ├── CHANGELOG.md ├── pyproject.toml ├── README.md └── LICENSE /src/oauthcli/__init__.py: -------------------------------------------------------------------------------- 1 | from .flow import AuthFlow 2 | from .providers import * 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .venv/ 3 | dist/ 4 | *.egg-info/ 5 | osm_test.py 6 | *.swp 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 0.3.1 (2023-10-15) 4 | 5 | * Print the browser hint to stderr instead of stdout. 6 | 7 | ## 0.3.0 (2023-10-14) 8 | 9 | * Endpoints for OSM API were all wrong. 10 | * Removed all defaults for the scope. 11 | * Added `token_test` parameters to query an API for authentication test. 12 | 13 | ## 0.2.0 (2023-10-14) 14 | 15 | * `force` parameter to `auth_*` functions. 16 | * Command-line script `oauthclean` for removing stored tokens. 17 | 18 | ## 0.1.4 (2023-10-14) 19 | 20 | * Different apps use different keys for the same providers. 21 | 22 | (Oh the joys of testing in production!) 23 | 24 | ## 0.1.3 (2023-10-14) 25 | 26 | * Moved sources to the `src` directory. 27 | 28 | ## 0.1.2 (2023-10-14) 29 | 30 | * Added `AuthFlow.request()` method. 31 | 32 | ## 0.1.1 (2023-10-14) 33 | 34 | * Forgot to mention dependencies, and fixed the minimum Python version. 35 | 36 | ## 0.1.0 (2023-10-14) 37 | 38 | * Initial release. 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "cli-oauth2" 7 | version = "0.3.1" 8 | authors = [ 9 | { name="Ilya Zverev", email="ilya@zverev.info" }, 10 | ] 11 | description = "Helper library for OAuth2 in command-line tools" 12 | keywords = ["oauth", "oauth2", "cli", "command line"] 13 | readme = "README.md" 14 | license = {file = "LICENSE"} 15 | requires-python = ">=3.9" 16 | dependencies = [ 17 | "requests-oauthlib", 18 | "platformdirs >= 3.2.0", 19 | ] 20 | classifiers = [ 21 | "Programming Language :: Python :: 3", 22 | "Development Status :: 4 - Beta", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: Apache Software License", 25 | "Operating System :: OS Independent", 26 | "Topic :: Software Development :: Libraries :: Python Modules", 27 | ] 28 | 29 | [project.urls] 30 | "Homepage" = "https://github.com/Zverik/cli-oauth2" 31 | "Bug Tracker" = "https://github.com/Zverik/cli-oauth2/issues" 32 | 33 | [project.scripts] 34 | oauthclean = "oauthcli.clean:main" 35 | -------------------------------------------------------------------------------- /src/oauthcli/clean.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import platformdirs 4 | from collections import Counter 5 | 6 | 7 | def main(): 8 | config_dir = platformdirs.user_config_dir('PythonCliAuth') 9 | filename = os.path.join(config_dir, 'tokens.json') 10 | if os.path.exists(filename): 11 | with open(filename, 'r') as f: 12 | tokens = json.load(f) 13 | providers = Counter(s.split('/')[0] for s in tokens.keys()) 14 | print('Which provider\'s tokens to delete:\n') 15 | names = sorted(providers.keys()) 16 | for i, p in enumerate(names): 17 | print(f'{i+1}. {p} ({providers[p]})') 18 | print('0. All of them') 19 | print('Q. Cancel') 20 | while True: 21 | inp = input('> ').strip().lower() 22 | if inp: 23 | break 24 | try: 25 | n = int(inp) 26 | if n == 0: 27 | os.remove(filename) 28 | else: 29 | tokens = {k: v for k, v in tokens.items() 30 | if not k.startswith(names[n - 1] + '/')} 31 | with open(filename, 'w') as f: 32 | json.dump(tokens, f) 33 | except ValueError: 34 | print('Okay, doing nothing.') 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CLI OAuth2 2 | 3 | This Python library help command-line tool authors to use 4 | OAuth2 services. Built using [requests-oauthlib](https://requests-oauthlib.readthedocs.io/) 5 | with parts of [google\_auth\_oauthlib](https://github.com/googleapis/google-auth-library-python-oauthlib). 6 | 7 | ## Installation 8 | 9 | pip install cli-oauth2 10 | 11 | ## Usage 12 | 13 | Before using OAuth2, you must obtain a Client ID and Client Secret by registering an application with the provider. For example, for OpenStreetMapAuth, register an application [here](https://www.openstreetmap.org/oauth2/applications). Important: set the redirect URI of the application to `http://127.0.0.1:8080/`. 14 | 15 | Then do something like this: 16 | 17 | ```python 18 | from oauthcli import OpenStreetMapAuth 19 | 20 | auth = OpenStreetMapAuth( 21 | client_id, client_secret, ['read_prefs'] 22 | ).auth_server(token_test=lambda r: r.get('user/details')) 23 | 24 | data = auth.get('user/details.json') 25 | if data.status_code != 200: 26 | print(f'Error {data.status_code}: {data.text})') 27 | else: 28 | print(f'Hello, {data.json()["user"]["display_name"]}') 29 | ``` 30 | 31 | Tokens are saved to disk, so subsequent runs won't require authorization. 32 | 33 | Auth objects have these methods and properties: 34 | 35 | * `auth_server()` opens a web browser and catches the response by 36 | starting a local server. 37 | * `auth_code()` opens a web browser and expected a user to copy the code 38 | presented by the provider. It uses `urn:ietf:wg:oauth:2.0:oob` redirect uri. 39 | * `authorized` returns whether there is an active access token. 40 | * `get`, `post` etc call the relevant `requests` methods, but often shadow 41 | the server name. See the example above. 42 | * `session` is the underlying [OAuth2Session](https://requests-oauthlib.readthedocs.io/en/latest/api.html#oauth-2-0-session) object. 43 | 44 | There are some predefined providers: 45 | 46 | * `OpenStreetMapAuth` 47 | * `OpenStreetMapDevAuth` 48 | * `GoogleAuth` 49 | * `GitHubAuth` 50 | * `MastodonAuth` (requires a `server` parameter) 51 | * `RedditAuth` 52 | * `FacebookAuth` 53 | * `LinkedInAuth` 54 | 55 | Note that only OSM and GitHub providers were tested. I welcome 56 | pull requests with fixes. 57 | 58 | If you need to use another provider, just subclass `AuthFlow` and 59 | pass it `provider_id` (the key for the stored token map), 60 | `OAuth2Session(client_id, scope=scopes)`, 61 | `auth_url`, `token_url`, and `client_secret`. 62 | 63 | ## Cleanup 64 | 65 | The tool stores tokens in a json in the configuration directory. 66 | To clean some or all tokens, use the `oauthclean` command-line tool. 67 | 68 | ## Author and License 69 | 70 | Written by Ilya Zverev, published under Apache License 2.0. 71 | 72 | Contains portions of [google\_auth\_oauthlib](https://github.com/googleapis/google-auth-library-python-oauthlib) 73 | as of commit 1a9dca889357b93bdad17d75a28ac81e3ba6067f, published under 74 | Apache License 2.0. 75 | -------------------------------------------------------------------------------- /src/oauthcli/providers.py: -------------------------------------------------------------------------------- 1 | from .flow import AuthFlow 2 | from requests_oauthlib import OAuth2Session 3 | from typing import Optional, Sequence 4 | 5 | 6 | class OpenStreetMapAuth(AuthFlow): 7 | def __init__( 8 | self, 9 | client_id: str, 10 | client_secret: str, 11 | scopes: Sequence[str], 12 | provider_id: str = 'openstreetmap', 13 | url: str = 'https://www.openstreetmap.org', 14 | ): 15 | super().__init__( 16 | provider_id, 17 | OAuth2Session(client_id, scope=scopes), 18 | f'{url.rstrip("/")}/oauth2/authorize', 19 | f'{url.rstrip("/")}/oauth2/token', 20 | client_secret, 21 | ) 22 | self.default_local_host = '127.0.0.1' 23 | self.url = url.rstrip("/") 24 | 25 | def process_url(self, api: str) -> str: 26 | return f'{self.url}/api/0.6/{api.lstrip("/")}' 27 | 28 | 29 | class OpenStreetMapDevAuth(OpenStreetMapAuth): 30 | def __init__( 31 | self, 32 | client_id: str, 33 | client_secret: str, 34 | scopes: Sequence[str], 35 | ): 36 | super().__init__( 37 | client_id, client_secret, scopes, 38 | 'openstreetmap_dev', 39 | 'https://api06.dev.openstreetmap.org' 40 | ) 41 | 42 | 43 | class GoogleAuth(AuthFlow): 44 | def __init__( 45 | self, 46 | client_id: str, 47 | client_secret: str, 48 | scopes: Sequence[str], 49 | ): 50 | super().__init__( 51 | 'google', 52 | OAuth2Session(client_id, scope=scopes), 53 | 'https://accounts.google.com/o/oauth2/auth', 54 | 'https://oauth2.googleapis.com/token', 55 | client_secret, 56 | ) 57 | 58 | 59 | class GitHubAuth(AuthFlow): 60 | def __init__( 61 | self, 62 | client_id: str, 63 | client_secret: str, 64 | scopes: Optional[Sequence[str]] = None, 65 | ): 66 | super().__init__( 67 | 'github', 68 | OAuth2Session(client_id, scope=scopes), 69 | 'https://github.com/login/oauth/authorize', 70 | 'https://github.com/login/oauth/access_token', 71 | client_secret, 72 | ) 73 | 74 | def process_url(self, api: str) -> str: 75 | return f'https://api.github.com/{api.lstrip("/")}' 76 | 77 | 78 | class MastodonAuth(AuthFlow): 79 | def __init__( 80 | self, 81 | server: str, 82 | client_id: str, 83 | client_secret: str, 84 | scopes: Optional[Sequence[str]] = None, 85 | ): 86 | super().__init__( 87 | 'mastodon', 88 | OAuth2Session(client_id, scope=scopes), 89 | f'{server.rstrip("/")}/oauth2/authorize', 90 | f'{server.rstrip("/")}/oauth2/token', 91 | client_secret, 92 | ) 93 | self.server = server.rstrip('/') 94 | 95 | def process_url(self, api: str) -> str: 96 | return f'{self.server}/api/v1/{api.lstrip("/")}' 97 | 98 | 99 | class RedditAuth(AuthFlow): 100 | def __init__( 101 | self, 102 | client_id: str, 103 | client_secret: str, 104 | scopes: Sequence[str], 105 | ): 106 | super().__init__( 107 | 'reddit', 108 | OAuth2Session(client_id, scope=scopes), 109 | 'https://www.reddit.com/oauth2/authorize', 110 | 'https://www.reddit.com/oauth2/access_token', 111 | client_secret, 112 | ) 113 | 114 | def process_url(self, api: str) -> str: 115 | return f'https://www.reddit.com/{api.lstrip("/")}' 116 | 117 | 118 | class FacebookAuth(AuthFlow): 119 | def __init__( 120 | self, 121 | client_id: str, 122 | client_secret: str, 123 | scopes: Sequence[str], 124 | ): 125 | super().__init__( 126 | 'facebook', 127 | OAuth2Session(client_id, scope=scopes), 128 | 'https://www.facebook.com/dialog/oauth', 129 | 'https://graph.facebook.com/oauth/access_token', 130 | client_secret, 131 | ) 132 | 133 | 134 | class LinkedInAuth(AuthFlow): 135 | def __init__( 136 | self, 137 | client_id: str, 138 | client_secret: str, 139 | scopes: Sequence[str], 140 | ): 141 | super().__init__( 142 | 'linkedin', 143 | OAuth2Session(client_id, scope=scopes), 144 | 'https://www.linkedin.com/uas/oauth2/authorization', 145 | 'https://www.linkedin.com/uas/oauth2/accessToken', 146 | client_secret, 147 | ) 148 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /src/oauthcli/flow.py: -------------------------------------------------------------------------------- 1 | try: 2 | from secrets import SystemRandom 3 | except ImportError: # pragma: NO COVER 4 | from random import SystemRandom 5 | import contextlib 6 | import socket 7 | import hashlib 8 | import logging 9 | import platformdirs 10 | import json 11 | import webbrowser 12 | import string 13 | import wsgiref.simple_server 14 | import wsgiref.util 15 | import os.path 16 | import sys 17 | from base64 import urlsafe_b64encode 18 | from requests_oauthlib import OAuth2Session 19 | from typing import Optional, Union, Callable 20 | 21 | 22 | class AuthFlow: 23 | def __init__( 24 | self, 25 | provider_id: str, 26 | session: OAuth2Session, 27 | auth_url: str, 28 | token_url: str, 29 | client_secret: Optional[str] = None, 30 | ): 31 | self.provider_id = provider_id 32 | self.session = session 33 | self.session.token_updater = self._save_token 34 | self.code_verifier = None 35 | self.auth_url = auth_url 36 | self.token_url = token_url 37 | self.client_secret = client_secret 38 | self.default_local_host = 'localhost' 39 | self._load_token() 40 | 41 | @property 42 | def authorized(self) -> bool: 43 | return self.session.authorized 44 | 45 | def logout(self): 46 | """Removes a stored token, but does not clear the current session. 47 | Warning: a request with the current session can refresh and save 48 | the token, making this call ineffective.""" 49 | self._save_token(None) 50 | 51 | def process_url(self, api: str) -> str: 52 | return api 53 | 54 | def request(self, method: str, api: str, **kwargs): 55 | return self.session.request(method, self.process_url(api), **kwargs) 56 | 57 | def get(self, api: str, **kwargs): 58 | return self.session.get(self.process_url(api), **kwargs) 59 | 60 | def post(self, api: str, **kwargs): 61 | return self.session.post(self.process_url(api), **kwargs) 62 | 63 | def put(self, api: str, **kwargs): 64 | return self.session.put(self.process_url(api), **kwargs) 65 | 66 | def patch(self, api: str, **kwargs): 67 | return self.session.patch(self.process_url(api), **kwargs) 68 | 69 | def delete(self, api: str, **kwargs): 70 | return self.session.delete(self.process_url(api), **kwargs) 71 | 72 | def head(self, api: str, **kwargs): 73 | return self.session.head(self.process_url(api), **kwargs) 74 | 75 | def options(self, api: str, **kwargs): 76 | return self.session.options(self.process_url(api), **kwargs) 77 | 78 | def _load_token(self): 79 | if not self.session.client_id: 80 | return 81 | token_key = f'{self.provider_id}/{self.session.client_id}' 82 | config_dir = platformdirs.user_config_dir('PythonCliAuth', ensure_exists=True) 83 | filename = os.path.join(config_dir, 'tokens.json') 84 | if os.path.exists(filename): 85 | with open(filename, 'r') as f: 86 | tokens = json.load(f) 87 | if token_key in tokens: 88 | self.session.token = tokens[token_key] 89 | 90 | def _save_token(self, token: Optional[dict]): 91 | token_key = f'{self.provider_id}/{self.session.client_id}' 92 | config_dir = platformdirs.user_config_dir('PythonCliAuth', ensure_exists=True) 93 | filename = os.path.join(config_dir, 'tokens.json') 94 | tokens = {} 95 | try: 96 | if os.path.exists(filename): 97 | with open(filename, 'r') as f: 98 | tokens = json.load(f) 99 | except IOError: 100 | pass 101 | 102 | if not token: 103 | if token_key in tokens: 104 | del tokens[token_key] 105 | else: 106 | tokens[token_key] = token 107 | 108 | try: 109 | with open(filename, 'w') as f: 110 | json.dump(tokens, f) 111 | except IOError: 112 | logging.exception('Could not save tokens to %s', filename) 113 | 114 | def authorization_url(self, **kwargs): 115 | # ↓ this is google-specific 116 | # kwargs.setdefault("access_type", "offline") 117 | chars = string.ascii_letters + string.digits + "-._~" 118 | rnd = SystemRandom() 119 | random_verifier = [rnd.choice(chars) for _ in range(0, 128)] 120 | self.code_verifier = "".join(random_verifier) 121 | 122 | if self.code_verifier: 123 | code_hash = hashlib.sha256() 124 | code_hash.update(str.encode(self.code_verifier)) 125 | unencoded_challenge = code_hash.digest() 126 | b64_challenge = urlsafe_b64encode(unencoded_challenge) 127 | code_challenge = b64_challenge.decode().split("=")[0] 128 | kwargs.setdefault("code_challenge", code_challenge) 129 | kwargs.setdefault("code_challenge_method", "S256") 130 | 131 | url, state = self.session.authorization_url(self.auth_url, **kwargs) 132 | return url, state 133 | 134 | def fetch_token(self, **kwargs): 135 | kwargs.setdefault("client_secret", self.client_secret) 136 | kwargs.setdefault("code_verifier", self.code_verifier) 137 | token = self.session.fetch_token(self.token_url, **kwargs) 138 | self._save_token(token) 139 | return token 140 | 141 | _DEFAULT_AUTH_CODE_MESSAGE = "Enter the authorization code: " 142 | 143 | _DEFAULT_AUTH_PROMPT_MESSAGE = ( 144 | "Please visit this URL to authorize this application: {url}" 145 | ) 146 | 147 | _DEFAULT_WEB_SUCCESS_MESSAGE = ( 148 | "The authentication flow has completed. You may close this window." 149 | ) 150 | 151 | def _check_auth(self, force: bool, token_test: Optional[Callable] = None) -> bool: 152 | if not self.authorized or force: 153 | return False 154 | ok = True 155 | if token_test is not None: 156 | try: 157 | resp = token_test(self) 158 | if resp.status_code % 100 == 4: 159 | ok = False 160 | except: # noqa: E722 161 | ok = False 162 | return ok 163 | 164 | def auth_code( 165 | self, 166 | authorization_prompt_message=_DEFAULT_AUTH_PROMPT_MESSAGE, 167 | open_browser=True, 168 | code_message=_DEFAULT_AUTH_CODE_MESSAGE, 169 | token_audience=None, 170 | force: bool = False, 171 | token_test: Optional[Callable] = None, 172 | **kwargs 173 | ): 174 | """Runs auth flow without starting a web server. 175 | Note that you must have 'urn:ietf:wg:oauth:2.0:oob' for 176 | the redirect URL in the provider app settings.""" 177 | if self._check_auth(force, token_test): 178 | return self 179 | 180 | self.session.redirect_uri = 'urn:ietf:wg:oauth:2.0:oob' 181 | auth_url, _ = self.authorization_url(**kwargs) 182 | 183 | if open_browser: 184 | webbrowser.open(auth_url, new=2, autoraise=True) 185 | 186 | if authorization_prompt_message: 187 | print(authorization_prompt_message.format(url=auth_url), file=sys.stderr) 188 | 189 | while True: 190 | auth_code = input(code_message).strip() 191 | if auth_code: 192 | break 193 | self.fetch_token(code=auth_code, audience=token_audience) 194 | return self 195 | 196 | def _find_open_port(self, ports: list[int]): 197 | start = 8080 if not ports else ports[0] 198 | stop = start + 100 if len(ports) < 2 else ports[1] 199 | for port in range(start, stop): 200 | with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: 201 | try: 202 | sock.bind(('127.0.0.1', port)) 203 | sock.listen(1) 204 | except socket.error: 205 | is_open = False 206 | else: 207 | is_open = True 208 | if is_open: 209 | return port 210 | raise ConnectionError('Could not find an open port') 211 | 212 | # Copied from google-auth. 213 | def auth_server( 214 | self, 215 | host: Optional[str] = None, 216 | bind_addr: Optional[int] = None, 217 | port: Union[int, list[int]] = 8080, 218 | authorization_prompt_message: Optional[str] = _DEFAULT_AUTH_PROMPT_MESSAGE, 219 | success_message: str = _DEFAULT_WEB_SUCCESS_MESSAGE, 220 | open_browser: bool = True, 221 | redirect_uri_trailing_slash: bool = True, 222 | timeout_seconds: Optional[int] = None, 223 | token_audience: Optional[str] = None, 224 | force: bool = False, 225 | token_test: Optional[Callable] = None, 226 | **kwargs 227 | ): 228 | """Run the flow using the server strategy. 229 | 230 | The server strategy instructs the user to open the authorization URL in 231 | their browser and will attempt to automatically open the URL for them. 232 | It will start a local web server to listen for the authorization 233 | response. Once authorization is complete the authorization server will 234 | redirect the user's browser to the local web server. The web server 235 | will get the authorization code from the response and shutdown. The 236 | code is then exchanged for a token. 237 | 238 | Args: 239 | host (str): The hostname for the local redirect server. This will 240 | be served over http, not https. 241 | bind_addr (str): Optionally provide an ip address for the redirect 242 | server to listen on when it is not the same as host 243 | (e.g. in a container). Default value is None, 244 | which means that the redirect server will listen 245 | on the ip address specified in the host parameter. 246 | port (int / list[int]): The port for the local redirect server. 247 | When a list, it would find the first open port in the range. 248 | authorization_prompt_message (str | None): The message to display to tell 249 | the user to navigate to the authorization URL. If None or empty, 250 | don't display anything. 251 | success_message (str): The message to display in the web browser 252 | the authorization flow is complete. 253 | open_browser (bool): Whether or not to open the authorization URL 254 | in the user's browser. 255 | redirect_uri_trailing_slash (bool): whether or not to add trailing 256 | slash when constructing the redirect_uri. Default value is True. 257 | timeout_seconds (int): It will raise an error after the timeout timing 258 | if there are no credentials response. The value is in seconds. 259 | When set to None there is no timeout. 260 | Default value is None. 261 | token_audience (str): Passed along with the request for an access 262 | token. Determines the endpoints with which the token can be 263 | used. Optional. 264 | force (bool): Set to True to authorize even when already have a token. 265 | token_test (Callable): Function that receives this object for a param, 266 | makes a call, and returns the response. 267 | kwargs: Additional keyword arguments passed through to 268 | :meth:`authorization_url`. 269 | 270 | Returns: 271 | google.oauth2.credentials.Credentials: The OAuth 2.0 credentials 272 | for the user. 273 | """ 274 | if self._check_auth(force, token_test): 275 | return self 276 | 277 | if isinstance(port, list): 278 | port = self._find_open_port(port) 279 | 280 | if not host: 281 | host = self.default_local_host 282 | 283 | wsgi_app = _RedirectWSGIApp(success_message) 284 | # Fail fast if the address is occupied 285 | wsgiref.simple_server.WSGIServer.allow_reuse_address = False 286 | local_server = wsgiref.simple_server.make_server( 287 | bind_addr or host, port, wsgi_app, handler_class=_WSGIRequestHandler 288 | ) 289 | 290 | redirect_uri_format = ( 291 | "http://{}:{}/" if redirect_uri_trailing_slash else "http://{}:{}" 292 | ) 293 | self.session.redirect_uri = redirect_uri_format.format(host, local_server.server_port) 294 | auth_url, _ = self.authorization_url(**kwargs) 295 | 296 | if open_browser: 297 | webbrowser.open(auth_url, new=2, autoraise=True) 298 | 299 | if authorization_prompt_message: 300 | print(authorization_prompt_message.format(url=auth_url), file=sys.stderr) 301 | 302 | local_server.timeout = timeout_seconds 303 | local_server.handle_request() 304 | 305 | # Note: using https here because oauthlib is very picky that 306 | # OAuth 2.0 should only occur over https. 307 | authorization_response = wsgi_app.last_request_uri.replace("http", "https") 308 | self.fetch_token( 309 | authorization_response=authorization_response, audience=token_audience 310 | ) 311 | 312 | # This closes the socket 313 | local_server.server_close() 314 | return self 315 | 316 | 317 | class _WSGIRequestHandler(wsgiref.simple_server.WSGIRequestHandler): 318 | """Custom WSGIRequestHandler. 319 | 320 | Uses a named logger instead of printing to stderr. 321 | """ 322 | 323 | def log_message(self, format, *args): 324 | # pylint: disable=redefined-builtin 325 | # (format is the argument name defined in the superclass.) 326 | logging.info(format, *args) 327 | 328 | 329 | class _RedirectWSGIApp(object): 330 | """WSGI app to handle the authorization redirect. 331 | 332 | Stores the request URI and displays the given success message. 333 | """ 334 | 335 | def __init__(self, success_message): 336 | """ 337 | Args: 338 | success_message (str): The message to display in the web browser 339 | the authorization flow is complete. 340 | """ 341 | self.last_request_uri = None 342 | self._success_message = success_message 343 | 344 | def __call__(self, environ, start_response): 345 | """WSGI Callable. 346 | 347 | Args: 348 | environ (Mapping[str, Any]): The WSGI environment. 349 | start_response (Callable[str, list]): The WSGI start_response 350 | callable. 351 | 352 | Returns: 353 | Iterable[bytes]: The response body. 354 | """ 355 | start_response("200 OK", [("Content-type", "text/plain; charset=utf-8")]) 356 | self.last_request_uri = wsgiref.util.request_uri(environ) 357 | return [self._success_message.encode("utf-8")] 358 | --------------------------------------------------------------------------------