charmlibs-interfaces-oauth 1.0.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.
- charmlibs/interfaces/oauth/__init__.py +99 -0
- charmlibs/interfaces/oauth/_oauth.py +806 -0
- charmlibs/interfaces/oauth/_version.py +15 -0
- charmlibs/interfaces/oauth/py.typed +0 -0
- charmlibs_interfaces_oauth-1.0.0.dist-info/METADATA +30 -0
- charmlibs_interfaces_oauth-1.0.0.dist-info/RECORD +7 -0
- charmlibs_interfaces_oauth-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Copyright 2026 Canonical Ltd.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
"""Oauth Library.
|
|
17
|
+
|
|
18
|
+
This library is designed to enable applications to register OAuth2/OIDC
|
|
19
|
+
clients with an OIDC Provider through the ``oauth`` interface.
|
|
20
|
+
|
|
21
|
+
Getting started
|
|
22
|
+
---------------
|
|
23
|
+
|
|
24
|
+
To get started using this library you just need to fetch the library using ``charmcraft``.
|
|
25
|
+
|
|
26
|
+
.. note::
|
|
27
|
+
You also need to add ``jsonschema`` to your charm's ``requirements.txt``.
|
|
28
|
+
|
|
29
|
+
.. code-block:: shell
|
|
30
|
+
|
|
31
|
+
cd some-charm
|
|
32
|
+
charmcraft fetch-lib charms.hydra.v0.oauth
|
|
33
|
+
|
|
34
|
+
Then, to initialize the library:
|
|
35
|
+
|
|
36
|
+
.. code-block:: python
|
|
37
|
+
|
|
38
|
+
# ...
|
|
39
|
+
from charms.hydra.v0.oauth import ClientConfig, OAuthRequirer
|
|
40
|
+
|
|
41
|
+
OAUTH = "oauth"
|
|
42
|
+
OAUTH_SCOPES = "openid email"
|
|
43
|
+
OAUTH_GRANT_TYPES = ["authorization_code"]
|
|
44
|
+
|
|
45
|
+
class SomeCharm(CharmBase):
|
|
46
|
+
def __init__(self, *args):
|
|
47
|
+
# ...
|
|
48
|
+
self.oauth = OAuthRequirer(self, client_config, relation_name=OAUTH)
|
|
49
|
+
|
|
50
|
+
self.framework.observe(self.oauth.on.oauth_info_changed, self._configure_application)
|
|
51
|
+
# ...
|
|
52
|
+
|
|
53
|
+
def _on_ingress_ready(self, event):
|
|
54
|
+
self.external_url = "https://example.com"
|
|
55
|
+
self._set_client_config()
|
|
56
|
+
|
|
57
|
+
def _set_client_config(self):
|
|
58
|
+
client_config = ClientConfig(
|
|
59
|
+
urljoin(self.external_url, "/oauth/callback"),
|
|
60
|
+
OAUTH_SCOPES,
|
|
61
|
+
OAUTH_GRANT_TYPES,
|
|
62
|
+
)
|
|
63
|
+
self.oauth.update_client_config(client_config)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
from ._oauth import (
|
|
67
|
+
ClientChangedEvent,
|
|
68
|
+
ClientConfig,
|
|
69
|
+
ClientConfigError,
|
|
70
|
+
ClientCreatedEvent,
|
|
71
|
+
ClientDeletedEvent,
|
|
72
|
+
DataValidationError,
|
|
73
|
+
InvalidClientConfigEvent,
|
|
74
|
+
OAuthInfoChangedEvent,
|
|
75
|
+
OAuthInfoRemovedEvent,
|
|
76
|
+
OAuthProvider,
|
|
77
|
+
OauthProviderConfig,
|
|
78
|
+
OAuthProviderEvents,
|
|
79
|
+
OAuthRequirer,
|
|
80
|
+
OAuthRequirerEvents,
|
|
81
|
+
)
|
|
82
|
+
from ._version import __version__ as __version__
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
'ClientChangedEvent',
|
|
86
|
+
'ClientConfig',
|
|
87
|
+
'ClientConfigError',
|
|
88
|
+
'ClientCreatedEvent',
|
|
89
|
+
'ClientDeletedEvent',
|
|
90
|
+
'DataValidationError',
|
|
91
|
+
'InvalidClientConfigEvent',
|
|
92
|
+
'OAuthInfoChangedEvent',
|
|
93
|
+
'OAuthInfoRemovedEvent',
|
|
94
|
+
'OAuthProvider',
|
|
95
|
+
'OAuthProviderEvents',
|
|
96
|
+
'OAuthRequirer',
|
|
97
|
+
'OAuthRequirerEvents',
|
|
98
|
+
'OauthProviderConfig',
|
|
99
|
+
]
|
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
# Copyright 2026 Canonical Ltd.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
"""OAuth interface implementation.
|
|
17
|
+
|
|
18
|
+
Migrated from charms.hydra.v0.oauth (v0.12).
|
|
19
|
+
|
|
20
|
+
Version: 1.0.0
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import re
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
28
|
+
from typing import Any, cast
|
|
29
|
+
|
|
30
|
+
import jsonschema
|
|
31
|
+
from ops.charm import CharmBase, RelationBrokenEvent, RelationChangedEvent, RelationCreatedEvent
|
|
32
|
+
from ops.framework import EventBase, EventSource, Handle, Object, ObjectEvents
|
|
33
|
+
from ops.model import Relation, Secret, SecretNotFoundError, TooManyRelatedAppsError
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
DEFAULT_RELATION_NAME = 'oauth'
|
|
38
|
+
ALLOWED_GRANT_TYPES = [
|
|
39
|
+
'authorization_code',
|
|
40
|
+
'refresh_token',
|
|
41
|
+
'client_credentials',
|
|
42
|
+
'urn:ietf:params:oauth:grant-type:device_code',
|
|
43
|
+
]
|
|
44
|
+
ALLOWED_CLIENT_AUTHN_METHODS = ['client_secret_basic', 'client_secret_post']
|
|
45
|
+
CLIENT_SECRET_FIELD = 'secret' # noqa: S105
|
|
46
|
+
|
|
47
|
+
url_regex = re.compile(
|
|
48
|
+
r'(^http://)|(^https://)' # http:// or https://
|
|
49
|
+
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|'
|
|
50
|
+
r'[A-Z0-9-]{2,}\.?)|' # domain...
|
|
51
|
+
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
|
|
52
|
+
r'(?::\d+)?' # optional port
|
|
53
|
+
r'(?:/?|[/?]\S+)$',
|
|
54
|
+
re.IGNORECASE,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
OAUTH_PROVIDER_JSON_SCHEMA: dict[str, Any] = {
|
|
58
|
+
'$schema': 'http://json-schema.org/draft-07/schema',
|
|
59
|
+
'$id': 'https://canonical.github.io/charm-relation-interfaces/interfaces/oauth/schemas/provider.json',
|
|
60
|
+
'type': 'object',
|
|
61
|
+
'properties': {
|
|
62
|
+
'issuer_url': {
|
|
63
|
+
'type': 'string',
|
|
64
|
+
},
|
|
65
|
+
'authorization_endpoint': {
|
|
66
|
+
'type': 'string',
|
|
67
|
+
},
|
|
68
|
+
'token_endpoint': {
|
|
69
|
+
'type': 'string',
|
|
70
|
+
},
|
|
71
|
+
'introspection_endpoint': {
|
|
72
|
+
'type': 'string',
|
|
73
|
+
},
|
|
74
|
+
'userinfo_endpoint': {
|
|
75
|
+
'type': 'string',
|
|
76
|
+
},
|
|
77
|
+
'jwks_endpoint': {
|
|
78
|
+
'type': 'string',
|
|
79
|
+
},
|
|
80
|
+
'scope': {
|
|
81
|
+
'type': 'string',
|
|
82
|
+
},
|
|
83
|
+
'client_id': {
|
|
84
|
+
'type': 'string',
|
|
85
|
+
},
|
|
86
|
+
'client_secret_id': {
|
|
87
|
+
'type': 'string',
|
|
88
|
+
},
|
|
89
|
+
'groups': {'type': 'string', 'default': None},
|
|
90
|
+
'ca_chain': {'type': 'array', 'items': {'type': 'string'}, 'default': []},
|
|
91
|
+
'jwt_access_token': {'type': 'string', 'default': 'False'},
|
|
92
|
+
},
|
|
93
|
+
'required': [
|
|
94
|
+
'issuer_url',
|
|
95
|
+
'authorization_endpoint',
|
|
96
|
+
'token_endpoint',
|
|
97
|
+
'introspection_endpoint',
|
|
98
|
+
'userinfo_endpoint',
|
|
99
|
+
'jwks_endpoint',
|
|
100
|
+
'scope',
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
OAUTH_REQUIRER_JSON_SCHEMA: dict[str, Any] = {
|
|
104
|
+
'$schema': 'http://json-schema.org/draft-07/schema',
|
|
105
|
+
'$id': 'https://canonical.github.io/charm-relation-interfaces/interfaces/oauth/schemas/requirer.json',
|
|
106
|
+
'type': 'object',
|
|
107
|
+
'properties': {
|
|
108
|
+
'redirect_uri': {
|
|
109
|
+
'type': 'string',
|
|
110
|
+
'default': None,
|
|
111
|
+
},
|
|
112
|
+
'audience': {'type': 'array', 'default': [], 'items': {'type': 'string'}},
|
|
113
|
+
'scope': {'type': 'string', 'default': None},
|
|
114
|
+
'grant_types': {
|
|
115
|
+
'type': 'array',
|
|
116
|
+
'default': None,
|
|
117
|
+
'items': {
|
|
118
|
+
'enum': ALLOWED_GRANT_TYPES,
|
|
119
|
+
'type': 'string',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
'token_endpoint_auth_method': {
|
|
123
|
+
'type': 'string',
|
|
124
|
+
'enum': ALLOWED_CLIENT_AUTHN_METHODS,
|
|
125
|
+
'default': 'client_secret_basic',
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
'required': ['audience', 'scope', 'grant_types', 'token_endpoint_auth_method'],
|
|
129
|
+
'allOf': [
|
|
130
|
+
{
|
|
131
|
+
'if': {
|
|
132
|
+
'properties': {
|
|
133
|
+
'grant_types': {
|
|
134
|
+
'contains': {
|
|
135
|
+
'const': 'authorization_code',
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
'required': ['grant_types'],
|
|
140
|
+
},
|
|
141
|
+
'then': {
|
|
142
|
+
'required': ['redirect_uri'],
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ClientConfigError(Exception):
|
|
150
|
+
"""Emitted when invalid client config is provided."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class DataValidationError(RuntimeError):
|
|
154
|
+
"""Raised when data validation fails on relation data."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _load_data(data: Mapping[str, str], schema: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
158
|
+
"""Parses nested fields and checks whether `data` matches `schema`."""
|
|
159
|
+
ret: dict[str, Any] = {}
|
|
160
|
+
for k, v in data.items():
|
|
161
|
+
try:
|
|
162
|
+
ret[k] = json.loads(v)
|
|
163
|
+
except json.JSONDecodeError: # noqa: PERF203
|
|
164
|
+
ret[k] = v
|
|
165
|
+
|
|
166
|
+
if schema:
|
|
167
|
+
_validate_data(ret, schema)
|
|
168
|
+
return ret
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _dump_data(data: dict[str, Any], schema: dict[str, Any] | None = None) -> dict[str, str]:
|
|
172
|
+
if schema:
|
|
173
|
+
_validate_data(data, schema)
|
|
174
|
+
|
|
175
|
+
ret: dict[str, str] = {}
|
|
176
|
+
for k, v in data.items():
|
|
177
|
+
if isinstance(v, (list, dict)):
|
|
178
|
+
try:
|
|
179
|
+
ret[k] = json.dumps(v)
|
|
180
|
+
except json.JSONDecodeError as e:
|
|
181
|
+
raise DataValidationError(f'Failed to encode relation json: {e}') from e
|
|
182
|
+
elif isinstance(v, bool):
|
|
183
|
+
ret[k] = str(v)
|
|
184
|
+
else:
|
|
185
|
+
ret[k] = str(v)
|
|
186
|
+
return ret
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def strtobool(val: str) -> bool:
|
|
190
|
+
"""Convert a string representation of truth to true (1) or false (0).
|
|
191
|
+
|
|
192
|
+
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
|
|
193
|
+
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
|
|
194
|
+
'val' is anything else.
|
|
195
|
+
"""
|
|
196
|
+
if not isinstance(val, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
197
|
+
raise ValueError(f'invalid value type {type(val)}')
|
|
198
|
+
|
|
199
|
+
val = val.lower()
|
|
200
|
+
if val in ('y', 'yes', 't', 'true', 'on', '1'):
|
|
201
|
+
return True
|
|
202
|
+
elif val in ('n', 'no', 'f', 'false', 'off', '0'):
|
|
203
|
+
return False
|
|
204
|
+
else:
|
|
205
|
+
raise ValueError(f'invalid truth value {val}')
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class OAuthRelation(Object):
|
|
209
|
+
"""A class containing helper methods for oauth relation."""
|
|
210
|
+
|
|
211
|
+
_relation_name: str
|
|
212
|
+
|
|
213
|
+
def _pop_relation_data(self, relation_id: Relation) -> None:
|
|
214
|
+
if not self.model.unit.is_leader():
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
if len(self.model.relations) == 0:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
relation = self.model.get_relation(self._relation_name, relation_id=relation_id.id)
|
|
221
|
+
if not relation or not relation.app:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
for data in list(relation.data[self.model.app]):
|
|
226
|
+
relation.data[self.model.app].pop(data, '')
|
|
227
|
+
except Exception as e:
|
|
228
|
+
logger.info('Failed to pop the relation data: %s', e)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _validate_data(data: dict[str, Any], schema: dict[str, Any]) -> None:
|
|
232
|
+
"""Checks whether `data` matches `schema`.
|
|
233
|
+
|
|
234
|
+
Will raise DataValidationError if the data is not valid, else return None.
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
jsonschema.validate(instance=data, schema=schema)
|
|
238
|
+
except jsonschema.ValidationError as e:
|
|
239
|
+
raise DataValidationError(data, schema) from e
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class ClientConfig:
|
|
244
|
+
"""Helper class containing a client's configuration."""
|
|
245
|
+
|
|
246
|
+
redirect_uri: str | None
|
|
247
|
+
scope: str
|
|
248
|
+
grant_types: list[str]
|
|
249
|
+
audience: list[str] = field(default_factory=lambda: [])
|
|
250
|
+
token_endpoint_auth_method: str = 'client_secret_basic' # noqa: S105
|
|
251
|
+
client_id: str | None = None
|
|
252
|
+
|
|
253
|
+
def validate(self) -> None:
|
|
254
|
+
"""Validate the client configuration."""
|
|
255
|
+
if 'authorization_code' in self.grant_types and not self.redirect_uri:
|
|
256
|
+
raise ClientConfigError(
|
|
257
|
+
'redirect_uri is required when using authorization_code grant_type'
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Validate redirect_uri when configured
|
|
261
|
+
if self.redirect_uri is not None and not re.match(url_regex, self.redirect_uri):
|
|
262
|
+
raise ClientConfigError(f'Invalid URL {self.redirect_uri}')
|
|
263
|
+
|
|
264
|
+
if self.redirect_uri is not None and self.redirect_uri.startswith('http://'):
|
|
265
|
+
logger.warning("Provided Redirect URL uses http scheme. Don't do this in production")
|
|
266
|
+
|
|
267
|
+
# Validate grant_types
|
|
268
|
+
for grant_type in self.grant_types:
|
|
269
|
+
if grant_type not in ALLOWED_GRANT_TYPES:
|
|
270
|
+
raise ClientConfigError(
|
|
271
|
+
f'Invalid grant_type {grant_type}, must be one of {ALLOWED_GRANT_TYPES}'
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Validate client authentication methods
|
|
275
|
+
if self.token_endpoint_auth_method not in ALLOWED_CLIENT_AUTHN_METHODS:
|
|
276
|
+
raise ClientConfigError(
|
|
277
|
+
f'Invalid client auth method {self.token_endpoint_auth_method}, '
|
|
278
|
+
f'must be one of {ALLOWED_CLIENT_AUTHN_METHODS}'
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def to_dict(self) -> dict[str, Any]:
|
|
282
|
+
"""Convert object to dict."""
|
|
283
|
+
return {k: v for k, v in asdict(self).items() if v is not None}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass
|
|
287
|
+
class OauthProviderConfig:
|
|
288
|
+
"""Helper class containing provider's configuration."""
|
|
289
|
+
|
|
290
|
+
issuer_url: str
|
|
291
|
+
authorization_endpoint: str
|
|
292
|
+
token_endpoint: str
|
|
293
|
+
introspection_endpoint: str
|
|
294
|
+
userinfo_endpoint: str
|
|
295
|
+
jwks_endpoint: str
|
|
296
|
+
scope: str
|
|
297
|
+
client_id: str | None = None
|
|
298
|
+
client_secret: str | None = None
|
|
299
|
+
groups: str | None = None
|
|
300
|
+
ca_chain: str | None = None
|
|
301
|
+
jwt_access_token: bool | None = False
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def from_dict(cls, dic: dict[str, Any]) -> 'OauthProviderConfig':
|
|
305
|
+
"""Generate OauthProviderConfig instance from dict."""
|
|
306
|
+
jwt_access_token = False
|
|
307
|
+
if 'jwt_access_token' in dic:
|
|
308
|
+
val = dic['jwt_access_token']
|
|
309
|
+
jwt_access_token = val if isinstance(val, bool) else strtobool(str(val))
|
|
310
|
+
return cls(
|
|
311
|
+
jwt_access_token=jwt_access_token,
|
|
312
|
+
**{
|
|
313
|
+
k: v
|
|
314
|
+
for k, v in dic.items()
|
|
315
|
+
if k in [f.name for f in fields(cls)] and k != 'jwt_access_token'
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class OAuthInfoChangedEvent(EventBase):
|
|
321
|
+
"""Event to notify the charm that the information in the databag changed."""
|
|
322
|
+
|
|
323
|
+
def __init__(self, handle: Handle, client_id: str, client_secret_id: str):
|
|
324
|
+
super().__init__(handle)
|
|
325
|
+
self.client_id = client_id
|
|
326
|
+
self.client_secret_id = client_secret_id
|
|
327
|
+
|
|
328
|
+
def snapshot(self) -> dict[str, Any]:
|
|
329
|
+
"""Save event."""
|
|
330
|
+
return {
|
|
331
|
+
'client_id': self.client_id,
|
|
332
|
+
'client_secret_id': self.client_secret_id,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
def restore(self, snapshot: dict[str, Any]) -> None:
|
|
336
|
+
"""Restore event."""
|
|
337
|
+
super().restore(snapshot)
|
|
338
|
+
self.client_id = cast('str', snapshot['client_id'])
|
|
339
|
+
self.client_secret_id = cast('str', snapshot['client_secret_id'])
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class InvalidClientConfigEvent(EventBase):
|
|
343
|
+
"""Event to notify the charm that the client configuration is invalid."""
|
|
344
|
+
|
|
345
|
+
def __init__(self, handle: Handle, error: str):
|
|
346
|
+
super().__init__(handle)
|
|
347
|
+
self.error = error
|
|
348
|
+
|
|
349
|
+
def snapshot(self) -> dict[str, Any]:
|
|
350
|
+
"""Save event."""
|
|
351
|
+
return {
|
|
352
|
+
'error': self.error,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
def restore(self, snapshot: dict[str, Any]) -> None:
|
|
356
|
+
"""Restore event."""
|
|
357
|
+
self.error = cast('str', snapshot['error'])
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class OAuthInfoRemovedEvent(EventBase):
|
|
361
|
+
"""Event to notify the charm that the provider data was removed."""
|
|
362
|
+
|
|
363
|
+
def snapshot(self) -> dict[str, Any]:
|
|
364
|
+
"""Save event."""
|
|
365
|
+
return {}
|
|
366
|
+
|
|
367
|
+
def restore(self, snapshot: dict[str, Any]) -> None:
|
|
368
|
+
"""Restore event."""
|
|
369
|
+
pass
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class OAuthRequirerEvents(ObjectEvents):
|
|
373
|
+
"""Event descriptor for events raised by `OAuthRequirerEvents`."""
|
|
374
|
+
|
|
375
|
+
oauth_info_changed = EventSource(OAuthInfoChangedEvent)
|
|
376
|
+
oauth_info_removed = EventSource(OAuthInfoRemovedEvent)
|
|
377
|
+
invalid_client_config = EventSource(InvalidClientConfigEvent)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class OAuthRequirer(OAuthRelation):
|
|
381
|
+
"""Register an oauth client."""
|
|
382
|
+
|
|
383
|
+
on = OAuthRequirerEvents() # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
|
384
|
+
|
|
385
|
+
def __init__(
|
|
386
|
+
self,
|
|
387
|
+
charm: CharmBase,
|
|
388
|
+
client_config: ClientConfig | None = None,
|
|
389
|
+
relation_name: str = DEFAULT_RELATION_NAME,
|
|
390
|
+
) -> None:
|
|
391
|
+
super().__init__(charm, relation_name)
|
|
392
|
+
self._charm = charm
|
|
393
|
+
self._relation_name = relation_name
|
|
394
|
+
self._client_config = client_config
|
|
395
|
+
events = self._charm.on[relation_name]
|
|
396
|
+
self.framework.observe(events.relation_created, self._on_relation_created_event)
|
|
397
|
+
self.framework.observe(events.relation_changed, self._on_relation_changed_event)
|
|
398
|
+
self.framework.observe(events.relation_broken, self._on_relation_broken_event)
|
|
399
|
+
|
|
400
|
+
def _on_relation_created_event(self, event: RelationCreatedEvent) -> None:
|
|
401
|
+
try:
|
|
402
|
+
self._update_relation_data(self._client_config, event.relation.id)
|
|
403
|
+
except ClientConfigError as e:
|
|
404
|
+
self.on.invalid_client_config.emit(e.args[0])
|
|
405
|
+
|
|
406
|
+
def _on_relation_broken_event(self, event: RelationBrokenEvent) -> None:
|
|
407
|
+
# This may be caused by a provider unit being removed.
|
|
408
|
+
# Also the oauth data may still be there, perhaps we should remove this
|
|
409
|
+
# event altogether for now.
|
|
410
|
+
|
|
411
|
+
# Notify the requirer that the relation data was removed
|
|
412
|
+
self.on.oauth_info_removed.emit()
|
|
413
|
+
|
|
414
|
+
def _on_relation_changed_event(self, event: RelationChangedEvent) -> None:
|
|
415
|
+
if not event.app:
|
|
416
|
+
return
|
|
417
|
+
raw_data = cast('Mapping[str, str]', event.relation.data.get(event.app))
|
|
418
|
+
if not raw_data:
|
|
419
|
+
logger.info('No relation data available.')
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
data = _load_data(raw_data, OAUTH_PROVIDER_JSON_SCHEMA)
|
|
423
|
+
|
|
424
|
+
client_id = cast('str | None', data.get('client_id'))
|
|
425
|
+
client_secret_id = cast('str | None', data.get('client_secret_id'))
|
|
426
|
+
if not client_id or not client_secret_id:
|
|
427
|
+
logger.info('OAuth Provider info is available, waiting for client to be registered.')
|
|
428
|
+
# The client credentials are not ready yet, so we do nothing
|
|
429
|
+
# This could mean that the client credentials were removed from the databag,
|
|
430
|
+
# but we don't allow that (for now), so we don't have to check for it.
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
self.on.oauth_info_changed.emit(client_id, client_secret_id)
|
|
434
|
+
|
|
435
|
+
def _update_relation_data(
|
|
436
|
+
self, client_config: ClientConfig | None, relation_id: int | None = None
|
|
437
|
+
) -> None:
|
|
438
|
+
if not self.model.unit.is_leader() or not client_config:
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
if not isinstance(client_config, ClientConfig): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
442
|
+
raise ValueError(f'Unexpected client_config type: {type(client_config)}')
|
|
443
|
+
|
|
444
|
+
client_config.validate()
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
relation = self.model.get_relation(
|
|
448
|
+
relation_name=self._relation_name, relation_id=relation_id
|
|
449
|
+
)
|
|
450
|
+
except TooManyRelatedAppsError as e:
|
|
451
|
+
raise RuntimeError(
|
|
452
|
+
'More than one relations are defined. Please provide a relation_id'
|
|
453
|
+
) from e
|
|
454
|
+
|
|
455
|
+
if not relation or not relation.app:
|
|
456
|
+
return
|
|
457
|
+
|
|
458
|
+
data = _dump_data(client_config.to_dict(), OAUTH_REQUIRER_JSON_SCHEMA)
|
|
459
|
+
relation.data[self.model.app].update(data)
|
|
460
|
+
|
|
461
|
+
def is_client_created(self, relation_id: int | None = None) -> bool | None:
|
|
462
|
+
"""Check if the client has been created."""
|
|
463
|
+
if len(self.model.relations) == 0:
|
|
464
|
+
return None
|
|
465
|
+
try:
|
|
466
|
+
relation = self.model.get_relation(self._relation_name, relation_id=relation_id)
|
|
467
|
+
except TooManyRelatedAppsError as e:
|
|
468
|
+
raise RuntimeError(
|
|
469
|
+
'More than one relations are defined. Please provide a relation_id'
|
|
470
|
+
) from e
|
|
471
|
+
|
|
472
|
+
if not relation or not relation.app:
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
return (
|
|
476
|
+
'client_id' in relation.data[relation.app]
|
|
477
|
+
and 'client_secret_id' in relation.data[relation.app]
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def get_provider_info(self, relation_id: int | None = None) -> OauthProviderConfig | None:
|
|
481
|
+
"""Get the provider information from the databag."""
|
|
482
|
+
if len(self.model.relations) == 0:
|
|
483
|
+
return None
|
|
484
|
+
try:
|
|
485
|
+
relation = self.model.get_relation(self._relation_name, relation_id=relation_id)
|
|
486
|
+
except TooManyRelatedAppsError as e:
|
|
487
|
+
raise RuntimeError(
|
|
488
|
+
'More than one relations are defined. Please provide a relation_id'
|
|
489
|
+
) from e
|
|
490
|
+
if not relation or not relation.app:
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
raw_data = relation.data.get(relation.app)
|
|
494
|
+
if not raw_data:
|
|
495
|
+
logger.info('No relation data available.')
|
|
496
|
+
return
|
|
497
|
+
|
|
498
|
+
data = _load_data(raw_data, OAUTH_PROVIDER_JSON_SCHEMA)
|
|
499
|
+
|
|
500
|
+
client_secret_id = cast('str | None', data.get('client_secret_id'))
|
|
501
|
+
if client_secret_id:
|
|
502
|
+
client_secret_obj = self.get_client_secret(client_secret_id)
|
|
503
|
+
client_secret = client_secret_obj.get_content()[CLIENT_SECRET_FIELD]
|
|
504
|
+
data['client_secret'] = client_secret
|
|
505
|
+
|
|
506
|
+
oauth_provider = OauthProviderConfig.from_dict(data)
|
|
507
|
+
return oauth_provider
|
|
508
|
+
|
|
509
|
+
def get_client_secret(self, client_secret_id: str) -> Secret:
|
|
510
|
+
"""Get the client_secret."""
|
|
511
|
+
client_secret = self.model.get_secret(id=client_secret_id)
|
|
512
|
+
return client_secret
|
|
513
|
+
|
|
514
|
+
def update_client_config(
|
|
515
|
+
self, client_config: ClientConfig, relation_id: int | None = None
|
|
516
|
+
) -> None:
|
|
517
|
+
"""Update the client config stored in the object."""
|
|
518
|
+
self._client_config = client_config
|
|
519
|
+
self._update_relation_data(client_config, relation_id=relation_id)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class ClientCreatedEvent(EventBase):
|
|
523
|
+
"""Event to notify the Provider charm to create a new client."""
|
|
524
|
+
|
|
525
|
+
def __init__(
|
|
526
|
+
self,
|
|
527
|
+
handle: Handle,
|
|
528
|
+
redirect_uri: str,
|
|
529
|
+
scope: str,
|
|
530
|
+
grant_types: list[str],
|
|
531
|
+
audience: list[str],
|
|
532
|
+
token_endpoint_auth_method: str,
|
|
533
|
+
relation_id: int,
|
|
534
|
+
) -> None:
|
|
535
|
+
super().__init__(handle)
|
|
536
|
+
self.redirect_uri = redirect_uri
|
|
537
|
+
self.scope = scope
|
|
538
|
+
self.grant_types = grant_types
|
|
539
|
+
self.audience = audience
|
|
540
|
+
self.token_endpoint_auth_method = token_endpoint_auth_method
|
|
541
|
+
self.relation_id = relation_id
|
|
542
|
+
|
|
543
|
+
def snapshot(self) -> dict[str, Any]:
|
|
544
|
+
"""Save event."""
|
|
545
|
+
return {
|
|
546
|
+
'redirect_uri': self.redirect_uri,
|
|
547
|
+
'scope': self.scope,
|
|
548
|
+
'grant_types': self.grant_types,
|
|
549
|
+
'audience': self.audience,
|
|
550
|
+
'token_endpoint_auth_method': self.token_endpoint_auth_method,
|
|
551
|
+
'relation_id': self.relation_id,
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
def restore(self, snapshot: dict[str, Any]) -> None:
|
|
555
|
+
"""Restore event."""
|
|
556
|
+
self.redirect_uri = cast('str', snapshot['redirect_uri'])
|
|
557
|
+
self.scope = cast('str', snapshot['scope'])
|
|
558
|
+
self.grant_types = cast('list[str]', snapshot['grant_types'])
|
|
559
|
+
self.audience = cast('list[str]', snapshot['audience'])
|
|
560
|
+
self.token_endpoint_auth_method = cast('str', snapshot['token_endpoint_auth_method'])
|
|
561
|
+
self.relation_id = cast('int', snapshot['relation_id'])
|
|
562
|
+
|
|
563
|
+
def to_client_config(self) -> ClientConfig:
|
|
564
|
+
"""Convert the event information to a ClientConfig object."""
|
|
565
|
+
return ClientConfig(
|
|
566
|
+
self.redirect_uri,
|
|
567
|
+
self.scope,
|
|
568
|
+
self.grant_types,
|
|
569
|
+
self.audience,
|
|
570
|
+
self.token_endpoint_auth_method,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class ClientChangedEvent(EventBase):
|
|
575
|
+
"""Event to notify the Provider charm that the client config changed."""
|
|
576
|
+
|
|
577
|
+
def __init__(
|
|
578
|
+
self,
|
|
579
|
+
handle: Handle,
|
|
580
|
+
redirect_uri: str,
|
|
581
|
+
scope: str,
|
|
582
|
+
grant_types: list[str],
|
|
583
|
+
audience: list[str],
|
|
584
|
+
token_endpoint_auth_method: str,
|
|
585
|
+
relation_id: int,
|
|
586
|
+
client_id: str,
|
|
587
|
+
) -> None:
|
|
588
|
+
super().__init__(handle)
|
|
589
|
+
self.redirect_uri = redirect_uri
|
|
590
|
+
self.scope = scope
|
|
591
|
+
self.grant_types = grant_types
|
|
592
|
+
self.audience = audience
|
|
593
|
+
self.token_endpoint_auth_method = token_endpoint_auth_method
|
|
594
|
+
self.relation_id = relation_id
|
|
595
|
+
self.client_id = client_id
|
|
596
|
+
|
|
597
|
+
def snapshot(self) -> dict[str, Any]:
|
|
598
|
+
"""Save event."""
|
|
599
|
+
return {
|
|
600
|
+
'redirect_uri': self.redirect_uri,
|
|
601
|
+
'scope': self.scope,
|
|
602
|
+
'grant_types': self.grant_types,
|
|
603
|
+
'audience': self.audience,
|
|
604
|
+
'token_endpoint_auth_method': self.token_endpoint_auth_method,
|
|
605
|
+
'relation_id': self.relation_id,
|
|
606
|
+
'client_id': self.client_id,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
def restore(self, snapshot: dict[str, Any]) -> None:
|
|
610
|
+
"""Restore event."""
|
|
611
|
+
self.redirect_uri = cast('str', snapshot['redirect_uri'])
|
|
612
|
+
self.scope = cast('str', snapshot['scope'])
|
|
613
|
+
self.grant_types = cast('list[str]', snapshot['grant_types'])
|
|
614
|
+
self.audience = cast('list[str]', snapshot['audience'])
|
|
615
|
+
self.token_endpoint_auth_method = cast('str', snapshot['token_endpoint_auth_method'])
|
|
616
|
+
self.relation_id = cast('int', snapshot['relation_id'])
|
|
617
|
+
self.client_id = cast('str', snapshot['client_id'])
|
|
618
|
+
|
|
619
|
+
def to_client_config(self) -> ClientConfig:
|
|
620
|
+
"""Convert the event information to a ClientConfig object."""
|
|
621
|
+
return ClientConfig(
|
|
622
|
+
self.redirect_uri,
|
|
623
|
+
self.scope,
|
|
624
|
+
self.grant_types,
|
|
625
|
+
self.audience,
|
|
626
|
+
self.token_endpoint_auth_method,
|
|
627
|
+
self.client_id,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
class ClientDeletedEvent(EventBase):
|
|
632
|
+
"""Event to notify the Provider charm that the client was deleted."""
|
|
633
|
+
|
|
634
|
+
def __init__(
|
|
635
|
+
self,
|
|
636
|
+
handle: Handle,
|
|
637
|
+
relation_id: int,
|
|
638
|
+
) -> None:
|
|
639
|
+
super().__init__(handle)
|
|
640
|
+
self.relation_id = relation_id
|
|
641
|
+
|
|
642
|
+
def snapshot(self) -> dict[str, Any]:
|
|
643
|
+
"""Save event."""
|
|
644
|
+
return {'relation_id': self.relation_id}
|
|
645
|
+
|
|
646
|
+
def restore(self, snapshot: dict[str, Any]) -> None:
|
|
647
|
+
"""Restore event."""
|
|
648
|
+
self.relation_id = cast('int', snapshot['relation_id'])
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
class OAuthProviderEvents(ObjectEvents):
|
|
652
|
+
"""Event descriptor for events raised by `OAuthProviderEvents`."""
|
|
653
|
+
|
|
654
|
+
client_created = EventSource(ClientCreatedEvent)
|
|
655
|
+
client_changed = EventSource(ClientChangedEvent)
|
|
656
|
+
client_deleted = EventSource(ClientDeletedEvent)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class OAuthProvider(OAuthRelation):
|
|
660
|
+
"""A provider object for OIDC Providers."""
|
|
661
|
+
|
|
662
|
+
on = OAuthProviderEvents() # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
|
|
663
|
+
|
|
664
|
+
def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME) -> None:
|
|
665
|
+
super().__init__(charm, relation_name)
|
|
666
|
+
self._charm = charm
|
|
667
|
+
self._relation_name = relation_name
|
|
668
|
+
|
|
669
|
+
events = self._charm.on[relation_name]
|
|
670
|
+
self.framework.observe(
|
|
671
|
+
events.relation_changed,
|
|
672
|
+
self._get_client_config_from_relation_data,
|
|
673
|
+
)
|
|
674
|
+
self.framework.observe(
|
|
675
|
+
events.relation_broken,
|
|
676
|
+
self._on_relation_broken,
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
def _get_client_config_from_relation_data(self, event: RelationChangedEvent) -> None:
|
|
680
|
+
if not self.model.unit.is_leader():
|
|
681
|
+
return
|
|
682
|
+
|
|
683
|
+
if not event.app:
|
|
684
|
+
return
|
|
685
|
+
|
|
686
|
+
raw_data = cast('Mapping[str, str]', event.relation.data.get(event.app))
|
|
687
|
+
if not raw_data:
|
|
688
|
+
logger.info('No requirer relation data available.')
|
|
689
|
+
return
|
|
690
|
+
|
|
691
|
+
client_data = _load_data(raw_data, OAUTH_REQUIRER_JSON_SCHEMA)
|
|
692
|
+
redirect_uri = cast('str | None', client_data.get('redirect_uri'))
|
|
693
|
+
scope = cast('str | None', client_data.get('scope'))
|
|
694
|
+
grant_types = cast('list[str] | None', client_data.get('grant_types'))
|
|
695
|
+
audience = cast('list[str] | None', client_data.get('audience'))
|
|
696
|
+
token_endpoint_auth_method = cast(
|
|
697
|
+
'str | None', client_data.get('token_endpoint_auth_method')
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
provider_data_raw = cast('Mapping[str, str]', event.relation.data.get(self._charm.app))
|
|
701
|
+
if not provider_data_raw:
|
|
702
|
+
logger.info('No provider relation data available.')
|
|
703
|
+
return
|
|
704
|
+
provider_data = _load_data(provider_data_raw, OAUTH_PROVIDER_JSON_SCHEMA)
|
|
705
|
+
client_id = cast('str | None', provider_data.get('client_id'))
|
|
706
|
+
|
|
707
|
+
relation_id = event.relation.id
|
|
708
|
+
|
|
709
|
+
if client_id:
|
|
710
|
+
# Modify an existing client
|
|
711
|
+
self.on.client_changed.emit(
|
|
712
|
+
redirect_uri,
|
|
713
|
+
scope,
|
|
714
|
+
grant_types,
|
|
715
|
+
audience,
|
|
716
|
+
token_endpoint_auth_method,
|
|
717
|
+
relation_id,
|
|
718
|
+
client_id,
|
|
719
|
+
)
|
|
720
|
+
else:
|
|
721
|
+
# Create a new client
|
|
722
|
+
self.on.client_created.emit(
|
|
723
|
+
redirect_uri, scope, grant_types, audience, token_endpoint_auth_method, relation_id
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
def _get_secret_label(self, relation: Relation) -> str:
|
|
727
|
+
return f'client_secret_{relation.id}'
|
|
728
|
+
|
|
729
|
+
def _on_relation_broken(self, event: RelationBrokenEvent) -> None:
|
|
730
|
+
# There is no way to tell if this event was emitted because the
|
|
731
|
+
# relation was removed or if one of the applications was scaled down.
|
|
732
|
+
# Until this is fixed, we don't delete the client.
|
|
733
|
+
# Workaround for https://github.com/canonical/operator/issues/888
|
|
734
|
+
# self._pop_relation_data(event.relation.id)
|
|
735
|
+
|
|
736
|
+
# self._delete_juju_secret(event.relation)
|
|
737
|
+
self.on.client_deleted.emit(event.relation.id)
|
|
738
|
+
|
|
739
|
+
def _create_juju_secret(self, client_secret: str, relation: Relation) -> Secret:
|
|
740
|
+
"""Create a juju secret and grant it to a relation."""
|
|
741
|
+
secret = {CLIENT_SECRET_FIELD: client_secret}
|
|
742
|
+
juju_secret = self.model.app.add_secret(secret, label=self._get_secret_label(relation))
|
|
743
|
+
juju_secret.grant(relation)
|
|
744
|
+
return juju_secret
|
|
745
|
+
|
|
746
|
+
def _delete_juju_secret(self, relation: Relation) -> None:
|
|
747
|
+
try:
|
|
748
|
+
secret = self.model.get_secret(label=self._get_secret_label(relation))
|
|
749
|
+
except SecretNotFoundError:
|
|
750
|
+
return
|
|
751
|
+
else:
|
|
752
|
+
secret.remove_all_revisions()
|
|
753
|
+
|
|
754
|
+
def remove_secret(self, relation: Relation) -> None:
|
|
755
|
+
return self._delete_juju_secret(relation)
|
|
756
|
+
|
|
757
|
+
def set_provider_info_in_relation_data(
|
|
758
|
+
self,
|
|
759
|
+
issuer_url: str,
|
|
760
|
+
authorization_endpoint: str,
|
|
761
|
+
token_endpoint: str,
|
|
762
|
+
introspection_endpoint: str,
|
|
763
|
+
userinfo_endpoint: str,
|
|
764
|
+
jwks_endpoint: str,
|
|
765
|
+
scope: str,
|
|
766
|
+
groups: str | None = None,
|
|
767
|
+
ca_chain: str | None = None,
|
|
768
|
+
jwt_access_token: bool | None = False,
|
|
769
|
+
) -> None:
|
|
770
|
+
"""Put the provider information in the databag."""
|
|
771
|
+
if not self.model.unit.is_leader():
|
|
772
|
+
return
|
|
773
|
+
|
|
774
|
+
data = {
|
|
775
|
+
'issuer_url': issuer_url,
|
|
776
|
+
'authorization_endpoint': authorization_endpoint,
|
|
777
|
+
'token_endpoint': token_endpoint,
|
|
778
|
+
'introspection_endpoint': introspection_endpoint,
|
|
779
|
+
'userinfo_endpoint': userinfo_endpoint,
|
|
780
|
+
'jwks_endpoint': jwks_endpoint,
|
|
781
|
+
'scope': scope,
|
|
782
|
+
'jwt_access_token': jwt_access_token,
|
|
783
|
+
}
|
|
784
|
+
if groups:
|
|
785
|
+
data['groups'] = groups
|
|
786
|
+
if ca_chain:
|
|
787
|
+
data['ca_chain'] = ca_chain
|
|
788
|
+
|
|
789
|
+
for relation in self.model.relations[self._relation_name]:
|
|
790
|
+
relation.data[self.model.app].update(_dump_data(data))
|
|
791
|
+
|
|
792
|
+
def set_client_credentials_in_relation_data(
|
|
793
|
+
self, relation_id: int, client_id: str, client_secret: str
|
|
794
|
+
) -> None:
|
|
795
|
+
"""Put the client credentials in the databag."""
|
|
796
|
+
if not self.model.unit.is_leader():
|
|
797
|
+
return
|
|
798
|
+
|
|
799
|
+
relation = self.model.get_relation(self._relation_name, relation_id)
|
|
800
|
+
if not relation or not relation.app:
|
|
801
|
+
return
|
|
802
|
+
# TODO: What if we are refreshing the client_secret? We need to add a
|
|
803
|
+
# new revision for that
|
|
804
|
+
secret = self._create_juju_secret(client_secret, relation)
|
|
805
|
+
data = {'client_id': client_id, 'client_secret_id': secret.id}
|
|
806
|
+
relation.data[self.model.app].update(_dump_data(data))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright 2026 Canonical Ltd.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
__version__ = '1.0.0'
|
|
File without changes
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: charmlibs-interfaces-oauth
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The charmlibs.interfaces.oauth package.
|
|
5
|
+
Project-URL: Documentation, https://canonical.com/juju/docs/charmlibs/reference/charmlibs/interfaces/oauth
|
|
6
|
+
Project-URL: Repository, https://github.com/canonical/charmlibs/tree/main/interfaces/oauth
|
|
7
|
+
Project-URL: Issues, https://github.com/canonical/charmlibs/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/canonical/charmlibs/blob/main/interfaces/oauth/CHANGELOG.md
|
|
9
|
+
Author: Identity Team at Canonical
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: jsonschema>=4.26.0
|
|
17
|
+
Requires-Dist: ops<4,>=2.23.1
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# charmlibs.interfaces.oauth
|
|
21
|
+
|
|
22
|
+
The `oauth` interface library.
|
|
23
|
+
|
|
24
|
+
To install, add `charmlibs-interfaces-oauth` to your Python dependencies. Then in your Python code, import as:
|
|
25
|
+
|
|
26
|
+
```py
|
|
27
|
+
from charmlibs.interfaces import oauth
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
See the [reference documentation](https://canonical.com/juju/docs/charmlibs/reference/charmlibs/interfaces/oauth) for more.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
charmlibs/interfaces/oauth/__init__.py,sha256=T6mMVl8BwUqdnquDYEXggNP3VkIgPK_QzKnWB5Or6XU,2763
|
|
2
|
+
charmlibs/interfaces/oauth/_oauth.py,sha256=yZsxOmNrgcg4ieqJD1-0LrjmtiIenb8PQYEZgnLTvq8,28405
|
|
3
|
+
charmlibs/interfaces/oauth/_version.py,sha256=mimKCOrPlb1vtVJVsxk3us5vTy0K8n3WoU3GISe6wFE,597
|
|
4
|
+
charmlibs/interfaces/oauth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
charmlibs_interfaces_oauth-1.0.0.dist-info/METADATA,sha256=_b12i8dEAHmNUfDc6RCVVNosOnlpl41oWq1OGbIEqes,1225
|
|
6
|
+
charmlibs_interfaces_oauth-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
charmlibs_interfaces_oauth-1.0.0.dist-info/RECORD,,
|