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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any