Skip to content

feat: configurable claim locations on DomainConfig - #15

Merged
jamesbeedy merged 1 commit into
mainfrom
feat/configurable-claim-locations
Sep 11, 2026
Merged

jamesbeedy merged 1 commit into
mainfrom
feat/configurable-claim-locations

Conversation

@jamesbeedy

Copy link
Copy Markdown
Contributor

What

Adds claim_locations and required_claims to DomainConfig: a declarative way to read a claim that is not top level, as a mapping of target field to a path of claim keys.

DomainConfig(
    domain="auth.example.com",
    audience="my-cluster",
    claim_locations={
        "permissions": ("resource_access", "{audience}", "permission_roles"),
        "application_roles": ("resource_access", "{audience}", "application_roles"),
    },
    required_claims={"permissions"},
)

{audience} is substituted from the audience passed to decode, {azp} from the token's own claim.

Why

permission_extractor is the only existing hook and it has two limits: it writes to exactly one field, permissions, so a second nested claim cannot be surfaced at all; and it is a callable, so every consumer hand-writes the same path walk.

The shipped extract_keycloak_permissions keys on azp, correct for a token issued straight to a client. A token exchanged for a specific audience names the broker in azp and files the roles under the audience, so that extractor misses and each consumer writes its own. That is the case driving this.

Behaviour

  • An unresolved path (missing key, non-mapping part way down, unresolvable placeholder) leaves the field unset, so TokenPayload applies its own default. A claim the provider does not issue is an answer, not an error.
  • Naming the field in required_claims inverts that: a miss raises PayloadMappingError, a 500, matching what a permission_extractor that does not match the token has always done.
  • Applied before permission_extractor, which still wins on permissions, so the two coexist while a caller migrates.
  • Purely additive. Both new fields default empty, so a config that does not set them behaves exactly as before.

Tests

Five new cases in tests/unit/test_token_decoder.py: the {audience} and {azp} placeholders, an unresolved path falling back to the default, a required miss raising PayloadMappingError, and the extractor winning over a location. Full suite 379 passed; just lint and just typecheck clean.

🤖 Generated with Claude Code

`permission_extractor` is the only way to reach a claim that is not top
level, and it has two limits. It writes to exactly one field,
`permissions`, so a second nested claim cannot be surfaced at all. And it
is a callable, so every consumer hand-writes the same path walk.

The shipped `extract_keycloak_permissions` keys on `azp`, which is right
for a token issued straight to a client. A token exchanged for a specific
audience has `azp` naming the broker and the roles filed under the
audience instead, so that extractor misses and each consumer writes its
own.

`claim_locations` maps a target field to a path of claim keys, with
`{audience}` and `{azp}` substituted per token. Both of the above become
configuration:

    claim_locations={
        "permissions": ("resource_access", "{audience}", "permission_roles"),
        "application_roles": ("resource_access", "{audience}", "application_roles"),
    }

An unresolved path leaves the field unset so `TokenPayload` applies its
own default, because a claim the provider does not issue is an answer
rather than an error. Naming the field in `required_claims` inverts that
and raises `PayloadMappingError`, matching what a `permission_extractor`
that does not match the token has always done.

Applied before `permission_extractor`, which still wins on `permissions`,
so the two coexist while a caller migrates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jamesbeedy
jamesbeedy merged commit 2070bba into main Sep 11, 2026
1 check passed
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.

1 participant