open-shield-python 0.1.0__tar.gz → 0.2.1__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 (77) hide show
  1. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/CONTRIBUTING.md +3 -10
  2. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/PKG-INFO +3 -14
  3. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/README.md +2 -0
  4. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/tasks.md +4 -10
  5. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/pyproject.toml +1 -22
  6. open_shield_python-0.2.1/src/open_shield/adapters/config.py +49 -0
  7. open_shield_python-0.2.1/src/open_shield/api/fastapi/__init__.py +15 -0
  8. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/api/fastapi/dependencies.py +20 -4
  9. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/api/fastapi/middleware.py +20 -13
  10. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/domain/entities.py +4 -0
  11. open_shield_python-0.2.1/src/open_shield/domain/ports/__init__.py +5 -0
  12. open_shield_python-0.2.1/src/open_shield/domain/ports/tenant_resolver.py +37 -0
  13. open_shield_python-0.2.1/src/open_shield/domain/services/__init__.py +5 -0
  14. open_shield_python-0.2.1/src/open_shield/domain/services/claim_mapping.py +44 -0
  15. open_shield_python-0.2.1/src/open_shield/domain/services/token_service.py +151 -0
  16. open_shield_python-0.2.1/tests/unit/domain/test_claim_mapping.py +247 -0
  17. open_shield_python-0.1.0/.agent/rules.md +0 -19
  18. open_shield_python-0.1.0/.agent/workflows/safe-commit.md +0 -27
  19. open_shield_python-0.1.0/docs/planning/phases/phase-4-logto-integration.md +0 -73
  20. open_shield_python-0.1.0/scripts/verify.sh +0 -17
  21. open_shield_python-0.1.0/src/open_shield/adapters/config.py +0 -26
  22. open_shield_python-0.1.0/src/open_shield/api/fastapi/__init__.py +0 -4
  23. open_shield_python-0.1.0/src/open_shield/domain/ports/__init__.py +0 -4
  24. open_shield_python-0.1.0/src/open_shield/domain/services/__init__.py +0 -4
  25. open_shield_python-0.1.0/src/open_shield/domain/services/token_service.py +0 -83
  26. open_shield_python-0.1.0/tests/integration/auth_flow/test_logto_integration.py +0 -72
  27. open_shield_python-0.1.0/tests/integration/conftest.py +0 -119
  28. open_shield_python-0.1.0/tests/integration/infrastructure/docker-compose.logto.yml +0 -34
  29. open_shield_python-0.1.0/tests/integration/infrastructure/setup_logto.py +0 -101
  30. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/python-packaging/SKILL.md +0 -0
  31. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/python-packaging/resources/implementation-playbook.md +0 -0
  32. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/python-pro/SKILL.md +0 -0
  33. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/python-testing-patterns/SKILL.md +0 -0
  34. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/python-testing-patterns/resources/implementation-playbook.md +0 -0
  35. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/SKILL.md +0 -0
  36. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/examples/before_after_refactor.py +0 -0
  37. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/examples/clean_architecture_layout.md +0 -0
  38. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/examples/dip_ports_adapters.py +0 -0
  39. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/resources/code_review_checklist.md +0 -0
  40. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.github/workflows/ci.yml +0 -0
  41. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.gitignore +0 -0
  42. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/CHANGELOG.md +0 -0
  43. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/LICENSE +0 -0
  44. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/None +0 -0
  45. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/architecture/system-overview.md +0 -0
  46. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/design/overview.md +0 -0
  47. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/implementation/guidelines.md +0 -0
  48. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/README.md +0 -0
  49. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/backlog.md +0 -0
  50. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/decisions/0000-template.md +0 -0
  51. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/decisions/001-clean-architecture.md +0 -0
  52. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/decisions/002-modern-tooling.md +0 -0
  53. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/decisions/003-pydantic-usage.md +0 -0
  54. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-0-init.md +0 -0
  55. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-1-core.md +0 -0
  56. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-2-authz.md +0 -0
  57. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-3-integration.md +0 -0
  58. /open_shield_python-0.1.0/docs/planning/phases/phase-5-publish.md → /open_shield_python-0.2.1/docs/planning/phases/phase-4-publish.md +0 -0
  59. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/roadmap.md +0 -0
  60. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/specs.md +0 -0
  61. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/tech-debt.md +0 -0
  62. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/main.py +0 -0
  63. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/adapters/__init__.py +0 -0
  64. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/adapters/key_provider.py +0 -0
  65. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/adapters/token_validator.py +0 -0
  66. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/api/__init__.py +0 -0
  67. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/domain/__init__.py +0 -0
  68. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/domain/exceptions.py +0 -0
  69. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/domain/ports/key_provider.py +0 -0
  70. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/domain/ports/token_validator.py +0 -0
  71. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/domain/services/authorization_service.py +0 -0
  72. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/tests/integration/adapters/test_key_provider.py +0 -0
  73. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/tests/integration/adapters/test_token_validator.py +0 -0
  74. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/tests/integration/api/test_fastapi.py +0 -0
  75. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/tests/unit/adapters/test_config.py +0 -0
  76. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/tests/unit/domain/test_authorization_service.py +0 -0
  77. {open_shield_python-0.1.0 → open_shield_python-0.2.1}/tests/unit/domain/test_token_service.py +0 -0
@@ -27,18 +27,11 @@ Thank you for your interest in contributing to Open Shield! We welcome contribut
27
27
 
28
28
  ## Code Standards
29
29
 
30
- - **Linting & Testing**: We provide a convenience script.
31
- - Run `./scripts/verify.sh` to execute `ruff format`, `ruff check`, `mypy`, and `pytest` in one go.
32
- - **Always** run this script before pushing your changes.
30
+ - **Linting**: We use `ruff`. Run `uv run ruff check .` before committing.
31
+ - **Typing**: We use `mypy`. Run `uv run mypy .` to ensure type safety.
32
+ - **Architecture**: Please respect the Clean Architecture layers (`domain`, `adapters`, `api`).
33
33
  - **Commits**: Follow [Conventional Commits](https://www.conventionalcommits.org/).
34
34
 
35
- ## Assistant / Agent Workflow
36
-
37
- If you are an AI assistant working on this repository:
38
- 1. **Always** check for existing workflows in `.agent/workflows`.
39
- 2. **Prioritize** using `/safe-commit` when finalizing a task to ensure quality gates are met.
40
- 3. **Read** `.agent/rules.md` for project-specific behavioral rules.
41
-
42
35
  ## Pull Requests
43
36
 
44
37
  1. Fork the repo and create your branch (`feat/amazing-feature`).
@@ -1,21 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-shield-python
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Vendor-agnostic authentication and authorization enforcement SDK
5
- Project-URL: Repository, https://github.com/prayog-ai-labs/open-shield-python
6
- Project-URL: Issues, https://github.com/prayog-ai-labs/open-shield-python/issues
7
- Author-email: Avinash <avinash@prayog.ai>
8
- License: MIT
9
5
  License-File: LICENSE
10
- Keywords: authentication,authorization,fastapi,jwt,oidc,security
11
- Classifier: Development Status :: 4 - Beta
12
- Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
- Classifier: Operating System :: OS Independent
15
- Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.12
17
- Classifier: Topic :: Security
18
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
6
  Requires-Python: >=3.12
20
7
  Requires-Dist: cryptography>=42.0.0
21
8
  Requires-Dist: fastapi>=0.129.0
@@ -27,6 +14,8 @@ Description-Content-Type: text/markdown
27
14
 
28
15
  # Open Shield Python SDK
29
16
 
17
+ [![CI](https://github.com/prayog-ai-labs/open-shield-python/actions/workflows/ci.yml/badge.svg)](https://github.com/prayog-ai-labs/open-shield-python/actions/workflows/ci.yml)
18
+
30
19
  Vendor-agnostic authentication and authorization enforcement SDK for Python.
31
20
 
32
21
  Open Shield allows you to enforce authentication (AuthN) and authorization (AuthZ) in your Python applications without tightly coupling your code to a specific identity provider (like Auth0, Keycloak, or Cognito).
@@ -1,5 +1,7 @@
1
1
  # Open Shield Python SDK
2
2
 
3
+ [![CI](https://github.com/prayog-ai-labs/open-shield-python/actions/workflows/ci.yml/badge.svg)](https://github.com/prayog-ai-labs/open-shield-python/actions/workflows/ci.yml)
4
+
3
5
  Vendor-agnostic authentication and authorization enforcement SDK for Python.
4
6
 
5
7
  Open Shield allows you to enforce authentication (AuthN) and authorization (AuthZ) in your Python applications without tightly coupling your code to a specific identity provider (like Auth0, Keycloak, or Cognito).
@@ -19,13 +19,7 @@
19
19
  - [x] Create FastAPI Middleware (depends on `TokenService`) <!-- id: 16 -->
20
20
  - [x] Implement FastAPI Dependencies (`DependencyInjection`) <!-- id: 17 -->
21
21
  - [x] Map Domain Exceptions to HTTP Responses <!-- id: 18 -->
22
- - [/] Phase 4: Integration Testing & Verification <!-- id: 19 -->
23
- - [x] **Research Logto Integration** <!-- id: 23 -->
24
- - [x] **Create Logto Integration Plan** <!-- id: 25 -->
25
- - [x] **Implement Logto Integration Tests** <!-- id: 26 -->
26
- - [x] Create `docker-compose.test.yml` with Logto <!-- id: 27 -->
27
- - [x] Create setup scripts/fixtures for Logto <!-- id: 28 -->
28
- - [x] Develop Integration Test Suite (`tests/integration/auth_flow`) <!-- id: 29 -->
29
- - [ ] Comprehensive Testing (Unit/Integration) <!-- id: 21 -->
30
- - [ ] Documentation (Usage, Architecture) <!-- id: 20 -->
31
- - [ ] Publish to PyPI <!-- id: 22 -->
22
+ - [x] Phase 4: Polish & Release <!-- id: 19 -->
23
+ - [x] Comprehensive Testing (Unit/Integration) <!-- id: 21 -->
24
+ - [x] Documentation (Usage, Architecture) <!-- id: 20 -->
25
+ - [x] Publish to PyPI <!-- id: 22 -->
@@ -1,25 +1,9 @@
1
1
  [project]
2
2
  name = "open-shield-python"
3
- version = "0.1.0"
3
+ version = "0.2.1"
4
4
  description = "Vendor-agnostic authentication and authorization enforcement SDK"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
7
- authors = [
8
- { name = "Avinash", email = "avinash@prayog.ai" },
9
- ]
10
- license = { text = "MIT" }
11
- classifiers = [
12
- "Development Status :: 4 - Beta",
13
- "Intended Audience :: Developers",
14
- "License :: OSI Approved :: MIT License",
15
- "Programming Language :: Python :: 3",
16
- "Programming Language :: Python :: 3.12",
17
- "Operating System :: OS Independent",
18
- "Topic :: Security",
19
- "Topic :: Software Development :: Libraries :: Python Modules",
20
- ]
21
- keywords = ["authentication", "authorization", "oidc", "jwt", "security", "fastapi"]
22
-
23
7
  dependencies = [
24
8
  "pydantic>=2.0.0",
25
9
  "pydantic-settings>=2.0.0",
@@ -29,10 +13,6 @@ dependencies = [
29
13
  "fastapi>=0.129.0",
30
14
  ]
31
15
 
32
- [project.urls]
33
- Repository = "https://github.com/prayog-ai-labs/open-shield-python"
34
- Issues = "https://github.com/prayog-ai-labs/open-shield-python/issues"
35
-
36
16
  [build-system]
37
17
  requires = ["hatchling"]
38
18
  build-backend = "hatchling.build"
@@ -71,5 +51,4 @@ dev = [
71
51
  "respx>=0.22.0",
72
52
  "ruff>=0.15.0",
73
53
  "uvicorn>=0.40.0",
74
- "pytest-asyncio>=0.23.0",
75
54
  ]
@@ -0,0 +1,49 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+
4
+ class OpenShieldConfig(BaseSettings):
5
+ """Configuration for Open Shield SDK.
6
+
7
+ Loads settings from environment variables (OPEN_SHIELD_ prefix).
8
+
9
+ OIDC Settings:
10
+ ISSUER_URL: OIDC provider's issuer URL (required).
11
+ AUDIENCE: Expected audience claim value.
12
+ ALGORITHMS: Allowed JWT signing algorithms.
13
+
14
+ Claim Mapping:
15
+ USER_ID_CLAIM: Claim containing the unique user identifier.
16
+ EMAIL_CLAIM: Claim containing the user's email address.
17
+ TENANT_ID_CLAIM: Claim containing tenant/organization ID.
18
+ SCOPE_CLAIM: Claim containing space-separated scopes.
19
+ ROLES_CLAIM: Claim containing user roles.
20
+ TENANT_FALLBACK: Strategy when tenant claim is missing ("sub" | "none").
21
+
22
+ Authorization:
23
+ REQUIRE_SCOPES: Whether to enforce scope presence globally.
24
+ REQUIRE_ROLES: Whether to enforce role presence globally.
25
+ """
26
+
27
+ model_config = SettingsConfigDict(
28
+ env_prefix="OPEN_SHIELD_",
29
+ env_file=".env",
30
+ env_file_encoding="utf-8",
31
+ extra="ignore",
32
+ )
33
+
34
+ # OIDC
35
+ ISSUER_URL: str
36
+ AUDIENCE: str | None = None
37
+ ALGORITHMS: list[str] = ["RS256"]
38
+
39
+ # Claim mapping (identity-provider agnostic)
40
+ USER_ID_CLAIM: str = "sub"
41
+ EMAIL_CLAIM: str = "email"
42
+ TENANT_ID_CLAIM: str = "tid"
43
+ SCOPE_CLAIM: str = "scope"
44
+ ROLES_CLAIM: str = "roles"
45
+ TENANT_FALLBACK: str = "none"
46
+
47
+ # Authorization defaults
48
+ REQUIRE_SCOPES: bool = True
49
+ REQUIRE_ROLES: bool = False
@@ -0,0 +1,15 @@
1
+ from .dependencies import (
2
+ RequireRole,
3
+ RequireScope,
4
+ get_optional_user_context,
5
+ get_user_context,
6
+ )
7
+ from .middleware import OpenShieldMiddleware
8
+
9
+ __all__ = [
10
+ "OpenShieldMiddleware",
11
+ "RequireRole",
12
+ "RequireScope",
13
+ "get_optional_user_context",
14
+ "get_user_context",
15
+ ]
@@ -1,3 +1,5 @@
1
+ from typing import cast
2
+
1
3
  from fastapi import Depends, HTTPException, Request
2
4
 
3
5
  from open_shield.domain.entities import UserContext
@@ -6,13 +8,27 @@ from open_shield.domain.services import AuthorizationService
6
8
 
7
9
 
8
10
  def get_user_context(request: Request) -> UserContext:
9
- """
10
- Dependency to retrieve the UserContext from request.state.
11
- Assumes OpenShieldMiddleware has run.
11
+ """Dependency to retrieve the UserContext from request.state.
12
+
13
+ Assumes OpenShieldMiddleware has run and attached user_context.
14
+
15
+ Raises:
16
+ HTTPException: 401 if no user context is found.
12
17
  """
13
18
  if not hasattr(request.state, "user_context"):
14
19
  raise HTTPException(status_code=401, detail="Authentication required")
15
- from typing import cast
20
+
21
+ return cast(UserContext, request.state.user_context)
22
+
23
+
24
+ def get_optional_user_context(request: Request) -> UserContext | None:
25
+ """Dependency for optional authentication.
26
+
27
+ Returns None instead of 401 when no credentials are provided.
28
+ Useful for routes that support both authenticated and anonymous access.
29
+ """
30
+ if not hasattr(request.state, "user_context"):
31
+ return None
16
32
 
17
33
  return cast(UserContext, request.state.user_context)
18
34
 
@@ -5,13 +5,16 @@ from starlette.types import ASGIApp
5
5
  from open_shield.adapters import OIDCDiscoKeyProvider, OpenShieldConfig, PyJWTValidator
6
6
  from open_shield.domain.exceptions import OpenShieldError, TokenValidationError
7
7
  from open_shield.domain.services import TokenService
8
+ from open_shield.domain.services.claim_mapping import ClaimMapping
8
9
 
9
10
 
10
11
  class OpenShieldMiddleware(BaseHTTPMiddleware):
11
- """
12
- Middleware that intercepts requests, validates the Authorization header,
12
+ """Middleware that intercepts requests, validates the Authorization header,
13
13
  and attaches the UserContext to the request state.
14
14
 
15
+ Supports configurable claim mapping via OpenShieldConfig, making it
16
+ compatible with any OIDC-compliant provider (Logto, Keycloak, Auth0, etc.).
17
+
15
18
  Attributes:
16
19
  app: The ASGI application.
17
20
  token_service: The domain service for validation.
@@ -34,9 +37,17 @@ class OpenShieldMiddleware(BaseHTTPMiddleware):
34
37
  "/health",
35
38
  }
36
39
 
37
- # Initialize dependencies
38
- # In a real app, these might be injected, but middleware initialization is often
39
- # the composition root.
40
+ # Build claim mapping from config
41
+ claim_mapping = ClaimMapping(
42
+ user_id_claim=config.USER_ID_CLAIM,
43
+ email_claim=config.EMAIL_CLAIM,
44
+ tenant_id_claim=config.TENANT_ID_CLAIM,
45
+ scope_claim=config.SCOPE_CLAIM,
46
+ roles_claim=config.ROLES_CLAIM,
47
+ tenant_fallback=config.TENANT_FALLBACK,
48
+ )
49
+
50
+ # Initialize dependencies (composition root)
40
51
  key_provider = OIDCDiscoKeyProvider(issuer_url=config.ISSUER_URL)
41
52
  validator = PyJWTValidator(
42
53
  key_provider=key_provider,
@@ -44,7 +55,10 @@ class OpenShieldMiddleware(BaseHTTPMiddleware):
44
55
  audience=config.AUDIENCE,
45
56
  issuer=config.ISSUER_URL,
46
57
  )
47
- self.token_service = TokenService(validator=validator)
58
+ self.token_service = TokenService(
59
+ validator=validator,
60
+ claim_mapping=claim_mapping,
61
+ )
48
62
 
49
63
  async def dispatch(
50
64
  self, request: Request, call_next: RequestResponseEndpoint
@@ -54,9 +68,6 @@ class OpenShieldMiddleware(BaseHTTPMiddleware):
54
68
 
55
69
  auth_header = request.headers.get("Authorization")
56
70
  if not auth_header:
57
- # Basic check, detailed handling usually done by dependency or explicit 401
58
- # If we want global enforcement, strictly 401 here.
59
- # Return 401.
60
71
  return Response("Missing Authorization Header", status_code=401)
61
72
 
62
73
  try:
@@ -69,15 +80,11 @@ class OpenShieldMiddleware(BaseHTTPMiddleware):
69
80
  # Attach to request.state for downstream access
70
81
  request.state.user_context = user_context
71
82
 
72
- # Enforce global require_scopes/roles if configured?
73
- # Usually better handled in route dependencies.
74
-
75
83
  except (ValueError, TokenValidationError) as e:
76
84
  return Response(f"Unauthorized: {e!s}", status_code=401)
77
85
  except OpenShieldError as e:
78
86
  return Response(f"Forbidden: {e!s}", status_code=403)
79
87
  except Exception:
80
- # Log this error
81
88
  return Response(
82
89
  "Internal Server Error during Authentication", status_code=500
83
90
  )
@@ -27,6 +27,10 @@ class User(Entity):
27
27
  scopes: list[str] = Field(
28
28
  default_factory=list, description="Granted scopes/permissions"
29
29
  )
30
+ actor_type: str = Field(
31
+ default="user",
32
+ description="Inferred actor type: 'user', 'agent', or 'service'",
33
+ )
30
34
  metadata: dict[str, Any] = Field(
31
35
  default_factory=dict, description="Additional user attributes"
32
36
  )
@@ -0,0 +1,5 @@
1
+ from .key_provider import KeyProviderPort
2
+ from .tenant_resolver import TenantResolverPort
3
+ from .token_validator import TokenValidatorPort
4
+
5
+ __all__ = ["KeyProviderPort", "TenantResolverPort", "TokenValidatorPort"]
@@ -0,0 +1,37 @@
1
+ """Port for resolving tenant context from machine client credentials.
2
+
3
+ This port supports Case 3 (M2M clients) in the tenant resolution cascade:
4
+
5
+ 1. M2M client → lookup_client_tenant(client_id) → tenant_id
6
+ 2. Organization claim → organization_id → tenant_id
7
+ 3. Fallback → sub → tenant_id (individual user mode only)
8
+
9
+ Consumers implement this port to map client_id → tenant_id using their
10
+ own registry (database, config file, Logto Management API, etc.).
11
+ """
12
+
13
+ from abc import ABC, abstractmethod
14
+
15
+
16
+ class TenantResolverPort(ABC):
17
+ """Resolves tenant context for machine-to-machine (M2M) clients.
18
+
19
+ M2M tokens (client_credentials flow) don't carry organization claims.
20
+ This port allows consumers to map a ``client_id`` to a ``tenant_id``
21
+ using their own backend registry.
22
+
23
+ If no resolver is provided to ``TokenService``, M2M tokens fall through
24
+ to the organization claim or sub fallback.
25
+ """
26
+
27
+ @abstractmethod
28
+ def resolve_tenant(self, client_id: str) -> str | None:
29
+ """Look up the tenant for a machine client.
30
+
31
+ Args:
32
+ client_id: The OAuth2 client_id from the token.
33
+
34
+ Returns:
35
+ The tenant_id if found, or None to continue the cascade.
36
+ """
37
+ ...
@@ -0,0 +1,5 @@
1
+ from .authorization_service import AuthorizationService
2
+ from .claim_mapping import ClaimMapping
3
+ from .token_service import TokenService
4
+
5
+ __all__ = ["AuthorizationService", "ClaimMapping", "TokenService"]
@@ -0,0 +1,44 @@
1
+ """Configurable JWT claim-to-field mapping.
2
+
3
+ Allows consumers to map any OIDC provider's claim names to Open Shield's
4
+ internal user/tenant model without modifying SDK code.
5
+
6
+ Examples:
7
+ # Logto
8
+ ClaimMapping(tenant_id_claim="organization_id")
9
+
10
+ # Keycloak
11
+ ClaimMapping(roles_claim="realm_access.roles", tenant_id_claim="tenant")
12
+
13
+ # Auth0
14
+ ClaimMapping(tenant_id_claim="https://myapp.com/org_id")
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ClaimMapping:
24
+ """Maps JWT claim names to semantic identity fields.
25
+
26
+ All fields have sensible defaults that work with most OIDC providers.
27
+ Override individual fields to match your provider's token format.
28
+
29
+ Attributes:
30
+ user_id_claim: Claim containing the unique user identifier.
31
+ email_claim: Claim containing the user's email address.
32
+ tenant_id_claim: Claim containing the tenant/organization ID.
33
+ scope_claim: Claim containing space-separated scopes.
34
+ roles_claim: Claim containing the user's roles.
35
+ tenant_fallback: Strategy when tenant claim is missing.
36
+ "sub" = fall back to user_id, "none" = no tenant.
37
+ """
38
+
39
+ user_id_claim: str = "sub"
40
+ email_claim: str = "email"
41
+ tenant_id_claim: str = "tid"
42
+ scope_claim: str = "scope"
43
+ roles_claim: str = "roles"
44
+ tenant_fallback: str = "none"
@@ -0,0 +1,151 @@
1
+ from open_shield.domain.entities import TenantContext, Token, User, UserContext
2
+ from open_shield.domain.exceptions import TokenValidationError
3
+ from open_shield.domain.ports import TenantResolverPort, TokenValidatorPort
4
+ from open_shield.domain.services.claim_mapping import ClaimMapping
5
+
6
+
7
+ class TokenService:
8
+ """Domain service for orchestrating token validation and context extraction.
9
+
10
+ Implements a 3-step tenant resolution cascade:
11
+ 1. M2M client → lookup via TenantResolverPort (if provided)
12
+ 2. Organization claim → configurable tenant_id_claim
13
+ 3. Fallback → sub (if tenant_fallback="sub")
14
+
15
+ Args:
16
+ validator: Port for JWT validation.
17
+ claim_mapping: Configurable claim-to-field mapping.
18
+ tenant_resolver: Optional port for M2M client→tenant lookup.
19
+ If not provided, M2M tokens fall through to the org claim.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ validator: TokenValidatorPort,
25
+ claim_mapping: ClaimMapping | None = None,
26
+ tenant_resolver: TenantResolverPort | None = None,
27
+ ):
28
+ self.validator = validator
29
+ self.claims = claim_mapping or ClaimMapping()
30
+ self.tenant_resolver = tenant_resolver
31
+
32
+ def validate_and_extract(self, token_string: str) -> UserContext:
33
+ """Validate a raw token string and extract the user context.
34
+
35
+ Args:
36
+ token_string: The raw JWT from the Authorization header.
37
+
38
+ Returns:
39
+ A populated UserContext object.
40
+
41
+ Raises:
42
+ TokenValidationError: If validation fails.
43
+ """
44
+ token = self.validator.validate_token(token_string)
45
+ user = self._extract_user(token)
46
+ tenant = self._extract_tenant(token)
47
+
48
+ return UserContext(user=user, token=token, tenant=tenant)
49
+
50
+ def _extract_user(self, token: Token) -> User:
51
+ """Extract user identity and permissions from the token.
52
+
53
+ Uses the configured claim mapping to read fields from any
54
+ OIDC-compliant provider's token format.
55
+ """
56
+ sub = token.subject
57
+ if not sub:
58
+ raise TokenValidationError("Token missing 'sub' claim")
59
+
60
+ email = token.claims.get(self.claims.email_claim)
61
+
62
+ # Roles: support flat list and nested Keycloak-style realm_access
63
+ roles = token.claims.get(self.claims.roles_claim, [])
64
+ if isinstance(roles, str):
65
+ roles = [roles]
66
+ if "realm_access" in token.claims and isinstance(
67
+ token.claims["realm_access"], dict
68
+ ):
69
+ roles = list(roles) # ensure mutable
70
+ roles.extend(token.claims["realm_access"].get("roles", []))
71
+
72
+ # Scopes: space-separated string or list
73
+ raw_scope = token.claims.get(self.claims.scope_claim, "")
74
+ scopes = raw_scope.split() if isinstance(raw_scope, str) else list(raw_scope)
75
+
76
+ # Actor type inference
77
+ actor_type = self._infer_actor_type(token.claims, roles)
78
+
79
+ return User(
80
+ id=sub,
81
+ email=email,
82
+ roles=list(set(roles)), # Deduplicate
83
+ scopes=scopes,
84
+ actor_type=actor_type,
85
+ metadata=token.claims,
86
+ )
87
+
88
+ def _extract_tenant(self, token: Token) -> TenantContext | None:
89
+ """Extract tenant context using a 3-step cascade.
90
+
91
+ Resolution order:
92
+ 1. M2M client lookup — If this is a client_credentials token
93
+ (sub == client_id) AND a TenantResolverPort is configured,
94
+ resolve tenant from the client registry.
95
+ 2. Organization claim — Read the configured tenant_id_claim
96
+ (e.g. organization_id, tid, org_id).
97
+ 3. Sub fallback — If tenant_fallback="sub", use the user's
98
+ subject as tenant_id (individual user mode).
99
+
100
+ Returns:
101
+ TenantContext if resolved, None if no tenant could be determined.
102
+ """
103
+ claims = token.claims
104
+ sub = token.subject
105
+
106
+ # --- Step 1: M2M client → tenant lookup ---
107
+ client_id = claims.get("client_id", claims.get("azp"))
108
+ is_m2m = client_id and sub and sub == client_id
109
+
110
+ if is_m2m and self.tenant_resolver:
111
+ resolved = self.tenant_resolver.resolve_tenant(client_id)
112
+ if resolved:
113
+ return TenantContext(
114
+ tenant_id=resolved,
115
+ metadata={"resolution": "m2m_lookup", "client_id": client_id},
116
+ )
117
+
118
+ # --- Step 2: Organization claim ---
119
+ tid = claims.get(self.claims.tenant_id_claim)
120
+ if tid:
121
+ return TenantContext(
122
+ tenant_id=tid,
123
+ metadata={"resolution": "claim", "claim": self.claims.tenant_id_claim},
124
+ )
125
+
126
+ # --- Step 3: Sub fallback (individual user mode) ---
127
+ if self.claims.tenant_fallback == "sub" and sub:
128
+ return TenantContext(
129
+ tenant_id=sub,
130
+ metadata={"resolution": "sub_fallback"},
131
+ )
132
+
133
+ return None
134
+
135
+ def _infer_actor_type(self, claims: dict, roles: list) -> str: # type: ignore[type-arg]
136
+ """Infer actor type from token claims.
137
+
138
+ Heuristic:
139
+ - If 'client_id' == 'sub' (client credentials flow) → "service" or "agent"
140
+ - Default → "user"
141
+ """
142
+ sub = claims.get("sub", "")
143
+ client_id = claims.get("client_id", claims.get("azp", ""))
144
+
145
+ # Client credentials flow: sub == client_id (no human user)
146
+ if client_id and sub == client_id:
147
+ if "agent" in roles:
148
+ return "agent"
149
+ return "service"
150
+
151
+ return "user"