Skip to content

Commit 5239e0f

Browse files
committed
feat: add support for tokens and system keyring
1 parent 841f805 commit 5239e0f

File tree

4 files changed

+111
-7
lines changed

4 files changed

+111
-7
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12+
### Added
13+
14+
- Support authentication with API tokens
15+
- Added `Credentials` class for parsing credentials from environment similarly than in our Go based tooling.
16+
1217
## [2.7.0] - 2025-07-23
1318

1419
### Changed

setup.cfg

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,7 @@ install_requires =
2020
packages =
2121
upcloud_api
2222
upcloud_api.cloud_manager
23+
24+
[options.extras_require]
25+
keyring =
26+
keyring>=23.0

upcloud_api/cloud_manager/__init__.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import base64
22

33
from upcloud_api.api import API
4+
from upcloud_api.credentials import Credentials
45
from upcloud_api.cloud_manager.firewall_mixin import FirewallManager
56
from upcloud_api.cloud_manager.host_mixin import HostManager
67
from upcloud_api.cloud_manager.ip_address_mixin import IPManager
@@ -31,21 +32,17 @@ class CloudManager(
3132

3233
api: API
3334

34-
def __init__(self, username: str, password: str, timeout: int = 60) -> None:
35+
def __init__(self, username: str = None, password: str = None, timeout: int = 60, token: str = None) -> None:
3536
"""
3637
Initiates CloudManager that handles all HTTP connections with UpCloud's API.
3738
3839
Optionally determine a timeout for API connections (in seconds). A timeout with the value
3940
`None` means that there is no timeout.
4041
"""
41-
if not username or not password:
42-
raise Exception('Invalid credentials, please provide a username and password')
43-
44-
credentials = f'{username}:{password}'.encode()
45-
encoded_credentials = base64.b64encode(credentials).decode()
42+
credentials = Credentials.parse(username, password, token)
4643

4744
self.api = API(
48-
token=f'Basic {encoded_credentials}',
45+
token=credentials.authorization,
4946
timeout=timeout,
5047
)
5148

upcloud_api/credentials.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import base64
2+
import os
3+
4+
try:
5+
import keyring
6+
except ImportError:
7+
keyring = None
8+
9+
from upcloud_api.errors import UpCloudClientError
10+
11+
12+
ENV_KEY_USERNAME = "UPCLOUD_USERNAME"
13+
ENV_KEY_PASSWORD = "UPCLOUD_PASSWORD"
14+
ENV_KEY_TOKEN = "UPCLOUD_TOKEN"
15+
16+
KEYRING_SERVICE_NAME = "UpCloud"
17+
KEYRING_TOKEN_USER = ""
18+
KEYRING_GO_PREFIX = "go-keyring-base64:"
19+
20+
21+
def _parse_keyring_value(value: str) -> str:
22+
if value.startswith(KEYRING_GO_PREFIX):
23+
value = value[len(KEYRING_GO_PREFIX):]
24+
25+
try:
26+
return base64.b64decode(value).decode()
27+
except:
28+
raise UpCloudClientError(f"Invalid keyring value: {value}")
29+
30+
return value
31+
32+
33+
def _read_keyring_value(username: str) -> str:
34+
if keyring is None:
35+
return None
36+
37+
value = keyring.get_password(KEYRING_SERVICE_NAME, username)
38+
return _parse_keyring_value(value) if value else None
39+
40+
class Credentials:
41+
'''Class for handling UpCloud API credentials.
42+
'''
43+
def __init__(self, username: str = None, password: str = None, token: str = None):
44+
self._username = username
45+
self._password = password
46+
self._token = token
47+
48+
@property
49+
def authorization(self) -> str:
50+
"""Returns the authorization header value based on the provided credentials.
51+
"""
52+
if self._token:
53+
return f'Bearer {self._token}'
54+
55+
credentials = f'{self._username}:{self._password}'.encode()
56+
encoded_credentials = base64.b64encode(credentials).decode()
57+
return f'Basic {encoded_credentials}'
58+
59+
@property
60+
def is_defined(self) -> bool:
61+
"""Checks if the credentials are defined.
62+
"""
63+
return bool(self._username and self._password or self._token)
64+
65+
def _read_from_env(self):
66+
if not self._username:
67+
self._username = os.getenv(ENV_KEY_USERNAME)
68+
if not self._password:
69+
self._password = os.getenv(ENV_KEY_PASSWORD)
70+
if not self._token:
71+
self._token = os.getenv(ENV_KEY_TOKEN)
72+
73+
def _read_from_keyring(self):
74+
if self._username and not self._password:
75+
self._password = _read_keyring_value(self._username)
76+
77+
if self.is_defined:
78+
return
79+
80+
self._token = _read_keyring_value(KEYRING_TOKEN_USER)
81+
82+
@classmethod
83+
def parse(cls, username: str = None, password: str = None, token: str = None):
84+
"""Parses credentials from the provided parameters, environment variables or the system keyring.
85+
"""
86+
credentials = cls(username, password, token)
87+
if credentials.is_defined:
88+
return credentials
89+
90+
credentials._read_from_env()
91+
if credentials.is_defined:
92+
return credentials
93+
94+
credentials._read_from_keyring()
95+
if credentials.is_defined:
96+
return credentials
97+
98+
raise UpCloudClientError(f'Credentials not found. These must be set in configuration, via environment variables or in the system keyring ({KEYRING_SERVICE_NAME})')

0 commit comments

Comments
 (0)