supaapps-platform93-auth 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.
- supaapps_platform93_auth-0.1.0/.gitignore +38 -0
- supaapps_platform93_auth-0.1.0/PKG-INFO +36 -0
- supaapps_platform93_auth-0.1.0/README.md +17 -0
- supaapps_platform93_auth-0.1.0/pyproject.toml +25 -0
- supaapps_platform93_auth-0.1.0/src/platform93_auth/__init__.py +38 -0
- supaapps_platform93_auth-0.1.0/src/platform93_auth/django.py +18 -0
- supaapps_platform93_auth-0.1.0/src/platform93_auth/fastapi.py +17 -0
- supaapps_platform93_auth-0.1.0/src/platform93_auth/flask.py +23 -0
- supaapps_platform93_auth-0.1.0/src/platform93_auth/machine.py +295 -0
- supaapps_platform93_auth-0.1.0/src/platform93_auth/verifier.py +145 -0
- supaapps_platform93_auth-0.1.0/tests/conformance.py +104 -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,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,17 @@
|
|
|
1
|
+
# supaapps-platform93-auth
|
|
2
|
+
|
|
3
|
+
Strict Platform93 JWT verification plus a backend-only `MachineClient`. The machine
|
|
4
|
+
client uses OAuth client credentials and provides notification, invitation, user,
|
|
5
|
+
workspace, entitlement, billing-summary, and custom-event methods. Keep its client
|
|
6
|
+
secret in backend secret storage and never expose it to browser code.
|
|
7
|
+
|
|
8
|
+
Strict server-side verification for Platform93 application JWTs, including issuer,
|
|
9
|
+
audience, actor type, application boundary, expiry, and JWKS rotation checks.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install supaapps-platform93-auth
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Framework adapters are available through the `fastapi`, `django`, and `flask` extras.
|
|
16
|
+
See the [Platform93 repository](https://github.com/supaapps/platform93) for contracts
|
|
17
|
+
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"
|
|
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,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,104 @@
|
|
|
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": f"/applications/{fixture['application_id']}/profile/read",
|
|
55
|
+
"roles": {"application": ["member"], "workspaces": {}},
|
|
56
|
+
}
|
|
57
|
+
signing_key = primary
|
|
58
|
+
if mutation == "machine": claims.update(token_kind="machine", actor_type="client")
|
|
59
|
+
elif mutation == "delegated":
|
|
60
|
+
claims["act"] = {"sub": "control_user-1", "type": "control_user"}
|
|
61
|
+
claims["roles"] = {"application": [], "workspaces": {}}
|
|
62
|
+
elif mutation == "wrong_issuer": claims["iss"] = "https://wrong.example"
|
|
63
|
+
elif mutation == "wrong_audience": claims["aud"] = ["wrong-api"]
|
|
64
|
+
elif mutation == "wrong_application": claims["application_id"] = "01900000-0000-7000-8000-000000000000"
|
|
65
|
+
elif mutation == "expired": claims["exp"] = now - 60
|
|
66
|
+
elif mutation == "future_nbf": claims["nbf"] = now + 300
|
|
67
|
+
elif mutation == "future_iat": claims["iat"] = now + 300
|
|
68
|
+
elif mutation == "missing_sub": claims.pop("sub")
|
|
69
|
+
elif mutation == "missing_application": claims.pop("application_id")
|
|
70
|
+
elif mutation == "missing_token_kind": claims.pop("token_kind")
|
|
71
|
+
elif mutation == "missing_actor_type": claims.pop("actor_type")
|
|
72
|
+
elif mutation == "missing_roles": claims.pop("roles")
|
|
73
|
+
elif mutation == "space_injected_scope": claims["scope"] += f" /applications/{fixture['application_id']}/billing/write"
|
|
74
|
+
elif mutation == "tab_injected_scope": claims["scope"] += f"\t/applications/{fixture['application_id']}/billing/write"
|
|
75
|
+
elif mutation == "unicode_injected_scope": claims["scope"] += f"\u200b/applications/{fixture['application_id']}/billing/write"
|
|
76
|
+
elif mutation == "encoded_space_scope": claims["scope"] = f"/applications/{fixture['application_id']}/billing%20write"
|
|
77
|
+
elif mutation == "cross_application_scope": claims["scope"] = "/applications/01900000-0000-7000-8000-000000000000/billing/read"
|
|
78
|
+
elif mutation == "embedded_wildcard_scope": claims["scope"] = f"/applications/{fixture['application_id']}/billing/*/write"
|
|
79
|
+
elif mutation == "duplicate_scope": claims["scope"] += f" {claims['scope']}"
|
|
80
|
+
elif mutation == "invalid_roles": claims["roles"] = {"application": ["billing admin"], "workspaces": {}}
|
|
81
|
+
elif mutation == "delegated_roles": claims["act"] = {"sub": "control_user-1", "type": "control_user"}
|
|
82
|
+
elif mutation == "control_user_actor": claims["actor_type"] = "control_user"
|
|
83
|
+
elif mutation == "missing_kid": headers.pop("kid")
|
|
84
|
+
elif mutation == "unknown_kid": headers["kid"] = "unknown"
|
|
85
|
+
elif mutation == "wrong_signature": signing_key = wrong
|
|
86
|
+
elif mutation == "delegated_wrong_actor_type": claims["act"] = {"sub": "control_user-1", "type": "user"}
|
|
87
|
+
token = jwt.encode(claims, signing_key, algorithm="RS256", headers=headers)
|
|
88
|
+
if mutation == "wrong_algorithm":
|
|
89
|
+
parts = token.split(".")
|
|
90
|
+
parts[0] = base64.urlsafe_b64encode(json.dumps({**headers, "alg": "HS256"}).encode()).rstrip(b"=").decode()
|
|
91
|
+
token = ".".join(parts)
|
|
92
|
+
return token
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
for case in fixture["cases"]:
|
|
97
|
+
accepted = True
|
|
98
|
+
try:
|
|
99
|
+
verifier.verify(token_for(case["mutation"]))
|
|
100
|
+
except Exception:
|
|
101
|
+
accepted = False
|
|
102
|
+
assert accepted == case["accept"], case["name"]
|
|
103
|
+
finally:
|
|
104
|
+
server.shutdown()
|