authaction-python-sdk 0.1.0__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.
- authaction_python_sdk-0.1.0/PKG-INFO +215 -0
- authaction_python_sdk-0.1.0/README.md +189 -0
- authaction_python_sdk-0.1.0/authaction/__init__.py +70 -0
- authaction_python_sdk-0.1.0/authaction/_verifier.py +77 -0
- authaction_python_sdk-0.1.0/authaction/django/__init__.py +29 -0
- authaction_python_sdk-0.1.0/authaction/django/authentication.py +104 -0
- authaction_python_sdk-0.1.0/authaction/exceptions.py +10 -0
- authaction_python_sdk-0.1.0/authaction/fastapi/__init__.py +30 -0
- authaction_python_sdk-0.1.0/authaction/fastapi/dependencies.py +90 -0
- authaction_python_sdk-0.1.0/authaction/flask/__init__.py +24 -0
- authaction_python_sdk-0.1.0/authaction/flask/decorators.py +62 -0
- authaction_python_sdk-0.1.0/authaction_python_sdk.egg-info/PKG-INFO +215 -0
- authaction_python_sdk-0.1.0/authaction_python_sdk.egg-info/SOURCES.txt +20 -0
- authaction_python_sdk-0.1.0/authaction_python_sdk.egg-info/dependency_links.txt +1 -0
- authaction_python_sdk-0.1.0/authaction_python_sdk.egg-info/requires.txt +21 -0
- authaction_python_sdk-0.1.0/authaction_python_sdk.egg-info/top_level.txt +1 -0
- authaction_python_sdk-0.1.0/pyproject.toml +35 -0
- authaction_python_sdk-0.1.0/setup.cfg +4 -0
- authaction_python_sdk-0.1.0/tests/test_django.py +107 -0
- authaction_python_sdk-0.1.0/tests/test_fastapi.py +137 -0
- authaction_python_sdk-0.1.0/tests/test_flask.py +104 -0
- authaction_python_sdk-0.1.0/tests/test_verifier.py +97 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: authaction-python-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AuthAction JWT verification SDK for Python — Django, Flask, and FastAPI
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: oauth2,jwt,jwks,authentication,authaction,django,flask,fastapi
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: PyJWT[crypto]>=2.8.0
|
|
10
|
+
Provides-Extra: django
|
|
11
|
+
Requires-Dist: django>=3.2; extra == "django"
|
|
12
|
+
Requires-Dist: djangorestframework>=3.14; extra == "django"
|
|
13
|
+
Provides-Extra: flask
|
|
14
|
+
Requires-Dist: flask>=2.3; extra == "flask"
|
|
15
|
+
Provides-Extra: fastapi
|
|
16
|
+
Requires-Dist: fastapi>=0.100; extra == "fastapi"
|
|
17
|
+
Requires-Dist: httpx>=0.24; extra == "fastapi"
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest-mock>=3.12; extra == "dev"
|
|
21
|
+
Requires-Dist: django>=3.2; extra == "dev"
|
|
22
|
+
Requires-Dist: djangorestframework>=3.14; extra == "dev"
|
|
23
|
+
Requires-Dist: flask>=2.3; extra == "dev"
|
|
24
|
+
Requires-Dist: fastapi>=0.100; extra == "dev"
|
|
25
|
+
Requires-Dist: httpx>=0.24; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# authaction-python-sdk
|
|
28
|
+
|
|
29
|
+
JWT verification SDK for Python backends. Validates AuthAction access tokens via JWKS — handles key fetching, caching, and rotation automatically.
|
|
30
|
+
|
|
31
|
+
Works with **Django REST Framework**, **Flask**, and **FastAPI**.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Core only
|
|
37
|
+
pip install authaction-python-sdk
|
|
38
|
+
|
|
39
|
+
# With Django support
|
|
40
|
+
pip install "authaction-python-sdk[django]"
|
|
41
|
+
|
|
42
|
+
# With Flask support
|
|
43
|
+
pip install "authaction-python-sdk[flask]"
|
|
44
|
+
|
|
45
|
+
# With FastAPI support
|
|
46
|
+
pip install "authaction-python-sdk[fastapi]"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Core
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from authaction import AuthAction
|
|
55
|
+
|
|
56
|
+
aa = AuthAction(
|
|
57
|
+
domain=os.getenv("AUTHACTION_DOMAIN"), # e.g. myapp.eu.authaction.com
|
|
58
|
+
audience=os.getenv("AUTHACTION_AUDIENCE"), # e.g. https://api.myapp.com
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Verify a raw token — raises TokenExpiredError / TokenInvalidError on failure
|
|
62
|
+
payload = aa.verify_token(token)
|
|
63
|
+
|
|
64
|
+
# Verify from Authorization header — returns None on missing/invalid, never raises
|
|
65
|
+
payload = aa.verify_request(request.headers.get("Authorization"))
|
|
66
|
+
|
|
67
|
+
print(payload["sub"]) # user identifier
|
|
68
|
+
print(payload["email"]) # any JWT claim
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Django REST Framework
|
|
74
|
+
|
|
75
|
+
### 1. Configure settings
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
# settings.py
|
|
79
|
+
AUTHACTION = {
|
|
80
|
+
"DOMAIN": os.getenv("AUTHACTION_DOMAIN"),
|
|
81
|
+
"AUDIENCE": os.getenv("AUTHACTION_AUDIENCE"),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
REST_FRAMEWORK = {
|
|
85
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
86
|
+
"authaction.django.AuthActionAuthentication",
|
|
87
|
+
],
|
|
88
|
+
"DEFAULT_PERMISSION_CLASSES": [
|
|
89
|
+
"rest_framework.permissions.IsAuthenticated",
|
|
90
|
+
],
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 2. Use in views
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from rest_framework.decorators import api_view, permission_classes
|
|
98
|
+
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
99
|
+
from rest_framework.response import Response
|
|
100
|
+
|
|
101
|
+
@api_view(["GET"])
|
|
102
|
+
@permission_classes([AllowAny])
|
|
103
|
+
def public_view(request):
|
|
104
|
+
return Response({"message": "Public"})
|
|
105
|
+
|
|
106
|
+
@api_view(["GET"])
|
|
107
|
+
def protected_view(request):
|
|
108
|
+
return Response({"sub": request.user.sub, "email": request.user.email})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`request.user` is an `AuthenticatedToken` — access any JWT claim as an attribute:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
request.user.sub # str
|
|
115
|
+
request.user.email # any claim
|
|
116
|
+
request.user.payload # dict — all raw claims
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Flask
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from authaction import AuthAction
|
|
125
|
+
from authaction.flask import make_require_auth
|
|
126
|
+
|
|
127
|
+
aa = AuthAction(
|
|
128
|
+
domain=os.getenv("AUTHACTION_DOMAIN"),
|
|
129
|
+
audience=os.getenv("AUTHACTION_AUDIENCE"),
|
|
130
|
+
)
|
|
131
|
+
require_auth = make_require_auth(aa)
|
|
132
|
+
|
|
133
|
+
@app.get("/public")
|
|
134
|
+
def public_route():
|
|
135
|
+
return {"message": "Public"}
|
|
136
|
+
|
|
137
|
+
@app.get("/protected")
|
|
138
|
+
@require_auth
|
|
139
|
+
def protected_route():
|
|
140
|
+
from flask import g
|
|
141
|
+
return {"sub": g.current_user["sub"]}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The decoded payload is available as `g.current_user` (a `dict`) inside decorated routes.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## FastAPI
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from fastapi import FastAPI, Depends
|
|
152
|
+
from authaction import AuthAction
|
|
153
|
+
from authaction.fastapi import make_require_auth
|
|
154
|
+
|
|
155
|
+
aa = AuthAction(
|
|
156
|
+
domain=os.getenv("AUTHACTION_DOMAIN"),
|
|
157
|
+
audience=os.getenv("AUTHACTION_AUDIENCE"),
|
|
158
|
+
)
|
|
159
|
+
require_auth = make_require_auth(aa)
|
|
160
|
+
|
|
161
|
+
app = FastAPI()
|
|
162
|
+
|
|
163
|
+
@app.get("/public")
|
|
164
|
+
def public_route():
|
|
165
|
+
return {"message": "Public"}
|
|
166
|
+
|
|
167
|
+
@app.get("/protected")
|
|
168
|
+
def protected_route(user: dict = Depends(require_auth)):
|
|
169
|
+
return {"sub": user["sub"], "email": user.get("email")}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Scope enforcement
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
require_admin = make_require_auth(aa, scopes=["admin"])
|
|
176
|
+
|
|
177
|
+
@app.delete("/users/{user_id}")
|
|
178
|
+
def delete_user(user_id: str, user: dict = Depends(require_admin)):
|
|
179
|
+
... # raises HTTP 403 if token lacks 'admin' scope
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Exceptions
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
from authaction.exceptions import TokenExpiredError, TokenInvalidError
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
payload = aa.verify_token(token)
|
|
191
|
+
except TokenExpiredError:
|
|
192
|
+
# token exp claim is in the past
|
|
193
|
+
...
|
|
194
|
+
except TokenInvalidError:
|
|
195
|
+
# bad signature, wrong issuer/audience, malformed JWT
|
|
196
|
+
...
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Environment variables
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
AUTHACTION_DOMAIN=your-tenant.eu.authaction.com
|
|
203
|
+
AUTHACTION_AUDIENCE=https://api.your-app.com
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## How JWKS caching works
|
|
207
|
+
|
|
208
|
+
Uses `PyJWT`'s `PyJWKClient` which:
|
|
209
|
+
- Fetches public keys from `https://<domain>/.well-known/jwks.json` on first use
|
|
210
|
+
- Caches up to 16 keys in-process (LRU, configurable via `jwks_cache_keys`)
|
|
211
|
+
- Automatically re-fetches when an unknown `kid` is encountered (key rotation)
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
MIT
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# authaction-python-sdk
|
|
2
|
+
|
|
3
|
+
JWT verification SDK for Python backends. Validates AuthAction access tokens via JWKS — handles key fetching, caching, and rotation automatically.
|
|
4
|
+
|
|
5
|
+
Works with **Django REST Framework**, **Flask**, and **FastAPI**.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Core only
|
|
11
|
+
pip install authaction-python-sdk
|
|
12
|
+
|
|
13
|
+
# With Django support
|
|
14
|
+
pip install "authaction-python-sdk[django]"
|
|
15
|
+
|
|
16
|
+
# With Flask support
|
|
17
|
+
pip install "authaction-python-sdk[flask]"
|
|
18
|
+
|
|
19
|
+
# With FastAPI support
|
|
20
|
+
pip install "authaction-python-sdk[fastapi]"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Core
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from authaction import AuthAction
|
|
29
|
+
|
|
30
|
+
aa = AuthAction(
|
|
31
|
+
domain=os.getenv("AUTHACTION_DOMAIN"), # e.g. myapp.eu.authaction.com
|
|
32
|
+
audience=os.getenv("AUTHACTION_AUDIENCE"), # e.g. https://api.myapp.com
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Verify a raw token — raises TokenExpiredError / TokenInvalidError on failure
|
|
36
|
+
payload = aa.verify_token(token)
|
|
37
|
+
|
|
38
|
+
# Verify from Authorization header — returns None on missing/invalid, never raises
|
|
39
|
+
payload = aa.verify_request(request.headers.get("Authorization"))
|
|
40
|
+
|
|
41
|
+
print(payload["sub"]) # user identifier
|
|
42
|
+
print(payload["email"]) # any JWT claim
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Django REST Framework
|
|
48
|
+
|
|
49
|
+
### 1. Configure settings
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
# settings.py
|
|
53
|
+
AUTHACTION = {
|
|
54
|
+
"DOMAIN": os.getenv("AUTHACTION_DOMAIN"),
|
|
55
|
+
"AUDIENCE": os.getenv("AUTHACTION_AUDIENCE"),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
REST_FRAMEWORK = {
|
|
59
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
60
|
+
"authaction.django.AuthActionAuthentication",
|
|
61
|
+
],
|
|
62
|
+
"DEFAULT_PERMISSION_CLASSES": [
|
|
63
|
+
"rest_framework.permissions.IsAuthenticated",
|
|
64
|
+
],
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 2. Use in views
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from rest_framework.decorators import api_view, permission_classes
|
|
72
|
+
from rest_framework.permissions import AllowAny, IsAuthenticated
|
|
73
|
+
from rest_framework.response import Response
|
|
74
|
+
|
|
75
|
+
@api_view(["GET"])
|
|
76
|
+
@permission_classes([AllowAny])
|
|
77
|
+
def public_view(request):
|
|
78
|
+
return Response({"message": "Public"})
|
|
79
|
+
|
|
80
|
+
@api_view(["GET"])
|
|
81
|
+
def protected_view(request):
|
|
82
|
+
return Response({"sub": request.user.sub, "email": request.user.email})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`request.user` is an `AuthenticatedToken` — access any JWT claim as an attribute:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
request.user.sub # str
|
|
89
|
+
request.user.email # any claim
|
|
90
|
+
request.user.payload # dict — all raw claims
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Flask
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from authaction import AuthAction
|
|
99
|
+
from authaction.flask import make_require_auth
|
|
100
|
+
|
|
101
|
+
aa = AuthAction(
|
|
102
|
+
domain=os.getenv("AUTHACTION_DOMAIN"),
|
|
103
|
+
audience=os.getenv("AUTHACTION_AUDIENCE"),
|
|
104
|
+
)
|
|
105
|
+
require_auth = make_require_auth(aa)
|
|
106
|
+
|
|
107
|
+
@app.get("/public")
|
|
108
|
+
def public_route():
|
|
109
|
+
return {"message": "Public"}
|
|
110
|
+
|
|
111
|
+
@app.get("/protected")
|
|
112
|
+
@require_auth
|
|
113
|
+
def protected_route():
|
|
114
|
+
from flask import g
|
|
115
|
+
return {"sub": g.current_user["sub"]}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The decoded payload is available as `g.current_user` (a `dict`) inside decorated routes.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## FastAPI
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from fastapi import FastAPI, Depends
|
|
126
|
+
from authaction import AuthAction
|
|
127
|
+
from authaction.fastapi import make_require_auth
|
|
128
|
+
|
|
129
|
+
aa = AuthAction(
|
|
130
|
+
domain=os.getenv("AUTHACTION_DOMAIN"),
|
|
131
|
+
audience=os.getenv("AUTHACTION_AUDIENCE"),
|
|
132
|
+
)
|
|
133
|
+
require_auth = make_require_auth(aa)
|
|
134
|
+
|
|
135
|
+
app = FastAPI()
|
|
136
|
+
|
|
137
|
+
@app.get("/public")
|
|
138
|
+
def public_route():
|
|
139
|
+
return {"message": "Public"}
|
|
140
|
+
|
|
141
|
+
@app.get("/protected")
|
|
142
|
+
def protected_route(user: dict = Depends(require_auth)):
|
|
143
|
+
return {"sub": user["sub"], "email": user.get("email")}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Scope enforcement
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
require_admin = make_require_auth(aa, scopes=["admin"])
|
|
150
|
+
|
|
151
|
+
@app.delete("/users/{user_id}")
|
|
152
|
+
def delete_user(user_id: str, user: dict = Depends(require_admin)):
|
|
153
|
+
... # raises HTTP 403 if token lacks 'admin' scope
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Exceptions
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from authaction.exceptions import TokenExpiredError, TokenInvalidError
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
payload = aa.verify_token(token)
|
|
165
|
+
except TokenExpiredError:
|
|
166
|
+
# token exp claim is in the past
|
|
167
|
+
...
|
|
168
|
+
except TokenInvalidError:
|
|
169
|
+
# bad signature, wrong issuer/audience, malformed JWT
|
|
170
|
+
...
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Environment variables
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
AUTHACTION_DOMAIN=your-tenant.eu.authaction.com
|
|
177
|
+
AUTHACTION_AUDIENCE=https://api.your-app.com
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## How JWKS caching works
|
|
181
|
+
|
|
182
|
+
Uses `PyJWT`'s `PyJWKClient` which:
|
|
183
|
+
- Fetches public keys from `https://<domain>/.well-known/jwks.json` on first use
|
|
184
|
+
- Caches up to 16 keys in-process (LRU, configurable via `jwks_cache_keys`)
|
|
185
|
+
- Automatically re-fetches when an unknown `kid` is encountered (key rotation)
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AuthAction Python SDK — JWT verification for Django and Flask APIs.
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
from authaction import AuthAction
|
|
7
|
+
|
|
8
|
+
aa = AuthAction(domain="myapp.eu.authaction.com", audience="https://api.myapp.com")
|
|
9
|
+
|
|
10
|
+
# Verify a raw token (raises TokenExpiredError / TokenInvalidError on failure)
|
|
11
|
+
payload = aa.verify_token(token)
|
|
12
|
+
|
|
13
|
+
# Verify from an Authorization header value (returns None on missing/invalid)
|
|
14
|
+
payload = aa.verify_request(request.headers.get("Authorization"))
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ._verifier import JwtVerifier
|
|
22
|
+
from .exceptions import AuthActionError, TokenExpiredError, TokenInvalidError
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AuthAction",
|
|
26
|
+
"JwtVerifier",
|
|
27
|
+
"AuthActionError",
|
|
28
|
+
"TokenExpiredError",
|
|
29
|
+
"TokenInvalidError",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthAction:
|
|
36
|
+
"""
|
|
37
|
+
Top-level AuthAction client.
|
|
38
|
+
|
|
39
|
+
:param domain: AuthAction tenant domain (e.g. ``myapp.eu.authaction.com``).
|
|
40
|
+
:param audience: API identifier registered in AuthAction.
|
|
41
|
+
:param jwks_cache_keys: Maximum number of public keys to cache (default 16).
|
|
42
|
+
|
|
43
|
+
Example::
|
|
44
|
+
|
|
45
|
+
aa = AuthAction(
|
|
46
|
+
domain=os.getenv("AUTHACTION_DOMAIN"),
|
|
47
|
+
audience=os.getenv("AUTHACTION_AUDIENCE"),
|
|
48
|
+
)
|
|
49
|
+
payload = aa.verify_token(token)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
domain: str,
|
|
55
|
+
audience: str,
|
|
56
|
+
jwks_cache_keys: int = 16,
|
|
57
|
+
) -> None:
|
|
58
|
+
self._verifier = JwtVerifier(
|
|
59
|
+
domain=domain,
|
|
60
|
+
audience=audience,
|
|
61
|
+
jwks_cache_keys=jwks_cache_keys,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def verify_token(self, token: str) -> dict[str, Any]:
|
|
65
|
+
"""Verify a raw JWT string and return the decoded claims."""
|
|
66
|
+
return self._verifier.verify_token(token)
|
|
67
|
+
|
|
68
|
+
def verify_request(self, authorization_header: str | None) -> dict[str, Any] | None:
|
|
69
|
+
"""Verify a Bearer token from an Authorization header value."""
|
|
70
|
+
return self._verifier.verify_request(authorization_header)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
from jwt import PyJWKClient, PyJWKClientError
|
|
7
|
+
|
|
8
|
+
from .exceptions import TokenExpiredError, TokenInvalidError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JwtVerifier:
|
|
12
|
+
"""
|
|
13
|
+
Core JWT verifier.
|
|
14
|
+
|
|
15
|
+
Wraps PyJWT's PyJWKClient which already handles:
|
|
16
|
+
- JWKS fetching from /.well-known/jwks.json
|
|
17
|
+
- In-memory key caching (LRU, configurable size)
|
|
18
|
+
- Automatic key rotation (re-fetches when an unknown kid is seen)
|
|
19
|
+
|
|
20
|
+
Always validates: RS256 algorithm, issuer, audience, and expiry.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
domain: str,
|
|
26
|
+
audience: str,
|
|
27
|
+
jwks_cache_keys: int = 16,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._domain = domain
|
|
30
|
+
self._audience = audience
|
|
31
|
+
self._issuer = f"https://{domain}"
|
|
32
|
+
self._client = PyJWKClient(
|
|
33
|
+
f"https://{domain}/.well-known/jwks.json",
|
|
34
|
+
cache_keys=True,
|
|
35
|
+
max_cached_keys=jwks_cache_keys,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def verify_token(self, token: str) -> dict[str, Any]:
|
|
39
|
+
"""
|
|
40
|
+
Verify a raw JWT string and return the decoded claims.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
TokenExpiredError: The token's ``exp`` claim is in the past.
|
|
44
|
+
TokenInvalidError: Signature, issuer, audience, or structure is invalid.
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
signing_key = self._client.get_signing_key_from_jwt(token)
|
|
48
|
+
except PyJWKClientError as exc:
|
|
49
|
+
raise TokenInvalidError(f"JWKS key lookup failed: {exc}") from exc
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
return jwt.decode(
|
|
53
|
+
token,
|
|
54
|
+
signing_key.key,
|
|
55
|
+
algorithms=["RS256"],
|
|
56
|
+
audience=self._audience,
|
|
57
|
+
issuer=self._issuer,
|
|
58
|
+
)
|
|
59
|
+
except jwt.ExpiredSignatureError as exc:
|
|
60
|
+
raise TokenExpiredError("Token has expired") from exc
|
|
61
|
+
except jwt.InvalidTokenError as exc:
|
|
62
|
+
raise TokenInvalidError(str(exc)) from exc
|
|
63
|
+
|
|
64
|
+
def verify_request(self, authorization_header: str | None) -> dict[str, Any] | None:
|
|
65
|
+
"""
|
|
66
|
+
Extract the Bearer token from an Authorization header value and verify it.
|
|
67
|
+
|
|
68
|
+
Returns ``None`` when the header is absent or not a Bearer scheme.
|
|
69
|
+
Never raises — returns ``None`` on invalid tokens.
|
|
70
|
+
"""
|
|
71
|
+
if not authorization_header or not authorization_header.startswith("Bearer "):
|
|
72
|
+
return None
|
|
73
|
+
token = authorization_header[7:].strip()
|
|
74
|
+
try:
|
|
75
|
+
return self.verify_token(token)
|
|
76
|
+
except (TokenExpiredError, TokenInvalidError):
|
|
77
|
+
return None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django REST Framework integration for AuthAction.
|
|
3
|
+
|
|
4
|
+
Configure once in ``settings.py``::
|
|
5
|
+
|
|
6
|
+
AUTHACTION = {
|
|
7
|
+
"DOMAIN": "myapp.eu.authaction.com",
|
|
8
|
+
"AUDIENCE": "https://api.myapp.com",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
REST_FRAMEWORK = {
|
|
12
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
13
|
+
"authaction.django.AuthActionAuthentication",
|
|
14
|
+
],
|
|
15
|
+
"DEFAULT_PERMISSION_CLASSES": [
|
|
16
|
+
"rest_framework.permissions.IsAuthenticated",
|
|
17
|
+
],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Access the decoded payload in views::
|
|
21
|
+
|
|
22
|
+
@api_view(["GET"])
|
|
23
|
+
def protected(request):
|
|
24
|
+
return Response({"sub": request.user.sub})
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from .authentication import AuthActionAuthentication, AuthenticatedToken
|
|
28
|
+
|
|
29
|
+
__all__ = ["AuthActionAuthentication", "AuthenticatedToken"]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from rest_framework.authentication import BaseAuthentication
|
|
6
|
+
from rest_framework.exceptions import AuthenticationFailed
|
|
7
|
+
from rest_framework.request import Request
|
|
8
|
+
|
|
9
|
+
from .._verifier import JwtVerifier
|
|
10
|
+
from ..exceptions import TokenExpiredError, TokenInvalidError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthenticatedToken:
|
|
14
|
+
"""
|
|
15
|
+
Minimal user-like object that wraps the decoded JWT claims.
|
|
16
|
+
Assigned to ``request.user`` after successful authentication.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
is_authenticated = True
|
|
20
|
+
|
|
21
|
+
def __init__(self, payload: dict[str, Any]) -> None:
|
|
22
|
+
self.payload = payload
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def sub(self) -> str:
|
|
26
|
+
return str(self.payload.get("sub", ""))
|
|
27
|
+
|
|
28
|
+
def __getattr__(self, item: str) -> Any:
|
|
29
|
+
try:
|
|
30
|
+
return self.payload[item]
|
|
31
|
+
except KeyError:
|
|
32
|
+
raise AttributeError(item) from None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_verifier() -> JwtVerifier:
|
|
36
|
+
"""Lazily construct the verifier from Django settings."""
|
|
37
|
+
from django.conf import settings # imported lazily to avoid Django startup order issues
|
|
38
|
+
|
|
39
|
+
config: dict[str, Any] = getattr(settings, "AUTHACTION", {})
|
|
40
|
+
domain = config.get("DOMAIN") or ""
|
|
41
|
+
audience = config.get("AUDIENCE") or ""
|
|
42
|
+
if not domain or not audience:
|
|
43
|
+
raise ImproperlyConfigured( # noqa: F821
|
|
44
|
+
"AUTHACTION settings must include 'DOMAIN' and 'AUDIENCE'. "
|
|
45
|
+
"Add AUTHACTION = {'DOMAIN': '...', 'AUDIENCE': '...'} to settings.py."
|
|
46
|
+
)
|
|
47
|
+
return JwtVerifier(domain=domain, audience=audience)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
from django.core.exceptions import ImproperlyConfigured # noqa: F401
|
|
52
|
+
except ImportError:
|
|
53
|
+
ImproperlyConfigured = RuntimeError # type: ignore[misc,assignment]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuthActionAuthentication(BaseAuthentication):
|
|
57
|
+
"""
|
|
58
|
+
Django REST Framework authentication class that validates AuthAction JWTs.
|
|
59
|
+
|
|
60
|
+
Read the token from ``Authorization: Bearer <token>`` and return
|
|
61
|
+
``(AuthenticatedToken(payload), raw_token)`` on success, or ``None``
|
|
62
|
+
when the header is absent (to allow other authenticators to run).
|
|
63
|
+
|
|
64
|
+
Raises ``AuthenticationFailed`` (HTTP 401) when the header is present
|
|
65
|
+
but the token is invalid or expired.
|
|
66
|
+
|
|
67
|
+
Usage::
|
|
68
|
+
|
|
69
|
+
REST_FRAMEWORK = {
|
|
70
|
+
"DEFAULT_AUTHENTICATION_CLASSES": [
|
|
71
|
+
"authaction.django.AuthActionAuthentication",
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
_verifier: JwtVerifier | None = None
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def get_verifier(cls) -> JwtVerifier:
|
|
80
|
+
if cls._verifier is None:
|
|
81
|
+
cls._verifier = _get_verifier()
|
|
82
|
+
return cls._verifier
|
|
83
|
+
|
|
84
|
+
def authenticate(self, request: Request) -> tuple[AuthenticatedToken, str] | None:
|
|
85
|
+
auth_header: str = request.headers.get("Authorization", "")
|
|
86
|
+
|
|
87
|
+
if not auth_header.startswith("Bearer "):
|
|
88
|
+
return None # Not a Bearer request — let other authenticators try
|
|
89
|
+
|
|
90
|
+
token = auth_header[7:].strip()
|
|
91
|
+
if not token:
|
|
92
|
+
raise AuthenticationFailed("Empty Bearer token")
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
payload = self.get_verifier().verify_token(token)
|
|
96
|
+
except TokenExpiredError:
|
|
97
|
+
raise AuthenticationFailed("Token has expired")
|
|
98
|
+
except TokenInvalidError as exc:
|
|
99
|
+
raise AuthenticationFailed(str(exc))
|
|
100
|
+
|
|
101
|
+
return AuthenticatedToken(payload), token
|
|
102
|
+
|
|
103
|
+
def authenticate_header(self, request: Request) -> str:
|
|
104
|
+
return "Bearer"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class AuthActionError(Exception):
|
|
2
|
+
"""Base exception for all AuthAction SDK errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TokenExpiredError(AuthActionError):
|
|
6
|
+
"""The JWT's ``exp`` claim is in the past."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TokenInvalidError(AuthActionError):
|
|
10
|
+
"""The JWT signature, issuer, audience, or structure is invalid."""
|