Conversation
There was a problem hiding this comment.
Pull request overview
Updates the OIDC multi-issuer validator to model trust as explicit (issuer, audience) pairs so the same issuer can be trusted for multiple audiences, and updates tests/docs accordingly.
Changes:
- Refactor
OidcMultiIssuerValidatortrust map to key on(issuer, audience)via a new exportedTrustEntry. - Extend unit tests and documentation to cover same-issuer/different-audience scenarios and new constructors.
- Small related maintenance updates (scope-claim refactor, Black pin, nox defaults, changelog/version bump, Claude skills docs).
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| version.txt | Bumps package version to 2.4.0. |
| src/planet_auth/oidc/multi_validator.py | Core change: trust keyed by (issuer, audience), adds TrustEntry, adds audience-based validator selection and new constructor. |
| src/planet_auth/init.py | Re-exports TrustEntry as part of public API. |
| src/planet_auth/oidc/token_validator.py | Minor refactor for scope-claim extraction variables. |
| src/planet_auth/oidc/auth_client.py | Fixes typo in an error message (“validation”). |
| tests/test_planet_auth/unit/auth/auth_clients/oidc/test_multi_validator.py | Updates tests for new trust model and adds new audience-routing/constructor coverage. |
| tests/test_planet_auth_utils/unit/auth_utils/test_plauth_factory.py | Adjusts expectations for trust-map keys to TrustEntry. |
| docs/examples/service/flask--oidc-multi-issuer--local-only-validation.py | Documents single-audience-per-entry and same-issuer/multi-audience approach. |
| docs/examples/service/flask--oidc-multi-issuer--local-and-remote-validation.py | Same as above for local+remote example. |
| docs/examples-service.md | Updates narrative to describe (issuer, audience) trust model. |
| docs/changelog.md | Adds 2.4.0 changelog entry describing new trust model. |
| pyproject.toml | Pins Black to < 26.0.0 in test extras. |
| noxfile.py | Changes nox defaults (don’t stop on first error; reuse existing venvs). |
| .claude/skills/test/SKILL.md | Adds Claude skill doc for running tests. |
| .claude/skills/security-scan/SKILL.md | Adds Claude skill doc for semgrep scan. |
| .claude/skills/lint/SKILL.md | Adds Claude skill doc for lint suite. |
| .claude/skills/format/SKILL.md | Adds Claude skill doc for Black formatting. |
| .claude/skills/check-all/SKILL.md | Adds Claude skill doc for running full nox suite. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -236,11 +345,16 @@ def _select_validator(self, token) -> Auth: | |||
| message=f"Issuer claim ('iss') must be a of string type. '{type(issuer).__name__}' type was detected." | |||
| ) | |||
|
|
|||
| validator = self._trusted.get(issuer) | |||
| if validator: | |||
| return validator | |||
| token_audiences = self._get_token_audiences(unverified_decoded_token) | |||
| for aud in token_audiences: | |||
| validator = self._trusted.get(TrustEntry(issuer=issuer, audience=aud)) | |||
| if validator: | |||
| return validator | |||
|
|
|||
| raise AuthException( | |||
| message="Rejecting token from an unrecognized issuer '{}'".format(issuer), | |||
| message="Rejecting token from an unrecognized issuer/audience combination. issuer='{}' audiences={}".format( | |||
| issuer, token_audiences | |||
| ), | |||
| event=AuthEvent.TOKEN_INVALID_BAD_ISSUER, | |||
| ) | |||
|
|
|||
There was a problem hiding this comment.
_select_validator() now routes based on aud, but if the token is missing an aud claim (or has an invalid type), _get_token_audiences() returns an empty list and the error raised is AuthException(...BAD_ISSUER) (“unrecognized issuer/audience”). That changes the failure mode from “invalid token” to “unknown issuer” and hides the actual problem. Consider validating the aud claim structure here and raising InvalidTokenException for missing/invalid aud before attempting trust lookup.
| @staticmethod | ||
| def _get_token_audiences(unverified_decoded_token: dict) -> List[str]: | ||
| """ | ||
| Extract audiences from an unverified token. Per the OIDC/OAuth2 spec, | ||
| the ``aud`` claim may be a single string or a list of strings. | ||
| Returns a list in either case, or an empty list when ``aud`` is absent. | ||
| """ | ||
| aud = unverified_decoded_token.get("aud") | ||
| if aud is None: | ||
| return [] | ||
| if isinstance(aud, str): | ||
| return [aud] | ||
| if isinstance(aud, list): | ||
| return [a for a in aud if isinstance(a, str)] | ||
| return [] |
There was a problem hiding this comment.
_get_token_audiences() silently drops non-string entries when aud is a list ([a for a in aud if isinstance(a, str)]). Per the JWT/OIDC specs aud should be a string or a list of strings; a mixed-type list should be treated as structurally invalid. Suggest failing closed by raising InvalidTokenException when aud is neither str nor List[str] (or when any list element is non-string), rather than ignoring unexpected values.
| @staticmethod | ||
| def from_issuer_audience_pairs( | ||
| trusted: List["TrustEntry"], | ||
| log_result: bool = True, | ||
| ): | ||
| """ | ||
| Create a new multi issuer validator from a list of | ||
| [planet_auth.TrustEntry][] named tuples. This is the simplest way | ||
| to configure the multi-validator for local-only token validation, | ||
| and makes the trust model explicit. | ||
|
|
||
| Each entry represents a single trusted (issuer, audience) pair. | ||
| The same issuer may appear multiple times with different audiences, | ||
| enabling a service to accept tokens from one authorization server | ||
| that were minted for different audiences. | ||
|
|
||
| For advanced use cases that require remote token validation (e.g. | ||
| revocation checks), use ``from_auth_server_configs`` instead, which | ||
| allows specifying client credentials and other auth client | ||
| configuration. | ||
|
|
||
| Warning: | ||
| This method assumes that the auth server URL and the issuer | ||
| (as burned into signed access tokens) are the same. This is | ||
| normally true, but not universally required. See | ||
| ``from_auth_server_urls`` for a discussion of the implications. | ||
|
|
||
| Parameters: | ||
| trusted: A list of [planet_auth.TrustEntry][] named tuples. | ||
| Each entry represents a single trusted authority. Falsy | ||
| entries are ignored. | ||
| log_result: Control whether successful token validations should | ||
| be logged. | ||
|
|
||
| Example: | ||
| ```python | ||
| from planet_auth import OidcMultiIssuerValidator, TrustEntry | ||
|
|
||
| auth_validator = OidcMultiIssuerValidator.from_issuer_audience_pairs( | ||
| trusted=[ | ||
| TrustEntry(issuer="https://oauth_server.example.com/oauth2/server_id", audience="https://api.example.com/"), | ||
| # Same issuer, different audience: | ||
| TrustEntry(issuer="https://oauth_server.example.com/oauth2/server_id", audience="https://internal-api.example.com/"), | ||
| # Different issuer: | ||
| TrustEntry(issuer="https://other-oauth.example.com/oauth2/server_id", audience="https://api.example.com/"), | ||
| ], | ||
| ) | ||
| ``` | ||
| """ | ||
| auth_providers = [] | ||
| for entry in trusted: | ||
| if entry: | ||
| if not entry.issuer or not entry.audience: | ||
| raise TypeError("TrustEntry must have non-empty issuer and audience values.") | ||
| auth_providers.append( | ||
| Auth.initialize_from_config_dict( | ||
| client_config={ | ||
| "client_type": "oidc_client_validator", | ||
| "auth_server": auth_server_url, | ||
| "issuer": auth_server_url, | ||
| "audiences": [audience], | ||
| "auth_server": entry.issuer, | ||
| "issuer": entry.issuer, | ||
| "audiences": [entry.audience], | ||
| } | ||
| ) | ||
| ) | ||
|
|
||
| return OidcMultiIssuerValidator( | ||
| trusted=trusted, | ||
| trusted=auth_providers, | ||
| log_result=log_result, | ||
| ) |
There was a problem hiding this comment.
from_issuer_audience_pairs() raises TypeError when issuer/audience are empty strings. Elsewhere in the auth/token validation APIs, missing/empty required values typically raise InvalidArgumentException/AuthException rather than a built-in TypeError. Consider using a project exception type here for consistency and clearer error handling by callers.
| @@ -124,27 +153,83 @@ def from_auth_server_urls( | |||
| by a network or configuration problem impacting a single auth server. | |||
|
|
|||
| The multi-validator requires foreknowledge of all the issuers for | |||
| proper initialization. So, without this assumption it would be unavoidable | |||
| proper initialization. So, without this assumption, it would be unavoidable | |||
| to introduce network risk into the constructor. This assumption | |||
| allows us to push all network errors to runtime, and avoids | |||
| possible initialization time errors. | |||
| """ | |||
| trusted = [] | |||
| for auth_server_url in trusted_auth_server_urls: | |||
| if auth_server_url: | |||
| trusted.append( | |||
| return OidcMultiIssuerValidator.from_issuer_audience_pairs( | |||
| trusted=[TrustEntry(issuer=url, audience=audience) for url in trusted_auth_server_urls if url], | |||
| log_result=log_result, | |||
| ) | |||
|
|
|||
There was a problem hiding this comment.
from_auth_server_urls() changed audience from an optional parameter to required (audience: str with no default). If this method is public API (it appears to be), this is a breaking signature change. Consider keeping a default (e.g., audience: Optional[str] = None) and raising a clear InvalidArgumentException when omitted, or documenting/deprecating the old call pattern explicitly.
| nox.options.stop_on_first_error = True | ||
| nox.options.reuse_existing_virtualenvs = False | ||
| nox.options.stop_on_first_error = False | ||
| nox.options.reuse_existing_virtualenvs = True |
There was a problem hiding this comment.
Switching nox.options.reuse_existing_virtualenvs to True can lead to non-reproducible lint/test results when dependencies or Python versions change (stale env contamination), especially in shared dev environments. Consider keeping this False by default (or gating it behind an environment variable) while still allowing local opt-in for faster iteration.
| nox.options.reuse_existing_virtualenvs = True | |
| nox.options.reuse_existing_virtualenvs = os.getenv( | |
| "NOX_REUSE_EXISTING_VIRTUALENVS", "" | |
| ).lower() in {"1", "true", "yes", "on"} |
| # This would also let us maybe handle other auth mechanisms beyond oauth? | ||
| @staticmethod | ||
| def _patch_primary(under_test: OidcMultiIssuerValidator, patched_auth_client: AuthClient): | ||
| under_test._trusted[TEST_PRIMARY_ISSUER]._auth_client = patched_auth_client | ||
| under_test._trusted[(TEST_PRIMARY_ISSUER, TEST_AUDIENCE)]._auth_client = patched_auth_client | ||
|
|
||
| @staticmethod | ||
| def _patch_secondary(under_test: OidcMultiIssuerValidator, patched_auth_client: AuthClient): | ||
| under_test._trusted[TEST_SECONDARY_ISSUER]._auth_client = patched_auth_client | ||
| under_test._trusted[(TEST_SECONDARY_ISSUER, TEST_AUDIENCE)]._auth_client = patched_auth_client | ||
|
|
There was a problem hiding this comment.
The tests patch into under_test._trusted[...] using a raw (issuer, audience) tuple key. Since the production code now uses TrustEntry keys, using TrustEntry(...) in tests would make the intent clearer and reduce coupling to NamedTuple/tuple equality semantics.
Uh oh!
There was an error while loading. Please reload this page.