toolframe-oidc-auth 0.2.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 @@
1
+ """OIDC authentication and OAuth delegation through public Toolframe services."""
@@ -0,0 +1,345 @@
1
+ """Authlib authorization server adapted to Toolframe's asynchronous stores.
2
+
3
+ Each call runs in a worker thread while storage stays on the owning event loop.
4
+ The caller holds a provider lock and one managed SQL transaction for code/token
5
+ consumption and issuance. OAuth validation remains in Authlib's grant classes.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import secrets
11
+ import time
12
+ import uuid
13
+ from concurrent.futures import TimeoutError as FutureTimeout
14
+ from urllib.parse import parse_qs, urlsplit
15
+
16
+ from authlib.oauth2.rfc6749 import (
17
+ AuthorizationServer,
18
+ InvalidGrantError,
19
+ InvalidRequestError,
20
+ OAuth2Request,
21
+ )
22
+ from authlib.oauth2.rfc6749.grants import AuthorizationCodeGrant, RefreshTokenGrant
23
+ from authlib.oauth2.rfc6749.requests import BasicOAuth2Payload, JsonRequest
24
+ from authlib.oauth2.rfc6750 import BearerTokenGenerator
25
+ from authlib.oauth2.rfc7591 import ClientRegistrationEndpoint
26
+ from authlib.oauth2.rfc7591.errors import InvalidClientMetadataError
27
+ from authlib.oauth2.rfc7636 import CodeChallenge
28
+ from authlib.oauth2.rfc9207 import IssuerParameter
29
+ from starlette.datastructures import Headers
30
+ from toolframe_sdk import PluginError
31
+
32
+ from .models import Client, Code, Token, digest, valid_redirect
33
+
34
+
35
+ class Request(OAuth2Request):
36
+ def __init__(self, method, uri, body="", headers=None):
37
+ super().__init__(method, uri, headers=Headers(headers or {}))
38
+ pairs = parse_qs(
39
+ urlsplit(uri).query if method == "GET" else body, keep_blank_values=True
40
+ )
41
+ if any(len(values) != 1 for values in pairs.values()):
42
+ raise InvalidRequestError("Duplicate OAuth parameters")
43
+ self.values = {key: values[0] for key, values in pairs.items()}
44
+ self.payload = BasicOAuth2Payload(self.values)
45
+
46
+ @property
47
+ def form(self):
48
+ return self.values if self.method == "POST" else {}
49
+
50
+ @property
51
+ def args(self):
52
+ return self.values if self.method == "GET" else {}
53
+
54
+
55
+ class Reply:
56
+ def __init__(self, status, body, headers):
57
+ self.status, self.body, self.headers = status, body, dict(headers)
58
+
59
+ @property
60
+ def location(self):
61
+ return self.headers.get("Location")
62
+
63
+ @location.setter
64
+ def location(self, value):
65
+ self.headers["Location"] = value
66
+
67
+ def result(self):
68
+ return {
69
+ "status": self.status,
70
+ "body": json.dumps(self.body) if isinstance(self.body, dict) else self.body,
71
+ "headers": self.headers,
72
+ }
73
+
74
+
75
+ class S256(CodeChallenge):
76
+ SUPPORTED_CODE_CHALLENGE_METHOD = ("S256",)
77
+
78
+ def validate_code_challenge(self, grant, redirect_uri):
79
+ if grant.request.payload.data.get("code_challenge_method") != "S256":
80
+ raise InvalidRequestError("PKCE S256 is required")
81
+ return super().validate_code_challenge(grant, redirect_uri)
82
+
83
+
84
+ class Issuer(IssuerParameter):
85
+ def __init__(self, issuer):
86
+ self.issuer = issuer
87
+
88
+ def get_issuer(self):
89
+ return self.issuer
90
+
91
+
92
+ class CodeGrant(AuthorizationCodeGrant):
93
+ TOKEN_ENDPOINT_AUTH_METHODS = ("none",)
94
+
95
+ def save_authorization_code(self, code, request):
96
+ data = {
97
+ "client_id": request.client.client_id,
98
+ "redirect_uri": request.payload.redirect_uri,
99
+ "scope": request.scope,
100
+ "code_challenge": request.payload.data["code_challenge"],
101
+ "resource": request.payload.data["resource"],
102
+ "grant_id": request.user["id"],
103
+ }
104
+ self.server.sync(
105
+ self.server.store.execute(
106
+ "INSERT INTO oauth_codes(digest,data,expires_at) VALUES(?,?,?)",
107
+ [digest(code), json.dumps(data), time.time() + 60],
108
+ )
109
+ )
110
+
111
+ def query_authorization_code(self, code, client):
112
+ rows = self.server.sync(
113
+ self.server.store.query(
114
+ "SELECT * FROM oauth_codes WHERE digest=?", [digest(code)]
115
+ )
116
+ )
117
+ if not rows or rows[0]["used"] or rows[0]["expires_at"] <= time.time():
118
+ return None
119
+ value = Code(rows[0])
120
+ if value.data["client_id"] != client.client_id or value.data[
121
+ "resource"
122
+ ] != self.request.values.get("resource"):
123
+ return None
124
+ return value
125
+
126
+ def delete_authorization_code(self, code):
127
+ self.server.sync(
128
+ self.server.store.execute(
129
+ "UPDATE oauth_codes SET used=1 WHERE digest=?", [code.digest]
130
+ )
131
+ )
132
+
133
+ def authenticate_user(self, code):
134
+ return self.server.active_grant(code.data["grant_id"], code.get_scope())
135
+
136
+
137
+ class RefreshGrant(RefreshTokenGrant):
138
+ TOKEN_ENDPOINT_AUTH_METHODS = ("none",)
139
+ INCLUDE_NEW_REFRESH_TOKEN = True
140
+
141
+ def authenticate_refresh_token(self, value):
142
+ rows = self.server.sync(
143
+ self.server.store.query(
144
+ "SELECT * FROM oauth_tokens WHERE refresh_digest=?", [digest(value)]
145
+ )
146
+ )
147
+ if not rows:
148
+ return None
149
+ token = Token(rows[0])
150
+ if not token.check_client(self.request.client):
151
+ return None
152
+ if token.data["refresh_used"]:
153
+ # Keep this denial's revocation in the committed SQL transaction.
154
+ self.server.sync(
155
+ self.server.store.execute(
156
+ "UPDATE oauth_tokens SET revoked=1 WHERE grant_id=?",
157
+ [token.data["grant_id"]],
158
+ )
159
+ )
160
+ self.server.sync(
161
+ self.server.plugin.context.call(
162
+ "auth", "revoke_grant", {"id": token.data["grant_id"]}
163
+ )
164
+ )
165
+ return None
166
+ if (
167
+ token.data["revoked"]
168
+ or token.data["refresh_expires_at"] <= time.time()
169
+ or token.data["resource"] != self.request.values.get("resource")
170
+ ):
171
+ return None
172
+ return token
173
+
174
+ def authenticate_user(self, token):
175
+ return self.server.active_grant(
176
+ token.data["grant_id"], self.request.payload.scope or token.get_scope()
177
+ )
178
+
179
+ def revoke_old_credential(self, token):
180
+ self.server.sync(
181
+ self.server.store.execute(
182
+ "UPDATE oauth_tokens SET refresh_used=1 WHERE id=?", [token.data["id"]]
183
+ )
184
+ )
185
+
186
+
187
+ class Registration(ClientRegistrationEndpoint):
188
+ def extract_client_metadata(self, request):
189
+ # This AS registers public clients only. Select and explicitly return
190
+ # 'none' when a client leaves its authentication method to the server.
191
+ # Explicit requests for secret-based methods remain validation errors.
192
+ if "token_endpoint_auth_method" not in request.payload.data:
193
+ request.payload.data["token_endpoint_auth_method"] = "none"
194
+ return super().extract_client_metadata(request)
195
+
196
+ def authenticate_token(self, request):
197
+ return self.server.plugin.config.get("dynamic_registration", False)
198
+
199
+ def generate_client_info(self, request):
200
+ return {
201
+ "client_id": secrets.token_urlsafe(24),
202
+ "client_id_issued_at": int(time.time()),
203
+ }
204
+
205
+ def get_server_metadata(self):
206
+ return self.server.plugin.metadata()
207
+
208
+ def save_client(self, info, metadata, request):
209
+ redirects = metadata.get("redirect_uris", [])
210
+ if (
211
+ not isinstance(redirects, list)
212
+ or not 1 <= len(redirects) <= 10
213
+ or not all(valid_redirect(uri) for uri in redirects)
214
+ or metadata.get("token_endpoint_auth_method", "client_secret_basic")
215
+ != "none"
216
+ ):
217
+ raise InvalidClientMetadataError(
218
+ "Public clients require exact HTTPS or loopback redirects and auth method none"
219
+ )
220
+ name = metadata.get("client_name", "Agent")
221
+ if not isinstance(name, str) or not 1 <= len(name) <= 120:
222
+ raise InvalidClientMetadataError(
223
+ "Supply a client name up to 120 characters"
224
+ )
225
+ count = self.server.sync(
226
+ self.server.store.query("SELECT COUNT(*) AS n FROM oauth_clients")
227
+ )[0]["n"]
228
+ if count >= self.server.plugin.config.get("max_clients", 1000):
229
+ raise InvalidClientMetadataError(
230
+ "Client registration capacity reached; contact the operator"
231
+ )
232
+ scopes = metadata.get("scope", " ".join(self.server.plugin.scopes)).split()
233
+ if not set(scopes) <= set(self.server.plugin.scopes):
234
+ raise InvalidClientMetadataError("Unknown client scopes")
235
+ metadata.update(
236
+ scope=" ".join(scopes),
237
+ client_name=name,
238
+ grant_types=metadata.get(
239
+ "grant_types", ["authorization_code", "refresh_token"]
240
+ ),
241
+ )
242
+ self.server.sync(
243
+ self.server.store.execute(
244
+ "INSERT INTO oauth_clients VALUES(?,?,?)",
245
+ [info["client_id"], json.dumps(metadata), time.time()],
246
+ )
247
+ )
248
+ return Client(info["client_id"], metadata)
249
+
250
+
251
+ class Server(AuthorizationServer):
252
+ def __init__(self, plugin, loop, store):
253
+ super().__init__(scopes_supported=plugin.scopes)
254
+ self.plugin, self.loop, self.store = plugin, loop, store
255
+ self.register_grant(CodeGrant, [S256(required=True)])
256
+ self.register_grant(RefreshGrant)
257
+ self.register_extension(Issuer(plugin.issuer))
258
+ self.register_endpoint(Registration)
259
+ self.register_token_generator(
260
+ "default",
261
+ BearerTokenGenerator(
262
+ lambda **_: "tf_oauth_" + secrets.token_urlsafe(32),
263
+ lambda **_: "tf_refresh_" + secrets.token_urlsafe(32),
264
+ lambda _client, _grant_type: plugin.config.get(
265
+ "access_token_seconds", 300
266
+ ),
267
+ ),
268
+ )
269
+
270
+ def sync(self, coroutine):
271
+ future = asyncio.run_coroutine_threadsafe(coroutine, self.loop)
272
+ try:
273
+ return future.result(timeout=25)
274
+ except FutureTimeout:
275
+ future.cancel()
276
+ raise
277
+
278
+ def query_client(self, client_id):
279
+ for client in self.plugin.config.get("clients", []):
280
+ if client["client_id"] == client_id:
281
+ return Client(
282
+ client_id, {"scope": " ".join(self.plugin.scopes), **client}
283
+ )
284
+ rows = self.sync(
285
+ self.store.query("SELECT * FROM oauth_clients WHERE id=?", [client_id])
286
+ )
287
+ return Client(client_id, json.loads(rows[0]["metadata"])) if rows else None
288
+
289
+ def active_grant(self, identifier, scope):
290
+ try:
291
+ result = self.sync(
292
+ self.plugin.context.call("auth", "grant_check", {"id": identifier})
293
+ )["grant"]
294
+ except PluginError:
295
+ raise InvalidGrantError("Authorization is no longer available") from None
296
+ if not set(scope.split()) <= set(result["scopes"]):
297
+ raise InvalidGrantError("User permissions changed; authorize again")
298
+ return result
299
+
300
+ def save_token(self, token, request):
301
+ grant, now = request.user, time.time()
302
+ lifetime = min(token["expires_in"], int(grant["expires_at"] - now))
303
+ if lifetime <= 0:
304
+ raise InvalidGrantError("Authorization expired")
305
+ token["expires_in"] = lifetime
306
+ self.sync(
307
+ self.store.execute(
308
+ "INSERT INTO oauth_tokens(id,access_digest,refresh_digest,grant_id,client_id,resource,scopes,expires_at,refresh_expires_at) VALUES(?,?,?,?,?,?,?,?,?)",
309
+ [
310
+ uuid.uuid4().hex,
311
+ digest(token["access_token"]),
312
+ digest(token["refresh_token"])
313
+ if token.get("refresh_token")
314
+ else None,
315
+ grant["id"],
316
+ request.client.client_id,
317
+ grant["resource"],
318
+ json.dumps(token.get("scope", "").split()),
319
+ now + lifetime,
320
+ grant["expires_at"],
321
+ ],
322
+ )
323
+ )
324
+
325
+ def create_oauth2_request(self, request):
326
+ return request
327
+
328
+ def create_json_request(self, request):
329
+ value = JsonRequest(
330
+ request["method"], request["uri"], Headers(request.get("headers", {}))
331
+ )
332
+ try:
333
+ data = json.loads(request.get("body", "{}"))
334
+ except ValueError:
335
+ raise InvalidRequestError("Supply a JSON object") from None
336
+ if not isinstance(data, dict):
337
+ raise InvalidRequestError("Supply a JSON object")
338
+ value.payload = BasicOAuth2Payload(data)
339
+ return value
340
+
341
+ def handle_response(self, status, body, headers):
342
+ return Reply(status, body, headers)
343
+
344
+ def send_signal(self, *_args, **_kwargs):
345
+ pass
@@ -0,0 +1,15 @@
1
+
2
+ CREATE TABLE oauth_clients (id TEXT PRIMARY KEY, metadata TEXT NOT NULL, created_at DOUBLE PRECISION NOT NULL);
3
+ CREATE TABLE oauth_flows (
4
+ id TEXT PRIMARY KEY, browser_digest TEXT NOT NULL, data TEXT NOT NULL, expires_at DOUBLE PRECISION NOT NULL
5
+ );
6
+ CREATE TABLE oauth_codes (
7
+ digest TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at DOUBLE PRECISION NOT NULL, used INTEGER NOT NULL DEFAULT 0
8
+ );
9
+ CREATE TABLE oauth_tokens (
10
+ id TEXT PRIMARY KEY, access_digest TEXT NOT NULL UNIQUE, refresh_digest TEXT UNIQUE,
11
+ grant_id TEXT NOT NULL, client_id TEXT NOT NULL, resource TEXT NOT NULL,
12
+ scopes TEXT NOT NULL, expires_at DOUBLE PRECISION NOT NULL, refresh_expires_at DOUBLE PRECISION NOT NULL,
13
+ refresh_used INTEGER NOT NULL DEFAULT 0, revoked INTEGER NOT NULL DEFAULT 0
14
+ );
15
+ CREATE INDEX oauth_tokens_grant ON oauth_tokens(grant_id);
@@ -0,0 +1,119 @@
1
+ """Protocol records: only digests of externally usable credentials are stored."""
2
+
3
+ import hashlib
4
+ import json
5
+ import time
6
+ from urllib.parse import urlsplit
7
+
8
+ from authlib.oauth2.rfc6749 import ClientMixin
9
+
10
+ SCHEMA = """
11
+ CREATE TABLE oauth_clients (id TEXT PRIMARY KEY, metadata TEXT NOT NULL, created_at REAL NOT NULL);
12
+ CREATE TABLE oauth_flows (
13
+ id TEXT PRIMARY KEY, browser_digest TEXT NOT NULL, data TEXT NOT NULL, expires_at REAL NOT NULL
14
+ );
15
+ CREATE TABLE oauth_codes (
16
+ digest TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at REAL NOT NULL, used INTEGER NOT NULL DEFAULT 0
17
+ );
18
+ CREATE TABLE oauth_tokens (
19
+ id TEXT PRIMARY KEY, access_digest TEXT NOT NULL UNIQUE, refresh_digest TEXT UNIQUE,
20
+ grant_id TEXT NOT NULL, client_id TEXT NOT NULL, resource TEXT NOT NULL,
21
+ scopes TEXT NOT NULL, expires_at REAL NOT NULL, refresh_expires_at REAL NOT NULL,
22
+ refresh_used INTEGER NOT NULL DEFAULT 0, revoked INTEGER NOT NULL DEFAULT 0
23
+ );
24
+ CREATE INDEX oauth_tokens_grant ON oauth_tokens(grant_id);
25
+ """
26
+
27
+
28
+ def digest(value):
29
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
30
+
31
+
32
+ def valid_redirect(value):
33
+ if (
34
+ not isinstance(value, str)
35
+ or len(value) > 2048
36
+ or any(ord(c) < 33 for c in value)
37
+ ):
38
+ return False
39
+ try:
40
+ uri = urlsplit(value)
41
+ return bool(
42
+ uri.hostname
43
+ and not uri.username
44
+ and not uri.password
45
+ and not uri.fragment
46
+ and (uri.port is None or uri.port > 0)
47
+ and (
48
+ uri.scheme == "https"
49
+ or (
50
+ uri.scheme == "http"
51
+ and uri.hostname in {"127.0.0.1", "::1", "localhost"}
52
+ )
53
+ )
54
+ )
55
+ except ValueError:
56
+ return False
57
+
58
+
59
+ class Client(ClientMixin):
60
+ def __init__(self, client_id, metadata):
61
+ self.client_id, self.metadata = client_id, metadata
62
+
63
+ def get_client_id(self):
64
+ return self.client_id
65
+
66
+ def get_default_redirect_uri(self):
67
+ return self.metadata["redirect_uris"][0]
68
+
69
+ def check_redirect_uri(self, redirect_uri):
70
+ return redirect_uri in self.metadata["redirect_uris"]
71
+
72
+ def get_allowed_scope(self, scope):
73
+ allowed = self.metadata.get("scope", "").split()
74
+ return " ".join(s for s in (scope or " ".join(allowed)).split() if s in allowed)
75
+
76
+ def check_client_secret(self, secret):
77
+ return False
78
+
79
+ def check_endpoint_auth_method(self, method, endpoint):
80
+ return method == "none"
81
+
82
+ def check_response_type(self, response_type):
83
+ return response_type == "code"
84
+
85
+ def check_grant_type(self, grant_type):
86
+ return grant_type in self.metadata.get(
87
+ "grant_types", ["authorization_code", "refresh_token"]
88
+ )
89
+
90
+
91
+ class Code:
92
+ def __init__(self, record):
93
+ self.digest = record["digest"]
94
+ self.data = json.loads(record["data"])
95
+ self.code_challenge = self.data["code_challenge"]
96
+ self.code_challenge_method = "S256"
97
+
98
+ def get_redirect_uri(self):
99
+ return self.data["redirect_uri"]
100
+
101
+ def get_scope(self):
102
+ return self.data["scope"]
103
+
104
+
105
+ class Token:
106
+ def __init__(self, record):
107
+ self.data = record
108
+
109
+ def check_client(self, client):
110
+ return self.data["client_id"] == client.client_id
111
+
112
+ def get_scope(self):
113
+ return " ".join(json.loads(self.data["scopes"]))
114
+
115
+ def is_expired(self):
116
+ return self.data["expires_at"] <= time.time()
117
+
118
+ def is_revoked(self):
119
+ return bool(self.data["revoked"])