Skip to content

Add tokens.issuer: optional for providers whose JWTs name no issuer (Zendesk) - #90

Merged
haruotsu merged 6 commits into
mainfrom
zendesk-issuerless-jwt
Oct 14, 2026
Merged

haruotsu merged 6 commits into
mainfrom
zendesk-issuerless-jwt

Conversation

@haruotsu

@haruotsu haruotsu commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Why

tokens.format: jwt assumed that a provider issuing JWT access tokens names its own host in iss. Zendesk, the provider it was written for, issues JWTs (with token expiry turned on) whose claims carry only exp. As a result:

  • a connection declaring tokens.format: jwt refused every Zendesk token: has the wrong shape: a JWT that names no issuer
  • without the declaration, Zendesk was accepted by elimination, so an agent holding it next to another anonymous connection (e.g. notion-mcp) failed gete validate

There was no way to say "this provider's JWTs name no issuer".

What changes

  • New tokens.issuer: optional. A connection declaring format: jwt plus issuer: optional also accepts a JWT whose claims name no issuer. A JWT that does name an issuer is still held to the connection's own host. Opaque tokens, unreadable claims, foreign issuers and ya29. Google access tokens (including the two-dot ya29.c. shape) are still refused.
  • tokens.format: jwt alone keeps its meaning. Every JWT must name the connection's own service.
  • Elimination gives up only the issuerless JWT. A connection with neither prefixes nor a format still accepts opaque tokens, and JWTs naming its own host, as before. A JWT naming no issuer is now left to the connection that declared issuer: optional.
  • gete validate:
    • two anonymous connections on one agent: refused (unchanged)
    • two connections declaring issuer: optional on one agent: refused, since an issuerless JWT would pass as either's
    • connections judging JWTs by issuer whose issuer hosts overlap: refused (unchanged)
    • Zendesk (issuer: optional) next to notion-mcp, or next to a service whose JWTs name their own issuer: allowed
  • Log messages name the declaration to add when an issuerless JWT is refused:
    • for an anonymous connection: declare connections.<id>.tokens.format: jwt with issuer: optional if its tokens never name one
    • for a format: jwt connection: declare connections.<id>.tokens.issuer: optional if its tokens never name one
  • A resolved declaration this gete cannot read (an unknown issuer value) is held as required, never widened.
  • Catalog zendesk.yaml, README, schema descriptions, gete connections. These no longer say the expiring tokens name the subdomain as issuer. They describe them as JWTs carrying only exp, for which the installation declares format: jwt + issuer: optional. The token shape with expiry off is marked as not confirmed. No accepted example and no verified date are added. gete connections shows (none: jwt naming this service) or (none: jwt naming this service or no issuer).

Compatibility

Narrowly breaking. Elimination no longer accepts a JWT that names no issuer, so an installation that took Zendesk's expiring tokens by elimination (no declaration) has them refused. The log names the fix:

connections:
  zendesk:
    base_url: https://acme.zendesk.com
    tokens:
      format: jwt
      issuer: optional

Nothing else changes for existing declarations. Connections that declare tokens.format: jwt, and anonymous connections that receive JWTs naming their own issuer, behave as before.

The issuerless JWT is refused by elimination everywhere, not only where an agent also holds an issuer: optional connection. The runtime judges a token by its connection alone and does not know what else the agent holds. Making the verdict depend on what else gete.yaml declares would let an unrelated entry change how a connection behaves.

Tests

uv run pytest (1009 passed), ruff check, ruff format --check, mypy.

tokens.format: jwt was written on the belief that a provider whose
access tokens are JWTs names its own host as their issuer, and a
declaring connection refused any JWT that did not. Zendesk, the
provider it was written for, issues JWTs whose claims carry only exp.
So a connection declaring the format refused every token Zendesk
issued. Removing the declaration put Zendesk back among the
connections accepting by elimination, where an agent can hold only one,
and an agent pairing it with another anonymous connection such as
notion-mcp failed validate.

The declaration now promises a shape: the token is a JWT, and an issuer
its claims name is the connection's own service. A JWT naming none is
taken. Opaque tokens, unreadable claims and foreign issuers are still
refused, so Google's ID and service account tokens still fall out.

Since a declaring connection now takes issuerless JWTs, a connection
accepting by elimination must not, or the two could not be told apart.
So elimination takes no JWT at all. The shapes split cleanly: opaque
to the anonymous connection, JWT to the declaring one, and an agent may
hold one of each whatever issuer stands behind them. Two declaring
connections are refused together, as two anonymous ones are, since an
issuerless JWT would pass as either's; telling them apart by issuer
host no longer holds.

Elimination refuses JWTs everywhere, not only where an agent also holds
a declaring connection. The runtime judges a token by its connection
alone and does not know what else the agent holds. Making a
connection's verdict depend on what else is declared would let an
unrelated gete.yaml entry change it. And the break is loud: a JWT
refused by elimination is logged with the declaration that would take
it. An installation that took Zendesk's (or any provider's) JWTs by
elimination declares tokens.format: jwt on that connection.

🤖 Generated with Claude Code
The catalog entry, its setup text and the README said that with token
expiry on, Zendesk issues JWTs naming the subdomain as their issuer.
They carry only exp, so an operator following that text went looking
for an issuer mismatch that was never there. With expiry on the tokens
are JWTs with no issuer, and the installation declares tokens.format:
jwt. What arrives with expiry off has not been confirmed, so the entry
says so rather than guessing, and still carries no accepted example
and no verified date.

🤖 Generated with Claude Code
Google issues ya29.c.<payload> access tokens beside the ya29.<payload>
ones, and the two dots make the former look like a JWT. A connection
declaring tokens.format: jwt refused one only because its middle
segment happens not to decode as claims; now that the declaration
takes a JWT naming no issuer, a payload that did decode would have
passed. The prefix is what the token announces, so the declared path
refuses it by name, the way elimination already does, and the
conformance check every connection is held to carries the two-dot
shape as well.

The log said the wrong thing about the same token. A connection
accepting by elimination described it as a JWT and named
tokens.format: jwt as the declaration to add, which refuses it too.
So it did for a JWT-shaped token whose claims cannot be read, which
nothing takes. describe_token now reads both the way accepts_token
does: a Google access token matches no declared shape, and unreadable
claims are named as such, so the declaration is advised only where it
would take the token.

🤖 Generated with Claude Code
The README introduces token_prefixes: [] as taking any token no other
connection's prefix matches, and only the tokens.format section further
down says a JWT is refused as well. A reader stopping at the first
paragraph was left with the old rule, so the split is named where the
rule begins.

gete connections printed "(none: jwt naming no issuer but this
service)", which reads as a contradiction; it now says that the
issuer, where one is named, is this service.

Three tests still described the issuer rule the previous change
removed. The one on an anonymous pair sharing an issuer checked a
pairing the test on two prefixless connections already covers, so it
goes; the others say what holds now, that the shapes part rather than
that an issuer is named. The resolved-declaration test took Zendesk's
tokens to name the subdomain, which they do not, and carries the
issuerless shape they have.

🤖 Generated with Claude Code
The previous change made tokens.format: jwt take a JWT that names no
issuer, and stopped elimination from taking any JWT, so that the two
kinds of connection would part by shape. That shut out a pairing that
is perfectly distinguishable: Zendesk beside a service whose JWTs name
their own issuer, such as an internal API behind its own authorization
server. The latter now had to declare the format too, and two declaring
connections had to be refused together, because an issuerless JWT
would pass as either's.

Whether a JWT may name no issuer is now said on its own:
tokens.issuer: optional. Without it the format keeps the meaning it had
before - every JWT names the connection's own service - and elimination
keeps taking a JWT that names its own service, as it did. The one thing
elimination gives up is the issuerless JWT, which now belongs to the
connection that declared it may receive one. An agent may hold one such
connection, as it may hold one anonymous connection; issuer hosts that
overlap are refused as before.

This narrows what breaks. Only an installation that took issuerless
JWTs by elimination - Zendesk with token expiry on, undeclared - has
its tokens refused, and the log names the declaration to add. A
declaration this gete cannot read is held as required, because reading
it as optional would take tokens it may have meant to refuse.

🤖 Generated with Claude Code
@haruotsu haruotsu changed the title Accept issuerless JWTs under tokens.format: jwt; elimination takes no JWT (breaking) Add tokens.issuer: optional for providers whose JWTs name no issuer (Zendesk) Oct 13, 2026
gete validate refused two connections accepting by elimination with a
message that said what they failed to declare and nothing about what
would take one of them out of that place. An operator who added
tokens.format: jwt on a provider whose JWTs name no issuer then saw
every token refused at runtime, and learned of issuer: optional only
from that log. The report names both declarations, so the fix is one
step rather than two.

Two tests still described the rule an earlier change on this branch
had, under which elimination took no JWT at all. The one naming
Google's authorization server as a connection's own host was narrowed
to a declared format; elimination takes a JWT naming the connection's
own service again, so an anonymous connection on that host lets
Google's ID tokens through the same match, and the check is seen
catching both. A validate test's docstring said a connection held to
JWTs takes nothing the anonymous one does; the two part by issuer
host, and it says so again.

🤖 Generated with Claude Code
@haruotsu
haruotsu merged commit fdad27e into main Oct 14, 2026
3 checks passed
@haruotsu
haruotsu deleted the zendesk-issuerless-jwt branch October 14, 2026 01:50
@github-actions github-actions Bot mentioned this pull request Oct 14, 2026
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