oidcauthlib 3.0.20__tar.gz → 3.0.22__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.
Files changed (99) hide show
  1. oidcauthlib-3.0.22/PKG-INFO +68 -0
  2. oidcauthlib-3.0.22/README.md +40 -0
  3. oidcauthlib-3.0.22/VERSION +1 -0
  4. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/auth_manager.py +7 -1
  5. oidcauthlib-3.0.22/oidcauthlib/auth/userinfo/oidc_userinfo_manager.py +297 -0
  6. oidcauthlib-3.0.22/oidcauthlib/open_telemetry/configurator.py +74 -0
  7. oidcauthlib-3.0.22/oidcauthlib/open_telemetry/filtering_sampler.py +90 -0
  8. oidcauthlib-3.0.22/oidcauthlib/open_telemetry/filtering_span_exporter.py +145 -0
  9. oidcauthlib-3.0.22/oidcauthlib/open_telemetry/filtering_span_processor.py +167 -0
  10. oidcauthlib-3.0.22/oidcauthlib/open_telemetry/otel_setup.py +294 -0
  11. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/open_telemetry/span_names.py +3 -0
  12. oidcauthlib-3.0.22/oidcauthlib/utilities/logger/__init__.py +0 -0
  13. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/logger/log_levels.py +1 -0
  14. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/logger/logging_response.py +10 -9
  15. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/logger/logging_transport.py +12 -11
  16. oidcauthlib-3.0.22/oidcauthlib.egg-info/PKG-INFO +68 -0
  17. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib.egg-info/SOURCES.txt +7 -0
  18. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib.egg-info/requires.txt +2 -1
  19. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/pyproject.toml +7 -3
  20. oidcauthlib-3.0.20/PKG-INFO +0 -28
  21. oidcauthlib-3.0.20/README.md +0 -1
  22. oidcauthlib-3.0.20/VERSION +0 -1
  23. oidcauthlib-3.0.20/oidcauthlib.egg-info/PKG-INFO +0 -28
  24. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/LICENSE +0 -0
  25. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/MANIFEST.in +0 -0
  26. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/__init__.py +0 -0
  27. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/__init__.py +0 -0
  28. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/auth_helper.py +0 -0
  29. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/cache/__init__.py +0 -0
  30. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/cache/oauth_cache.py +0 -0
  31. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/cache/oauth_memory_cache.py +0 -0
  32. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/cache/oauth_mongo_cache.py +0 -0
  33. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/config/__init__.py +0 -0
  34. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/config/auth_config.py +0 -0
  35. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/config/auth_config_reader.py +0 -0
  36. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/dcr/__init__.py +0 -0
  37. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/dcr/dcr_client.py +0 -0
  38. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/dcr/dcr_manager.py +0 -0
  39. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/dcr/dcr_registration.py +0 -0
  40. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/exceptions/__init__.py +0 -0
  41. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/exceptions/authorization_bearer_token_expired_exception.py +0 -0
  42. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/exceptions/authorization_bearer_token_invalid_exception.py +0 -0
  43. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/exceptions/authorization_bearer_token_missing_exception.py +0 -0
  44. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/exceptions/authorization_needed_exception.py +0 -0
  45. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/fastapi_auth_manager.py +0 -0
  46. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/middleware/__init__.py +0 -0
  47. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/middleware/request_scope_middleware.py +0 -0
  48. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/middleware/token_reader_middleware.py +0 -0
  49. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/models/__init__.py +0 -0
  50. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/models/auth.py +0 -0
  51. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/models/base_db_model.py +0 -0
  52. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/models/cache_item.py +0 -0
  53. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/models/client_key_set.py +0 -0
  54. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/models/token.py +0 -0
  55. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/__init__.py +0 -0
  56. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/base_repository.py +0 -0
  57. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/memory/__init__.py +0 -0
  58. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/memory/memory_repository.py +0 -0
  59. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/mongo/__init__.py +0 -0
  60. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/mongo/mongo_repository.py +0 -0
  61. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/repository/repository_factory.py +0 -0
  62. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/routers/__init__.py +0 -0
  63. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/routers/auth_router.py +0 -0
  64. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/token_reader.py +0 -0
  65. {oidcauthlib-3.0.20/oidcauthlib/auth/well_known_configuration → oidcauthlib-3.0.22/oidcauthlib/auth/userinfo}/__init__.py +0 -0
  66. {oidcauthlib-3.0.20/oidcauthlib/container → oidcauthlib-3.0.22/oidcauthlib/auth/well_known_configuration}/__init__.py +0 -0
  67. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/well_known_configuration/auth_server_metadata.py +0 -0
  68. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/well_known_configuration/auth_server_metadata_discovery.py +0 -0
  69. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/well_known_configuration/well_known_configuration_cache.py +0 -0
  70. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/well_known_configuration/well_known_configuration_cache_result.py +0 -0
  71. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/auth/well_known_configuration/well_known_configuration_manager.py +0 -0
  72. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/cache_gate/__init__.py +0 -0
  73. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/cache_gate/advisory_lock.py +0 -0
  74. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/cache_gate/version_gate.py +0 -0
  75. {oidcauthlib-3.0.20/oidcauthlib/open_telemetry → oidcauthlib-3.0.22/oidcauthlib/container}/__init__.py +0 -0
  76. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/container/oidc_authlib_container_factory.py +0 -0
  77. {oidcauthlib-3.0.20/oidcauthlib/storage → oidcauthlib-3.0.22/oidcauthlib/open_telemetry}/__init__.py +0 -0
  78. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/open_telemetry/attribute_names.py +0 -0
  79. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/py.typed +0 -0
  80. {oidcauthlib-3.0.20/oidcauthlib/utilities → oidcauthlib-3.0.22/oidcauthlib/storage}/__init__.py +0 -0
  81. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/cache_to_collection_mapper.py +0 -0
  82. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/memory_storage_factory.py +0 -0
  83. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/mongo_gridfs_db.py +0 -0
  84. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/mongo_gridfs_exception.py +0 -0
  85. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/mongo_storage_factory.py +0 -0
  86. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/storage_factory.py +0 -0
  87. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/storage/storage_factory_creator.py +0 -0
  88. {oidcauthlib-3.0.20/oidcauthlib/utilities/environment → oidcauthlib-3.0.22/oidcauthlib/utilities}/__init__.py +0 -0
  89. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/cached.py +0 -0
  90. {oidcauthlib-3.0.20/oidcauthlib/utilities/logger → oidcauthlib-3.0.22/oidcauthlib/utilities/environment}/__init__.py +0 -0
  91. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/environment/abstract_environment_variables.py +0 -0
  92. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/environment/oidc_environment_variables.py +0 -0
  93. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/mongo_url_utils.py +0 -0
  94. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib/utilities/url_validator.py +0 -0
  95. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib.egg-info/dependency_links.txt +0 -0
  96. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/oidcauthlib.egg-info/top_level.txt +0 -0
  97. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/setup.cfg +0 -0
  98. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/tests/test_mongo_url_utils.py +0 -0
  99. {oidcauthlib-3.0.20 → oidcauthlib-3.0.22}/tests/test_simple.py +0 -0
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: oidcauthlib
3
+ Version: 3.0.22
4
+ Summary: oidcauthlib
5
+ Author-email: Imran Qureshi <imran.qureshi@bwell.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/icanbwell/oidc-auth-lib
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: httpx>=0.27.2
16
+ Requires-Dist: httpx2>=2.12.0
17
+ Requires-Dist: authlib>=1.8.0
18
+ Requires-Dist: joserfc>=1.4.3
19
+ Requires-Dist: pydantic<3.0.0,>=2.0
20
+ Requires-Dist: pymongo[snappy]>=4.15.3
21
+ Requires-Dist: python-snappy>=0.7.3
22
+ Requires-Dist: fastapi>=0.115.8
23
+ Requires-Dist: starlette>=0.49.1
24
+ Requires-Dist: py-key-value-aio[memory,mongodb,pydantic,redis]>=0.4.4
25
+ Requires-Dist: opentelemetry-api>=1.39.1
26
+ Requires-Dist: simple-container>=1.0.2
27
+ Dynamic: license-file
28
+
29
+ # oidcauthlib
30
+
31
+ ## OIDC userinfo scopes (`OidcUserInfoManager`)
32
+
33
+ Some providers (e.g. Okta employee access tokens) only carry `openid profile email groups` in the JWT;
34
+ the scopes that authorize a caller appear only on the OIDC userinfo response.
35
+ `oidcauthlib.auth.userinfo.oidc_userinfo_manager.OidcUserInfoManager` resolves a token's effective
36
+ scopes from the token's own claims plus the userinfo endpoint, and caches the userinfo-derived scopes in
37
+ a key-value store.
38
+
39
+ ```python
40
+ manager = OidcUserInfoManager(
41
+ auth_config_reader=auth_config_reader,
42
+ well_known_configuration_manager=well_known_configuration_manager,
43
+ store=store, # py-key-value-aio store; yours, not shared with other services
44
+ cache_collection="oidc-user-info-cache",
45
+ cache_ttl_seconds=86400,
46
+ fetch_timeout_seconds=30,
47
+ scope_claims=["custom:scope"], # optional extra claims holding scopes
48
+ group_claims=["groups"], # optional claims holding group names, used whole as scopes
49
+ scope_remove_prefixes=["fhir/prod/"],
50
+ )
51
+ scopes = await manager.get_scopes_from_user_info_async(access_token=token)
52
+ ```
53
+
54
+ - **Scope derivation** follows fhir-server-ui's `jwtParser`: the `scope` claim (plus `scp`), then
55
+ `scope_claims`, then `group_claims` (each value taken whole). Commas separate like spaces, results are
56
+ de-duplicated, and `scope_remove_prefixes` are stripped (first match wins). Nothing beyond `scope`/`scp`
57
+ is read unless configured.
58
+ - **Provider** is matched to the token's `iss` (exact `issuer`, then `well_known_uri` equal to or beneath the
59
+ issuer at a path boundary), then its client id. A token matching no provider raises `ValueError`; it is
60
+ never sent to another provider.
61
+ - **Cache** key is `oidc-userinfo-scopes:<issuer>:<client_id>:<sub>|v1` with each segment percent-encoded
62
+ (so values containing `:` cannot collide, and two providers never share an entry); only the
63
+ userinfo-derived scopes are stored, never the userinfo response. The token's own scopes are re-derived on every call. A token without
64
+ `sub`, or an empty scope list, is not cached. Changes to a user's entitlements are visible after at most
65
+ `cache_ttl_seconds`.
66
+ - **Failures:** a cache write failure is logged and swallowed. A cache read failure, malformed cache
67
+ entry, userinfo HTTP error or missing provider is raised, so each service decides whether to fail closed
68
+ or degrade.
@@ -0,0 +1,40 @@
1
+ # oidcauthlib
2
+
3
+ ## OIDC userinfo scopes (`OidcUserInfoManager`)
4
+
5
+ Some providers (e.g. Okta employee access tokens) only carry `openid profile email groups` in the JWT;
6
+ the scopes that authorize a caller appear only on the OIDC userinfo response.
7
+ `oidcauthlib.auth.userinfo.oidc_userinfo_manager.OidcUserInfoManager` resolves a token's effective
8
+ scopes from the token's own claims plus the userinfo endpoint, and caches the userinfo-derived scopes in
9
+ a key-value store.
10
+
11
+ ```python
12
+ manager = OidcUserInfoManager(
13
+ auth_config_reader=auth_config_reader,
14
+ well_known_configuration_manager=well_known_configuration_manager,
15
+ store=store, # py-key-value-aio store; yours, not shared with other services
16
+ cache_collection="oidc-user-info-cache",
17
+ cache_ttl_seconds=86400,
18
+ fetch_timeout_seconds=30,
19
+ scope_claims=["custom:scope"], # optional extra claims holding scopes
20
+ group_claims=["groups"], # optional claims holding group names, used whole as scopes
21
+ scope_remove_prefixes=["fhir/prod/"],
22
+ )
23
+ scopes = await manager.get_scopes_from_user_info_async(access_token=token)
24
+ ```
25
+
26
+ - **Scope derivation** follows fhir-server-ui's `jwtParser`: the `scope` claim (plus `scp`), then
27
+ `scope_claims`, then `group_claims` (each value taken whole). Commas separate like spaces, results are
28
+ de-duplicated, and `scope_remove_prefixes` are stripped (first match wins). Nothing beyond `scope`/`scp`
29
+ is read unless configured.
30
+ - **Provider** is matched to the token's `iss` (exact `issuer`, then `well_known_uri` equal to or beneath the
31
+ issuer at a path boundary), then its client id. A token matching no provider raises `ValueError`; it is
32
+ never sent to another provider.
33
+ - **Cache** key is `oidc-userinfo-scopes:<issuer>:<client_id>:<sub>|v1` with each segment percent-encoded
34
+ (so values containing `:` cannot collide, and two providers never share an entry); only the
35
+ userinfo-derived scopes are stored, never the userinfo response. The token's own scopes are re-derived on every call. A token without
36
+ `sub`, or an empty scope list, is not cached. Changes to a user's entitlements are visible after at most
37
+ `cache_ttl_seconds`.
38
+ - **Failures:** a cache write failure is logged and swallowed. A cache read failure, malformed cache
39
+ entry, userinfo HTTP error or missing provider is raised, so each service decides whether to fail closed
40
+ or degrade.
@@ -0,0 +1 @@
1
+ 3.0.22
@@ -6,6 +6,7 @@ import uuid
6
6
  from typing import Any, Dict, cast, List
7
7
 
8
8
  import httpx
9
+ import httpx2
9
10
  from authlib.integrations.httpx_client import AsyncOAuth2Client
10
11
  from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
11
12
 
@@ -160,7 +161,12 @@ class AuthManager:
160
161
  # --- Build client kwargs ---
161
162
  client_kwargs: dict[str, Any] = {
162
163
  "scope": auth_config.scope,
163
- "transport": LoggingTransport(httpx.AsyncHTTPTransport()),
164
+ # authlib's httpx_client integration is httpx2-based as of authlib 1.8.0
165
+ # (authlib#909) -- this transport is passed straight into
166
+ # httpx2.AsyncClient.__init__, so it must wrap an httpx2 transport, not
167
+ # httpx's (a custom httpx-based transport= is one of authlib's own
168
+ # documented breaking changes for that release).
169
+ "transport": LoggingTransport(httpx2.AsyncHTTPTransport()),
164
170
  }
165
171
 
166
172
  if auth_config.use_pkce and auth_config.pkce_method:
@@ -0,0 +1,297 @@
1
+ import hashlib
2
+ import logging
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+ from key_value.aio.protocols.key_value import AsyncKeyValueProtocol
9
+ from opentelemetry import trace
10
+ from pydantic import BaseModel, Field
11
+
12
+ from oidcauthlib.auth.config.auth_config import AuthConfig
13
+ from oidcauthlib.auth.config.auth_config_reader import AuthConfigReader
14
+ from oidcauthlib.auth.models.token import Token
15
+ from oidcauthlib.auth.well_known_configuration.well_known_configuration_manager import (
16
+ WellKnownConfigurationManager,
17
+ )
18
+ from oidcauthlib.open_telemetry.span_names import OidcOpenTelemetrySpanNames
19
+ from oidcauthlib.utilities.logger.log_levels import SRC_LOG_LEVELS
20
+
21
+ logger = logging.getLogger(__name__)
22
+ logger.setLevel(SRC_LOG_LEVELS["AUTH"])
23
+
24
+
25
+ class OidcUserInfoCacheEntry(BaseModel):
26
+ """Document stored for one cached scope list."""
27
+
28
+ cache_version: int = Field(description="Version of the cache entry format.")
29
+ client_id: str | None = Field(default=None)
30
+ value: dict[str, Any] = Field(description="Cached payload: {'scopes': [...]}.")
31
+
32
+
33
+ class OidcUserInfoManager:
34
+ """Resolves a token's effective scopes using the OIDC userinfo endpoint, cached in a key-value store.
35
+
36
+ Some providers (e.g. Okta employee access tokens) only carry ``openid profile email groups`` in the
37
+ JWT; the scopes that authorize a caller appear only on the userinfo response.
38
+
39
+ Behavior:
40
+ - The provider is matched to the token's ``iss`` claim (exact ``issuer``, then ``iss`` contained in the
41
+ provider's ``well_known_uri``), then to its client id. A token matching no provider is an error:
42
+ the bearer token is never sent to an unrelated provider.
43
+ - The userinfo endpoint is discovered through ``WellKnownConfigurationManager``.
44
+ - Scopes are derived the way fhir-server-ui's ``jwtParser`` does: the ``scope`` claim (plus ``scp``,
45
+ which Okta uses), the claims named in ``scope_claims``, and each claim named in ``group_claims``
46
+ with its values taken whole as scopes. Commas are treated as separators, the result is
47
+ de-duplicated, and ``scope_remove_prefixes`` are stripped (first match wins). No claim is read by
48
+ default beyond ``scope``/``scp``: group and extra scope claims are configuration.
49
+ - The result is the union of the scopes derived from the token's own claims and from the userinfo
50
+ response.
51
+ - Only the scopes derived from the userinfo response are cached, never the response itself, under
52
+ ``oidc-userinfo-scopes:<issuer>:<client_id>:<sub>|v<CACHE_VERSION>`` (each segment percent-encoded). The token's own scopes are always
53
+ re-derived from the presented token, so a narrower token never inherits a broader cached set.
54
+ Bumping ``CACHE_VERSION`` obsoletes every existing entry.
55
+ - A token without ``sub`` is never cached: the key would be shared by every user of the client.
56
+ - An empty scope list is never cached: it is also what an account with no scopes looks like, and
57
+ caching it would pin that result for the whole TTL.
58
+ - A cache write failure is logged and swallowed; it never fails the lookup.
59
+
60
+ The store and collection are supplied by the caller, so each service keeps its own database. A cached
61
+ scope list outlives a change to the user's entitlements by up to ``cache_ttl_seconds``.
62
+
63
+ Failures of the lookup itself (no matching provider, HTTP error, unreadable cache entry) are raised so
64
+ the caller decides whether to fail closed or degrade.
65
+ """
66
+
67
+ CACHE_VERSION = 1
68
+ _KEY_PREFIX = "oidc-userinfo-scopes"
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ auth_config_reader: AuthConfigReader,
74
+ well_known_configuration_manager: WellKnownConfigurationManager,
75
+ store: AsyncKeyValueProtocol,
76
+ cache_collection: str,
77
+ cache_ttl_seconds: float,
78
+ fetch_timeout_seconds: float,
79
+ scope_claims: Sequence[str] = (),
80
+ group_claims: Sequence[str] = (),
81
+ scope_remove_prefixes: Sequence[str] = (),
82
+ transport: httpx.AsyncBaseTransport | None = None,
83
+ ) -> None:
84
+ """
85
+ Args:
86
+ auth_config_reader: Reads the configured auth providers.
87
+ well_known_configuration_manager: Supplies the provider's discovery document.
88
+ store: Key-value store holding the cached scopes.
89
+ cache_collection: Collection of ``store`` the entries are written to.
90
+ cache_ttl_seconds: How long a cached entry lives.
91
+ fetch_timeout_seconds: Timeout of the userinfo request.
92
+ scope_claims: Extra claims, besides ``scope`` and ``scp``, whose values are scopes.
93
+ group_claims: Claims whose values are group names, taken whole as scopes.
94
+ scope_remove_prefixes: Prefixes stripped from each scope (first match wins).
95
+ transport: httpx transport, for tests.
96
+ """
97
+ self._auth_config_reader = auth_config_reader
98
+ self._well_known_configuration_manager = well_known_configuration_manager
99
+ self._store = store
100
+ self._cache_collection = cache_collection
101
+ self._cache_ttl_seconds = cache_ttl_seconds
102
+ self._fetch_timeout_seconds = fetch_timeout_seconds
103
+ self._scope_claims = tuple(scope_claims)
104
+ self._group_claims = tuple(group_claims)
105
+ self._scope_remove_prefixes = tuple(scope_remove_prefixes)
106
+ self._transport = transport
107
+
108
+ def resolve_auth_config(self, *, access_token: Token) -> AuthConfig:
109
+ """The configuration of the provider that issued ``access_token``.
110
+
111
+ Raises:
112
+ ValueError: no configured provider matches the token's issuer or client id.
113
+ """
114
+ issuer = access_token.issuer or (access_token.claims or {}).get("iss")
115
+ configs = self._auth_config_reader.get_auth_configs_for_all_auth_providers()
116
+ if issuer:
117
+ for config in configs:
118
+ if config.issuer and config.issuer == issuer:
119
+ return config
120
+ for config in configs:
121
+ if config.well_known_uri and self._is_under_issuer(uri=config.well_known_uri, issuer=issuer):
122
+ return config
123
+ client_id = access_token.client_id
124
+ if client_id:
125
+ auth_provider = self._auth_config_reader.get_provider_for_client_id(client_id=client_id)
126
+ if auth_provider:
127
+ auth_config = self._auth_config_reader.get_config_for_auth_provider(auth_provider=auth_provider)
128
+ if auth_config:
129
+ return auth_config
130
+ raise ValueError(f"No auth provider matches token issuer {issuer!r} or client id {client_id!r}")
131
+
132
+ @staticmethod
133
+ def _is_under_issuer(*, uri: str, issuer: str) -> bool:
134
+ """True when ``uri`` is the issuer itself or a path beneath it.
135
+
136
+ A bare substring test would also match ``https://a.example.com.au/...`` for the issuer
137
+ ``https://a.example.com`` and send the token to the wrong provider.
138
+ """
139
+ base = issuer.rstrip("/")
140
+ return uri == base or uri.startswith(f"{base}/")
141
+
142
+ @staticmethod
143
+ def _loggable(*, cache_key: str) -> str:
144
+ """Short digest of ``cache_key``: the key embeds the user's ``sub``, which must not reach logs."""
145
+ return hashlib.sha256(cache_key.encode()).hexdigest()[:12]
146
+
147
+ async def get_user_info_async(self, *, access_token: Token) -> dict[str, Any]:
148
+ """Fetch the userinfo response for ``access_token``.
149
+
150
+ ``access_token`` must already have been verified by the caller; its claims are trusted.
151
+
152
+ Raises:
153
+ ValueError: the token, auth config, discovery document or response is unusable.
154
+ httpx.HTTPError: the userinfo request failed.
155
+ """
156
+ if not access_token.token:
157
+ raise ValueError("Raw access token is required")
158
+
159
+ auth_config = self.resolve_auth_config(access_token=access_token)
160
+
161
+ well_known_result = await self._well_known_configuration_manager.get_async(auth_config=auth_config)
162
+ well_known_config = well_known_result.well_known_config if well_known_result else None
163
+ if not well_known_config:
164
+ raise ValueError("Well-known configuration not found for auth config")
165
+
166
+ userinfo_endpoint = well_known_config.get("userinfo_endpoint")
167
+ if not userinfo_endpoint or not isinstance(userinfo_endpoint, str):
168
+ raise ValueError("Userinfo endpoint not found in well-known configuration")
169
+
170
+ tracer = trace.get_tracer(__name__)
171
+ with tracer.start_as_current_span(OidcOpenTelemetrySpanNames.GET_USER_INFO_FROM_OIDC_ENDPOINT):
172
+ async with httpx.AsyncClient(transport=self._transport) as client:
173
+ response = await client.get(
174
+ userinfo_endpoint,
175
+ headers={
176
+ "Authorization": f"Bearer {access_token.token}",
177
+ "Accept": "application/json",
178
+ },
179
+ timeout=self._fetch_timeout_seconds,
180
+ )
181
+ response.raise_for_status()
182
+ user_info = response.json()
183
+ if not isinstance(user_info, dict):
184
+ raise ValueError("User info response is not a valid JSON object")
185
+ return user_info
186
+
187
+ async def get_scopes_from_user_info_async(self, *, access_token: Token) -> list[str]:
188
+ """Effective scopes of ``access_token``: its own plus those of the userinfo endpoint.
189
+
190
+ The userinfo scopes come from the cache when present, else from the endpoint (then cached).
191
+
192
+ Raises:
193
+ ValueError: the cached entry has an unexpected shape, or no provider matches the token.
194
+ httpx.HTTPError: the userinfo request failed.
195
+ """
196
+ tracer = trace.get_tracer(__name__)
197
+ with tracer.start_as_current_span(OidcOpenTelemetrySpanNames.GET_SCOPES_FROM_OIDC_USERINFO):
198
+ token_scopes = self.build_scopes(claims=access_token.claims or {})
199
+ cache_key = self.build_cache_key(
200
+ issuer=access_token.issuer or (access_token.claims or {}).get("iss"),
201
+ client_id=access_token.client_id,
202
+ subject=access_token.subject,
203
+ )
204
+
205
+ user_info_scopes: list[str] | None = None
206
+ if cache_key is not None:
207
+ user_info_scopes = await self._read_cached_scopes(cache_key=cache_key)
208
+
209
+ if user_info_scopes is None:
210
+ user_info = await self.get_user_info_async(access_token=access_token)
211
+ user_info_scopes = self.build_scopes(claims=user_info)
212
+ if cache_key is not None and user_info_scopes:
213
+ await self._write_cached_scopes(cache_key=cache_key, scopes=user_info_scopes)
214
+
215
+ return list(dict.fromkeys([*token_scopes, *user_info_scopes]))
216
+
217
+ def build_scopes(self, *, claims: dict[str, Any]) -> list[str]:
218
+ """Scopes carried by ``claims`` (a token's payload or a userinfo response)."""
219
+ scopes: list[str] = []
220
+
221
+ for claim_name in ("scope", "scp"):
222
+ scopes.extend(self._claim_values(value=claims.get(claim_name)))
223
+
224
+ for claim_name in self._scope_claims:
225
+ scopes.extend(self._claim_values(value=claims.get(claim_name)))
226
+
227
+ for claim_name in self._group_claims:
228
+ scopes.extend(self._claim_values(value=claims.get(claim_name)))
229
+
230
+ scopes = list(dict.fromkeys(scopes))
231
+ if self._scope_remove_prefixes:
232
+ scopes = [self._strip_prefix(scope=s) for s in scopes]
233
+ return scopes
234
+
235
+ @staticmethod
236
+ def _claim_values(*, value: Any) -> list[str]:
237
+ """String or list claim value as separate tokens; commas separate like spaces (as in jwtParser)."""
238
+ if isinstance(value, str):
239
+ return value.replace(",", " ").split()
240
+ if isinstance(value, list):
241
+ return [token for item in value if isinstance(item, str) for token in item.replace(",", " ").split()]
242
+ return []
243
+
244
+ def _strip_prefix(self, *, scope: str) -> str:
245
+ for prefix in self._scope_remove_prefixes:
246
+ if scope.startswith(prefix):
247
+ return scope[len(prefix) :]
248
+ return scope
249
+
250
+ @classmethod
251
+ def build_cache_key(cls, *, issuer: str | None, client_id: str | None, subject: str | None) -> str | None:
252
+ """Versioned key for one user of one client of one issuer, or None when the token is not cacheable.
253
+
254
+ A token without ``sub`` or without an issuer is not cacheable: the key would be shared by every user
255
+ of the client, or by every provider that issues tokens without an ``iss`` claim.
256
+
257
+ Each segment is percent-encoded, so a ``:`` inside an issuer, client id or subject can never move a
258
+ segment boundary and make two different users share a key. A missing client id is an empty segment,
259
+ which no encoded value can equal.
260
+ """
261
+ if not subject or not issuer:
262
+ return None
263
+ segments = [quote(value or "", safe="") for value in (issuer, client_id, subject)]
264
+ return f"{':'.join([cls._KEY_PREFIX, *segments])}|v{cls.CACHE_VERSION}"
265
+
266
+ async def _read_cached_scopes(self, *, cache_key: str) -> list[str] | None:
267
+ raw = await self._store.get(cache_key, collection=self._cache_collection)
268
+ key_id = self._loggable(cache_key=cache_key)
269
+ if raw is None:
270
+ logger.debug(f"OIDC userinfo scopes cache miss for key: {key_id}")
271
+ return None
272
+ try:
273
+ entry = OidcUserInfoCacheEntry.model_validate(raw)
274
+ except ValueError as exc:
275
+ raise ValueError(f"OIDC userinfo scopes cache entry has invalid shape for key: {key_id}") from exc
276
+ scopes = entry.value.get("scopes")
277
+ if not isinstance(scopes, list) or not all(isinstance(s, str) for s in scopes):
278
+ raise ValueError(f"OIDC userinfo scopes cache entry has invalid shape for key: {key_id}. {type(scopes)}")
279
+ return scopes
280
+
281
+ async def _write_cached_scopes(self, *, cache_key: str, scopes: list[str]) -> None:
282
+ entry = OidcUserInfoCacheEntry(
283
+ cache_version=self.CACHE_VERSION,
284
+ client_id=None,
285
+ value={"scopes": scopes},
286
+ )
287
+ try:
288
+ await self._store.put(
289
+ cache_key,
290
+ entry.model_dump(),
291
+ collection=self._cache_collection,
292
+ ttl=self._cache_ttl_seconds,
293
+ )
294
+ logger.debug(f"Cached OIDC userinfo scopes for key: {self._loggable(cache_key=cache_key)}")
295
+ except Exception:
296
+ # A cache write failure must not break the auth flow.
297
+ logger.exception(f"Failed to cache OIDC userinfo scopes for key: {self._loggable(cache_key=cache_key)}")
@@ -0,0 +1,74 @@
1
+ import logging
2
+ import os
3
+ from typing import Set
4
+ from opentelemetry.sdk.trace import TracerProvider
5
+ from opentelemetry.sdk.trace.sampling import ParentBased
6
+ from oidcauthlib.open_telemetry.filtering_sampler import FilteringSampler
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def get_excluded_span_names() -> Set[str]:
12
+ """Get excluded span names from environment or use defaults."""
13
+ default_excluded = {"saslStart", "saslContinue", "isMaster", "ping"}
14
+
15
+ env_excluded = os.environ.get("OTEL_EXCLUDED_SPAN_NAMES", "")
16
+ if env_excluded:
17
+ custom_excluded = {name.strip() for name in env_excluded.split(",") if name.strip()}
18
+ logger.info("Using custom excluded span names: %s", custom_excluded)
19
+ return custom_excluded
20
+
21
+ return default_excluded
22
+
23
+
24
+ def get_excluded_span_prefixes() -> Set[str]:
25
+ """Get excluded span name prefixes from environment."""
26
+ env_excluded = os.environ.get("OTEL_EXCLUDED_SPAN_PREFIXES", "")
27
+ if env_excluded:
28
+ custom_excluded = {prefix.strip() for prefix in env_excluded.split(",") if prefix.strip()}
29
+ logger.info("Using custom excluded span prefixes: %s", custom_excluded)
30
+ return custom_excluded
31
+
32
+ return set()
33
+
34
+
35
+ def configure_opentelemetry(tracer_provider: TracerProvider) -> None:
36
+ """
37
+ Configure OpenTelemetry with span filtering.
38
+
39
+ This is called by the auto-instrumentation configurator hook.
40
+ """
41
+ logger.info("Configuring OpenTelemetry with span filtering...")
42
+
43
+ try:
44
+ # Get current sampler
45
+ current_sampler = getattr(tracer_provider, "sampler", None)
46
+ if current_sampler is None:
47
+ logger.warning("Could not get current sampler from TracerProvider")
48
+ return
49
+
50
+ # Get filtering configuration
51
+ excluded_span_names = get_excluded_span_names()
52
+ excluded_span_prefixes = get_excluded_span_prefixes()
53
+
54
+ # Create filtering sampler that wraps the current sampler
55
+ filtering_sampler = FilteringSampler(
56
+ parent_sampler=current_sampler,
57
+ excluded_span_names=excluded_span_names,
58
+ excluded_span_prefixes=excluded_span_prefixes,
59
+ )
60
+
61
+ # Wrap it in ParentBased to respect parent sampling decisions
62
+ parent_based_filtering_sampler = ParentBased(root=filtering_sampler)
63
+
64
+ # Replace the sampler
65
+ tracer_provider.__dict__["sampler"] = parent_based_filtering_sampler
66
+
67
+ logger.info(
68
+ "✓ Configured OpenTelemetry with span filtering. Excluding span names: %s, prefixes: %s",
69
+ excluded_span_names,
70
+ excluded_span_prefixes,
71
+ )
72
+
73
+ except Exception as e:
74
+ logger.exception("Failed to configure OpenTelemetry span filtering", exc_info=e)
@@ -0,0 +1,90 @@
1
+ import logging
2
+ from typing import Optional, Set, Sequence, override
3
+
4
+ from opentelemetry.context import Context
5
+ from opentelemetry.sdk.trace.sampling import (
6
+ Sampler,
7
+ SamplingResult,
8
+ Decision,
9
+ ALWAYS_ON,
10
+ )
11
+ from opentelemetry.trace import Link, SpanKind, TraceState
12
+ from opentelemetry.util.types import Attributes
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class FilteringSampler(Sampler):
18
+ """
19
+ A sampler that filters out spans based on name patterns.
20
+
21
+ This is more reliable than a SpanProcessor because it prevents
22
+ spans from being created in the first place, rather than trying
23
+ to filter them after creation.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ parent_sampler: Optional[Sampler] = None,
29
+ excluded_span_names: Optional[Set[str]] = None,
30
+ excluded_span_prefixes: Optional[Set[str]] = None,
31
+ ):
32
+ """
33
+ Initialize the filtering sampler.
34
+
35
+ Args:
36
+ parent_sampler: The sampler to delegate to for non-filtered spans
37
+ excluded_span_names: Set of exact span names to exclude
38
+ excluded_span_prefixes: Set of span name prefixes to exclude
39
+ """
40
+ self.parent_sampler = parent_sampler or ALWAYS_ON
41
+ self.excluded_span_names = excluded_span_names or {
42
+ "saslStart",
43
+ "saslContinue",
44
+ "isMaster",
45
+ "ping",
46
+ }
47
+ self.excluded_span_prefixes = excluded_span_prefixes or set()
48
+
49
+ logger.info(
50
+ "FilteringSampler initialized. Excluding span names: %s, prefixes: %s",
51
+ self.excluded_span_names,
52
+ self.excluded_span_prefixes,
53
+ )
54
+
55
+ @override
56
+ def should_sample(
57
+ self,
58
+ parent_context: Optional["Context"],
59
+ trace_id: int,
60
+ name: str,
61
+ kind: Optional[SpanKind] = None,
62
+ attributes: Optional[Attributes] = None,
63
+ links: Optional[Sequence[Link]] = None,
64
+ trace_state: Optional["TraceState"] = None,
65
+ ) -> SamplingResult:
66
+ """
67
+ Determine if a span should be sampled.
68
+
69
+ Returns DROP for filtered spans, otherwise delegates to parent sampler.
70
+ """
71
+ # Check exact match
72
+ if name in self.excluded_span_names:
73
+ logger.debug("Filtering span (exact match): %s", name)
74
+ return SamplingResult(Decision.DROP)
75
+
76
+ # Check prefix match
77
+ for prefix in self.excluded_span_prefixes:
78
+ if name.startswith(prefix):
79
+ logger.debug("Filtering span (prefix match): %s", name)
80
+ return SamplingResult(Decision.DROP)
81
+
82
+ logger.debug(f"FilteringSampler: Not filtering span: {name}")
83
+
84
+ # Delegate to parent sampler
85
+ return self.parent_sampler.should_sample(parent_context, trace_id, name, kind, attributes, links, trace_state)
86
+
87
+ @override
88
+ def get_description(self) -> str:
89
+ """Get a description of this sampler."""
90
+ return f"FilteringSampler(parent={self.parent_sampler.get_description()})"