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.
- toolframe_oidc_auth/__init__.py +1 -0
- toolframe_oidc_auth/authorization.py +345 -0
- toolframe_oidc_auth/migrations/v1.postgresql.sql +15 -0
- toolframe_oidc_auth/models.py +119 -0
- toolframe_oidc_auth/plugin.py +694 -0
- toolframe_oidc_auth-0.2.0.dist-info/METADATA +235 -0
- toolframe_oidc_auth-0.2.0.dist-info/RECORD +11 -0
- toolframe_oidc_auth-0.2.0.dist-info/WHEEL +4 -0
- toolframe_oidc_auth-0.2.0.dist-info/entry_points.txt +2 -0
- toolframe_oidc_auth-0.2.0.dist-info/licenses/LICENSE +202 -0
- toolframe_oidc_auth-0.2.0.dist-info/licenses/NOTICE +2 -0
|
@@ -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"])
|