alpha-python 0.2.5__tar.gz → 0.3.0__tar.gz

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.
Files changed (131) hide show
  1. {alpha_python-0.2.5 → alpha_python-0.3.0}/PKG-INFO +1 -1
  2. {alpha_python-0.2.5 → alpha_python-0.3.0}/pyproject.toml +1 -1
  3. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/__init__.py +9 -0
  4. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/controller.mustache +2 -2
  5. alpha_python-0.3.0/src/alpha/infra/connectors/__init__.py +11 -0
  6. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/infra/connectors/ldap_connector.py +1 -1
  7. alpha_python-0.3.0/src/alpha/infra/connectors/oidc_connector.py +503 -0
  8. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/providers/__init__.py +3 -0
  9. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/providers/ldap_provider.py +43 -11
  10. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/providers/models/identity.py +9 -6
  11. alpha_python-0.3.0/src/alpha/providers/oidc_provider.py +418 -0
  12. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha_python.egg-info/PKG-INFO +1 -1
  13. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha_python.egg-info/SOURCES.txt +2 -1
  14. alpha_python-0.2.5/src/alpha/infra/connectors/__init__.py +0 -5
  15. alpha_python-0.2.5/src/alpha/providers/keycloak_provider.py +0 -16
  16. {alpha_python-0.2.5 → alpha_python-0.3.0}/LICENSE +0 -0
  17. {alpha_python-0.2.5 → alpha_python-0.3.0}/README.md +0 -0
  18. {alpha_python-0.2.5 → alpha_python-0.3.0}/setup.cfg +0 -0
  19. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/adapters/__init__.py +0 -0
  20. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/adapters/sqla_unit_of_work.py +0 -0
  21. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/cli.py +0 -0
  22. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/containers/__init__.py +0 -0
  23. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/containers/container.py +0 -0
  24. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/domain/__init__.py +0 -0
  25. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/domain/models/__init__.py +0 -0
  26. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/domain/models/base_model.py +0 -0
  27. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/domain/models/life_cycle_base.py +0 -0
  28. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/domain/models/user.py +0 -0
  29. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/encoder.py +0 -0
  30. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/exceptions.py +0 -0
  31. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/__init__.py +0 -0
  32. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/_type_conversion_matrix.py +0 -0
  33. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/_type_mapping.py +0 -0
  34. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/class_factories.py +0 -0
  35. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/default_field_factory.py +0 -0
  36. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/field_iterator.py +0 -0
  37. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/jwt_factory.py +0 -0
  38. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/logging_handler_factory.py +0 -0
  39. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/model_class_factory.py +0 -0
  40. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/models/__init__.py +0 -0
  41. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/models/factory_classes.py +0 -0
  42. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/request_factory.py +0 -0
  43. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/response_factory.py +0 -0
  44. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/factories/type_factories.py +0 -0
  45. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/__init__.py +0 -0
  46. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/api_generate_handler.py +0 -0
  47. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/api_run_handler.py +0 -0
  48. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/base_handler.py +0 -0
  49. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/gen-code.sh +0 -0
  50. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/models/__init__.py +0 -0
  51. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/models/argument.py +0 -0
  52. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/models/command.py +0 -0
  53. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/models/section.py +0 -0
  54. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/models/subparser.py +0 -0
  55. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/run-api.sh +0 -0
  56. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/__init__.py +0 -0
  57. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/Dockerfile.mustache +0 -0
  58. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/README.mustache +0 -0
  59. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/__init__model.mustache +0 -0
  60. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/__init__test.mustache +0 -0
  61. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/__main__.mustache +0 -0
  62. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/base_model.mustache +0 -0
  63. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/controller_test.mustache +0 -0
  64. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/dockerignore.mustache +0 -0
  65. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/encoder.mustache +0 -0
  66. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/git_push.sh.mustache +0 -0
  67. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/gitignore.mustache +0 -0
  68. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/model.mustache +0 -0
  69. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/openapi.mustache +0 -0
  70. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/param_type.mustache +0 -0
  71. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/requirements.mustache +0 -0
  72. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/security_controller_.mustache +0 -0
  73. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/setup.mustache +0 -0
  74. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/test-requirements.mustache +0 -0
  75. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/tox.mustache +0 -0
  76. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/travis.mustache +0 -0
  77. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/typing_utils.mustache +0 -0
  78. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/handlers/templates/python-flask/util.mustache +0 -0
  79. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/infra/__init__.py +0 -0
  80. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/infra/databases/__init__.py +0 -0
  81. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/infra/databases/sql_alchemy.py +0 -0
  82. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/infra/models/__init__.py +0 -0
  83. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/infra/models/filter_operators.py +0 -0
  84. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/infra/models/json_patch.py +0 -0
  85. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/infra/models/order_by.py +0 -0
  86. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/infra/models/query_clause.py +0 -0
  87. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/infra/models/search_filter.py +0 -0
  88. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/__init__.py +0 -0
  89. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/attrs_instance.py +0 -0
  90. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/dataclass_instance.py +0 -0
  91. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/factories.py +0 -0
  92. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/handler.py +0 -0
  93. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/openapi_model.py +0 -0
  94. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/patchable.py +0 -0
  95. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/providers.py +0 -0
  96. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/pydantic_instance.py +0 -0
  97. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/sql_database.py +0 -0
  98. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/sql_mapper.py +0 -0
  99. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/sql_repository.py +0 -0
  100. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/token_factory.py +0 -0
  101. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/unit_of_work.py +0 -0
  102. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/interfaces/updateable.py +0 -0
  103. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/mixins/__init__.py +0 -0
  104. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/mixins/jwt_provider.py +0 -0
  105. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/providers/api_key_provider.py +0 -0
  106. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/providers/database_provider.py +0 -0
  107. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/providers/local_provider.py +0 -0
  108. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/providers/models/__init__.py +0 -0
  109. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/providers/models/credentials.py +0 -0
  110. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/providers/models/token.py +0 -0
  111. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/py.typed +0 -0
  112. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/repositories/__init__.py +0 -0
  113. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/repositories/models/__init__.py +0 -0
  114. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/repositories/models/repository_model.py +0 -0
  115. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/repositories/sql_alchemy_repository.py +0 -0
  116. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/services/__init__.py +0 -0
  117. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/services/authentication_service.py +0 -0
  118. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/utils/__init__.py +0 -0
  119. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/utils/_http_codes.py +0 -0
  120. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/utils/is_attrs.py +0 -0
  121. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/utils/is_pydantic.py +0 -0
  122. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/utils/logging_configurator.py +0 -0
  123. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/utils/logging_level_checker.py +0 -0
  124. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/utils/response_object.py +0 -0
  125. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/utils/verify_identity.py +0 -0
  126. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha/utils/version_checker.py +0 -0
  127. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha_python.egg-info/dependency_links.txt +0 -0
  128. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha_python.egg-info/entry_points.txt +0 -0
  129. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha_python.egg-info/requires.txt +0 -0
  130. {alpha_python-0.2.5 → alpha_python-0.3.0}/src/alpha_python.egg-info/top_level.txt +0 -0
  131. {alpha_python-0.2.5 → alpha_python-0.3.0}/tests/test_encoder.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpha-python
3
- Version: 0.2.5
3
+ Version: 0.3.0
4
4
  Summary: Alpha is intended to be the first dependency you need to add to your Python application. It is a Python library which contains standard building blocks that can be used in applications that are used as APIs and/or make use of database interaction.
5
5
  Author-email: Bart Reijling <bart@reijling.eu>
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "alpha-python"
3
- version = "0.2.5"
3
+ version = "0.3.0"
4
4
  description = "Alpha is intended to be the first dependency you need to add to your Python application. It is a Python library which contains standard building blocks that can be used in applications that are used as APIs and/or make use of database interaction."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -6,6 +6,10 @@ from alpha.domain.models.user import User
6
6
  from alpha.domain.models.base_model import BaseDomainModel, DomainModel
7
7
  from alpha.domain.models.life_cycle_base import LifeCycleBase
8
8
  from alpha.infra.connectors.ldap_connector import LDAPConnector
9
+ from alpha.infra.connectors.oidc_connector import (
10
+ OIDCConnector,
11
+ KeyCloakOIDCConnector,
12
+ )
9
13
  from alpha.infra.databases.sql_alchemy import SqlAlchemyDatabase
10
14
  from alpha.infra.models.filter_operators import And, Or
11
15
  from alpha.infra.models.json_patch import JsonPatch
@@ -40,6 +44,7 @@ from alpha.providers.models.identity import (
40
44
  from alpha.providers.models.credentials import PasswordCredentials
41
45
  from alpha.providers.models.token import Token
42
46
  from alpha.providers.ldap_provider import LDAPProvider, ADProvider
47
+ from alpha.providers.oidc_provider import OIDCProvider, KeyCloakProvider
43
48
  from alpha.repositories.models.repository_model import RepositoryModel
44
49
  from alpha.repositories.sql_alchemy_repository import SqlAlchemyRepository
45
50
  from alpha.services.authentication_service import AuthenticationService
@@ -67,6 +72,8 @@ __all__ = [
67
72
  "LifeCycleBase",
68
73
  "User",
69
74
  "LDAPConnector",
75
+ "OIDCConnector",
76
+ "KeyCloakOIDCConnector",
70
77
  "SqlAlchemyDatabase",
71
78
  "And",
72
79
  "Or",
@@ -101,6 +108,8 @@ __all__ = [
101
108
  "Token",
102
109
  "LDAPProvider",
103
110
  "ADProvider",
111
+ "OIDCProvider",
112
+ "KeyCloakProvider",
104
113
  "RepositoryModel",
105
114
  "SqlAlchemyRepository",
106
115
  "AuthenticationService",
@@ -154,8 +154,8 @@ def {{operationId}}(
154
154
  try:
155
155
  # Objects used for authorization
156
156
  roles=[{{#vendorExtensions.x-alpha-verify-roles}}"{{.}}",{{/vendorExtensions.x-alpha-verify-roles}}]
157
- groups=[{{#vendorExtensions.x-alpha-verify-groups}}"{{.}}",{{/vendorExtensions.x-alpha-verify-groups}}],
158
- permissions=[{{#vendorExtensions.x-alpha-verify-permissions}}"{{.}}",{{/vendorExtensions.x-alpha-verify-permissions}}],
157
+ groups=[{{#vendorExtensions.x-alpha-verify-groups}}"{{.}}",{{/vendorExtensions.x-alpha-verify-groups}}]
158
+ permissions=[{{#vendorExtensions.x-alpha-verify-permissions}}"{{.}}",{{/vendorExtensions.x-alpha-verify-permissions}}]
159
159
  {{#authMethods}}{{#isBasicBearer}}
160
160
  # validate token
161
161
  if token_factory.validate(token):
@@ -0,0 +1,11 @@
1
+ from alpha.infra.connectors.ldap_connector import LDAPConnector
2
+ from alpha.infra.connectors.oidc_connector import (
3
+ OIDCConnector,
4
+ KeyCloakOIDCConnector,
5
+ )
6
+
7
+ __all__ = [
8
+ "LDAPConnector",
9
+ "OIDCConnector",
10
+ "KeyCloakOIDCConnector",
11
+ ]
@@ -60,7 +60,7 @@ class LDAPConnector:
60
60
  if use_tls:
61
61
  tls = Tls(
62
62
  validate=ssl.CERT_REQUIRED,
63
- version=ssl.PROTOCOL_TLSv1_2,
63
+ version=ssl.PROTOCOL_TLS_CLIENT,
64
64
  )
65
65
 
66
66
  self._server = Server(
@@ -0,0 +1,503 @@
1
+ """OIDC connectors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping, cast
6
+ from urllib.parse import quote, urljoin
7
+
8
+ import requests
9
+ from requests.auth import HTTPBasicAuth
10
+
11
+ from alpha import exceptions
12
+
13
+
14
+ class OIDCConnector:
15
+ """OIDC connector for interacting with an OpenID Connect identity provider
16
+ via OIDC/OAuth2 protocols.
17
+
18
+ Parameters
19
+ ----------
20
+ token_url
21
+ Token endpoint URL or relative path.
22
+ userinfo_url
23
+ Optional userinfo endpoint URL or relative path.
24
+ introspection_url
25
+ Optional token introspection endpoint URL or relative path.
26
+ client_id
27
+ OAuth2 client identifier used for token requests.
28
+ client_secret
29
+ OAuth2 client secret used for token requests.
30
+ scope
31
+ Space-delimited OAuth2 scopes for standard token requests.
32
+ verify_tls
33
+ Whether to verify TLS certificates for HTTP requests.
34
+ timeout_seconds
35
+ Request timeout in seconds.
36
+ use_basic_auth
37
+ Whether to send client credentials via HTTP Basic Auth.
38
+ base_url
39
+ Optional base URL to resolve relative endpoints.
40
+ user_lookup_url_template
41
+ Template URL used to look up a user by subject/username.
42
+ admin_client_id
43
+ Optional client identifier for admin token requests.
44
+ admin_client_secret
45
+ Optional client secret for admin token requests.
46
+ admin_scope
47
+ Optional scope override for admin token requests.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ token_url: str,
53
+ userinfo_url: str | None = None,
54
+ introspection_url: str | None = None,
55
+ client_id: str | None = None,
56
+ client_secret: str | None = None,
57
+ scope: str | list[str] | None = None,
58
+ verify_tls: bool = True,
59
+ timeout_seconds: int = 10,
60
+ use_basic_auth: bool = True,
61
+ base_url: str | None = None,
62
+ user_lookup_url_template: str | None = None,
63
+ admin_client_id: str | None = None,
64
+ admin_client_secret: str | None = None,
65
+ admin_scope: str | list[str] | None = None,
66
+ ) -> None:
67
+ self._base_url = base_url
68
+ self._token_url = self._build_url(token_url)
69
+ self._userinfo_url = (
70
+ self._build_url(userinfo_url) if userinfo_url else None
71
+ )
72
+ self._introspection_url = (
73
+ self._build_url(introspection_url) if introspection_url else None
74
+ )
75
+ self._user_lookup_url_template = user_lookup_url_template
76
+ self._client_id = client_id
77
+ self._client_secret = client_secret
78
+ self._scope = self._sanitize_scope(scope)
79
+ self._verify_tls = verify_tls
80
+ self._timeout_seconds = timeout_seconds
81
+ self._use_basic_auth = use_basic_auth
82
+ self._admin_client_id = admin_client_id or client_id
83
+ self._admin_client_secret = admin_client_secret or client_secret
84
+ self._admin_scope = self._sanitize_scope(admin_scope) or self._scope
85
+
86
+ self._session = requests.Session()
87
+
88
+ def close(self) -> None:
89
+ """Close the underlying HTTP session to release resources."""
90
+ self._session.close()
91
+
92
+ def __enter__(self) -> "OIDCConnector":
93
+ """Enter the runtime context related to this object."""
94
+ return self
95
+
96
+ def __exit__(self, exc_type, exc, tb) -> None:
97
+ """Exit the runtime context and close the HTTP session."""
98
+ self.close()
99
+ @property
100
+ def userinfo_url(self) -> str | None:
101
+ """Return the configured userinfo endpoint URL.
102
+
103
+ Returns
104
+ -------
105
+ The full userinfo endpoint URL or None.
106
+ """
107
+ return self._userinfo_url
108
+
109
+ @property
110
+ def introspection_url(self) -> str | None:
111
+ """Return the configured introspection endpoint URL.
112
+
113
+ Returns
114
+ -------
115
+ The full introspection endpoint URL or None.
116
+ """
117
+ return self._introspection_url
118
+
119
+ @property
120
+ def user_lookup_url_template(self) -> str | None:
121
+ """Return the configured user lookup URL template.
122
+
123
+ Returns
124
+ -------
125
+ URL template for user lookup or None.
126
+ """
127
+ return self._user_lookup_url_template
128
+
129
+ def request_password_token(
130
+ self, username: str, password: str
131
+ ) -> dict[str, Any]:
132
+ """Request an access token using the password grant.
133
+
134
+ Parameters
135
+ ----------
136
+ username
137
+ Resource owner username.
138
+ password
139
+ Resource owner password.
140
+
141
+ Returns
142
+ -------
143
+ Token response payload from the identity provider.
144
+ """
145
+ data: dict[str, Any] = {
146
+ "grant_type": "password",
147
+ "username": username,
148
+ "password": password,
149
+ }
150
+ if self._scope:
151
+ data["scope"] = self._scope
152
+ return self._post_token(data)
153
+
154
+ def request_client_credentials_token(self) -> dict[str, Any]:
155
+ """Request an access token using the client credentials grant.
156
+
157
+ Returns
158
+ -------
159
+ Token response payload from the identity provider.
160
+ """
161
+ data: dict[str, Any] = {"grant_type": "client_credentials"}
162
+ if self._admin_scope:
163
+ data["scope"] = self._admin_scope
164
+ return self._post_token(data, use_admin=True)
165
+
166
+ def get_userinfo(self, access_token: str) -> dict[str, Any]:
167
+ """Fetch user profile information for the given access token.
168
+
169
+ Parameters
170
+ ----------
171
+ access_token
172
+ Bearer access token.
173
+
174
+ Returns
175
+ -------
176
+ Userinfo response payload.
177
+
178
+ Raises
179
+ ------
180
+ exceptions.MissingConfigurationException
181
+ If the userinfo endpoint is not configured.
182
+ """
183
+ if not self._userinfo_url:
184
+ raise exceptions.MissingConfigurationException(
185
+ "userinfo_url is not configured"
186
+ )
187
+ return self._request_json(
188
+ "GET",
189
+ self._userinfo_url,
190
+ headers={"Authorization": f"Bearer {access_token}"},
191
+ )
192
+
193
+ def introspect_token(self, token: str) -> dict[str, Any]:
194
+ """Introspect a token using the configured endpoint.
195
+
196
+ Parameters
197
+ ----------
198
+ token
199
+ Access or refresh token to introspect.
200
+
201
+ Returns
202
+ -------
203
+ Introspection response payload.
204
+
205
+ Raises
206
+ ------
207
+ exceptions.MissingConfigurationException
208
+ If the introspection endpoint is not configured.
209
+ """
210
+ if not self._introspection_url:
211
+ raise exceptions.MissingConfigurationException(
212
+ "introspection_url is not configured"
213
+ )
214
+ data = {"token": token}
215
+ return self._request_json(
216
+ "POST",
217
+ self._introspection_url,
218
+ data=data,
219
+ auth=self._build_auth(),
220
+ )
221
+
222
+ def get_user_by_subject(self, subject: str) -> dict[str, Any]:
223
+ """Look up a user by subject using the admin client.
224
+
225
+ Parameters
226
+ ----------
227
+ subject
228
+ Subject or username to look up.
229
+
230
+ Returns
231
+ -------
232
+ User representation returned by the provider.
233
+
234
+ Raises
235
+ ------
236
+ exceptions.MissingConfigurationException
237
+ If the user lookup URL template is not configured.
238
+ exceptions.IdentityError
239
+ If an admin token cannot be obtained or response is invalid.
240
+ exceptions.UserNotFoundException
241
+ If the user is not found.
242
+ """
243
+ if not self._user_lookup_url_template:
244
+ raise exceptions.MissingConfigurationException(
245
+ "user_lookup_url_template is not configured"
246
+ )
247
+
248
+ token_data = self.request_client_credentials_token()
249
+ access_token = token_data.get("access_token")
250
+ if not access_token:
251
+ raise exceptions.IdentityError(
252
+ "Unable to obtain admin access token"
253
+ )
254
+
255
+ # URL-encode the subject to prevent format string injection
256
+ url = self._user_lookup_url_template.format(subject=quote(subject, safe=''))
257
+ response: Any = self._request_json(
258
+ "GET",
259
+ url,
260
+ headers={"Authorization": f"Bearer {access_token}"},
261
+ )
262
+
263
+ if isinstance(response, list):
264
+ if not response:
265
+ raise exceptions.UserNotFoundException(
266
+ f"User '{subject}' not found by identity provider"
267
+ )
268
+ first_item = cast(Mapping[str, Any], response[0])
269
+ return dict(first_item)
270
+
271
+ if isinstance(response, Mapping):
272
+ return dict(cast(Mapping[str, Any], response))
273
+
274
+ raise exceptions.IdentityError(
275
+ "Unexpected response while fetching user"
276
+ )
277
+
278
+ def _post_token(
279
+ self, data: dict[str, Any], use_admin: bool = False
280
+ ) -> dict[str, Any]:
281
+ """Request a token at the token endpoint.
282
+
283
+ Parameters
284
+ ----------
285
+ data
286
+ Form payload to send to the token endpoint.
287
+ use_admin, optional
288
+ Whether to use admin client credentials, by default False.
289
+
290
+ Returns
291
+ -------
292
+ Token response payload from the identity provider.
293
+
294
+ Raises
295
+ ------
296
+ exceptions.MissingConfigurationException
297
+ If the required client identifier is missing.
298
+ """
299
+ if use_admin:
300
+ client_id = self._admin_client_id
301
+ client_secret = self._admin_client_secret
302
+ else:
303
+ client_id = self._client_id
304
+ client_secret = self._client_secret
305
+
306
+ if not client_id:
307
+ raise exceptions.MissingConfigurationException(
308
+ "client_id is not configured"
309
+ )
310
+
311
+ if not self._token_url:
312
+ raise exceptions.MissingConfigurationException(
313
+ "token_url is not configured"
314
+ )
315
+
316
+ if not self._use_basic_auth:
317
+ data["client_id"] = client_id
318
+ if client_secret:
319
+ data["client_secret"] = client_secret
320
+
321
+ auth = (
322
+ HTTPBasicAuth(client_id, client_secret)
323
+ if self._use_basic_auth and client_secret
324
+ else None
325
+ )
326
+
327
+ return self._request_json(
328
+ method="POST",
329
+ url=self._token_url,
330
+ data=data,
331
+ auth=auth,
332
+ )
333
+
334
+ def _request_json(
335
+ self,
336
+ method: str,
337
+ url: str,
338
+ **kwargs: Any,
339
+ ) -> Any:
340
+ """Execute an HTTP request and return a JSON response.
341
+
342
+ Parameters
343
+ ----------
344
+ method
345
+ HTTP method (GET, POST, etc.).
346
+ url
347
+ Absolute URL for the request.
348
+ **kwargs
349
+ Additional arguments passed to `requests.request()`.
350
+
351
+ Returns
352
+ -------
353
+ Parsed JSON response payload.
354
+
355
+ Raises
356
+ ------
357
+ exceptions.IdentityError
358
+ If the request fails or returns a non-JSON response.
359
+ exceptions.InvalidCredentialsException
360
+ If the identity provider rejects credentials.
361
+ """
362
+ try:
363
+ response = self._session.request(
364
+ method,
365
+ url,
366
+ timeout=self._timeout_seconds,
367
+ verify=self._verify_tls,
368
+ **kwargs,
369
+ )
370
+ except requests.RequestException as exc:
371
+ raise exceptions.IdentityError(
372
+ f"OAuth2 request failed: {exc}"
373
+ ) from exc
374
+
375
+ if response.status_code >= 400:
376
+ message = self._extract_error_message(response)
377
+ if response.status_code in (400, 401, 403):
378
+ raise exceptions.InvalidCredentialsException(message)
379
+ raise exceptions.IdentityError(message)
380
+
381
+ try:
382
+ return response.json()
383
+ except ValueError as exc:
384
+ raise exceptions.IdentityError(
385
+ "OAuth2 response did not include JSON payload"
386
+ ) from exc
387
+
388
+ @staticmethod
389
+ def _extract_error_message(response: requests.Response) -> str:
390
+ """Extract a provider error message from an HTTP response.
391
+
392
+ Parameters
393
+ ----------
394
+ response
395
+ HTTP response returned by the provider.
396
+
397
+ Returns
398
+ -------
399
+ Human-readable error message.
400
+ """
401
+ try:
402
+ payload = response.json()
403
+ error = payload.get("error_description") or payload.get("error")
404
+ if error:
405
+ return str(error)
406
+ except ValueError:
407
+ # If the response body is not valid JSON, fall back to a generic message.
408
+ pass
409
+ return f"OAuth2 request failed with status {response.status_code}"
410
+
411
+ def _build_url(self, url: str | None) -> str | None:
412
+ """Resolve a relative endpoint against the configured base URL.
413
+
414
+ Parameters
415
+ ----------
416
+ url
417
+ Absolute or relative URL.
418
+
419
+ Returns
420
+ -------
421
+ Absolute URL when possible, otherwise None.
422
+ """
423
+ if url is None:
424
+ return None
425
+ if self._base_url and not url.startswith("http"):
426
+ return urljoin(self._base_url.rstrip("/") + "/", url)
427
+ return url
428
+
429
+ def _build_auth(self) -> HTTPBasicAuth | None:
430
+ """Create HTTP Basic Auth credentials for introspection requests.
431
+
432
+ Returns
433
+ -------
434
+ Basic auth instance when configured, otherwise None.
435
+ """
436
+ if not self._client_id or not self._client_secret:
437
+ return None
438
+ if not self._use_basic_auth:
439
+ return None
440
+ return HTTPBasicAuth(self._client_id, self._client_secret)
441
+
442
+ def _sanitize_scope(self, scope: str | list[str] | None) -> str | None:
443
+ """Sanitize the scope parameter to be a space-delimited string.
444
+
445
+ Parameters
446
+ ----------
447
+ scope
448
+ Scope as a string or list of strings.
449
+
450
+ Returns
451
+ -------
452
+ Space-delimited scope string or None.
453
+ """
454
+ if scope is None:
455
+ return None
456
+ if isinstance(scope, list):
457
+ return " ".join(scope)
458
+ return scope
459
+
460
+
461
+ class KeyCloakOIDCConnector(OIDCConnector):
462
+ """Keycloak-specific OIDC connector."""
463
+
464
+ def __init__(
465
+ self,
466
+ base_url: str,
467
+ realm: str,
468
+ client_id: str,
469
+ client_secret: str,
470
+ scope: str | list[str] | None = None,
471
+ admin_client_id: str | None = None,
472
+ admin_client_secret: str | None = None,
473
+ admin_scope: str | None = None,
474
+ verify_tls: bool = True,
475
+ timeout_seconds: int = 10,
476
+ ) -> None:
477
+ token_url = (
478
+ f"{base_url}/realms/{realm}" "/protocol/openid-connect/token"
479
+ )
480
+ userinfo_url = (
481
+ f"{base_url}/realms/{realm}" "/protocol/openid-connect/userinfo"
482
+ )
483
+ introspection_url = (
484
+ f"{base_url}/realms/{realm}"
485
+ "/protocol/openid-connect/token/introspect"
486
+ )
487
+ user_lookup_url_template = (
488
+ f"{base_url}/admin/realms/{realm}/users" "?username={subject}"
489
+ )
490
+ super().__init__(
491
+ token_url=token_url,
492
+ userinfo_url=userinfo_url,
493
+ introspection_url=introspection_url,
494
+ client_id=client_id,
495
+ client_secret=client_secret,
496
+ scope=scope,
497
+ verify_tls=verify_tls,
498
+ timeout_seconds=timeout_seconds,
499
+ user_lookup_url_template=user_lookup_url_template,
500
+ admin_client_id=admin_client_id,
501
+ admin_client_secret=admin_client_secret,
502
+ admin_scope=admin_scope,
503
+ )
@@ -10,6 +10,7 @@ from alpha.providers.models.token import Token
10
10
 
11
11
  # Providers
12
12
  from alpha.providers.ldap_provider import LDAPProvider, ADProvider
13
+ from alpha.providers.oidc_provider import OIDCProvider, KeyCloakProvider
13
14
 
14
15
  __all__ = [
15
16
  "Identity",
@@ -20,4 +21,6 @@ __all__ = [
20
21
  "Token",
21
22
  "LDAPProvider",
22
23
  "ADProvider",
24
+ "OIDCProvider",
25
+ "KeyCloakProvider",
23
26
  ]