Skip to content

feat: add support for tokens and system keyring #171

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Support authentication with API tokens
- Added `Credentials` class for parsing credentials from environment similarly than in our Go based tooling.

## [2.7.0] - 2025-07-23

### Changed
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ python setup.py install
More usage examples are available under [docs/]. If there's a specific thing you're interested in,
but are not able to get working, please [contact UpCloud support](https://upcloud.com/contact/).

### Load credentials from environment

```python
from upcloud_api import CloudManager, Credentials

credentials = Credentials.parse()
c = CloudManager(**credentials.dict)
c.get_account()
```

### Defining and creating servers

```python
Expand Down
3 changes: 2 additions & 1 deletion requirements-dev.in
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
keyring
mock
pip-tools
pytest
pytest-cov
responses
responses
12 changes: 12 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,20 @@ idna==3.10
# via requests
iniconfig==2.1.0
# via pytest
jaraco-classes==3.4.0
# via keyring
jaraco-context==6.0.1
# via keyring
jaraco-functools==4.2.1
# via keyring
keyring==25.6.0
# via -r requirements-dev.in
mock==5.2.0
# via -r requirements-dev.in
more-itertools==10.7.0
# via
# jaraco-classes
# jaraco-functools
packaging==25.0
# via
# build
Expand Down
4 changes: 4 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ install_requires =
packages =
upcloud_api
upcloud_api.cloud_manager

[options.extras_require]
keyring =
keyring>=23.0
8 changes: 8 additions & 0 deletions test/test_cloud_manager.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import json

import pytest
import responses
from conftest import Mock

from upcloud_api import CloudManager
from upcloud_api.errors import UpCloudClientError


class TestCloudManagerBasic:
def test_no_credentials(self):
with pytest.raises(UpCloudClientError):
CloudManager()

@responses.activate
def test_get_account(self, manager):
data = Mock.mock_get("account")
Expand Down
66 changes: 66 additions & 0 deletions test/test_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import keyring

from upcloud_api import Credentials


class DictBackend(keyring.backend.KeyringBackend):
priority = 1

def __init__(self, secrets=None):
super().__init__()
self._secrets = secrets or {}

def set_password(self, servicename, username, password):
pass

def get_password(self, servicename, username):
return self._secrets.get(servicename, {}).get(username)

def delete_password(self, servicename, username):
pass


class TestCredentials:
def test_precedence(self, monkeypatch):
param_basic = 'Basic cGFyYW1fdXNlcjpwYXJhbV9wYXNz'
param_bearer = 'Bearer param_token'
env_basic = 'Basic ZW52X3VzZXI6ZW52X3Bhc3M='
env_bearer = 'Bearer env_token'
keyring_basic = 'Basic ZW52X3VzZXI6a2V5cmluZ19wYXNz'
keyring_bearer = 'Bearer keyring_token'

backend = DictBackend(
{
"UpCloud": {
"env_user": "keyring_pass",
"": "keyring_token",
}
}
)
keyring.set_keyring(backend)

credentials = Credentials.parse()
assert credentials.authorization == keyring_bearer

monkeypatch.setenv("UPCLOUD_USERNAME", 'env_user')

credentials = Credentials.parse()
assert credentials.authorization == keyring_basic

monkeypatch.setenv("UPCLOUD_PASSWORD", 'env_pass')

credentials = Credentials.parse(username='param_user', password='param_pass')
assert credentials.authorization == param_basic

credentials = Credentials.parse()
assert credentials.authorization == env_basic

monkeypatch.setenv("UPCLOUD_TOKEN", 'env_token')
credentials = Credentials.parse(username='param_user', password='param_pass')
assert credentials.authorization == param_basic

credentials = Credentials.parse()
assert credentials.authorization == env_bearer

credentials = Credentials.parse(token='param_token')
assert credentials.authorization == param_bearer
1 change: 1 addition & 0 deletions upcloud_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
__copyright__ = 'Copyright (c) 2015 UpCloud Oy'

from upcloud_api.cloud_manager import CloudManager
from upcloud_api.credentials import Credentials
from upcloud_api.errors import UpCloudAPIError, UpCloudClientError
from upcloud_api.firewall import FirewallRule
from upcloud_api.host import Host
Expand Down
20 changes: 11 additions & 9 deletions upcloud_api/cloud_manager/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import base64

from upcloud_api.api import API
from upcloud_api.cloud_manager.firewall_mixin import FirewallManager
from upcloud_api.cloud_manager.host_mixin import HostManager
Expand All @@ -10,6 +8,8 @@
from upcloud_api.cloud_manager.server_mixin import ServerManager
from upcloud_api.cloud_manager.storage_mixin import StorageManager
from upcloud_api.cloud_manager.tag_mixin import TagManager
from upcloud_api.credentials import Credentials
from upcloud_api.errors import UpCloudClientError


class CloudManager(
Expand All @@ -31,21 +31,23 @@ class CloudManager(

api: API

def __init__(self, username: str, password: str, timeout: int = 60) -> None:
def __init__(
self, username: str = None, password: str = None, timeout: int = 60, token: str = None
) -> None:
"""
Initiates CloudManager that handles all HTTP connections with UpCloud's API.

Optionally determine a timeout for API connections (in seconds). A timeout with the value
`None` means that there is no timeout.
"""
if not username or not password:
raise Exception('Invalid credentials, please provide a username and password')

credentials = f'{username}:{password}'.encode()
encoded_credentials = base64.b64encode(credentials).decode()
credentials = Credentials(username, password, token)
if not credentials.is_defined:
raise UpCloudClientError(
"Credentials are not defined. Please provide username and password or an API token."
)

self.api = API(
token=f'Basic {encoded_credentials}',
token=credentials.authorization,
timeout=timeout,
)

Expand Down
120 changes: 120 additions & 0 deletions upcloud_api/credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import base64
import os

try:
import keyring
except ImportError:
keyring = None

from upcloud_api.errors import UpCloudClientError

ENV_KEY_USERNAME = "UPCLOUD_USERNAME"
ENV_KEY_PASSWORD = "UPCLOUD_PASSWORD" # noqa: S105
ENV_KEY_TOKEN = "UPCLOUD_TOKEN" # noqa: S105

KEYRING_SERVICE_NAME = "UpCloud"
KEYRING_TOKEN_USER = ""
KEYRING_GO_PREFIX = "go-keyring-base64:"


def _parse_keyring_value(value: str) -> str:
if value.startswith(KEYRING_GO_PREFIX):
value = value[len(KEYRING_GO_PREFIX) :]
return base64.b64decode(value).decode()

return value


def _read_keyring_value(username: str) -> str:
if keyring is None:
return None

value = keyring.get_password(KEYRING_SERVICE_NAME, username)
try:
return _parse_keyring_value(value) if value else None
except Exception:
raise UpCloudClientError(
f"Failed to read keyring value for {username}. Ensure that the value saved to the system keyring is correct."
) from None


class Credentials:
"""
Class for handling UpCloud API credentials.
"""

def __init__(self, username: str = None, password: str = None, token: str = None):
"""
Initializes the Credentials object with username, password and/or token. Use `parse` method to read credentials from environment variables or keyring.
"""
self._username = username
self._password = password
self._token = token

@property
def authorization(self) -> str:
"""
Returns the authorization header value based on the provided credentials.
"""
if self._token:
return f"Bearer {self._token}"

credentials = f"{self._username}:{self._password}".encode()
encoded_credentials = base64.b64encode(credentials).decode()
return f"Basic {encoded_credentials}"

@property
def dict(self) -> dict:
"""
Returns the credentials as a dictionary.
"""
return {
"username": self._username,
"password": self._password,
"token": self._token,
}

@property
def is_defined(self) -> bool:
"""
Checks if the credentials are defined.
"""
return bool(self._username and self._password or self._token)

def _read_from_env(self):
if not self._username:
self._username = os.getenv(ENV_KEY_USERNAME)
if not self._password:
self._password = os.getenv(ENV_KEY_PASSWORD)
if not self._token:
self._token = os.getenv(ENV_KEY_TOKEN)

def _read_from_keyring(self):
if self._username and not self._password:
self._password = _read_keyring_value(self._username)

if self.is_defined:
return

self._token = _read_keyring_value(KEYRING_TOKEN_USER)

@classmethod
def parse(cls, username: str = None, password: str = None, token: str = None):
"""
Parses credentials from the provided parameters, environment variables or the system keyring.
"""
credentials = cls(username, password, token)
if credentials.is_defined:
return credentials

credentials._read_from_env()
if credentials.is_defined:
return credentials

credentials._read_from_keyring()
if credentials.is_defined:
return credentials

raise UpCloudClientError(
f"Credentials not found. These must be set in configuration, via environment variables or in the system keyring ({KEYRING_SERVICE_NAME})"
)
Loading