diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index cfdbf3fc6..3aa701868 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -24,7 +24,7 @@ jobs: restore-keys: | ${{ runner.os }}-pip - - name: Build, verify, and upload to TestPyPI + - name: Build, verify, and upload to PyPI run: | pip install --upgrade nox nox -s build publish_pypi diff --git a/.gitignore b/.gitignore index 17f499eed..df10277b8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,9 @@ coverage.xml *.pyc # Editors +.idea/ .vscode/ # Docs build site .venv +venv diff --git a/CHANGES.txt b/CHANGES.txt index ee8da3be2..78422e9f8 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,5 +1,15 @@ Changelog moved to https://github.com/planetlabs/planet-client-python/releases +3.0.0 (Unreleased) +- Overhaul of planet.Auth AP interface to leverage the + [Planet Auth Utility Library](https://github.com/planetlabs/planet-auth-python) + and move preferred authentication mechanisms to OAuth2 over API keys. + See the Client Authentication Guide in the documentation for details and + examples. +- Overhaul of the `planet auth` CLI to support Auth infrastructure overhaul. + Of immediate note to uses is that `planet auth login` should be preferred + to `planet auth init` to initialize the CLI client for use. + 2.13.0 (2024-12-18) - Add Planet class (`from planet import Planet`) - Planet is a client that uses sync methods. Users do not have diff --git a/docs/cli/cli-guide.md b/docs/cli/cli-guide.md index dc501de4a..090d14787 100644 --- a/docs/cli/cli-guide.md +++ b/docs/cli/cli-guide.md @@ -34,13 +34,13 @@ Yes. Even if you’re not writing code—and only using the "no code" CLI part o Install the Planet SDK for Python using [pip](https://pip.pypa.io): ```console -$ pip install planet +pip install planet ``` ## Step 3: Check the Planet SDK for Python version ```console -$ planet --version +planet --version ``` You should be on some version 2 of the Planet SDK for Python. @@ -55,46 +55,40 @@ To confirm your Planet account, or to get one if you don’t already have one, s ### Authenticate with the Planet server -Just as you log in when you browse to https://account.planet.com, you’ll want to sign on to your account so you have access to your account and orders. +Just as you log in when you browse to https://planet.com/account, you’ll want to sign on to your account so you have access to your account and orders. At a terminal console, type the following Planet command: ```console -$ planet auth init +planet auth login ``` -You’ll be prompted for the email and password you use to access [your account](https://account.planet.com). When you type in your password, you won’t see any indication that the characters are being accepted. But when you hit enter, you’ll know that you’ve succeeded because you’ll see on the command line: +A browser window should be opened, and you will be directed to login to your account. This +command will wait for the browser login to complete, and should exit shortly afterwards. +When this process succeeds, you will see the following message on the console: ```console -Initialized +Login succeeded. ``` -### Get your API key - -Now that you’ve logged in, you can easily retrieve your API key that is being used for requests with the following command: - +If you are in an environment where the `planet` command line utility cannot open a browser (such +as a remote shell on a cloud service provider), use the following command and follow the instructions: ```console -planet auth value +planet auth login --no-open-browser ``` -Many `planet` calls you make require an API key. This is a very convenient way to quickly grab your API key. - -#### Your API Key as an Environment Variable +### Get your Access Token -You can also set the value of your API Key as an environment variable in your terminal at the command line: +Now that you’ve logged in, you can easily retrieve an Access Token that is being used for requests with the following command: ```console -export PL_API_KEY=<your api key> +planet auth print-access-token ``` -And you can see that the value was stored successfully as an environment variable with the following command: - -```console -echo $PL_API_KEY -``` +Many `planet` calls you make require an access token. This is a very convenient way to quickly grab the current access token. -!!!note "The API Key environment variable is ignored by the CLI but used by the Python library" - If you do create a `PL_API_KEY` environment variable, the CLI will be unaffected but the Planet library will use this as the source for authorization instead of the value stored in `planet auth init`. +**Note** : As a security measure, access tokens are time limited. They have a relatively short lifespan, and must +be refreshed. The `print-access-token` command takes care of this transparently for the user. ## Step 5: Search for Planet Imagery diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index a3500cee7..bf2107f69 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -4,8 +4,10 @@ title: CLI Reference This page provides documentation for our command line tools. +{% raw %} ::: mkdocs-click :module: planet.cli.cli :command: main :prog_name: planet :depth: 1 +{% endraw %} diff --git a/docs/get-started/quick-start-guide.md b/docs/get-started/quick-start-guide.md index 28abcf8da..a6ae82c4e 100644 --- a/docs/get-started/quick-start-guide.md +++ b/docs/get-started/quick-start-guide.md @@ -27,10 +27,12 @@ pip install planet ### Authentication -Use the `PL_API_KEY` environment variable to authenticate with the Planet API. For other authentication options, see the [SDK guide](../python/sdk-guide.md). +Use the `planet auth` CLI command to establish a user login session that will +be saved to the user's home directory. For other authentication options, see +the [Client Authentication Guide](../python/sdk-client-auth.md). ```bash -export PL_API_KEY=your_api_key +planet auth login ``` ### The Planet client @@ -39,7 +41,7 @@ The `Planet` class is the main entry point for the Planet SDK. It provides acces ```python from planet import Planet -pl = Planet() # automatically detects PL_API_KEY +pl = Planet() # automatically detects authentication configured by `planet auth login` ``` The Planet client has members `data`, `orders`, and `subscriptions`, which allow you to interact with the Data API, Orders API, and Subscriptions API. Usage examples for searching, ordering and creating subscriptions can be found in the [SDK guide](../python/sdk-guide.md). diff --git a/docs/python/sdk-client-auth.md b/docs/python/sdk-client-auth.md new file mode 100644 index 000000000..bcc3adfaf --- /dev/null +++ b/docs/python/sdk-client-auth.md @@ -0,0 +1,375 @@ +# Client Authentication Guide + +## Introduction +All calls to Planet APIs must be authenticated. Only authorized clients may +use Planet Platform APIs. + +For general information on how to authenticate to Planet APIs, please see +the [Authentication](https://docs.planet.com/develop/authentication) section of Planet's platform documentation. + +!!! warning + Some statements are forward-looking. OAuth2 M2M tokens are + currently only supported by `services.sentine-hub.com` and not supported + by `api.planet.com`. + + All APIs support interactive user OAuth2 access tokens, but a process for + developers to register and manage clients has not yet been made public. + We have also not yet release a way for end-users of such applications to + manage which applications have been authorized to access the platform on + their behalf. + + If you would like to developed an interactive application that uses + Planet's APIs on behalf of a user (as the `planet` CLI utility does), + please contact Planet and work with engineering to register your + application. + +---- +## Authentication Protocols +At the API protocol level underneath the SDK, there are several distinct +ways a client may authenticate to the Planet APIs, depending on the use case: + +* **OAuth2 user access tokens** - API access as the end-user, using OAuth2 +user access tokens. This is the preferred way for user interactive +applications to authenticate to Planet APIs. A web browser is required +to initialize a session, but not required for continued operation. +* **OAuth2 M2M access tokens** - API access as a service user, using OAuth2 +M2M access tokens. This is the preferred way for automated processes +to authenticate to Planet APIs that must operate without a human user. +No web browser is required, but this method carries some additional +security considerations. +* **Planet API keys** - API access as a planet end-user using a simple +fixed string bearer key. This method is being targeted for deprecation. + +### OAuth2 +OAuth2 authentication requires that the client possesses an access token +in order to make API calls. Access tokens are obtained by the client from +the Planet authorization server that is separate from the API servers, and are +presented by the client to API services to prove the client's right to make +API calls. + +Unlike Planet API keys, access tokens do not last forever for a variety of +reasons and must be regularly refreshed by the client before their expiration. +However, clients should not refresh access tokens for every API call; clients +that misbehave in this way will be throttled by the authorization service, +potentially losing access to APIs. + +When using the Planet SDK, the many of the details of obtaining and refreshing +OAuth2 access tokens will be taken care of for you. + +Planet OAuth2 access tokens will work for all Planet APIs underneath +both the `api.planet.com` and `services.sentinel-hub.com` domains. + +Planet Access tokens conform to the JSON Web Token (JWT) specification. +Tokens may be inspected to determine their expiration time, which will be +in the `exp` claim. + +!!! note + Clients should generally treat the access tokens as opaque bearer tokens. + While JWTs are open for inspection, Planet does not guarantee the stability + of undocumented claims. Rely only on those documented here. + +More information regarding OAuth2 and JWTs may be found here: + +* [RFC 6749 - The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749) +* [RFC 8628 - OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628) +* [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) +* [RFC 9068 - JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://datatracker.ietf.org/doc/html/rfc9068) + +#### OAuth2 Client Registration +!!! TODO + * link to docs for this process + * discuss registering a interactive client (that will access Planet + as the user) vs registering a M2M client identity (which is really + more like creating a new user) vs registering a confidential client. + discuss native vs web clients. + +Developers of applications must register client applications with Planet, and +will be issued a Client ID as part of that process. Developers should register +a client for each distinct application so that end-users may discretely manage +applications permitted to access Planet APIs on their behalf. + +### Planet API Keys +Planet API keys are simple fixed strings that may be presented by the client +to API services that assert the client's right to access APIs. API keys are +obtained by the user from their account page, and provided to the client +so that it may make API calls on the user's behalf. + +Planet API keys are simpler to use than OAuth2, but are considered less secure +in many ways. Because of this, Planet API keys are targeted for eventual +deprecation. Support for this method is maintained for continuity +while OAuth2 based methods are being rolled out across all Planet APIs and +clients. + +Planet API Keys will work for Planet APIs underneath `api.planet.com`, but +will **NOT** work for APIs underneath `services.sentinel-hub.com`. + +---- +## Authentication with the SDK + +Before any calls can be made to a Planet API using the SDK, it is +necessary for the user to login and establish an authentication session. +Exactly how this should be done with the SDK depends on the +application's complexity and needs. + +In simple cases, this may be managed external to the application +by using the [`planet auth`](../../cli/cli-reference/#auth) +command line utility. + +In more complex cases, an application may need to manage the +stored session itself independent of utilities provided by the CLI. In such +cases the application will be responsible for instantiating a `planet.Auth` +object, initiating user login, and saving the session resulting information. +Session information may contain sensitive information such as access and +refresh tokens, and must be stored securely by the application. Session +information will also be regularly updated during SDK operations, so the +application must handle keeping the saved session information up-to-date. + +Regardless of which authentication protocol is used, the SDK encapsulates +the details with +[`planet.Auth`](../sdk-reference/#planet.auth.Auth) and +[`planet.Session`](../sdk-reference/#planet.http.Session). + +#### Session State Storage + +Once a user login session is established using any method, the state should be +saved to secure persistent storage to allow for continued access to the Planet +platform without the need to perform the login repeatedly. If state cannot +be persisted in the application environment, the application can operate in +in-memory mode, and will be forced create a new login session every time the +application is run. In some cases, this may result in throttling by the +authorization service. + +By default, the SDK provides the option to save session state in the user's +home directory in a way that is compatible with the CLI. The SDK also +provides a way for the application to provide its own secure storage. +Applications needing to use their own storage will do so by providing +the `Auth` layer in the SDK with a custom implementation of the +`planet_auth.ObjectStorageProvider` abstract base class. See examples +below for more details. + +### Using `planet auth` CLI Managed Auth Session +For simple programs and scripts, it is easiest for the program to defer +session management to the [`planet auth`](../../cli/cli-reference/#auth) +CLI. This method will store session information in the user's home directory +in the `~/.planet.json` file and `~/.planet/` directory. The python SDK will +use the information saved in these locations to make API calls. + +When this approach is taken, the authentication session will be shared between +actions taken by the `planet` utility, and those taken by the programs built +using the SDK. Changes made by one will impact the behavior of the other. + +**Requirements and Limitations:** + +* The program must have read and write access to the user's home directory. +* This method requires that the end-user has access to and understands + the [`planet`](../../cli/cli-reference) CLI command needed to manage session + authentication. +* This approach should not be used on public terminals or in cases where the + user's home directory cannot be kept confidential. + +#### Initialize Session - Login +Session login can be performed using the following command. This command can +be used to initialize sessions using any of the authentication methods +discussed above, and will default to creating an OAuth2 user session. +Refer to the command's `--help` for more information. +```shell title="Initialize session using planet CLI" +planet auth login +``` + +#### Using Saved Session +Using the CLI managed session is the default behavior for SDK functions. +Developing an application that uses this session requires no additional +action by the developer. When a developer chooses to create an application +that behaves in this way, it will most often be done implicitly by relying +on SDK default behavior, but it may also be done explicitly. + +```python linenums="1" title="Implicitly use CLI managed login sessions" +{% include 'auth-session-management/cli_managed_auth_state__implicit.py' %} +``` + +```python linenums="1" title="Explicitly use CLI managed login sessions" +{% include 'auth-session-management/cli_managed_auth_state__explicit.py' %} +``` + +```python linenums="1" title="Use a specific session that is shared with the CLI" +{% include 'auth-session-management/cli_managed_auth_state__specific_auth_profile.py' %} + +``` + +### Manually Creating a Session Using Library Functions +If an application cannot or should not use a login session initiated by the +[`planet auth`](../../cli/cli-reference/#auth) CLI command, it will be +responsible for managing the process on its own, persisting session state as +needed. + +The process differs slightly for applications accessing Planet services on behalf +of a human user verses accessing Planet services using a service account. Depending +on the use case, applications may need to support one or the other or both (just +as the [`planet`](../../cli/cli-reference) CLI command supports both methods). + +#### OAuth2 Session for Users +User session initialization inherently involves using a web browser to +complete user authentication. This architecture allows for greater security +by keeping the user's password from being directly exposed to the application +code. This also allows for flexibility in user federation and multifactor +authentication procedures without the complexity of these needing to +be exposes to the application developer who is focused on geospatial +operations using the Planet platform, and not the nuances of user +authentication and authorization. + +##### With a Local Web Browser +In environments where a local browser is available, the Planet SDK can manage +the process of launching the browser locally, transferring control to the Planet +authorization services for session initialization, and accepting a network +callback from the local browser to regain control once the authorization +process is complete. At a network protocol level, this is establishing the user +login session using the OAuth2 authorization code flow. + +To use this method using the SDK, the following requirements must be met: + +* The application must be able to launch a local web browser. +* The web browse must be able to connect to Planet services. +* The application must be able to listen on a network port that is accessible + to the browser. + +###### Examples - Authorization Code Flow +```python linenums="1" title="Login as a user using a local browser with in memory only state persistance" +{% include 'auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py' %} +``` + +```python linenums="1" title="Login as a user using a local browser with sessions persisted on disk and shared with the CLI" +{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py' %} +``` + +```python linenums="1" title="Login as a user using a local browser with sessions persisted to application provided storage" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_authcode__with_browser.py' %} +``` + +##### Without a Local Web Browser +In environments where a local web browsers is not available the process above +will not work. For example, a remote shell to a cloud environment is not likely +to be able to open a browser on the user's desktop or receive network callbacks +from the user's desktop browser. In these cases, a browser is +still required. To login in such a case the SDK will generate a URL and a +verification code that must be presented to the user. The user must visit the +URL out of band to complete the login process while the application polls for +the completion of the login process using the SDK. At a network protocol +level, this is establishing the user login session using the OAuth2 device +code flow. + +To use this method using the SDK, the following requirements must be met: + +* The application must be able to connect to Planet services. +* The application must be able to display instructions to the user, directing + them to a web location to complete login. + +###### Examples - Device Code Flow +```python linenums="1" title="Login as a user using an external browser with in memory only state persistance" +{% include 'auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py' %} +``` + +```python linenums="1" title="Login as a user using an external browser with sessions persisted on disk and shared with the CLI" +{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py' %} +``` + +```python linenums="1" title="Login as a user using an external browser with sessions persisted to application provided storage" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_devicecode__external_browser.py' %} +``` + +#### OAuth2 Session for Service Accounts +Service account session initialization is simpler than user session +initialization, and does not require a web browser. + +While preserving session state for user sessions was a concern driven +in part by a concern for the user experience of using a web browser for +initialization, for service account it remains a concern to avoid +throttling by the authorization service. + +If applications are expected to run longer than the life of an access token +(a few hours), then in memory operations are acceptable (for example: a long +running data processing job). If application lifespan is short and frequent, +than the application should still take steps to persist the session state (for +example: a command line utility run from a shell with a short lifespan). + +Like the session state itself, service account initialization parameters are +sensitive, and it is the responsibility of the application to store them +securely. + +At a network protocol level, OAuth2 service account sessions are implemented +using the OAuth2 authorization code flow. This carries with it some additional +security considerations, discussed in +[RFC 6819 §4.4.4](https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.4). +Because of these consideration, service accounts should only be used for +workflows that are independent of a controlling user. + +##### Examples - Client Credentials Flow +```python linenums="1" title="Access APIs using a service account with in memory only state persistance" +{% include 'auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py' %} +``` + +```python linenums="1" title="Access APIs using a service account with sessions persisted on disk and shared with the CLI" +{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py' %} +``` + +```python linenums="1" title="Access APIs using a service account with sessions persisted to application provided storage" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py' %} +``` + +#### Planet API Key Sessions +Legacy applications that need to continue to support Planet API keys may do so +until API keys are deprecated. This method should not be adopted for new +development. + +##### Examples - Planet API Keys + +```python linenums="1" title="Access APIs using Planet API keys in memory" +{% include 'auth-session-management/app_managed_auth_state__in_memory__api_key.py' %} +``` + +```python linenums="1" title="Access APIs using Planet API keys using the on disk file format used by older versions of the SDK" +{% include 'auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py' %} +``` + +```json linenums="1" title="Legacy API Key file" +{% include 'auth-session-management/legacy_api_key_file.json' %} +``` + +```python linenums="1" title="Access APIs using Planet API keys with CLI managed shared state on disk" +{% include 'auth-session-management/app_managed_auth_state__on_disk_cli_shared__api_key.py' %} +``` + +```python linenums="1" title="Access APIs using Planet API keys using legacy on disk persistance" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__api_key.py' %} +``` + +## OAuth2 Scopes +OAuth2 uses scopes to allow users to limit how much access clients have to the Planet +service on their behalf. + +* **`planet`** - Use this scope to request access to Planet APIs. +* **`offline_acess`** - Use this scope to request a refresh token. This may + only be requested by clients that access APIs on behalf of a user. M2M + clients may not request this scope. + + +## Environment Variables +When session information is not explicitly configured, the following environment variables +will influence the library behavior when initialized to user default preferences. + +* **`PL_AUTH_PROFILE`** - Specify a custom CLI managed auth client profile by name. +* **`PL_AUTH_CLIENT_ID`** - Specify an OAuth2 M2M client ID. +* **`PL_AUTH_CLIENT_SECRET`** - Specify an OAuth2 M2M client secret. +* **`PL_AUTH_API_KEY`** - Specify a legacy Planet API key. +---- + + +## Web Services +!!! TODO + All of the above really deals with native applications running in an + environment controlled by the end-user. The considerations + are different if the application being developed is a web service where + the end-user is not directly accessing Planet APIs. This involves + "Confidential" OAuth2 client configurations, and needs to be documented + here. + +---- diff --git a/docs/python/sdk-guide.md b/docs/python/sdk-guide.md index bb0a17d62..66c7720b6 100644 --- a/docs/python/sdk-guide.md +++ b/docs/python/sdk-guide.md @@ -23,32 +23,26 @@ The `Planet` class is the main entry point for the Planet SDK. It provides acces ```python from planet import Planet -pl = Planet() # automatically detects PL_API_KEY +pl = Planet() # automatically detects authentication configured by `planet auth login` ``` The Planet client has members `data`, `orders`, and `subscriptions`, which allow you to interact with the Data API, Orders API, and Subscriptions API. ### Authentication - -Use the `PL_API_KEY` environment variable to authenticate with the Planet API. +To establish a user session that will be saved to the user's home directory +and will be picked up by the SDK, execute the following command: ```bash -export PL_API_KEY=your_api_key +planet auth login ``` -These examples will assume you are using the `PL_API_KEY` environment variable. If you are, you can skip to the next section. - -#### Authenticate using the Session class - -Alternately, you can also authenticate using the `Session` class: - -```python -from planet import Auth, Session, Auth -from planet.auth import APIKeyAuth - -pl = Planet(session=Session(auth=APIKeyAuth(key='your_api_key'))) -``` +These examples will assume you have done this, and are using the SDK's default +client authentication mechanisms. If you are not, please see the +[Client Authentication Guide](sdk-client-auth.md) for a complete discussion of +all authentication options provided by the SDK. This includes user +authentication with a web browser, service account authentication for detached +workloads, and support for legacy authentication mechanisms. ### Search @@ -331,4 +325,3 @@ If there's something you're missing or are stuck, the development team would lov - To report a bug or suggest a feature, [raise an issue on GitHub](https://github.com/planetlabs/planet-client-python/issues/new) - To get in touch with the development team, email [developers@planet.com](mailto:developers@planet.com) - diff --git a/examples/auth-session-management/app_managed_auth_state__app_custom_storage__api_key.py b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__api_key.py new file mode 100644 index 000000000..033a9e530 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__api_key.py @@ -0,0 +1,2 @@ +# No example of this use case provided at this time. +# The use of M2M OAuth sessions is encouraged over the use of API keys. diff --git a/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py new file mode 100644 index 000000000..bb5c17780 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py @@ -0,0 +1,96 @@ +import json +import logging +import os +import pathlib +import stat + +import planet + +from planet_auth import ObjectStorageProvider, ObjectStorageProvider_KeyType + +logging.basicConfig(level=logging.CRITICAL) + + +class DemoStorageProvider(ObjectStorageProvider): + """ + Simple demo custom storage provider that uses + ~/.planet-demo as a storage home for saving object. + + As a practical matter, ObjectStorageProvider_KeyType is defined + to be pathlib.Path, and we leverage that in this example. + But, storage providers are not required to use the local file + system to store objects. + """ + + def __init__(self): + self._demo_storage_root = pathlib.Path.home() / ".planet-demo" + + def load_obj(self, key: ObjectStorageProvider_KeyType) -> dict: + demo_obj_filepath = self._demo_obj_filepath(key) + return self._load_file(file_path=demo_obj_filepath) + + def save_obj(self, key: ObjectStorageProvider_KeyType, data: dict) -> None: + demo_obj_filepath = self._demo_obj_filepath(key) + self._save_file(file_path=demo_obj_filepath, data=data) + + def obj_exists(self, key: ObjectStorageProvider_KeyType) -> bool: + demo_obj_filepath = self._demo_obj_filepath(key) + return demo_obj_filepath.exists() + + def obj_rename(self, src: ObjectStorageProvider_KeyType, dst: ObjectStorageProvider_KeyType) -> None: + src_filepath = self._demo_obj_filepath(src) + dst_filepath = self._demo_obj_filepath(dst) + src_filepath.rename(dst_filepath) + + def _demo_obj_filepath(self, obj_key): + if obj_key.is_absolute(): + obj_path = self._demo_storage_root / obj_key.relative_to("/") + else: + obj_path = self._demo_storage_root / obj_key + return obj_path + + @staticmethod + def _load_file(file_path: pathlib.Path) -> dict: + logging.debug(msg="Loading JSON data from file {}".format(file_path)) + with open(file_path, mode="r", encoding="UTF-8") as file_r: + return json.load(file_r) + + @staticmethod + def _save_file(file_path: pathlib.Path, data: dict): + file_path.parent.mkdir(parents=True, exist_ok=True) + logging.debug(msg="Writing JSON data to file {}".format(file_path)) + with open(file_path, mode="w", encoding="UTF-8") as file_w: + os.chmod(file_path, stat.S_IREAD | stat.S_IWRITE) + _no_none_data = { + key: value + for key, value in data.items() if value is not None + } + file_w.write(json.dumps(_no_none_data, indent=2, sort_keys=True)) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_m2m( + client_id="__MUST_BE_END_USER_SUPPLIED__", + client_secret="__MUST_BE_END_USER_SUPPLIED__", + profile_name="my-example-name-m2m-with-custom-storage", + save_state_to_storage=True, + storage_provider=DemoStorageProvider(), + ) + + # Explicit login is not required for M2M client use. The above sufficient. + # plsdk_auth.user_login() + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_authcode__with_browser.py b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_authcode__with_browser.py new file mode 100644 index 000000000..d26478a8e --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_authcode__with_browser.py @@ -0,0 +1,104 @@ +import json +import logging +import os +import pathlib +import stat + +import planet + +from planet_auth import ObjectStorageProvider, ObjectStorageProvider_KeyType + +logging.basicConfig(level=logging.CRITICAL) + + +class DemoStorageProvider(ObjectStorageProvider): + """ + Simple demo custom storage provider that uses + ~/.planet-demo as a storage home for saving object. + + As a practical matter, ObjectStorageProvider_KeyType is defined + to be pathlib.Path, and we leverage that in this example. + But, storage providers are not required to use the local file + system to store objects. + """ + + def __init__(self): + self._demo_storage_root = pathlib.Path.home() / ".planet-demo" + + def load_obj(self, key: ObjectStorageProvider_KeyType) -> dict: + demo_obj_filepath = self._demo_obj_filepath(key) + return self._load_file(file_path=demo_obj_filepath) + + def save_obj(self, key: ObjectStorageProvider_KeyType, data: dict) -> None: + demo_obj_filepath = self._demo_obj_filepath(key) + self._save_file(file_path=demo_obj_filepath, data=data) + + def obj_exists(self, key: ObjectStorageProvider_KeyType) -> bool: + demo_obj_filepath = self._demo_obj_filepath(key) + return demo_obj_filepath.exists() + + def obj_rename(self, src: ObjectStorageProvider_KeyType, dst: ObjectStorageProvider_KeyType) -> None: + src_filepath = self._demo_obj_filepath(src) + dst_filepath = self._demo_obj_filepath(dst) + src_filepath.rename(dst_filepath) + + def _demo_obj_filepath(self, obj_key): + if obj_key.is_absolute(): + obj_path = self._demo_storage_root / obj_key.relative_to("/") + else: + obj_path = self._demo_storage_root / obj_key + return obj_path + + @staticmethod + def _load_file(file_path: pathlib.Path) -> dict: + logging.debug(msg="Loading JSON data from file {}".format(file_path)) + with open(file_path, mode="r", encoding="UTF-8") as file_r: + return json.load(file_r) + + @staticmethod + def _save_file(file_path: pathlib.Path, data: dict): + file_path.parent.mkdir(parents=True, exist_ok=True) + logging.debug(msg="Writing JSON data to file {}".format(file_path)) + with open(file_path, mode="w", encoding="UTF-8") as file_w: + os.chmod(file_path, stat.S_IREAD | stat.S_IWRITE) + _no_none_data = { + key: value + for key, value in data.items() if value is not None + } + file_w.write(json.dumps(_no_none_data, indent=2, sort_keys=True)) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_user_auth_code( + client_id="__MUST_BE_APP_DEVELOPER_SUPPLIED__", + requested_scopes=[ + # Request access to Planet APIs + planet.PlanetOAuthScopes.PLANET, + # Request a refresh token so repeated browser logins are not required + planet.PlanetOAuthScopes.OFFLINE_ACCESS, + ], + callback_url="http://localhost:8080", + profile_name="my-example-name-auth-code-with-custom-storage", + save_state_to_storage=True, + storage_provider=DemoStorageProvider(), + ) + + # In contrast to an in-memory only application that must initialize a login every + # time, an app with persistent storage can skip this when it is not needed. + if not plsdk_auth.is_initialized(): + plsdk_auth.user_login(allow_open_browser=True) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_devicecode__external_browser.py b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_devicecode__external_browser.py new file mode 100644 index 000000000..6bdfb2eb8 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_devicecode__external_browser.py @@ -0,0 +1,141 @@ +import json +import logging +import os +import pathlib +import stat + +import planet + +from planet_auth import ObjectStorageProvider, ObjectStorageProvider_KeyType + +logging.basicConfig(level=logging.CRITICAL) + + +class DemoStorageProvider(ObjectStorageProvider): + """ + Simple demo custom storage provider that uses + ~/.planet-demo as a storage home for saving object. + + As a practical matter, ObjectStorageProvider_KeyType is defined + to be pathlib.Path, and we leverage that in this example. + But, storage providers are not required to use the local file + system to store objects. + """ + + def __init__(self): + self._demo_storage_root = pathlib.Path.home() / ".planet-demo" + + def load_obj(self, key: ObjectStorageProvider_KeyType) -> dict: + demo_obj_filepath = self._demo_obj_filepath(key) + return self._load_file(file_path=demo_obj_filepath) + + def save_obj(self, key: ObjectStorageProvider_KeyType, data: dict) -> None: + demo_obj_filepath = self._demo_obj_filepath(key) + self._save_file(file_path=demo_obj_filepath, data=data) + + def obj_exists(self, key: ObjectStorageProvider_KeyType) -> bool: + demo_obj_filepath = self._demo_obj_filepath(key) + return demo_obj_filepath.exists() + + def obj_rename(self, src: ObjectStorageProvider_KeyType, dst: ObjectStorageProvider_KeyType) -> None: + src_filepath = self._demo_obj_filepath(src) + dst_filepath = self._demo_obj_filepath(dst) + src_filepath.rename(dst_filepath) + + def _demo_obj_filepath(self, obj_key): + if obj_key.is_absolute(): + obj_path = self._demo_storage_root / obj_key.relative_to("/") + else: + obj_path = self._demo_storage_root / obj_key + return obj_path + + @staticmethod + def _load_file(file_path: pathlib.Path) -> dict: + logging.debug(msg="Loading JSON data from file {}".format(file_path)) + with open(file_path, mode="r", encoding="UTF-8") as file_r: + return json.load(file_r) + + @staticmethod + def _save_file(file_path: pathlib.Path, data: dict): + file_path.parent.mkdir(parents=True, exist_ok=True) + logging.debug(msg="Writing JSON data to file {}".format(file_path)) + with open(file_path, mode="w", encoding="UTF-8") as file_w: + os.chmod(file_path, stat.S_IREAD | stat.S_IWRITE) + _no_none_data = { + key: value + for key, value in data.items() if value is not None + } + file_w.write(json.dumps(_no_none_data, indent=2, sort_keys=True)) + + +def initialize_user_session(plsdk_auth): + # Example of initiating a user session where the app is 100% + # responsible for the user experience. + + # 1. Initiate the login + login_initialization_info = plsdk_auth.device_user_login_initiate() + + # 2. Display necessary instructions to the user. + # + # "verification_uri" and "user_code" are required under RFC 8628. + # "verification_uri_complete" is optional under the RFC. + # + # If the user is expected to type in the URL, verification_uri will be + # shorter. If the URL may be presented in a clickable means (such as a + # link, button, or QR code) the verification_uri_complete may offer a + # better user experience. + verification_uri_complete = login_initialization_info.get( + "verification_uri_complete") + verification_uri = login_initialization_info.get("verification_uri") + user_code = login_initialization_info.get("user_code") + + print("Please activate your client.") + if verification_uri_complete: + print(f"Visit the activation site:\n" + f"\n\t{verification_uri_complete}\n" + f"\nand confirm the authorization code:\n" + f"\n\t{user_code}\n") + else: + print(f"Visit the activation site:\n" + f"\n\t{verification_uri}\n" + f"\nand enter the authorization code:\n" + f"\n\t{user_code}\n") + + # 3. Return control to the SDK. This will block until the user + # completes login. + plsdk_auth.device_user_login_complete(login_initialization_info) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_user_device_code( + client_id="__MUST_BE_APP_DEVELOPER_SUPPLIED__", + requested_scopes=[ + # Request access to Planet APIs + planet.PlanetOAuthScopes.PLANET, + # Request a refresh token so repeated browser logins are not required + planet.PlanetOAuthScopes.OFFLINE_ACCESS, + ], + profile_name="my-example-name-device-code-with-custom-storage", + save_state_to_storage=True, + storage_provider=DemoStorageProvider(), + ) + + # In contrast to an in-memory only application that must initialize a login every + # time, an app with persistent storage can skip this when it is not needed. + if not plsdk_auth.is_initialized(): + initialize_user_session(plsdk_auth) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py b/examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py new file mode 100644 index 000000000..7efb7659a --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py @@ -0,0 +1,26 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Create an auth context with the specified API key + plsdk_auth = planet.Auth.from_key( + key="__PLANET_API_KEY_MUST_BE_END_USER_SUPPLIED__") + + # Explicit login is not required for API key use. The above sufficient. + # plsdk_auth.user_login() + + # Create a Planet SDK object that uses the loaded auth session + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py new file mode 100644 index 000000000..517e186a7 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py @@ -0,0 +1,31 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_m2m( + client_id="__MUST_BE_END_USER_SUPPLIED__", + client_secret="__MUST_BE_END_USER_SUPPLIED__", + save_state_to_storage=False, + ) + + # Explicit login is not required for M2M client use. The above is sufficient. + # plsdk_auth.user_login() + + # Create a Planet SDK object that uses the loaded auth session/ + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py new file mode 100644 index 000000000..312d1136d --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py @@ -0,0 +1,38 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_user_auth_code( + client_id="__MUST_BE_APP_DEVELOPER_SUPPLIED__", + requested_scopes=[ + # Request access to Planet APIs + planet.PlanetOAuthScopes.PLANET, + # Request a refresh token so repeated browser logins are not required + planet.PlanetOAuthScopes.OFFLINE_ACCESS, + ], + callback_url="http://localhost:8080", + save_state_to_storage=False, + ) + + # An application with no persistent storage must + # initialize a login every time. This is not smooth user experience. + plsdk_auth.user_login(allow_open_browser=True) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py new file mode 100644 index 000000000..0f332cfa9 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py @@ -0,0 +1,75 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def initialize_user_session(plsdk_auth): + # Example of initiating a user session where the app is 100% + # responsible for the user experience. + + # 1. Initiate the login + login_initialization_info = plsdk_auth.device_user_login_initiate() + + # 2. Display necessary instructions to the user. + # + # "verification_uri" and "user_code" are required under RFC 8628. + # "verification_uri_complete" is optional under the RFC. + # + # If the user is expected to type in the URL, verification_uri will be + # shorter. If the URL may be presented in a clickable means (such as a + # link, button, or QR code) the verification_uri_complete may offer a + # better user experience. + verification_uri_complete = login_initialization_info.get( + "verification_uri_complete") + verification_uri = login_initialization_info.get("verification_uri") + user_code = login_initialization_info.get("user_code") + + print("Please activate your client.") + if verification_uri_complete: + print(f"Visit the activation site:\n" + f"\n\t{verification_uri_complete}\n" + f"\nand confirm the authorization code:\n" + f"\n\t{user_code}\n") + else: + print(f"Visit the activation site:\n" + f"\n\t{verification_uri}\n" + f"\nand enter the authorization code:\n" + f"\n\t{user_code}\n") + + # 3. Return control to the SDK. This will block until the user + # completes login. + plsdk_auth.device_user_login_complete(login_initialization_info) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_user_device_code( + client_id="__MUST_BE_APP_DEVELOPER_SUPPLIED__", + requested_scopes=[ + # Request access to Planet APIs + planet.PlanetOAuthScopes.PLANET, + # Request a refresh token so repeated browser logins are not required + planet.PlanetOAuthScopes.OFFLINE_ACCESS, + ], + save_state_to_storage=False, + ) + + # An application with no persistent storage must initialize a login every + # time. This is not smooth user experience. + initialize_user_session(plsdk_auth) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__api_key.py b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__api_key.py new file mode 100644 index 000000000..033a9e530 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__api_key.py @@ -0,0 +1,2 @@ +# No example of this use case provided at this time. +# The use of M2M OAuth sessions is encouraged over the use of API keys. diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py new file mode 100644 index 000000000..61d631983 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py @@ -0,0 +1,32 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_m2m( + client_id="__MUST_BE_END_USER_SUPPLIED__", + client_secret="__MUST_BE_END_USER_SUPPLIED__", + profile_name="my-name-for-example-m2m-auth-profile", + save_state_to_storage=True, + ) + + # Explicit login is not required for M2M client use. The above is sufficient. + # plsdk_auth.user_login() + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py new file mode 100644 index 000000000..5be309780 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py @@ -0,0 +1,40 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_user_auth_code( + client_id="__MUST_BE_APP_DEVELOPER_SUPPLIED__", + requested_scopes=[ + # Request access to Planet APIs + planet.PlanetOAuthScopes.PLANET, + # Request a refresh token so repeated browser logins are not required + planet.PlanetOAuthScopes.OFFLINE_ACCESS, + ], + callback_url="http://localhost:8080", + profile_name="my-name-for-example-user-session-with-local-browser", + save_state_to_storage=True, + ) + + # In contrast to an in-memory only application that must initialize a login every + # time, an app with persistent storage can skip this when it is not needed. + if not plsdk_auth.is_initialized(): + plsdk_auth.user_login(allow_open_browser=True) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py new file mode 100644 index 000000000..d0ca115ad --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py @@ -0,0 +1,77 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def initialize_user_session(plsdk_auth): + # Example of initiating a user session where the app is 100% + # responsible for the user experience. + + # 1. Initiate the login + login_initialization_info = plsdk_auth.device_user_login_initiate() + + # 2. Display necessary instructions to the user. + # + # "verification_uri" and "user_code" are required under RFC 8628. + # "verification_uri_complete" is optional under the RFC. + # + # If the user is expected to type in the URL, verification_uri will be + # shorter. If the URL may be presented in a clickable means (such as a + # link, button, or QR code) the verification_uri_complete may offer a + # better user experience. + verification_uri_complete = login_initialization_info.get( + "verification_uri_complete") + verification_uri = login_initialization_info.get("verification_uri") + user_code = login_initialization_info.get("user_code") + + print("Please activate your client.") + if verification_uri_complete: + print(f"Visit the activation site:\n" + f"\n\t{verification_uri_complete}\n" + f"\nand confirm the authorization code:\n" + f"\n\t{user_code}\n") + else: + print(f"Visit the activation site:\n" + f"\n\t{verification_uri}\n" + f"\nand enter the authorization code:\n" + f"\n\t{user_code}\n") + + # 3. Return control to the SDK. This will block until the user + # completes login. + plsdk_auth.device_user_login_complete(login_initialization_info) + + +def example_main(): + # Create an auth context with a client ID that + # is unique to this application. + plsdk_auth = planet.Auth.from_oauth_user_device_code( + client_id="__MUST_BE_APP_DEVELOPER_SUPPLIED__", + requested_scopes=[ + # Request access to Planet APIs + planet.PlanetOAuthScopes.PLANET, + # Request a refresh token so repeated browser logins are not required + planet.PlanetOAuthScopes.OFFLINE_ACCESS, + ], + profile_name="my-name-example-user-auth-with-external-browser", + save_state_to_storage=True, + ) + + # In contrast to an in-memory only application that must initialize a login every + # time, an app with persistent storage can skip this when it is not needed. + if not plsdk_auth.is_initialized(): + initialize_user_session(plsdk_auth) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py b/examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py new file mode 100644 index 000000000..a14257592 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py @@ -0,0 +1,26 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Create an auth context with a Planet API key loaded from the + # specified file that was created with older versions of the SDK + plsdk_auth = planet.Auth.from_file("legacy_api_key_file.json") + + # Explicit login is not required for API key use. The above sufficient. + # plsdk_auth.user_login() + + # Create a Planet SDK object that uses the loaded auth session + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/cli_managed_auth_state__explicit.py b/examples/auth-session-management/cli_managed_auth_state__explicit.py new file mode 100644 index 000000000..2a8a68f08 --- /dev/null +++ b/examples/auth-session-management/cli_managed_auth_state__explicit.py @@ -0,0 +1,32 @@ +import json +import logging +import planet +import sys + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Explicitly load the user's auth session from disk. The user must have + # invoked `planet auth login` before this program is run, or the API calls + # will fail. This will not initialize a new session. + plsdk_auth = planet.Auth.from_user_default_session() + + if not plsdk_auth.is_initialized(): + print( + "Login required. Execute the following command:\n\n\tplanet auth login\n" + ) + sys.exit(99) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/cli_managed_auth_state__implicit.py b/examples/auth-session-management/cli_managed_auth_state__implicit.py new file mode 100644 index 000000000..19b73f608 --- /dev/null +++ b/examples/auth-session-management/cli_managed_auth_state__implicit.py @@ -0,0 +1,21 @@ +import json +import logging +import planet + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # By default, the Planet SDK will be instantiated with the default auth + # session configured by `planet auth` and saved to disk. This default + # initialization will also take information from environment variables. + pl = planet.Planet() + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py b/examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py new file mode 100644 index 000000000..f1583ac91 --- /dev/null +++ b/examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py @@ -0,0 +1,38 @@ +import json +import logging +import planet +import sys + +logging.basicConfig(level=logging.CRITICAL) + + +def example_main(): + # Explicitly load the user's auth session from disk for a specific + # authentication session ("profile"). The user must have invoked + # `planet auth login` before this program is run or the program + # must have performed a login() elsewhere prior to this example. + # If this has not been done, the API calls will fail. This example + # does not initialize a new session. + plsdk_auth = planet.Auth.from_profile( + profile_name="my-cli-managed-profile") + + # If required, how to login depends on what is configured in the specific + # profile. See other examples for login calls. + if not plsdk_auth.is_initialized(): + print( + "Login required. Execute the following command:\n\n\tplanet auth login --auth-profile my-cli-managed-profile\n" + ) + sys.exit(99) + + # Create a Planet SDK object that uses the loaded auth session. + sess = planet.Session(plsdk_auth) + pl = planet.Planet(sess) + + # Use the SDK to call Planet APIs. + # Refreshing access tokens will be managed automatically by the SDK. + for item in pl.data.list_searches(): + print(json.dumps(item, indent=2, sort_keys=True)) + + +if __name__ == '__main__': + example_main() diff --git a/examples/auth-session-management/legacy_api_key_file.json b/examples/auth-session-management/legacy_api_key_file.json new file mode 100644 index 000000000..548a94c9d --- /dev/null +++ b/examples/auth-session-management/legacy_api_key_file.json @@ -0,0 +1,3 @@ +{ + "key": "__PLANET_API_KEY_MUST_BE_END_USER_SUPPLIED__" +} diff --git a/mkdocs.yml b/mkdocs.yml index 2c38a9769..bbb89a6bd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,6 +49,9 @@ extra_css: plugins: - search + - macros: + include_dir: 'examples' + on_error_fail: true - mkdocstrings: handlers: python: @@ -80,6 +83,7 @@ nav: - cli/cli-reference.md - "Python": - python/sdk-guide.md + - python/sdk-client-auth.md - python/async-sdk-guide.md - python/sdk-reference.md - "Resources": diff --git a/noxfile.py b/noxfile.py index 620014b82..46b70b6a4 100644 --- a/noxfile.py +++ b/noxfile.py @@ -55,6 +55,7 @@ def test(session): '-v', '-Werror', '-Wignore::DeprecationWarning:tqdm.std', + '-Wignore::PendingDeprecationWarning:planet.auth', *options) diff --git a/planet/__init__.py b/planet/__init__.py index fe2729fe4..8858af955 100644 --- a/planet/__init__.py +++ b/planet/__init__.py @@ -16,12 +16,14 @@ from . import data_filter, order_request, reporting, subscription_request from .__version__ import __version__ # NOQA from .auth import Auth +from .auth_builtins import PlanetOAuthScopes from .clients import DataClient, FeaturesClient, OrdersClient, SubscriptionsClient # NOQA from .io import collect from .sync import Planet __all__ = [ 'Auth', + 'PlanetOAuthScopes', 'collect', 'DataClient', 'data_filter', diff --git a/planet/auth.py b/planet/auth.py index 57a4b6ce5..2a79da8d7 100644 --- a/planet/auth.py +++ b/planet/auth.py @@ -1,5 +1,5 @@ # Copyright 2020 Planet Labs, Inc. -# Copyright 2022 Planet Labs PBC. +# Copyright 2022, 2024, 2025 Planet Labs PBC. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,93 +15,363 @@ """Manage authentication with Planet APIs""" from __future__ import annotations # https://stackoverflow.com/a/33533514 import abc -import json -import logging +import copy import os import pathlib -import stat import typing -from typing import Optional - +import warnings import httpx -import jwt +from typing import List + +import planet_auth +import planet_auth_utils + +from .constants import SECRET_FILE_PATH +from .auth_builtins import _ProductionEnv, _OIDC_AUTH_CLIENT_CONFIG__USER_SKEL, _OIDC_AUTH_CLIENT_CONFIG__M2M_SKEL +from .exceptions import PlanetError + + +class Auth(abc.ABC, httpx.Auth): + """ + Handle authentication information for use with Planet APIs. + Static constructor methods should be used to create an auth context + that can be used by Planet API client modules to authenticate + requests made to the Planet service. + """ + + @staticmethod + def _normalize_profile_name(profile_name: str): + if profile_name.find(os.sep) != -1: + raise ValueError(f"Profile names cannot contain '{os.sep}'") + return profile_name.lower() + + @staticmethod + def from_user_default_session() -> Auth: + """ + Create authentication from user defaults. + + This method should be used when an application wants to defer + auth profile management to the user and the `planet auth` CLI + command entirely. + + Users may use the `planet auth login` command to initialize + and manage sessions. + + Defaults take into account environment variables (highest priority), + user configuration saved to `~/.planet.json` and `~/.planet/ + (next priority), and built-in defaults (lowest priority). -from . import http -from .constants import ENV_API_KEY, PLANET_BASE_URL, SECRET_FILE_PATH -from .exceptions import AuthException + This method does not support the use a custom storage provider. + The session must be initialized entirely in memory (e.g. through + environment variables), or from on disk CLI managed settings. -LOGGER = logging.getLogger(__name__) + Environment Variables: + PL_AUTH_CLIENT_ID: Specify an OAuth2 M2M client ID + PL_AUTH_CLIENT_SECRET: Specify an OAuth2 M2M client secret + PL_AUTH_API_KEY: Specify a legacy Planet API key + PL_AUTH_PROFILE: Specify a custom planet_auth library auth + client profile (Advanced use cases) + """ + return _PLAuthLibAuth(plauth=planet_auth_utils.PlanetAuthFactory. + initialize_auth_client_context()) -BASE_URL = f'{PLANET_BASE_URL}/v0/auth' + @staticmethod + def from_profile(profile_name: str) -> Auth: + """ + Create authentication for a user whose initialized login information + has been saved to `~/.planet.json` and `~/.planet/`. -AuthType = httpx.Auth + A user should perform a login to initialize this session out-of-band + using the command `planet auth login`. + To initialize this session programmatically without the CLI, + you must complete an OAuth2 user login flow with one of the login + methods. -class Auth(metaclass=abc.ABCMeta): - """Handle authentication information for use with Planet APIs.""" + This method does not support the use a custom storage provider. + """ + pl_authlib_context = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context( + auth_profile_opt=profile_name) + return _PLAuthLibAuth(plauth=pl_authlib_context) + # TODO: add support for confidential clients @staticmethod - def from_key(key: str) -> AuthType: + def from_oauth_user_auth_code( + client_id: str, + callback_url: str, + requested_scopes: typing.Optional[List[str]] = None, + save_state_to_storage: bool = True, + profile_name: typing.Optional[str] = None, + storage_provider: typing.Optional[ + planet_auth.ObjectStorageProvider] = None, + ) -> Auth: + """ + Create authentication for the specified registered client + application. + + Developers of applications must register clients with + Planet, and will be issued a Client ID as part of that process. + Developers should register a client for each distinct application so + that end-users may discretely manage applications permitted to access + Planet APIs on their behalf. + + This method does not perform a user login to initialize a session. + If not initialized out of band using the CLI, sessions must be initialized + with the user_login() before API calls may be made. + + Parameters: + client_id: Client ID + requested_scopes: List of requested OAuth2 scopes + callback_url: Client callback URL + profile_name: User friendly name to use when saving the configuration + to storage per the `save_state_to_storage` flag. The profile name + will be normalized to a file system compatible identifier, + regardless of storage provider. + save_state_to_storage: Boolean controlling whether login sessions + should be saved to storage. When the default storage provider is + used, they will be stored in a way that is compatible with + the `planet` CLI. + storage_provider: A custom storage provider to save session state + for the application. + """ + plauth_config_dict = copy.deepcopy(_OIDC_AUTH_CLIENT_CONFIG__USER_SKEL) + plauth_config_dict["client_type"] = "oidc_auth_code" + plauth_config_dict["client_id"] = client_id + if requested_scopes: + plauth_config_dict["scopes"] = requested_scopes + plauth_config_dict["redirect_uri"] = callback_url + + if not profile_name: + profile_name = client_id + normalized_profile_name = Auth._normalize_profile_name(profile_name) + + pl_authlib_context = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context_from_custom_config( + client_config=plauth_config_dict, + initial_token_data={}, + save_token_file=save_state_to_storage, + profile_name=normalized_profile_name, + save_profile_config=save_state_to_storage, + storage_provider=storage_provider, + ) + + return Auth._from_plauth(pl_authlib_context) + + # TODO: add support for confidential clients + @staticmethod + def from_oauth_user_device_code( + client_id: str, + requested_scopes: typing.Optional[List[str]] = None, + save_state_to_storage: bool = True, + profile_name: typing.Optional[str] = None, + storage_provider: typing.Optional[ + planet_auth.ObjectStorageProvider] = None + ) -> Auth: + """ + Create authentication for the specified registered client + application. + + Developers of applications must register clients with + Planet, and will be issued a Client ID as part of that process. + Developers should register a client for each distinct application so + that end-users may discretely manage applications permitted to access + Planet APIs on their behalf. + + This method does not perform a user login to initialize a session. + + This method does not perform a user login to initialize a session. + If not initialized out of band using the CLI, sessions must be initialized + with the device login methods before API calls may be made. + + Parameters: + client_id: Client ID + requested_scopes: List of requested OAuth2 scopes + profile_name: User friendly name to use when saving the configuration + to storage per the `save_state_to_storage` flag. The profile name + will be normalized to file system compatible identifier, regardless + of the storage provider being used. + save_state_to_storage: Boolean controlling whether login sessions + should be saved to storage. When the default storage provider is + used, they will be stored in a way that is compatible with + the `planet` CLI. + storage_provider: A custom storage provider to save session state + for the application. + """ + plauth_config_dict = copy.deepcopy(_OIDC_AUTH_CLIENT_CONFIG__USER_SKEL) + plauth_config_dict["client_type"] = "oidc_device_code" + plauth_config_dict["client_id"] = client_id + if requested_scopes: + plauth_config_dict["scopes"] = requested_scopes + + if not profile_name: + profile_name = client_id + normalized_profile_name = Auth._normalize_profile_name(profile_name) + + pl_authlib_context = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context_from_custom_config( + client_config=plauth_config_dict, + initial_token_data={}, + save_token_file=save_state_to_storage, + profile_name=normalized_profile_name, + save_profile_config=save_state_to_storage, + storage_provider=storage_provider, + ) + + return Auth._from_plauth(pl_authlib_context) + + @staticmethod + def from_oauth_m2m( + client_id: str, + client_secret: str, + requested_scopes: typing.Optional[List[str]] = None, + save_state_to_storage: bool = True, + profile_name: typing.Optional[str] = None, + storage_provider: typing.Optional[ + planet_auth.ObjectStorageProvider] = None, + ) -> Auth: + """ + Create authentication from the specified OAuth2 service account + client ID and secret. + + Parameters: + client_id: Planet service account client ID. + client_secret: Planet service account client secret. + requested_scopes: List of requested OAuth2 scopes + profile_name: User friendly name to use when saving the configuration + to storage per the `save_state_to_storage` flag. The profile name + will be normalized to a file system compatible identifier regardless + of the storage provider being used. + save_state_to_storage: Boolean controlling whether login sessions + should be saved to storage. When the default storage provider is + used, they will be stored in a way that is compatible with + the `planet` CLI. + storage_provider: A custom storage provider to save session state + for the application. + """ + plauth_config_dict = copy.deepcopy(_OIDC_AUTH_CLIENT_CONFIG__M2M_SKEL) + plauth_config_dict["client_id"] = client_id + plauth_config_dict["client_secret"] = client_secret + if requested_scopes: + plauth_config_dict["scopes"] = requested_scopes + + if not profile_name: + profile_name = client_id + normalized_profile_name = Auth._normalize_profile_name(profile_name) + + pl_authlib_context = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context_from_custom_config( + client_config=plauth_config_dict, + initial_token_data={}, + save_token_file=save_state_to_storage, + profile_name=normalized_profile_name, + save_profile_config=save_state_to_storage, + storage_provider=storage_provider, + ) + return Auth._from_plauth(pl_authlib_context) + + @staticmethod + def _from_plauth(pl_authlib_context: planet_auth.Auth) -> Auth: + """ + Create authentication from the provided Planet Auth Library + Authentication Context. Generally, applications will want to use one + of the Auth Library factory helpers to construct this context (See the + factory class). + + This method is intended for advanced use cases where the developer + has their own client ID registered, and is familiar with the + Planet Auth Library. (Registering client IDs is a feature of the + Planet Platform not yet released to the public as of January 2025.) + """ + return _PLAuthLibAuth(plauth=pl_authlib_context) + + @staticmethod + def from_key(key: typing.Optional[str]) -> Auth: """Obtain authentication from api key. Parameters: key: Planet API key """ - auth = APIKeyAuth(key=key) - LOGGER.debug('Auth obtained from api key.') - return auth + warnings.warn( + "Planet API keys will be deprecated for most use cases." + " Initialize an OAuth client, or create an OAuth service account." + " Proceeding for now.", + PendingDeprecationWarning) + if not key: + raise APIKeyAuthException('API key cannot be empty.') + + pl_authlib_context = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context( + auth_api_key_opt=key, + save_token_file=False, + ) + return _PLAuthLibAuth(plauth=pl_authlib_context) @staticmethod def from_file( - filename: Optional[typing.Union[str, - pathlib.Path]] = None) -> AuthType: + filename: typing.Optional[typing.Union[str, + pathlib.Path]] = None) -> Auth: """Create authentication from secret file. - The secret file is named `.planet.json` and is stored in the user + The default secret file is named `.planet.json` and is stored in the user directory. The file has a special format and should have been created with `Auth.write()`. + Pending deprecation: + OAuth2, which should replace API keys in most cases does not have + a direct replacement for "from_file()" in many cases. + The format of the `.planet.json file` is changing with the + migration of Planet APIs to OAuth2. With that, this method is + also being deprecated as a means to bootstrap auth configuration + with a simple API key. For the time being this method will still + be supported, but this method will fail if the file is present + with only new configuration fields, and lacks the legacy API key + field. + Parameters: filename: Alternate path for the planet secret file. """ - filename = filename or SECRET_FILE_PATH - - try: - secrets = _SecretFile(filename).read() - auth = APIKeyAuth.from_dict(secrets) - except FileNotFoundError: - raise AuthException(f'File {filename} does not exist.') - except (KeyError, json.decoder.JSONDecodeError): - raise AuthException(f'File {filename} is not the correct format.') - - LOGGER.debug(f'Auth read from secret file {filename}.') - return auth + warnings.warn("Auth.from_file() will be deprecated.", + PendingDeprecationWarning) + plauth_config = { + **_ProductionEnv.LEGACY_AUTH_AUTHORITY, + "client_type": planet_auth.PlanetLegacyAuthClientConfig.meta().get( + "client_type"), + } + pl_authlib_context = planet_auth.Auth.initialize_from_config_dict( + client_config=plauth_config, + token_file=filename or SECRET_FILE_PATH) + # planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context( + # auth_profile_opt=_BuiltinConfigurationProvider.BUILTIN_PROFILE_NAME_LEGACY, + # token_file_opt=filename or SECRET_FILE_PATH + # ) + return _PLAuthLibAuth(plauth=pl_authlib_context) @staticmethod - def from_env(variable_name: Optional[str] = None) -> AuthType: - """Create authentication from environment variable. + def from_env(variable_name: typing.Optional[str] = None) -> Auth: + """Create authentication from environment variables. Reads the `PL_API_KEY` environment variable + Pending Deprecation: + This method is pending deprecation. The method `from_defaults()` + considers environment variables and configuration files through + the planet_auth and planet_auth_utils libraries, and works with + legacy API keys, OAuth2 M2M clients, OAuth2 interactive profiles. + This method should be used in most cases as a replacement. + Parameters: variable_name: Alternate environment variable. """ - variable_name = variable_name or ENV_API_KEY - api_key = os.getenv(variable_name, '') - try: - auth = APIKeyAuth(api_key) - LOGGER.debug(f'Auth set from environment variable {variable_name}') - except APIKeyAuthException: - raise AuthException( - f'Environment variable {variable_name} either does not exist ' - 'or is empty.') - return auth + warnings.warn( + "from_env() will be deprecated. Use from_defaults() in most" + " cases, which will consider both environment variables and user" + " configuration files.", + PendingDeprecationWarning) + variable_name = variable_name or planet_auth_utils.EnvironmentVariables.AUTH_API_KEY + api_key = os.getenv(variable_name, None) + return Auth.from_key(api_key) @staticmethod def from_login(email: str, password: str, - base_url: Optional[str] = None) -> AuthType: + base_url: typing.Optional[str] = None) -> Auth: """Create authentication from login email and password. Note: To keep your password secure, the use of `getpass` is @@ -113,159 +383,104 @@ def from_login(email: str, base_url: The base URL to use. Defaults to production authentication API base url. """ - cl = AuthClient(base_url=base_url) - auth_data = cl.login(email, password) - - api_key = auth_data['api_key'] - auth = APIKeyAuth(api_key) - LOGGER.debug('Auth set from login email and password') - return auth + raise DeprecationWarning( + "Auth.from_login() has been deprecated. Use Auth.from_user_session()." + ) @classmethod - @abc.abstractmethod - def from_dict(cls, data: dict) -> AuthType: - pass + def from_dict(cls, data: dict) -> Auth: + raise DeprecationWarning("Auth.from_dict() has been deprecated.") - @property - @abc.abstractmethod - def value(self): - pass - - @abc.abstractmethod def to_dict(self) -> dict: - pass + raise DeprecationWarning("Auth.to_dict() has been deprecated.") def store(self, - filename: Optional[typing.Union[str, pathlib.Path]] = None): - """Store authentication information in secret file. + filename: typing.Optional[typing.Union[str, + pathlib.Path]] = None): + raise DeprecationWarning("Auth.store() has been deprecated.") - Parameters: - filename: Alternate path for the planet secret file. - """ - filename = filename or SECRET_FILE_PATH - secret_file = _SecretFile(filename) - secret_file.write(self.to_dict()) + @property + def value(self): + raise DeprecationWarning("Auth.value has been deprecated.") + @abc.abstractmethod + def user_login( + self, + allow_open_browser: typing.Optional[bool] = False, + allow_tty_prompt: typing.Optional[bool] = False, + ): + """ + Perform an interactive login. User interaction will be via the TTY + and/or a local web browser, with the details dependent on the + client auth configuration. -class AuthClient: + :param allow_open_browser: + :param allow_tty_prompt: + """ - def __init__(self, base_url: Optional[str] = None): + @abc.abstractmethod + def device_user_login_initiate(self) -> dict: """ - Parameters: - base_url: The base URL to use. Defaults to production - authentication API base url. + Initiate a user login that uses the OAuth2 Device Code Flow for applications + that cannot operate a browser locally. The returned dictionary should be used + to prompt the user to complete the process, and will conform to RFC 8628. """ - self._base_url = base_url or BASE_URL - if self._base_url.endswith('/'): - self._base_url = self._base_url[:-1] - - def login(self, email: str, password: str) -> dict: - """Login using email identity and credentials. - - Note: To keep your password secure, the use of `getpass` is - recommended. - - Parameters: - email: Planet account email address. - password: Planet account password. - Returns: - A JSON object containing an `api_key` property with the user's - API_KEY. + @abc.abstractmethod + def device_user_login_complete(self, login_initialization_info: dict): + """ + Complete a user login that uses the OAuth2 Device Code Flow for applications + that was initiated by a call to `device_user_login_initiate()`. The structure + that was returned from `device_user_login_initiate()` should be passed + to this function unaltered after it has been used to prompt the user. """ - url = f'{self._base_url}/login' - data = {'email': email, 'password': password} - - sess = http.AuthSession() - resp = sess.request(url=url, method='POST', json=data) - return self.decode_response(resp) - @staticmethod - def decode_response(response): - """Decode the token JWT""" - token = response.json()['token'] - return jwt.decode(token, options={'verify_signature': False}) + @abc.abstractmethod + def is_initialized(self) -> bool: + """ + Check whether the user session has been initialized. For OAuth2 + user based sessions, this means that a login has been performed + or saved login session data has been located. For M2M and API Key + sessions, this should be true if keys or secrets have been + properly configured. + """ -class APIKeyAuthException(AuthException): +class APIKeyAuthException(PlanetError): """exceptions thrown by APIKeyAuth""" pass -class APIKeyAuth(httpx.BasicAuth, Auth): - """Planet API Key authentication.""" - DICT_KEY = 'key' - - def __init__(self, key: str): - """Initialize APIKeyAuth. - - Parameters: - key: API key. - - Raises: - APIKeyException: If API key is None or empty string. - """ - if not key: - raise APIKeyAuthException('API key cannot be empty.') - self._key = key - super().__init__(self._key, '') - - @classmethod - def from_dict(cls, data: dict) -> APIKeyAuth: - """Instantiate APIKeyAuth from a dict.""" - api_key = data[cls.DICT_KEY] - return cls(api_key) - - def to_dict(self): - """Represent APIKeyAuth as a dict.""" - return {self.DICT_KEY: self._key} - - @property - def value(self): - return self._key - - -class _SecretFile: - - def __init__(self, path: typing.Union[str, pathlib.Path]): - self.path = pathlib.Path(path) - - self.permissions = stat.S_IRUSR | stat.S_IWUSR # user rw - - # in sdk versions <=2.0.0, secret file was created with the wrong - # permissions, fix this automatically as well as catching the unlikely - # cases where the permissions get changed externally - self._enforce_permissions() +class _PLAuthLibAuth(Auth): + # The Planet Auth Library uses a "has a" authenticator pattern for its + # planet_auth.Auth context class. This SDK library employs a "is a" + # authenticator design pattern for users of its Auth context obtained + # from the constructors above. This class smooths over that design + # difference as we move to using the Planet Auth Library. + def __init__(self, plauth: planet_auth.Auth): + self._plauth = plauth - def write(self, contents: dict): - try: - secrets_to_write = self.read() - secrets_to_write.update(contents) - except (FileNotFoundError, KeyError, json.decoder.JSONDecodeError): - secrets_to_write = contents + def auth_flow(self, r: httpx._models.Request): + return self._plauth.request_authenticator().auth_flow(r) - self._write(secrets_to_write) + def user_login( + self, + allow_open_browser: typing.Optional[bool] = False, + allow_tty_prompt: typing.Optional[bool] = False, + ): + self._plauth.login( + allow_open_browser=allow_open_browser, + allow_tty_prompt=allow_tty_prompt, + ) - def _write(self, contents: dict): - LOGGER.debug(f'Writing to {self.path}') + def device_user_login_initiate(self) -> dict: + return self._plauth.device_login_initiate() - def opener(path, flags): - return os.open(path, flags, self.permissions) + def device_user_login_complete(self, login_initialization_info: dict): + return self._plauth.device_login_complete(login_initialization_info) - with open(self.path, 'w', opener=opener) as fp: - fp.write(json.dumps(contents)) + def is_initialized(self) -> bool: + return self._plauth.request_authenticator_is_ready() - def read(self) -> dict: - LOGGER.debug(f'Reading from {self.path}') - with open(self.path, 'r') as fp: - contents = json.loads(fp.read()) - return contents - def _enforce_permissions(self): - """if the file's permissions are not what they should be, fix them""" - if self.path.exists(): - # in octal, permissions is the last three bits of the mode - file_permissions = self.path.stat().st_mode & 0o777 - if file_permissions != self.permissions: - LOGGER.info('Fixing planet secret file permissions.') - self.path.chmod(self.permissions) +AuthType = Auth diff --git a/planet/auth_builtins.py b/planet/auth_builtins.py new file mode 100644 index 000000000..f322f362c --- /dev/null +++ b/planet/auth_builtins.py @@ -0,0 +1,165 @@ +# Copyright 2024-2025 Planet Labs PBC. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os +from typing import Dict, List, Optional +from planet_auth.logging.auth_logger import setStructuredLogging +from planet_auth_utils.builtins_provider import BuiltinConfigurationProviderInterface + +# Needs to be set at runtime (not necessarily at import time) for dependency +# injection to planet_auth_util +os.environ[ + "PL_AUTH_BUILTIN_CONFIG_PROVIDER"] = "planet.auth_builtins._BuiltinConfigurationProvider" + +# setPyLoggerForAuthLogger(logging.getLogger("planet_auth_sdk")) +setStructuredLogging(nested_key=None) + + +# No StrEnum in our lowest supported Python version +# class PlanetOAuthScopes(enum.StrEnum): +class PlanetOAuthScopes: + """ + Planet OAuth2 Scopes + """ + PLANET = "planet" + OFFLINE_ACCESS = "offline_access" + OPENID = "openid" + PROFILE = "profile" + EMAIL = "email" + + +class _ProductionEnv: + OAUTH_AUTHORITY_USER = { + "_comment": "OIDC/OAuth server used by Planet Public API endpoints", + "auth_server": "https://login.planet.com/", + "audiences": ["https://api.planet.com/"] + } + OAUTH_AUTHORITY_M2M = { + "_comment": "OIDC/OAuth server used by Planet Public API endpoints", + "auth_server": "https://services.sentinel-hub.com/auth/realms/main", + "audiences": ["https://api.planet.com/"] + } + LEGACY_AUTH_AUTHORITY = { + "_comment": "Planet legacy JWT auth server used by Planet Public API endpoints", + "legacy_auth_endpoint": "https://api.planet.com/v0/auth/login" + } + PUBLIC_OAUTH_AUTHORITIES = [ + OAUTH_AUTHORITY_USER, + OAUTH_AUTHORITY_M2M, + ] + + +_SDK_CLIENT_ID_PROD = "49lHVBYlXCdfIYqE1B9zeXt0iFHSXees" + +_OIDC_AUTH_CLIENT_CONFIG__USER_SKEL = { + **_ProductionEnv.OAUTH_AUTHORITY_USER, + "scopes": [ + PlanetOAuthScopes.PLANET, + PlanetOAuthScopes.OFFLINE_ACCESS, + # PlanetOAuthScopes.OPENID, + # PlanetOAuthScopes.PROFILE, + # PlanetOAuthScopes.EMAIL + ], + # "client_type": "oidc_device_code", # Must be provided when hydrating the SKEL + # "client_id": _SDK_CLIENT_ID_PROD, # Must be provided when hydrating the SKEL +} + +_OIDC_AUTH_CLIENT_CONFIG__SDK_PROD = { + # The well known OIDC client that is the Planet Python CLI. + # Developers should register their own clients so that users may + # manage grants for different applications. Registering applications + # also allows for application specific URLs or auth flow selection. + **_OIDC_AUTH_CLIENT_CONFIG__USER_SKEL, + "client_type": "oidc_device_code", + "client_id": _SDK_CLIENT_ID_PROD, + # FIXME: scopes currently from SKEL. + # It would be better to have per-client defaults and limits enforced by the auth server +} + +_OIDC_AUTH_CLIENT_CONFIG__M2M_SKEL = { + **_ProductionEnv.OAUTH_AUTHORITY_M2M, + "client_type": "oidc_client_credentials_secret", + # FIXME: we do not have scope or behavior parity between our M2M and our user OAuth authorities. + "scopes": [], + # "client_id": "__MUST_BE_USER_SUPPLIED__", + # "client_secret": "__MUST_BE_USER_SUPPLIED__", + # "scopes": ["planet"], + # "audiences": [""] +} + +_LEGACY_AUTH_CLIENT_CONFIG__PROD = { + **_ProductionEnv.LEGACY_AUTH_AUTHORITY, + "client_type": "planet_legacy", +} + + +class _BuiltinConfigurationProvider(BuiltinConfigurationProviderInterface): + """ + Concrete implementation of built-in client profiles for the planet_auth + library that pertain to the Planet Lab's cloud service. + """ + + # Real + # Using the client ID as a profile name might be nice, but is tricky... + # We normalize directory paths to lower case. The auth implementation uses + # mixed case ID strings. The odds of case normalized IDs colliding is low, + # but there is a bit of an off smell. + # BUILTIN_PROFILE_NAME_SDKCLI_CLIENT_ID = _SDK_CLIENT_ID_PROD + BUILTIN_PROFILE_NAME_PLANET_USER = "planet-user" + BUILTIN_PROFILE_NAME_PLANET_M2M = "planet-m2m" + BUILTIN_PROFILE_NAME_LEGACY = "legacy" + + # Aliases + # BUILTIN_PROFILE_ALIAS_PLANET_USER = "planet-user" + + _builtin_profile_auth_client_configs: Dict[str, dict] = { + # BUILTIN_PROFILE_NAME_SDKCLI_CLIENT_ID: _OIDC_AUTH_CLIENT_CONFIG__SDK_PROD, + BUILTIN_PROFILE_NAME_PLANET_USER: _OIDC_AUTH_CLIENT_CONFIG__SDK_PROD, + BUILTIN_PROFILE_NAME_PLANET_M2M: _OIDC_AUTH_CLIENT_CONFIG__M2M_SKEL, + BUILTIN_PROFILE_NAME_LEGACY: _LEGACY_AUTH_CLIENT_CONFIG__PROD, + } + + _builtin_profile_aliases: dict[str, str] = { + # BUILTIN_PROFILE_ALIAS_PLANET_USER: BUILTIN_PROFILE_NAME_SDKCLI_CLIENT_ID, + } + + _builtin_profile_default_by_client_type = { + "oidc_device_code": BUILTIN_PROFILE_NAME_PLANET_USER, + "oidc_auth_code": BUILTIN_PROFILE_NAME_PLANET_USER, + "oidc_client_credentials_secret": BUILTIN_PROFILE_NAME_PLANET_M2M, + "planet_legacy": BUILTIN_PROFILE_NAME_LEGACY, + } + + _builtin_trust_realms: Dict[str, Optional[List[dict]]] = { + "PRODUCTION": _ProductionEnv.PUBLIC_OAUTH_AUTHORITIES, + "CUSTOM": None, + } + + def builtin_client_authclient_config_dicts(self) -> Dict[str, dict]: + return self._builtin_profile_auth_client_configs + + def builtin_client_profile_aliases(self) -> Dict[str, str]: + return self._builtin_profile_aliases + + def builtin_default_profile_by_client_type(self) -> Dict[str, str]: + return self._builtin_profile_default_by_client_type + + def builtin_default_profile(self) -> str: + # return self.BUILTIN_PROFILE_NAME_DEFAULT + return self.BUILTIN_PROFILE_NAME_PLANET_USER + + def builtin_trust_environment_names(self) -> List[str]: + return list(_BuiltinConfigurationProvider._builtin_trust_realms.keys()) + + def builtin_trust_environments(self) -> Dict[str, Optional[List[dict]]]: + return _BuiltinConfigurationProvider._builtin_trust_realms diff --git a/planet/cli/auth.py b/planet/cli/auth.py index 060336697..4549e9a1c 100644 --- a/planet/cli/auth.py +++ b/planet/cli/auth.py @@ -1,4 +1,4 @@ -# Copyright 2022 Planet Labs PBC. +# Copyright 2022-2025 Planet Labs PBC. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,69 +13,43 @@ # limitations under the License. """Auth API CLI""" import logging -import os - import click - -import planet -from planet.constants import ENV_API_KEY -from .cmds import translate_exceptions +import planet_auth_utils LOGGER = logging.getLogger(__name__) -@click.group() # type: ignore -@click.pass_context -@click.option('-u', - '--base-url', - default=None, - help='Assign custom base Auth API URL.') -def auth(ctx, base_url): - """Commands for working with Planet authentication""" - ctx.obj['BASE_URL'] = base_url - - -@auth.command() # type: ignore +@click.group("auth") # type: ignore @click.pass_context -@translate_exceptions -@click.option( - '--email', - default=None, - prompt=True, - help=('The email address associated with your Planet credentials.')) -@click.password_option('--password', - confirmation_prompt=False, - help=('Account password. Will not be saved.')) -def init(ctx, email, password): - """Obtain and store authentication information""" - base_url = ctx.obj['BASE_URL'] - plauth = planet.Auth.from_login(email, password, base_url=base_url) - plauth.store() - click.echo('Initialized') - if os.getenv(ENV_API_KEY): - click.echo(f'Warning - Environment variable {ENV_API_KEY} already ' - 'exists. To update, with the new value, use the following:') - click.echo(f'export {ENV_API_KEY}=$(planet auth value)') +def cmd_auth(ctx): + """ + Commands for working with Planet authentication. + """ -@auth.command() # type: ignore -@translate_exceptions -def value(): - """Print the stored authentication information""" - click.echo(planet.Auth.from_file().value) +cmd_auth.add_command(name="login", cmd=planet_auth_utils.cmd_plauth_login) +# TODO: mark print-api-key as deprecated when we better support M2M tokens +# planet_auth_utils.cmd_pllegacy_print_api_key.deprecated = True +cmd_auth.add_command(name="print-api-key", cmd=planet_auth_utils.cmd_pllegacy_print_api_key) +cmd_auth.add_command(name="print-access-token", + cmd=planet_auth_utils.cmd_oauth_print_access_token) +cmd_auth.add_command(name="refresh", cmd=planet_auth_utils.cmd_oauth_refresh) +cmd_auth.add_command(name="reset", cmd=planet_auth_utils.cmd_plauth_reset) -@auth.command() # type: ignore -@translate_exceptions -@click.argument('key') -def store(key): - """Store authentication information""" - plauth = planet.Auth.from_key(key) - if click.confirm('This overrides the stored value. Continue?'): - plauth.store() - click.echo('Updated') - if os.getenv(ENV_API_KEY): - click.echo(f'Warning - Environment variable {ENV_API_KEY} already ' - 'exists. To update, with the new value, use the ' - 'following:') - click.echo(f'export {ENV_API_KEY}=$(planet auth value)') +# We are only plumbing a sub-set of the util lib's "profile" command, +# which is why we shadow it. +@click.group("profile") +@click.pass_context +def cmd_auth_profile(ctx): + """ + Manage auth profiles. + """ + + +cmd_auth_profile.add_command(name="list", + cmd=planet_auth_utils.cmd_profile_list) +cmd_auth_profile.add_command(name="show", + cmd=planet_auth_utils.cmd_profile_show) +cmd_auth_profile.add_command(name="set", cmd=planet_auth_utils.cmd_profile_set) +cmd_auth.add_command(cmd_auth_profile) diff --git a/planet/cli/cli.py b/planet/cli/cli.py index 328cf4f98..7d3805209 100644 --- a/planet/cli/cli.py +++ b/planet/cli/cli.py @@ -1,5 +1,5 @@ # Copyright 2017 Planet Labs, Inc. -# Copyright 2022 Planet Labs PBC. +# Copyright 2022, 2025 Planet Labs PBC. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,9 +18,10 @@ import click +import planet_auth_utils import planet -from . import auth, collect, data, orders, subscriptions, features +from . import auth, cmds, collect, data, orders, subscriptions, features LOGGER = logging.getLogger(__name__) @@ -36,7 +37,18 @@ default="warning", help=("Optional: set verbosity level to warning, info, or debug.\ Defaults to warning.")) -def main(ctx, verbosity, quiet): +@planet_auth_utils.opt_profile +@planet_auth_utils.opt_client_id +@planet_auth_utils.opt_client_secret +@planet_auth_utils.opt_api_key +@cmds.translate_exceptions +def main(ctx, + verbosity, + quiet, + auth_profile, + auth_client_id, + auth_client_secret, + auth_api_key): """Planet SDK for Python CLI""" _configure_logging(verbosity) @@ -45,6 +57,30 @@ def main(ctx, verbosity, quiet): ctx.ensure_object(dict) ctx.obj['QUIET'] = quiet + _configure_cli_auth_ctx(ctx, + auth_profile, + auth_client_id, + auth_client_secret, + auth_api_key) + + +def _configure_cli_auth_ctx(ctx, + auth_profile, + auth_client_id, + auth_client_secret, + auth_api_key): + # planet-auth library Auth context type + ctx.obj[ + 'AUTH'] = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context( + auth_profile_opt=auth_profile, + auth_client_id_opt=auth_client_id, + auth_client_secret_opt=auth_client_secret, + auth_api_key_opt=auth_api_key) + + # planet SDK Auth context type + ctx.obj['PLSDK_AUTH'] = planet.Auth._from_plauth( + pl_authlib_context=ctx.obj['AUTH']) + def _configure_logging(verbosity): """configure logging via verbosity level, corresponding @@ -73,9 +109,21 @@ def _configure_logging(verbosity): format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') -main.add_command(auth.auth) # type: ignore +# Hide the embedded util from help. It has many options and use cases that +# may not be directly the most relevant or user-friendly for the specific +# case of working against Planet Platform Services. +# The interface we want to support for the SDK CLI is a specialized +# subset defined by auth.py. +planet_auth_utils.cmd_plauth_embedded.hidden = True +main.add_command(cmd=planet_auth_utils.cmd_plauth_embedded, + name="plauth") # type: ignore + +main.add_command(auth.cmd_auth) # type: ignore main.add_command(data.data) # type: ignore main.add_command(orders.orders) # type: ignore main.add_command(subscriptions.subscriptions) # type: ignore main.add_command(collect.collect) # type: ignore main.add_command(features.features) + +if __name__ == "__main__": + main() # pylint: disable=E1120 diff --git a/planet/cli/cmds.py b/planet/cli/cmds.py index 9c8093134..63bbcf06c 100644 --- a/planet/cli/cmds.py +++ b/planet/cli/cmds.py @@ -18,6 +18,8 @@ import click +import planet_auth + from planet import exceptions from planet.cli.options import pretty @@ -116,11 +118,12 @@ def translate_exceptions(func): def wrapper(*args, **kwargs): try: func(*args, **kwargs) - except exceptions.AuthException: + except planet_auth.AuthException as pla_ex: raise click.ClickException( + f'{pla_ex}\n' 'Auth information does not exist or is corrupted. Initialize ' - 'with `planet auth init`.') - except exceptions.PlanetError as ex: - raise click.ClickException(str(ex)) + 'with `planet auth`.') + except (exceptions.PlanetError, FileNotFoundError) as ex: + raise click.ClickException(ex) return wrapper diff --git a/planet/cli/data.py b/planet/cli/data.py index b1830333c..7916a9432 100644 --- a/planet/cli/data.py +++ b/planet/cli/data.py @@ -42,7 +42,7 @@ @asynccontextmanager async def data_client(ctx): - async with CliSession() as sess: + async with CliSession(ctx) as sess: cl = DataClient(sess, base_url=ctx.obj['BASE_URL']) yield cl diff --git a/planet/cli/orders.py b/planet/cli/orders.py index 14589c20d..caa0c0f2a 100644 --- a/planet/cli/orders.py +++ b/planet/cli/orders.py @@ -76,7 +76,7 @@ def check_bundle(ctx, param, bundle) -> Optional[List[dict]]: @asynccontextmanager async def orders_client(ctx): base_url = ctx.obj['BASE_URL'] - async with CliSession() as sess: + async with CliSession(ctx) as sess: cl = OrdersClient(sess, base_url=base_url) yield cl diff --git a/planet/cli/session.py b/planet/cli/session.py index a3b28b4d0..8c1d3f6fc 100644 --- a/planet/cli/session.py +++ b/planet/cli/session.py @@ -1,12 +1,19 @@ """CLI HTTP/auth sessions.""" -from planet.auth import Auth from planet.http import Session class CliSession(Session): """Session with CLI-specific auth and identifying header""" - def __init__(self): - super().__init__(Auth.from_file()) + def __init__(self, click_ctx=None, plsdk_auth=None): + if click_ctx: + _plsdk_auth = click_ctx.obj['PLSDK_AUTH'] + else: + _plsdk_auth = None + + if plsdk_auth: + _plsdk_auth = plsdk_auth + + super().__init__(_plsdk_auth) self._client.headers.update({'X-Planet-App': 'python-cli'}) diff --git a/planet/cli/subscriptions.py b/planet/cli/subscriptions.py index 2b1602982..0c49211f5 100644 --- a/planet/cli/subscriptions.py +++ b/planet/cli/subscriptions.py @@ -31,7 +31,7 @@ def check_item_types(ctx, param, item_types) -> Optional[List[dict]]: @asynccontextmanager async def subscriptions_client(ctx): - async with CliSession() as sess: + async with CliSession(ctx) as sess: cl = SubscriptionsClient(sess, base_url=ctx.obj['BASE_URL']) yield cl diff --git a/planet/constants.py b/planet/constants.py index c9b1843bc..67c0029ae 100644 --- a/planet/constants.py +++ b/planet/constants.py @@ -20,8 +20,6 @@ DATA_DIR = Path(os.path.dirname(__file__)) / 'data' -ENV_API_KEY = 'PL_API_KEY' - PLANET_BASE_URL = 'https://api.planet.com' SECRET_FILE_PATH = Path(os.path.expanduser('~')) / '.planet.json' diff --git a/planet/exceptions.py b/planet/exceptions.py index eee852bd0..1935e65aa 100644 --- a/planet/exceptions.py +++ b/planet/exceptions.py @@ -78,11 +78,6 @@ class ClientError(PlanetError): pass -class AuthException(ClientError): - """Exceptions encountered during authentication""" - pass - - class PagingError(ClientError): """For errors that occur during paging.""" pass diff --git a/planet/http.py b/planet/http.py index e345f2e90..968bf8e9d 100644 --- a/planet/http.py +++ b/planet/http.py @@ -1,5 +1,5 @@ # Copyright 2020 Planet Labs, Inc. -# Copyright 2022 Planet Labs PBC. +# Copyright 2022, 2025 Planet Labs PBC. # # Licensed under the Apache License, Version 2.0 (the "License"); you may not # use this file except in compliance with the License. You may obtain a copy of @@ -241,13 +241,7 @@ def __init__( read_timeout_secs: Maximum time to wait for data to be received. """ if auth is None: - # Try getting credentials from environment before checking - # in the secret file, this is the conventional order (AWS - # CLI, for example.) - try: - auth = Auth.from_env() - except exceptions.PlanetError: - auth = Auth.from_file() + auth = Auth.from_user_default_session() if read_timeout_secs is None: read_timeout_secs = DEFAULT_READ_TIMEOUT_SECS @@ -466,44 +460,3 @@ def client(self, return _client_directory[name](self, base_url=base_url) except KeyError: raise exceptions.ClientError("No such client.") - - -class AuthSession(BaseSession): - """Synchronous connection to the Planet Auth service.""" - - def __init__(self): - """Initialize an AuthSession. - """ - self._client = httpx.Client(timeout=None) - self._client.headers.update({'User-Agent': self._get_user_agent()}) - self._client.event_hooks['request'] = [self._log_request] - self._client.event_hooks['response'] = [ - self._log_response, self._raise_for_status - ] - - def request(self, method: str, url: str, json: dict): - """Submit a request - - Parameters: - method: HTTP request method. - url: Location of the API endpoint. - json: JSON to send. - - Returns: - Server response. - - Raises: - planet.exceptions.APIException: On API error. - """ - request = self._client.build_request(method=method, url=url, json=json) - http_resp = self._client.send(request) - return models.Response(http_resp) - - @classmethod - def _raise_for_status(cls, response): - try: - super()._raise_for_status(response) - except exceptions.BadQuery: - raise exceptions.APIError('Not a valid email address.') - except exceptions.InvalidAPIKey: - raise exceptions.APIError('Incorrect email or password.') diff --git a/pyproject.toml b/pyproject.toml index dd16263c6..f99f71ef8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "pyjwt>=2.1", "tqdm>=4.56", "typing-extensions", + "planet-auth==2.0.4.1744065543b0" ] readme = "README.md" requires-python = ">=3.9" @@ -33,16 +34,29 @@ license = { file = "LICENSE" } dynamic = ["version"] [project.optional-dependencies] -test = ["pytest==8.3.3", "anyio", "pytest-cov", "respx>=0.22.0"] -lint = ["flake8", "mypy", "yapf==0.43.0"] +test = [ + "pytest==8.3.3", + "anyio", + "pytest-cov", + "respx>=0.22.0", + "coverage[toml]" +] +lint = [ + "flake8", + "mypy", + "yapf==0.43.0", +] docs = [ - "mkdocs==1.3", - "mkdocs-click==0.7.0", - "mkdocs-material==8.2.11", - "mkdocstrings==0.18.1", - "mkdocs_autorefs==1.0.1", + "mkdocs==1.3", + "mkdocs-click==0.7.0", + "mkdocs-material==8.2.11", + "mkdocstrings==0.18.1", + "mkdocs_autorefs==1.0.1", + "mkdocs-macros-plugin==1.3.7" +] +dev = [ + "planet[test, docs, lint]", ] -dev = ["planet[test, docs, lint]"] [project.scripts] planet = "planet.cli.cli:main" diff --git a/setup.cfg b/setup.cfg index 9e8fc8855..dfa8ff892 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,7 +9,7 @@ exclude = examples, tests [tool:pytest] addopts = - -rxXs + -v -rxXs --cov --cov-report=term [coverage:run] source = planet, tests @@ -18,7 +18,7 @@ branch = True [coverage:report] skip_covered = True show_missing = True -fail_under = 98 +fail_under = 90 [yapf] based_on_style = pep8 diff --git a/tests/conftest.py b/tests/conftest.py index 073e2bd22..57d5a0f9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,26 +21,10 @@ import pytest -from planet.auth import _SecretFile - _here = Path(os.path.abspath(os.path.dirname(__file__))) _test_data_path = _here / 'data' -@pytest.fixture(autouse=True, scope='module') -def test_secretfile_read(): - """Returns valid auth results as if reading a secret file""" - - def mockreturn(self): - return {'key': 'testkey'} - - # monkeypatch fixture is not available above a function scope - # usage: https://docs.pytest.org/en/6.2.x/reference.html#pytest.MonkeyPatch - with pytest.MonkeyPatch.context() as mp: - mp.setattr(_SecretFile, 'read', mockreturn) - yield - - @pytest.fixture def open_test_img(): img_path = _test_data_path / 'test_sm.tif' diff --git a/tests/integration/test_auth_api.py b/tests/integration/test_auth_api.py deleted file mode 100644 index ee8be11b3..000000000 --- a/tests/integration/test_auth_api.py +++ /dev/null @@ -1,77 +0,0 @@ -# Copyright 2021 Planet Labs PBC. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may not -# use this file except in compliance with the License. You may obtain a copy of -# the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations under -# the License. -from http import HTTPStatus -import logging - -import httpx -import jwt -import pytest -import respx - -from planet import exceptions -from planet.auth import AuthClient - -TEST_URL = 'http://MockNotRealURL/api/path' -TEST_LOGIN_URL = f'{TEST_URL}/login' - -LOGGER = logging.getLogger(__name__) - - -@respx.mock -def test_AuthClient_success(): - payload = {'api_key': 'iamakey'} - resp = {'token': jwt.encode(payload, 'key')} - mock_resp = httpx.Response(HTTPStatus.OK, json=resp) - respx.post(TEST_LOGIN_URL).return_value = mock_resp - - cl = AuthClient(base_url=TEST_URL) - auth_data = cl.login('email', 'password') - - assert auth_data == payload - - -@respx.mock -def test_AuthClient_invalid_email(): - resp = { - "errors": { - "email": ["Not a valid email address."] - }, - "message": "error validating request against UserAuthenticationSchema", - "status": 400, - "success": False - } - mock_resp = httpx.Response(400, json=resp) - respx.post(TEST_LOGIN_URL).return_value = mock_resp - - cl = AuthClient(base_url=TEST_URL) - with pytest.raises(exceptions.APIError, - match='Not a valid email address.'): - _ = cl.login('email', 'password') - - -@respx.mock -def test_AuthClient_invalid_password(): - resp = { - "errors": None, - "message": "Invalid email or password", - "status": 401, - "success": False - } - mock_resp = httpx.Response(401, json=resp) - respx.post(TEST_LOGIN_URL).return_value = mock_resp - - cl = AuthClient(base_url=TEST_URL) - with pytest.raises(exceptions.APIError, - match='Incorrect email or password.'): - _ = cl.login('email', 'password') diff --git a/tests/integration/test_auth_cli.py b/tests/integration/test_auth_cli.py deleted file mode 100644 index 62fbd3563..000000000 --- a/tests/integration/test_auth_cli.py +++ /dev/null @@ -1,123 +0,0 @@ -# Copyright 2022 Planet Labs PBC. -# -# Licensed under the Apache License, Version 2.0 (the "License"); you may not -# use this file except in compliance with the License. You may obtain a copy of -# the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations under -# the License. -from http import HTTPStatus -import json -import os - -from click.testing import CliRunner -import httpx -import jwt -import pytest -import respx - -from planet.cli import cli - -TEST_URL = 'http://MockNotRealURL/api/path' -TEST_LOGIN_URL = f'{TEST_URL}/login' - - -# skip the global mock of _SecretFile.read -# for this module -@pytest.fixture(autouse=True, scope='module') -def test_secretfile_read(): - return - - -@pytest.fixture -def redirect_secretfile(tmp_path): - """patch the cli so it works with a temporary secretfile - - this is to avoid collisions with the actual planet secretfile - """ - secretfile_path = tmp_path / 'secret.json' - - with pytest.MonkeyPatch.context() as mp: - mp.setattr(cli.auth.planet.auth, 'SECRET_FILE_PATH', secretfile_path) - yield secretfile_path - - -@respx.mock -def test_cli_auth_init_success(redirect_secretfile): - """Test the successful auth init path - - Also tests the base-url command, since we will get an exception - if the base url is not changed to the mocked url - """ - payload = {'api_key': 'test_cli_auth_init_success_key'} - resp = {'token': jwt.encode(payload, 'key')} - mock_resp = httpx.Response(HTTPStatus.OK, json=resp) - respx.post(TEST_LOGIN_URL).return_value = mock_resp - - result = CliRunner().invoke(cli.main, - args=['auth', '--base-url', TEST_URL, 'init'], - input='email\npw\n') - - # we would get a 'url not mocked' exception if the base url wasn't - # changed to the mocked url - assert not result.exception - - assert 'Initialized' in result.output - - -@respx.mock -def test_cli_auth_init_bad_pw(redirect_secretfile): - resp = { - "errors": None, - "message": "Invalid email or password", - "status": 401, - "success": False - } - mock_resp = httpx.Response(401, json=resp) - respx.post(TEST_LOGIN_URL).return_value = mock_resp - - result = CliRunner().invoke(cli.main, - args=['auth', '--base-url', TEST_URL, 'init'], - input='email\npw\n') - - assert result.exception - assert 'Error: Incorrect email or password.\n' in result.output - - -def test_cli_auth_value_success(redirect_secretfile): - key = 'test_cli_auth_value_success_key' - content = {'key': key} - with open(redirect_secretfile, 'w') as f: - json.dump(content, f) - - result = CliRunner().invoke(cli.main, ['auth', 'value']) - assert not result.exception - assert result.output == f'{key}\n' - - -def test_cli_auth_value_failure(redirect_secretfile): - result = CliRunner().invoke(cli.main, ['auth', 'value']) - assert result.exception - assert 'Error: Auth information does not exist or is corrupted.' \ - in result.output - - -def test_cli_auth_store_cancel(redirect_secretfile): - result = CliRunner().invoke(cli.main, ['auth', 'store', 'setval'], - input='') - assert not result.exception - assert not os.path.isfile(redirect_secretfile) - - -def test_cli_auth_store_confirm(redirect_secretfile): - result = CliRunner().invoke(cli.main, ['auth', 'store', 'setval'], - input='y') - assert not result.exception - - with open(redirect_secretfile, 'r') as f: - assert json.load(f) == {'key': 'setval'} diff --git a/tests/integration/test_features_api.py b/tests/integration/test_features_api.py index 4ae554836..a8cd5c7fd 100644 --- a/tests/integration/test_features_api.py +++ b/tests/integration/test_features_api.py @@ -20,7 +20,7 @@ import respx from planet import FeaturesClient, Session -from planet.auth import APIKeyAuth +from planet.auth import Auth from planet.sync.features import FeaturesAPI pytestmark = pytest.mark.anyio # noqa @@ -47,7 +47,7 @@ TEST_COLLECTION_LIST = [TEST_COLLECTION_1, TEST_COLLECTION_2] # set up test clients -test_session = Session(auth=APIKeyAuth(key="test")) +test_session = Session(auth=Auth.from_key(key="test")) cl_async = FeaturesClient(test_session, base_url=TEST_URL) cl_sync = FeaturesAPI(test_session, base_url=TEST_URL) diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index cd8c265ec..000000000 --- a/tests/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -log_cli = True -log_format = %(asctime)s %(levelname)s %(message)s -log_date_format = %Y-%m-%d %H:%M:%S diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 51ce8f414..8a2d15df0 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1,5 +1,5 @@ # Copyright 2020 Planet Labs, Inc. -# Copyright 2022 Planet Labs PBC. +# Copyright 2022, 2025 Planet Labs PBC. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,12 +12,17 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import json import logging +import planet_auth_utils import pytest +import planet.auth from planet import auth +import planet_auth + +import planet.auth_builtins +from planet.auth_builtins import PlanetOAuthScopes LOGGER = logging.getLogger(__name__) @@ -37,8 +42,12 @@ def secret_path(monkeypatch, tmp_path): def test_Auth_from_key(): - test_auth_env1 = auth.Auth.from_key('testkey') - assert test_auth_env1.value == 'testkey' + test_auth_env1 = auth.Auth.from_key('testkey_from_key') + # We know that planet_auth instantiates an in memory "static API key" auth client. + # test_api_key = test_auth_env1._plauth.request_authenticator().credential().legacy_api_key() + test_api_key = test_auth_env1._plauth.request_authenticator().credential( + ).api_key() + assert test_api_key == 'testkey_from_key' def test_Auth_from_key_empty(): @@ -48,43 +57,55 @@ def test_Auth_from_key_empty(): def test_Auth_from_file(secret_path): with open(secret_path, 'w') as fp: - fp.write('{"key": "testvar"}') + fp.write('{"key": "testvar_from_file"}') test_auth = auth.Auth.from_file() - assert test_auth.value == 'testvar' + # We know that planet_auth instantiates a "Legacy" auth client. + test_api_key = test_auth._plauth.request_authenticator().credential( + ).legacy_api_key() + # test_api_key = test_auth._plauth.request_authenticator().credential().api_key() + assert test_api_key == 'testvar_from_file' def test_Auth_from_file_doesnotexist(secret_path): - with pytest.raises(auth.AuthException): - _ = auth.Auth.from_file(secret_path) + test_auth = auth.Auth.from_file(secret_path) + with pytest.raises(FileNotFoundError): + _ = test_auth._plauth.request_authenticator().credential( + ).legacy_api_key() def test_Auth_from_file_wrongformat(secret_path): with open(secret_path, 'w') as fp: - fp.write('{"notkey": "testvar"}') - - with pytest.raises(auth.AuthException): - _ = auth.Auth.from_file(secret_path) + fp.write('{"notkey": "testvar_wrong_format"}') + test_auth = auth.Auth.from_file(secret_path) + with pytest.raises(planet_auth.InvalidDataException): + _ = test_auth._plauth.request_authenticator().credential( + ).legacy_api_key() def test_Auth_from_file_alternate(tmp_path): secret_path = str(tmp_path / '.test') with open(secret_path, 'w') as fp: - fp.write('{"key": "testvar"}') + fp.write('{"key": "testvar_alt_path"}') test_auth = auth.Auth.from_file(secret_path) - assert test_auth.value == 'testvar' + test_api_key = test_auth._plauth.request_authenticator().credential( + ).legacy_api_key() + assert test_api_key == 'testvar_alt_path' def test_Auth_from_env(monkeypatch): - monkeypatch.setenv('PL_API_KEY', 'testkey') + monkeypatch.setenv('PL_API_KEY', 'testkey_env') test_auth_env = auth.Auth.from_env() - assert test_auth_env.value == 'testkey' + # TODO: that I short cicuit between legacy and API key auth impls makes this weird. + test_api_key = test_auth_env._plauth.request_authenticator().credential( + ).api_key() + assert test_api_key == 'testkey_env' def test_Auth_from_env_failure(monkeypatch): monkeypatch.delenv('PL_API_KEY', raising=False) - with pytest.raises(auth.AuthException): + with pytest.raises(auth.APIKeyAuthException): _ = auth.Auth.from_env() @@ -94,7 +115,10 @@ def test_Auth_from_env_alternate_success(monkeypatch): monkeypatch.delenv('PL_API_KEY', raising=False) test_auth_env = auth.Auth.from_env(alternate) - assert test_auth_env.value == 'testkey' + test_api_key = test_auth_env._plauth.request_authenticator().credential( + ).api_key() + + assert test_api_key == 'testkey' def test_Auth_from_env_alternate_doesnotexist(monkeypatch): @@ -102,55 +126,209 @@ def test_Auth_from_env_alternate_doesnotexist(monkeypatch): monkeypatch.delenv(alternate, raising=False) monkeypatch.delenv('PL_API_KEY', raising=False) - with pytest.raises(auth.AuthException): + with pytest.raises(auth.APIKeyAuthException): _ = auth.Auth.from_env(alternate) def test_Auth_from_login(monkeypatch): - auth_data = 'authdata' + # auth.AuthClient has been completely removed + # in the conversion to planet_auth + # def login(*args, **kwargs): + # return {'api_key': auth_data} + # + # monkeypatch.setattr(auth.AuthClient, 'login', login) + with pytest.raises(DeprecationWarning): + _ = auth.Auth.from_login('email', 'pw') - def login(*args, **kwargs): - return {'api_key': auth_data} - monkeypatch.setattr(auth.AuthClient, 'login', login) +def test_Auth_from_user_defaults(): + # The primary implementation is implemented and unit tested by the planet + # auth libraries. This tests that it doesn't explode with an exception. + # CI/CD currently is run by configuring auth via PL_API_KEY env var. + # What this will actually do in a user's environment depends on a lot + # of variables. + _ = auth.Auth.from_user_default_session() + + +def test_Auth_from_profile__builtin_default_profile(): + under_test = auth.Auth.from_profile( + planet_auth_utils.Builtins.builtin_default_profile_name()) + assert isinstance(under_test, planet.auth._PLAuthLibAuth) + assert isinstance(under_test._plauth.auth_client(), + planet_auth.DeviceCodeAuthClient) + + assert under_test._plauth.auth_client( + )._devicecode_client_config.auth_server( + ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_USER[ + "auth_server"] + + assert under_test._plauth.auth_client( + )._devicecode_client_config.client_id( + ) == planet.auth_builtins._SDK_CLIENT_ID_PROD - test_auth = auth.Auth.from_login('email', 'pw') - assert test_auth.value == auth_data +def test_Auth_from_user_auth_code_client(): + under_test = auth.Auth.from_oauth_user_auth_code( + client_id="mock_client_id__auth_code_client", + callback_url="http://localhost:8080", + save_state_to_storage=False) + + assert isinstance(under_test, planet.auth._PLAuthLibAuth) + assert isinstance(under_test._plauth.auth_client(), + planet_auth.AuthCodeAuthClient) + + assert under_test._plauth.auth_client( + )._authcode_client_config.auth_server( + ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_USER[ + "auth_server"] + + assert under_test._plauth.auth_client()._authcode_client_config.client_id( + ) == "mock_client_id__auth_code_client" + + assert under_test._plauth.auth_client()._authcode_client_config.scopes( + ) == planet.auth_builtins._OIDC_AUTH_CLIENT_CONFIG__USER_SKEL["scopes"] + + +def test_Auth_from_user_auth_code_client_2(): + under_test = auth.Auth.from_oauth_user_auth_code( + client_id="mock_client_id__auth_code_client_2", + callback_url="http://localhost:8080", + requested_scopes=[PlanetOAuthScopes.PLANET], + profile_name="utest-override-default-profile-name-auth-code-2", + save_state_to_storage=False) + + assert isinstance(under_test, planet.auth._PLAuthLibAuth) + assert isinstance(under_test._plauth.auth_client(), + planet_auth.AuthCodeAuthClient) + + assert under_test._plauth.auth_client( + )._authcode_client_config.auth_server( + ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_USER[ + "auth_server"] -def test_Auth_store_doesnotexist(tmp_path): - test_auth = auth.Auth.from_key('test') - secret_path = str(tmp_path / '.test') - test_auth.store(secret_path) + assert under_test._plauth.auth_client()._authcode_client_config.client_id( + ) == "mock_client_id__auth_code_client_2" + + assert under_test._plauth.auth_client()._authcode_client_config.scopes( + ) == [PlanetOAuthScopes.PLANET] + + assert under_test._plauth.profile_name( + ) == "utest-override-default-profile-name-auth-code-2" - with open(secret_path, 'r') as fp: - assert json.loads(fp.read()) == {"key": "test"} +def test_Auth_from_user_device_code_client(): + under_test = auth.Auth.from_oauth_user_device_code( + client_id="mock_client_id__device_code_client", + save_state_to_storage=False) -def test_Auth_store_exists(tmp_path): - secret_path = str(tmp_path / '.test') + assert isinstance(under_test, planet.auth._PLAuthLibAuth) + assert isinstance(under_test._plauth.auth_client(), + planet_auth.DeviceCodeAuthClient) - with open(secret_path, 'w') as fp: - fp.write('{"existing": "exists"}') + assert under_test._plauth.auth_client( + )._devicecode_client_config.auth_server( + ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_USER[ + "auth_server"] - test_auth = auth.Auth.from_key('test') - test_auth.store(secret_path) + assert under_test._plauth.auth_client( + )._devicecode_client_config.client_id( + ) == "mock_client_id__device_code_client" - with open(secret_path, 'r') as fp: - assert json.loads(fp.read()) == {"key": "test", "existing": "exists"} + assert under_test._plauth.auth_client()._devicecode_client_config.scopes( + ) == planet.auth_builtins._OIDC_AUTH_CLIENT_CONFIG__USER_SKEL["scopes"] -def test__SecretFile_permissions_doesnotexist(secret_path): - """No exception is raised if the file doesn't exist""" - auth._SecretFile(secret_path) +def test_Auth_from_user_device_code_client_2(): + under_test = auth.Auth.from_oauth_user_device_code( + client_id="mock_client_id__device_code_client_2", + requested_scopes=[ + PlanetOAuthScopes.PLANET, + ], + profile_name="utest-override-default-profile-name-device-code-2", + save_state_to_storage=False) + assert isinstance(under_test, planet.auth._PLAuthLibAuth) + assert isinstance(under_test._plauth.auth_client(), + planet_auth.DeviceCodeAuthClient) -def test__SecretFile_permissions_incorrect(secret_path): - """Incorrect permissions are fixed""" - with open(secret_path, 'w') as fp: - fp.write('{"existing": "exists"}') + assert under_test._plauth.auth_client( + )._devicecode_client_config.auth_server( + ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_USER[ + "auth_server"] + + assert under_test._plauth.auth_client( + )._devicecode_client_config.client_id( + ) == "mock_client_id__device_code_client_2" + + assert under_test._plauth.auth_client()._devicecode_client_config.scopes( + ) == [PlanetOAuthScopes.PLANET] + + assert under_test._plauth.profile_name( + ) == "utest-override-default-profile-name-device-code-2" + + +def test_Auth_from_oauth_m2m(): + under_test = auth.Auth.from_oauth_m2m( + client_id="mock_client_id__from_oauth_m2m", + client_secret="mock_client_secret__from_oauth_m2m", + requested_scopes=[ + PlanetOAuthScopes.PLANET, + ], + ) + assert isinstance(under_test, planet.auth._PLAuthLibAuth) + assert isinstance(under_test._plauth.auth_client(), + planet_auth.ClientCredentialsClientSecretAuthClient) + + assert under_test._plauth.auth_client()._ccauth_client_config.auth_server( + ) == planet.auth_builtins._ProductionEnv.OAUTH_AUTHORITY_M2M["auth_server"] + + assert under_test._plauth.auth_client()._ccauth_client_config.client_id( + ) == "mock_client_id__from_oauth_m2m" + + assert under_test._plauth.auth_client( + )._ccauth_client_config.client_secret( + ) == "mock_client_secret__from_oauth_m2m" + + assert under_test._plauth.auth_client()._ccauth_client_config.scopes() == [ + PlanetOAuthScopes.PLANET + ] + + +def test_Auth_profile_name_normalization(): + under_test = auth.Auth.from_oauth_m2m( + client_id="mock_client_id__from_oauth_m2m", + client_secret="mock_client_secret__from_oauth_m2m", + profile_name="mIxeD_CaSe") + + assert under_test._plauth.profile_name() == "mixed_case" + + +def test_Auth_profile_name_illegal(): + with pytest.raises(ValueError): + _ = auth.Auth.from_oauth_m2m( + client_id="mock_client_id__from_oauth_m2m", + client_secret="mock_client_secret__from_oauth_m2m", + profile_name="path/sep/not/allowed") + + +def test_auth_value_deprecated(): + test_auth = auth.Auth.from_key("test_deprecated_key") + with pytest.raises(DeprecationWarning): + _ = test_auth.value + + +def test_auth_store_deprecated(): + test_auth = auth.Auth.from_key("test_deprecated_key") + with pytest.raises(DeprecationWarning): + test_auth.store() + + +def test_auth_to_dict_deprecated(): + test_auth = auth.Auth.from_key("test_deprecated_key") + with pytest.raises(DeprecationWarning): + _ = test_auth.to_dict() - secret_path.chmod(0o666) - auth._SecretFile(secret_path) - assert secret_path.stat().st_mode & 0o777 == 0o600 +def test_auth_from_dict_deprecated(): + with pytest.raises(DeprecationWarning): + _ = auth.Auth.from_dict({}) diff --git a/tests/unit/test_cli_session.py b/tests/unit/test_cli_session.py index c4e95f230..ed389f70c 100644 --- a/tests/unit/test_cli_session.py +++ b/tests/unit/test_cli_session.py @@ -11,7 +11,6 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under # the License. -import base64 from http import HTTPStatus import json @@ -23,7 +22,6 @@ # from planet.auth import _SecretFile from planet import auth from planet.cli import session -from planet.exceptions import AuthException TEST_URL = 'mock://mock.com' @@ -63,7 +61,9 @@ async def test_CliSession_headers(test_valid_secretfile): @respx.mock @pytest.mark.anyio async def test_CliSession_auth_valid(test_valid_secretfile): - async with session.CliSession() as sess: + # The default auth + async with session.CliSession( + plsdk_auth=auth.Auth.from_key("clisessiontest")) as sess: route = respx.get(TEST_URL) route.return_value = httpx.Response(HTTPStatus.OK) @@ -71,30 +71,11 @@ async def test_CliSession_auth_valid(test_valid_secretfile): # the proper headers are included and they have the expected values received_request = route.calls.last.request - credentials = received_request.headers['authorization'].strip( - 'Authorization: Basic ') - assert base64.b64decode(credentials) == b'clisessiontest:' - - -@respx.mock -@pytest.mark.anyio -async def test_CliSession_auth_invalid(tmp_path, monkeypatch): - # write invalid secret file - secret_path = f'{tmp_path}/secret.test' - monkeypatch.setattr(auth, 'SECRET_FILE_PATH', secret_path) - with open(secret_path, 'w') as fp: - json.dump({'invalidkey': 'clisessiontest'}, fp) - - with pytest.raises(AuthException): - session.CliSession() - - -@respx.mock -@pytest.mark.anyio -async def test_CliSession_auth_nofile(tmp_path, monkeypatch): - # point to non-existant file - secret_path = f'{tmp_path}/doesnotexist.test' - monkeypatch.setattr(auth, 'SECRET_FILE_PATH', secret_path) - - with pytest.raises(AuthException): - session.CliSession() + # The planet_auth library sends the api key as bearer token. + # The older Planet SDK sent it as HTTP basic. + # Most Planet APIs accept either (and API keys are being deprecated.) + # credentials = received_request.headers['authorization'].strip( + # 'Authorization: Basic ') + # assert base64.b64decode(credentials) == b'clisessiontest:' + credentials = received_request.headers['authorization'] + assert credentials == 'api-key clisessiontest' diff --git a/tests/unit/test_http.py b/tests/unit/test_http.py index 9e538543a..762b66f8c 100644 --- a/tests/unit/test_http.py +++ b/tests/unit/test_http.py @@ -195,6 +195,7 @@ async def test_session_contextmanager(): @pytest.mark.parametrize('data', (None, {'boo': 'baa'})) async def test_session_request_success(data): + # async with http.Session(auth=planet.Auth.from_plauth(pl_authlib_context=planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context(auth_profile_opt="none"))) as ps: async with http.Session() as ps: resp_json = {'foo': 'bar'} route = respx.get(TEST_URL) @@ -282,25 +283,3 @@ def test__calculate_wait(): # this doesn't really test the randomness but does test exponential # and threshold assert math.floor(wait) == expected - - -@respx.mock -@pytest.mark.anyio -async def test_authsession_request(): - sess = http.AuthSession() - resp_json = {'token': 'foobar'} - mock_resp = httpx.Response(HTTPStatus.OK, json=resp_json) - respx.get(TEST_URL).return_value = mock_resp - - resp = sess.request(method='GET', url=TEST_URL, json={'foo': 'bar'}) - assert resp.json() == resp_json - - -def test_authsession__raise_for_status(mock_response): - with pytest.raises(exceptions.APIError): - http.AuthSession._raise_for_status( - mock_response(HTTPStatus.BAD_REQUEST, json={})) - - with pytest.raises(exceptions.APIError): - http.AuthSession._raise_for_status( - mock_response(HTTPStatus.UNAUTHORIZED, json={}))