ohauthcat 0.0.2__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.
- ohauthcat/__init__.py +95 -0
- ohauthcat/_version.py +6 -0
- ohauthcat/access_token.py +400 -0
- ohauthcat/audit.py +56 -0
- ohauthcat/client.py +178 -0
- ohauthcat/contract.json +1 -0
- ohauthcat/custom_attributes.py +130 -0
- ohauthcat/directory_csv.py +72 -0
- ohauthcat/end_user.py +232 -0
- ohauthcat/errors.py +135 -0
- ohauthcat/existing_auth.py +1222 -0
- ohauthcat/flags.py +200 -0
- ohauthcat/framework.py +448 -0
- ohauthcat/jwt_verifier.py +225 -0
- ohauthcat/logo.py +123 -0
- ohauthcat/migration.py +351 -0
- ohauthcat/models.py +9246 -0
- ohauthcat/passkeys.py +211 -0
- ohauthcat/py.typed +0 -0
- ohauthcat/resource_server.py +148 -0
- ohauthcat/resources.py +10311 -0
- ohauthcat/response_models.py +8758 -0
- ohauthcat/ssf.py +218 -0
- ohauthcat/sso.py +391 -0
- ohauthcat/standby.py +137 -0
- ohauthcat/transport.py +929 -0
- ohauthcat/webhooks.py +178 -0
- ohauthcat/wire.py +199 -0
- ohauthcat-0.0.2.dist-info/METADATA +181 -0
- ohauthcat-0.0.2.dist-info/RECORD +33 -0
- ohauthcat-0.0.2.dist-info/WHEEL +4 -0
- ohauthcat-0.0.2.dist-info/licenses/LICENSE +35 -0
- ohauthcat-0.0.2.dist-info/licenses/THIRD_PARTY_LICENSES +852 -0
ohauthcat/__init__.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Official OhAuthCat SDK for Python."""
|
|
2
|
+
|
|
3
|
+
from ohauthcat import webhooks
|
|
4
|
+
from ohauthcat._version import __version__
|
|
5
|
+
from ohauthcat.access_token import AccessTokenClaims, AccessTokenVerificationError
|
|
6
|
+
from ohauthcat.audit import audit_event_hints
|
|
7
|
+
from ohauthcat.client import AsyncOhAuthCat, OhAuthCat
|
|
8
|
+
from ohauthcat.end_user import (
|
|
9
|
+
EndUserTokenVerifier,
|
|
10
|
+
MachineTokenError,
|
|
11
|
+
MachineTokenSource,
|
|
12
|
+
has_permission,
|
|
13
|
+
has_role,
|
|
14
|
+
hosted_login_url,
|
|
15
|
+
machine_token_verifier,
|
|
16
|
+
)
|
|
17
|
+
from ohauthcat.errors import (
|
|
18
|
+
UNKNOWN_ERROR_CODE,
|
|
19
|
+
OhAuthCatError,
|
|
20
|
+
OhAuthCatValidationError,
|
|
21
|
+
PlanLimitReachedError,
|
|
22
|
+
parse_error_body,
|
|
23
|
+
)
|
|
24
|
+
from ohauthcat.flags import FlagResult, LocalFlags, evaluate_flag, flag_bucket
|
|
25
|
+
from ohauthcat.jwt_verifier import JwtVerifier, TokenVerificationError
|
|
26
|
+
from ohauthcat.logo import MAX_LOGO_BYTES, AsyncLogos, Logos, LogoType, check_logo
|
|
27
|
+
from ohauthcat.migration import LegacySessionContext
|
|
28
|
+
from ohauthcat.passkeys import AsyncNativePasskeys, NativePasskeys
|
|
29
|
+
from ohauthcat.resource_server import (
|
|
30
|
+
BearerResult,
|
|
31
|
+
BearerValidator,
|
|
32
|
+
OAuthTokenVerifier,
|
|
33
|
+
bearer_challenge,
|
|
34
|
+
oauth_issuer,
|
|
35
|
+
protected_resource_metadata,
|
|
36
|
+
protected_resource_metadata_url,
|
|
37
|
+
token_scopes,
|
|
38
|
+
)
|
|
39
|
+
from ohauthcat.ssf import SecurityEvent, SecurityEventToken, SetVerifier, SsfError, SsfStreams
|
|
40
|
+
from ohauthcat.sso import Authorization, RestartSignIn, pkce_challenge
|
|
41
|
+
from ohauthcat.webhooks import VerifyOptions, WebhookVerificationError
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"MAX_LOGO_BYTES",
|
|
45
|
+
"UNKNOWN_ERROR_CODE",
|
|
46
|
+
"AccessTokenClaims",
|
|
47
|
+
"AccessTokenVerificationError",
|
|
48
|
+
"AsyncLogos",
|
|
49
|
+
"AsyncNativePasskeys",
|
|
50
|
+
"AsyncOhAuthCat",
|
|
51
|
+
"Authorization",
|
|
52
|
+
"BearerResult",
|
|
53
|
+
"BearerValidator",
|
|
54
|
+
"EndUserTokenVerifier",
|
|
55
|
+
"FlagResult",
|
|
56
|
+
"JwtVerifier",
|
|
57
|
+
"LegacySessionContext",
|
|
58
|
+
"LocalFlags",
|
|
59
|
+
"LogoType",
|
|
60
|
+
"Logos",
|
|
61
|
+
"MachineTokenError",
|
|
62
|
+
"MachineTokenSource",
|
|
63
|
+
"NativePasskeys",
|
|
64
|
+
"OAuthTokenVerifier",
|
|
65
|
+
"OhAuthCat",
|
|
66
|
+
"OhAuthCatError",
|
|
67
|
+
"OhAuthCatValidationError",
|
|
68
|
+
"PlanLimitReachedError",
|
|
69
|
+
"RestartSignIn",
|
|
70
|
+
"SecurityEvent",
|
|
71
|
+
"SecurityEventToken",
|
|
72
|
+
"SetVerifier",
|
|
73
|
+
"SsfError",
|
|
74
|
+
"SsfStreams",
|
|
75
|
+
"TokenVerificationError",
|
|
76
|
+
"VerifyOptions",
|
|
77
|
+
"WebhookVerificationError",
|
|
78
|
+
"__version__",
|
|
79
|
+
"audit_event_hints",
|
|
80
|
+
"bearer_challenge",
|
|
81
|
+
"check_logo",
|
|
82
|
+
"evaluate_flag",
|
|
83
|
+
"flag_bucket",
|
|
84
|
+
"has_permission",
|
|
85
|
+
"has_role",
|
|
86
|
+
"hosted_login_url",
|
|
87
|
+
"machine_token_verifier",
|
|
88
|
+
"oauth_issuer",
|
|
89
|
+
"parse_error_body",
|
|
90
|
+
"pkce_challenge",
|
|
91
|
+
"protected_resource_metadata",
|
|
92
|
+
"protected_resource_metadata_url",
|
|
93
|
+
"token_scopes",
|
|
94
|
+
"webhooks",
|
|
95
|
+
]
|
ohauthcat/_version.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Configured-issuer ES256 access-token verification with bounded JWKS caching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import binascii
|
|
8
|
+
import re
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from cryptography.exceptions import InvalidSignature
|
|
17
|
+
from cryptography.hazmat.primitives import hashes
|
|
18
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
19
|
+
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
21
|
+
|
|
22
|
+
from ohauthcat.errors import OhAuthCatError
|
|
23
|
+
from ohauthcat.transport import Settings, origin
|
|
24
|
+
from ohauthcat.wire import JSON, record
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AccessTokenVerificationError(ValueError):
|
|
28
|
+
"""A safe failure that never includes a token, claim or key payload."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
super().__init__("Access token verification failed")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AccessTokenClaims(BaseModel):
|
|
35
|
+
"""Authenticated, issuer/client/environment-bound access-token claims."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(strict=True, frozen=True)
|
|
38
|
+
iss: str
|
|
39
|
+
aud: str
|
|
40
|
+
sub: str
|
|
41
|
+
env: str
|
|
42
|
+
org: str
|
|
43
|
+
conn: str
|
|
44
|
+
iat: int
|
|
45
|
+
nbf: int
|
|
46
|
+
exp: int
|
|
47
|
+
jti: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def decode_segment(segment: str) -> bytes:
|
|
51
|
+
"""Require canonical unpadded base64url, never permissive alternate encodings."""
|
|
52
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", segment):
|
|
53
|
+
raise AccessTokenVerificationError()
|
|
54
|
+
try:
|
|
55
|
+
result = base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
|
|
56
|
+
if base64.urlsafe_b64encode(result).decode().rstrip("=") != segment:
|
|
57
|
+
raise AccessTokenVerificationError()
|
|
58
|
+
return result
|
|
59
|
+
except (ValueError, binascii.Error):
|
|
60
|
+
raise AccessTokenVerificationError() from None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def token_parts(token: str) -> tuple[str, bytes, bytes, bytes]:
|
|
64
|
+
"""Pin JOSE semantics before obtaining a verification key."""
|
|
65
|
+
try:
|
|
66
|
+
if not isinstance(token, str) or not 0 < len(token) <= 4096:
|
|
67
|
+
raise AccessTokenVerificationError()
|
|
68
|
+
first, second, third = token.split(".")
|
|
69
|
+
header = record(JSON.validate_json(decode_segment(first)))
|
|
70
|
+
kid = header.get("kid")
|
|
71
|
+
signature = decode_segment(third)
|
|
72
|
+
if (
|
|
73
|
+
header.get("alg") != "ES256"
|
|
74
|
+
or header.get("typ") != "at+jwt"
|
|
75
|
+
or not isinstance(kid, str)
|
|
76
|
+
or not 0 < len(kid) <= 128
|
|
77
|
+
or "crit" in header
|
|
78
|
+
or "b64" in header
|
|
79
|
+
or len(signature) != 64
|
|
80
|
+
):
|
|
81
|
+
raise AccessTokenVerificationError()
|
|
82
|
+
return kid, (first + "." + second).encode("ascii"), decode_segment(second), signature
|
|
83
|
+
except (ValueError, UnicodeError, ValidationError, RecursionError):
|
|
84
|
+
raise AccessTokenVerificationError() from None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def public_keys(raw: bytes) -> dict[str, ec.EllipticCurvePublicKey]:
|
|
88
|
+
"""Accept only unique public ES256/P-256 signature keys."""
|
|
89
|
+
try:
|
|
90
|
+
entries = record(JSON.validate_json(raw)).get("keys")
|
|
91
|
+
if not isinstance(entries, list) or not 1 <= len(entries) <= 32:
|
|
92
|
+
raise AccessTokenVerificationError()
|
|
93
|
+
result: dict[str, ec.EllipticCurvePublicKey] = {}
|
|
94
|
+
for entry in entries:
|
|
95
|
+
key = record(entry)
|
|
96
|
+
kid, x, y = key.get("kid"), key.get("x"), key.get("y")
|
|
97
|
+
if (
|
|
98
|
+
key.get("kty") != "EC"
|
|
99
|
+
or key.get("crv") != "P-256"
|
|
100
|
+
or key.get("alg") != "ES256"
|
|
101
|
+
or key.get("use") != "sig"
|
|
102
|
+
or "d" in key
|
|
103
|
+
or not isinstance(kid, str)
|
|
104
|
+
or not 0 < len(kid) <= 128
|
|
105
|
+
or kid in result
|
|
106
|
+
or not isinstance(x, str)
|
|
107
|
+
or not isinstance(y, str)
|
|
108
|
+
):
|
|
109
|
+
raise AccessTokenVerificationError()
|
|
110
|
+
decoded_x, decoded_y = decode_segment(x), decode_segment(y)
|
|
111
|
+
if len(decoded_x) != 32 or len(decoded_y) != 32:
|
|
112
|
+
raise AccessTokenVerificationError()
|
|
113
|
+
result[kid] = ec.EllipticCurvePublicNumbers(
|
|
114
|
+
int.from_bytes(decoded_x, "big"), int.from_bytes(decoded_y, "big"), ec.SECP256R1()
|
|
115
|
+
).public_key()
|
|
116
|
+
return result
|
|
117
|
+
except (ValueError, ValidationError, RecursionError):
|
|
118
|
+
raise AccessTokenVerificationError() from None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cache_ttl(header: str | None) -> float:
|
|
122
|
+
"""Respect no-cache/no-store and cap explicit cache age at 300 seconds."""
|
|
123
|
+
value = header or ""
|
|
124
|
+
if re.search(r"(?:^|,)\s*(?:no-store|no-cache)(?:\s*(?:,|$))", value, re.I):
|
|
125
|
+
return 0
|
|
126
|
+
age = re.search(r"(?:^|,)\s*max-age=(\d+)(?:\s*(?:,|$))", value)
|
|
127
|
+
return min(300, int(age[1][:10])) if age else 300
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def verify_claims(
|
|
131
|
+
key: ec.EllipticCurvePublicKey, signed: bytes, raw: bytes, signature: bytes, settings: Settings
|
|
132
|
+
) -> AccessTokenClaims:
|
|
133
|
+
"""Verify raw r||s using cryptography and enforce configured trust bindings."""
|
|
134
|
+
try:
|
|
135
|
+
key.verify(
|
|
136
|
+
encode_dss_signature(
|
|
137
|
+
int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big")
|
|
138
|
+
),
|
|
139
|
+
signed,
|
|
140
|
+
ec.ECDSA(hashes.SHA256()),
|
|
141
|
+
)
|
|
142
|
+
claims = AccessTokenClaims.model_validate(JSON.validate_json(raw))
|
|
143
|
+
now = time.time()
|
|
144
|
+
if (
|
|
145
|
+
any(
|
|
146
|
+
not value
|
|
147
|
+
for value in [
|
|
148
|
+
claims.iss,
|
|
149
|
+
claims.aud,
|
|
150
|
+
claims.sub,
|
|
151
|
+
claims.env,
|
|
152
|
+
claims.org,
|
|
153
|
+
claims.conn,
|
|
154
|
+
claims.jti,
|
|
155
|
+
]
|
|
156
|
+
)
|
|
157
|
+
or any(not 0 <= value <= 2**53 - 1 for value in [claims.iat, claims.nbf, claims.exp])
|
|
158
|
+
or claims.iss != origin(settings.issuer, issuer=True)
|
|
159
|
+
or claims.aud != settings.client_id
|
|
160
|
+
or claims.env != settings.environment_id
|
|
161
|
+
or now >= claims.exp + 60
|
|
162
|
+
or now + 60 < claims.nbf
|
|
163
|
+
or now + 60 < claims.iat
|
|
164
|
+
or claims.exp <= claims.iat
|
|
165
|
+
or claims.nbf > claims.exp
|
|
166
|
+
):
|
|
167
|
+
raise AccessTokenVerificationError()
|
|
168
|
+
return claims
|
|
169
|
+
except (InvalidSignature, ValueError, ValidationError, RecursionError):
|
|
170
|
+
raise AccessTokenVerificationError() from None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class Cache:
|
|
175
|
+
"""Per-client cache; callers serialize refresh mutations."""
|
|
176
|
+
|
|
177
|
+
keys: dict[str, ec.EllipticCurvePublicKey] = field(default_factory=dict)
|
|
178
|
+
expires: float = 0
|
|
179
|
+
unknown_refresh: float = float("-inf")
|
|
180
|
+
failed_refresh: float = float("-inf")
|
|
181
|
+
|
|
182
|
+
def needs_refresh(self, kid: str) -> bool:
|
|
183
|
+
now = time.monotonic()
|
|
184
|
+
if (kid not in self.keys and now - self.unknown_refresh < 30) or (
|
|
185
|
+
now >= self.expires and now - self.failed_refresh < 30
|
|
186
|
+
):
|
|
187
|
+
raise AccessTokenVerificationError()
|
|
188
|
+
if now >= self.expires:
|
|
189
|
+
return True
|
|
190
|
+
if kid not in self.keys:
|
|
191
|
+
self.unknown_refresh = now
|
|
192
|
+
return True
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
def replace(self, raw: bytes, header: str | None, kid: str) -> None:
|
|
196
|
+
self.keys = public_keys(raw)
|
|
197
|
+
self.expires = time.monotonic() + cache_ttl(header)
|
|
198
|
+
self.failed_refresh = float("-inf")
|
|
199
|
+
if kid not in self.keys:
|
|
200
|
+
self.unknown_refresh = time.monotonic()
|
|
201
|
+
|
|
202
|
+
def failed(self) -> None:
|
|
203
|
+
self.failed_refresh = self.unknown_refresh = time.monotonic()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def jwks_request(
|
|
207
|
+
settings: Settings, remaining: float | None = None, retrieval_origin: str | None = None
|
|
208
|
+
) -> httpx.Request:
|
|
209
|
+
"""Use only configured issuer, with no inherited client auth or cookies."""
|
|
210
|
+
return httpx.Request(
|
|
211
|
+
"GET",
|
|
212
|
+
origin(retrieval_origin or settings.issuer, issuer=True) + "/.well-known/jwks.json",
|
|
213
|
+
extensions={
|
|
214
|
+
"timeout": {
|
|
215
|
+
name: (settings.timeout_ms / 1000 if remaining is None else remaining)
|
|
216
|
+
for name in ["connect", "read", "write", "pool"]
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class Verifier:
|
|
223
|
+
"""Sync single-flight refresh guarded by a bounded per-client lock."""
|
|
224
|
+
|
|
225
|
+
def __init__(
|
|
226
|
+
self,
|
|
227
|
+
settings: Settings,
|
|
228
|
+
client: httpx.Client,
|
|
229
|
+
retrieval_origin: Callable[[], str] | None = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
self.settings, self.client = settings, client
|
|
232
|
+
self.retrieval_origin = retrieval_origin
|
|
233
|
+
self.fetch_jwks: Callable[[float], tuple[bytes, str | None, int]] | None = None
|
|
234
|
+
self.keys_healthy: Callable[[int], None] | None = None
|
|
235
|
+
self.cache = Cache()
|
|
236
|
+
self.lock = threading.Lock()
|
|
237
|
+
self.closed = False
|
|
238
|
+
|
|
239
|
+
def close(self) -> None:
|
|
240
|
+
self.closed = True
|
|
241
|
+
|
|
242
|
+
def verify(self, token: str) -> AccessTokenClaims:
|
|
243
|
+
if not self.settings.client_id or not self.settings.environment_id or self.closed:
|
|
244
|
+
raise AccessTokenVerificationError()
|
|
245
|
+
deadline = time.monotonic() + self.settings.timeout_ms / 1000
|
|
246
|
+
kid, signed, raw, signature = token_parts(token)
|
|
247
|
+
if not self.lock.acquire(timeout=max(0, deadline - time.monotonic())):
|
|
248
|
+
raise AccessTokenVerificationError()
|
|
249
|
+
try:
|
|
250
|
+
if self.closed:
|
|
251
|
+
raise AccessTokenVerificationError()
|
|
252
|
+
if self.cache.needs_refresh(kid):
|
|
253
|
+
response: httpx.Response | None = None
|
|
254
|
+
try:
|
|
255
|
+
remaining = deadline - time.monotonic()
|
|
256
|
+
if remaining <= 0:
|
|
257
|
+
raise AccessTokenVerificationError()
|
|
258
|
+
if self.fetch_jwks is not None:
|
|
259
|
+
raw_bytes, control, revision = self.fetch_jwks(deadline)
|
|
260
|
+
self.cache.replace(raw_bytes, control, kid)
|
|
261
|
+
if self.keys_healthy is not None:
|
|
262
|
+
self.keys_healthy(revision)
|
|
263
|
+
else:
|
|
264
|
+
response = self.client.send(
|
|
265
|
+
jwks_request(
|
|
266
|
+
self.settings,
|
|
267
|
+
remaining,
|
|
268
|
+
None if self.retrieval_origin is None else self.retrieval_origin(),
|
|
269
|
+
),
|
|
270
|
+
stream=True,
|
|
271
|
+
auth=None,
|
|
272
|
+
follow_redirects=False,
|
|
273
|
+
)
|
|
274
|
+
if not response.is_success:
|
|
275
|
+
raise AccessTokenVerificationError()
|
|
276
|
+
chunks: list[bytes] = []
|
|
277
|
+
size = 0
|
|
278
|
+
for chunk in response.iter_bytes():
|
|
279
|
+
size += len(chunk)
|
|
280
|
+
if size > 65536 or time.monotonic() >= deadline or self.closed:
|
|
281
|
+
raise AccessTokenVerificationError()
|
|
282
|
+
chunks.append(chunk)
|
|
283
|
+
if self.closed:
|
|
284
|
+
raise AccessTokenVerificationError()
|
|
285
|
+
self.cache.replace(
|
|
286
|
+
b"".join(chunks), response.headers.get("Cache-Control"), kid
|
|
287
|
+
)
|
|
288
|
+
except (httpx.HTTPError, ValueError, OhAuthCatError):
|
|
289
|
+
self.cache.failed()
|
|
290
|
+
raise AccessTokenVerificationError() from None
|
|
291
|
+
finally:
|
|
292
|
+
if response is not None:
|
|
293
|
+
response.close()
|
|
294
|
+
key = self.cache.keys.get(kid)
|
|
295
|
+
if key is None:
|
|
296
|
+
raise AccessTokenVerificationError()
|
|
297
|
+
return verify_claims(key, signed, raw, signature, self.settings)
|
|
298
|
+
finally:
|
|
299
|
+
self.lock.release()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class AsyncVerifier:
|
|
303
|
+
"""Async single-flight refresh; cancelling one waiter does not cancel shared work."""
|
|
304
|
+
|
|
305
|
+
def __init__(
|
|
306
|
+
self,
|
|
307
|
+
settings: Settings,
|
|
308
|
+
client: httpx.AsyncClient,
|
|
309
|
+
retrieval_origin: Callable[[], str] | None = None,
|
|
310
|
+
) -> None:
|
|
311
|
+
self.settings, self.client = settings, client
|
|
312
|
+
self.retrieval_origin = retrieval_origin
|
|
313
|
+
self.fetch_jwks: Callable[[float], Awaitable[tuple[bytes, str | None, int]]] | None = None
|
|
314
|
+
self.keys_healthy: Callable[[int], None] | None = None
|
|
315
|
+
self.cache = Cache()
|
|
316
|
+
self.pending: asyncio.Task[None] | None = None
|
|
317
|
+
self.closed = False
|
|
318
|
+
|
|
319
|
+
async def aclose(self) -> None:
|
|
320
|
+
self.closed = True
|
|
321
|
+
if self.pending is not None:
|
|
322
|
+
self.pending.cancel()
|
|
323
|
+
with suppress(asyncio.CancelledError, AccessTokenVerificationError):
|
|
324
|
+
await self.pending
|
|
325
|
+
|
|
326
|
+
async def _refresh(self, kid: str) -> None:
|
|
327
|
+
async def run() -> None:
|
|
328
|
+
response: httpx.Response | None = None
|
|
329
|
+
try:
|
|
330
|
+
if self.fetch_jwks is not None:
|
|
331
|
+
raw_bytes, control, revision = await self.fetch_jwks(
|
|
332
|
+
time.monotonic() + self.settings.timeout_ms / 1000
|
|
333
|
+
)
|
|
334
|
+
self.cache.replace(raw_bytes, control, kid)
|
|
335
|
+
if self.keys_healthy is not None:
|
|
336
|
+
self.keys_healthy(revision)
|
|
337
|
+
else:
|
|
338
|
+
response = await self.client.send(
|
|
339
|
+
jwks_request(
|
|
340
|
+
self.settings,
|
|
341
|
+
retrieval_origin=None
|
|
342
|
+
if self.retrieval_origin is None
|
|
343
|
+
else self.retrieval_origin(),
|
|
344
|
+
),
|
|
345
|
+
stream=True,
|
|
346
|
+
auth=None,
|
|
347
|
+
follow_redirects=False,
|
|
348
|
+
)
|
|
349
|
+
if not response.is_success or self.closed:
|
|
350
|
+
raise AccessTokenVerificationError()
|
|
351
|
+
chunks: list[bytes] = []
|
|
352
|
+
size = 0
|
|
353
|
+
async for chunk in response.aiter_bytes():
|
|
354
|
+
size += len(chunk)
|
|
355
|
+
if size > 65536:
|
|
356
|
+
raise AccessTokenVerificationError()
|
|
357
|
+
chunks.append(chunk)
|
|
358
|
+
if self.closed:
|
|
359
|
+
raise AccessTokenVerificationError()
|
|
360
|
+
self.cache.replace(b"".join(chunks), response.headers.get("Cache-Control"), kid)
|
|
361
|
+
finally:
|
|
362
|
+
if response is not None:
|
|
363
|
+
await response.aclose()
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
await asyncio.wait_for(run(), self.settings.timeout_ms / 1000)
|
|
367
|
+
except (httpx.HTTPError, ValueError, OhAuthCatError, asyncio.TimeoutError):
|
|
368
|
+
self.cache.failed()
|
|
369
|
+
raise AccessTokenVerificationError() from None
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _consume_refresh(task: asyncio.Task[None]) -> None:
|
|
373
|
+
"""Retrieve failures even if the last waiting caller was cancelled."""
|
|
374
|
+
if not task.cancelled():
|
|
375
|
+
task.exception()
|
|
376
|
+
|
|
377
|
+
async def verify(self, token: str) -> AccessTokenClaims:
|
|
378
|
+
if not self.settings.client_id or not self.settings.environment_id or self.closed:
|
|
379
|
+
raise AccessTokenVerificationError()
|
|
380
|
+
kid, signed, raw, signature = token_parts(token)
|
|
381
|
+
try:
|
|
382
|
+
if self.pending is not None and self.pending.done():
|
|
383
|
+
self.pending = None
|
|
384
|
+
if self.pending is None and self.cache.needs_refresh(kid):
|
|
385
|
+
self.pending = asyncio.create_task(self._refresh(kid))
|
|
386
|
+
self.pending.add_done_callback(self._consume_refresh)
|
|
387
|
+
if self.pending is not None:
|
|
388
|
+
pending = self.pending
|
|
389
|
+
done, _ = await asyncio.wait({pending}, timeout=self.settings.timeout_ms / 1000)
|
|
390
|
+
if not done:
|
|
391
|
+
raise AccessTokenVerificationError()
|
|
392
|
+
pending.result()
|
|
393
|
+
if self.closed:
|
|
394
|
+
raise AccessTokenVerificationError()
|
|
395
|
+
key = self.cache.keys.get(kid)
|
|
396
|
+
if key is None:
|
|
397
|
+
raise AccessTokenVerificationError()
|
|
398
|
+
return verify_claims(key, signed, raw, signature, self.settings)
|
|
399
|
+
except (asyncio.TimeoutError, ValueError):
|
|
400
|
+
raise AccessTokenVerificationError() from None
|
ohauthcat/audit.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Pre-send hints for audit log events against the pinned schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import JsonValue
|
|
6
|
+
|
|
7
|
+
from ohauthcat.wire import SCHEMAS, matches, record
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _resolve(schema: JsonValue) -> dict[str, JsonValue]:
|
|
11
|
+
current = record(schema)
|
|
12
|
+
for _ in range(16):
|
|
13
|
+
ref = current.get("$ref")
|
|
14
|
+
if not isinstance(ref, str):
|
|
15
|
+
break
|
|
16
|
+
current = record(SCHEMAS.get(ref.rsplit("/", 1)[-1], {}))
|
|
17
|
+
return current
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _walk(value: JsonValue, schema: JsonValue, path: str, hints: list[str]) -> None:
|
|
21
|
+
if matches(value, schema):
|
|
22
|
+
return
|
|
23
|
+
shape = _resolve(schema)
|
|
24
|
+
properties = shape.get("properties")
|
|
25
|
+
if isinstance(value, dict) and isinstance(properties, dict):
|
|
26
|
+
required = shape.get("required")
|
|
27
|
+
for name in required if isinstance(required, list) else []:
|
|
28
|
+
if isinstance(name, str) and value.get(name) is None:
|
|
29
|
+
hints.append(f"{path}.{name} is required")
|
|
30
|
+
for name, item in value.items():
|
|
31
|
+
if item is None:
|
|
32
|
+
continue
|
|
33
|
+
if name in properties:
|
|
34
|
+
_walk(item, properties[name], f"{path}.{name}", hints)
|
|
35
|
+
elif shape.get("additionalProperties") is False:
|
|
36
|
+
hints.append(f"{path}.{name} is not a known field")
|
|
37
|
+
if not hints:
|
|
38
|
+
hints.append(f"{path} does not match the schema")
|
|
39
|
+
return
|
|
40
|
+
items = shape.get("items")
|
|
41
|
+
if isinstance(value, list) and isinstance(items, dict):
|
|
42
|
+
before = len(hints)
|
|
43
|
+
for index, item in enumerate(value):
|
|
44
|
+
_walk(item, items, f"{path}[{index}]", hints)
|
|
45
|
+
if len(hints) == before:
|
|
46
|
+
hints.append(f"{path} has the wrong number of items")
|
|
47
|
+
return
|
|
48
|
+
hints.append(f"{path} does not match the schema")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def audit_event_hints(body: JsonValue) -> list[str]:
|
|
52
|
+
"""Readable problems with an audit log event body; empty when well formed.
|
|
53
|
+
The server still validates the event against the action's registered schema."""
|
|
54
|
+
hints: list[str] = []
|
|
55
|
+
_walk(body, {"$ref": "#/components/schemas/AuditLogEventCreate"}, "body", hints)
|
|
56
|
+
return hints
|