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 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,6 @@
1
+ """The SDK version, kept in step with the Node SDK."""
2
+
3
+ __version__ = "0.0.2"
4
+
5
+ # Sent on every request to OhAuthCat, so the edge can tell SDK traffic apart.
6
+ USER_AGENT = f"ohauthcat-sdk-python/{__version__}"
@@ -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