devlab-auth 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devlab_auth/__init__.py +33 -0
- devlab_auth/client.py +50 -0
- devlab_auth/fastapi.py +93 -0
- devlab_auth/jwks.py +63 -0
- devlab_auth/m2m.py +102 -0
- devlab_auth/middleware.py +52 -0
- devlab_auth/models.py +46 -0
- devlab_auth/py.typed +1 -0
- devlab_auth-0.1.0.dist-info/METADATA +74 -0
- devlab_auth-0.1.0.dist-info/RECORD +11 -0
- devlab_auth-0.1.0.dist-info/WHEEL +4 -0
devlab_auth/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""DevLab Authentication SDK for Python (FastAPI, LangChain, Agents)."""
|
|
2
|
+
|
|
3
|
+
from devlab_auth.client import DevLabAuth
|
|
4
|
+
from devlab_auth.jwks import JwksValidator
|
|
5
|
+
from devlab_auth.m2m import M2mApiKeyValidator
|
|
6
|
+
from devlab_auth.models import AuthPrincipal, M2mValidationResult
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AuthPrincipal",
|
|
10
|
+
"DevLabAuth",
|
|
11
|
+
"JwksValidator",
|
|
12
|
+
"M2mApiKeyValidator",
|
|
13
|
+
"M2mValidationResult",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from devlab_auth.fastapi import (
|
|
18
|
+
get_current_principal,
|
|
19
|
+
require_role,
|
|
20
|
+
require_scope,
|
|
21
|
+
set_devlab_auth,
|
|
22
|
+
)
|
|
23
|
+
from devlab_auth.middleware import DevLabAuthMiddleware
|
|
24
|
+
|
|
25
|
+
__all__ += [
|
|
26
|
+
"DevLabAuthMiddleware",
|
|
27
|
+
"get_current_principal",
|
|
28
|
+
"require_role",
|
|
29
|
+
"require_scope",
|
|
30
|
+
"set_devlab_auth",
|
|
31
|
+
]
|
|
32
|
+
except ImportError:
|
|
33
|
+
pass
|
devlab_auth/client.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""High-level DevLabAuth orchestrator combining JWKS and M2M validation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from devlab_auth.jwks import JwksValidator
|
|
6
|
+
from devlab_auth.m2m import M2mApiKeyValidator
|
|
7
|
+
from devlab_auth.models import AuthPrincipal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DevLabAuth:
|
|
11
|
+
"""Primary entry point for DevLab authentication verification."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
portal_url: str | None = None,
|
|
16
|
+
required_app_id: str | None = None,
|
|
17
|
+
jwks_cache_ttl: int = 3600,
|
|
18
|
+
m2m_cache_ttl: int = 300,
|
|
19
|
+
):
|
|
20
|
+
self.portal_url = (
|
|
21
|
+
portal_url or os.getenv("DEVLAB_PORTAL_API_URL") or "http://localhost:4000"
|
|
22
|
+
)
|
|
23
|
+
self.jwks_validator = JwksValidator(
|
|
24
|
+
portal_url=self.portal_url, cache_ttl_seconds=jwks_cache_ttl
|
|
25
|
+
)
|
|
26
|
+
self.m2m_validator = M2mApiKeyValidator(
|
|
27
|
+
portal_url=self.portal_url,
|
|
28
|
+
cache_ttl_seconds=m2m_cache_ttl,
|
|
29
|
+
required_app_id=required_app_id,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
async def authenticate_async(self, credential: str) -> AuthPrincipal:
|
|
33
|
+
"""Dispatches credential to M2M or JWKS validator asynchronously."""
|
|
34
|
+
cred = credential.strip()
|
|
35
|
+
if cred.startswith("Bearer "):
|
|
36
|
+
cred = cred[7:].strip()
|
|
37
|
+
|
|
38
|
+
if cred.startswith("dlk_"):
|
|
39
|
+
return await self.m2m_validator.validate_key_async(cred)
|
|
40
|
+
return self.jwks_validator.validate_token(cred)
|
|
41
|
+
|
|
42
|
+
def authenticate_sync(self, credential: str) -> AuthPrincipal:
|
|
43
|
+
"""Dispatches credential to M2M or JWKS validator synchronously."""
|
|
44
|
+
cred = credential.strip()
|
|
45
|
+
if cred.startswith("Bearer "):
|
|
46
|
+
cred = cred[7:].strip()
|
|
47
|
+
|
|
48
|
+
if cred.startswith("dlk_"):
|
|
49
|
+
return self.m2m_validator.validate_key_sync(cred)
|
|
50
|
+
return self.jwks_validator.validate_token(cred)
|
devlab_auth/fastapi.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""FastAPI dependencies and security guards."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Coroutine
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import Header, HTTPException, Security, status
|
|
7
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
8
|
+
|
|
9
|
+
from devlab_auth.client import DevLabAuth
|
|
10
|
+
from devlab_auth.models import AuthPrincipal
|
|
11
|
+
|
|
12
|
+
security = HTTPBearer(auto_error=False)
|
|
13
|
+
_default_auth: DevLabAuth | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_devlab_auth() -> DevLabAuth:
|
|
17
|
+
"""Resolves or initializes the global DevLabAuth singleton."""
|
|
18
|
+
global _default_auth
|
|
19
|
+
if _default_auth is None:
|
|
20
|
+
_default_auth = DevLabAuth()
|
|
21
|
+
return _default_auth
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def set_devlab_auth(auth_instance: DevLabAuth) -> None:
|
|
25
|
+
"""Configures a custom DevLabAuth instance globally."""
|
|
26
|
+
global _default_auth
|
|
27
|
+
_default_auth = auth_instance
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def get_current_principal(
|
|
31
|
+
credentials: HTTPAuthorizationCredentials | None = Security(security),
|
|
32
|
+
x_api_key: str | None = Header(None, alias="x-api-key"),
|
|
33
|
+
x_devlab_key: str | None = Header(None, alias="x-devlab-key"),
|
|
34
|
+
) -> AuthPrincipal:
|
|
35
|
+
"""Universal FastAPI dependency: resolves user JWT or M2M key."""
|
|
36
|
+
token = None
|
|
37
|
+
if credentials and credentials.credentials:
|
|
38
|
+
token = credentials.credentials.strip()
|
|
39
|
+
elif x_api_key:
|
|
40
|
+
token = x_api_key.strip()
|
|
41
|
+
elif x_devlab_key:
|
|
42
|
+
token = x_devlab_key.strip()
|
|
43
|
+
|
|
44
|
+
if not token:
|
|
45
|
+
raise HTTPException(
|
|
46
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
47
|
+
detail="Missing authentication credentials (Bearer token or x-api-key)",
|
|
48
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
auth = get_devlab_auth()
|
|
52
|
+
try:
|
|
53
|
+
return await auth.authenticate_async(token)
|
|
54
|
+
except PermissionError as pe:
|
|
55
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(pe)) from pe
|
|
56
|
+
except Exception as e:
|
|
57
|
+
raise HTTPException(
|
|
58
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
59
|
+
detail=f"Authentication failed: {e!s}",
|
|
60
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
61
|
+
) from e
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def require_role(*roles: str) -> Callable[..., Coroutine[Any, Any, AuthPrincipal]]:
|
|
65
|
+
"""FastAPI dependency factory enforcing specified roles."""
|
|
66
|
+
|
|
67
|
+
async def role_checker(
|
|
68
|
+
principal: AuthPrincipal = Security(get_current_principal),
|
|
69
|
+
) -> AuthPrincipal:
|
|
70
|
+
if not principal.has_role(*roles):
|
|
71
|
+
raise HTTPException(
|
|
72
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
73
|
+
detail=f"Insufficient role permissions. Required: {', '.join(roles)}",
|
|
74
|
+
)
|
|
75
|
+
return principal
|
|
76
|
+
|
|
77
|
+
return role_checker
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def require_scope(scope: str) -> Callable[..., Coroutine[Any, Any, AuthPrincipal]]:
|
|
81
|
+
"""FastAPI dependency factory enforcing specified permission scope."""
|
|
82
|
+
|
|
83
|
+
async def scope_checker(
|
|
84
|
+
principal: AuthPrincipal = Security(get_current_principal),
|
|
85
|
+
) -> AuthPrincipal:
|
|
86
|
+
if not principal.has_scope(scope):
|
|
87
|
+
raise HTTPException(
|
|
88
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
89
|
+
detail=f"Missing required permission scope: '{scope}'",
|
|
90
|
+
)
|
|
91
|
+
return principal
|
|
92
|
+
|
|
93
|
+
return scope_checker
|
devlab_auth/jwks.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""RS256 JWKS token validation against DevLab Portal."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import jwt
|
|
7
|
+
from jwt import PyJWKClient
|
|
8
|
+
|
|
9
|
+
from devlab_auth.models import AuthPrincipal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JwksValidator:
|
|
13
|
+
"""Cryptographic RS256 JWT validator using DevLab Portal JWKS endpoint."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
portal_url: str | None = None,
|
|
18
|
+
jwks_uri: str | None = None,
|
|
19
|
+
cache_ttl_seconds: int = 3600,
|
|
20
|
+
):
|
|
21
|
+
base_url = portal_url or os.getenv("DEVLAB_PORTAL_API_URL") or "http://localhost:4000"
|
|
22
|
+
self.jwks_uri = jwks_uri or f"{base_url.rstrip('/')}/.well-known/jwks.json"
|
|
23
|
+
self._client = PyJWKClient(self.jwks_uri, cache_jwk_set=True, lifespan=cache_ttl_seconds)
|
|
24
|
+
|
|
25
|
+
def validate_token(self, token: str) -> AuthPrincipal:
|
|
26
|
+
"""Validates an RS256 JWT and returns the parsed AuthPrincipal."""
|
|
27
|
+
signing_key = self._client.get_signing_key_from_jwt(token)
|
|
28
|
+
payload: dict[str, Any] = jwt.decode(
|
|
29
|
+
token,
|
|
30
|
+
signing_key.key,
|
|
31
|
+
algorithms=["RS256"],
|
|
32
|
+
options={"verify_aud": False},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
sub = str(payload.get("sub") or payload.get("id") or "")
|
|
36
|
+
email = payload.get("email")
|
|
37
|
+
name = payload.get("name")
|
|
38
|
+
role = payload.get("role")
|
|
39
|
+
tenant_id = payload.get("tenant_id")
|
|
40
|
+
app_id = payload.get("app_id")
|
|
41
|
+
|
|
42
|
+
raw_scopes = payload.get("scopes") or []
|
|
43
|
+
if isinstance(raw_scopes, str):
|
|
44
|
+
scopes = [raw_scopes]
|
|
45
|
+
elif isinstance(raw_scopes, list):
|
|
46
|
+
scopes = [str(s) for s in raw_scopes]
|
|
47
|
+
else:
|
|
48
|
+
scopes = []
|
|
49
|
+
|
|
50
|
+
if role and role not in scopes:
|
|
51
|
+
scopes.append(role)
|
|
52
|
+
|
|
53
|
+
return AuthPrincipal(
|
|
54
|
+
id=sub,
|
|
55
|
+
email=email,
|
|
56
|
+
name=name,
|
|
57
|
+
role=role,
|
|
58
|
+
tenant_id=tenant_id,
|
|
59
|
+
app_id=app_id,
|
|
60
|
+
scopes=scopes,
|
|
61
|
+
is_m2m=False,
|
|
62
|
+
metadata=payload,
|
|
63
|
+
)
|
devlab_auth/m2m.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Machine-to-Machine (M2M) API key validation against DevLab Portal."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from cachetools import TTLCache
|
|
8
|
+
|
|
9
|
+
from devlab_auth.models import AuthPrincipal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class M2mApiKeyValidator:
|
|
13
|
+
"""Validates DevLab M2M API keys (dlk_...) with in-memory caching."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
portal_url: str | None = None,
|
|
18
|
+
cache_ttl_seconds: int = 300,
|
|
19
|
+
cache_maxsize: int = 1000,
|
|
20
|
+
required_app_id: str | None = None,
|
|
21
|
+
):
|
|
22
|
+
base_url = portal_url or os.getenv("DEVLAB_PORTAL_API_URL") or "http://localhost:4000"
|
|
23
|
+
self.validate_url = f"{base_url.rstrip('/')}/api/v1/api-keys/validate"
|
|
24
|
+
self.cache: TTLCache[str, AuthPrincipal] = TTLCache(
|
|
25
|
+
maxsize=cache_maxsize, ttl=cache_ttl_seconds
|
|
26
|
+
)
|
|
27
|
+
self.required_app_id = required_app_id
|
|
28
|
+
|
|
29
|
+
async def validate_key_async(self, api_key: str) -> AuthPrincipal:
|
|
30
|
+
"""Asynchronously validates an API key, using cache if available."""
|
|
31
|
+
if api_key in self.cache:
|
|
32
|
+
return self.cache[api_key]
|
|
33
|
+
|
|
34
|
+
async with httpx.AsyncClient(timeout=4.0) as client:
|
|
35
|
+
response = await client.post(
|
|
36
|
+
self.validate_url,
|
|
37
|
+
headers={
|
|
38
|
+
"Authorization": f"Bearer {api_key}",
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
if response.status_code != 200:
|
|
43
|
+
raise ValueError(f"M2M validation failed with status {response.status_code}")
|
|
44
|
+
|
|
45
|
+
data = response.json()
|
|
46
|
+
if not data.get("valid"):
|
|
47
|
+
raise ValueError(data.get("message") or "Invalid API key")
|
|
48
|
+
|
|
49
|
+
principal = self._build_principal(data)
|
|
50
|
+
self._check_app_id(principal)
|
|
51
|
+
self.cache[api_key] = principal
|
|
52
|
+
return principal
|
|
53
|
+
|
|
54
|
+
def validate_key_sync(self, api_key: str) -> AuthPrincipal:
|
|
55
|
+
"""Synchronously validates an API key, using cache if available."""
|
|
56
|
+
if api_key in self.cache:
|
|
57
|
+
return self.cache[api_key]
|
|
58
|
+
|
|
59
|
+
with httpx.Client(timeout=4.0) as client:
|
|
60
|
+
response = client.post(
|
|
61
|
+
self.validate_url,
|
|
62
|
+
headers={
|
|
63
|
+
"Authorization": f"Bearer {api_key}",
|
|
64
|
+
"Content-Type": "application/json",
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
if response.status_code != 200:
|
|
68
|
+
raise ValueError(f"M2M validation failed with status {response.status_code}")
|
|
69
|
+
|
|
70
|
+
data = response.json()
|
|
71
|
+
if not data.get("valid"):
|
|
72
|
+
raise ValueError(data.get("message") or "Invalid API key")
|
|
73
|
+
|
|
74
|
+
principal = self._build_principal(data)
|
|
75
|
+
self._check_app_id(principal)
|
|
76
|
+
self.cache[api_key] = principal
|
|
77
|
+
return principal
|
|
78
|
+
|
|
79
|
+
def _build_principal(self, data: dict[str, Any]) -> AuthPrincipal:
|
|
80
|
+
scopes = data.get("scopes", [])
|
|
81
|
+
if isinstance(scopes, str):
|
|
82
|
+
scopes = [scopes]
|
|
83
|
+
return AuthPrincipal(
|
|
84
|
+
id=str(data.get("keyId") or "m2m-key"),
|
|
85
|
+
name=data.get("name"),
|
|
86
|
+
app_id=data.get("appId"),
|
|
87
|
+
role="service",
|
|
88
|
+
scopes=scopes,
|
|
89
|
+
is_m2m=True,
|
|
90
|
+
metadata=data,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _check_app_id(self, principal: AuthPrincipal) -> None:
|
|
94
|
+
if not self.required_app_id:
|
|
95
|
+
return
|
|
96
|
+
if principal.has_scope("*") or principal.has_scope(f"{self.required_app_id}:*"):
|
|
97
|
+
return
|
|
98
|
+
if principal.app_id and principal.app_id == self.required_app_id:
|
|
99
|
+
return
|
|
100
|
+
raise PermissionError(
|
|
101
|
+
f"API key belonging to '{principal.app_id}' lacks scope for '{self.required_app_id}'"
|
|
102
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Pure ASGI / FastAPI middleware for global request authentication."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Request, Response
|
|
6
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
7
|
+
from starlette.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
from devlab_auth.client import DevLabAuth
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DevLabAuthMiddleware(BaseHTTPMiddleware):
|
|
13
|
+
"""ASGI middleware authenticating requests and injecting 'request.state.principal'."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
app: Any,
|
|
18
|
+
auth: DevLabAuth | None = None,
|
|
19
|
+
public_paths: set[str] | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(app)
|
|
22
|
+
self.auth = auth or DevLabAuth()
|
|
23
|
+
self.public_paths = public_paths or {
|
|
24
|
+
"/health",
|
|
25
|
+
"/ready",
|
|
26
|
+
"/docs",
|
|
27
|
+
"/openapi.json",
|
|
28
|
+
"/redoc",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
32
|
+
if request.url.path in self.public_paths:
|
|
33
|
+
return await call_next(request)
|
|
34
|
+
|
|
35
|
+
header = request.headers.get("Authorization") or request.headers.get("x-api-key")
|
|
36
|
+
if not header:
|
|
37
|
+
return JSONResponse(
|
|
38
|
+
status_code=401,
|
|
39
|
+
content={"error": "Unauthorized: Missing Authorization Bearer token or x-api-key"},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
principal = await self.auth.authenticate_async(header)
|
|
44
|
+
request.state.principal = principal
|
|
45
|
+
return await call_next(request)
|
|
46
|
+
except PermissionError as pe:
|
|
47
|
+
return JSONResponse(status_code=403, content={"error": str(pe)})
|
|
48
|
+
except Exception as e:
|
|
49
|
+
return JSONResponse(
|
|
50
|
+
status_code=401,
|
|
51
|
+
content={"error": f"Authentication rejected: {e!s}"},
|
|
52
|
+
)
|
devlab_auth/models.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Data models for DevLab authentication principals, tokens, and verification results."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthPrincipal(BaseModel):
|
|
9
|
+
"""Represents an authenticated entity in the DevLab ecosystem."""
|
|
10
|
+
|
|
11
|
+
id: str = Field(description="Unique subject or key identifier")
|
|
12
|
+
email: str | None = Field(default=None, description="User email if user session")
|
|
13
|
+
name: str | None = Field(default=None, description="Display name if available")
|
|
14
|
+
role: str | None = Field(default=None, description="User role (operator/admin)")
|
|
15
|
+
tenant_id: str | None = Field(default=None, description="Workspace or tenant identifier")
|
|
16
|
+
app_id: str | None = Field(default=None, description="Originating application identifier")
|
|
17
|
+
scopes: list[str] = Field(default_factory=list, description="Granted permission scopes")
|
|
18
|
+
is_m2m: bool = Field(default=False, description="True if authenticated via M2M API key")
|
|
19
|
+
metadata: dict[str, Any] = Field(default_factory=dict, description="Raw claims metadata")
|
|
20
|
+
|
|
21
|
+
def has_scope(self, required_scope: str) -> bool:
|
|
22
|
+
"""Checks if principal has wildcard '*' or specific scope."""
|
|
23
|
+
if "*" in self.scopes:
|
|
24
|
+
return True
|
|
25
|
+
if required_scope in self.scopes:
|
|
26
|
+
return True
|
|
27
|
+
prefix = required_scope.split(":")[0] + ":*"
|
|
28
|
+
return prefix in self.scopes
|
|
29
|
+
|
|
30
|
+
def has_role(self, *required_roles: str) -> bool:
|
|
31
|
+
"""Checks if principal has one of the required roles."""
|
|
32
|
+
if self.role == "super_admin":
|
|
33
|
+
return True
|
|
34
|
+
return bool(self.role and self.role in required_roles)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class M2mValidationResult(BaseModel):
|
|
38
|
+
"""Result of an M2M API key validation against DevLab Portal."""
|
|
39
|
+
|
|
40
|
+
valid: bool
|
|
41
|
+
key_id: str | None = None
|
|
42
|
+
name: str | None = None
|
|
43
|
+
app_id: str | None = None
|
|
44
|
+
scopes: list[str] = Field(default_factory=list)
|
|
45
|
+
rate_limit_rpm: int | None = None
|
|
46
|
+
error: str | None = None
|
devlab_auth/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561. This package uses inline types.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: devlab-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Universal DevLab Authentication SDK for Python (RS256 JWKS & M2M API Keys with FastAPI guards)
|
|
5
|
+
Author-email: Yuvaraj Pattabi <yuva.devlab@gmail.com>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: cachetools>=5.3.0
|
|
8
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0.0
|
|
10
|
+
Requires-Dist: pyjwt[crypto]>=2.8.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: fastapi>=0.100.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: mypy>=1.10.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
17
|
+
Provides-Extra: fastapi
|
|
18
|
+
Requires-Dist: fastapi>=0.100.0; extra == 'fastapi'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# DevLab Auth — Python SDK
|
|
22
|
+
|
|
23
|
+
Enterprise-grade authentication SDK and FastAPI security guards for the Yuva DevLab platform ecosystem.
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
- **Strict Typing:** 100% type-annotated with `py.typed` marker (PEP 561) and strict mypy compliance.
|
|
27
|
+
- **RS256 JWKS Verification:** Validates DevLab Portal SSO tokens without network overhead using local in-memory public key caching.
|
|
28
|
+
- **M2M API Key Validation:** Authenticates machine-to-machine callers (`dlk_...`) via DevLab Portal API with local TTL caching.
|
|
29
|
+
- **FastAPI Dependency Injection:** Drop-in `Depends(get_current_principal)`, `require_role`, and `require_scope` guards.
|
|
30
|
+
- **ASGI Middleware:** Global route protection via `DevLabAuthMiddleware`.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
```bash
|
|
34
|
+
# Using uv
|
|
35
|
+
uv add devlab-auth
|
|
36
|
+
|
|
37
|
+
# Using pip
|
|
38
|
+
pip install devlab-auth
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quickstart (FastAPI)
|
|
42
|
+
```python
|
|
43
|
+
from fastapi import FastAPI, Depends
|
|
44
|
+
from devlab_auth import AuthPrincipal, get_current_principal, require_role
|
|
45
|
+
|
|
46
|
+
app = FastAPI()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.get("/api/v1/forecast")
|
|
50
|
+
async def get_forecast(principal: AuthPrincipal = Depends(get_current_principal)):
|
|
51
|
+
return {
|
|
52
|
+
"caller_id": principal.id,
|
|
53
|
+
"is_service": principal.is_m2m,
|
|
54
|
+
"role": principal.role,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.post("/api/v1/admin/purge", dependencies=[Depends(require_role("admin", "super_admin"))])
|
|
59
|
+
async def admin_purge():
|
|
60
|
+
return {"status": "purged"}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Development & Verification
|
|
64
|
+
```bash
|
|
65
|
+
# Run test suite
|
|
66
|
+
uv run pytest
|
|
67
|
+
|
|
68
|
+
# Strict lint check & format
|
|
69
|
+
uv run ruff check .
|
|
70
|
+
uv run ruff format --check .
|
|
71
|
+
|
|
72
|
+
# Strict typechecking (zero any, strict mode)
|
|
73
|
+
uv run mypy src
|
|
74
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
devlab_auth/__init__.py,sha256=zv3l22XziBOEQMYUpQ6UAW9qp6hVM1bCmHDi5Sae3js,805
|
|
2
|
+
devlab_auth/client.py,sha256=Rwj2uvcx3MMIXz7HFFaJs9dv1fCN9jNDTrHdFvwRn3I,1780
|
|
3
|
+
devlab_auth/fastapi.py,sha256=hGJZ58q5jZi2cCxYNnqH0QlbGavszm0Wd3N7rnBBEXg,3175
|
|
4
|
+
devlab_auth/jwks.py,sha256=9XzY1BMeoFfts7HzRJgRQuTkkfFpS0EcZaqXwgNsX6c,1954
|
|
5
|
+
devlab_auth/m2m.py,sha256=lp5pC0TDUmqfAv5V6XhGC0MKIgANMslcMD7RUvk0y-Q,3709
|
|
6
|
+
devlab_auth/middleware.py,sha256=eDu-xxLwE39cBd56QG3Fu7DJWCTCYoM01qA3EKhfe2c,1796
|
|
7
|
+
devlab_auth/models.py,sha256=ndExw2GuaZSBu7t0T6oxx-iZ6qZsh6DZ-c30IxKzpJw,1965
|
|
8
|
+
devlab_auth/py.typed,sha256=DtCsIDq6KOv2NOEdQjTbeMWJKRh6ZEL2E-6Mf1RLeMA,59
|
|
9
|
+
devlab_auth-0.1.0.dist-info/METADATA,sha256=d60-tWvjjK-nf-LOY0K4YBjal04XgSMKkUod8-YTh7U,2319
|
|
10
|
+
devlab_auth-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
devlab_auth-0.1.0.dist-info/RECORD,,
|