altissimo-auth 1.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.
- altissimo/auth/__init__.py +43 -0
- altissimo/auth/cascade.py +43 -0
- altissimo/auth/core/__init__.py +1 -0
- altissimo/auth/core/exceptions.py +36 -0
- altissimo/auth/core/models.py +146 -0
- altissimo/auth/core/policies.py +46 -0
- altissimo/auth/core/telemetry.py +42 -0
- altissimo/auth/fastapi/__init__.py +412 -0
- altissimo/auth/flask/__init__.py +235 -0
- altissimo/auth/ninja/__init__.py +366 -0
- altissimo/auth/providers/__init__.py +24 -0
- altissimo/auth/providers/api_key.py +42 -0
- altissimo/auth/providers/firebase.py +85 -0
- altissimo/auth/providers/google.py +63 -0
- altissimo/auth/providers/iap.py +41 -0
- altissimo/auth/providers/jwt.py +95 -0
- altissimo/auth/providers/oidc.py +126 -0
- altissimo/auth/providers/webhooks.py +68 -0
- altissimo/auth/py.typed +0 -0
- altissimo/auth/service.py +181 -0
- altissimo/auth/testing.py +383 -0
- altissimo_auth-1.1.0.dist-info/METADATA +306 -0
- altissimo_auth-1.1.0.dist-info/RECORD +25 -0
- altissimo_auth-1.1.0.dist-info/WHEEL +4 -0
- altissimo_auth-1.1.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Altissimo Auth — reusable, framework-agnostic authentication library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
from .core.exceptions import (
|
|
8
|
+
AuthError,
|
|
9
|
+
AuthForbiddenError,
|
|
10
|
+
AuthNotFoundError,
|
|
11
|
+
AuthUnauthorizedError,
|
|
12
|
+
GoogleTokenVerificationError,
|
|
13
|
+
)
|
|
14
|
+
from .core.models import (
|
|
15
|
+
APIKeyRecord,
|
|
16
|
+
AuthReasonCode,
|
|
17
|
+
AuthSource,
|
|
18
|
+
FirebaseUser,
|
|
19
|
+
GoogleTokenInfo,
|
|
20
|
+
GoogleUser,
|
|
21
|
+
IAPIdentity,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
__version__ = version("altissimo-auth")
|
|
26
|
+
except PackageNotFoundError:
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"APIKeyRecord",
|
|
31
|
+
"AuthError",
|
|
32
|
+
"AuthForbiddenError",
|
|
33
|
+
"AuthNotFoundError",
|
|
34
|
+
"AuthReasonCode",
|
|
35
|
+
"AuthSource",
|
|
36
|
+
"AuthUnauthorizedError",
|
|
37
|
+
"FirebaseUser",
|
|
38
|
+
"GoogleTokenInfo",
|
|
39
|
+
"GoogleTokenVerificationError",
|
|
40
|
+
"GoogleUser",
|
|
41
|
+
"IAPIdentity",
|
|
42
|
+
"__version__",
|
|
43
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Auth Cascade — multi-method fallback for Django Ninja."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthCascade:
|
|
11
|
+
"""Try multiple auth methods sequentially, returning the first success.
|
|
12
|
+
|
|
13
|
+
This is useful for endpoints that accept multiple forms of authentication
|
|
14
|
+
(e.g., Firebase ID token, Google OAuth2 token, or API key) and need to
|
|
15
|
+
return the specific model for whichever method succeeded.
|
|
16
|
+
|
|
17
|
+
Usage with Django Ninja:
|
|
18
|
+
cascade = AuthCascade([
|
|
19
|
+
FirebaseAuth(),
|
|
20
|
+
GoogleAuth(),
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
@api.get("/info", auth=cascade)
|
|
24
|
+
def info(request):
|
|
25
|
+
user = request.auth
|
|
26
|
+
...
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, auth_methods: list[Callable]) -> None:
|
|
30
|
+
"""Initialize with a list of callable auth methods."""
|
|
31
|
+
self.auth_methods = auth_methods
|
|
32
|
+
|
|
33
|
+
def __call__(self, request: Any) -> Any | None:
|
|
34
|
+
"""Try each method in sequence. Return the first non-None result."""
|
|
35
|
+
for method in self.auth_methods:
|
|
36
|
+
try:
|
|
37
|
+
result = method(request)
|
|
38
|
+
if result is not None:
|
|
39
|
+
return result
|
|
40
|
+
except Exception as e:
|
|
41
|
+
logger.debug("AuthCascade method %s failed: %s", method.__class__.__name__, e)
|
|
42
|
+
continue
|
|
43
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core auth models, exceptions, and utilities."""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Auth exceptions.
|
|
2
|
+
|
|
3
|
+
All exceptions carry a machine-readable `reason_code` that adapters can use
|
|
4
|
+
for telemetry and structured error responses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .models import AuthReasonCode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthError(Exception):
|
|
16
|
+
"""Base auth exception."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, *, reason_code: AuthReasonCode) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.reason_code = reason_code
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthUnauthorizedError(AuthError):
|
|
24
|
+
"""Authentication failure (HTTP 401)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthForbiddenError(AuthError):
|
|
28
|
+
"""Authorization failure (HTTP 403)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthNotFoundError(AuthError):
|
|
32
|
+
"""Authenticated subject cannot be mapped (HTTP 404)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GoogleTokenVerificationError(Exception):
|
|
36
|
+
"""Raised when Google OAuth2 token verification fails."""
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Auth models.
|
|
2
|
+
|
|
3
|
+
Framework-agnostic Pydantic models shared across all auth providers and adapters.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthSource(StrEnum):
|
|
14
|
+
"""Supported auth sources."""
|
|
15
|
+
|
|
16
|
+
API_KEY = "api_key"
|
|
17
|
+
FIREBASE = "firebase"
|
|
18
|
+
GOOGLE = "google"
|
|
19
|
+
IAP = "iap"
|
|
20
|
+
SERVICE_ACCOUNT = "service_account"
|
|
21
|
+
WEBHOOK = "webhook"
|
|
22
|
+
JWT = "jwt"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuthReasonCode(StrEnum):
|
|
26
|
+
"""Stable reason codes for auth decisions."""
|
|
27
|
+
|
|
28
|
+
OK = "ok"
|
|
29
|
+
|
|
30
|
+
# API key
|
|
31
|
+
MISSING_API_KEY = "missing_api_key"
|
|
32
|
+
INVALID_API_KEY = "invalid_api_key"
|
|
33
|
+
|
|
34
|
+
# Google OAuth2
|
|
35
|
+
INVALID_GOOGLE_TOKEN = "invalid_google_token"
|
|
36
|
+
|
|
37
|
+
# Firebase
|
|
38
|
+
INVALID_FIREBASE_TOKEN = "invalid_firebase_token"
|
|
39
|
+
EXPIRED_FIREBASE_TOKEN = "expired_firebase_token"
|
|
40
|
+
USER_DISABLED = "user_disabled"
|
|
41
|
+
|
|
42
|
+
# Authorization
|
|
43
|
+
NOT_ADMIN = "not_admin"
|
|
44
|
+
INSUFFICIENT_CLAIMS = "insufficient_claims"
|
|
45
|
+
|
|
46
|
+
# OIDC service account
|
|
47
|
+
INVALID_OIDC_TOKEN = "invalid_oidc_token"
|
|
48
|
+
UNAUTHORIZED_CALLER = "unauthorized_caller"
|
|
49
|
+
AUTH_SERVICE_UNAVAILABLE = "auth_service_unavailable"
|
|
50
|
+
|
|
51
|
+
# Webhooks
|
|
52
|
+
INVALID_WEBHOOK_SIGNATURE = "invalid_webhook_signature"
|
|
53
|
+
INVALID_WEBHOOK_PAYLOAD = "invalid_webhook_payload"
|
|
54
|
+
|
|
55
|
+
# JWT
|
|
56
|
+
INVALID_JWT = "invalid_jwt"
|
|
57
|
+
EXPIRED_JWT = "expired_jwt"
|
|
58
|
+
INVALID_JWT_ISSUER = "invalid_jwt_issuer"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GoogleTokenInfo(BaseModel):
|
|
62
|
+
"""Google OAuth2 ID token payload."""
|
|
63
|
+
|
|
64
|
+
iss: str
|
|
65
|
+
azp: str
|
|
66
|
+
aud: str
|
|
67
|
+
sub: str
|
|
68
|
+
hd: str | None = None
|
|
69
|
+
email: EmailStr
|
|
70
|
+
email_verified: bool
|
|
71
|
+
at_hash: str | None = None
|
|
72
|
+
nbf: datetime | None = None
|
|
73
|
+
iat: datetime
|
|
74
|
+
exp: datetime
|
|
75
|
+
jlt: str | None = None
|
|
76
|
+
alg: str | None = None
|
|
77
|
+
kid: str | None = None
|
|
78
|
+
typ: str | None = None
|
|
79
|
+
|
|
80
|
+
model_config = ConfigDict(extra="allow")
|
|
81
|
+
|
|
82
|
+
@field_validator("email", mode="before")
|
|
83
|
+
@classmethod
|
|
84
|
+
def lowercase_email(cls, value: Any) -> Any:
|
|
85
|
+
"""Normalize email casing."""
|
|
86
|
+
return value.lower() if isinstance(value, str) else value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class GoogleUser(BaseModel):
|
|
90
|
+
"""Authenticated Google API user."""
|
|
91
|
+
|
|
92
|
+
id: str
|
|
93
|
+
email: EmailStr
|
|
94
|
+
hd: str | None = None
|
|
95
|
+
admin: bool = False
|
|
96
|
+
|
|
97
|
+
model_config = ConfigDict(extra="allow")
|
|
98
|
+
|
|
99
|
+
@field_validator("email", mode="before")
|
|
100
|
+
@classmethod
|
|
101
|
+
def lowercase_email(cls, value: Any) -> Any:
|
|
102
|
+
"""Normalize email casing."""
|
|
103
|
+
return value.lower() if isinstance(value, str) else value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class IAPIdentity(BaseModel):
|
|
107
|
+
"""Identity extracted from Google IAP headers."""
|
|
108
|
+
|
|
109
|
+
email: str | None = None
|
|
110
|
+
user_id: str | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class FirebaseUser(BaseModel):
|
|
114
|
+
"""Firebase user record.
|
|
115
|
+
|
|
116
|
+
Pydantic representation of a firebase_admin UserRecord, decoupled from
|
|
117
|
+
the firebase-admin SDK so consumers don't need to depend on it directly.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
uid: str
|
|
121
|
+
email: str | None = None
|
|
122
|
+
email_verified: bool = False
|
|
123
|
+
disabled: bool = False
|
|
124
|
+
display_name: str | None = None
|
|
125
|
+
phone_number: str | None = None
|
|
126
|
+
photo_url: str | None = None
|
|
127
|
+
provider_id: str | None = None
|
|
128
|
+
tenant_id: str | None = None
|
|
129
|
+
tokens_valid_after_timestamp: int | str | None = None
|
|
130
|
+
custom_claims: dict[str, Any] | None = None
|
|
131
|
+
provider_data: list[dict[str, Any]] = Field(default_factory=list)
|
|
132
|
+
|
|
133
|
+
model_config = ConfigDict(extra="allow")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class APIKeyRecord(BaseModel):
|
|
137
|
+
"""Represents a validated API key.
|
|
138
|
+
|
|
139
|
+
Consumers should subclass or wrap this model to add project-specific fields
|
|
140
|
+
(e.g. scopes, rate limits, owner references).
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
id: str
|
|
144
|
+
"""The API key identifier."""
|
|
145
|
+
|
|
146
|
+
model_config = ConfigDict(extra="allow")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Authorization policy checks.
|
|
2
|
+
|
|
3
|
+
Stateless helpers that raise `AuthForbiddenError` when a principal does
|
|
4
|
+
not satisfy the required authorization level.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .exceptions import AuthForbiddenError, AuthUnauthorizedError
|
|
12
|
+
from .models import AuthReasonCode, FirebaseUser, GoogleUser
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AuthPolicyService:
|
|
16
|
+
"""Authorization policy checks."""
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def require_google_admin(user: GoogleUser) -> GoogleUser:
|
|
20
|
+
"""Require Google user to be admin."""
|
|
21
|
+
if not user.admin:
|
|
22
|
+
raise AuthForbiddenError("Forbidden", reason_code=AuthReasonCode.NOT_ADMIN)
|
|
23
|
+
return user
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def require_firebase_admin(user: FirebaseUser) -> FirebaseUser:
|
|
27
|
+
"""Require Firebase user to have admin custom claim."""
|
|
28
|
+
if not (user.custom_claims and user.custom_claims.get("admin")):
|
|
29
|
+
raise AuthForbiddenError("Forbidden", reason_code=AuthReasonCode.NOT_ADMIN)
|
|
30
|
+
return user
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def require_api_key_or_user(api_key_present: bool, user_present: bool) -> None:
|
|
34
|
+
"""Require either API key or authenticated user."""
|
|
35
|
+
if not api_key_present and not user_present:
|
|
36
|
+
raise AuthUnauthorizedError(
|
|
37
|
+
"API Key or authenticated user required",
|
|
38
|
+
reason_code=AuthReasonCode.MISSING_API_KEY,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def require_claim(user: FirebaseUser, claim: str, expected: Any = True) -> FirebaseUser:
|
|
43
|
+
"""Require Firebase user to have a specific custom claim value."""
|
|
44
|
+
if not user.custom_claims or user.custom_claims.get(claim) != expected:
|
|
45
|
+
raise AuthForbiddenError("Forbidden", reason_code=AuthReasonCode.INSUFFICIENT_CLAIMS)
|
|
46
|
+
return user
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Structured auth event logging.
|
|
2
|
+
|
|
3
|
+
Provides a single `log_auth_event` helper that emits structured JSON log
|
|
4
|
+
entries. Consumers can replace the logger or add handlers as needed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from .models import AuthReasonCode
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("altissimo.auth")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def log_auth_event(
|
|
17
|
+
*,
|
|
18
|
+
event: str,
|
|
19
|
+
auth_source: str,
|
|
20
|
+
route: str,
|
|
21
|
+
method: str,
|
|
22
|
+
status_code: int,
|
|
23
|
+
reason_code: AuthReasonCode | str,
|
|
24
|
+
principal_id: str | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Emit a structured auth log event."""
|
|
27
|
+
normalized = reason_code.value if isinstance(reason_code, AuthReasonCode) else reason_code
|
|
28
|
+
logger.info(
|
|
29
|
+
"auth event: %s",
|
|
30
|
+
event,
|
|
31
|
+
extra={
|
|
32
|
+
"json_fields": {
|
|
33
|
+
"event": event,
|
|
34
|
+
"auth_source": auth_source,
|
|
35
|
+
"route": route,
|
|
36
|
+
"method": method,
|
|
37
|
+
"status_code": status_code,
|
|
38
|
+
"reason_code": normalized,
|
|
39
|
+
"principal_id": principal_id,
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
)
|