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.
@@ -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,3 @@
1
+ from .verifier import Claims, Verifier
2
+
3
+ __all__ = ["Claims", "Verifier"]
@@ -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()