fluvius-energy-api 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fluvius_energy_api/__init__.py +140 -0
- fluvius_energy_api/_http.py +128 -0
- fluvius_energy_api/auth.py +239 -0
- fluvius_energy_api/client.py +334 -0
- fluvius_energy_api/credentials.py +215 -0
- fluvius_energy_api/exceptions.py +104 -0
- fluvius_energy_api/models/__init__.py +123 -0
- fluvius_energy_api/models/base.py +36 -0
- fluvius_energy_api/models/energy.py +201 -0
- fluvius_energy_api/models/enums.py +137 -0
- fluvius_energy_api/models/installation.py +68 -0
- fluvius_energy_api/models/mandate.py +73 -0
- fluvius_energy_api/models/session.py +46 -0
- fluvius_energy_api/py.typed +0 -0
- fluvius_energy_api-0.1.0.dist-info/METADATA +311 -0
- fluvius_energy_api-0.1.0.dist-info/RECORD +18 -0
- fluvius_energy_api-0.1.0.dist-info/WHEEL +4 -0
- fluvius_energy_api-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Fluvius Energy API Python wrapper."""
|
|
2
|
+
|
|
3
|
+
from .auth import TokenProvider
|
|
4
|
+
from .client import Environment, FluviusEnergyClient
|
|
5
|
+
from .credentials import FluviusCredentials
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
ConfigurationError,
|
|
9
|
+
FluviusAPIError,
|
|
10
|
+
ForbiddenError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
ServerError,
|
|
13
|
+
ServiceUnavailableError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
from .models import (
|
|
17
|
+
AuxiliarySubHeadpoint,
|
|
18
|
+
ClientSessionDataService,
|
|
19
|
+
CreateClientSessionRequest,
|
|
20
|
+
CreateClientSessionResponse,
|
|
21
|
+
CreateClientSessionResponseApiDataResponse,
|
|
22
|
+
CreateClientSessionStatus,
|
|
23
|
+
CreateMandateRequest,
|
|
24
|
+
CreateMandateResponse,
|
|
25
|
+
CreateMandateResponseApiDataResponse,
|
|
26
|
+
DataServiceType,
|
|
27
|
+
EnergyType,
|
|
28
|
+
ErrorResponse,
|
|
29
|
+
ErrorValidationResponse,
|
|
30
|
+
EstimatedVolume,
|
|
31
|
+
EstimatedVolumeType,
|
|
32
|
+
Flow,
|
|
33
|
+
GasConversionFactor,
|
|
34
|
+
GetEnergyResponse,
|
|
35
|
+
GetEnergyResponseApiDataResponse,
|
|
36
|
+
GetInstallationResponse,
|
|
37
|
+
GetInstallationResponseApiDataResponse,
|
|
38
|
+
GetMandatesResponse,
|
|
39
|
+
GetMandatesResponseApiDataResponse,
|
|
40
|
+
Headpoint,
|
|
41
|
+
HeadpointUnion,
|
|
42
|
+
Installation,
|
|
43
|
+
LocalProductionInstallation,
|
|
44
|
+
LocalProductionInstallationType,
|
|
45
|
+
Mandate,
|
|
46
|
+
MandateRenewalStatus,
|
|
47
|
+
MandateStatus,
|
|
48
|
+
MeasurementDirection,
|
|
49
|
+
MeasurementTimeSlice,
|
|
50
|
+
MeasurementValue,
|
|
51
|
+
MeasurementValidationState,
|
|
52
|
+
MeasurementValueSet,
|
|
53
|
+
MeterType,
|
|
54
|
+
MeteringOnHeadpoint,
|
|
55
|
+
MeteringOnHeadpointAndMeter,
|
|
56
|
+
MeteringOnMeter,
|
|
57
|
+
OfftakeSubHeadpoint,
|
|
58
|
+
PeriodType,
|
|
59
|
+
PhysicalMeter,
|
|
60
|
+
ProductionSubHeadpoint,
|
|
61
|
+
Register,
|
|
62
|
+
ResponseMetadata,
|
|
63
|
+
SubHeadpoint,
|
|
64
|
+
SubHeadpointUnion,
|
|
65
|
+
Unit,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
__version__ = "0.1.0"
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
# Client & Auth
|
|
72
|
+
"Environment",
|
|
73
|
+
"FluviusEnergyClient",
|
|
74
|
+
"FluviusCredentials",
|
|
75
|
+
"TokenProvider",
|
|
76
|
+
# Exceptions
|
|
77
|
+
"AuthenticationError",
|
|
78
|
+
"ConfigurationError",
|
|
79
|
+
"FluviusAPIError",
|
|
80
|
+
"ForbiddenError",
|
|
81
|
+
"NotFoundError",
|
|
82
|
+
"ServerError",
|
|
83
|
+
"ServiceUnavailableError",
|
|
84
|
+
"ValidationError",
|
|
85
|
+
# Enums
|
|
86
|
+
"CreateClientSessionStatus",
|
|
87
|
+
"DataServiceType",
|
|
88
|
+
"EnergyType",
|
|
89
|
+
"EstimatedVolumeType",
|
|
90
|
+
"Flow",
|
|
91
|
+
"GasConversionFactor",
|
|
92
|
+
"LocalProductionInstallationType",
|
|
93
|
+
"MandateRenewalStatus",
|
|
94
|
+
"MandateStatus",
|
|
95
|
+
"MeasurementValidationState",
|
|
96
|
+
"MeterType",
|
|
97
|
+
"PeriodType",
|
|
98
|
+
"Register",
|
|
99
|
+
"Unit",
|
|
100
|
+
# Base models
|
|
101
|
+
"ErrorResponse",
|
|
102
|
+
"ErrorValidationResponse",
|
|
103
|
+
"ResponseMetadata",
|
|
104
|
+
# Session models
|
|
105
|
+
"ClientSessionDataService",
|
|
106
|
+
"CreateClientSessionRequest",
|
|
107
|
+
"CreateClientSessionResponse",
|
|
108
|
+
"CreateClientSessionResponseApiDataResponse",
|
|
109
|
+
# Mandate models
|
|
110
|
+
"CreateMandateRequest",
|
|
111
|
+
"CreateMandateResponse",
|
|
112
|
+
"CreateMandateResponseApiDataResponse",
|
|
113
|
+
"GetMandatesResponse",
|
|
114
|
+
"GetMandatesResponseApiDataResponse",
|
|
115
|
+
"Mandate",
|
|
116
|
+
# Energy models
|
|
117
|
+
"AuxiliarySubHeadpoint",
|
|
118
|
+
"GetEnergyResponse",
|
|
119
|
+
"GetEnergyResponseApiDataResponse",
|
|
120
|
+
"Headpoint",
|
|
121
|
+
"HeadpointUnion",
|
|
122
|
+
"MeasurementDirection",
|
|
123
|
+
"MeasurementTimeSlice",
|
|
124
|
+
"MeasurementValue",
|
|
125
|
+
"MeasurementValueSet",
|
|
126
|
+
"MeteringOnHeadpoint",
|
|
127
|
+
"MeteringOnHeadpointAndMeter",
|
|
128
|
+
"MeteringOnMeter",
|
|
129
|
+
"OfftakeSubHeadpoint",
|
|
130
|
+
"PhysicalMeter",
|
|
131
|
+
"ProductionSubHeadpoint",
|
|
132
|
+
"SubHeadpoint",
|
|
133
|
+
"SubHeadpointUnion",
|
|
134
|
+
# Installation models
|
|
135
|
+
"EstimatedVolume",
|
|
136
|
+
"GetInstallationResponse",
|
|
137
|
+
"GetInstallationResponseApiDataResponse",
|
|
138
|
+
"Installation",
|
|
139
|
+
"LocalProductionInstallation",
|
|
140
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Internal HTTP client wrapper for the Fluvius Energy API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .exceptions import (
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
FluviusAPIError,
|
|
12
|
+
ForbiddenError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
ServerError,
|
|
15
|
+
ServiceUnavailableError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from .models.base import ErrorResponse, ErrorValidationResponse
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .auth import TokenProvider
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HTTPClient:
|
|
25
|
+
"""Internal HTTP client with authentication and error handling."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
base_url: str,
|
|
30
|
+
token_provider: TokenProvider,
|
|
31
|
+
timeout: float = 30.0,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._base_url = base_url.rstrip("/")
|
|
34
|
+
self._token_provider = token_provider
|
|
35
|
+
self._timeout = timeout
|
|
36
|
+
self._client: httpx.Client | None = None
|
|
37
|
+
|
|
38
|
+
def _get_client(self) -> httpx.Client:
|
|
39
|
+
"""Get or create the HTTP client."""
|
|
40
|
+
if self._client is None:
|
|
41
|
+
self._client = httpx.Client(
|
|
42
|
+
base_url=self._base_url,
|
|
43
|
+
timeout=self._timeout,
|
|
44
|
+
)
|
|
45
|
+
return self._client
|
|
46
|
+
|
|
47
|
+
def _get_headers(self) -> dict[str, str]:
|
|
48
|
+
"""Get headers for API requests, including fresh auth token."""
|
|
49
|
+
headers = self._token_provider.get_authorization_headers()
|
|
50
|
+
headers["Content-Type"] = "application/json"
|
|
51
|
+
headers["Accept"] = "application/json"
|
|
52
|
+
return headers
|
|
53
|
+
|
|
54
|
+
def close(self) -> None:
|
|
55
|
+
"""Close the HTTP client."""
|
|
56
|
+
if self._client is not None:
|
|
57
|
+
self._client.close()
|
|
58
|
+
self._client = None
|
|
59
|
+
self._token_provider.close()
|
|
60
|
+
|
|
61
|
+
def _handle_error_response(self, response: httpx.Response) -> None:
|
|
62
|
+
"""Handle error responses from the API."""
|
|
63
|
+
error_response: ErrorResponse | ErrorValidationResponse | None = None
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
error_data = response.json()
|
|
67
|
+
# Check for validation errors (API returns PascalCase)
|
|
68
|
+
if "validationErrorMessages" in error_data or "ValidationErrorMessages" in error_data:
|
|
69
|
+
error_response = ErrorValidationResponse.model_validate(error_data)
|
|
70
|
+
else:
|
|
71
|
+
error_response = ErrorResponse.model_validate(error_data)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
message = (
|
|
76
|
+
error_response.message
|
|
77
|
+
if error_response and error_response.message
|
|
78
|
+
else response.text or f"HTTP {response.status_code}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
match response.status_code:
|
|
82
|
+
case 400:
|
|
83
|
+
raise ValidationError(
|
|
84
|
+
message=message,
|
|
85
|
+
error_response=error_response
|
|
86
|
+
if isinstance(error_response, ErrorValidationResponse)
|
|
87
|
+
else None,
|
|
88
|
+
)
|
|
89
|
+
case 401:
|
|
90
|
+
raise AuthenticationError(message=message, error_response=error_response)
|
|
91
|
+
case 403:
|
|
92
|
+
raise ForbiddenError(message=message, error_response=error_response)
|
|
93
|
+
case 404:
|
|
94
|
+
raise NotFoundError(message=message, error_response=error_response)
|
|
95
|
+
case 500:
|
|
96
|
+
raise ServerError(message=message, error_response=error_response)
|
|
97
|
+
case 503:
|
|
98
|
+
raise ServiceUnavailableError(
|
|
99
|
+
message=message, error_response=error_response
|
|
100
|
+
)
|
|
101
|
+
case _:
|
|
102
|
+
raise FluviusAPIError(
|
|
103
|
+
message=message,
|
|
104
|
+
status_code=response.status_code,
|
|
105
|
+
error_response=error_response,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
109
|
+
"""Make a GET request."""
|
|
110
|
+
client = self._get_client()
|
|
111
|
+
response = client.get(path, params=params, headers=self._get_headers())
|
|
112
|
+
|
|
113
|
+
if not response.is_success:
|
|
114
|
+
self._handle_error_response(response)
|
|
115
|
+
|
|
116
|
+
return response.json()
|
|
117
|
+
|
|
118
|
+
def post(
|
|
119
|
+
self, path: str, json_data: dict[str, Any] | None = None
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
"""Make a POST request."""
|
|
122
|
+
client = self._get_client()
|
|
123
|
+
response = client.post(path, json=json_data, headers=self._get_headers())
|
|
124
|
+
|
|
125
|
+
if not response.is_success:
|
|
126
|
+
self._handle_error_response(response)
|
|
127
|
+
|
|
128
|
+
return response.json()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""OAuth2 token management for Fluvius API authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import jwt
|
|
13
|
+
|
|
14
|
+
from .credentials import FluviusCredentials
|
|
15
|
+
from .exceptions import AuthenticationError
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
AZURE_AD_TOKEN_URL = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
|
20
|
+
TOKEN_LIFETIME_SECONDS = 3600
|
|
21
|
+
REFRESH_MARGIN_SECONDS = 60
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AccessToken:
|
|
26
|
+
"""Represents an OAuth2 access token."""
|
|
27
|
+
|
|
28
|
+
access_token: str
|
|
29
|
+
token_type: str
|
|
30
|
+
expires_in: int
|
|
31
|
+
obtained_at: float
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def expires_at(self) -> float:
|
|
35
|
+
"""Unix timestamp when the token expires."""
|
|
36
|
+
return self.obtained_at + self.expires_in
|
|
37
|
+
|
|
38
|
+
def is_expired(self, margin_seconds: int = REFRESH_MARGIN_SECONDS) -> bool:
|
|
39
|
+
"""Check if the token is expired or about to expire.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
margin_seconds: Safety margin in seconds before actual expiration.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
True if the token should be refreshed.
|
|
46
|
+
"""
|
|
47
|
+
return time.time() >= (self.expires_at - margin_seconds)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TokenProvider:
|
|
51
|
+
"""Manages OAuth2 token lifecycle with automatic refresh.
|
|
52
|
+
|
|
53
|
+
Supports two authentication methods:
|
|
54
|
+
- Certificate-based: Uses JWT bearer assertion with RS256 (production)
|
|
55
|
+
- Client secret: Uses simple client_secret parameter (sandbox)
|
|
56
|
+
|
|
57
|
+
Tokens are cached and automatically refreshed before expiration.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
credentials: The Fluvius API credentials.
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
```python
|
|
64
|
+
credentials = FluviusCredentials.from_env()
|
|
65
|
+
token_provider = TokenProvider(credentials)
|
|
66
|
+
|
|
67
|
+
# Get a valid access token (auto-refreshes if needed)
|
|
68
|
+
token = token_provider.get_access_token()
|
|
69
|
+
|
|
70
|
+
# Get authorization headers for API requests
|
|
71
|
+
headers = token_provider.get_authorization_headers()
|
|
72
|
+
```
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, credentials: FluviusCredentials) -> None:
|
|
76
|
+
"""Initialize the token provider.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
credentials: The Fluvius API credentials.
|
|
80
|
+
"""
|
|
81
|
+
self.credentials = credentials
|
|
82
|
+
self._token: AccessToken | None = None
|
|
83
|
+
self._http_client: httpx.Client | None = None
|
|
84
|
+
|
|
85
|
+
def _get_http_client(self) -> httpx.Client:
|
|
86
|
+
"""Get or create the HTTP client for token requests."""
|
|
87
|
+
if self._http_client is None:
|
|
88
|
+
self._http_client = httpx.Client(timeout=30.0)
|
|
89
|
+
return self._http_client
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
"""Close the HTTP client."""
|
|
93
|
+
if self._http_client is not None:
|
|
94
|
+
self._http_client.close()
|
|
95
|
+
self._http_client = None
|
|
96
|
+
|
|
97
|
+
def get_access_token(self) -> str:
|
|
98
|
+
"""Get a valid access token, refreshing if necessary.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The access token string.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
AuthenticationError: If token acquisition fails.
|
|
105
|
+
"""
|
|
106
|
+
if self._token is None or self._token.is_expired():
|
|
107
|
+
logger.debug("Token is missing or expired, acquiring new token")
|
|
108
|
+
self._token = self._acquire_token()
|
|
109
|
+
else:
|
|
110
|
+
logger.debug("Reusing cached token")
|
|
111
|
+
|
|
112
|
+
return self._token.access_token
|
|
113
|
+
|
|
114
|
+
def get_authorization_headers(self) -> dict[str, str]:
|
|
115
|
+
"""Get HTTP headers for authenticated API requests.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Dictionary containing Authorization and subscription key headers.
|
|
119
|
+
"""
|
|
120
|
+
return {
|
|
121
|
+
"Authorization": f"Bearer {self.get_access_token()}",
|
|
122
|
+
"Ocp-Apim-Subscription-Key": self.credentials.subscription_key,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def _acquire_token(self) -> AccessToken:
|
|
126
|
+
"""Acquire a new access token from Azure AD.
|
|
127
|
+
|
|
128
|
+
Uses certificate-based auth if credentials include certificate,
|
|
129
|
+
otherwise uses client secret.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The acquired access token.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
AuthenticationError: If token acquisition fails.
|
|
136
|
+
"""
|
|
137
|
+
token_url = AZURE_AD_TOKEN_URL.format(tenant_id=self.credentials.tenant_id)
|
|
138
|
+
|
|
139
|
+
# Build request data based on authentication method
|
|
140
|
+
if self.credentials.uses_certificate:
|
|
141
|
+
logger.debug("Using certificate-based authentication")
|
|
142
|
+
client_assertion = self._create_client_assertion()
|
|
143
|
+
data = {
|
|
144
|
+
"grant_type": "client_credentials",
|
|
145
|
+
"client_id": self.credentials.client_id,
|
|
146
|
+
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
|
147
|
+
"client_assertion": client_assertion,
|
|
148
|
+
"scope": self.credentials.scope,
|
|
149
|
+
}
|
|
150
|
+
else:
|
|
151
|
+
logger.debug("Using client secret authentication")
|
|
152
|
+
data = {
|
|
153
|
+
"grant_type": "client_credentials",
|
|
154
|
+
"client_id": self.credentials.client_id,
|
|
155
|
+
"client_secret": self.credentials.client_secret,
|
|
156
|
+
"scope": self.credentials.scope,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
client = self._get_http_client()
|
|
160
|
+
obtained_at = time.time()
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
response = client.post(
|
|
164
|
+
token_url,
|
|
165
|
+
data=data,
|
|
166
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
167
|
+
)
|
|
168
|
+
response.raise_for_status()
|
|
169
|
+
except httpx.HTTPStatusError as e:
|
|
170
|
+
error_detail = ""
|
|
171
|
+
try:
|
|
172
|
+
error_body = e.response.json()
|
|
173
|
+
error_detail = f": {error_body.get('error_description', error_body.get('error', ''))}"
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
raise AuthenticationError(
|
|
177
|
+
f"Failed to acquire access token{error_detail}"
|
|
178
|
+
) from e
|
|
179
|
+
except httpx.RequestError as e:
|
|
180
|
+
raise AuthenticationError(
|
|
181
|
+
f"Failed to connect to Azure AD: {e}"
|
|
182
|
+
) from e
|
|
183
|
+
|
|
184
|
+
token_data = response.json()
|
|
185
|
+
|
|
186
|
+
return AccessToken(
|
|
187
|
+
access_token=token_data["access_token"],
|
|
188
|
+
token_type=token_data.get("token_type", "Bearer"),
|
|
189
|
+
expires_in=token_data.get("expires_in", TOKEN_LIFETIME_SECONDS),
|
|
190
|
+
obtained_at=obtained_at,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _create_client_assertion(self) -> str:
|
|
194
|
+
"""Create a signed JWT client assertion for Azure AD.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The signed JWT token.
|
|
198
|
+
"""
|
|
199
|
+
now = int(time.time())
|
|
200
|
+
expiry = now + TOKEN_LIFETIME_SECONDS
|
|
201
|
+
|
|
202
|
+
headers = {
|
|
203
|
+
"alg": "RS256",
|
|
204
|
+
"typ": "JWT",
|
|
205
|
+
"x5t": self._encode_thumbprint(self.credentials.certificate_thumbprint),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
payload = {
|
|
209
|
+
"aud": f"https://login.microsoftonline.com/{self.credentials.tenant_id}/v2.0",
|
|
210
|
+
"iss": self.credentials.client_id,
|
|
211
|
+
"sub": self.credentials.client_id,
|
|
212
|
+
"jti": str(uuid.uuid4()),
|
|
213
|
+
"nbf": now,
|
|
214
|
+
"exp": expiry,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return jwt.encode(
|
|
218
|
+
payload,
|
|
219
|
+
self.credentials.private_key,
|
|
220
|
+
algorithm="RS256",
|
|
221
|
+
headers=headers,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _encode_thumbprint(thumbprint: str) -> str:
|
|
226
|
+
"""Encode a certificate thumbprint for the x5t JWT header.
|
|
227
|
+
|
|
228
|
+
The thumbprint is a hexadecimal string that needs to be converted
|
|
229
|
+
to bytes and then base64url-encoded.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
thumbprint: Hexadecimal certificate thumbprint.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Base64url-encoded thumbprint.
|
|
236
|
+
"""
|
|
237
|
+
thumbprint_bytes = bytes.fromhex(thumbprint)
|
|
238
|
+
encoded = base64.urlsafe_b64encode(thumbprint_bytes).rstrip(b"=")
|
|
239
|
+
return encoded.decode("ascii")
|