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.
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/PKG-INFO +4 -2
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/README.md +3 -1
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/pyproject.toml +1 -1
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/__init__.py +3 -1
- pkg_auth-3.3.0/src/pkg_auth/authentication/adapters/keycloak/__init__.py +12 -0
- pkg_auth-3.3.0/src/pkg_auth/authentication/adapters/keycloak/service_token.py +210 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/exceptions.py +4 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/PKG-INFO +4 -2
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/SOURCES.txt +1 -0
- pkg_auth-3.2.0/src/pkg_auth/authentication/adapters/keycloak/__init__.py +0 -6
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/setup.cfg +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/cli.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/client.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/env.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/helpers.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/provision_client.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/admin/settings.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/adapters/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/use_cases/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/use_cases/authenticate.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/entities.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/ports.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/domain/value_objects.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/decorators.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/memory.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/protocol.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/cache/redis.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/apps.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/mixins.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/models.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/membership.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/role.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/service.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/repositories/user.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/base.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/mixins.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/models.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/_helpers.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/check_permission.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/create_organization.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/create_role.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_membership.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_organization.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_role.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/list_user_organizations.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/provision_default_services.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/register_permission_catalog.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/resolve_auth_context.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/resolve_delegated_auth_context.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/set_organization_service.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/sync_service_catalog.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/update_organization.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/update_role.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/upsert_membership.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/cli/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/cli/sync_catalog.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/cli/sync_services.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/config.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/entities.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/exceptions.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/ports.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/domain/value_objects.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/platform.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/apps.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/auth_context_middleware.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/decorators.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/install.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/middleware.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/auth_context_dep.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/auth_factory.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/current_user_dep.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/decorators.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/errors.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/fastapi/identity_dep.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/strawberry/__init__.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/strawberry/auth.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/strawberry/permissions.py +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/dependency_links.txt +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/entry_points.txt +0 -0
- {pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
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)
|
|
@@ -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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/adapters/keycloak/jwt_decoder.py
RENAMED
|
File without changes
|
|
File without changes
|
{pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/use_cases/__init__.py
RENAMED
|
File without changes
|
{pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authentication/application/use_cases/authenticate.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/django_orm/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/adapters/sqlalchemy/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/__init__.py
RENAMED
|
File without changes
|
{pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/_helpers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/create_role.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/delete_role.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/authorization/application/use_cases/update_role.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pkg_auth-3.2.0 → pkg_auth-3.3.0}/src/pkg_auth/integrations/django/auth_context_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|