Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DRAFT : Carl/oauth via plauth lib #1063

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ coverage.xml
*.pyc

# Editors
.idea/
.vscode/
# Docs build
site
.venv
venv
8 changes: 8 additions & 0 deletions Branch-Working-and-Release-Notes.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
- Replaced most user auth code with the planet auth library. This brings with it OAuth for user interactive and M2M flows.
- Deprecated the old auth command. New auth command is implemented with the separate auth library
- TODO: link to lib
- planet auth init -> planet auth oauth login (or, planet --auth-profile legacy auth legacy login)
- planet auth value -> planet plauth oauth print-access-token (or planet --auth-profile legacy auth legacy print-api-key)
- The legacy secret file has largely been deprecated. This will require a user migration.

# TODO: we need to allocate a client ID for the `planet` CLI. We are presently leaning on plauth's client ID. (maybe this is OK)
254 changes: 64 additions & 190 deletions planet/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Copyright 2020 Planet Labs, Inc.
# Copyright 2022 Planet Labs PBC.
# Copyright 2022, 2024 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.
Expand All @@ -15,28 +15,25 @@
"""Manage authentication with Planet APIs"""
from __future__ import annotations # https://stackoverflow.com/a/33533514
import abc
import json
import logging
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
import planet_auth
import planet_auth_config
import planet_auth_utils

LOGGER = logging.getLogger(__name__)
from .constants import SECRET_FILE_PATH

BASE_URL = f'{PLANET_BASE_URL}/v0/auth'

AuthType = httpx.Auth


# planet_auth and planet_auth_utils code more or less entirely
# entirely supersedes this class. But, keeping this here for
# now for interface stability to bridge with the rest of the SDK.
class Auth(metaclass=abc.ABCMeta):
"""Handle authentication information for use with Planet APIs."""

Expand All @@ -47,14 +44,19 @@ def from_key(key: str) -> AuthType:
Parameters:
key: Planet API key
"""
auth = APIKeyAuth(key=key)
LOGGER.debug('Auth obtained from api key.')
return auth
warnings.warn("Planet API keys will be deprecated at some point."
" Initialize an OAuth client, or create an OAuth service account."
" Proceeding for now.", PendingDeprecationWarning)
pl_authlib_context = planet_auth_utils.ProfileManager.initialize_auth_client_context(
auth_profile_opt=planet_auth_utils.Profiles.BUILTIN_PROFILE_NAME_LEGACY,
auth_api_key_opt=key,
save_token_file=False
)
return _PLAuthLibAuth(plauth=pl_authlib_context)

@staticmethod
def from_file(
filename: Optional[typing.Union[str,
pathlib.Path]] = None) -> AuthType:
filename: typing.Optional[typing.Union[str, pathlib.Path]] = None) -> AuthType:
"""Create authentication from secret file.

The secret file is named `.planet.json` and is stored in the user
Expand All @@ -65,43 +67,39 @@ def from_file(
filename: Alternate path for the planet secret file.

"""
filename = filename or SECRET_FILE_PATH

try:
secrets = _SecretFile(filename).read()
auth = APIKeyAuth.from_dict(secrets)
except FileNotFoundError:
raise AuthException(f'File {filename} does not exist.')
except (KeyError, json.decoder.JSONDecodeError):
raise AuthException(f'File {filename} is not the correct format.')

LOGGER.debug(f'Auth read from secret file {filename}.')
return auth
# There is no direct replacement for "from_file()", which held an API key.
# API keys will be deprecated, and user login will be different from service account
# login under OAuth.
warnings.warn("Auth.from_file() will be deprecated.", PendingDeprecationWarning)
plauth_config = {
**planet_auth_config.Production.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)

@staticmethod
def from_env(variable_name: Optional[str] = None) -> AuthType:
def from_env(variable_name: typing.Optional[str] = None) -> AuthType:
"""Create authentication from environment variable.

Reads the `PL_API_KEY` environment variable

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
# There are just too many env vars and ways they interact and combine to continue to
# support this method with the planet auth lib in the future. Especially as we want
# to move away from API keys and towards OAuth methods.
warnings.warn("Auth.from_env() will be deprecated.", PendingDeprecationWarning)
variable_name = variable_name or planet_auth.EnvironmentVariables.AUTH_API_KEY
api_key = os.getenv(variable_name, None)
return Auth.from_key(api_key)

@staticmethod
def from_login(email: str,
password: str,
base_url: Optional[str] = None) -> AuthType:
base_url: typing.Optional[str] = None) -> AuthType:
"""Create authentication from login email and password.

Note: To keep your password secure, the use of `getpass` is
Expand All @@ -113,159 +111,35 @@ def from_login(email: str,
base_url: The base URL to use. Defaults to production
authentication API base url.
"""
cl = AuthClient(base_url=base_url)
auth_data = cl.login(email, password)

api_key = auth_data['api_key']
auth = APIKeyAuth(api_key)
LOGGER.debug('Auth set from login email and password')
return auth

@classmethod
@abc.abstractmethod
def from_dict(cls, data: dict) -> AuthType:
pass

@property
@abc.abstractmethod
def value(self):
pass

@abc.abstractmethod
def to_dict(self) -> dict:
pass

def store(self,
filename: Optional[typing.Union[str, pathlib.Path]] = None):
"""Store authentication information in secret file.

Parameters:
filename: Alternate path for the planet secret file.
"""
filename = filename or SECRET_FILE_PATH
secret_file = _SecretFile(filename)
secret_file.write(self.to_dict())


class AuthClient:

def __init__(self, base_url: Optional[str] = None):
"""
Parameters:
base_url: The base URL to use. Defaults to production
authentication API base url.
"""
self._base_url = base_url or BASE_URL
if self._base_url.endswith('/'):
self._base_url = self._base_url[:-1]

def login(self, email: str, password: str) -> dict:
"""Login using email identity and credentials.

Note: To keep your password secure, the use of `getpass` is
recommended.

Parameters:
email: Planet account email address.
password: Planet account password.

Returns:
A JSON object containing an `api_key` property with the user's
API_KEY.
"""
url = f'{self._base_url}/login'
data = {'email': email, 'password': password}
warnings.warn("Auth.from_login() and password based user login will be deprecated.", PendingDeprecationWarning)
if base_url:
warnings.warn("base_url is not longer a supported parameter to Auth.from_login()", DeprecationWarning)

sess = http.AuthSession()
resp = sess.request(url=url, method='POST', json=data)
return self.decode_response(resp)
pl_authlib_context = planet_auth_utils.ProfileManager.initialize_auth_client_context(
auth_profile_opt=planet_auth_utils.Profiles.BUILTIN_PROFILE_NAME_LEGACY
)
# Note: login() will save the resulting token
pl_authlib_context.login(username=email, password=password, allow_tty_prompt=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})


class APIKeyAuthException(Exception):
"""exceptions thrown by APIKeyAuth"""
pass


class APIKeyAuth(httpx.BasicAuth, Auth):
"""Planet API Key authentication."""
DICT_KEY = 'key'

def __init__(self, key: str):
"""Initialize APIKeyAuth.

Parameters:
key: API key.

Raises:
APIKeyException: If API key is None or empty string.
def from_plauth(pl_authlib_context: planet_auth.Auth):
"""
if not key:
raise APIKeyAuthException('API key cannot be empty.')
self._key = key
super().__init__(self._key, '')

@classmethod
def from_dict(cls, data: dict) -> APIKeyAuth:
"""Instantiate APIKeyAuth from a dict."""
api_key = data[cls.DICT_KEY]
return cls(api_key)

def to_dict(self):
"""Represent APIKeyAuth as a dict."""
return {self.DICT_KEY: self._key}

@property
def value(self):
return self._key


class _SecretFile:

def __init__(self, path: typing.Union[str, pathlib.Path]):
self.path = pathlib.Path(path)

self.permissions = stat.S_IRUSR | stat.S_IWUSR # user rw

# in sdk versions <=2.0.0, secret file was created with the wrong
# permissions, fix this automatically as well as catching the unlikely
# cases where the permissions get changed externally
self._enforce_permissions()

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

self._write(secrets_to_write)

def _write(self, contents: dict):
LOGGER.debug(f'Writing to {self.path}')

def opener(path, flags):
return os.open(path, flags, self.permissions)
Create authentication from the provided Planet Auth Library Authentication Context.
Generally, applications will want to use one of the Auth Library helpers to
construct this context, such as the `initialize_auth_client_context()` method.
"""
return _PLAuthLibAuth(plauth=pl_authlib_context)

with open(self.path, 'w', opener=opener) as fp:
fp.write(json.dumps(contents))

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
class _PLAuthLibAuth(Auth, AuthType):
# 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 user's 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 _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)
def auth_flow(self, r: httpx._models.Request):
return self._plauth.request_authenticator().auth_flow(r)
Loading
Loading