open-shield-python 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/CONTRIBUTING.md +3 -10
  2. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/PKG-INFO +3 -14
  3. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/README.md +2 -0
  4. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/tasks.md +4 -10
  5. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/pyproject.toml +1 -22
  6. open_shield_python-0.2.0/src/open_shield/adapters/config.py +49 -0
  7. open_shield_python-0.2.0/src/open_shield/api/fastapi/__init__.py +15 -0
  8. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/api/fastapi/dependencies.py +20 -4
  9. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/api/fastapi/middleware.py +20 -13
  10. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/domain/entities.py +4 -0
  11. open_shield_python-0.2.0/src/open_shield/domain/services/__init__.py +5 -0
  12. open_shield_python-0.2.0/src/open_shield/domain/services/claim_mapping.py +44 -0
  13. open_shield_python-0.2.0/src/open_shield/domain/services/token_service.py +114 -0
  14. open_shield_python-0.2.0/tests/unit/domain/test_claim_mapping.py +154 -0
  15. open_shield_python-0.1.0/.agent/rules.md +0 -19
  16. open_shield_python-0.1.0/.agent/workflows/safe-commit.md +0 -27
  17. open_shield_python-0.1.0/docs/planning/phases/phase-4-logto-integration.md +0 -73
  18. open_shield_python-0.1.0/scripts/verify.sh +0 -17
  19. open_shield_python-0.1.0/src/open_shield/adapters/config.py +0 -26
  20. open_shield_python-0.1.0/src/open_shield/api/fastapi/__init__.py +0 -4
  21. open_shield_python-0.1.0/src/open_shield/domain/services/__init__.py +0 -4
  22. open_shield_python-0.1.0/src/open_shield/domain/services/token_service.py +0 -83
  23. open_shield_python-0.1.0/tests/integration/auth_flow/test_logto_integration.py +0 -72
  24. open_shield_python-0.1.0/tests/integration/conftest.py +0 -119
  25. open_shield_python-0.1.0/tests/integration/infrastructure/docker-compose.logto.yml +0 -34
  26. open_shield_python-0.1.0/tests/integration/infrastructure/setup_logto.py +0 -101
  27. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/.agent/skills/python-packaging/SKILL.md +0 -0
  28. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/.agent/skills/python-packaging/resources/implementation-playbook.md +0 -0
  29. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/.agent/skills/python-pro/SKILL.md +0 -0
  30. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/.agent/skills/python-testing-patterns/SKILL.md +0 -0
  31. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/.agent/skills/python-testing-patterns/resources/implementation-playbook.md +0 -0
  32. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/.agent/skills/solid_architecture/SKILL.md +0 -0
  33. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/.agent/skills/solid_architecture/examples/before_after_refactor.py +0 -0
  34. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/.agent/skills/solid_architecture/examples/clean_architecture_layout.md +0 -0
  35. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/.agent/skills/solid_architecture/examples/dip_ports_adapters.py +0 -0
  36. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/.agent/skills/solid_architecture/resources/code_review_checklist.md +0 -0
  37. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/.github/workflows/ci.yml +0 -0
  38. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/.gitignore +0 -0
  39. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/CHANGELOG.md +0 -0
  40. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/LICENSE +0 -0
  41. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/None +0 -0
  42. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/architecture/system-overview.md +0 -0
  43. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/design/overview.md +0 -0
  44. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/implementation/guidelines.md +0 -0
  45. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/README.md +0 -0
  46. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/backlog.md +0 -0
  47. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/decisions/0000-template.md +0 -0
  48. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/decisions/001-clean-architecture.md +0 -0
  49. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/decisions/002-modern-tooling.md +0 -0
  50. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/decisions/003-pydantic-usage.md +0 -0
  51. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/phases/phase-0-init.md +0 -0
  52. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/phases/phase-1-core.md +0 -0
  53. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/phases/phase-2-authz.md +0 -0
  54. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/phases/phase-3-integration.md +0 -0
  55. /open_shield_python-0.1.0/docs/planning/phases/phase-5-publish.md → /open_shield_python-0.2.0/docs/planning/phases/phase-4-publish.md +0 -0
  56. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/roadmap.md +0 -0
  57. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/specs.md +0 -0
  58. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/docs/planning/tech-debt.md +0 -0
  59. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/main.py +0 -0
  60. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/adapters/__init__.py +0 -0
  61. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/adapters/key_provider.py +0 -0
  62. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/adapters/token_validator.py +0 -0
  63. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/api/__init__.py +0 -0
  64. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/domain/__init__.py +0 -0
  65. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/domain/exceptions.py +0 -0
  66. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/domain/ports/__init__.py +0 -0
  67. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/domain/ports/key_provider.py +0 -0
  68. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/domain/ports/token_validator.py +0 -0
  69. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/src/open_shield/domain/services/authorization_service.py +0 -0
  70. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/tests/integration/adapters/test_key_provider.py +0 -0
  71. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/tests/integration/adapters/test_token_validator.py +0 -0
  72. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/tests/integration/api/test_fastapi.py +0 -0
  73. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/tests/unit/adapters/test_config.py +0 -0
  74. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/tests/unit/domain/test_authorization_service.py +0 -0
  75. {open_shield_python-0.1.0 → open_shield_python-0.2.0}/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.0
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.0"
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 .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,114 @@
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 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
+ Dependencies are injected via constructor (DIP).
11
+
12
+ Args:
13
+ validator: Port for JWT validation.
14
+ claim_mapping: Configurable claim-to-field mapping.
15
+ Defaults to standard OIDC claim names if not provided.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ validator: TokenValidatorPort,
21
+ claim_mapping: ClaimMapping | None = None,
22
+ ):
23
+ self.validator = validator
24
+ self.claims = claim_mapping or ClaimMapping()
25
+
26
+ def validate_and_extract(self, token_string: str) -> UserContext:
27
+ """Validate a raw token string and extract the user context.
28
+
29
+ Args:
30
+ token_string: The raw JWT from the Authorization header.
31
+
32
+ Returns:
33
+ A populated UserContext object.
34
+
35
+ Raises:
36
+ TokenValidationError: If validation fails.
37
+ """
38
+ token = self.validator.validate_token(token_string)
39
+ user = self._extract_user(token)
40
+ tenant = self._extract_tenant(token)
41
+
42
+ return UserContext(user=user, token=token, tenant=tenant)
43
+
44
+ def _extract_user(self, token: Token) -> User:
45
+ """Extract user identity and permissions from the token.
46
+
47
+ Uses the configured claim mapping to read fields from any
48
+ OIDC-compliant provider's token format.
49
+ """
50
+ sub = token.subject
51
+ if not sub:
52
+ raise TokenValidationError("Token missing 'sub' claim")
53
+
54
+ email = token.claims.get(self.claims.email_claim)
55
+
56
+ # Roles: support flat list and nested Keycloak-style realm_access
57
+ roles = token.claims.get(self.claims.roles_claim, [])
58
+ if isinstance(roles, str):
59
+ roles = [roles]
60
+ if "realm_access" in token.claims and isinstance(
61
+ token.claims["realm_access"], dict
62
+ ):
63
+ roles = list(roles) # ensure mutable
64
+ roles.extend(token.claims["realm_access"].get("roles", []))
65
+
66
+ # Scopes: space-separated string or list
67
+ raw_scope = token.claims.get(self.claims.scope_claim, "")
68
+ scopes = raw_scope.split() if isinstance(raw_scope, str) else list(raw_scope)
69
+
70
+ # Actor type inference
71
+ actor_type = self._infer_actor_type(token.claims, roles)
72
+
73
+ return User(
74
+ id=sub,
75
+ email=email,
76
+ roles=list(set(roles)), # Deduplicate
77
+ scopes=scopes,
78
+ actor_type=actor_type,
79
+ metadata=token.claims,
80
+ )
81
+
82
+ def _extract_tenant(self, token: Token) -> TenantContext | None:
83
+ """Extract tenant context from the token.
84
+
85
+ Uses the configured tenant_id_claim. Falls back to the
86
+ configured tenant_fallback strategy if the claim is missing.
87
+ """
88
+ tid = token.claims.get(self.claims.tenant_id_claim)
89
+
90
+ if not tid and self.claims.tenant_fallback == "sub":
91
+ tid = token.subject
92
+
93
+ if tid:
94
+ return TenantContext(tenant_id=tid, metadata={})
95
+
96
+ return None
97
+
98
+ def _infer_actor_type(self, claims: dict, roles: list) -> str: # type: ignore[type-arg]
99
+ """Infer actor type from token claims.
100
+
101
+ Heuristic:
102
+ - If 'client_id' == 'sub' (client credentials flow) → "service" or "agent"
103
+ - Default → "user"
104
+ """
105
+ sub = claims.get("sub", "")
106
+ client_id = claims.get("client_id", claims.get("azp", ""))
107
+
108
+ # Client credentials flow: sub == client_id (no human user)
109
+ if client_id and sub == client_id:
110
+ if "agent" in roles:
111
+ return "agent"
112
+ return "service"
113
+
114
+ return "user"
@@ -0,0 +1,154 @@
1
+ from typing import Any
2
+
3
+ from open_shield.domain.entities import Token
4
+ from open_shield.domain.ports import TokenValidatorPort
5
+ from open_shield.domain.services import TokenService
6
+ from open_shield.domain.services.claim_mapping import ClaimMapping
7
+
8
+
9
+ class _MockValidator(TokenValidatorPort):
10
+ """Mock validator that returns a token with given claims."""
11
+
12
+ def __init__(self, claims: dict[str, Any]) -> None:
13
+ self._claims = claims
14
+
15
+ def validate_token(self, token_string: str) -> Token:
16
+ return Token(raw=token_string, claims=self._claims)
17
+
18
+ def decode_unverified(self, token_string: str) -> dict[str, Any]:
19
+ return self._claims
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Claim Mapping Tests
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ def test_default_claim_mapping() -> None:
28
+ """Default ClaimMapping reads standard OIDC claims."""
29
+ cm = ClaimMapping()
30
+ assert cm.user_id_claim == "sub"
31
+ assert cm.email_claim == "email"
32
+ assert cm.tenant_id_claim == "tid"
33
+ assert cm.scope_claim == "scope"
34
+ assert cm.roles_claim == "roles"
35
+ assert cm.tenant_fallback == "none"
36
+
37
+
38
+ def test_custom_email_claim() -> None:
39
+ """Email is extracted from a custom claim name."""
40
+ claims = {"sub": "u1", "preferred_username": "alice@corp.com"}
41
+ cm = ClaimMapping(email_claim="preferred_username")
42
+ service = TokenService(_MockValidator(claims), claim_mapping=cm)
43
+
44
+ ctx = service.validate_and_extract("token")
45
+ assert ctx.user.email == "alice@corp.com"
46
+
47
+
48
+ def test_custom_tenant_claim_logto() -> None:
49
+ """Tenant extracted from Logto's organization_id claim."""
50
+ claims = {"sub": "u1", "organization_id": "org_abc"}
51
+ cm = ClaimMapping(tenant_id_claim="organization_id")
52
+ service = TokenService(_MockValidator(claims), claim_mapping=cm)
53
+
54
+ ctx = service.validate_and_extract("token")
55
+ assert ctx.tenant is not None
56
+ assert ctx.tenant.tenant_id == "org_abc"
57
+
58
+
59
+ def test_custom_tenant_claim_keycloak() -> None:
60
+ """Tenant extracted from Keycloak-style custom claim."""
61
+ claims = {"sub": "u1", "tenant": "customer_a"}
62
+ cm = ClaimMapping(tenant_id_claim="tenant")
63
+ service = TokenService(_MockValidator(claims), claim_mapping=cm)
64
+
65
+ ctx = service.validate_and_extract("token")
66
+ assert ctx.tenant is not None
67
+ assert ctx.tenant.tenant_id == "customer_a"
68
+
69
+
70
+ def test_tenant_fallback_to_sub() -> None:
71
+ """When tenant claim is missing and fallback='sub', use user ID."""
72
+ claims = {"sub": "user_123"}
73
+ cm = ClaimMapping(tenant_fallback="sub")
74
+ service = TokenService(_MockValidator(claims), claim_mapping=cm)
75
+
76
+ ctx = service.validate_and_extract("token")
77
+ assert ctx.tenant is not None
78
+ assert ctx.tenant.tenant_id == "user_123"
79
+
80
+
81
+ def test_tenant_fallback_none() -> None:
82
+ """When tenant claim is missing and fallback='none', tenant is None."""
83
+ claims = {"sub": "user_123"}
84
+ cm = ClaimMapping(tenant_fallback="none")
85
+ service = TokenService(_MockValidator(claims), claim_mapping=cm)
86
+
87
+ ctx = service.validate_and_extract("token")
88
+ assert ctx.tenant is None
89
+
90
+
91
+ def test_custom_scope_claim() -> None:
92
+ """Scopes extracted from a custom claim name."""
93
+ claims = {"sub": "u1", "permissions": "read write admin"}
94
+ cm = ClaimMapping(scope_claim="permissions")
95
+ service = TokenService(_MockValidator(claims), claim_mapping=cm)
96
+
97
+ ctx = service.validate_and_extract("token")
98
+ assert ctx.user.scopes == ["read", "write", "admin"]
99
+
100
+
101
+ def test_custom_roles_claim() -> None:
102
+ """Roles extracted from a custom claim name."""
103
+ claims = {"sub": "u1", "app_roles": ["editor", "viewer"]}
104
+ cm = ClaimMapping(roles_claim="app_roles")
105
+ service = TokenService(_MockValidator(claims), claim_mapping=cm)
106
+
107
+ ctx = service.validate_and_extract("token")
108
+ assert "editor" in ctx.user.roles
109
+ assert "viewer" in ctx.user.roles
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Actor Type Inference Tests
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ def test_actor_type_user_default() -> None:
118
+ """Default actor type is 'user' for normal JWT tokens."""
119
+ claims = {"sub": "user_123", "email": "user@test.com"}
120
+ service = TokenService(_MockValidator(claims))
121
+
122
+ ctx = service.validate_and_extract("token")
123
+ assert ctx.user.actor_type == "user"
124
+
125
+
126
+ def test_actor_type_service_m2m() -> None:
127
+ """M2M token (sub == client_id) → 'service'."""
128
+ claims = {"sub": "client_abc", "client_id": "client_abc"}
129
+ service = TokenService(_MockValidator(claims))
130
+
131
+ ctx = service.validate_and_extract("token")
132
+ assert ctx.user.actor_type == "service"
133
+
134
+
135
+ def test_actor_type_agent_m2m() -> None:
136
+ """M2M token with 'agent' role → 'agent'."""
137
+ claims = {
138
+ "sub": "agent_xyz",
139
+ "client_id": "agent_xyz",
140
+ "roles": ["agent"],
141
+ }
142
+ service = TokenService(_MockValidator(claims))
143
+
144
+ ctx = service.validate_and_extract("token")
145
+ assert ctx.user.actor_type == "agent"
146
+
147
+
148
+ def test_actor_type_azp_fallback() -> None:
149
+ """azp claim used when client_id is absent."""
150
+ claims = {"sub": "svc_001", "azp": "svc_001"}
151
+ service = TokenService(_MockValidator(claims))
152
+
153
+ ctx = service.validate_and_extract("token")
154
+ assert ctx.user.actor_type == "service"
@@ -1,19 +0,0 @@
1
- # Agent Rules for Open Shield
2
-
3
- These rules apply to any AI agent working on this codebase.
4
-
5
- ## 🛡️ Critical Workflows
6
-
7
- 1. **Verify Before Push**:
8
- - You MUST run `./scripts/verify.sh` or use the `/safe-commit` workflow before pushing any code to `main` or `staging`.
9
- - If verification fails, **DO NOT** push. Fix the errors first.
10
-
11
- 2. **Clean Architecture**:
12
- - Respect the dependency rule: `Domain` <- `Adapters` <- `App/API`.
13
- - Domain entities/ports must NOT import from adapters or external frameworks (except generic types).
14
-
15
- 3. **Conventional Commits**:
16
- - Use strict conventional commits (`feat:`, `fix:`, `chore:`, `refactor:`, `docs:`).
17
-
18
- 4. **Self-Correction**:
19
- - If a tool fails (e.g., `ruff` finds errors), attempt to fix it automatically using the tool's suggestions (e.g., `ruff check --fix`) before asking the user, unless the fix is ambiguous.