pkg-auth 3.2.0__tar.gz → 3.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 (119) hide show
  1. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/PKG-INFO +4 -2
  2. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/README.md +3 -1
  3. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/pyproject.toml +1 -1
  4. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/__init__.py +3 -1
  5. pkg_auth-3.3.0/src/pkg_auth/authentication/adapters/keycloak/__init__.py +12 -0
  6. pkg_auth-3.3.0/src/pkg_auth/authentication/adapters/keycloak/service_token.py +210 -0
  7. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/exceptions.py +4 -0
  8. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/PKG-INFO +4 -2
  9. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/SOURCES.txt +1 -0
  10. pkg_auth-3.2.0/src/pkg_auth/authentication/adapters/keycloak/__init__.py +0 -6
  11. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/setup.cfg +0 -0
  12. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/__init__.py +0 -0
  13. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/__init__.py +0 -0
  14. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/cli.py +0 -0
  15. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/client.py +0 -0
  16. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/env.py +0 -0
  17. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/helpers.py +0 -0
  18. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/provision_client.py +0 -0
  19. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/settings.py +0 -0
  20. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/adapters/__init__.py +0 -0
  21. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +0 -0
  22. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/__init__.py +0 -0
  23. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/use_cases/__init__.py +0 -0
  24. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/use_cases/authenticate.py +0 -0
  25. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/__init__.py +0 -0
  26. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/entities.py +0 -0
  27. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/ports.py +0 -0
  28. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/value_objects.py +0 -0
  29. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/__init__.py +0 -0
  30. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/__init__.py +0 -0
  31. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/__init__.py +0 -0
  32. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/decorators.py +0 -0
  33. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/memory.py +0 -0
  34. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/protocol.py +0 -0
  35. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/redis.py +0 -0
  36. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/__init__.py +0 -0
  37. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/apps.py +0 -0
  38. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/mixins.py +0 -0
  39. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/models.py +0 -0
  40. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +0 -0
  41. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/membership.py +0 -0
  42. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization.py +0 -0
  43. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +0 -0
  44. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +0 -0
  45. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/role.py +0 -0
  46. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/service.py +0 -0
  47. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/user.py +0 -0
  48. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/__init__.py +0 -0
  49. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/base.py +0 -0
  50. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +0 -0
  51. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +0 -0
  52. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +0 -0
  53. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +0 -0
  54. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +0 -0
  55. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +0 -0
  56. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +0 -0
  57. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/mixins.py +0 -0
  58. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/models.py +0 -0
  59. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +0 -0
  60. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +0 -0
  61. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +0 -0
  62. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +0 -0
  63. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +0 -0
  64. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +0 -0
  65. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +0 -0
  66. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +0 -0
  67. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/__init__.py +0 -0
  68. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/__init__.py +0 -0
  69. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/_helpers.py +0 -0
  70. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/check_permission.py +0 -0
  71. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/create_organization.py +0 -0
  72. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/create_role.py +0 -0
  73. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_membership.py +0 -0
  74. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_organization.py +0 -0
  75. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_role.py +0 -0
  76. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/list_user_organizations.py +0 -0
  77. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/provision_default_services.py +0 -0
  78. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/register_permission_catalog.py +0 -0
  79. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/resolve_auth_context.py +0 -0
  80. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/resolve_delegated_auth_context.py +0 -0
  81. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +0 -0
  82. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/set_organization_service.py +0 -0
  83. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +0 -0
  84. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/sync_service_catalog.py +0 -0
  85. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +0 -0
  86. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/update_organization.py +0 -0
  87. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/update_role.py +0 -0
  88. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/upsert_membership.py +0 -0
  89. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/cli/__init__.py +0 -0
  90. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/cli/sync_catalog.py +0 -0
  91. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/cli/sync_services.py +0 -0
  92. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/config.py +0 -0
  93. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/__init__.py +0 -0
  94. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/entities.py +0 -0
  95. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/exceptions.py +0 -0
  96. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/ports.py +0 -0
  97. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/value_objects.py +0 -0
  98. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/platform.py +0 -0
  99. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/__init__.py +0 -0
  100. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/__init__.py +0 -0
  101. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/apps.py +0 -0
  102. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/auth_context_middleware.py +0 -0
  103. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/decorators.py +0 -0
  104. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/install.py +0 -0
  105. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/middleware.py +0 -0
  106. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/__init__.py +0 -0
  107. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/auth_context_dep.py +0 -0
  108. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/auth_factory.py +0 -0
  109. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/current_user_dep.py +0 -0
  110. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/decorators.py +0 -0
  111. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/errors.py +0 -0
  112. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/identity_dep.py +0 -0
  113. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/strawberry/__init__.py +0 -0
  114. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/strawberry/auth.py +0 -0
  115. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/strawberry/permissions.py +0 -0
  116. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/dependency_links.txt +0 -0
  117. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/entry_points.txt +0 -0
  118. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/requires.txt +0 -0
  119. {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pkg-auth
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Clean-architecture auth core for multiple Python frameworks
5
5
  Author-email: Fritill <info@fritill.ae>
6
6
  License: MIT
@@ -123,6 +123,7 @@ See [`examples/itqadem_courses_app`](examples/itqadem_courses_app) for a complet
123
123
  ## Features
124
124
 
125
125
  - **Authentication** — Keycloak JWT validation producing an `IdentityContext`.
126
+ - **Service token** — a service's own Keycloak token (client credentials), cached and refreshed, for calling other services.
126
127
  - **Authorization (ACL)** — database-backed users, organizations, roles, permissions, and memberships, resolved into a hot-path `AuthContext` (`role_names`, `perms`).
127
128
  - **Permission visibility** — tri-state catalog visibility (`platform_only` / `shared` / `tenant_only`).
128
129
  - **Localized descriptions** — permission descriptions stored as localized JSONB (`LocalizedText`, `ACL_DEFAULT_LOCALE`).
@@ -135,7 +136,7 @@ See [`examples/itqadem_courses_app`](examples/itqadem_courses_app) for a complet
135
136
 
136
137
  ```
137
138
  pkg_auth/
138
- authentication/ JWT validation → IdentityContext (identity only)
139
+ authentication/ JWT validation → IdentityContext; the service's own token
139
140
  authorization/ Full ACL (users, orgs, roles, perms, memberships)
140
141
  domain/ Pure entities, ports (Protocol), exceptions
141
142
  application/use_cases/ Business logic (use cases)
@@ -160,5 +161,6 @@ pkg_auth/
160
161
  - [FastAPI Integration](docs/FastAPI.md)
161
162
  - [Django Integration](docs/Django.md)
162
163
  - [Strawberry Integration](docs/Strawberry.md)
164
+ - [Service Token](docs/Service-Token.md) — call another service as this service
163
165
  - [Keycloak Admin](docs/Keycloak-Admin.md)
164
166
  - [Migration v1](docs/MIGRATION_v1.md) · [Migration v2](docs/MIGRATION_v2.md)
@@ -75,6 +75,7 @@ See [`examples/itqadem_courses_app`](examples/itqadem_courses_app) for a complet
75
75
  ## Features
76
76
 
77
77
  - **Authentication** — Keycloak JWT validation producing an `IdentityContext`.
78
+ - **Service token** — a service's own Keycloak token (client credentials), cached and refreshed, for calling other services.
78
79
  - **Authorization (ACL)** — database-backed users, organizations, roles, permissions, and memberships, resolved into a hot-path `AuthContext` (`role_names`, `perms`).
79
80
  - **Permission visibility** — tri-state catalog visibility (`platform_only` / `shared` / `tenant_only`).
80
81
  - **Localized descriptions** — permission descriptions stored as localized JSONB (`LocalizedText`, `ACL_DEFAULT_LOCALE`).
@@ -87,7 +88,7 @@ See [`examples/itqadem_courses_app`](examples/itqadem_courses_app) for a complet
87
88
 
88
89
  ```
89
90
  pkg_auth/
90
- authentication/ JWT validation → IdentityContext (identity only)
91
+ authentication/ JWT validation → IdentityContext; the service's own token
91
92
  authorization/ Full ACL (users, orgs, roles, perms, memberships)
92
93
  domain/ Pure entities, ports (Protocol), exceptions
93
94
  application/use_cases/ Business logic (use cases)
@@ -112,5 +113,6 @@ pkg_auth/
112
113
  - [FastAPI Integration](docs/FastAPI.md)
113
114
  - [Django Integration](docs/Django.md)
114
115
  - [Strawberry Integration](docs/Strawberry.md)
116
+ - [Service Token](docs/Service-Token.md) — call another service as this service
115
117
  - [Keycloak Admin](docs/Keycloak-Admin.md)
116
118
  - [Migration v1](docs/MIGRATION_v1.md) · [Migration v2](docs/MIGRATION_v2.md)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pkg-auth"
7
- version = "3.2.0"
7
+ version = "3.3.0"
8
8
  description = "Clean-architecture auth core for multiple Python frameworks"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -6,7 +6,7 @@ Public API:
6
6
  Subject, EmailAddress, RealmName — identity value objects
7
7
  TokenDecoder — Protocol port for token verification
8
8
  AuthenticateTokenUseCase — token → IdentityContext
9
- AuthenticationError, TokenExpiredError, InvalidTokenError
9
+ AuthenticationError, TokenExpiredError, InvalidTokenError, ServiceTokenError
10
10
  """
11
11
  from __future__ import annotations
12
12
 
@@ -15,6 +15,7 @@ from .domain.entities import IdentityContext
15
15
  from .domain.exceptions import (
16
16
  AuthenticationError,
17
17
  InvalidTokenError,
18
+ ServiceTokenError,
18
19
  TokenExpiredError,
19
20
  )
20
21
  from .domain.ports import TokenDecoder
@@ -30,4 +31,5 @@ __all__ = [
30
31
  "AuthenticationError",
31
32
  "TokenExpiredError",
32
33
  "InvalidTokenError",
34
+ "ServiceTokenError",
33
35
  ]
@@ -0,0 +1,12 @@
1
+ """Keycloak adapters: JWT verification and this service's own token."""
2
+ from __future__ import annotations
3
+
4
+ from .jwt_decoder import JWTTokenDecoder
5
+ from .service_token import ServiceCredentials, ServiceTokenAuth, ServiceTokenProvider
6
+
7
+ __all__ = [
8
+ "JWTTokenDecoder",
9
+ "ServiceCredentials",
10
+ "ServiceTokenAuth",
11
+ "ServiceTokenProvider",
12
+ ]
@@ -0,0 +1,210 @@
1
+ """This service's own Keycloak token (client-credentials grant), for calling other services."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import os
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, AsyncGenerator, Callable, Generator, Mapping, Optional, Tuple
10
+
11
+ import httpx
12
+
13
+ from ...domain.exceptions import ServiceTokenError
14
+
15
+ _ENV_NAMES = (
16
+ "KEYCLOAK_BASE_URL",
17
+ "KEYCLOAK_REALM",
18
+ "KEYCLOAK_CLIENT_ID",
19
+ "KEYCLOAK_CLIENT_SECRET",
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ServiceCredentials:
25
+ """Where and as whom a service asks Keycloak for its own token."""
26
+
27
+ token_url: str
28
+ client_id: str
29
+ client_secret: str = field(repr=False)
30
+
31
+ @classmethod
32
+ def for_realm(
33
+ cls, *, base_url: str, realm: str, client_id: str, client_secret: str
34
+ ) -> "ServiceCredentials":
35
+ token_url = f"{base_url.rstrip('/')}/realms/{realm}/protocol/openid-connect/token"
36
+ return cls(token_url=token_url, client_id=client_id, client_secret=client_secret)
37
+
38
+ @classmethod
39
+ def from_env(cls, environ: Optional[Mapping[str, str]] = None) -> "ServiceCredentials":
40
+ """Read ``KEYCLOAK_BASE_URL``, ``KEYCLOAK_REALM``, ``KEYCLOAK_CLIENT_ID``
41
+ and ``KEYCLOAK_CLIENT_SECRET``; raise ``ValueError`` naming every one unset."""
42
+ env = os.environ if environ is None else environ
43
+ values = {name: (env.get(name) or "").strip() for name in _ENV_NAMES}
44
+ missing = [name for name, value in values.items() if not value]
45
+ if missing:
46
+ raise ValueError(f"service token needs {', '.join(missing)}")
47
+ return cls.for_realm(
48
+ base_url=values["KEYCLOAK_BASE_URL"],
49
+ realm=values["KEYCLOAK_REALM"],
50
+ client_id=values["KEYCLOAK_CLIENT_ID"],
51
+ client_secret=values["KEYCLOAK_CLIENT_SECRET"],
52
+ )
53
+
54
+
55
+ class ServiceTokenProvider:
56
+ """Fetches, caches and refreshes this service's token.
57
+
58
+ One instance per process, shared by sync and async callers. A cached
59
+ token is reused until ``refresh_margin_seconds`` before it expires (or
60
+ half its lifetime, for tokens shorter than twice the margin), so a call
61
+ never goes out with a token that dies in flight.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ credentials: ServiceCredentials,
67
+ *,
68
+ refresh_margin_seconds: float = 30.0,
69
+ timeout_seconds: float = 10.0,
70
+ clock: Callable[[], float] = time.monotonic,
71
+ transport: Optional[httpx.BaseTransport] = None,
72
+ async_transport: Optional[httpx.AsyncBaseTransport] = None,
73
+ ) -> None:
74
+ self._credentials = credentials
75
+ self._margin = refresh_margin_seconds
76
+ self._timeout = timeout_seconds
77
+ self._clock = clock
78
+ self._transport = transport
79
+ self._async_transport = async_transport
80
+
81
+ self._token: Optional[str] = None
82
+ self._refresh_at = 0.0
83
+ self._sync_lock = threading.Lock()
84
+ self._async_lock: Optional[asyncio.Lock] = None
85
+
86
+ @classmethod
87
+ def from_env(
88
+ cls, environ: Optional[Mapping[str, str]] = None, **kwargs: Any
89
+ ) -> "ServiceTokenProvider":
90
+ return cls(ServiceCredentials.from_env(environ), **kwargs)
91
+
92
+ @property
93
+ def client_id(self) -> str:
94
+ return self._credentials.client_id
95
+
96
+ def get_token(self) -> str:
97
+ token = self._cached()
98
+ if token is not None:
99
+ return token
100
+ with self._sync_lock:
101
+ token = self._cached()
102
+ if token is not None:
103
+ return token
104
+ with httpx.Client(timeout=self._timeout, transport=self._transport) as client:
105
+ try:
106
+ response = client.post(**self._request())
107
+ except httpx.HTTPError as exc:
108
+ raise self._unreachable(exc) from exc
109
+ return self._store(response)
110
+
111
+ async def aget_token(self) -> str:
112
+ token = self._cached()
113
+ if token is not None:
114
+ return token
115
+ if self._async_lock is None:
116
+ self._async_lock = asyncio.Lock()
117
+ async with self._async_lock:
118
+ token = self._cached()
119
+ if token is not None:
120
+ return token
121
+ async with httpx.AsyncClient(
122
+ timeout=self._timeout, transport=self._async_transport
123
+ ) as client:
124
+ try:
125
+ response = await client.post(**self._request())
126
+ except httpx.HTTPError as exc:
127
+ raise self._unreachable(exc) from exc
128
+ return self._store(response)
129
+
130
+ def invalidate(self) -> None:
131
+ """Drop the cached token; the next call fetches a new one."""
132
+ self._token = None
133
+ self._refresh_at = 0.0
134
+
135
+ def _cached(self) -> Optional[str]:
136
+ token = self._token
137
+ if token is not None and self._clock() < self._refresh_at:
138
+ return token
139
+ return None
140
+
141
+ def _request(self) -> dict[str, Any]:
142
+ return {
143
+ "url": self._credentials.token_url,
144
+ "data": {
145
+ "grant_type": "client_credentials",
146
+ "client_id": self._credentials.client_id,
147
+ "client_secret": self._credentials.client_secret,
148
+ },
149
+ }
150
+
151
+ def _unreachable(self, exc: Exception) -> ServiceTokenError:
152
+ return ServiceTokenError(
153
+ f"could not reach Keycloak for client {self._credentials.client_id!r}: {exc}"
154
+ )
155
+
156
+ def _store(self, response: httpx.Response) -> str:
157
+ token, expires_in = self._parse(response)
158
+ lifetime = max(float(expires_in), 0.0)
159
+ self._token = token
160
+ self._refresh_at = self._clock() + lifetime - min(self._margin, lifetime / 2)
161
+ return token
162
+
163
+ def _parse(self, response: httpx.Response) -> Tuple[str, Any]:
164
+ client_id = self._credentials.client_id
165
+ try:
166
+ body = response.json()
167
+ except ValueError:
168
+ body = {}
169
+ if not isinstance(body, dict):
170
+ body = {}
171
+ if response.status_code != 200:
172
+ reason = body.get("error_description") or body.get("error") or response.text[:200]
173
+ raise ServiceTokenError(
174
+ f"Keycloak refused a token for client {client_id!r}: "
175
+ f"HTTP {response.status_code} {reason}"
176
+ )
177
+ token = body.get("access_token")
178
+ if not isinstance(token, str) or not token:
179
+ raise ServiceTokenError(f"Keycloak returned no access_token for client {client_id!r}")
180
+ return token, body.get("expires_in", 60)
181
+
182
+
183
+ class ServiceTokenAuth(httpx.Auth):
184
+ """``httpx`` auth that sends the service token and, on a 401, fetches a
185
+ fresh one and retries once (the cached token may have been revoked)."""
186
+
187
+ requires_request_body = True
188
+
189
+ def __init__(self, provider: ServiceTokenProvider) -> None:
190
+ self._provider = provider
191
+
192
+ def sync_auth_flow(
193
+ self, request: httpx.Request
194
+ ) -> Generator[httpx.Request, httpx.Response, None]:
195
+ request.headers["Authorization"] = f"Bearer {self._provider.get_token()}"
196
+ response = yield request
197
+ if response.status_code == 401:
198
+ self._provider.invalidate()
199
+ request.headers["Authorization"] = f"Bearer {self._provider.get_token()}"
200
+ yield request
201
+
202
+ async def async_auth_flow(
203
+ self, request: httpx.Request
204
+ ) -> AsyncGenerator[httpx.Request, httpx.Response]:
205
+ request.headers["Authorization"] = f"Bearer {await self._provider.aget_token()}"
206
+ response = yield request
207
+ if response.status_code == 401:
208
+ self._provider.invalidate()
209
+ request.headers["Authorization"] = f"Bearer {await self._provider.aget_token()}"
210
+ yield request
@@ -16,3 +16,7 @@ class TokenExpiredError(AuthenticationError):
16
16
 
17
17
  class InvalidTokenError(AuthenticationError):
18
18
  """Token is malformed, has an invalid signature, or fails verification."""
19
+
20
+
21
+ class ServiceTokenError(AuthenticationError):
22
+ """Keycloak did not issue this service's own (client-credentials) token."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pkg-auth
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Clean-architecture auth core for multiple Python frameworks
5
5
  Author-email: Fritill <info@fritill.ae>
6
6
  License: MIT
@@ -123,6 +123,7 @@ See [`examples/itqadem_courses_app`](examples/itqadem_courses_app) for a complet
123
123
  ## Features
124
124
 
125
125
  - **Authentication** — Keycloak JWT validation producing an `IdentityContext`.
126
+ - **Service token** — a service's own Keycloak token (client credentials), cached and refreshed, for calling other services.
126
127
  - **Authorization (ACL)** — database-backed users, organizations, roles, permissions, and memberships, resolved into a hot-path `AuthContext` (`role_names`, `perms`).
127
128
  - **Permission visibility** — tri-state catalog visibility (`platform_only` / `shared` / `tenant_only`).
128
129
  - **Localized descriptions** — permission descriptions stored as localized JSONB (`LocalizedText`, `ACL_DEFAULT_LOCALE`).
@@ -135,7 +136,7 @@ See [`examples/itqadem_courses_app`](examples/itqadem_courses_app) for a complet
135
136
 
136
137
  ```
137
138
  pkg_auth/
138
- authentication/ JWT validation → IdentityContext (identity only)
139
+ authentication/ JWT validation → IdentityContext; the service's own token
139
140
  authorization/ Full ACL (users, orgs, roles, perms, memberships)
140
141
  domain/ Pure entities, ports (Protocol), exceptions
141
142
  application/use_cases/ Business logic (use cases)
@@ -160,5 +161,6 @@ pkg_auth/
160
161
  - [FastAPI Integration](docs/FastAPI.md)
161
162
  - [Django Integration](docs/Django.md)
162
163
  - [Strawberry Integration](docs/Strawberry.md)
164
+ - [Service Token](docs/Service-Token.md) — call another service as this service
163
165
  - [Keycloak Admin](docs/Keycloak-Admin.md)
164
166
  - [Migration v1](docs/MIGRATION_v1.md) · [Migration v2](docs/MIGRATION_v2.md)
@@ -18,6 +18,7 @@ src/pkg_auth/authentication/__init__.py
18
18
  src/pkg_auth/authentication/adapters/__init__.py
19
19
  src/pkg_auth/authentication/adapters/keycloak/__init__.py
20
20
  src/pkg_auth/authentication/adapters/keycloak/jwt_decoder.py
21
+ src/pkg_auth/authentication/adapters/keycloak/service_token.py
21
22
  src/pkg_auth/authentication/application/__init__.py
22
23
  src/pkg_auth/authentication/application/use_cases/__init__.py
23
24
  src/pkg_auth/authentication/application/use_cases/authenticate.py
@@ -1,6 +0,0 @@
1
- """Keycloak JWT decoder adapter."""
2
- from __future__ import annotations
3
-
4
- from .jwt_decoder import JWTTokenDecoder
5
-
6
- __all__ = ["JWTTokenDecoder"]
File without changes