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.
@@ -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
+ )