pkg-auth 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pkg_auth/__init__.py +15 -0
- pkg_auth/admin/__init__.py +35 -0
- pkg_auth/admin/cli.py +87 -0
- pkg_auth/admin/client.py +401 -0
- pkg_auth/admin/env.py +74 -0
- pkg_auth/admin/helpers.py +113 -0
- pkg_auth/admin/provision_client.py +86 -0
- pkg_auth/admin/settings.py +33 -0
- pkg_auth/authentication/__init__.py +33 -0
- pkg_auth/authentication/adapters/__init__.py +1 -0
- pkg_auth/authentication/adapters/keycloak/__init__.py +6 -0
- pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +105 -0
- pkg_auth/authentication/application/__init__.py +1 -0
- pkg_auth/authentication/application/use_cases/__init__.py +1 -0
- pkg_auth/authentication/application/use_cases/authenticate.py +91 -0
- pkg_auth/authentication/domain/__init__.py +1 -0
- pkg_auth/authentication/domain/entities.py +50 -0
- pkg_auth/authentication/domain/exceptions.py +18 -0
- pkg_auth/authentication/domain/ports.py +26 -0
- pkg_auth/authentication/domain/value_objects.py +42 -0
- pkg_auth/authorization/__init__.py +117 -0
- pkg_auth/authorization/adapters/__init__.py +1 -0
- pkg_auth/authorization/adapters/cache/__init__.py +32 -0
- pkg_auth/authorization/adapters/cache/decorators.py +181 -0
- pkg_auth/authorization/adapters/cache/memory.py +61 -0
- pkg_auth/authorization/adapters/cache/protocol.py +36 -0
- pkg_auth/authorization/adapters/cache/redis.py +60 -0
- pkg_auth/authorization/adapters/django_orm/__init__.py +37 -0
- pkg_auth/authorization/adapters/django_orm/apps.py +24 -0
- pkg_auth/authorization/adapters/django_orm/mixins.py +142 -0
- pkg_auth/authorization/adapters/django_orm/models.py +226 -0
- pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +20 -0
- pkg_auth/authorization/adapters/django_orm/repositories/membership.py +118 -0
- pkg_auth/authorization/adapters/django_orm/repositories/organization.py +73 -0
- pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +71 -0
- pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +102 -0
- pkg_auth/authorization/adapters/django_orm/repositories/role.py +120 -0
- pkg_auth/authorization/adapters/django_orm/repositories/service.py +60 -0
- pkg_auth/authorization/adapters/django_orm/repositories/user.py +77 -0
- pkg_auth/authorization/adapters/sqlalchemy/__init__.py +90 -0
- pkg_auth/authorization/adapters/sqlalchemy/base.py +55 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +1 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +293 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +39 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +65 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +52 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +116 -0
- pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +1 -0
- pkg_auth/authorization/adapters/sqlalchemy/mixins.py +187 -0
- pkg_auth/authorization/adapters/sqlalchemy/models.py +268 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +16 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +146 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +97 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +106 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +127 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +171 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +93 -0
- pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +74 -0
- pkg_auth/authorization/application/__init__.py +1 -0
- pkg_auth/authorization/application/use_cases/__init__.py +1 -0
- pkg_auth/authorization/application/use_cases/_helpers.py +82 -0
- pkg_auth/authorization/application/use_cases/check_permission.py +21 -0
- pkg_auth/authorization/application/use_cases/create_organization.py +41 -0
- pkg_auth/authorization/application/use_cases/create_role.py +69 -0
- pkg_auth/authorization/application/use_cases/delete_membership.py +21 -0
- pkg_auth/authorization/application/use_cases/delete_organization.py +21 -0
- pkg_auth/authorization/application/use_cases/delete_role.py +23 -0
- pkg_auth/authorization/application/use_cases/list_user_organizations.py +21 -0
- pkg_auth/authorization/application/use_cases/provision_default_services.py +38 -0
- pkg_auth/authorization/application/use_cases/register_permission_catalog.py +122 -0
- pkg_auth/authorization/application/use_cases/resolve_auth_context.py +70 -0
- pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +34 -0
- pkg_auth/authorization/application/use_cases/set_organization_service.py +50 -0
- pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +86 -0
- pkg_auth/authorization/application/use_cases/sync_service_catalog.py +91 -0
- pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +32 -0
- pkg_auth/authorization/application/use_cases/update_organization.py +31 -0
- pkg_auth/authorization/application/use_cases/update_role.py +61 -0
- pkg_auth/authorization/application/use_cases/upsert_membership.py +54 -0
- pkg_auth/authorization/cli/__init__.py +1 -0
- pkg_auth/authorization/cli/sync_catalog.py +180 -0
- pkg_auth/authorization/cli/sync_services.py +151 -0
- pkg_auth/authorization/config.py +21 -0
- pkg_auth/authorization/domain/__init__.py +1 -0
- pkg_auth/authorization/domain/entities.py +192 -0
- pkg_auth/authorization/domain/exceptions.py +68 -0
- pkg_auth/authorization/domain/ports.py +217 -0
- pkg_auth/authorization/domain/value_objects.py +208 -0
- pkg_auth/authorization/platform.py +47 -0
- pkg_auth/integrations/__init__.py +0 -0
- pkg_auth/integrations/django/__init__.py +32 -0
- pkg_auth/integrations/django/apps.py +10 -0
- pkg_auth/integrations/django/auth_context_middleware.py +105 -0
- pkg_auth/integrations/django/decorators.py +74 -0
- pkg_auth/integrations/django/install.py +136 -0
- pkg_auth/integrations/django/middleware.py +63 -0
- pkg_auth/integrations/fastapi/__init__.py +26 -0
- pkg_auth/integrations/fastapi/auth_context_dep.py +150 -0
- pkg_auth/integrations/fastapi/auth_factory.py +84 -0
- pkg_auth/integrations/fastapi/decorators.py +55 -0
- pkg_auth/integrations/fastapi/errors.py +72 -0
- pkg_auth/integrations/fastapi/identity_dep.py +41 -0
- pkg_auth/integrations/strawberry/__init__.py +20 -0
- pkg_auth/integrations/strawberry/auth.py +137 -0
- pkg_auth/integrations/strawberry/permissions.py +56 -0
- pkg_auth-3.0.0.dist-info/METADATA +147 -0
- pkg_auth-3.0.0.dist-info/RECORD +110 -0
- pkg_auth-3.0.0.dist-info/WHEEL +5 -0
- pkg_auth-3.0.0.dist-info/entry_points.txt +4 -0
- pkg_auth-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .client import KeycloakAdminClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def _ensure_api_client(
|
|
9
|
+
kc: KeycloakAdminClient,
|
|
10
|
+
*,
|
|
11
|
+
api_client_id: str,
|
|
12
|
+
) -> tuple[dict[str, Any], str]:
|
|
13
|
+
client_repr = {
|
|
14
|
+
"clientId": api_client_id,
|
|
15
|
+
"protocol": "openid-connect",
|
|
16
|
+
"publicClient": False,
|
|
17
|
+
"bearerOnly": True,
|
|
18
|
+
"standardFlowEnabled": False,
|
|
19
|
+
"implicitFlowEnabled": False,
|
|
20
|
+
"directAccessGrantsEnabled": False,
|
|
21
|
+
"serviceAccountsEnabled": False,
|
|
22
|
+
"enabled": True,
|
|
23
|
+
}
|
|
24
|
+
ensured = await kc.ensure_client(client_repr)
|
|
25
|
+
|
|
26
|
+
server_obj = await kc.get_client_by_client_id(api_client_id)
|
|
27
|
+
if not server_obj:
|
|
28
|
+
raise RuntimeError("Client ensure failed unexpectedly: cannot read back the client")
|
|
29
|
+
|
|
30
|
+
return ensured, server_obj["id"] # (trimmed, internal_id)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _ensure_roles(
|
|
34
|
+
kc: KeycloakAdminClient,
|
|
35
|
+
*,
|
|
36
|
+
internal_id: str,
|
|
37
|
+
permissions: list[str],
|
|
38
|
+
strict_roles: bool,
|
|
39
|
+
) -> dict[str, int]:
|
|
40
|
+
if strict_roles:
|
|
41
|
+
return await kc.ensure_client_roles_strict(internal_id, permissions)
|
|
42
|
+
return await kc.ensure_client_roles(internal_id, permissions)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _ensure_frontend_mappers(
|
|
46
|
+
kc: KeycloakAdminClient,
|
|
47
|
+
*,
|
|
48
|
+
api_client_id: str,
|
|
49
|
+
internal_api_id: str,
|
|
50
|
+
frontend_client_ids: list[str],
|
|
51
|
+
strict_audience: bool,
|
|
52
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
53
|
+
audience_actions: list[dict[str, Any]] = []
|
|
54
|
+
roles_mapper_actions: list[dict[str, Any]] = []
|
|
55
|
+
|
|
56
|
+
for fe_client_id in frontend_client_ids:
|
|
57
|
+
fe = await kc.get_client_by_client_id(fe_client_id)
|
|
58
|
+
if not fe:
|
|
59
|
+
msg = f"frontend client not found in realm {kc.s.keycloak_realm}"
|
|
60
|
+
audience_actions.append(
|
|
61
|
+
{"frontend_client": fe_client_id, "created": False, "updated": False, "error": msg}
|
|
62
|
+
)
|
|
63
|
+
roles_mapper_actions.append(
|
|
64
|
+
{"frontend_client": fe_client_id, "created": False, "updated": False, "error": msg}
|
|
65
|
+
)
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
res_aud = await kc.ensure_audience_mapper(
|
|
69
|
+
fe["id"],
|
|
70
|
+
api_client_id,
|
|
71
|
+
update_if_different=strict_audience,
|
|
72
|
+
)
|
|
73
|
+
audience_actions.append({"frontend_client": fe_client_id, **res_aud})
|
|
74
|
+
|
|
75
|
+
res_roles = await kc.ensure_client_roles_mapper(
|
|
76
|
+
fe["id"],
|
|
77
|
+
api_client_id,
|
|
78
|
+
update_if_different=strict_audience,
|
|
79
|
+
)
|
|
80
|
+
roles_mapper_actions.append({"frontend_client": fe_client_id, **res_roles})
|
|
81
|
+
|
|
82
|
+
return audience_actions, roles_mapper_actions
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _remove_frontend_mappers(
|
|
86
|
+
kc: KeycloakAdminClient,
|
|
87
|
+
*,
|
|
88
|
+
api_client_id: str,
|
|
89
|
+
remove_frontend_client_ids: list[str],
|
|
90
|
+
) -> list[dict[str, Any]]:
|
|
91
|
+
removed: list[dict[str, Any]] = []
|
|
92
|
+
for fe_client_id in remove_frontend_client_ids:
|
|
93
|
+
fe = await kc.get_client_by_client_id(fe_client_id)
|
|
94
|
+
if not fe:
|
|
95
|
+
removed.append(
|
|
96
|
+
{
|
|
97
|
+
"frontend_client": fe_client_id,
|
|
98
|
+
"audience_removed": False,
|
|
99
|
+
"roles_mapper_removed": False,
|
|
100
|
+
"error": f"frontend client not found in realm {kc.s.keycloak_realm}",
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
continue
|
|
104
|
+
did_aud = await kc.remove_audience_mapper(fe["id"], api_client_id)
|
|
105
|
+
did_roles = await kc.remove_client_roles_mapper(fe["id"], api_client_id)
|
|
106
|
+
removed.append(
|
|
107
|
+
{
|
|
108
|
+
"frontend_client": fe_client_id,
|
|
109
|
+
"audience_removed": did_aud,
|
|
110
|
+
"roles_mapper_removed": did_roles,
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
return removed
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Iterable, Any
|
|
4
|
+
|
|
5
|
+
from .client import KeycloakAdminClient
|
|
6
|
+
from .helpers import _ensure_api_client, _ensure_roles, _ensure_frontend_mappers, _remove_frontend_mappers
|
|
7
|
+
from .settings import KCAdminSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def provision_keycloak_client(
|
|
11
|
+
*,
|
|
12
|
+
settings: KCAdminSettings,
|
|
13
|
+
client_id: Optional[str] = None,
|
|
14
|
+
permissions: Optional[Iterable[str]] = None,
|
|
15
|
+
frontend_client_ids: Optional[Iterable[str]] = None,
|
|
16
|
+
remove_frontend_client_ids: Optional[Iterable[str]] = None,
|
|
17
|
+
strict_roles: bool = False,
|
|
18
|
+
strict_audience: bool = False,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
"""
|
|
21
|
+
High-level async helper to provision a Keycloak API client.
|
|
22
|
+
|
|
23
|
+
Steps:
|
|
24
|
+
1) Ensure the API client exists (bearer-only).
|
|
25
|
+
2) Ensure client roles correspond to the provided permissions.
|
|
26
|
+
3) Ensure audience + roles mappers on frontend clients.
|
|
27
|
+
4) Optionally remove mappers for some frontend clients (strict mode).
|
|
28
|
+
|
|
29
|
+
This is the refactored version of your old `provision(...)` function.
|
|
30
|
+
|
|
31
|
+
Returns a summary dictionary with:
|
|
32
|
+
- client
|
|
33
|
+
- roles
|
|
34
|
+
- audience
|
|
35
|
+
- client_roles_mapper
|
|
36
|
+
- removed
|
|
37
|
+
"""
|
|
38
|
+
api_client_id = (client_id or settings.default_api_client_id).strip()
|
|
39
|
+
desired_permissions = list(permissions or [])
|
|
40
|
+
|
|
41
|
+
fe_ids = list(frontend_client_ids or settings.frontend_client_ids)
|
|
42
|
+
remove_fe_ids = list(remove_frontend_client_ids or [])
|
|
43
|
+
|
|
44
|
+
kc = KeycloakAdminClient(settings=settings)
|
|
45
|
+
try:
|
|
46
|
+
# 1) Ensure API client exists
|
|
47
|
+
client_repr, internal_api_id = await _ensure_api_client(
|
|
48
|
+
kc,
|
|
49
|
+
api_client_id=api_client_id,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# 2) Ensure roles
|
|
53
|
+
roles_summary = await _ensure_roles(
|
|
54
|
+
kc,
|
|
55
|
+
internal_id=internal_api_id,
|
|
56
|
+
permissions=desired_permissions,
|
|
57
|
+
strict_roles=strict_roles,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# 3) Ensure audience + roles mappers for frontend clients
|
|
61
|
+
audience_actions, roles_mapper_actions = await _ensure_frontend_mappers(
|
|
62
|
+
kc,
|
|
63
|
+
api_client_id=api_client_id,
|
|
64
|
+
internal_api_id=internal_api_id,
|
|
65
|
+
frontend_client_ids=fe_ids,
|
|
66
|
+
strict_audience=strict_audience,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# 4) Optionally remove mappers for remove_frontend_client_ids
|
|
70
|
+
removed = []
|
|
71
|
+
if strict_audience and remove_fe_ids:
|
|
72
|
+
removed = await _remove_frontend_mappers(
|
|
73
|
+
kc,
|
|
74
|
+
api_client_id=api_client_id,
|
|
75
|
+
remove_frontend_client_ids=remove_fe_ids,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
"client": client_repr,
|
|
80
|
+
"roles": roles_summary,
|
|
81
|
+
"audience": audience_actions,
|
|
82
|
+
"client_roles_mapper": roles_mapper_actions,
|
|
83
|
+
"removed": removed,
|
|
84
|
+
}
|
|
85
|
+
finally:
|
|
86
|
+
await kc.close()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional, List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class KCAdminSettings:
|
|
9
|
+
"""
|
|
10
|
+
Keycloak admin connection + wiring settings.
|
|
11
|
+
|
|
12
|
+
Host code decides how to construct this (env, config file, etc.).
|
|
13
|
+
"""
|
|
14
|
+
keycloak_base_url: str
|
|
15
|
+
keycloak_admin_user: str
|
|
16
|
+
keycloak_admin_pass: str
|
|
17
|
+
keycloak_realm: str
|
|
18
|
+
verify_ssl: bool = True
|
|
19
|
+
|
|
20
|
+
# Service naming / wiring
|
|
21
|
+
app_name: Optional[str] = None
|
|
22
|
+
service_name: Optional[str] = None
|
|
23
|
+
frontend_client_ids: List[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def base_url_slash(self) -> str:
|
|
27
|
+
b = self.keycloak_base_url.strip()
|
|
28
|
+
return b if b.endswith("/") else b + "/"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def default_api_client_id(self) -> str:
|
|
32
|
+
name = (self.app_name or self.service_name or "service").strip()
|
|
33
|
+
return f"{name}-api"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Authentication module: JWT validation and identity projection.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
IdentityContext — frozen identity snapshot from a JWT
|
|
6
|
+
Subject, EmailAddress, RealmName — identity value objects
|
|
7
|
+
TokenDecoder — Protocol port for token verification
|
|
8
|
+
AuthenticateTokenUseCase — token → IdentityContext
|
|
9
|
+
AuthenticationError, TokenExpiredError, InvalidTokenError
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .application.use_cases.authenticate import AuthenticateTokenUseCase
|
|
14
|
+
from .domain.entities import IdentityContext
|
|
15
|
+
from .domain.exceptions import (
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
InvalidTokenError,
|
|
18
|
+
TokenExpiredError,
|
|
19
|
+
)
|
|
20
|
+
from .domain.ports import TokenDecoder
|
|
21
|
+
from .domain.value_objects import EmailAddress, RealmName, Subject
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"IdentityContext",
|
|
25
|
+
"Subject",
|
|
26
|
+
"EmailAddress",
|
|
27
|
+
"RealmName",
|
|
28
|
+
"TokenDecoder",
|
|
29
|
+
"AuthenticateTokenUseCase",
|
|
30
|
+
"AuthenticationError",
|
|
31
|
+
"TokenExpiredError",
|
|
32
|
+
"InvalidTokenError",
|
|
33
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Authentication adapter implementations."""
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Keycloak JWT decoder adapter (PyJWT + JWKS fetching)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
7
|
+
|
|
8
|
+
import jwt
|
|
9
|
+
from jwt.exceptions import (
|
|
10
|
+
DecodeError,
|
|
11
|
+
ExpiredSignatureError,
|
|
12
|
+
InvalidSignatureError,
|
|
13
|
+
InvalidTokenError as JWTInvalidTokenError,
|
|
14
|
+
)
|
|
15
|
+
from requests import Session
|
|
16
|
+
|
|
17
|
+
from ...domain.exceptions import InvalidTokenError, TokenExpiredError
|
|
18
|
+
from ...domain.ports import TokenDecoder
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JWTTokenDecoder(TokenDecoder):
|
|
22
|
+
"""PyJWT-based ``TokenDecoder`` implementation for Keycloak.
|
|
23
|
+
|
|
24
|
+
Knows how to:
|
|
25
|
+
- fetch JWKS from a Keycloak realm
|
|
26
|
+
- cache JWKS keys with a TTL
|
|
27
|
+
- verify the token signature, expiry, issuer, and audience
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
jwks_uri: str,
|
|
34
|
+
issuer: str,
|
|
35
|
+
audience: str,
|
|
36
|
+
cache_ttl_seconds: int = 300,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._jwks_uri = jwks_uri
|
|
39
|
+
self._issuer = issuer
|
|
40
|
+
self._audience = audience
|
|
41
|
+
self._cache_ttl = cache_ttl_seconds
|
|
42
|
+
|
|
43
|
+
self._session = Session()
|
|
44
|
+
self._jwks_keys: Optional[List[Dict[str, Any]]] = None
|
|
45
|
+
self._jwks_last_fetched: float = 0.0
|
|
46
|
+
|
|
47
|
+
def decode(self, token: str) -> Mapping[str, Any]:
|
|
48
|
+
try:
|
|
49
|
+
headers = jwt.get_unverified_header(token)
|
|
50
|
+
kid = headers.get("kid")
|
|
51
|
+
|
|
52
|
+
jwks_keys = self._fetch_jwks_keys()
|
|
53
|
+
key = next((k for k in jwks_keys if k.get("kid") == kid), None)
|
|
54
|
+
|
|
55
|
+
if key is None:
|
|
56
|
+
raise InvalidTokenError("No matching key found in JWKS")
|
|
57
|
+
|
|
58
|
+
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key))
|
|
59
|
+
|
|
60
|
+
payload: Dict[str, Any] = jwt.decode(
|
|
61
|
+
token,
|
|
62
|
+
public_key,
|
|
63
|
+
algorithms=["RS256"],
|
|
64
|
+
options={"verify_aud": False},
|
|
65
|
+
issuer=self._issuer,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
aud_claim = payload.get("aud")
|
|
69
|
+
if isinstance(aud_claim, str):
|
|
70
|
+
aud_list = [aud_claim]
|
|
71
|
+
elif isinstance(aud_claim, list):
|
|
72
|
+
aud_list = list(aud_claim)
|
|
73
|
+
else:
|
|
74
|
+
aud_list = []
|
|
75
|
+
|
|
76
|
+
if self._audience not in aud_list:
|
|
77
|
+
raise InvalidTokenError(
|
|
78
|
+
f"Invalid audience: expected {self._audience!r}, got {aud_list!r}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return payload
|
|
82
|
+
|
|
83
|
+
except ExpiredSignatureError as exc:
|
|
84
|
+
raise TokenExpiredError("Token has expired") from exc
|
|
85
|
+
except (InvalidSignatureError, DecodeError, JWTInvalidTokenError) as exc:
|
|
86
|
+
raise InvalidTokenError(f"Invalid token: {exc}") from exc
|
|
87
|
+
|
|
88
|
+
def _fetch_jwks_keys(self) -> List[Dict[str, Any]]:
|
|
89
|
+
now = time.time()
|
|
90
|
+
if (
|
|
91
|
+
self._jwks_keys is not None
|
|
92
|
+
and (now - self._jwks_last_fetched) < self._cache_ttl
|
|
93
|
+
):
|
|
94
|
+
return self._jwks_keys
|
|
95
|
+
|
|
96
|
+
response = self._session.get(self._jwks_uri)
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
|
|
99
|
+
body = response.json()
|
|
100
|
+
keys = body.get("keys", [])
|
|
101
|
+
if not isinstance(keys, list):
|
|
102
|
+
raise InvalidTokenError(f"JWKS endpoint returned invalid keys: {keys!r}")
|
|
103
|
+
self._jwks_keys = keys
|
|
104
|
+
self._jwks_last_fetched = now
|
|
105
|
+
return keys
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Authentication application layer (use cases)."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Authentication use cases."""
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Authenticate use case: token → IdentityContext."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from ...domain.entities import IdentityContext
|
|
8
|
+
from ...domain.exceptions import (
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
InvalidTokenError,
|
|
11
|
+
TokenExpiredError,
|
|
12
|
+
)
|
|
13
|
+
from ...domain.ports import TokenDecoder
|
|
14
|
+
from ...domain.value_objects import EmailAddress, RealmName, Subject
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class AuthenticateTokenUseCase:
|
|
19
|
+
"""Decode a token via the injected ``TokenDecoder`` and project its
|
|
20
|
+
claims into an :class:`IdentityContext`.
|
|
21
|
+
|
|
22
|
+
This use case knows nothing about authorization, organizations, or
|
|
23
|
+
permissions. It is the single entry point for "who is making this
|
|
24
|
+
request?" — nothing more.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
token_decoder: TokenDecoder
|
|
28
|
+
|
|
29
|
+
def execute(self, token: str) -> IdentityContext:
|
|
30
|
+
"""Authenticate a token and return the identity it represents.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
TokenExpiredError: token's ``exp`` claim is in the past.
|
|
34
|
+
InvalidTokenError: missing or malformed ``sub``, bad signature,
|
|
35
|
+
or any other verification failure.
|
|
36
|
+
AuthenticationError: unexpected decoder failure.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
claims = self.token_decoder.decode(token)
|
|
40
|
+
except (TokenExpiredError, InvalidTokenError):
|
|
41
|
+
raise
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
raise AuthenticationError(f"Token validation failed: {exc}") from exc
|
|
44
|
+
|
|
45
|
+
return self._build_identity(claims)
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _build_identity(claims: Mapping[str, Any]) -> IdentityContext:
|
|
49
|
+
sub = claims.get("sub")
|
|
50
|
+
if not isinstance(sub, str) or not sub:
|
|
51
|
+
raise InvalidTokenError("Token is missing required `sub` claim")
|
|
52
|
+
|
|
53
|
+
email_raw = claims.get("email")
|
|
54
|
+
email: EmailAddress | None = None
|
|
55
|
+
if isinstance(email_raw, str) and "@" in email_raw:
|
|
56
|
+
email = EmailAddress(email_raw)
|
|
57
|
+
|
|
58
|
+
realm: RealmName | None = None
|
|
59
|
+
iss = claims.get("iss")
|
|
60
|
+
if isinstance(iss, str) and "/realms/" in iss:
|
|
61
|
+
realm = RealmName(iss.rsplit("/realms/", 1)[-1])
|
|
62
|
+
|
|
63
|
+
session_id_raw = claims.get("sid") or claims.get("session_state")
|
|
64
|
+
session_id: str | None = (
|
|
65
|
+
session_id_raw if isinstance(session_id_raw, str) else None
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return IdentityContext(
|
|
69
|
+
subject=Subject(sub),
|
|
70
|
+
email=email,
|
|
71
|
+
email_verified=bool(claims.get("email_verified") or False),
|
|
72
|
+
full_name=_str_or_none(claims.get("name")),
|
|
73
|
+
first_name=_str_or_none(claims.get("given_name")),
|
|
74
|
+
last_name=_str_or_none(claims.get("family_name")),
|
|
75
|
+
preferred_username=_str_or_none(claims.get("preferred_username")),
|
|
76
|
+
realm=realm,
|
|
77
|
+
session_id=session_id,
|
|
78
|
+
issued_at=_int_or_none(claims.get("iat")),
|
|
79
|
+
expires_at=_int_or_none(claims.get("exp")),
|
|
80
|
+
auth_time=_int_or_none(claims.get("auth_time")),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _str_or_none(value: object) -> str | None:
|
|
85
|
+
return value if isinstance(value, str) else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _int_or_none(value: object) -> int | None:
|
|
89
|
+
if isinstance(value, bool):
|
|
90
|
+
return None
|
|
91
|
+
return value if isinstance(value, int) else None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Authentication domain layer (entities, value objects, ports, exceptions)."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Identity context — the validated output of token authentication.
|
|
2
|
+
|
|
3
|
+
This is the only entity exposed by the authentication module. It carries
|
|
4
|
+
identity and session metadata; it does NOT carry authorization rights.
|
|
5
|
+
Authorization is derived per-(user, organization) by the
|
|
6
|
+
``pkg_auth.authorization`` module from a real ACL database.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .value_objects import EmailAddress, RealmName, Subject
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class IdentityContext:
|
|
17
|
+
"""Identity and session metadata for an authenticated principal.
|
|
18
|
+
|
|
19
|
+
Built from JWT claims by :class:`AuthenticateTokenUseCase`. Frozen
|
|
20
|
+
because it's a snapshot of the token at the moment of validation —
|
|
21
|
+
it never mutates after construction.
|
|
22
|
+
|
|
23
|
+
The ``subject`` field is required: a JWT without a ``sub`` claim is
|
|
24
|
+
rejected as ``InvalidTokenError`` before this object is built.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
subject: Subject
|
|
28
|
+
email: EmailAddress | None = None
|
|
29
|
+
email_verified: bool = False
|
|
30
|
+
|
|
31
|
+
full_name: str | None = None
|
|
32
|
+
first_name: str | None = None
|
|
33
|
+
last_name: str | None = None
|
|
34
|
+
preferred_username: str | None = None
|
|
35
|
+
|
|
36
|
+
realm: RealmName | None = None
|
|
37
|
+
session_id: str | None = None
|
|
38
|
+
issued_at: int | None = None
|
|
39
|
+
expires_at: int | None = None
|
|
40
|
+
auth_time: int | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def email_str(self) -> str | None:
|
|
44
|
+
"""The email as a plain string, or ``None`` if unset."""
|
|
45
|
+
return str(self.email) if self.email is not None else None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def subject_str(self) -> str:
|
|
49
|
+
"""The subject as a plain string."""
|
|
50
|
+
return str(self.subject)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Authentication exceptions.
|
|
2
|
+
|
|
3
|
+
Authorization-related exceptions live in
|
|
4
|
+
``pkg_auth.authorization.domain.exceptions`` (added in M2).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthenticationError(Exception):
|
|
10
|
+
"""Base for all authentication failures."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TokenExpiredError(AuthenticationError):
|
|
14
|
+
"""The token's ``exp`` claim is in the past."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InvalidTokenError(AuthenticationError):
|
|
18
|
+
"""Token is malformed, has an invalid signature, or fails verification."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Authentication domain ports (Protocols)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Mapping, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TokenDecoder(Protocol):
|
|
8
|
+
"""Port for decoding an access token into a claims mapping.
|
|
9
|
+
|
|
10
|
+
Implementations live in the adapters layer (e.g.
|
|
11
|
+
:class:`pkg_auth.authentication.adapters.keycloak.JWTTokenDecoder`).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def decode(self, token: str) -> Mapping[str, Any]:
|
|
15
|
+
"""Decode and verify the given token.
|
|
16
|
+
|
|
17
|
+
Implementations must:
|
|
18
|
+
- verify the signature
|
|
19
|
+
- check the ``exp`` claim
|
|
20
|
+
- validate the issuer and audience
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
TokenExpiredError: when the ``exp`` claim is in the past.
|
|
24
|
+
InvalidTokenError: for any other validation failure.
|
|
25
|
+
"""
|
|
26
|
+
...
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Identity value objects (subject, email, realm name)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class Subject:
|
|
9
|
+
"""The IdP subject identifier (Keycloak ``sub`` claim).
|
|
10
|
+
|
|
11
|
+
Kept as a distinct type so it isn't accidentally treated as an
|
|
12
|
+
internal user primary key.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
value: str
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
return self.value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class EmailAddress:
|
|
23
|
+
"""An email address with light validation."""
|
|
24
|
+
|
|
25
|
+
value: str
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
if "@" not in self.value:
|
|
29
|
+
raise ValueError(f"Invalid email address: {self.value!r}")
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return self.value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class RealmName:
|
|
37
|
+
"""A Keycloak realm name extracted from an issuer URL."""
|
|
38
|
+
|
|
39
|
+
value: str
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str:
|
|
42
|
+
return self.value
|