clue-api 1.7.0.dev446__tar.gz → 1.7.0.dev458__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.
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/PKG-INFO +1 -1
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/config.py +8 -1
- clue_api-1.7.0.dev458/clue/models/jwks.py +19 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/network.py +42 -32
- clue_api-1.7.0.dev458/clue/services/jwt_service.py +476 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/services/lookup_service.py +61 -42
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/pyproject.toml +1 -1
- clue_api-1.7.0.dev446/clue/services/jwt_service.py +0 -254
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/LICENSE +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/README.md +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/.gitignore +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/api/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/api/base.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/api/v1/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/api/v1/actions.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/api/v1/auth.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/api/v1/configs.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/api/v1/fetchers.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/api/v1/lookup.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/api/v1/registration.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/api/v1/static.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/api/v1/sync.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/app.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/cache/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/bytes_utils.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/classification.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/classification.yml +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/dict_utils.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/exceptions.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/forge.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/json_utils.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/list_utils.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/logging/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/logging/audit.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/logging/format.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/regex.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/str_utils.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/swagger.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/common/uid.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/config.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/constants/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/constants/env.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/constants/supported_types.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/cronjobs/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/cronjobs/plugins.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/error.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/extensions/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/extensions/config.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/gunicorn_config.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/healthz.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/helper/discover.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/helper/headers.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/helper/oauth.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/helper/obo.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/helper/plugin_requests.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/actions.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/auth_user.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/fetchers.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/graph.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/model_list.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/results/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/results/base.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/results/file.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/results/graph.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/results/image.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/results/status.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/results/validation.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/schema.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/selector.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/sync.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/models/validators.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/patched.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/plugin/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/plugin/celery_app.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/plugin/helpers/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/plugin/helpers/central_server.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/plugin/helpers/email_render.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/plugin/helpers/token.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/plugin/helpers/trino.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/plugin/models.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/plugin/utils.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/py.typed +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/remote/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/remote/datatypes/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/remote/datatypes/cache.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/remote/datatypes/events.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/remote/datatypes/hash.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/remote/datatypes/queues/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/remote/datatypes/queues/comms.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/remote/datatypes/set.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/remote/datatypes/user_quota_tracker.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/security/__init__.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/security/obo.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/security/utils.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/services/action_service.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/services/auth_service.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/services/config_service.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/services/fetcher_service.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/services/mongo_service.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/services/type_service.py +0 -0
- {clue_api-1.7.0.dev446 → clue_api-1.7.0.dev458}/clue/services/user_service.py +0 -0
|
@@ -125,6 +125,9 @@ class OAuthProvider(BaseModel):
|
|
|
125
125
|
class OAuth(BaseModel):
|
|
126
126
|
enabled: bool = Field(description="Enable use of OAuth?", default=False)
|
|
127
127
|
gravatar_enabled: bool = Field(description="Enable gravatar?", default=False)
|
|
128
|
+
jwks_refresh_cooldown_seconds: int = Field(
|
|
129
|
+
default=60, ge=1, description="Minimum interval in seconds between outbound JWKS refresh attempts"
|
|
130
|
+
)
|
|
128
131
|
providers: dict[str, OAuthProvider] = Field(default={}, description="OAuth provider configuration")
|
|
129
132
|
other_audiences: list[str] | None = Field(
|
|
130
133
|
default=None, description="What other audiences in JWT tokens should Clue accept?"
|
|
@@ -403,7 +406,11 @@ class ExternalSource(BaseModel):
|
|
|
403
406
|
datahub_link: Url | None = Field(description="Link to datahub entry on this enrichment", default=None)
|
|
404
407
|
documentation_link: Url | None = Field(description="Link to documentation on this enrichment", default=None)
|
|
405
408
|
production: bool = Field(
|
|
406
|
-
description=
|
|
409
|
+
description=(
|
|
410
|
+
"Is this source ready for production? Skips entry validation for increased speed. "
|
|
411
|
+
"Bulk responses also bypass result schema validation. "
|
|
412
|
+
"Classification validation and user-clearance filtering always apply."
|
|
413
|
+
),
|
|
407
414
|
default=False,
|
|
408
415
|
)
|
|
409
416
|
include_default: bool = Field(
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Any, NamedTuple
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class JWKSSnapshot(NamedTuple):
|
|
5
|
+
"""An immutable-by-convention generation of provider keys and the times they were fetched.
|
|
6
|
+
|
|
7
|
+
Keys and timestamps are bundled so a reader that holds one snapshot always judges key age by the
|
|
8
|
+
timestamps captured with those keys, never by a newer refresh. Instances must not be mutated after
|
|
9
|
+
they are published to the cache or the pinned fallback.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
jwks: JWK dictionaries keyed by `kid`.
|
|
13
|
+
providers: The OAuth provider name that owns each `kid`.
|
|
14
|
+
timestamps: Monotonic time of each provider's last successful fetch, keyed by provider name.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
jwks: dict[str, dict[str, Any]]
|
|
18
|
+
providers: dict[str, str]
|
|
19
|
+
timestamps: dict[str, float]
|
|
@@ -520,7 +520,7 @@ class QueryResult(ResultMetadata):
|
|
|
520
520
|
@field_validator("items")
|
|
521
521
|
@classmethod
|
|
522
522
|
def validate_items(cls, items: list[QueryEntry], info: ValidationInfo) -> list[QueryEntry]: # noqa: ANN102
|
|
523
|
-
"""
|
|
523
|
+
"""Retain only items accessible to the user, failing closed without user classification.
|
|
524
524
|
|
|
525
525
|
Args:
|
|
526
526
|
items (list[QueryEntry]): The items to validate.
|
|
@@ -529,39 +529,49 @@ class QueryResult(ResultMetadata):
|
|
|
529
529
|
Returns:
|
|
530
530
|
list[QueryEntry]: The validated items field.
|
|
531
531
|
"""
|
|
532
|
-
if info.context:
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
532
|
+
if not isinstance(info.context, dict) or "user" not in info.context:
|
|
533
|
+
logger.warning("No user context given, dropping all query items")
|
|
534
|
+
return []
|
|
535
|
+
|
|
536
|
+
return cls.filter_items(items, info.context["user"])
|
|
537
|
+
|
|
538
|
+
@classmethod
|
|
539
|
+
def filter_items(cls, items: list[QueryEntry], user: Any) -> list[QueryEntry]: # noqa: ANN102
|
|
540
|
+
"""Apply classification authorization independently of response schema validation."""
|
|
541
|
+
user_classification = user.get("classification") if isinstance(user, dict) else None
|
|
542
|
+
if (
|
|
543
|
+
not isinstance(user_classification, str)
|
|
544
|
+
or not user_classification
|
|
545
|
+
or user_classification.upper().partition("//")[0] in {"INV", CLASSIFICATION.INVALID_CLASSIFICATION}
|
|
546
|
+
or not CLASSIFICATION.is_valid(user_classification)
|
|
547
|
+
):
|
|
548
|
+
logger.warning("Missing or invalid user classification, dropping all query items")
|
|
549
|
+
return []
|
|
550
|
+
|
|
551
|
+
filtered_results: list[QueryEntry] = []
|
|
552
|
+
for item in items:
|
|
553
|
+
if (
|
|
554
|
+
"classification" in item.model_fields_set
|
|
555
|
+
and isinstance(item.classification, str)
|
|
556
|
+
and item.classification
|
|
557
|
+
and item.classification.upper().partition("//")[0] not in {"INV", CLASSIFICATION.INVALID_CLASSIFICATION}
|
|
558
|
+
and CLASSIFICATION.is_valid(item.classification)
|
|
559
|
+
and CLASSIFICATION.is_accessible(user_classification, item.classification, ignore_invalid=True)
|
|
560
|
+
):
|
|
561
|
+
filtered_results.append(item)
|
|
559
562
|
else:
|
|
560
|
-
logger.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
+
logger.debug("Removing item with invalid or inaccessible classification")
|
|
564
|
+
|
|
565
|
+
if len(items) > len(filtered_results):
|
|
566
|
+
logger.info(
|
|
567
|
+
"Dropped %s items due to inaccessible classification (user classification: %s)",
|
|
568
|
+
len(items) - len(filtered_results),
|
|
569
|
+
user_classification,
|
|
570
|
+
)
|
|
571
|
+
elif DEBUG:
|
|
572
|
+
logger.debug("All %s values are accessible by user classification %s", len(items), user_classification)
|
|
563
573
|
|
|
564
|
-
return
|
|
574
|
+
return filtered_results
|
|
565
575
|
|
|
566
576
|
|
|
567
577
|
class PluginResponse(BaseModel):
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
# implementation based on this stackoverflow post:
|
|
2
|
+
# https://stackoverflow.com/a/67943659
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from threading import Lock
|
|
8
|
+
from typing import Any, Optional, cast
|
|
9
|
+
|
|
10
|
+
import jwt
|
|
11
|
+
import requests
|
|
12
|
+
from jwt.api_jwk import PyJWK
|
|
13
|
+
|
|
14
|
+
from clue.common.exceptions import ClueKeyError, ClueValueError
|
|
15
|
+
from clue.common.logging import get_logger
|
|
16
|
+
from clue.config import cache, config
|
|
17
|
+
from clue.models.jwks import JWKSSnapshot
|
|
18
|
+
from clue.security.utils import decode_jwt_payload
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__file__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Shared across users in this worker so only one JWKS fetch can run at a time.
|
|
24
|
+
_jwks_refresh_lock = Lock()
|
|
25
|
+
_last_jwks_refresh_time: float | None = None
|
|
26
|
+
# Last successfully fetched JWKS. Kept outside SimpleCache so cache pressure can never lock out known keys.
|
|
27
|
+
_pinned_jwks: JWKSSnapshot | None = None
|
|
28
|
+
# Keys whose provider was last fetched longer ago than this are never served, from the cache or the pin.
|
|
29
|
+
MAX_PINNED_JWKS_STALE_SECONDS = 12 * 60 * 60
|
|
30
|
+
JWKS_CACHE_TIMEOUT_SECONDS = 60 * 60 * 12
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_jwk(access_token: str) -> PyJWK:
|
|
34
|
+
"""Get the JSON Web Key associated with the given JWT"""
|
|
35
|
+
# "kid" is the JSON Web Key's identifier. It tells us which key was used to validate the token.
|
|
36
|
+
kid = jwt.get_unverified_header(access_token).get("kid")
|
|
37
|
+
if not kid or not isinstance(kid, str):
|
|
38
|
+
raise ClueValueError("Unexpected kid value in access token: %s", kid)
|
|
39
|
+
|
|
40
|
+
jwks, _ = get_jwks()
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# Check to see if we have it cached
|
|
44
|
+
key = PyJWK(jwks[kid])
|
|
45
|
+
except KeyError:
|
|
46
|
+
# The unverified kid may be attacker-controlled; refresh only if the cooldown permits it.
|
|
47
|
+
try:
|
|
48
|
+
jwks, _ = get_jwks(refresh=True)
|
|
49
|
+
key = PyJWK(jwks[kid])
|
|
50
|
+
except KeyError as e:
|
|
51
|
+
raise ClueKeyError("There is no valid JWK for this token.") from e
|
|
52
|
+
|
|
53
|
+
return key
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_provider(access_token: str) -> str:
|
|
57
|
+
"""Get the provider of a given access token
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
access_token (str): The access token to determine the provider of
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ClueValueError: The provider of this access token does not match any supported providers
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
str: The provider of the token
|
|
67
|
+
"""
|
|
68
|
+
# "kid" is the JSON Web Key's identifier. It tells us which key was used to validate the token.
|
|
69
|
+
kid = jwt.get_unverified_header(access_token).get("kid")
|
|
70
|
+
if not kid or not isinstance(kid, str):
|
|
71
|
+
raise ClueValueError("Unexpected kid value in access token: %s", kid)
|
|
72
|
+
|
|
73
|
+
_, providers = get_jwks()
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
# Check to see if we have it cached
|
|
77
|
+
oauth_provider = providers[kid]
|
|
78
|
+
except KeyError:
|
|
79
|
+
# Provider lookups use the same refresh cooldown as key lookups.
|
|
80
|
+
try:
|
|
81
|
+
_, providers = get_jwks(refresh=True)
|
|
82
|
+
oauth_provider = providers[kid]
|
|
83
|
+
except KeyError as e:
|
|
84
|
+
raise ClueValueError("The provider of this access token does not match any supported providers") from e
|
|
85
|
+
|
|
86
|
+
return oauth_provider
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _fresh_only(snapshot: JWKSSnapshot, now: float | None = None) -> JWKSSnapshot:
|
|
90
|
+
"""Filters out keys and providers whose last fetch is older than the stale TTL.
|
|
91
|
+
|
|
92
|
+
This is pure: freshness is judged only by the timestamps carried in `snapshot`, never by module state,
|
|
93
|
+
so a concurrent refresh cannot make an old snapshot look newer than it is.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
snapshot: The snapshot of keys, kid owners and provider fetch times to filter.
|
|
97
|
+
now: Reference monotonic time. Defaults to `time.monotonic()` when omitted.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A new snapshot containing only providers fetched less than `MAX_PINNED_JWKS_STALE_SECONDS` before
|
|
101
|
+
`now`, and the keys they own. Providers missing from `snapshot.timestamps` are treated as expired.
|
|
102
|
+
"""
|
|
103
|
+
now = time.monotonic() if now is None else now
|
|
104
|
+
fresh_providers = {
|
|
105
|
+
name for name, fetched_at in snapshot.timestamps.items() if now - fetched_at < MAX_PINNED_JWKS_STALE_SECONDS
|
|
106
|
+
}
|
|
107
|
+
jwks = {kid: jwk for kid, jwk in snapshot.jwks.items() if snapshot.providers.get(kid) in fresh_providers}
|
|
108
|
+
return JWKSSnapshot(
|
|
109
|
+
jwks,
|
|
110
|
+
{kid: snapshot.providers[kid] for kid in jwks},
|
|
111
|
+
{name: fetched_at for name, fetched_at in snapshot.timestamps.items() if name in fresh_providers},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _unexpired_pinned_jwks() -> JWKSSnapshot | None:
|
|
116
|
+
"""Returns the pinned snapshot with expired providers removed, for use as a fallback.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
The unexpired part of the pinned snapshot, or None if there is no pinned snapshot or every provider has
|
|
120
|
+
expired. A fresh provider that returned no keys is authoritative, so the result may have no keys.
|
|
121
|
+
"""
|
|
122
|
+
pinned = _pinned_jwks # single read: keys and timestamps travel together
|
|
123
|
+
if pinned is None:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
fresh = _fresh_only(pinned)
|
|
127
|
+
# Any fresh timestamp is authoritative, even with no keys; other providers' expired keys don't matter.
|
|
128
|
+
return fresh if fresh.timestamps else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _known_jwks(cached_jwks: JWKSSnapshot | None) -> JWKSSnapshot:
|
|
132
|
+
"""Returns the best unexpired keys available without fetching.
|
|
133
|
+
|
|
134
|
+
Used on cooldowns and while another request holds the refresh lock, so valid tokens keep verifying
|
|
135
|
+
after cache evictions.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
cached_jwks: The snapshot read from the cache, or None if it was evicted or never populated.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
The unexpired cached snapshot if there is one, otherwise the unexpired pinned snapshot, otherwise an
|
|
142
|
+
empty snapshot.
|
|
143
|
+
"""
|
|
144
|
+
if cached_jwks is not None:
|
|
145
|
+
return _fresh_only(cached_jwks)
|
|
146
|
+
return _unexpired_pinned_jwks() or JWKSSnapshot({}, {}, {})
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_valid_jwk(jwk: Any) -> bool:
|
|
150
|
+
"""Checks that an untrusted JWK entry is structurally usable.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
jwk: A single entry from a provider's `keys` array.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
True if `jwk` is a dict with a non-empty string `kid`, a string `kty`, and key material that
|
|
157
|
+
`PyJWK` accepts; False otherwise.
|
|
158
|
+
"""
|
|
159
|
+
if not (
|
|
160
|
+
isinstance(jwk, dict) and isinstance(jwk.get("kid"), str) and jwk["kid"] and isinstance(jwk.get("kty"), str)
|
|
161
|
+
):
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
PyJWK(jwk)
|
|
166
|
+
except Exception: # Key material is untrusted; any parsing failure means the key is unusable.
|
|
167
|
+
return False
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _fetch_provider_jwks(jwks_uri: str) -> list[dict[str, Any]]:
|
|
172
|
+
"""Fetches and validates one provider's JWKS.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
jwks_uri: The provider's JWKS endpoint.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The valid keys from the response. Malformed entries are skipped with a warning; an empty list is an
|
|
179
|
+
authoritative "no keys" answer.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
requests.RequestException: The request failed or returned an HTTP error status.
|
|
183
|
+
ValueError: The payload is not a JSON object with a `keys` list, or every entry in a non-empty
|
|
184
|
+
`keys` list is malformed.
|
|
185
|
+
"""
|
|
186
|
+
response = requests.get(jwks_uri, timeout=10)
|
|
187
|
+
response.raise_for_status()
|
|
188
|
+
payload = response.json()
|
|
189
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("keys"), list):
|
|
190
|
+
raise ValueError(f"Malformed JWKS payload from {jwks_uri}") # noqa: TRY004
|
|
191
|
+
|
|
192
|
+
valid_keys = [jwk for jwk in payload["keys"] if _is_valid_jwk(jwk)]
|
|
193
|
+
skipped = len(payload["keys"]) - len(valid_keys)
|
|
194
|
+
if skipped:
|
|
195
|
+
logger.warning("Ignoring %d malformed JWK(s) from %s", skipped, jwks_uri)
|
|
196
|
+
if payload["keys"] and not valid_keys:
|
|
197
|
+
raise ValueError(f"JWKS from {jwks_uri} contains no valid keys")
|
|
198
|
+
|
|
199
|
+
return valid_keys
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _fetch_all_providers(now: float) -> tuple[JWKSSnapshot, dict[str, Exception]]:
|
|
203
|
+
"""Fetches every configured provider independently so one failure cannot discard another's result.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
now: Monotonic time recorded as the fetch time of each provider that succeeds.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
A snapshot of the providers that were fetched successfully (including those returning no keys), and
|
|
210
|
+
the exception for each provider that failed.
|
|
211
|
+
"""
|
|
212
|
+
jwks: dict[str, dict[str, Any]] = {}
|
|
213
|
+
providers: dict[str, str] = {}
|
|
214
|
+
timestamps: dict[str, float] = {}
|
|
215
|
+
failures: dict[str, Exception] = {}
|
|
216
|
+
for provider_name, provider_data in config.auth.oauth.providers.items():
|
|
217
|
+
if not provider_data.jwks_uri:
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
provider_jwks = _fetch_provider_jwks(provider_data.jwks_uri)
|
|
222
|
+
except (requests.RequestException, ValueError, KeyError, TypeError, AttributeError) as e:
|
|
223
|
+
failures[provider_name] = e
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
timestamps[provider_name] = now
|
|
227
|
+
jwks |= {jwk["kid"]: jwk for jwk in provider_jwks}
|
|
228
|
+
providers |= {jwk["kid"]: provider_name for jwk in provider_jwks}
|
|
229
|
+
|
|
230
|
+
return JWKSSnapshot(jwks, providers, timestamps), failures
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _carry_over_failed_providers(fresh: JWKSSnapshot, failed: Iterable[str]) -> JWKSSnapshot:
|
|
234
|
+
"""Adds each failed provider's pinned keys, with their original fetch times, to a fresh snapshot.
|
|
235
|
+
|
|
236
|
+
Expiry is not checked here: the caller runs `_fresh_only` on the result, so carried-over providers
|
|
237
|
+
cannot outlive `MAX_PINNED_JWKS_STALE_SECONDS` however many partial refreshes happen.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
fresh: Snapshot of the providers that were fetched successfully. Its entries win on a `kid` clash.
|
|
241
|
+
failed: Names of providers whose refresh failed.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
A new snapshot combining `fresh` with the pinned data of the failed providers.
|
|
245
|
+
"""
|
|
246
|
+
old = _pinned_jwks
|
|
247
|
+
if old is None:
|
|
248
|
+
return fresh
|
|
249
|
+
|
|
250
|
+
carried = {kid for kid, owner in old.providers.items() if owner in failed}
|
|
251
|
+
return JWKSSnapshot(
|
|
252
|
+
{kid: old.jwks[kid] for kid in carried} | fresh.jwks,
|
|
253
|
+
{kid: old.providers[kid] for kid in carried} | fresh.providers,
|
|
254
|
+
{name: fetched_at for name, fetched_at in old.timestamps.items() if name in failed} | fresh.timestamps,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _get_jwks_snapshot(refresh: bool) -> JWKSSnapshot:
|
|
259
|
+
global _last_jwks_refresh_time, _pinned_jwks
|
|
260
|
+
|
|
261
|
+
cached_jwks: JWKSSnapshot | None = cache.get("get_jwks")
|
|
262
|
+
if cached_jwks is not None and not refresh:
|
|
263
|
+
return _fresh_only(cached_jwks)
|
|
264
|
+
|
|
265
|
+
# Nonblocking: a waiting thread would stall behind a slow provider (up to the request timeout), so
|
|
266
|
+
# overlapping callers serve existing keys or fail fast instead.
|
|
267
|
+
if not _jwks_refresh_lock.acquire(blocking=False):
|
|
268
|
+
return _known_jwks(cached_jwks)
|
|
269
|
+
|
|
270
|
+
# The cache is only written inside this lock, and never with fallback data, so stale snapshots cannot roll it back.
|
|
271
|
+
try:
|
|
272
|
+
# Another request may have updated the cache before we acquired the lock.
|
|
273
|
+
cached_jwks = cache.get("get_jwks")
|
|
274
|
+
now = time.monotonic()
|
|
275
|
+
if (
|
|
276
|
+
_last_jwks_refresh_time is not None
|
|
277
|
+
and now - _last_jwks_refresh_time < config.auth.oauth.jwks_refresh_cooldown_seconds
|
|
278
|
+
):
|
|
279
|
+
return _known_jwks(cached_jwks)
|
|
280
|
+
|
|
281
|
+
# Recorded before fetching so failed and timed-out attempts are rate-limited too; otherwise an attacker
|
|
282
|
+
# sending unknown kids while a provider is down could trigger an outbound request per call.
|
|
283
|
+
_last_jwks_refresh_time = now
|
|
284
|
+
fetched, failures = _fetch_all_providers(now)
|
|
285
|
+
|
|
286
|
+
if not fetched.timestamps:
|
|
287
|
+
if not failures:
|
|
288
|
+
return _known_jwks(cached_jwks)
|
|
289
|
+
|
|
290
|
+
first_error = next(iter(failures.values()))
|
|
291
|
+
fallback = _unexpired_pinned_jwks()
|
|
292
|
+
if fallback is None:
|
|
293
|
+
raise first_error
|
|
294
|
+
logger.warning("Failed to refresh JWKS, serving pinned keys: %s", first_error)
|
|
295
|
+
return fallback
|
|
296
|
+
|
|
297
|
+
# Successful providers are authoritative (even with no keys); failed ones keep their unexpired pinned keys.
|
|
298
|
+
if failures:
|
|
299
|
+
logger.warning("Failed to refresh JWKS for providers %s", ", ".join(failures))
|
|
300
|
+
|
|
301
|
+
# Re-checked with a fresh clock reading: slow requests may have pushed carried-over keys past the limit
|
|
302
|
+
# since `now` was taken, and the caller must never receive a key that expired during the fetch.
|
|
303
|
+
result = _fresh_only(_carry_over_failed_providers(fetched, failures), now=time.monotonic())
|
|
304
|
+
# One assignment publishes keys and timestamps together, so readers never see mismatched generations.
|
|
305
|
+
_pinned_jwks = result
|
|
306
|
+
cache.set("get_jwks", result, timeout=JWKS_CACHE_TIMEOUT_SECONDS)
|
|
307
|
+
return result
|
|
308
|
+
finally:
|
|
309
|
+
_jwks_refresh_lock.release()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_jwks(refresh: bool = False) -> tuple[dict[str, dict[str, Any]], dict[str, str]]:
|
|
313
|
+
"""Get the JSON Web Key Set for all supported providers
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
refresh (bool): Request a refresh, subject to the outbound fetch cooldown.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
tuple[dict[str, str], dict[str, str]]: The JWKS and the providers that are included in it
|
|
320
|
+
"""
|
|
321
|
+
snapshot = _get_jwks_snapshot(refresh)
|
|
322
|
+
return snapshot.jwks, snapshot.providers
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def extract_audience(access_token: str) -> list[str]:
|
|
326
|
+
"Extract the audience from an encoded JWT."
|
|
327
|
+
audience: list[str] | str | None = decode_jwt_payload(access_token).get("aud", None)
|
|
328
|
+
|
|
329
|
+
if not audience:
|
|
330
|
+
return []
|
|
331
|
+
|
|
332
|
+
return [audience] if not isinstance(audience, list) else audience
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def get_audience(oauth_provider: str) -> str:
|
|
336
|
+
"""Get the audience for the specified OAuth provider
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
oauth_provider (str): The OAuth provider to retrieve the audience of
|
|
340
|
+
|
|
341
|
+
Raises:
|
|
342
|
+
ClueValueError: The provider is azure, and is improperly formatted
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
str: The audience of the provider
|
|
346
|
+
"""
|
|
347
|
+
audience: str = "clue"
|
|
348
|
+
provider_data = config.auth.oauth.providers[oauth_provider]
|
|
349
|
+
if provider_data.audience:
|
|
350
|
+
audience = provider_data.audience
|
|
351
|
+
elif provider_data.client_id:
|
|
352
|
+
audience = provider_data.client_id
|
|
353
|
+
|
|
354
|
+
if oauth_provider == "azure" and f"{audience}/.default" not in provider_data.scope:
|
|
355
|
+
raise ClueValueError("Azure scope must contain the <client_id>/.default claim!")
|
|
356
|
+
|
|
357
|
+
return audience
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def decode(
|
|
361
|
+
access_token: str,
|
|
362
|
+
key: Optional[str] = None,
|
|
363
|
+
algorithms: Optional[list[str]] = None,
|
|
364
|
+
audience: Optional[str] = None,
|
|
365
|
+
validate_audience: bool = False,
|
|
366
|
+
**kwargs,
|
|
367
|
+
) -> dict[str, Any]:
|
|
368
|
+
"""Decode an access token into a JSON Web Token dict
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
access_token (str): The access token to decode
|
|
372
|
+
key (Optional[str], optional): The key used to sign the token. Defaults to None.
|
|
373
|
+
algorithms (Optional[list[str]], optional): The algorithm to use when decoding. Defaults to None.
|
|
374
|
+
audience (Optional[str], optional): The audience to check against, if validating the audience. Defaults to None.
|
|
375
|
+
validate_audience (bool, optional): Should we validate the audience? Defaults to False.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
dict[str, Any]: The decoded JWT, in dict format
|
|
379
|
+
"""
|
|
380
|
+
if not key:
|
|
381
|
+
key = get_jwk(access_token).key
|
|
382
|
+
|
|
383
|
+
if not algorithms:
|
|
384
|
+
algorithms = [jwt.get_unverified_header(access_token).get("alg", "HS256")]
|
|
385
|
+
|
|
386
|
+
if validate_audience and not audience:
|
|
387
|
+
audience = get_audience(get_provider(access_token))
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
return jwt.decode(
|
|
391
|
+
jwt=access_token,
|
|
392
|
+
key=cast(str, key),
|
|
393
|
+
algorithms=algorithms,
|
|
394
|
+
audience=audience,
|
|
395
|
+
options={"verify_aud": validate_audience},
|
|
396
|
+
**kwargs,
|
|
397
|
+
) # type: ignore
|
|
398
|
+
except jwt.exceptions.InvalidAudienceError:
|
|
399
|
+
logger.debug("Default audience did not match - checking additional audiences")
|
|
400
|
+
if config.auth.oauth.other_audiences is not None:
|
|
401
|
+
# The main audience isn't valid, let's try the others
|
|
402
|
+
for audience in config.auth.oauth.other_audiences:
|
|
403
|
+
logger.debug("Checking audience %s", audience)
|
|
404
|
+
try:
|
|
405
|
+
return jwt.decode(
|
|
406
|
+
jwt=access_token,
|
|
407
|
+
key=cast(str, key),
|
|
408
|
+
algorithms=algorithms,
|
|
409
|
+
audience=audience,
|
|
410
|
+
options={"verify_aud": validate_audience},
|
|
411
|
+
**kwargs,
|
|
412
|
+
) # type: ignore
|
|
413
|
+
except jwt.InvalidAudienceError:
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
logger.warning(
|
|
417
|
+
"Default and additional audiences failed to validate. Expected: %s, Actual: %s",
|
|
418
|
+
audience,
|
|
419
|
+
",".join(extract_audience(access_token)),
|
|
420
|
+
)
|
|
421
|
+
raise
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def fetch_sa_token() -> Optional[str]:
|
|
425
|
+
"""Use a service account to fetch a valid token, if service accounts are enabled"""
|
|
426
|
+
if not config.auth.service_account.enabled:
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
# TODO: Eventually support multiple accounts
|
|
430
|
+
service_account = config.auth.service_account.accounts[0]
|
|
431
|
+
cache_key = f"sa_refresh_token_{service_account.username}"
|
|
432
|
+
|
|
433
|
+
provider = config.auth.oauth.providers[service_account.provider]
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
# Eventually switch this to a redis cache (the rest of this file too)
|
|
437
|
+
refresh_token = cache.get(key=cache_key)
|
|
438
|
+
use_cache = True
|
|
439
|
+
except AttributeError:
|
|
440
|
+
refresh_token = None
|
|
441
|
+
use_cache = False
|
|
442
|
+
|
|
443
|
+
if refresh_token:
|
|
444
|
+
sa_jwt = requests.post(
|
|
445
|
+
provider.access_token_url,
|
|
446
|
+
data={
|
|
447
|
+
"client_id": provider.client_id,
|
|
448
|
+
"client_secret": provider.client_secret,
|
|
449
|
+
"grant_type": "refresh_token",
|
|
450
|
+
"refresh_token": refresh_token,
|
|
451
|
+
"scope": provider.scope,
|
|
452
|
+
},
|
|
453
|
+
timeout=30,
|
|
454
|
+
).json()
|
|
455
|
+
else:
|
|
456
|
+
sa_jwt = requests.post(
|
|
457
|
+
provider.access_token_url,
|
|
458
|
+
data={
|
|
459
|
+
"client_id": provider.client_id,
|
|
460
|
+
"client_secret": provider.client_secret,
|
|
461
|
+
"grant_type": "password",
|
|
462
|
+
"username": service_account.username,
|
|
463
|
+
"password": service_account.password,
|
|
464
|
+
"scope": provider.scope,
|
|
465
|
+
},
|
|
466
|
+
timeout=30,
|
|
467
|
+
).json()
|
|
468
|
+
|
|
469
|
+
if "error" in sa_jwt:
|
|
470
|
+
logger.critical("[%s]: %s", sa_jwt["error"], sa_jwt["error_description"])
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
if "refresh_token" in sa_jwt and use_cache:
|
|
474
|
+
cache.set(cache_key, sa_jwt["refresh_token"], timeout=60 * 60 * 12)
|
|
475
|
+
|
|
476
|
+
return sa_jwt["access_token"]
|