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.
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/CONTRIBUTING.md +3 -10
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/PKG-INFO +3 -14
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/README.md +2 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/tasks.md +4 -10
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/pyproject.toml +1 -22
- open_shield_python-0.2.1/src/open_shield/adapters/config.py +49 -0
- open_shield_python-0.2.1/src/open_shield/api/fastapi/__init__.py +15 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/api/fastapi/dependencies.py +20 -4
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/api/fastapi/middleware.py +20 -13
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/domain/entities.py +4 -0
- open_shield_python-0.2.1/src/open_shield/domain/ports/__init__.py +5 -0
- open_shield_python-0.2.1/src/open_shield/domain/ports/tenant_resolver.py +37 -0
- open_shield_python-0.2.1/src/open_shield/domain/services/__init__.py +5 -0
- open_shield_python-0.2.1/src/open_shield/domain/services/claim_mapping.py +44 -0
- open_shield_python-0.2.1/src/open_shield/domain/services/token_service.py +151 -0
- open_shield_python-0.2.1/tests/unit/domain/test_claim_mapping.py +247 -0
- open_shield_python-0.1.0/.agent/rules.md +0 -19
- open_shield_python-0.1.0/.agent/workflows/safe-commit.md +0 -27
- open_shield_python-0.1.0/docs/planning/phases/phase-4-logto-integration.md +0 -73
- open_shield_python-0.1.0/scripts/verify.sh +0 -17
- open_shield_python-0.1.0/src/open_shield/adapters/config.py +0 -26
- open_shield_python-0.1.0/src/open_shield/api/fastapi/__init__.py +0 -4
- open_shield_python-0.1.0/src/open_shield/domain/ports/__init__.py +0 -4
- open_shield_python-0.1.0/src/open_shield/domain/services/__init__.py +0 -4
- open_shield_python-0.1.0/src/open_shield/domain/services/token_service.py +0 -83
- open_shield_python-0.1.0/tests/integration/auth_flow/test_logto_integration.py +0 -72
- open_shield_python-0.1.0/tests/integration/conftest.py +0 -119
- open_shield_python-0.1.0/tests/integration/infrastructure/docker-compose.logto.yml +0 -34
- open_shield_python-0.1.0/tests/integration/infrastructure/setup_logto.py +0 -101
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/python-packaging/SKILL.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/python-packaging/resources/implementation-playbook.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/python-pro/SKILL.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/python-testing-patterns/SKILL.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/python-testing-patterns/resources/implementation-playbook.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/SKILL.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/examples/before_after_refactor.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/examples/clean_architecture_layout.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/examples/dip_ports_adapters.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.agent/skills/solid_architecture/resources/code_review_checklist.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.github/workflows/ci.yml +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/.gitignore +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/CHANGELOG.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/LICENSE +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/None +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/architecture/system-overview.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/design/overview.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/implementation/guidelines.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/README.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/backlog.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/decisions/0000-template.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/decisions/001-clean-architecture.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/decisions/002-modern-tooling.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/decisions/003-pydantic-usage.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-0-init.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-1-core.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-2-authz.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/phases/phase-3-integration.md +0 -0
- /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
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/roadmap.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/specs.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/docs/planning/tech-debt.md +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/main.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/adapters/__init__.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/adapters/key_provider.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/adapters/token_validator.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/api/__init__.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/domain/__init__.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/domain/exceptions.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/domain/ports/key_provider.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/domain/ports/token_validator.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/domain/services/authorization_service.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/tests/integration/adapters/test_key_provider.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/tests/integration/adapters/test_token_validator.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/tests/integration/api/test_fastapi.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/tests/unit/adapters/test_config.py +0 -0
- {open_shield_python-0.1.0 → open_shield_python-0.2.1}/tests/unit/domain/test_authorization_service.py +0 -0
- {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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
+
[](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
|
+
[](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
|
-
- [
|
|
23
|
-
- [x]
|
|
24
|
-
- [x]
|
|
25
|
-
- [x]
|
|
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
|
|
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
|
+
]
|
{open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/api/fastapi/dependencies.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
{open_shield_python-0.1.0 → open_shield_python-0.2.1}/src/open_shield/api/fastapi/middleware.py
RENAMED
|
@@ -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
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
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,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,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"
|