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.
Files changed (110) hide show
  1. pkg_auth/__init__.py +15 -0
  2. pkg_auth/admin/__init__.py +35 -0
  3. pkg_auth/admin/cli.py +87 -0
  4. pkg_auth/admin/client.py +401 -0
  5. pkg_auth/admin/env.py +74 -0
  6. pkg_auth/admin/helpers.py +113 -0
  7. pkg_auth/admin/provision_client.py +86 -0
  8. pkg_auth/admin/settings.py +33 -0
  9. pkg_auth/authentication/__init__.py +33 -0
  10. pkg_auth/authentication/adapters/__init__.py +1 -0
  11. pkg_auth/authentication/adapters/keycloak/__init__.py +6 -0
  12. pkg_auth/authentication/adapters/keycloak/jwt_decoder.py +105 -0
  13. pkg_auth/authentication/application/__init__.py +1 -0
  14. pkg_auth/authentication/application/use_cases/__init__.py +1 -0
  15. pkg_auth/authentication/application/use_cases/authenticate.py +91 -0
  16. pkg_auth/authentication/domain/__init__.py +1 -0
  17. pkg_auth/authentication/domain/entities.py +50 -0
  18. pkg_auth/authentication/domain/exceptions.py +18 -0
  19. pkg_auth/authentication/domain/ports.py +26 -0
  20. pkg_auth/authentication/domain/value_objects.py +42 -0
  21. pkg_auth/authorization/__init__.py +117 -0
  22. pkg_auth/authorization/adapters/__init__.py +1 -0
  23. pkg_auth/authorization/adapters/cache/__init__.py +32 -0
  24. pkg_auth/authorization/adapters/cache/decorators.py +181 -0
  25. pkg_auth/authorization/adapters/cache/memory.py +61 -0
  26. pkg_auth/authorization/adapters/cache/protocol.py +36 -0
  27. pkg_auth/authorization/adapters/cache/redis.py +60 -0
  28. pkg_auth/authorization/adapters/django_orm/__init__.py +37 -0
  29. pkg_auth/authorization/adapters/django_orm/apps.py +24 -0
  30. pkg_auth/authorization/adapters/django_orm/mixins.py +142 -0
  31. pkg_auth/authorization/adapters/django_orm/models.py +226 -0
  32. pkg_auth/authorization/adapters/django_orm/repositories/__init__.py +20 -0
  33. pkg_auth/authorization/adapters/django_orm/repositories/membership.py +118 -0
  34. pkg_auth/authorization/adapters/django_orm/repositories/organization.py +73 -0
  35. pkg_auth/authorization/adapters/django_orm/repositories/organization_service.py +71 -0
  36. pkg_auth/authorization/adapters/django_orm/repositories/permission_catalog.py +102 -0
  37. pkg_auth/authorization/adapters/django_orm/repositories/role.py +120 -0
  38. pkg_auth/authorization/adapters/django_orm/repositories/service.py +60 -0
  39. pkg_auth/authorization/adapters/django_orm/repositories/user.py +77 -0
  40. pkg_auth/authorization/adapters/sqlalchemy/__init__.py +90 -0
  41. pkg_auth/authorization/adapters/sqlalchemy/base.py +55 -0
  42. pkg_auth/authorization/adapters/sqlalchemy/migrations/__init__.py +1 -0
  43. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260410_0001_initial_schema.py +293 -0
  44. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260412_0002_add_permission_is_platform.py +39 -0
  45. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0003_permission_visibility.py +65 -0
  46. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0004_permission_description_jsonb.py +52 -0
  47. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/20260620_0005_services_tables.py +116 -0
  48. pkg_auth/authorization/adapters/sqlalchemy/migrations/versions/__init__.py +1 -0
  49. pkg_auth/authorization/adapters/sqlalchemy/mixins.py +187 -0
  50. pkg_auth/authorization/adapters/sqlalchemy/models.py +268 -0
  51. pkg_auth/authorization/adapters/sqlalchemy/repositories/__init__.py +16 -0
  52. pkg_auth/authorization/adapters/sqlalchemy/repositories/membership.py +146 -0
  53. pkg_auth/authorization/adapters/sqlalchemy/repositories/organization.py +97 -0
  54. pkg_auth/authorization/adapters/sqlalchemy/repositories/organization_service.py +106 -0
  55. pkg_auth/authorization/adapters/sqlalchemy/repositories/permission_catalog.py +127 -0
  56. pkg_auth/authorization/adapters/sqlalchemy/repositories/role.py +171 -0
  57. pkg_auth/authorization/adapters/sqlalchemy/repositories/service.py +93 -0
  58. pkg_auth/authorization/adapters/sqlalchemy/repositories/user.py +74 -0
  59. pkg_auth/authorization/application/__init__.py +1 -0
  60. pkg_auth/authorization/application/use_cases/__init__.py +1 -0
  61. pkg_auth/authorization/application/use_cases/_helpers.py +82 -0
  62. pkg_auth/authorization/application/use_cases/check_permission.py +21 -0
  63. pkg_auth/authorization/application/use_cases/create_organization.py +41 -0
  64. pkg_auth/authorization/application/use_cases/create_role.py +69 -0
  65. pkg_auth/authorization/application/use_cases/delete_membership.py +21 -0
  66. pkg_auth/authorization/application/use_cases/delete_organization.py +21 -0
  67. pkg_auth/authorization/application/use_cases/delete_role.py +23 -0
  68. pkg_auth/authorization/application/use_cases/list_user_organizations.py +21 -0
  69. pkg_auth/authorization/application/use_cases/provision_default_services.py +38 -0
  70. pkg_auth/authorization/application/use_cases/register_permission_catalog.py +122 -0
  71. pkg_auth/authorization/application/use_cases/resolve_auth_context.py +70 -0
  72. pkg_auth/authorization/application/use_cases/resolve_user_from_jwt.py +34 -0
  73. pkg_auth/authorization/application/use_cases/set_organization_service.py +50 -0
  74. pkg_auth/authorization/application/use_cases/sync_permission_catalog.py +86 -0
  75. pkg_auth/authorization/application/use_cases/sync_service_catalog.py +91 -0
  76. pkg_auth/authorization/application/use_cases/sync_user_from_jwt.py +32 -0
  77. pkg_auth/authorization/application/use_cases/update_organization.py +31 -0
  78. pkg_auth/authorization/application/use_cases/update_role.py +61 -0
  79. pkg_auth/authorization/application/use_cases/upsert_membership.py +54 -0
  80. pkg_auth/authorization/cli/__init__.py +1 -0
  81. pkg_auth/authorization/cli/sync_catalog.py +180 -0
  82. pkg_auth/authorization/cli/sync_services.py +151 -0
  83. pkg_auth/authorization/config.py +21 -0
  84. pkg_auth/authorization/domain/__init__.py +1 -0
  85. pkg_auth/authorization/domain/entities.py +192 -0
  86. pkg_auth/authorization/domain/exceptions.py +68 -0
  87. pkg_auth/authorization/domain/ports.py +217 -0
  88. pkg_auth/authorization/domain/value_objects.py +208 -0
  89. pkg_auth/authorization/platform.py +47 -0
  90. pkg_auth/integrations/__init__.py +0 -0
  91. pkg_auth/integrations/django/__init__.py +32 -0
  92. pkg_auth/integrations/django/apps.py +10 -0
  93. pkg_auth/integrations/django/auth_context_middleware.py +105 -0
  94. pkg_auth/integrations/django/decorators.py +74 -0
  95. pkg_auth/integrations/django/install.py +136 -0
  96. pkg_auth/integrations/django/middleware.py +63 -0
  97. pkg_auth/integrations/fastapi/__init__.py +26 -0
  98. pkg_auth/integrations/fastapi/auth_context_dep.py +150 -0
  99. pkg_auth/integrations/fastapi/auth_factory.py +84 -0
  100. pkg_auth/integrations/fastapi/decorators.py +55 -0
  101. pkg_auth/integrations/fastapi/errors.py +72 -0
  102. pkg_auth/integrations/fastapi/identity_dep.py +41 -0
  103. pkg_auth/integrations/strawberry/__init__.py +20 -0
  104. pkg_auth/integrations/strawberry/auth.py +137 -0
  105. pkg_auth/integrations/strawberry/permissions.py +56 -0
  106. pkg_auth-3.0.0.dist-info/METADATA +147 -0
  107. pkg_auth-3.0.0.dist-info/RECORD +110 -0
  108. pkg_auth-3.0.0.dist-info/WHEEL +5 -0
  109. pkg_auth-3.0.0.dist-info/entry_points.txt +4 -0
  110. 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,6 @@
1
+ """Keycloak JWT decoder adapter."""
2
+ from __future__ import annotations
3
+
4
+ from .jwt_decoder import JWTTokenDecoder
5
+
6
+ __all__ = ["JWTTokenDecoder"]
@@ -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