supaapps-platform93-auth 0.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,38 @@
1
+ from .verifier import Claims, Verifier
2
+ from .machine import (
3
+ BillingSummary,
4
+ CustomEventInput,
5
+ EntitlementPage,
6
+ Invitation,
7
+ InvitationInput,
8
+ InvitationPage,
9
+ MachineClient,
10
+ NotificationInput,
11
+ PublishedEvent,
12
+ QueuedNotification,
13
+ User,
14
+ UserPage,
15
+ Workspace,
16
+ WorkspaceAccessPage,
17
+ WorkspacePage,
18
+ )
19
+
20
+ __all__ = [
21
+ "BillingSummary",
22
+ "Claims",
23
+ "CustomEventInput",
24
+ "EntitlementPage",
25
+ "Invitation",
26
+ "InvitationInput",
27
+ "InvitationPage",
28
+ "MachineClient",
29
+ "NotificationInput",
30
+ "PublishedEvent",
31
+ "QueuedNotification",
32
+ "User",
33
+ "UserPage",
34
+ "Verifier",
35
+ "Workspace",
36
+ "WorkspaceAccessPage",
37
+ "WorkspacePage",
38
+ ]
@@ -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,295 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from typing import NotRequired, TypedDict, cast
6
+ from urllib.parse import quote
7
+
8
+ import httpx
9
+
10
+
11
+ JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
12
+
13
+
14
+ class NotificationInput(TypedDict):
15
+ template_key: str
16
+ user_id: NotRequired[str]
17
+ recipient: NotRequired[str]
18
+ locale: NotRequired[str]
19
+ variables: NotRequired[dict[str, JsonValue]]
20
+
21
+
22
+ class QueuedNotification(TypedDict):
23
+ id: str
24
+ status: str
25
+ requested_locale: str
26
+ resolved_locale: str
27
+ fallback_used: bool
28
+
29
+
30
+ class InvitationInput(TypedDict):
31
+ email: str
32
+ workspace_id: NotRequired[str]
33
+ application_role_keys: NotRequired[list[str]]
34
+ workspace_role_keys: NotRequired[list[str]]
35
+ expires_in: NotRequired[int]
36
+
37
+
38
+ class Invitation(TypedDict):
39
+ id: str
40
+ email: str
41
+ workspace_id: str | None
42
+ application_role_keys: list[str]
43
+ workspace_role_keys: list[str]
44
+ status: str
45
+ expires_at: str
46
+
47
+
48
+ class InvitationPage(TypedDict):
49
+ items: list[Invitation]
50
+ next_cursor: str | None
51
+
52
+
53
+ class InvitationResent(TypedDict):
54
+ id: str
55
+ last_sent_at: str
56
+ resend_available_at: str
57
+ expires_at: str
58
+
59
+
60
+ class User(TypedDict):
61
+ id: str
62
+ application_id: str
63
+ email: str
64
+ first_name: str
65
+ last_name: str
66
+ username: str | None
67
+ locale: str
68
+ email_verified: bool
69
+ is_org_verified: bool
70
+ status: str
71
+ custom_attributes: dict[str, JsonValue]
72
+ version: int
73
+
74
+
75
+ class UserPage(TypedDict):
76
+ items: list[User]
77
+ next_cursor: str | None
78
+
79
+
80
+ class Workspace(TypedDict):
81
+ id: str
82
+ application_id: str
83
+ owner_user_id: str
84
+ key: str
85
+ name: str
86
+ metadata: dict[str, JsonValue]
87
+ status: str
88
+ version: int
89
+ created_at: str
90
+ updated_at: str
91
+
92
+
93
+ class WorkspacePage(TypedDict):
94
+ items: list[Workspace]
95
+ next_cursor: str | None
96
+
97
+
98
+ class WorkspaceAccess(TypedDict):
99
+ type: str
100
+ workspace_id: str
101
+ user_id: str | None
102
+ email: str | None
103
+ role_keys: list[str]
104
+ invitation_id: str | None
105
+ status: str
106
+ expires_at: str | None
107
+
108
+
109
+ class WorkspaceAccessPage(TypedDict):
110
+ items: list[WorkspaceAccess]
111
+ next_cursor: str | None
112
+
113
+
114
+ class EntitlementGrant(TypedDict):
115
+ id: str
116
+ subject_type: str
117
+ subject_id: str
118
+ source_type: str
119
+ feature_values: dict[str, JsonValue]
120
+ configuration: dict[str, JsonValue]
121
+ starts_at: str
122
+ expires_at: str | None
123
+ external_reference: str | None
124
+
125
+
126
+ class EntitlementPage(TypedDict):
127
+ items: list[EntitlementGrant]
128
+ next_cursor: str | None
129
+
130
+
131
+ class BillingProfile(TypedDict):
132
+ id: str
133
+ subject_type: str
134
+ subject_id: str
135
+ name: str | None
136
+ email: str | None
137
+ tax_id: str | None
138
+ version: int
139
+
140
+
141
+ class Subscription(TypedDict):
142
+ id: str
143
+ subject_type: str
144
+ subject_id: str
145
+ price_id: str
146
+ provider_id: str
147
+ status: str
148
+ external_reference: str | None
149
+
150
+
151
+ class BillingSummary(TypedDict):
152
+ subject_type: str
153
+ subject_id: str
154
+ billing_profile: BillingProfile | None
155
+ subscriptions: list[Subscription]
156
+
157
+
158
+ class CustomEventInput(TypedDict):
159
+ type: str
160
+ subject: str
161
+ data: dict[str, JsonValue]
162
+ correlation_id: NotRequired[str]
163
+ causation_id: NotRequired[str]
164
+
165
+
166
+ class PublishedEvent(TypedDict):
167
+ id: str
168
+ specversion: str
169
+ source: str
170
+ type: str
171
+ subject: str
172
+ schema_version: str
173
+ data: dict[str, JsonValue]
174
+
175
+
176
+ class MachineClient:
177
+ """Server-only Platform93 client with cached OAuth client credentials."""
178
+
179
+ def __init__(
180
+ self,
181
+ *,
182
+ base_url: str,
183
+ application_id: str,
184
+ client_id: str,
185
+ client_secret: str,
186
+ scopes: list[str] | None = None,
187
+ client: httpx.Client | None = None,
188
+ ) -> None:
189
+ if not all((base_url, application_id, client_id, client_secret)):
190
+ raise ValueError("Platform93 machine client requires base_url, application_id, client_id, and client_secret")
191
+ self.base_url = base_url.rstrip("/")
192
+ self.application_id = application_id
193
+ self.client_id = client_id
194
+ self.client_secret = client_secret
195
+ self.scopes = scopes or []
196
+ self.client = client or httpx.Client(timeout=30.0)
197
+ self._token: str | None = None
198
+ self._expires_at = 0.0
199
+ self._lock = threading.Lock()
200
+
201
+ def update_secret(self, secret: str) -> None:
202
+ if not secret:
203
+ raise ValueError("Platform93 machine client secret is required")
204
+ with self._lock:
205
+ self.client_secret = secret
206
+ self._token = None
207
+ self._expires_at = 0.0
208
+
209
+ def send_notification(self, notification: NotificationInput, idempotency_key: str) -> QueuedNotification:
210
+ return cast(QueuedNotification, self._request("POST", "/notifications", notification, idempotency_key))
211
+
212
+ def create_invitation(self, invitation: InvitationInput) -> Invitation:
213
+ return cast(Invitation, self._request("POST", "/invitations", invitation))
214
+
215
+ def list_invitations(self) -> InvitationPage:
216
+ return cast(InvitationPage, self._request("GET", "/invitations"))
217
+
218
+ def get_invitation(self, invitation_id: str) -> Invitation:
219
+ return cast(Invitation, self._request("GET", f"/invitations/{quote(invitation_id, safe='')}"))
220
+
221
+ def resend_invitation(self, invitation_id: str) -> InvitationResent:
222
+ return cast(InvitationResent, self._request("POST", f"/invitations/{quote(invitation_id, safe='')}/resend"))
223
+
224
+ def revoke_invitation(self, invitation_id: str) -> None:
225
+ self._request("DELETE", f"/invitations/{quote(invitation_id, safe='')}")
226
+
227
+ def list_users(self) -> UserPage:
228
+ return cast(UserPage, self._request("GET", "/users"))
229
+
230
+ def get_user(self, user_id: str) -> User:
231
+ return cast(User, self._request("GET", f"/users/{quote(user_id, safe='')}"))
232
+
233
+ def list_workspaces(self) -> WorkspacePage:
234
+ return cast(WorkspacePage, self._request("GET", "/workspaces"))
235
+
236
+ def get_workspace(self, workspace_id: str) -> Workspace:
237
+ return cast(Workspace, self._request("GET", f"/service/workspaces/{quote(workspace_id, safe='')}"))
238
+
239
+ def get_workspace_access(self, workspace_id: str) -> WorkspaceAccessPage:
240
+ return cast(WorkspaceAccessPage, self._request("GET", f"/service/workspaces/{quote(workspace_id, safe='')}/access"))
241
+
242
+ def get_entitlements(self, subject_type: str, subject_id: str) -> EntitlementPage:
243
+ return cast(EntitlementPage, self._request("GET", f"/subjects/{self._subject(subject_type, subject_id)}/entitlements"))
244
+
245
+ def get_billing(self, subject_type: str, subject_id: str) -> BillingSummary:
246
+ return cast(BillingSummary, self._request("GET", f"/subjects/{self._subject(subject_type, subject_id)}/billing"))
247
+
248
+ def publish_event(self, event: CustomEventInput, idempotency_key: str) -> PublishedEvent:
249
+ return cast(PublishedEvent, self._request("POST", "/events", event, idempotency_key))
250
+
251
+ def _subject(self, subject_type: str, subject_id: str) -> str:
252
+ if subject_type not in {"user", "workspace"}:
253
+ raise ValueError("Platform93 subject type must be user or workspace")
254
+ return f"{subject_type}/{quote(subject_id, safe='')}"
255
+
256
+ def _access_token(self) -> str:
257
+ with self._lock:
258
+ if self._token and time.time() < self._expires_at - 30:
259
+ return self._token
260
+ data = {"grant_type": "client_credentials"}
261
+ if self.scopes:
262
+ data["scope"] = " ".join(self.scopes)
263
+ response = self.client.post(
264
+ f"{self.base_url}/oidc/token",
265
+ data=data,
266
+ auth=(self.client_id, self.client_secret),
267
+ headers={"Accept": "application/json"},
268
+ )
269
+ response.raise_for_status()
270
+ payload = response.json()
271
+ token = payload.get("access_token")
272
+ if not isinstance(token, str) or not token:
273
+ raise RuntimeError("Platform93 token response did not contain an access token")
274
+ self._token = token
275
+ self._expires_at = time.time() + max(1, int(payload.get("expires_in", 300)))
276
+ return token
277
+
278
+ def _request(
279
+ self,
280
+ method: str,
281
+ path: str,
282
+ body: dict[str, object] | None = None,
283
+ idempotency_key: str | None = None,
284
+ ) -> dict[str, object]:
285
+ headers = {"Accept": "application/json", "Authorization": f"Bearer {self._access_token()}"}
286
+ if idempotency_key:
287
+ headers["Idempotency-Key"] = idempotency_key
288
+ response = self.client.request(
289
+ method,
290
+ f"{self.base_url}/v1/applications/{quote(self.application_id, safe='')}{path}",
291
+ json=body,
292
+ headers=headers,
293
+ )
294
+ response.raise_for_status()
295
+ return response.json() if response.content else {}
@@ -0,0 +1,145 @@
1
+ from dataclasses import dataclass
2
+ import re
3
+ from typing import Any
4
+
5
+ import jwt
6
+ from jwt import PyJWKClient
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Claims:
11
+ values: dict[str, Any]
12
+
13
+ @property
14
+ def locale(self) -> str:
15
+ return str(self.values.get("locale", ""))
16
+
17
+ @property
18
+ def custom_claims(self) -> dict[str, Any]:
19
+ value = self.values.get("custom_claims", {})
20
+ return value if isinstance(value, dict) else {}
21
+
22
+ @property
23
+ def roles(self) -> dict[str, Any]:
24
+ return self.values["roles"]
25
+
26
+ def has_permission(self, permission: str) -> bool:
27
+ if not _valid_absolute_permission(permission):
28
+ return False
29
+ return any(
30
+ value == permission
31
+ or (
32
+ value.endswith("/*")
33
+ and (permission == value[:-2] or permission.startswith(value[:-1]))
34
+ )
35
+ for value in self.values.get("scope", "").split(" ")
36
+ if _valid_absolute_permission(value)
37
+ )
38
+
39
+
40
+ class Verifier:
41
+ def __init__(self, issuer: str, audience: str, application_id: str):
42
+ self.issuer = issuer.rstrip("/")
43
+ self.audience = audience
44
+ self.application_id = application_id
45
+ self.jwks = PyJWKClient(
46
+ f"{self.issuer}/jwks.json", cache_jwk_set=True, lifespan=300
47
+ )
48
+
49
+ def verify(self, token: str) -> Claims:
50
+ header = jwt.get_unverified_header(token)
51
+ if (
52
+ header.get("alg") != "RS256"
53
+ or header.get("typ") != "JWT"
54
+ or not isinstance(header.get("kid"), str)
55
+ or not header["kid"]
56
+ ):
57
+ raise jwt.InvalidTokenError("Platform93 token algorithm rejected")
58
+ key = self.jwks.get_signing_key_from_jwt(token)
59
+ values = jwt.decode(
60
+ token,
61
+ key.key,
62
+ algorithms=["RS256"],
63
+ audience=self.audience,
64
+ issuer=self.issuer,
65
+ options={
66
+ "require": [
67
+ "exp", "iat", "nbf", "iss", "sub", "aud",
68
+ "application_id", "token_kind", "actor_type", "scope", "roles",
69
+ ]
70
+ },
71
+ )
72
+ if (
73
+ not isinstance(values.get("sub"), str)
74
+ or not values["sub"]
75
+ or values.get("application_id") != self.application_id
76
+ or not (
77
+ values.get("token_kind") == "access"
78
+ and values.get("actor_type") == "user"
79
+ or values.get("token_kind") == "machine"
80
+ and values.get("actor_type") == "client"
81
+ )
82
+ or not isinstance(values.get("scope"), str)
83
+ ):
84
+ raise jwt.InvalidTokenError("Platform93 token context rejected")
85
+ actor = values.get("act")
86
+ if actor is not None and (
87
+ values["token_kind"] != "access"
88
+ or not isinstance(actor, dict)
89
+ or actor.get("type") != "control_user"
90
+ or not isinstance(actor.get("sub"), str)
91
+ or not actor["sub"]
92
+ ):
93
+ raise jwt.InvalidTokenError("Platform93 delegated token actor rejected")
94
+ if not _valid_scope_claim(values["scope"], self.application_id) or not _valid_roles_claim(values.get("roles")):
95
+ raise jwt.InvalidTokenError("Platform93 token authorization claims rejected")
96
+ if actor is not None and (values["roles"]["application"] or values["roles"]["workspaces"]):
97
+ raise jwt.InvalidTokenError("Platform93 delegated token roles rejected")
98
+ return Claims(values)
99
+
100
+
101
+ def _valid_scope_claim(scope: Any, application_id: str) -> bool:
102
+ if not isinstance(scope, str):
103
+ return False
104
+ if scope == "":
105
+ return True
106
+ if scope != scope.strip() or any(value in scope for value in ("\t", "\r", "\n", " ")):
107
+ return False
108
+ values = scope.split(" ")
109
+ if len(values) != len(set(values)):
110
+ return False
111
+ prefix = f"/applications/{application_id}/"
112
+ return all(value in _PROTOCOL_SCOPES or value.startswith(prefix) and _valid_absolute_permission(value) for value in values)
113
+
114
+
115
+ def _valid_absolute_permission(value: str) -> bool:
116
+ if not isinstance(value, str) or not value.startswith("/") or any(character in value for character in " :\\%\t\r\n"):
117
+ return False
118
+ segments = value[1:].split("/")
119
+ return len(segments) >= 3 and all(
120
+ index == len(segments) - 1 if segment == "*" else bool(_SEGMENT.fullmatch(segment))
121
+ for index, segment in enumerate(segments)
122
+ )
123
+
124
+
125
+ def _valid_roles_claim(value: Any) -> bool:
126
+ if not isinstance(value, dict) or set(value) != {"application", "workspaces"}:
127
+ return False
128
+ application, workspaces = value["application"], value["workspaces"]
129
+ if not isinstance(application, list) or not isinstance(workspaces, dict) or not _unique_roles(application):
130
+ return False
131
+ return all(
132
+ bool(_WORKSPACE_KEY.fullmatch(workspace_id)) and isinstance(roles, list) and _unique_roles(roles)
133
+ for workspace_id, roles in workspaces.items()
134
+ )
135
+
136
+
137
+ def _unique_roles(values: list[Any]) -> bool:
138
+ if not all(isinstance(value, str) and _ROLE_KEY.fullmatch(value) for value in values):
139
+ return False
140
+ return len(values) == len(set(values))
141
+
142
+ _SEGMENT = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
143
+ _ROLE_KEY = re.compile(r"^[a-z][a-z0-9_-]{0,62}$")
144
+ _WORKSPACE_KEY = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
145
+ _PROTOCOL_SCOPES = {"openid", "profile", "email", "offline_access"}
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.5
2
+ Name: supaapps-platform93-auth
3
+ Version: 0.1.0
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 Platform93 JWT verification plus a backend-only `MachineClient`. The machine
23
+ client uses OAuth client credentials and provides notification, invitation, user,
24
+ workspace, entitlement, billing-summary, and custom-event methods. Keep its client
25
+ secret in backend secret storage and never expose it to browser code.
26
+
27
+ Strict server-side verification for Platform93 application JWTs, including issuer,
28
+ audience, actor type, application boundary, expiry, and JWKS rotation checks.
29
+
30
+ ```bash
31
+ pip install supaapps-platform93-auth
32
+ ```
33
+
34
+ Framework adapters are available through the `fastapi`, `django`, and `flask` extras.
35
+ See the [Platform93 repository](https://github.com/supaapps/platform93) for contracts
36
+ and integration documentation.
@@ -0,0 +1,9 @@
1
+ platform93_auth/__init__.py,sha256=OwWSmYo2KWF6EiQ1zhIFpLRY1uIBLTprx9U6g9HMc3U,717
2
+ platform93_auth/django.py,sha256=VSodLcCVmRuz1xOMPbg1G-_w5c1ocf4B_periGLcneo,697
3
+ platform93_auth/fastapi.py,sha256=pQt1XCr6vauQCpxTbUlRri5xhMQk8RAmJM8TIpYBBrI,620
4
+ platform93_auth/flask.py,sha256=bKz-0QTy_e3RKX4iV0kVZ7N0-Fbh81yEsIMM6PH9z-M,724
5
+ platform93_auth/machine.py,sha256=P0hpYn54hO1XWZR7Y54sFhPigVqLEOjQlS2T60tjezs,8894
6
+ platform93_auth/verifier.py,sha256=PtImj8nnCjDBahaMQ33jy_dnGWmV4vYfLSWn-CsYLhc,5516
7
+ supaapps_platform93_auth-0.1.0.dist-info/METADATA,sha256=4wS7JObp54GOuJgPA_og0EKxfI8pdq-FuN3zj_vHpwY,1441
8
+ supaapps_platform93_auth-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ supaapps_platform93_auth-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any