Repository navigation
Add tokens.issuer: optional for providers whose JWTs name no issuer (Zendesk) - #90
Merged
Merged
Conversation
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
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
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
tokens.format: jwtassumed that a provider issuing JWT access tokens names its own host iniss. Zendesk, the provider it was written for, issues JWTs (with token expiry turned on) whose claims carry onlyexp. As a result:tokens.format: jwtrefused every Zendesk token:has the wrong shape: a JWT that names no issuernotion-mcp) failedgete validateThere was no way to say "this provider's JWTs name no issuer".
What changes
tokens.issuer: optional. A connection declaringformat: jwtplusissuer: optionalalso 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 andya29.Google access tokens (including the two-dotya29.c.shape) are still refused.tokens.format: jwtalone keeps its meaning. Every JWT must name the connection's own service.issuer: optional.gete validate:issuer: optionalon one agent: refused, since an issuerless JWT would pass as either'sissuer: optional) next tonotion-mcp, or next to a service whose JWTs name their own issuer: alloweddeclare connections.<id>.tokens.format: jwt with issuer: optional if its tokens never name oneformat: jwtconnection:declare connections.<id>.tokens.issuer: optional if its tokens never name oneissuervalue) is held asrequired, never widened.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 onlyexp, for which the installation declaresformat: jwt+issuer: optional. The token shape with expiry off is marked as not confirmed. No accepted example and noverifieddate are added.gete connectionsshows(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:
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: optionalconnection. The runtime judges a token by its connection alone and does not know what else the agent holds. Making the verdict depend on what elsegete.yamldeclares would let an unrelated entry change how a connection behaves.Tests
uv run pytest(1009 passed),ruff check,ruff format --check,mypy.