supaapps-platform93-auth 0.1.0a2__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.
- supaapps_platform93_auth-0.1.0a2/.gitignore +38 -0
- supaapps_platform93_auth-0.1.0a2/PKG-INFO +31 -0
- supaapps_platform93_auth-0.1.0a2/README.md +12 -0
- supaapps_platform93_auth-0.1.0a2/pyproject.toml +25 -0
- supaapps_platform93_auth-0.1.0a2/src/platform93_auth/__init__.py +3 -0
- supaapps_platform93_auth-0.1.0a2/src/platform93_auth/django.py +18 -0
- supaapps_platform93_auth-0.1.0a2/src/platform93_auth/fastapi.py +17 -0
- supaapps_platform93_auth-0.1.0a2/src/platform93_auth/flask.py +23 -0
- supaapps_platform93_auth-0.1.0a2/src/platform93_auth/verifier.py +82 -0
- supaapps_platform93_auth-0.1.0a2/tests/conformance.py +91 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Local configuration and secrets
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
*.pem
|
|
6
|
+
*.key
|
|
7
|
+
*.p12
|
|
8
|
+
*.pfx
|
|
9
|
+
|
|
10
|
+
# Local Compose credentials
|
|
11
|
+
deploy/compose/secrets/
|
|
12
|
+
|
|
13
|
+
# Editors and operating systems
|
|
14
|
+
.DS_Store
|
|
15
|
+
.idea/
|
|
16
|
+
.vscode/
|
|
17
|
+
|
|
18
|
+
# Dependencies
|
|
19
|
+
node_modules/
|
|
20
|
+
**/node_modules/
|
|
21
|
+
vendor/
|
|
22
|
+
|
|
23
|
+
# Build and test output
|
|
24
|
+
dist/
|
|
25
|
+
build/
|
|
26
|
+
coverage/
|
|
27
|
+
tmp/
|
|
28
|
+
**/.next/
|
|
29
|
+
**/out/
|
|
30
|
+
**/test-results/
|
|
31
|
+
**/playwright-report/
|
|
32
|
+
*.log
|
|
33
|
+
*.test
|
|
34
|
+
*.prof
|
|
35
|
+
*.tsbuildinfo
|
|
36
|
+
/platform93
|
|
37
|
+
__pycache__/
|
|
38
|
+
*.py[cod]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: supaapps-platform93-auth
|
|
3
|
+
Version: 0.1.0a2
|
|
4
|
+
Summary: Strict Platform93 JWT verification
|
|
5
|
+
Project-URL: Homepage, https://github.com/supaapps/platform93
|
|
6
|
+
Project-URL: Repository, https://github.com/supaapps/platform93
|
|
7
|
+
Project-URL: Issues, https://github.com/supaapps/platform93/issues
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: httpx<1,>=0.28
|
|
11
|
+
Requires-Dist: pyjwt[crypto]<3,>=2.10
|
|
12
|
+
Provides-Extra: django
|
|
13
|
+
Requires-Dist: django>=5.1; extra == 'django'
|
|
14
|
+
Provides-Extra: fastapi
|
|
15
|
+
Requires-Dist: fastapi>=0.115; extra == 'fastapi'
|
|
16
|
+
Provides-Extra: flask
|
|
17
|
+
Requires-Dist: flask>=3.1; extra == 'flask'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# supaapps-platform93-auth
|
|
21
|
+
|
|
22
|
+
Strict server-side verification for Platform93 application JWTs, including issuer,
|
|
23
|
+
audience, actor type, application boundary, expiry, and JWKS rotation checks.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install supaapps-platform93-auth
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Framework adapters are available through the `fastapi`, `django`, and `flask` extras.
|
|
30
|
+
See the [Platform93 repository](https://github.com/supaapps/platform93) for contracts
|
|
31
|
+
and integration documentation.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# supaapps-platform93-auth
|
|
2
|
+
|
|
3
|
+
Strict server-side verification for Platform93 application JWTs, including issuer,
|
|
4
|
+
audience, actor type, application boundary, expiry, and JWKS rotation checks.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install supaapps-platform93-auth
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Framework adapters are available through the `fastapi`, `django`, and `flask` extras.
|
|
11
|
+
See the [Platform93 repository](https://github.com/supaapps/platform93) for contracts
|
|
12
|
+
and integration documentation.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "supaapps-platform93-auth"
|
|
7
|
+
version = "0.1.0-alpha.2"
|
|
8
|
+
description = "Strict Platform93 JWT verification"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
dependencies = ["PyJWT[crypto]>=2.10,<3", "httpx>=0.28,<1"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/supaapps/platform93"
|
|
16
|
+
Repository = "https://github.com/supaapps/platform93"
|
|
17
|
+
Issues = "https://github.com/supaapps/platform93/issues"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
fastapi = ["fastapi>=0.115"]
|
|
21
|
+
django = ["django>=5.1"]
|
|
22
|
+
flask = ["flask>=3.1"]
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/platform93_auth"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from django.http import JsonResponse
|
|
2
|
+
|
|
3
|
+
from .verifier import Verifier
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Platform93AuthenticationMiddleware:
|
|
7
|
+
def __init__(self, get_response, verifier: Verifier | None = None):
|
|
8
|
+
self.get_response = get_response
|
|
9
|
+
self.verifier = verifier
|
|
10
|
+
|
|
11
|
+
def __call__(self, request):
|
|
12
|
+
authorization = request.headers.get("Authorization", "")
|
|
13
|
+
if authorization.lower().startswith("bearer ") and self.verifier:
|
|
14
|
+
try:
|
|
15
|
+
request.platform93_claims = self.verifier.verify(authorization[7:].strip())
|
|
16
|
+
except Exception:
|
|
17
|
+
return JsonResponse({"detail": "Invalid bearer token"}, status=401)
|
|
18
|
+
return self.get_response(request)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fastapi import Header, HTTPException
|
|
4
|
+
|
|
5
|
+
from .verifier import Claims, Verifier
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def bearer_dependency(verifier: Verifier):
|
|
9
|
+
def verify(authorization: Annotated[str | None, Header()] = None) -> Claims:
|
|
10
|
+
if not authorization or not authorization.lower().startswith("bearer "):
|
|
11
|
+
raise HTTPException(status_code=401, detail="Bearer token required")
|
|
12
|
+
try:
|
|
13
|
+
return verifier.verify(authorization[7:].strip())
|
|
14
|
+
except Exception as error:
|
|
15
|
+
raise HTTPException(status_code=401, detail="Invalid bearer token") from error
|
|
16
|
+
|
|
17
|
+
return verify
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
|
|
3
|
+
from flask import g, jsonify, request
|
|
4
|
+
|
|
5
|
+
from .verifier import Verifier
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def require_platform93(verifier: Verifier):
|
|
9
|
+
def decorator(function):
|
|
10
|
+
@wraps(function)
|
|
11
|
+
def wrapped(*args, **kwargs):
|
|
12
|
+
authorization = request.headers.get("Authorization", "")
|
|
13
|
+
if not authorization.lower().startswith("bearer "):
|
|
14
|
+
return jsonify(detail="Bearer token required"), 401
|
|
15
|
+
try:
|
|
16
|
+
g.platform93_claims = verifier.verify(authorization[7:].strip())
|
|
17
|
+
except Exception:
|
|
18
|
+
return jsonify(detail="Invalid bearer token"), 401
|
|
19
|
+
return function(*args, **kwargs)
|
|
20
|
+
|
|
21
|
+
return wrapped
|
|
22
|
+
|
|
23
|
+
return decorator
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import jwt
|
|
5
|
+
from jwt import PyJWKClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Claims:
|
|
10
|
+
values: dict[str, Any]
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def locale(self) -> str:
|
|
14
|
+
return str(self.values.get("locale", ""))
|
|
15
|
+
|
|
16
|
+
def has_permission(self, permission: str) -> bool:
|
|
17
|
+
return any(
|
|
18
|
+
value == permission
|
|
19
|
+
or value == "*"
|
|
20
|
+
or (
|
|
21
|
+
value.endswith("/*")
|
|
22
|
+
and (permission == value[:-2] or permission.startswith(value[:-1]))
|
|
23
|
+
)
|
|
24
|
+
for value in self.values.get("scope", "").split()
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Verifier:
|
|
29
|
+
def __init__(self, issuer: str, audience: str, application_id: str):
|
|
30
|
+
self.issuer = issuer.rstrip("/")
|
|
31
|
+
self.audience = audience
|
|
32
|
+
self.application_id = application_id
|
|
33
|
+
self.jwks = PyJWKClient(
|
|
34
|
+
f"{self.issuer}/jwks.json", cache_jwk_set=True, lifespan=300
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def verify(self, token: str) -> Claims:
|
|
38
|
+
header = jwt.get_unverified_header(token)
|
|
39
|
+
if (
|
|
40
|
+
header.get("alg") != "RS256"
|
|
41
|
+
or header.get("typ") != "JWT"
|
|
42
|
+
or not isinstance(header.get("kid"), str)
|
|
43
|
+
or not header["kid"]
|
|
44
|
+
):
|
|
45
|
+
raise jwt.InvalidTokenError("Platform93 token algorithm rejected")
|
|
46
|
+
key = self.jwks.get_signing_key_from_jwt(token)
|
|
47
|
+
values = jwt.decode(
|
|
48
|
+
token,
|
|
49
|
+
key.key,
|
|
50
|
+
algorithms=["RS256"],
|
|
51
|
+
audience=self.audience,
|
|
52
|
+
issuer=self.issuer,
|
|
53
|
+
options={
|
|
54
|
+
"require": [
|
|
55
|
+
"exp", "iat", "nbf", "iss", "sub", "aud",
|
|
56
|
+
"application_id", "token_kind", "actor_type", "scope",
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
if (
|
|
61
|
+
not isinstance(values.get("sub"), str)
|
|
62
|
+
or not values["sub"]
|
|
63
|
+
or values.get("application_id") != self.application_id
|
|
64
|
+
or not (
|
|
65
|
+
values.get("token_kind") == "access"
|
|
66
|
+
and values.get("actor_type") == "user"
|
|
67
|
+
or values.get("token_kind") == "machine"
|
|
68
|
+
and values.get("actor_type") == "client"
|
|
69
|
+
)
|
|
70
|
+
or not isinstance(values.get("scope"), str)
|
|
71
|
+
):
|
|
72
|
+
raise jwt.InvalidTokenError("Platform93 token context rejected")
|
|
73
|
+
actor = values.get("act")
|
|
74
|
+
if actor is not None and (
|
|
75
|
+
values["token_kind"] != "access"
|
|
76
|
+
or not isinstance(actor, dict)
|
|
77
|
+
or actor.get("type") != "operator"
|
|
78
|
+
or not isinstance(actor.get("sub"), str)
|
|
79
|
+
or not actor["sub"]
|
|
80
|
+
):
|
|
81
|
+
raise jwt.InvalidTokenError("Platform93 delegated token actor rejected")
|
|
82
|
+
return Claims(values)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import jwt
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
10
|
+
|
|
11
|
+
from platform93_auth import Verifier
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
fixture = json.loads((Path(__file__).parents[4] / "conformance" / "jwt.json").read_text())
|
|
15
|
+
primary = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
16
|
+
wrong = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
17
|
+
public_jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(primary.public_key()))
|
|
18
|
+
public_jwk.update({"kid": "primary", "alg": "RS256", "use": "sig"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class JWKSHandler(BaseHTTPRequestHandler):
|
|
22
|
+
def do_GET(self):
|
|
23
|
+
body = json.dumps({"keys": [public_jwk]}).encode()
|
|
24
|
+
self.send_response(200)
|
|
25
|
+
self.send_header("Content-Type", "application/json")
|
|
26
|
+
self.send_header("Content-Length", str(len(body)))
|
|
27
|
+
self.end_headers()
|
|
28
|
+
self.wfile.write(body)
|
|
29
|
+
|
|
30
|
+
def log_message(self, *_args):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), JWKSHandler)
|
|
35
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
36
|
+
thread.start()
|
|
37
|
+
issuer = f"http://127.0.0.1:{server.server_port}"
|
|
38
|
+
verifier = Verifier(issuer, fixture["audience"], fixture["application_id"])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def token_for(mutation: str) -> str:
|
|
42
|
+
now = int(time.time())
|
|
43
|
+
headers = {"alg": "RS256", "typ": "JWT", "kid": "primary"}
|
|
44
|
+
claims = {
|
|
45
|
+
"iss": issuer,
|
|
46
|
+
"sub": "user-1",
|
|
47
|
+
"aud": [fixture["audience"]],
|
|
48
|
+
"exp": now + 300,
|
|
49
|
+
"iat": now,
|
|
50
|
+
"nbf": now - 1,
|
|
51
|
+
"application_id": fixture["application_id"],
|
|
52
|
+
"token_kind": "access",
|
|
53
|
+
"actor_type": "user",
|
|
54
|
+
"scope": "/applications/app/profile/read",
|
|
55
|
+
}
|
|
56
|
+
signing_key = primary
|
|
57
|
+
if mutation == "machine": claims.update(token_kind="machine", actor_type="client")
|
|
58
|
+
elif mutation == "delegated": claims["act"] = {"sub": "operator-1", "type": "operator"}
|
|
59
|
+
elif mutation == "wrong_issuer": claims["iss"] = "https://wrong.example"
|
|
60
|
+
elif mutation == "wrong_audience": claims["aud"] = ["wrong-api"]
|
|
61
|
+
elif mutation == "wrong_application": claims["application_id"] = "01900000-0000-7000-8000-000000000000"
|
|
62
|
+
elif mutation == "expired": claims["exp"] = now - 60
|
|
63
|
+
elif mutation == "future_nbf": claims["nbf"] = now + 300
|
|
64
|
+
elif mutation == "future_iat": claims["iat"] = now + 300
|
|
65
|
+
elif mutation == "missing_sub": claims.pop("sub")
|
|
66
|
+
elif mutation == "missing_application": claims.pop("application_id")
|
|
67
|
+
elif mutation == "missing_token_kind": claims.pop("token_kind")
|
|
68
|
+
elif mutation == "missing_actor_type": claims.pop("actor_type")
|
|
69
|
+
elif mutation == "operator_actor": claims["actor_type"] = "operator"
|
|
70
|
+
elif mutation == "missing_kid": headers.pop("kid")
|
|
71
|
+
elif mutation == "unknown_kid": headers["kid"] = "unknown"
|
|
72
|
+
elif mutation == "wrong_signature": signing_key = wrong
|
|
73
|
+
elif mutation == "delegated_wrong_actor_type": claims["act"] = {"sub": "operator-1", "type": "user"}
|
|
74
|
+
token = jwt.encode(claims, signing_key, algorithm="RS256", headers=headers)
|
|
75
|
+
if mutation == "wrong_algorithm":
|
|
76
|
+
parts = token.split(".")
|
|
77
|
+
parts[0] = base64.urlsafe_b64encode(json.dumps({**headers, "alg": "HS256"}).encode()).rstrip(b"=").decode()
|
|
78
|
+
token = ".".join(parts)
|
|
79
|
+
return token
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
for case in fixture["cases"]:
|
|
84
|
+
accepted = True
|
|
85
|
+
try:
|
|
86
|
+
verifier.verify(token_for(case["mutation"]))
|
|
87
|
+
except Exception:
|
|
88
|
+
accepted = False
|
|
89
|
+
assert accepted == case["accept"], case["name"]
|
|
90
|
+
finally:
|
|
91
|
+
server.shutdown()
|