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/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index c2ea51ace..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Release to GitHub and PyPI - -on: - workflow_dispatch: - inputs: - prerelease: - description: 'Is this a pre-release?' - required: true - type: boolean - -jobs: - package: - name: Build, verify, & upload package - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 - with: - python-version: "3.12" - - - name: Create GitHub Release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions. - with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} - draft: false - prerelease: ${{ inputs.logLevel }} - - - run: python -m pip install build twine check-wheel-contents - - run: python -m build --sdist --wheel . - - run: ls -l dist - - run: check-wheel-contents dist/*.whl - - - name: Check long_description - run: python -m twine check dist/* - - - name: Upload to TestPyPI - uses: pypa/gh-action-pypi-publish@v1.4.2 - with: - repository_url: https://test.pypi.org/legacy/ - user: __token__ - password: ${{ secrets.TEST_PYPI_PASSWORD }} - - - name: Upload to PyPI - uses: pypa/gh-action-pypi-publish@v1.4.2 - with: - user: __token__ - password: ${{ secrets.PYPI_PASSWORD }} \ No newline at end of file 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/CONTRIBUTING.md b/CONTRIBUTING.md index 8aded047a..fdba2711e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,10 +19,12 @@ insofar as is practical within the scope of changes targeted to the next major r versioning, major releases do not guarantee backwards compatibility. Stability is not guaranteed during the development cycle. -During the development cycle of a new major release, `RELEASE-PLANNING-X.0.md` should be maintained -with a brief summary of the major and breaking changes underpinning the reason for the upcoming -major release version. Upon release, this content is expected to be folded into package documentation -as appropriate, and this file should be removed. +During the development cycle of a new major release, a GitHub Project and Milestone should be +created to track changes targeted the release. A file such as `RELEASE-PLANNING-X.0.md` in the +root of the source tree may be used for early development prior to the creation of a GitHub +project, but should be retired when a new release becomes more formalized. Upon release, +all content is expected to be folded into package documentation as appropriate (announcements, +company blog posts, changelogs, migration guides, etc.). When a new major release is ready, the development mainline branch will be renamed to `main`, and the old mainline branch will be renamed to `maint-X.0` and will be used as the base for maintenance releases. diff --git a/README.md b/README.md index b223198d7..ddb555006 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md#branches) for more information on branches ##### Current Mainline Versions and Branches -| Version | Status | Branch | Documentation | Initial Release | End of Active Development | End of Maintenance | Notes | -|---------|---------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-----------------|---------------------------|--------------------|------------------------------------------------------------------------------------------------------------------------------| -| 3.x | `development` | [`main-3.0-dev`](https://github.com/planetlabs/planet-client-python/tree/main-3.0-dev) | TBD | TBD | TBD | TBD | See [RELEASE-PLANNING-X.0.md](https://github.com/planetlabs/planet-client-python/tree/main-3.0-dev/RELEASE-PLANNING-3.0.md). | -| 2.x | `active` | [`main`](https://github.com/planetlabs/planet-client-python/tree/main) | [Planet Labs Python Client v2 on Readthedocs.io](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/) | April 2023 | TBD | TBD | | -| 1.x | `end-of-life` | [`v1`](https://github.com/planetlabs/planet-client-python/tree/v1) | [Planet Labs Python Client v1 on Github.io](https://planetlabs.github.io/planet-client-python/) | April 2017 | April 2023 | TBD | | +| Version | Status | Branch | Documentation | Initial Release | End of Active Development | End of Maintenance | Notes | +|---------|---------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-----------------|---------------------------|--------------------|-------------------------------------------------------------------------------------------------| +| 3.x | `development` | [`main-3.0-dev`](https://github.com/planetlabs/planet-client-python/tree/main-3.0-dev) | [Planet Labs Python Client on ReadTheDocs.io](https://planet-sdk-for-python.readthedocs.io/en/latest/) | TBD | TBD | TBD | See [3.0.0 Release Milestone](https://github.com/planetlabs/planet-client-python/milestone/31). | +| 2.x | `active` | [`main`](https://github.com/planetlabs/planet-client-python/tree/main) | [Planet Labs Python Client v2 on ReadTheDocs.io](https://planet-sdk-for-python-v2.readthedocs.io/en/latest/) | April 2023 | TBD | TBD | | +| 1.x | `end-of-life` | [`v1`](https://github.com/planetlabs/planet-client-python/tree/v1) | [Planet Labs Python Client v1 on Github.io](https://planetlabs.github.io/planet-client-python/) | April 2017 | April 2023 | TBD | | ## Installation and Quick Start diff --git a/RELEASE.md b/RELEASE.md index b0aedbdbd..d94bb1b80 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -54,7 +54,6 @@ The SDK follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and t * _`version`_: [https://planet-sdk-for-python-v2.readthedocs.io/en/X.YY.ZZ/](https://planet-sdk-for-python-v2.readthedocs.io/en/X.YY.ZZ/) - Should point to version `X.YY.ZZ`. * Pre-release versions should _not_ impact the default version of the documentation. Pre-release version may be published as the `latest` version. - ## Local publishing Publishing to testpypi and pypi can also be performed locally with: diff --git a/docs/auth/auth-dev-app-managed-apikey.md b/docs/auth/auth-dev-app-managed-apikey.md new file mode 100644 index 000000000..1b2888c92 --- /dev/null +++ b/docs/auth/auth-dev-app-managed-apikey.md @@ -0,0 +1,39 @@ +# Application Managed Sessions - Planet API Key + +## 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 if possible. + +### Examples - Planet API Keys + +#### In Memory Session State +Once provided with an API key, an application may operate with the API key +in memory indefinitely without the need to prompt the user for re-authentication. +```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' %} +``` + +#### Version 2 Compatibility +The SDK continues to support files written by version 2 of the SDK to save +auth state. +```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 example" +{% include 'auth-session-management/legacy_api_key_file.json' %} +``` + +#### Session State Shared with CLI +```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' %} +``` + +#### Session State Saved to Application Storage + +```python linenums="1" title="Access APIs using Planet API keys with sessions persisted to application provided storage" +{% include 'auth-session-management/app_managed_auth_state__app_custom_storage__api_key.py' %} +``` + +---- diff --git a/docs/auth/auth-dev-app-managed-oauth.md b/docs/auth/auth-dev-app-managed-oauth.md new file mode 100644 index 000000000..f80d58063 --- /dev/null +++ b/docs/auth/auth-dev-app-managed-oauth.md @@ -0,0 +1,174 @@ +# Application Managed Sessions - OAuth2 + +If an application cannot or should not use a login session initiated by the +[`planet auth`](../../cli/cli-reference/#auth) CLI command, the application will be +responsible for managing the process on its own, persisting session state as +needed. + +Application managed sessions may be used with all authentication protocols. +Application developers may control whether sessions are visible to the CLI. +This is managed with the `save_state_to_storage` parameter on the `planet.Auth` +constructor methods illustrated below. + +The process varies depending on the authentication protocol used. +Depending on the use case, applications may need to support multiple authentication +methods, just as the [`planet`](../../cli/cli-reference) CLI command supports interacting with Planet APIs +using either a user or a service user account. + +## 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 exposed to the application developer who is focused on geospatial +operations using the Planet platform, and not the nuances of user +authentication and authorization. + +### OAuth2 User Client Registration +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. + +See [OAuth2 Client Registration](http://docs.planet.com/develop/authentication/#interactive-client-registration) +for more information. + +### With a Local Web Browser +In environments where a local browser is available, the Planet SDK library 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 establishes 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 browser 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 - OAuth2 Authorization Code Flow + +##### In Memory Session State +When an application cannot safely store user session state, it may operate purely in memory. When this +method is used, the user will be prompted to complete the login process each time the application is run. + +```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' %} +``` + +##### Session State Shared with CLI +Applications may save their session state in a way that is shared with the CLI. With saved state, +the user will only be prompted to complete the login process once. +```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' %} +``` + +##### Session State Saved to Application Storage +Applications may save their session state to application provided storage. With saved state, +the user should only be prompted to complete the login process once. Using application provided storage +will result in the session state not being shared with the CLI. + +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`](https://planet-auth.readthedocs.io/en/latest/api-planet-auth/#planet_auth.ObjectStorageProvider) +abstract base class. See examples below for more details. + +```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 browser is not available, additional steps must +be taken by the application author to initialize the user session. +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 complete 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 establishes 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. + +As above, this may be done with state only persisted in memory, with state +shared with the CLI, or with state saved to application provided storage. + +#### Examples - OAuth2 Device Code Flow + +##### In Memory Session State +```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' %} +``` + +##### Session State Shared with CLI +```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' %} +``` + +##### Session State Saved to Application Storage +```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 accounts 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, +then the application should take steps to persist the session state (for +example: a command line utility run repeatedly 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 concerns, discussed in +[RFC 6819 §4.4.4](https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.4). +Because of these considerations, service accounts should only be used for +workflows that are independent of a controlling user. + +As above, this may be done with state only persisted in memory, with state +shared with the CLI, or with state saved to application provided storage. + +### OAuth2 M2M Client Registration +Service accounts are managed under the +**OAuth Clients** panel on the [Planet Insights Account](https://insights.planet.com/account/#/) page. + +See [Sentinel Hub Authentication](https://docs.sentinel-hub.com/api/latest/api/overview/authentication/) for further information. + +### Examples - OAuth2 Client Credentials Flow + +#### In Memory Session State +```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' %} +``` + +#### Session State Shared with CLI +```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' %} +``` + +#### Session State Saved to Application Storage +```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' %} +``` + +---- diff --git a/docs/auth/auth-dev-cli-managed.md b/docs/auth/auth-dev-cli-managed.md new file mode 100644 index 000000000..ba7f84a76 --- /dev/null +++ b/docs/auth/auth-dev-cli-managed.md @@ -0,0 +1,110 @@ +# CLI Managed Sessions +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 programs built +using the SDK. Changes made by one will impact the behavior of the other. + +CLI managed sessions can be used for all authentication protocols supported +by the SDK library. + +**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 + 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 - CLI Login +Session login can be performed using the following command. This command can +be used to initialize sessions using any of the supported authentication methods, +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 +``` + +A particular configuration may be selected by using the `--auth-profile` option. +`planet-user` is the default, but may be [overridden](../auth-sdk/#configuration) +by the runtime environment. + + +```shell title="Initialize session using planet CLI, forcing the built-in user interactive OAuth2 login flow." +planet auth login --auth-profile planet-user +``` + + +```shell title="Initialize session using planet CLI, forcing the use of the specified service principal." +planet auth login --auth-client-id --auth-client-secret +``` + + +```shell title="Initialize session using planet CLI, forcing the use of a legacy Planet API key." +planet auth login --auth-api-key +``` + +## Using Saved Session +Using a CLI managed session is the default behavior for SDK functions. +Developing an application that uses a CLI managed 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. + +### CLI Selected Session +The default behavior of the SDK is to defer which session is loaded to CLI. + + +```python linenums="1" title="Implicitly use CLI managed login sessions, deferring session selection to the user and the CLI." +{% include 'auth-session-management/cli_managed_auth_state__implicit.py' %} +``` + +```python linenums="1" title="Explicitly use CLI managed login sessions, deferring session selection to the user and the CLI." +{% include 'auth-session-management/cli_managed_auth_state__explicit.py' %} +``` + +### Application Selected Session +Applications may be developed to always select a specific CLI managed profile. +This may be useful in cases where an application wishes to guide the user +experience towards expecting an auth session that is separate from the default +sessions used by the CLI. + +In cases where the application has access to the +user's home directory and saved sessions, forcing the use of a particular +profile circumvents the user's CLI managed preferences. + + +Note: This first example does not create the session `my-app-profile`. +This must be created either through a separate code path as show in +the [Application Managed Sessions](../auth-dev-app-managed-oauth) guide, +or by using a CLI command to copy an existing profile such as +`planet auth profile copy planet-user my-app-profile`. + +```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' %} +``` + + +It is also possible to force the use of the SDK's built-in OAuth2 application ID +for interactive user applications. This capability is provided for developer +convenience, primarily for smaller programs and scripts. Larger applications +developed for multiple users should +[register](../auth-dev-app-managed-oauth/#oauth2-user-client-registration) +a unique application ID. + +This second example also initiates a login and does not save session state to storage. +This means this example does not depend on the CLI, and may be considered a simple +example of an [Application Managed Session](../auth-dev-app-managed-oauth). + +```python linenums="1" title="Use the Planet SDK with an OAuth2 user session initialized by the application and utilizing the SDK's built-in OAuth2 application ID." +{% include 'auth-session-management/app_managed_auth_state__using_sdk_app_id.py' %} +``` + +--- diff --git a/docs/auth/auth-overview.md b/docs/auth/auth-overview.md new file mode 100644 index 000000000..c8148e03d --- /dev/null +++ b/docs/auth/auth-overview.md @@ -0,0 +1,126 @@ +# Client Authentication Overview + +## 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. +This documentation focuses on the use of the Planet Python SDK and +[`planet`](../../cli/cli-reference) CLI. + +!!! info + Work to unify authentication practices between `api.planet.com` and `services.sentinel-hub.com` + is ongoing and being rolled out in phases over time. Documentation referring + to work in progress is marked as such 🚧. + + Of particular note is the general shift towards OAuth2 based authentication, + and a corresponding move away from Planet API keys. + +---- + +## Authentication Protocols +At the HTTP protocol level underneath the SDK, there are several distinct +ways a client may authenticate to the Planet APIs, depending on the use case. +See [Authentication Protocols](https://docs.planet.com/develop/authentication/#authentication-protocols) for a +complete discussion of when to choose a particular method. + +* **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 registered client application + and a web browser are required to initialize a session. A web browser is not + required for continued operation. The SDK itself is a registered + client application that may be used for this purpose. + + Examples of applications that fall into this category include + [ArcGIS Pro](https://www.esri.com/en-us/arcgis/products/arcgis-pro/overview), + [QGIS](https://qgis.org/), and the SDK's own [`planet`](../../cli/cli-reference) + CLI program. All Planet first-party web applications also use this method. + +* **OAuth2 M2M access tokens** (🚧 _Work in progress_) - API access as a service user, using OAuth2 + M2M access tokens. This is the new 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** (⚠️ _Pending future deprecation_) - API access as a Planet end-user using a simple + fixed string bearer key. This is the method that has historically been + documented and recommended for developers using Planet APIs. + +### OAuth2 +OAuth2 authentication requires that the client possess an access token +in order to make API calls. Access tokens are obtained by the client from +the Planet authorization server, which is separate from the API servers, and are +presented by the client to API services to assert 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. +When using the Planet SDK, many of the details of obtaining and refreshing +OAuth2 access tokens will be taken care of for you. + +OAuth2 defines many different ways to obtain access tokens, and a full discussion +is beyond the scope of this SDK user guide. Please refer to the [Resources](#resources) +below for more information. Planet broadly divides OAuth2 use cases into +user-interactive and machine-to-machine use cases, as described in this guide. + +**SDK Examples:** + +* **OAuth2 user access tokens** + * [Using the CLI (Quick start)](../auth-dev-cli-managed/#planet-auth-login-planet-user) + * [Forcing use of SDK Built-in Application ID in code (Quick start)](../auth-dev-cli-managed/#use-cli-session-force-builtin) + * [Using a custom registered application ID](../auth-dev-app-managed-oauth/#oauth2-session-for-users) +* **OAuth2 M2M access tokens** + * [Using the CLI (Quick start)](../auth-dev-cli-managed/#planet-auth-login-planet-m2m) + * [Using a M2M Access Token in code](../auth-dev-app-managed-oauth/#oauth2-session-for-service-accounts) + +!!! info + OAuth2 user access tokens currently work for all Planet APIs under both + the `api.planet.com` and `services.sentinel-hub.com` domains. + + 🚧 OAuth2 machine-to-machine (M2M) access tokens are currently available for use + with `services.sentinel-hub.com` APIs. Work to support `api.planet.com` is + ongoing. It should also be noted that at this time no API clients for + `services.sentinel-hub.com` APIs have been incorporated into this SDK. + The SDK may still be used to obtain and manage M2M access tokens to + support external applications. + +### Planet API Keys +Planet API keys are simple fixed strings that may be presented by the client +to API services to assert the client's right to access APIs. API keys are +obtained by the user from their [Account](https://www.planet.com/account) page +under the [_My Settings_](https://www.planet.com/account/#/user-settings) tab. + +**SDK Examples:** + +* **Planet API keys** + * [Using the CLI (Quick start)](../auth-dev-cli-managed/#planet-auth-login-planet-apikey) + * [Using a Planet API Key in code](../auth-dev-app-managed-apikey) + + +!!! warning + Planet API keys are being targeted for eventual deprecation in favor + of OAuth2 mechanisms for most use cases. No specific timeframe has been + set for disabling API keys, but new development should use OAuth2 + mechanisms where possible. + + Planet API keys will work for Planet APIs underneath `api.planet.com`, but + will **NOT** work for APIs underneath `services.sentinel-hub.com`. + + There is no plan for API keys to ever be supported by APIs underneath + `services.sentinel-hub.com`. + +---- + +## Resources +More information regarding Authentication to Planet APIs, OAuth2, and JWTs +may be found here: + +* [Planet Authentication](https://docs.planet.com/develop/authentication/) +* [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) +* [RFC 6819 - OAuth 2.0 Threat Model and Security Considerations](https://datatracker.ietf.org/doc/html/rfc6819) + +---- diff --git a/docs/auth/auth-sdk.md b/docs/auth/auth-sdk.md new file mode 100644 index 000000000..1831a86f0 --- /dev/null +++ b/docs/auth/auth-sdk.md @@ -0,0 +1,133 @@ +# Authentication with the SDK + +## Overview +The [`planet.Auth`](../../python/sdk-reference/#planet.auth.Auth) class is the +main class that is responsible for managing how clients built with the SDK +authenticate to the Planet Insights Platform API services. By default, +API clients provided by the SDK will create an `Auth` instance that is connected +to login sessions managed by the [`planet auth`](../../cli/cli-reference/#auth) +CLI utility, with state saved to the `.planet.json` file and `.planet` +directory in the user's home directory. + +When applications require more control over the authentication process, +constructor methods on the [`planet.Auth`](../../python/sdk-reference/#planet.auth.Auth) +class may be used to create instances with specific configurations. +`Auth` instances may then be wrapped in [`planet.Session`](../../python/sdk-reference/#planet.http.Session) +objects so they can be attached to the +[`planet.Planet`](../../python/sdk-reference/#planet.client.Planet) synchronous +client, or various [asynchronous API clients](../../python/async-sdk-guide/) provided by the SDK. + +## Configuration + +When determining how to authenticate requests made against the Planet +APIs, the default behavior of the SDK and the Planet CLI is to load +configuration from a number of sources at runtime: + +- Highest priority is given to arguments passed to the [`Auth`](../../python/sdk-reference/#planet.auth.Auth) + class (when using the SDK) or via the command line (when using the CLI). + When saving preferences using the CLI, configuration is saved to + configuration files (below). +- Next, environment variables are checked. + Of these, `PL_API_KEY` has been used by Planet software for many years, + and is the most likely to be set in a user's environment. + The other environment variables are new to version 3 of the Planet Python SDK. + **Note**: This means that environment variables override configuration + saved by the `planet` CLI program. See [Environment Variables](#environment-variables) + below. +- Then, the configuration file `.planet.json` and files underneath + the `.planet` directory in the user's home directory are consulted. + These configuration files may be managed with the + [`planet auth profile`](../../cli/cli-reference/#profile) CLI command. +- Finally, built-in defaults will be used. + +### Environment Variables +When the SDK is not otherwise explicitly configured by an application, +or behavior is not overridden by command-line arguments, the following +environment variables will be used: + +| Variable | Description | +|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------| +| **`PL_AUTH_PROFILE`** | Specify a custom CLI managed auth client profile by name. This must name a valid CLI managed profile or an error will occur. | +| **`PL_AUTH_CLIENT_ID`** | Specify an OAuth2 M2M client ID. `PL_AUTH_CLIENT_SECRET` must also be specified, or this will be ignored. | +| **`PL_AUTH_CLIENT_SECRET`** | Specify an OAuth2 M2M client secret. `PL_AUTH_CLIENT_ID` must also be specified, or this will be ignored. | +| **`PL_AUTH_API_KEY`** | Specify a legacy Planet API key. | + +When multiple conflicting environment variables are set, `PL_AUTH_PROFILE` is +preferred over `PL_AUTH_CLIENT_ID` and `PL_AUTH_CLIENT_SECRET`, which are +preferred over `PL_AUTH_API_KEY`. + +### Reset User Configuration +The following commands may be used to clear an environment of any +previously configured settings: + +```sh title="Clear saved authentication settings" +unset PL_API_KEY +unset PL_AUTH_PROFILE +unset PL_AUTH_CLIENT_ID +unset PL_AUTH_CLIENT_SECRET +planet auth reset +``` + +## Profiles +Collectively, the configuration of the SDK to use a specific authentication +protocol (see [overview](../auth-overview#authentication-protocols)) and a +working set of session state information is termed a _profile_ by the SDK +and the CLI. Profiles are an abstraction of the SDK and the CLI, and are +not inherent to authentication to the Planet platform generally. + +The [`planet auth profile`](../../cli/cli-reference/#profile) CLI command +is provided to manage persistent profiles and sessions in the user's home +directory. These home directory persisted profiles are shared between the CLI +and applications built using the SDK. + +Applications built using the SDK may be configured to bypass home directory +profile and session storage, if this better suits the needs of the application. +See [Applicaiton Managed Sessions](../auth-dev-app-managed-oauth) for detailed +examples. + +## Sessions + +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. See [CLI Managed Sessions](../auth-dev-cli-managed) +for examples. + +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 resulting session 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 callbacks to store updated session information. +See [Application Managed Sessions](../auth-dev-app-managed-oauth) +for examples. + +### Session Persistence + +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, but will be forced to create a new login session every time the +application is run. If the rate of repeated logins is too great, this may +result in throttling by the authorization service. Particular attention should +be paid to this when creating automated processes that utilize service users. + +The SDK provides the option to save session state in the user's +home directory in a way that is compatible with the CLI. +When [CLI Managed Sessions](../auth-dev-cli-managed) are used, no additional +steps should be required of the application developer. + +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`](https://planet-auth.readthedocs.io/en/latest/api-planet-auth/#planet_auth.ObjectStorageProvider) +abstract base class. + +---- diff --git a/docs/cli/cli-guide.md b/docs/cli/cli-guide.md index 5a5701b7e..c93798b79 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= +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..7a04b3615 100644 --- a/docs/get-started/quick-start-guide.md +++ b/docs/get-started/quick-start-guide.md @@ -27,10 +27,13 @@ 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/cli-reference/#auth) CLI command to establish +a user login session that will be saved to the user's home directory. This +session will be picked up by SDK library functions by default. For other +authentication options, see the [Client Authentication Guide](../auth/auth-overview.md). ```bash -export PL_API_KEY=your_api_key +planet auth login ``` ### The Planet client @@ -39,7 +42,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/get-started/upgrading.md b/docs/get-started/upgrading-v2.md similarity index 100% rename from docs/get-started/upgrading.md rename to docs/get-started/upgrading-v2.md diff --git a/docs/get-started/upgrading-v3.md b/docs/get-started/upgrading-v3.md new file mode 100644 index 000000000..bc515acfc --- /dev/null +++ b/docs/get-started/upgrading-v3.md @@ -0,0 +1,95 @@ +# Upgrade from Version 2 to Version 3 + +Version 3 of the Planet SDK for Python is a major update of the SDK offering +new features, not all of which are backwards compatible with version 2. + +## Authentication +Version 3 of the SDK removes support for Planet's legacy authentication network +protocols in favor of OAuth2 based mechanisms. The legacy protocols +were never a [documented Planet API](https://docs.planet.com/develop/apis/), but could +easily be understood by inspection of the SDK code. + +Specifically, what is being deprecated in version 3 are the paths where the SDK +handled a username and password to obtain the user's API key for forward +operations. Users may still operate with an API key by retrieving it from the +Planet user interface under [My Settings](https://www.planet.com/account/#/user-settings) +and providing it to the SDK. While API keys remain supported for machine-to-machine +API use cases using `api.planet.com` APIs, OAuth2 mechanisms should be preferred +where the use case allows for it. + +Users may also continue to initialize SDK and CLI sessions with their username +and password, but rather than being processed by the SDK itself a browser must +be invoked to complete OAuth2 client session initialization. +This new method is intended to offer a number of long-term benefits, including: + +* The new method provides the SDK and the CLI with access tokens that may be + used with both `api.planet.com` and `services.sentinel-hub.com` endpoints. The method + used by version 2 of the SDK was specific to `api.planet.com` endpoints, and + will never be supported by `services.sentinel-hub.com` endpoints. +* The new method extends (currently optional) multifactor authentication (MFA) + to SDK and CLI client use cases. +* The new method is compatible with other platform enhancements currently under + development by Planet's software engineering team. + +For complete details on the new mechanisms, see the [Client Authentication Guide](../auth/auth-overview.md). + +### CLI Usage +The [`planet auth`](../../cli/cli-reference/#auth) command has been substantially +revised to align to the new authentication mechanisms. For migration from version 2 +of the SDK, the following changes are the most important to note: + +* The `planet auth init` command has been replaced with [`planet auth login`](../../cli/cli-reference/#login). + By default, this command will open a browser window to allow the user to log + in to their Planet account and authorize the SDK or CLI to access their account. + Other options are available to support a variety of use cases, including a + `--no-open-browser` option for remote shells. See `planet auth login --help` + for complete details. +* The `planet auth value` command has been deprecated. Depending on whether the SDK + has been initialized with OAuth2 or API key authentication, + [`planet auth print-access-token`](../../cli/cli-reference/#print-access-token) + or [`planet auth print-api-key`](../../cli/cli-reference/#print-api-key) may + be used. OAuth2 sessions should be preferred where possible. +* The `planet auth store` command has been deprecated. The various options to the + `planet auth login` command should provide suitable alternatives for all use cases. + OAuth2 sessions should be favored for user interactive use cases, such as CLI usage. + `planet auth login --auth-api-key YOUR_API_KEY` may be used to initialize the SDK + with API key based authentication where the use case requires it. + +### Session Persistence +Both version 2 and version 3 of the SDK use the `~/.planet.json` file in the user's +home directory to store the user's API key. If this file is present and was configured +by version 2 of the SDK, it should continue to work. + +While the `~/.planet.json` file continues to be used by version 3, and version 3 +understands files written by version 2, version 3 will not write the same information +to this file that version 2 did. Version 3 uses this file in conjunction with the +`~/.planet` directory and subdirectories to store OAuth2 tokens and additional +session information needed for a smooth user experience. + +Version 3 of the SDK provides a [`planet auth reset`](../../cli/cli-reference/#reset) +command to reset all saved state should it become corrupted. When this command is run, +the old files are moved aside rather than deleted. + +### SDK Session Initialization +See the [Client Authentication Guide](../auth/auth-overview.md) for a complete +discussion of all options now available. + +Basic SDK use cases should work with no alterations. +User sessions initialized by [`planet auth login`](../../cli/cli-reference/#login) +will be detected by an application using a default Planet client when +run in an environment with access to the user's home directory. For example: + +```python linenums="1" +{% include 'auth-session-management/cli_managed_auth_state__implicit.py' %} +``` + +Applications may also continue to initialize the SDK with a specific API key as follows: +```python linenums="1" +{% include 'auth-session-management/app_managed_auth_state__in_memory__api_key.py' %} +``` + +Users developing new applications should consult the [Client Authentication Guide](../auth/auth-overview.md) +for a complete discussion of all OAuth2 based mechanisms. OAuth2 mechanisms +should be preferred to the use of Planet API keys. + +---- diff --git a/docs/python/async-sdk-guide.md b/docs/python/async-sdk-guide.md index af0162486..68dd00143 100644 --- a/docs/python/async-sdk-guide.md +++ b/docs/python/async-sdk-guide.md @@ -6,7 +6,7 @@ This guide is for the Planet Async SDK for Python users who want to use asynchro This guide walks you through the steps: -* **[Authenticate](#authenticate-with-planet-services)**—pass your username and password to Planet services to verify your permissions to data. +* **[Authenticate](#authenticate-with-planet-services)**—authenticate to Planet services to verify your permissions to data. * **[Create a session](#create-a-session)**—set up a context for calling on Planet servers and receiving data back. * **[Create an order](#create-an-order)**—build an orders client, send the request within the session context, and download it when it’s ready. * **[Collect and list data](#collecting-results)**—handle the potentially large number of results from a search for imagery. @@ -22,52 +22,36 @@ pip install planet ## Authenticate with Planet services -An SDK `Session` requires authentication to communicate with Planet services. This -authentication information is retrieved when a `Session` is created. By default, -a `Session` retrieves authorization key from the environment variable `PL_API_KEY` or a secret file, in that order of priority. - -The SDK provides the `auth.Auth` class for managing authentication information. -This module can be used to obtain authentication information from the username -and password with `Auth.from_login()`. Additionally, it can be created with -the API key obtained directly from the Planet account site with `Auth.from_key()`. - -Once you have provided the authentication information (in other words, the username and API key), it can be accessed by way of the `Auth.value`. The most convenient way of managing it for local use is to write it to a secret file using `Auth.write()`. For example, to obtain and store authentication information: - -Once you have provided the authentication information (in other words, the account username and password), it can be accessed by way of `Auth.value`. The most convenient way of managing it for local use is to write it to a secret file using `Auth.write()`. -It can also be accessed, for example, to store in an environment variable, such as -`Auth.value`. - -Here is an example of retrieving and storing authentication information: - -```python -# Get the user account name and password -# from the command line and environment, -# and store credentials in an Auth object -import getpass -from planet import Auth - -user = input("Username: ") -pw = getpass.getpass() -auth = Auth.from_login(user,pw) -auth.store() -``` - -The default authentication behavior of the `Session` can be modified by specifying -`Auth` explicitly using the methods `Auth.from_file()` and `Auth.from_env()`. -While `Auth.from_key()` and `Auth.from_login` can be used, it is recommended -that those functions be used in authentication initialization. Authentication -information should be stored using `Auth.store()`. - -You can customize the manner of retrieval and location to read from when retrieving the authorization information. The file and environment variable read from can be customized in the respective functions. For example, authentication can be read from a custom -environment variable, as in the following code: +An SDK `Session` requires authentication to communicate with Planet services. The +details of authentication are managed with the [`planet.Auth`](../../python/sdk-reference/#planet.auth.Auth) +class, and are configured when a `Session` is created. Default behavior +shares the responsibility of managing authentication sessions with the [`planet auth`](../../cli/cli-reference/#auth) +CLI utility, which stores authentication sessions in the user's home directory. + +The default authentication behavior of the `Session` can be modified by providing an +`Auth` instance when creating the `Session`. There are many different ways to +create an `Auth` instance, depending on the use case. See +[Client Authentication Overview](../../auth/auth-overview/) +and [Authentication with the SDK](../../auth/auth-sdk/) for more details concerning +Planet Insights Platform authentication with the SDK. 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. + +For example, a program may wish create the `Auth` instance prior to setting up +the `Session` to guide the user towards external setup steps: ```python import asyncio -import os +import sys from planet import Auth, Session -auth = Auth.from_env('ALTERNATE_VAR') +auth = Auth.from_user_default_session() async def main(): + if not auth.is_initialized(): + print("Login required. Execute the following command:\n\n\tplanet auth login\n") + sys.exit(99) + async with Session(auth=auth) as sess: # perform operations here pass diff --git a/docs/python/sdk-client-auth.md b/docs/python/sdk-client-auth.md new file mode 100644 index 000000000..fe22f5179 --- /dev/null +++ b/docs/python/sdk-client-auth.md @@ -0,0 +1,15 @@ +# Authentication SDK Guide + +For general information on how to authenticate to Planet APIs, see the +[Authentication](https://docs.planet.com/develop/authentication/) section of Planet's +Insights Platform documentation. + +See [Client Authentication Overview](../../auth/auth-overview) for an overview +of authentication to the Planet Insights Platform that is geared towards SDK +users, and includes a discussion of authentication protocols that are under +construction and available for early access to SDK users. + +[Authentication with the SDK](../../auth/auth-sdk) provides a primer +on how to use the Planet SDK for Python to authenticate to Planet APIs. + +---- diff --git a/docs/python/sdk-guide.md b/docs/python/sdk-guide.md index 5dcd71c3d..19cf53ed6 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. For more advanced use cases, see the +[Client Authentication Guide](../auth/auth-overview.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 using OAuth2, and support for legacy applications using Planet API keys. ### Search 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..67a883204 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_m2m.py @@ -0,0 +1,101 @@ +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 mtime(self, key: ObjectStorageProvider_KeyType) -> float: + obj_filepath = self._demo_obj_filepath(key) + return obj_filepath.stat().st_mtime + + 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 the client ID and secret of the service account. + 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..cdbfe6577 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_authcode__with_browser.py @@ -0,0 +1,110 @@ +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 mtime(self, key: ObjectStorageProvider_KeyType) -> float: + obj_filepath = self._demo_obj_filepath(key) + return obj_filepath.stat().st_mtime + + 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..9f36cc797 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__app_custom_storage__oauth_user_devicecode__external_browser.py @@ -0,0 +1,147 @@ +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 mtime(self, key: ObjectStorageProvider_KeyType) -> float: + obj_filepath = self._demo_obj_filepath(key) + return obj_filepath.stat().st_mtime + + 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..cde34a5a7 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__api_key.py @@ -0,0 +1,23 @@ +import json +import planet + + +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..867fdd170 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_m2m.py @@ -0,0 +1,27 @@ +import json +import planet + + +def example_main(): + # Create an auth context with the client ID and secret of the service account. + 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..a7274db99 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_authcode__with_browser.py @@ -0,0 +1,35 @@ +import json +import planet + + +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..087dacabf --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__in_memory__oauth_user_devicecode__external_browser.py @@ -0,0 +1,72 @@ +import json +import planet + + +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..7afcf7652 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_m2m.py @@ -0,0 +1,28 @@ +import json +import planet + + +def example_main(): + # Create an auth context with the client ID and secret of the service account. + 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..4beeb5a28 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_authcode__with_browser.py @@ -0,0 +1,37 @@ +import json +import planet + + +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..0b74a39d1 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_cli_shared__oauth_user_devicecode__external_browser.py @@ -0,0 +1,74 @@ +import json +import planet + + +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..7c0761069 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__on_disk_legacy__api_key.py @@ -0,0 +1,23 @@ +import json +import planet + + +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/app_managed_auth_state__using_sdk_app_id.py b/examples/auth-session-management/app_managed_auth_state__using_sdk_app_id.py new file mode 100644 index 000000000..afdc8fd96 --- /dev/null +++ b/examples/auth-session-management/app_managed_auth_state__using_sdk_app_id.py @@ -0,0 +1,29 @@ +import json +import planet + + +def example_main(): + # Load the OAuth2 user-interactive client configration that is built-into the SDK. + # This configuration is shared with the `planet` CLI command. + # When save_state_to_storage is true, sessions will be shared with the + # CLI and saved to the user's home directory. When save_state_to_storage + # is false, the state will only be persistent in memory and the + # user will need to login each time the application is run. + plsdk_auth = planet.Auth.from_profile("planet-user", + save_state_to_storage=False) + + if not plsdk_auth.is_initialized(): + plsdk_auth.user_login(allow_open_browser=True, allow_tty_prompt=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/cli_managed_auth_state__explicit.py b/examples/auth-session-management/cli_managed_auth_state__explicit.py new file mode 100644 index 000000000..2ad7b8c42 --- /dev/null +++ b/examples/auth-session-management/cli_managed_auth_state__explicit.py @@ -0,0 +1,29 @@ +import json +import planet +import sys + + +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..bd6397803 --- /dev/null +++ b/examples/auth-session-management/cli_managed_auth_state__implicit.py @@ -0,0 +1,18 @@ +import json +import planet + + +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 inspect environment variables for configuration. + pl = planet.Planet() + + # Use the SDK to call Planet APIs. + # Refreshing OAuth2 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..42f334312 --- /dev/null +++ b/examples/auth-session-management/cli_managed_auth_state__specific_auth_profile.py @@ -0,0 +1,34 @@ +import json +import planet +import sys + + +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-app-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 36d13ac71..039dd2798 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,9 @@ extra_css: plugins: - search + - macros: + include_dir: 'examples' + on_error_fail: true - mkdocstrings: handlers: python: @@ -59,7 +62,7 @@ plugins: selection: inherited_members: true filters: - - "!^_" # exlude all members starting with _ + - "!^_" # exclude all members starting with _ - "^__init__$" # but always include __init__ modules and methods watch: - planet @@ -73,7 +76,9 @@ nav: - get-started/quick-start-guide.md - get-started/get-your-planet-account.md - get-started/venv-tutorial.md - - get-started/upgrading.md + - "Upgrade Guides": + - get-started/upgrading-v3.md + - get-started/upgrading-v2.md - "No Code CLI": - cli/cli-guide.md - cli/cli-intro.md @@ -84,8 +89,15 @@ nav: - cli/cli-reference.md - "Python": - python/sdk-guide.md + - python/sdk-client-auth.md - python/async-sdk-guide.md - python/sdk-reference.md + - "Client Authentication": + - auth/auth-overview.md + - auth/auth-sdk.md + - auth/auth-dev-cli-managed.md + - auth/auth-dev-app-managed-oauth.md + - auth/auth-dev-app-managed-apikey.md - "Resources": - resources/index.md - "Home": 'index.md' diff --git a/noxfile.py b/noxfile.py index 833c1c3c5..4fe10d2b6 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..f7acdb11a 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,257 +15,491 @@ """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 . import http -from .constants import ENV_API_KEY, PLANET_BASE_URL, SECRET_FILE_PATH -from .exceptions import AuthException +from typing import List -LOGGER = logging.getLogger(__name__) +from .auth_builtins import _ProductionEnv, _OIDC_AUTH_CLIENT_CONFIG__USER_SKEL, _OIDC_AUTH_CLIENT_CONFIG__M2M_SKEL +import planet_auth +import planet_auth_utils -BASE_URL = f'{PLANET_BASE_URL}/v0/auth' +from .constants import SECRET_FILE_PATH +from .exceptions import PlanetError -AuthType = httpx.Auth +planet_auth.setStructuredLogging(nested_key=None) -class Auth(metaclass=abc.ABCMeta): - """Handle authentication information for use with Planet APIs.""" +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 from_key(key: str) -> AuthType: - """Obtain authentication from api key. + 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() - Parameters: - key: Planet API key + @staticmethod + def from_user_default_session() -> Auth: """ - auth = APIKeyAuth(key=key) - LOGGER.debug('Auth obtained from api key.') - return auth + Create authentication context from user defaults. - @staticmethod - def from_file( - filename: Optional[typing.Union[str, - pathlib.Path]] = None) -> AuthType: - """Create authentication from secret file. + This method should be used when an application wants to defer + auth profile management to the user and the `planet auth` CLI + command entirely. - The 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()`. + Users may use the `planet auth login` and `planet auth profile + commands to initialize and manage sessions. - Parameters: - filename: Alternate path for the planet secret file. + Defaults take into account environment variables (highest priority), + user configuration saved to `~/.planet.json` and `~/.planet/` + (next priority), and built-in defaults (lowest priority). - """ - filename = filename or SECRET_FILE_PATH + This method does not support the use a custom storage provider. + + Environment Variables: - 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.') + | Variable Name | Description | + | --------------------- | ------------------------------------------------------------------ | + | 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 previously saved planet_auth library auth client profile | - LOGGER.debug(f'Auth read from secret file {filename}.') - return auth + """ + return _PLAuthLibAuth(plauth=planet_auth_utils.PlanetAuthFactory. + initialize_auth_client_context()) @staticmethod - def from_env(variable_name: Optional[str] = None) -> AuthType: - """Create authentication from environment variable. + def from_profile( + profile_name: str, + save_state_to_storage: bool = True, + ) -> Auth: + """ + Create authentication context from an auth session that has been + initialized and saved to `~/.planet.json` and `~/.planet/`. - Reads the `PL_API_KEY` environment variable + Users can initialize and save such a session out-of-band + using the `planet auth login` and `planet auth profile` commands. - 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 + To initialize this session programmatically without the CLI, + you must complete an OAuth2 user login flow with one of the login + methods on this class. The login method used must be compatible + with the specified profile. - @staticmethod - def from_login(email: str, - password: str, - base_url: Optional[str] = None) -> AuthType: - """Create authentication from login email and password. + This method does not support the use a custom storage provider. - Note: To keep your password secure, the use of `getpass` is - recommended. + In addition to sharing sessions with other programs through the user's + home directory, this method may also be used to load SDK built-in + client profiles. This is provided as a developer convenience. + Applications _should_ register unique client IDs with the Planet service + and use `from_oauth_user_auth_code()` or `from_oauth_user_device_code()` + to create profiles unique to the application. + At present, the following built-in profiles are available: + + | Profile Name | Description | + | ------------ | -------------------------------------------------------------------- | + | `planet-user` | User interactive OAuth2 client profile shared with the `planet` CLI. | Parameters: - email: Planet account email address. - password: Planet account password. - base_url: The base URL to use. Defaults to production - authentication API base url. + profile_name: Named profile from which to load auth configuration + and state. This should be a name of a CLI managed profile. + save_state_to_storage: Boolean controlling whether login sessions + should be saved to storage. This nearly always should be true, + since this constructor exists to share state through storage + backed profiles. The only exception may be when using a SDK + built-in profile in an application that should not attempt to + save state to disk. + """ + if not profile_name: + raise APIKeyAuthException('Profile name cannot be empty.') + pl_authlib_context = planet_auth_utils.PlanetAuthFactory.initialize_auth_client_context( + auth_profile_opt=profile_name, + save_token_file=save_state_to_storage, + save_profile_config=save_state_to_storage) + return _PLAuthLibAuth(plauth=pl_authlib_context) + + # TODO: add support for confidential clients + @staticmethod + 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: """ - cl = AuthClient(base_url=base_url) - auth_data = cl.login(email, password) + Create authentication context for the specified registered client + application. - api_key = auth_data['api_key'] - auth = APIKeyAuth(api_key) - LOGGER.debug('Auth set from login email and password') - return auth + 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. - @classmethod - @abc.abstractmethod - def from_dict(cls, data: dict) -> AuthType: - pass + 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. - @property - @abc.abstractmethod - def value(self): - pass + 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 context for the specified registered client + application. - @abc.abstractmethod - def to_dict(self) -> dict: - pass + 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. - def store(self, - filename: Optional[typing.Union[str, pathlib.Path]] = None): - """Store authentication information in secret file. + 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 `device_user_login_initiate()` and + `device_user_login_complete()` before API calls may be made. Parameters: - filename: Alternate path for the planet secret file. + 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. """ - filename = filename or SECRET_FILE_PATH - secret_file = _SecretFile(filename) - secret_file.write(self.to_dict()) - + 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) -class AuthClient: - - def __init__(self, base_url: Optional[str] = None): + @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: - base_url: The base URL to use. Defaults to production - authentication API base url. + 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. """ - self._base_url = base_url or BASE_URL - if self._base_url.endswith('/'): - self._base_url = self._base_url[:-1] + 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) - def login(self, email: str, password: str) -> dict: - """Login using email identity and credentials. + @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) - Note: To keep your password secure, the use of `getpass` is - recommended. + @staticmethod + def from_key(key: typing.Optional[str]) -> Auth: + """Obtain authentication from api key. 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. + key: Planet API key """ - url = f'{self._base_url}/login' - data = {'email': email, 'password': password} + 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.') - sess = http.AuthSession() - resp = sess.request(url=url, method='POST', json=data) - return self.decode_response(resp) + 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 decode_response(response): - """Decode the token JWT""" - token = response.json()['token'] - return jwt.decode(token, options={'verify_signature': False}) + def from_file( + filename: typing.Optional[typing.Union[str, + pathlib.Path]] = None) -> Auth: + """Create authentication from secret file. + 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()`. -class APIKeyAuthException(AuthException): - """exceptions thrown by APIKeyAuth""" - pass + 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. -class APIKeyAuth(httpx.BasicAuth, Auth): - """Planet API Key authentication.""" - DICT_KEY = 'key' + """ + 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) + return _PLAuthLibAuth(plauth=pl_authlib_context) - def __init__(self, key: str): - """Initialize APIKeyAuth. + @staticmethod + def from_env(variable_name: typing.Optional[str] = None) -> Auth: + """Create authentication from environment variables. - Parameters: - key: API key. + Reads the `PL_API_KEY` environment variable - Raises: - APIKeyException: If API key is None or empty string. + Pending Deprecation: + This method is pending deprecation. The method `from_user_default_session()` + considers environment variables and configuration files through + the planet_auth and planet_auth_utils libraries, and works with + legacy API keys, OAuth2 M2M clients, and OAuth2 interactive profiles. + This method should be used in most cases as a replacement. + + Parameters: + variable_name: Alternate environment variable. """ - if not key: - raise APIKeyAuthException('API key cannot be empty.') - self._key = key - super().__init__(self._key, '') + warnings.warn( + "from_env() will be deprecated. Use from_user_default_session() 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: typing.Optional[str] = None) -> Auth: + raise DeprecationWarning( + "Auth.from_login() has been deprecated. Use Auth.from_user_session()." + ) @classmethod - def from_dict(cls, data: dict) -> APIKeyAuth: - """Instantiate APIKeyAuth from a dict.""" - api_key = data[cls.DICT_KEY] - return cls(api_key) + def from_dict(cls, data: dict) -> Auth: + raise DeprecationWarning("Auth.from_dict() has been deprecated.") - def to_dict(self): - """Represent APIKeyAuth as a dict.""" - return {self.DICT_KEY: self._key} + def to_dict(self) -> dict: + raise DeprecationWarning("Auth.to_dict() has been deprecated.") + + def store(self, + filename: typing.Optional[typing.Union[str, + pathlib.Path]] = None): + raise DeprecationWarning("Auth.store() has been deprecated.") @property def value(self): - return self._key + 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. + + :param allow_open_browser: + :param allow_tty_prompt: + """ + + @abc.abstractmethod + def device_user_login_initiate(self) -> dict: + """ + 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. + """ + + @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. + """ + @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 _SecretFile: - def __init__(self, path: typing.Union[str, pathlib.Path]): - self.path = pathlib.Path(path) +class APIKeyAuthException(PlanetError): + """exceptions thrown by APIKeyAuth""" + pass - 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..f870639d8 --- /dev/null +++ b/planet/auth_builtins.py @@ -0,0 +1,155 @@ +# 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_config_injection import ( + AUTH_BUILTIN_PROVIDER, + BuiltinConfigurationProviderInterface, +) + +# Needs to be set before any planet_auth or planet_auth_utils imports. +os.environ[ + AUTH_BUILTIN_PROVIDER] = "planet.auth_builtins._BuiltinConfigurationProvider" + + +# 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": [""] + "_hidden": True, +} + +_LEGACY_AUTH_CLIENT_CONFIG__PROD = { + **_ProductionEnv.LEGACY_AUTH_AUTHORITY, + "client_type": "planet_legacy", + "_hidden": True, +} + + +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_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_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_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..a789ec7aa 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,62 @@ # 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.group("auth") # 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 +def cmd_auth(ctx): + """ + Commands for working with Planet authentication. + """ -@auth.command() # 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)') +cmd_auth.add_command(name="login", cmd=planet_auth_utils.cmd_plauth_login) +planet_auth_utils.monkeypatch_hide_click_cmd_options( + planet_auth_utils.cmd_plauth_login, + [ + # Hide client ID / client secret until we are ready for OAuth M2M + # "auth_client_id", + # "auth_client_secret", + # Hide audience and organization. They are useful for plauth as a + # generic OAuth client, but within the planet SDK we only care about + # the built-ins. + "audience", + "organization", + # Hide project. We have not finalized or publicly released the + # project selection interface. + "project", + ]) +# 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 -def value(): - """Print the stored authentication information""" - click.echo(planet.Auth.from_file().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. + """ -@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)') +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_profile.add_command(name="copy", + cmd=planet_auth_utils.cmd_profile_copy) +cmd_auth.add_command(cmd_auth_profile) diff --git a/planet/cli/cli.py b/planet/cli/cli.py index 328cf4f98..2b63cd03d 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,34 @@ 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 + # Embedded click commands imported from planet_auth_utils expect + # this in the 'AUTH' context field. + 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, + use_env=True, + use_configfile=True) + + # 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 +113,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 3d6bb16da..cad27acd7 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 53f626d82..f971bb8d2 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 @@ -489,44 +483,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 59ef0923a..770df1d34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "pyjwt>=2.1", "tqdm>=4.56", "typing-extensions", + "planet-auth>=2.1.0", ] 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.4.2", - "mkdocs-click==0.7.0", - "mkdocs-material==8.2.11", - "mkdocstrings==0.18.1", - "mkdocs_autorefs==1.0.1", + "mkdocs==1.4.2", + "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 e91e27e59..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 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 1192244a3..4e6202825 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..72c5cea76 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 circuit 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,217 @@ 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() + + +def test_auth_from_dict_deprecated(): + with pytest.raises(DeprecationWarning): + _ = auth.Auth.from_dict({}) - secret_path.chmod(0o666) - auth._SecretFile(secret_path) - assert secret_path.stat().st_mode & 0o777 == 0o600 +def test_plauth_builtins_namespace(): + # Planet auth can prefix environment and config variables with a namespace. + # Make sure that is as we want it for the SDK. + assert planet_auth_utils.EnvironmentVariables.AUTH_API_KEY == "PL_API_KEY" + assert planet_auth_utils.EnvironmentVariables.AUTH_SCOPE == "PL_AUTH_SCOPE" + assert planet_auth_utils.EnvironmentVariables.AUTH_PROFILE == "PL_AUTH_PROFILE" 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={}))