Skip to content

CG-1956 multi validator updates#50

Open
carl-adams-planet wants to merge 11 commits intomainfrom
carl/CG-1956--multi-validator-updates
Open

CG-1956 multi validator updates#50
carl-adams-planet wants to merge 11 commits intomainfrom
carl/CG-1956--multi-validator-updates

Conversation

@carl-adams-planet
Copy link
Copy Markdown
Collaborator

@carl-adams-planet carl-adams-planet commented Mar 30, 2026

  • Update the multi-validator to consider the same issuer being responsible for different audiences.
  • Claude skills for this project

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 OidcMultiIssuerValidator trust map to key on (issuer, audience) via a new exported TrustEntry.
  • 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.

Comment on lines 318 to 360
@@ -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,
)

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
Comment on lines +318 to +332
@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 []
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Copilot uses AI. Check for mistakes.
Comment on lines +166 to 234
@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,
)
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 131 to +165
@@ -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,
)

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread noxfile.py
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
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
nox.options.reuse_existing_virtualenvs = True
nox.options.reuse_existing_virtualenvs = os.getenv(
"NOX_REUSE_EXISTING_VIRTUALENVS", ""
).lower() in {"1", "true", "yes", "on"}

Copilot uses AI. Check for mistakes.
Comment on lines 205 to 213
# 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

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants