pargo-auth 0.1.2__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.
- pargo_auth/__init__.py +17 -0
- pargo_auth/exceptions.py +31 -0
- pargo_auth/middleware.py +206 -0
- pargo_auth/models.py +21 -0
- pargo_auth-0.1.2.dist-info/METADATA +144 -0
- pargo_auth-0.1.2.dist-info/RECORD +8 -0
- pargo_auth-0.1.2.dist-info/WHEEL +4 -0
- pargo_auth-0.1.2.dist-info/licenses/LICENSE +21 -0
pargo_auth/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""PARGO Auth - Shared authentication middleware for PARGO backend services."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.2"
|
|
4
|
+
|
|
5
|
+
from .middleware import SupabaseAuth, get_current_user, require_auth
|
|
6
|
+
from .models import AuthenticatedUser
|
|
7
|
+
from .exceptions import AuthError, InvalidTokenError, ExpiredTokenError
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"SupabaseAuth",
|
|
11
|
+
"get_current_user",
|
|
12
|
+
"require_auth",
|
|
13
|
+
"AuthenticatedUser",
|
|
14
|
+
"AuthError",
|
|
15
|
+
"InvalidTokenError",
|
|
16
|
+
"ExpiredTokenError",
|
|
17
|
+
]
|
pargo_auth/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Authentication exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuthError(Exception):
|
|
5
|
+
"""Base authentication error."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str = "Authentication failed", status_code: int = 401):
|
|
8
|
+
self.message = message
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
super().__init__(self.message)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InvalidTokenError(AuthError):
|
|
14
|
+
"""Token is malformed or signature is invalid."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str = "Invalid token"):
|
|
17
|
+
super().__init__(message, status_code=401)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExpiredTokenError(AuthError):
|
|
21
|
+
"""Token has expired."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str = "Token expired"):
|
|
24
|
+
super().__init__(message, status_code=401)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MissingTokenError(AuthError):
|
|
28
|
+
"""No token provided."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str = "Missing authorization header"):
|
|
31
|
+
super().__init__(message, status_code=401)
|
pargo_auth/middleware.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""FastAPI authentication middleware for Supabase JWT verification."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import jwt
|
|
10
|
+
from fastapi import Request, HTTPException, Depends
|
|
11
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
12
|
+
|
|
13
|
+
from .models import AuthenticatedUser
|
|
14
|
+
from .exceptions import AuthError, InvalidTokenError, ExpiredTokenError, MissingTokenError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Security scheme for OpenAPI docs
|
|
19
|
+
security = HTTPBearer(auto_error=False)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SupabaseAuth:
|
|
23
|
+
"""
|
|
24
|
+
Supabase JWT verification using JWKS.
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
auth = SupabaseAuth(
|
|
28
|
+
supabase_url="https://xxx.supabase.co",
|
|
29
|
+
supabase_anon_key="your-anon-key", # Optional, for audience validation
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@app.get("/protected")
|
|
33
|
+
async def protected(user: AuthenticatedUser = Depends(auth.get_user)):
|
|
34
|
+
return {"user_id": user.sub}
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
supabase_url: Optional[str] = None,
|
|
40
|
+
supabase_anon_key: Optional[str] = None,
|
|
41
|
+
skip_verification_in_dev: bool = True,
|
|
42
|
+
):
|
|
43
|
+
self.supabase_url = supabase_url or os.getenv("SUPABASE_URL")
|
|
44
|
+
self.supabase_anon_key = supabase_anon_key or os.getenv("SUPABASE_ANON_KEY")
|
|
45
|
+
self.skip_verification_in_dev = skip_verification_in_dev
|
|
46
|
+
self._jwks_client: Optional[jwt.PyJWKClient] = None
|
|
47
|
+
|
|
48
|
+
if not self.supabase_url:
|
|
49
|
+
raise ValueError("SUPABASE_URL must be set")
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def jwks_url(self) -> str:
|
|
53
|
+
"""JWKS endpoint for Supabase project."""
|
|
54
|
+
return f"{self.supabase_url}/auth/v1/.well-known/jwks.json"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def issuer(self) -> str:
|
|
58
|
+
"""Expected JWT issuer."""
|
|
59
|
+
return f"{self.supabase_url}/auth/v1"
|
|
60
|
+
|
|
61
|
+
def _get_jwks_client(self) -> jwt.PyJWKClient:
|
|
62
|
+
"""Get or create JWKS client (cached)."""
|
|
63
|
+
if self._jwks_client is None:
|
|
64
|
+
self._jwks_client = jwt.PyJWKClient(
|
|
65
|
+
self.jwks_url,
|
|
66
|
+
cache_keys=True,
|
|
67
|
+
lifespan=3600, # Cache keys for 1 hour
|
|
68
|
+
)
|
|
69
|
+
return self._jwks_client
|
|
70
|
+
|
|
71
|
+
def verify_token(self, token: str) -> AuthenticatedUser:
|
|
72
|
+
"""
|
|
73
|
+
Verify a Supabase JWT and return user info.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
InvalidTokenError: Token is malformed or signature invalid
|
|
77
|
+
ExpiredTokenError: Token has expired
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
# Get signing key from JWKS
|
|
81
|
+
jwks_client = self._get_jwks_client()
|
|
82
|
+
signing_key = jwks_client.get_signing_key_from_jwt(token)
|
|
83
|
+
|
|
84
|
+
# Decode and verify
|
|
85
|
+
payload = jwt.decode(
|
|
86
|
+
token,
|
|
87
|
+
signing_key.key,
|
|
88
|
+
algorithms=["RS256"],
|
|
89
|
+
issuer=self.issuer,
|
|
90
|
+
options={
|
|
91
|
+
"verify_aud": False, # Supabase doesn't always set aud
|
|
92
|
+
"verify_iss": True,
|
|
93
|
+
"verify_exp": True,
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return AuthenticatedUser(
|
|
98
|
+
sub=payload["sub"],
|
|
99
|
+
email=payload.get("email"),
|
|
100
|
+
email_verified=payload.get("email_verified", False),
|
|
101
|
+
raw_claims=payload,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
except jwt.ExpiredSignatureError:
|
|
105
|
+
raise ExpiredTokenError()
|
|
106
|
+
except jwt.InvalidTokenError as e:
|
|
107
|
+
logger.warning(f"Invalid token: {e}")
|
|
108
|
+
raise InvalidTokenError(str(e))
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.error(f"Token verification failed: {e}")
|
|
111
|
+
raise InvalidTokenError("Token verification failed")
|
|
112
|
+
|
|
113
|
+
async def get_user(
|
|
114
|
+
self,
|
|
115
|
+
request: Request,
|
|
116
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
117
|
+
) -> AuthenticatedUser:
|
|
118
|
+
"""
|
|
119
|
+
FastAPI dependency to get authenticated user.
|
|
120
|
+
|
|
121
|
+
Usage:
|
|
122
|
+
@app.get("/me")
|
|
123
|
+
async def get_me(user: AuthenticatedUser = Depends(auth.get_user)):
|
|
124
|
+
return {"id": user.sub}
|
|
125
|
+
"""
|
|
126
|
+
# Skip auth in local development
|
|
127
|
+
if self.skip_verification_in_dev and os.getenv("ENV") == "local":
|
|
128
|
+
return AuthenticatedUser(
|
|
129
|
+
sub="local-dev-user",
|
|
130
|
+
email="dev@local",
|
|
131
|
+
email_verified=True,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Extract token
|
|
135
|
+
token = None
|
|
136
|
+
if credentials:
|
|
137
|
+
token = credentials.credentials
|
|
138
|
+
|
|
139
|
+
# Fallback: check x-user-id header (legacy support)
|
|
140
|
+
if not token:
|
|
141
|
+
legacy_uid = request.headers.get("x-user-id")
|
|
142
|
+
if legacy_uid:
|
|
143
|
+
logger.warning("Using legacy x-user-id header - migrate to Bearer token")
|
|
144
|
+
return AuthenticatedUser(sub=legacy_uid)
|
|
145
|
+
|
|
146
|
+
if not token:
|
|
147
|
+
raise HTTPException(status_code=401, detail="Missing authorization")
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
return self.verify_token(token)
|
|
151
|
+
except AuthError as e:
|
|
152
|
+
raise HTTPException(status_code=e.status_code, detail=e.message)
|
|
153
|
+
|
|
154
|
+
async def get_user_optional(
|
|
155
|
+
self,
|
|
156
|
+
request: Request,
|
|
157
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
158
|
+
) -> Optional[AuthenticatedUser]:
|
|
159
|
+
"""
|
|
160
|
+
FastAPI dependency that returns None instead of raising if not authenticated.
|
|
161
|
+
Useful for endpoints that work differently for logged-in vs anonymous users.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
return await self.get_user(request, credentials)
|
|
165
|
+
except HTTPException:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Convenience: module-level instance using environment variables
|
|
170
|
+
_default_auth: Optional[SupabaseAuth] = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _get_default_auth() -> SupabaseAuth:
|
|
174
|
+
"""Get or create default auth instance from environment."""
|
|
175
|
+
global _default_auth
|
|
176
|
+
if _default_auth is None:
|
|
177
|
+
_default_auth = SupabaseAuth()
|
|
178
|
+
return _default_auth
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def get_current_user(
|
|
182
|
+
request: Request,
|
|
183
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
|
184
|
+
) -> AuthenticatedUser:
|
|
185
|
+
"""
|
|
186
|
+
Convenience dependency using environment-configured auth.
|
|
187
|
+
|
|
188
|
+
Usage:
|
|
189
|
+
from pargo_auth import get_current_user, AuthenticatedUser
|
|
190
|
+
|
|
191
|
+
@app.get("/me")
|
|
192
|
+
async def get_me(user: AuthenticatedUser = Depends(get_current_user)):
|
|
193
|
+
return {"id": user.sub}
|
|
194
|
+
"""
|
|
195
|
+
auth = _get_default_auth()
|
|
196
|
+
return await auth.get_user(request, credentials)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def require_auth():
|
|
200
|
+
"""
|
|
201
|
+
Dependency that can be used in router dependencies list.
|
|
202
|
+
|
|
203
|
+
Usage:
|
|
204
|
+
router = APIRouter(dependencies=[Depends(require_auth())])
|
|
205
|
+
"""
|
|
206
|
+
return Depends(get_current_user)
|
pargo_auth/models.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Data models for authentication."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class AuthenticatedUser:
|
|
9
|
+
"""Represents an authenticated user from Supabase JWT."""
|
|
10
|
+
|
|
11
|
+
sub: str # Supabase user ID (stable, use as canonical identity)
|
|
12
|
+
email: Optional[str] = None
|
|
13
|
+
email_verified: bool = False
|
|
14
|
+
|
|
15
|
+
# Raw claims for extensibility
|
|
16
|
+
raw_claims: Optional[dict] = None
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def supabase_uid(self) -> str:
|
|
20
|
+
"""Alias for sub - the Supabase user ID."""
|
|
21
|
+
return self.sub
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pargo-auth
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Shared authentication middleware for PARGO backend services
|
|
5
|
+
Project-URL: Homepage, https://github.com/pargoorg/pargo-auth
|
|
6
|
+
Project-URL: Repository, https://github.com/pargoorg/pargo-auth
|
|
7
|
+
Author-email: PARGO <dev@pargo.dk>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: cryptography>=41.0.0
|
|
20
|
+
Requires-Dist: fastapi>=0.100.0
|
|
21
|
+
Requires-Dist: httpx>=0.24.0
|
|
22
|
+
Requires-Dist: pyjwt>=2.8.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# pargo-auth
|
|
29
|
+
|
|
30
|
+
Shared authentication middleware for PARGO backend services. Verifies Supabase JWTs and provides FastAPI dependencies.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install pargo-auth
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from fastapi import FastAPI, Depends
|
|
42
|
+
from pargo_auth import get_current_user, AuthenticatedUser
|
|
43
|
+
|
|
44
|
+
app = FastAPI()
|
|
45
|
+
|
|
46
|
+
@app.get("/me")
|
|
47
|
+
async def get_me(user: AuthenticatedUser = Depends(get_current_user)):
|
|
48
|
+
return {
|
|
49
|
+
"id": user.sub,
|
|
50
|
+
"email": user.email,
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
Set these environment variables:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
SUPABASE_URL=https://your-project.supabase.co
|
|
60
|
+
ENV=local # Skip auth verification in local dev
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage Patterns
|
|
64
|
+
|
|
65
|
+
### Basic: Protect individual endpoints
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from pargo_auth import get_current_user, AuthenticatedUser
|
|
69
|
+
|
|
70
|
+
@app.get("/protected")
|
|
71
|
+
async def protected(user: AuthenticatedUser = Depends(get_current_user)):
|
|
72
|
+
return {"user_id": user.sub}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Protect entire router
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fastapi import APIRouter, Depends
|
|
79
|
+
from pargo_auth import require_auth
|
|
80
|
+
|
|
81
|
+
router = APIRouter(dependencies=[require_auth()])
|
|
82
|
+
|
|
83
|
+
@router.get("/data")
|
|
84
|
+
async def get_data(): # Auth already required by router
|
|
85
|
+
return {"data": "secret"}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Optional auth (different behavior for logged-in vs anonymous)
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from pargo_auth import SupabaseAuth, AuthenticatedUser
|
|
92
|
+
|
|
93
|
+
auth = SupabaseAuth()
|
|
94
|
+
|
|
95
|
+
@app.get("/content")
|
|
96
|
+
async def get_content(user: AuthenticatedUser | None = Depends(auth.get_user_optional)):
|
|
97
|
+
if user:
|
|
98
|
+
return {"content": "personalized", "user": user.sub}
|
|
99
|
+
return {"content": "generic"}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Custom instance (non-default config)
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from pargo_auth import SupabaseAuth
|
|
106
|
+
|
|
107
|
+
auth = SupabaseAuth(
|
|
108
|
+
supabase_url="https://custom.supabase.co",
|
|
109
|
+
skip_verification_in_dev=False, # Always verify, even locally
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@app.get("/strict")
|
|
113
|
+
async def strict_endpoint(user = Depends(auth.get_user)):
|
|
114
|
+
return {"user": user.sub}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## AuthenticatedUser
|
|
118
|
+
|
|
119
|
+
The `AuthenticatedUser` object contains:
|
|
120
|
+
|
|
121
|
+
| Field | Type | Description |
|
|
122
|
+
|-------|------|-------------|
|
|
123
|
+
| `sub` | `str` | Supabase user ID (stable, use as canonical identity) |
|
|
124
|
+
| `email` | `str \| None` | User's email (if available) |
|
|
125
|
+
| `email_verified` | `bool` | Whether email is verified |
|
|
126
|
+
| `raw_claims` | `dict \| None` | Full JWT payload for custom claims |
|
|
127
|
+
|
|
128
|
+
## Legacy Support
|
|
129
|
+
|
|
130
|
+
For backwards compatibility, the middleware also checks for `x-user-id` header if no Bearer token is present. This allows gradual migration from the old auth system.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# Install dev dependencies
|
|
136
|
+
pip install -e ".[dev]"
|
|
137
|
+
|
|
138
|
+
# Run tests
|
|
139
|
+
pytest
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pargo_auth/__init__.py,sha256=lWjCNYxegtjkGnnQu7mvjGs5iyLAuaSLFwnAmWqIPcM,454
|
|
2
|
+
pargo_auth/exceptions.py,sha256=Qckee29SlPf8VS21s-1gT8j_gCoYKaJhYKet9DE28WE,879
|
|
3
|
+
pargo_auth/middleware.py,sha256=fCh41HYMfNQbEieVFnUG6uCl-wr0SQ2KahGX297HCYE,6779
|
|
4
|
+
pargo_auth/models.py,sha256=I_mb0PGPy7FHZbMHxs1tR4NekD-cv1Ea4ZSzjStDZo4,548
|
|
5
|
+
pargo_auth-0.1.2.dist-info/METADATA,sha256=xXFakWjCY3MzTammApy2JUSnO8Myss86EA0x_LgIvg4,3612
|
|
6
|
+
pargo_auth-0.1.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
7
|
+
pargo_auth-0.1.2.dist-info/licenses/LICENSE,sha256=A-E9k545xbWsw-1DRhKLXcePHJ15tKx_bQl12OabqEY,1062
|
|
8
|
+
pargo_auth-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PARGO
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|