From 7fafe42e4c3ad3d70decd9a2f72fa0c3c4ac137e Mon Sep 17 00:00:00 2001 From: haruotsu <65439874+haruotsu@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:31:35 +0900 Subject: [PATCH 1/6] Accept a JWT that names no issuer where tokens.format: jwt is declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/gete/connection/checks.py | 40 ++++-------- src/gete/connection/registry.py | 46 +++++++------- src/gete/connection/runtime.py | 39 +++++------- src/gete/connections_listing.py | 2 +- src/gete/schema/connection.json | 4 +- tests/test_caller_token.py | 35 ++++++----- tests/test_connection_registry.py | 100 ++++++++++++------------------ tests/test_connections_command.py | 3 +- tests/test_validate.py | 55 ++++++++++++++-- 9 files changed, 164 insertions(+), 160 deletions(-) diff --git a/src/gete/connection/checks.py b/src/gete/connection/checks.py index 93b0f99..8fd49a7 100644 --- a/src/gete/connection/checks.py +++ b/src/gete/connection/checks.py @@ -58,12 +58,11 @@ def elimination_problems( gete ships as well, and declaring a service of your own must not depend on which of those announce themselves. - A declared format announces the service in every token, so it does not - take that one place - unless one issuer stands behind two of the - connections judging tokens by issuer, which is the same confusion by - another route. An anonymous connection is one of those: it takes a JWT - naming its own authorization server as readily as a declaring one does, - so a shared issuer confuses the two whichever of them declared. + A declared format is the other half of that split: a connection held to + tokens.format: jwt takes only JWTs, and one accepting by elimination + takes none, so an agent may hold one of each. Two declaring ones are + the anonymous pair again: a JWT whose claims name no issuer, as + Zendesk's do, is taken by either, so no issuer tells them apart. Prefixes that overlap are that confusion among the connections that do announce themselves: a token carrying the shared prefix passes as @@ -89,28 +88,15 @@ def elimination_problems( "connections may accept tokens by elimination, or a token from one " "of them would be accepted as another's" ) - # A prefix decides on its own and before any issuer does, so a connection - # held to one judges no token by who issued it, and shares an issuer with - # nothing. - by_issuer = [ - connection - for connection in connections - if connection.token_format is not None or not connection.token_prefixes + declared = [ + connection for connection in connections if connection.token_format is not None ] - for index, connection in enumerate(by_issuer): - for other in by_issuer[index + 1 :]: - if connection.token_format is None and other.token_format is None: - # Already refused above, by everything the pair fails to say - # about itself rather than by the one issuer they happen to - # share; naming it here would say it twice. - continue - shared = connection.issuer_hosts & other.issuer_hosts - if shared: - problems.append( - f"{connection.id}, {other.id} both accept tokens issued by " - f"{', '.join(sorted(shared))}; a token from one of them " - "would be accepted as the other's" - ) + if len(declared) >= 2: + problems.append( + f"{', '.join(connection.id for connection in declared)} declare " + "tokens.format; only one of an agent's connections may, because a " + "JWT that names no issuer would be accepted as any of theirs" + ) # A declared format decides before any prefix is read, so a connection # held to one takes no token for its prefix. by_prefix = [ diff --git a/src/gete/connection/registry.py b/src/gete/connection/registry.py index 5489e0d..6ea5d65 100644 --- a/src/gete/connection/registry.py +++ b/src/gete/connection/registry.py @@ -21,7 +21,8 @@ # A JWT has three dot-separated segments (two dots) and its base64url header # starts with "eyJ". Google ID tokens and service account tokens are JWTs, # but so are other services' access tokens, so the shape convicts nothing -# on its own: what a JWT names as its issuer is what is judged. +# on its own: a connection that declares the shape judges what a JWT names +# as its issuer, and one accepting by elimination takes no JWT at all. _JWT_DOTS = 2 _JWT_HEADER_PREFIX = "eyJ" @@ -65,8 +66,8 @@ def missing_base_url(connection_id: str) -> str: def looks_like_jwt(token: str) -> bool: - """True for anything shaped like a JWT. Shape alone convicts nothing: - what the claims name as their issuer is what accepts_token judges.""" + """True for anything shaped like a JWT, whether its claims can be read + or not: the shape is what decides which connection may judge it.""" return token.count(".") >= _JWT_DOTS or token.startswith(_JWT_HEADER_PREFIX) @@ -216,9 +217,9 @@ class Connection: redirect_hosts: frozenset[str] = frozenset() token_prefixes: tuple[str, ...] = () # The format the service commits to issuing, JWT_FORMAT or None. A - # connection that declares one takes nothing else, so it announces itself - # as surely as a prefix does and is not accepted by elimination; one this - # gete cannot judge takes nothing at all. + # connection that declares one takes nothing else, and one accepting by + # elimination takes nothing of that shape, so the two never share a + # token; one this gete cannot judge takes nothing at all. token_format: str | None = None # Prefixes declared by every other connection in the registry, filled in by # Registry. A connection without prefixes of its own accepts a token only @@ -327,10 +328,10 @@ def accepts_token(self, token: str) -> bool: however little else claims it. A declared prefix decides next: Google issues ya29.c. access tokens that carry two dots, and a shape heuristic must not overrule a prefix the catalog vouches for. With - neither, a JWT is judged by the issuer its claims name - Google ID - tokens and service account tokens always name Google's - and a JWT - that names none says as little as an opaque token, so it is judged - like one. One that cannot be read is refused rather than guessed about. + neither, the connection takes what announces itself no way at all, + and a JWT is not that: it is the shape tokens.format: jwt declares, + so the two kinds of connection part over it and one agent can hold + one of each. """ if not token: return False @@ -341,34 +342,29 @@ def accepts_token(self, token: str) -> bool: return self.token_format == JWT_FORMAT and self._issued_here_jwt(token) if self.token_prefixes: return token.startswith(self.token_prefixes) - if token.startswith(GOOGLE_ACCESS_TOKEN_PREFIX): + if token.startswith(GOOGLE_ACCESS_TOKEN_PREFIX) or looks_like_jwt(token): return False - if looks_like_jwt(token): - claims = jwt_claims(token) - if claims is None: - return False - if "iss" in claims: - issuer = claims["iss"] - return isinstance(issuer, str) and self.issued_here(issuer) # The token does not announce itself. All that can be said is that # it is not some other service's. return not any(token.startswith(prefix) for prefix in self.foreign_prefixes) def _issued_here_jwt(self, token: str) -> bool: - """Whether the token is a JWT this connection's own service issued. + """Whether the token keeps the promise tokens.format: jwt makes. - What a connection declaring the format holds every token to. A token - of any other shape, one whose claims cannot be read, and one that - names no issuer are all refused alike: none of them is what was - promised, and the promise is the only reason the connection may sit - beside another that accepts tokens by elimination. + A JWT whose claims can be read, and which names no issuer but this + connection's own service. Claims that name none are kept: Zendesk's + carry only exp. Google's ID tokens and service account tokens always + name theirs, which is never the connection's own, so they still fall + out without a list of Google's issuers. """ if not looks_like_jwt(token): return False claims = jwt_claims(token) if claims is None: return False - issuer = claims.get("iss") + if "iss" not in claims: + return True + issuer = claims["iss"] return isinstance(issuer, str) and self.issued_here(issuer) @property diff --git a/src/gete/connection/runtime.py b/src/gete/connection/runtime.py index f89722a..0f3bc62 100644 --- a/src/gete/connection/runtime.py +++ b/src/gete/connection/runtime.py @@ -101,23 +101,27 @@ def describe_token(connection: Connection, token: str) -> str: """Which declared shape the token has, without repeating any of it. A connection without prefixes declares no shapes, so a refused JWT is - described by what refused it - claims that cannot be read, or an issuer - that does not name this service - or the shape message would leave the - refusal looking like a mystery. A connection that declares a token format - is refusing against a promise, and the promise is named: an operator who - turned the provider's expiring-token setting off has every token refused - at once, and nothing else in the log would say why. Read in the order - accepts_token decides in, so the message names the rule that actually - refused the token. + described by what refused it, or the shape message would leave the + refusal looking like a mystery. One accepting by elimination takes no + JWT at all, and its operator is told the declaration that would take + it: a provider that starts issuing JWTs has every token refused at once, + and nothing else in the log would say why. A connection that declares a + token format is refusing against a promise, and the half it missed is + named. Read in the order accepts_token decides in, so the message names + the rule that actually refused the token. """ if connection.token_format is not None: return _unmet_format(connection.token_format, token) for prefix in connection.token_prefixes: if token.startswith(prefix): return f"starts with {prefix}" - if connection.token_prefixes: + if connection.token_prefixes or not looks_like_jwt(token): return NO_DECLARED_SHAPE - return _refused_jwt(token) or NO_DECLARED_SHAPE + return ( + f"a JWT, which {connection.id} does not take: it declares neither " + "token_prefixes nor tokens.format; declare " + f"connections.{connection.id}.tokens.format: jwt if its tokens are JWTs" + ) def _unmet_format(token_format: str, token: str) -> str: @@ -132,20 +136,9 @@ def _unmet_format(token_format: str, token: str) -> str: return f"declared as {token_format}, which this gete cannot judge" if not looks_like_jwt(token): return f"not the {token_format} this connection declares" - return _refused_jwt(token) or "a JWT that names no issuer" - - -def _refused_jwt(token: str) -> str | None: - """Why a JWT-shaped token is not taken for this service's, or None when - nothing about the JWT itself says so.""" - if not looks_like_jwt(token): - return None - claims = jwt_claims(token) - if claims is None: + if jwt_claims(token) is None: return "a JWT whose claims cannot be read" - if "iss" in claims: - return "a JWT whose issuer does not name this service" - return None + return "a JWT whose issuer does not name this service" def usable_token(connection: Connection, key: str, state: Any) -> str | None: diff --git a/src/gete/connections_listing.py b/src/gete/connections_listing.py index 67aaa24..a7279e7 100644 --- a/src/gete/connections_listing.py +++ b/src/gete/connections_listing.py @@ -21,7 +21,7 @@ def token_shapes(connection: Connection) -> str: if connection.token_prefixes: return ", ".join(connection.token_prefixes) if connection.token_format is not None: - return f"(none: {connection.token_format} issued by this service)" + return f"(none: {connection.token_format} naming no issuer but this service)" return BY_ELIMINATION diff --git a/src/gete/schema/connection.json b/src/gete/schema/connection.json index 463846b..459834c 100644 --- a/src/gete/schema/connection.json +++ b/src/gete/schema/connection.json @@ -61,7 +61,7 @@ } }, "token_prefixes": { - "description": "Prefixes of issued tokens. Empty means the service does not announce itself and tokens are accepted by elimination, unless tokens.format says what they look like; an agent may declare only one connection that announces itself neither way. Connections may share a prefix, as two installations of one service do; an agent may not declare two whose prefixes overlap.", + "description": "Prefixes of issued tokens. Empty means the service does not announce itself and tokens are accepted by elimination, unless tokens.format says what they look like; a connection accepting by elimination takes no JWT, and an agent may declare only one connection that announces itself neither way. Connections may share a prefix, as two installations of one service do; an agent may not declare two whose prefixes overlap.", "type": "array", "uniqueItems": true, "items": { @@ -78,7 +78,7 @@ ], "properties": { "format": { - "description": "jwt: the tokens are JWTs whose iss claim names the connection's own service. Nothing else is a format that says whose token it is.", + "description": "jwt: the tokens are JWTs, and an iss claim, where the claims carry one, names the connection's own service. A JWT that names no issuer is accepted. An agent may declare only one connection with this format.", "enum": [ "jwt" ] diff --git a/tests/test_caller_token.py b/tests/test_caller_token.py index b9073d0..fd269f0 100644 --- a/tests/test_caller_token.py +++ b/tests/test_caller_token.py @@ -27,8 +27,8 @@ ) -# Claims: {"exp": 0}. A JWT that names no issuer says as little about its -# origin as an opaque token. +# Claims: {"exp": 0}, as Zendesk's JWTs carry. The shape tokens.format: jwt +# declares, whatever little the claims say. JWT_WITHOUT_ISSUER = "eyJhbGciOiJFZERTQSJ9.eyJleHAiOjB9.sig" # A connection whose tokens are JWTs of its own making, as an installation # declares one whose provider issues them. @@ -145,17 +145,17 @@ def test_describe_token_names_the_prefix_not_the_value() -> None: assert GITHUB_TOKEN not in describe_token(GITHUB, GITHUB_TOKEN) -def test_describe_token_says_why_a_jwt_was_refused_without_repeating_it() -> None: - """A prefixless connection declares no shapes, so the shape message would - leave an issuer refusal looking like a mystery.""" - assert ( - describe_token(FREEE, GOOGLE_ISSUED_JWT) - == "a JWT whose issuer does not name this service" - ) - assert ( - describe_token(FREEE, "eyJhbGciOiJSUzI1NiJ9.x.sig") - == "a JWT whose claims cannot be read" +def test_describe_token_says_a_jwt_belongs_to_a_declaring_connection() -> None: + """A connection accepting by elimination takes no JWT, so the operator + reading the log has to be told the fix: declare the format on it.""" + expected = ( + "a JWT, which freee does not take: it declares neither token_prefixes " + "nor tokens.format; declare connections.freee.tokens.format: jwt if " + "its tokens are JWTs" ) + assert describe_token(FREEE, JWT_WITHOUT_ISSUER) == expected + assert describe_token(FREEE, GOOGLE_ISSUED_JWT) == expected + assert describe_token(FREEE, "eyJhbGciOiJSUzI1NiJ9.x.sig") == expected # A declared prefix is the whole judgment; the JWT is described no further. assert ( describe_token(GITHUB, GOOGLE_ISSUED_JWT) @@ -164,17 +164,20 @@ def test_describe_token_says_why_a_jwt_was_refused_without_repeating_it() -> Non def test_describe_token_says_a_declared_format_was_not_met() -> None: - """The connection promised JWTs of its own making. An operator who turned - the provider's expiring-token setting off sees every token refused, and - the message has to say which promise the token missed.""" + """The connection promised JWTs, and an issuer they name has to be its + own. The message says which half of the promise the token missed.""" + assert DECLARED.accepts_token(JWT_WITHOUT_ISSUER) assert ( describe_token(DECLARED, FREEE_TOKEN) == "not the jwt this connection declares" ) - assert describe_token(DECLARED, JWT_WITHOUT_ISSUER) == "a JWT that names no issuer" assert ( describe_token(DECLARED, GOOGLE_ISSUED_JWT) == "a JWT whose issuer does not name this service" ) + assert ( + describe_token(DECLARED, "eyJhbGciOiJSUzI1NiJ9.x.sig") + == "a JWT whose claims cannot be read" + ) assert FREEE_TOKEN not in describe_token(DECLARED, FREEE_TOKEN) diff --git a/tests/test_connection_registry.py b/tests/test_connection_registry.py index 8c7f1bc..68d1e6d 100644 --- a/tests/test_connection_registry.py +++ b/tests/test_connection_registry.py @@ -99,42 +99,22 @@ def test_google_issued_jwts_are_never_accepted(catalog: Registry) -> None: assert not entry.accepts_token(jwt_with({"iss": issuer})), entry.id -def test_a_jwt_naming_no_issuer_is_accepted_by_elimination() -> None: - """Zendesk issues JWTs whose claims name no issuer. They say as little - about their origin as an opaque token, so they are judged like one.""" - alone = Registry([connection()]).get("example") - assert alone.accepts_token(jwt_with({"exp": 0})) - - -def test_a_jwt_naming_the_connections_own_service_is_accepted() -> None: - """The issuer of a service's tokens is its authorization server, which - may live beside the API rather than on it; both count as the service.""" - alone = Registry([connection()]).get("example") - assert alone.accepts_token(jwt_with({"iss": "https://api.example.com"})) - assert alone.accepts_token(jwt_with({"iss": "api.example.com"})) - assert alone.accepts_token(jwt_with({"iss": "https://auth.example.com"})) - - -def test_a_jwt_naming_the_installation_root_is_accepted() -> None: - entry = Connection.from_mapping({**ROOTED, "base_url": "https://acme.example.com"}) - assert entry.accepts_token(jwt_with({"iss": "https://acme.example.com"})) - - @pytest.mark.parametrize( - "issuer", + "claims", [ - "https://accounts.google.com", # Google ID token - "agent@project.iam.gserviceaccount.com", # service account token - "https://idp.example.org", # some other service's authorization server - "https://api.example.com.evil.example", # suffix must not pass as a match - 5, # RFC 7519 wants a string; anything else names nothing - None, # present but null is just as far from a string - "https://[", # names no host; must be refused, not raise + {"exp": 0}, # Zendesk's, with token expiry turned on + {"iss": "https://api.example.com"}, # naming the connection's own service + {"iss": "https://auth.example.com"}, + {"iss": "https://idp.example.org"}, ], ) -def test_a_jwt_naming_any_other_issuer_is_refused(issuer: Any) -> None: +def test_a_connection_accepting_by_elimination_takes_no_jwt(claims: Any) -> None: + """A JWT is the shape tokens.format: jwt declares. Elimination takes what + announces itself no way at all, so the two shapes part between the + connections and an agent can hold one of each.""" alone = Registry([connection()]).get("example") - assert not alone.accepts_token(jwt_with({"iss": issuer})) + assert not alone.accepts_token(jwt_with(claims)) + assert alone.accepts_token("a1b2c3d4e5f60718293a4b5c6d7e8f90") def test_prefixless_connection_alone_still_refuses_google_access_tokens() -> None: @@ -151,13 +131,19 @@ def test_prefixless_connection_alone_still_refuses_google_access_tokens() -> Non def test_a_declared_jwt_format_accepts_a_token_the_service_issued() -> None: - """The declaration says the tokens are JWTs of this service's own making, - and the issuer is what says so.""" + """An issuer the claims name has to be the connection's own service.""" entry = Registry([connection(**JWT_TOKENS)]).get("example") assert entry.accepts_token(jwt_with({"iss": "https://auth.example.com"})) assert entry.accepts_token(jwt_with({"iss": "api.example.com"})) +def test_a_declared_jwt_format_accepts_a_jwt_that_names_no_issuer() -> None: + """Zendesk's JWTs carry only exp. The declaration promises the shape, and + claims that name nobody break no part of it.""" + entry = Registry([connection(**JWT_TOKENS)]).get("example") + assert entry.accepts_token(jwt_with({"exp": 0})) + + def test_a_declared_jwt_format_refuses_a_token_that_is_not_a_jwt() -> None: """Without the declaration elimination would take it; with it, the connection promises a shape and holds itself to it.""" @@ -171,10 +157,12 @@ def test_a_declared_jwt_format_refuses_a_token_that_is_not_a_jwt() -> None: @pytest.mark.parametrize( "claims", [ - {"exp": 0}, # a JWT that names no issuer says nothing about its origin {"iss": "https://idp.example.org"}, {"iss": "https://accounts.google.com"}, - {"iss": None}, + {"iss": None}, # present but null names nothing a host could match + {"iss": 5}, + {"iss": "https://api.example.com.evil.example"}, + {"iss": "https://["}, ], ) def test_a_declared_jwt_format_refuses_a_jwt_from_anywhere_else(claims: Any) -> None: @@ -581,20 +569,16 @@ def test_a_declared_token_format_is_not_an_acceptance_by_elimination() -> None: assert elimination_problems(["zendesk", "internal"], registry) == [] -def test_a_shared_issuer_confuses_a_declared_format_and_an_anonymous_one() -> None: - """The anonymous connection takes a JWT naming its own authorization - server too, so one issuer serving both is the same confusion as two - declaring connections sharing one - the declaration says nothing the - other's token does not say as well.""" +def test_an_issuer_shared_with_an_anonymous_connection_confuses_nothing() -> None: + """The anonymous connection takes no JWT at all, so whatever issuer the + claims name, the token can only be the declaring connection's.""" declared = connection(id="declared", **JWT_TOKENS) anonymous = connection(id="anonymous", hosts=["api.other.example.com"]) registry = Registry([declared, anonymous]) token = jwt_with({"iss": "https://auth.example.com"}) assert registry.get("declared").accepts_token(token) - assert registry.get("anonymous").accepts_token(token) - found = elimination_problems(["declared", "anonymous"], registry) - assert len(found) == 1, found - assert "auth.example.com" in found[0], found + assert not registry.get("anonymous").accepts_token(token) + assert elimination_problems(["declared", "anonymous"], registry) == [] def test_an_anonymous_pair_sharing_an_issuer_is_reported_once() -> None: @@ -606,8 +590,9 @@ def test_an_anonymous_pair_sharing_an_issuer_is_reported_once() -> None: assert len(found) == 1, found -def test_two_declared_token_formats_from_different_services_never_collide() -> None: - """Each takes only what its own issuer named; neither reaches the other's.""" +def test_two_declared_token_formats_are_reported_whatever_their_issuers() -> None: + """A JWT that names no issuer is taken by either, so a token from one + authorization passes as the other's even when their services differ.""" one = connection(id="one", **JWT_TOKENS) two = connection( id="two", @@ -619,17 +604,13 @@ def test_two_declared_token_formats_from_different_services_never_collide() -> N }, **JWT_TOKENS, ) - assert elimination_problems(["one", "two"], Registry([one, two])) == [] - - -def test_two_declared_token_formats_issued_by_one_host_are_reported() -> None: - """Both would accept a JWT that names the shared issuer, so a token from - either authorization passes as the other's.""" - api = connection(id="api", **JWT_TOKENS) - mcp = connection(id="mcp", hosts=["mcp.example.com"], **JWT_TOKENS) - found = elimination_problems(["api", "mcp"], Registry([api, mcp])) - assert [p for p in found if "api" in p and "mcp" in p] == found - assert len(found) == 1 + registry = Registry([one, two]) + issuerless = jwt_with({"exp": 0}) + assert registry.get("one").accepts_token(issuerless) + assert registry.get("two").accepts_token(issuerless) + found = elimination_problems(["one", "two"], registry) + assert len(found) == 1, found + assert "one, two" in found[0] and "tokens.format" in found[0], found def test_prefixes_next_to_a_declared_token_format_are_reported() -> None: @@ -641,8 +622,9 @@ def test_prefixes_next_to_a_declared_token_format_are_reported() -> None: def test_a_connection_that_accepts_google_issued_jwts_is_reported() -> None: """Naming Google's authorization server as one's own host would let ID - tokens through the issuer match; the checks must catch the declaration.""" - entry = connection(hosts=["accounts.google.com"]) + tokens through the issuer match a declared format makes; the checks must + catch the declaration.""" + entry = connection(hosts=["accounts.google.com"], **JWT_TOKENS) assert any( "Google-issued" in problem for problem in connection_problems(entry, Registry([entry])) diff --git a/tests/test_connections_command.py b/tests/test_connections_command.py index 182f21c..ebf5607 100644 --- a/tests/test_connections_command.py +++ b/tests/test_connections_command.py @@ -226,8 +226,7 @@ def test_a_declared_token_format_reads_as_the_format_it_declares() -> None: connection has to see which of the two it is.""" entry = prefixless(tokens={"format": "jwt"}) rows = connections_table(Registry([entry])) - assert "jwt" in rows[0]["token_prefixes"] - assert "elimination" not in rows[0]["token_prefixes"] + assert rows[0]["token_prefixes"] == "(none: jwt naming no issuer but this service)" assert "jwt" in format_connection(entry) diff --git a/tests/test_validate.py b/tests/test_validate.py index c31cbbc..c90ebca 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -405,12 +405,11 @@ def test_a_declared_token_format_may_be_held_beside_a_prefixless_connection( assert problems(project) == [] -def test_a_declared_format_beside_a_connection_of_the_same_issuer_is_refused( +def test_a_declared_format_may_share_an_issuer_with_a_prefixless_connection( project: ProjectBuilder, ) -> None: - """One authorization server standing in front of both services puts its - host in either connection's tokens, and the declaration then says nothing - the other's token does not say as well.""" + """The prefixless connection takes no JWT, so one authorization server in + front of both services cannot put a token in the wrong one's hands.""" project.write_project( { "version": 1, @@ -430,8 +429,54 @@ def test_a_declared_format_beside_a_connection_of_the_same_issuer_is_refused( project.write_agent( "mail-triage", {"connections": ["internal-api", "internal-billing"]} ) + assert problems(project) == [] + + +def test_two_declared_formats_on_one_agent_are_reported( + project: ProjectBuilder, +) -> None: + """A JWT that names no issuer is taken by either, whichever service each is.""" + project.write_project( + { + "version": 1, + "project": "example-project", + "location": "us-central1", + "connections": { + "internal-api": {**INTERNAL_API, "tokens": {"format": "jwt"}}, + "zendesk": { + "base_url": "https://acme.zendesk.com", + "tokens": {"format": "jwt"}, + }, + }, + } + ) + project.write_agent("mail-triage", {"connections": ["zendesk", "internal-api"]}) found = problems(project) - assert any("auth.internal.example.com" in problem for problem in found), found + assert any( + "tokens.format" in p and "internal-api" in p and "zendesk" in p for p in found + ), found + + +def test_zendesk_declared_as_jwt_may_be_held_beside_notion_mcp( + project: ProjectBuilder, +) -> None: + """Zendesk's JWTs and Notion MCP's opaque tokens part by shape, so one + agent can search the one and write to the other.""" + project.write_project( + { + "version": 1, + "project": "example-project", + "location": "us-central1", + "connections": { + "zendesk": { + "base_url": "https://acme.zendesk.com", + "tokens": {"format": "jwt"}, + }, + }, + } + ) + project.write_agent("mail-triage", {"connections": ["zendesk", "notion-mcp"]}) + assert problems(project) == [] def test_the_catalog_alone_still_leaves_zendesk_accepted_by_elimination( From d93c56c4910de40b90a56ef466037eb869680048 Mon Sep 17 00:00:00 2001 From: haruotsu <65439874+haruotsu@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:31:35 +0900 Subject: [PATCH 2/6] Describe Zendesk's expiring tokens as the JWTs they are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 26 +++++++------ src/gete/catalog/connections/zendesk.yaml | 40 ++++++++++---------- tests/conformance/test_catalog.py | 46 +++++++++++++++++++---- 3 files changed, 74 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 3378f69..0666b1e 100644 --- a/README.md +++ b/README.md @@ -361,30 +361,34 @@ elimination refuses every token that carries a prefix declared anywhere in the project or the catalog, whichever agent holds that connection. Some services announce themselves without a prefix: their access tokens are -JWTs whose `iss` claim names the service's own host. `tokens.format: jwt` says -so, and gete holds the connection to it — a token that is not such a JWT is -refused, however little else claims it. In return the connection is no longer -accepted by elimination, and an agent may hold it next to one that is: +JWTs. `tokens.format: jwt` says so, and gete holds the connection to it — a +token that is not a JWT is refused, however little else claims it, and so is +a JWT whose `iss` claim names any host but the service's own. A JWT that +names no issuer at all is taken: Zendesk's carry only `exp`. A connection +that declares neither prefixes nor a format takes no JWT, so the two part by +shape and an agent may hold one of each: ```yaml connections: zendesk: base_url: https://acme.zendesk.com tokens: - format: jwt # tokens are JWTs issued by acme.zendesk.com, nothing else + format: jwt # tokens are JWTs; any issuer they name is acme.zendesk.com ``` An agent may then read Zendesk and write to `internal-api` above, which keeps -the one place a token is taken by elimination. What the two may not share is -an issuer: one authorization server in front of both services puts its host -in either connection's tokens, and `gete validate` refuses that pairing for -the same reason it refuses two anonymous connections. +the one place a token is taken by elimination — even when one authorization +server stands in front of both, since `internal-api` turns every JWT away. +What an agent may not hold is two connections that declare the format: a JWT +naming no issuer would pass as either's, and `gete validate` refuses that +pairing for the same reason it refuses two anonymous connections. Whether a provider issues such tokens can be a setting rather than a promise — Zendesk does so only with token expiry turned on — so the declaration belongs in `gete.yaml`, not in a catalog entry that would promise it for every -installation. It cuts both ways: turn that setting off and every token the -connection is handed is refused until the declaration goes with it. +installation. Without it, every JWT the connection is handed is refused, and +the log names the declaration to add. It cuts both ways: turn that setting off +and a token that is not a JWT is refused until the declaration goes with it. A service whose root moves with the installation — the tenant in a subdomain, or a deployment you host — writes its URLs around `{base_url}`, and the diff --git a/src/gete/catalog/connections/zendesk.yaml b/src/gete/catalog/connections/zendesk.yaml index 9e14064..26f989f 100644 --- a/src/gete/catalog/connections/zendesk.yaml +++ b/src/gete/catalog/connections/zendesk.yaml @@ -7,14 +7,12 @@ docs: https://developer.zendesk.com/api-reference/ # until the installation sets connections.zendesk.base_url in its gete.yaml; # hosts stays empty because the only host comes from that root. # Zendesk announces no prefix, and what its access tokens look like is the -# installation's own setting rather than the service's promise: with token -# expiry turned on they are JWTs issued by the subdomain, and without it they -# are JWTs whose claims name no issuer, which says as little about their -# origin as an opaque token. The catalog cannot promise either, so tokens are -# accepted by elimination here - and an agent may hold only one connection of -# that kind. An installation that has expiry on says so with -# connections.zendesk.tokens.format: jwt in its gete.yaml, and then this -# connection no longer takes that place. +# installation's own setting rather than the service's promise. With token +# expiry turned on they arrive as JWTs whose claims carry only exp and name no +# issuer. With it off, their shape has not been confirmed. A connection that +# accepts tokens by elimination takes no JWT, so an installation that has +# expiry on declares connections.zendesk.tokens.format: jwt in its gete.yaml; +# the catalog cannot promise that for every installation. token_prefixes: [] oauth: @@ -46,18 +44,22 @@ setup: | fails afterwards. Zendesk can be set to issue access tokens that expire. Those tokens are - JWTs issued by the subdomain, and an installation that has the setting on - may declare connections.zendesk.tokens.format: jwt in its gete.yaml, which - frees an agent to hold Zendesk beside another connection whose tokens - announce themselves no way at all. The declaration is held to: turn the - setting off again and every Zendesk token is refused until it is removed. + JWTs whose claims carry only exp: they name no issuer. A connection that + accepts tokens by elimination takes no JWT, so an installation that has + the setting on declares connections.zendesk.tokens.format: jwt in its + gete.yaml. Until it does, every token is refused and the log says so. The + declaration also frees an agent to hold Zendesk beside another connection + whose tokens announce themselves no way at all. It is held to: turn the + setting off again and a token that is not a JWT is refused until the + declaration is removed. What arrives with the setting off has not been + confirmed. -# No accepted example: which of the two shapes arrives is the installation's -# setting, and an entry fixing one of them would contradict an installation -# that has the other - the declared one most of all, whose accepted tokens -# name a subdomain the catalog cannot know. What is refused is refused either -# way. verified is absent because no authorization has been taken through -# Gemini Enterprise yet. +# No accepted example: without the declaration only a token that is not a JWT +# could be accepted, and what Zendesk issues with expiry off has not been +# confirmed, so an example would be a claim this cannot keep. Nor is the +# expiring token a rejected one: the examples are checked against the entry an +# installation declares the format on too, and that entry takes it. verified is +# absent because no authorization has been taken through Gemini Enterprise yet. examples: rejects: - "ya29.a0AfH6SMB" # Google access token diff --git a/tests/conformance/test_catalog.py b/tests/conformance/test_catalog.py index 30e19c7..1f492df 100644 --- a/tests/conformance/test_catalog.py +++ b/tests/conformance/test_catalog.py @@ -346,23 +346,50 @@ def test_zendesk_says_what_a_person_has_to_do_before_authorizing() -> None: assert "redirect URI" in setup +# Claims: {"exp": 0}. What Zendesk issues with token expiry turned on: a JWT +# whose claims name no issuer. +ZENDESK_EXPIRING_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjB9.sig" + + def test_zendesk_promises_no_token_format_for_every_installation() -> None: - """Zendesk issues JWTs of its own only with token expiry turned on, which - is the installation's setting; the catalog must not promise it.""" + """What arrives depends on whether the installation turned token expiry + on, so the catalog must not promise a shape; setup says what to declare.""" assert "tokens" not in CATALOG["zendesk"] - assert "tokens.format" in CATALOG["zendesk"]["setup"] + assert "tokens.format: jwt" in CATALOG["zendesk"]["setup"] + + +def test_zendesk_setup_does_not_say_its_jwts_name_the_subdomain() -> None: + """They carry only exp; a setup promising an issuer sends an operator + after the wrong cause when every token is refused.""" + setup = " ".join(CATALOG["zendesk"]["setup"].split()) + assert "issued by the subdomain" not in setup + assert "only exp" in setup -def test_zendesk_records_no_accepted_shape_because_it_has_two() -> None: - """An accepted example would fix one of the shapes, and contradict the - installation that has the other. What is refused is refused either way.""" +def test_zendesk_is_not_verified_until_an_authorization_goes_through() -> None: + assert "verified" not in CATALOG["zendesk"] + + +def test_zendesk_records_no_accepted_shape() -> None: + """Without a declaration only an opaque token could be accepted, and no + one has seen Zendesk issue one; an example would be a claim this cannot + keep. What is refused is refused either way.""" assert "accepts" not in CATALOG["zendesk"]["examples"] assert CATALOG["zendesk"]["examples"]["rejects"] +def test_zendesk_refuses_an_expiring_token_until_the_format_is_declared() -> None: + """Accepting by elimination takes no JWT, so the catalog entry as it + stands turns the token away and the log says what to declare.""" + zendesk = Registry.from_catalog( + {"zendesk": {"base_url": "https://acme.zendesk.com"}} + ).get("zendesk") + assert not zendesk.accepts_token(ZENDESK_EXPIRING_TOKEN) + + def test_an_installation_may_declare_that_zendesk_issues_jwts() -> None: - """With expiry on the tokens name the subdomain, and Zendesk stops being - the one connection an agent may hold by elimination.""" + """With the format declared Zendesk takes its expiring tokens, and sits + beside a connection whose tokens announce themselves no way at all.""" registry = Registry.from_catalog( { "zendesk": { @@ -372,5 +399,8 @@ def test_an_installation_may_declare_that_zendesk_issues_jwts() -> None: } ) zendesk = registry.get("zendesk") + assert zendesk.accepts_token(ZENDESK_EXPIRING_TOKEN) + assert not registry.get("notion-mcp").accepts_token(ZENDESK_EXPIRING_TOKEN) assert connection_problems(zendesk, registry) == [] assert elimination_problems(["zendesk", "freee"], registry) == [] + assert elimination_problems(["zendesk", "notion-mcp"], registry) == [] From 128e9fee970aa181fe917ec2766d95374946034d Mon Sep 17 00:00:00 2001 From: haruotsu <65439874+haruotsu@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:57:19 +0900 Subject: [PATCH 3/6] Refuse Google access tokens by their prefix under tokens.format: jwt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google issues ya29.c. access tokens beside the ya29. 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 --- src/gete/connection/checks.py | 10 ++++++++-- src/gete/connection/registry.py | 7 +++++-- src/gete/connection/runtime.py | 22 ++++++++++++++++------ tests/test_caller_token.py | 23 ++++++++++++++++++++++- tests/test_connection_registry.py | 10 ++++++++++ 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/src/gete/connection/checks.py b/src/gete/connection/checks.py index 8fd49a7..d673b59 100644 --- a/src/gete/connection/checks.py +++ b/src/gete/connection/checks.py @@ -41,7 +41,11 @@ "eyJhbGciOiJSUzI1NiJ9." "eyJpc3MiOiJhZ2VudEBwcm9qZWN0LmlhbS5nc2VydmljZWFjY291bnQuY29tIn0.sig", ) -_GOOGLE_ACCESS_TOKEN_EXAMPLE = GOOGLE_ACCESS_TOKEN_PREFIX + "a0AfH6SMB" +# Both shapes Google issues: the second carries two dots, as a JWT does. +_GOOGLE_ACCESS_TOKEN_EXAMPLES = ( + GOOGLE_ACCESS_TOKEN_PREFIX + "a0AfH6SMB", + GOOGLE_ACCESS_TOKEN_PREFIX + "c.b0Aaekm1K", +) def elimination_problems( @@ -188,7 +192,9 @@ def connection_problems(connection: Connection, registry: Registry) -> list[str] prefix.startswith(GOOGLE_ACCESS_TOKEN_PREFIX) for prefix in connection.token_prefixes ) - if not claims_google and connection.accepts_token(_GOOGLE_ACCESS_TOKEN_EXAMPLE): + if not claims_google and any( + connection.accepts_token(example) for example in _GOOGLE_ACCESS_TOKEN_EXAMPLES + ): problems.append("a Google access token is accepted") # The MCP server is spoken to with the user's token, so its URL must sit # where hosts lets the token go - path scoping included. diff --git a/src/gete/connection/registry.py b/src/gete/connection/registry.py index 6ea5d65..3fa36f7 100644 --- a/src/gete/connection/registry.py +++ b/src/gete/connection/registry.py @@ -355,9 +355,12 @@ def _issued_here_jwt(self, token: str) -> bool: connection's own service. Claims that name none are kept: Zendesk's carry only exp. Google's ID tokens and service account tokens always name theirs, which is never the connection's own, so they still fall - out without a list of Google's issuers. + out without a list of Google's issuers. Google's ya29.c. + access tokens carry two dots as well, and are refused for the prefix + they announce rather than for a middle segment that happens not to + decode. """ - if not looks_like_jwt(token): + if token.startswith(GOOGLE_ACCESS_TOKEN_PREFIX) or not looks_like_jwt(token): return False claims = jwt_claims(token) if claims is None: diff --git a/src/gete/connection/runtime.py b/src/gete/connection/runtime.py index 0f3bc62..26aa558 100644 --- a/src/gete/connection/runtime.py +++ b/src/gete/connection/runtime.py @@ -12,6 +12,7 @@ from typing import Any from gete.connection.registry import ( + GOOGLE_ACCESS_TOKEN_PREFIX, JWT_FORMAT, Connection, Registry, @@ -105,18 +106,27 @@ def describe_token(connection: Connection, token: str) -> str: refusal looking like a mystery. One accepting by elimination takes no JWT at all, and its operator is told the declaration that would take it: a provider that starts issuing JWTs has every token refused at once, - and nothing else in the log would say why. A connection that declares a - token format is refusing against a promise, and the half it missed is - named. Read in the order accepts_token decides in, so the message names - the rule that actually refused the token. + and nothing else in the log would say why. The declaration is not named + where it would refuse the same token: a Google access token carries two + dots as a JWT does, and claims that cannot be read are taken by nothing. + A connection that declares a token format is refusing against a + promise, and the half it missed is named. Read in the order + accepts_token decides in, so the message names the rule that actually + refused the token. """ if connection.token_format is not None: return _unmet_format(connection.token_format, token) for prefix in connection.token_prefixes: if token.startswith(prefix): return f"starts with {prefix}" - if connection.token_prefixes or not looks_like_jwt(token): + if ( + connection.token_prefixes + or token.startswith(GOOGLE_ACCESS_TOKEN_PREFIX) + or not looks_like_jwt(token) + ): return NO_DECLARED_SHAPE + if jwt_claims(token) is None: + return "a JWT whose claims cannot be read" return ( f"a JWT, which {connection.id} does not take: it declares neither " "token_prefixes nor tokens.format; declare " @@ -134,7 +144,7 @@ def _unmet_format(token_format: str, token: str) -> str: """ if token_format != JWT_FORMAT: return f"declared as {token_format}, which this gete cannot judge" - if not looks_like_jwt(token): + if token.startswith(GOOGLE_ACCESS_TOKEN_PREFIX) or not looks_like_jwt(token): return f"not the {token_format} this connection declares" if jwt_claims(token) is None: return "a JWT whose claims cannot be read" diff --git a/tests/test_caller_token.py b/tests/test_caller_token.py index fd269f0..4d154a3 100644 --- a/tests/test_caller_token.py +++ b/tests/test_caller_token.py @@ -25,6 +25,9 @@ GOOGLE_ISSUED_JWT = ( "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20ifQ.sig" ) +# Google issues ya29.c. access tokens as well; the two dots make one +# look like a JWT. +GOOGLE_ACCESS_TOKEN_WITH_DOTS = "ya29.c.b0Aaekm1K" # Claims: {"exp": 0}, as Zendesk's JWTs carry. The shape tokens.format: jwt @@ -155,7 +158,6 @@ def test_describe_token_says_a_jwt_belongs_to_a_declaring_connection() -> None: ) assert describe_token(FREEE, JWT_WITHOUT_ISSUER) == expected assert describe_token(FREEE, GOOGLE_ISSUED_JWT) == expected - assert describe_token(FREEE, "eyJhbGciOiJSUzI1NiJ9.x.sig") == expected # A declared prefix is the whole judgment; the JWT is described no further. assert ( describe_token(GITHUB, GOOGLE_ISSUED_JWT) @@ -163,6 +165,21 @@ def test_describe_token_says_a_jwt_belongs_to_a_declaring_connection() -> None: ) +def test_describe_token_does_not_name_the_declaration_where_it_would_not_help() -> None: + """A Google access token with two dots looks like a JWT, and so does a + token whose claims cannot be read; a declaring connection refuses both + as well, so telling the operator to declare the format would send them + after a change that refuses the same token.""" + assert ( + describe_token(FREEE, GOOGLE_ACCESS_TOKEN_WITH_DOTS) + == "matches none of the declared shapes" + ) + assert ( + describe_token(FREEE, "eyJhbGciOiJSUzI1NiJ9.x.sig") + == "a JWT whose claims cannot be read" + ) + + def test_describe_token_says_a_declared_format_was_not_met() -> None: """The connection promised JWTs, and an issuer they name has to be its own. The message says which half of the promise the token missed.""" @@ -170,6 +187,10 @@ def test_describe_token_says_a_declared_format_was_not_met() -> None: assert ( describe_token(DECLARED, FREEE_TOKEN) == "not the jwt this connection declares" ) + assert ( + describe_token(DECLARED, GOOGLE_ACCESS_TOKEN_WITH_DOTS) + == "not the jwt this connection declares" + ) assert ( describe_token(DECLARED, GOOGLE_ISSUED_JWT) == "a JWT whose issuer does not name this service" diff --git a/tests/test_connection_registry.py b/tests/test_connection_registry.py index 68d1e6d..a5eb0b2 100644 --- a/tests/test_connection_registry.py +++ b/tests/test_connection_registry.py @@ -144,6 +144,16 @@ def test_a_declared_jwt_format_accepts_a_jwt_that_names_no_issuer() -> None: assert entry.accepts_token(jwt_with({"exp": 0})) +def test_a_declared_jwt_format_refuses_a_google_access_token_by_its_prefix() -> None: + """Google's ya29.c. access tokens carry two dots. One is refused + for the prefix it announces, not for a middle segment that happens not + to decode, so a payload that does decode changes nothing.""" + entry = Registry([connection(**JWT_TOKENS)]).get("example") + assert not entry.accepts_token("ya29.c.b0Aaekm1K") + readable = "ya29." + jwt_with({"exp": 0}).split(".", 1)[1] + assert not entry.accepts_token(readable) + + def test_a_declared_jwt_format_refuses_a_token_that_is_not_a_jwt() -> None: """Without the declaration elimination would take it; with it, the connection promises a shape and holds itself to it.""" From 5507084eb91844af090c91493b1574d36ef39f8d Mon Sep 17 00:00:00 2001 From: haruotsu <65439874+haruotsu@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:57:19 +0900 Subject: [PATCH 4/6] Say where the elimination rule is first stated that a JWT is not taken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 5 +++-- src/gete/connections_listing.py | 2 +- tests/test_connection_registry.py | 13 ++----------- tests/test_connections_command.py | 2 +- tests/test_resolve.py | 16 ++++------------ tests/test_validate.py | 2 +- 6 files changed, 12 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 0666b1e..90e796f 100644 --- a/README.md +++ b/README.md @@ -331,8 +331,9 @@ the flow. An authorization server that requires PKCE refuses the code exchange without one, and there is no other way to ask for it from a declaration. `token_prefixes: []` says the service does not announce itself: a token is -taken as its own once no other connection's prefix matches it. Two such -connections cannot be told apart, so an agent may hold only one of them. +taken as its own once no other connection's prefix matches it and it is not +a JWT, the shape `tokens.format: jwt` below declares. Two such connections +cannot be told apart, so an agent may hold only one of them. Declaring a second one in `gete.yaml` is fine; naming both under one agent's `connections` is what `gete validate` refuses. diff --git a/src/gete/connections_listing.py b/src/gete/connections_listing.py index a7279e7..2071f41 100644 --- a/src/gete/connections_listing.py +++ b/src/gete/connections_listing.py @@ -21,7 +21,7 @@ def token_shapes(connection: Connection) -> str: if connection.token_prefixes: return ", ".join(connection.token_prefixes) if connection.token_format is not None: - return f"(none: {connection.token_format} naming no issuer but this service)" + return f"(none: {connection.token_format} naming this service or no issuer)" return BY_ELIMINATION diff --git a/tests/test_connection_registry.py b/tests/test_connection_registry.py index a5eb0b2..c091f15 100644 --- a/tests/test_connection_registry.py +++ b/tests/test_connection_registry.py @@ -591,15 +591,6 @@ def test_an_issuer_shared_with_an_anonymous_connection_confuses_nothing() -> Non assert elimination_problems(["declared", "anonymous"], registry) == [] -def test_an_anonymous_pair_sharing_an_issuer_is_reported_once() -> None: - """Neither can be told from the other by anything at all; naming the - issuer they share on top of that would say it twice.""" - one = connection(id="one") - two = connection(id="two", hosts=["api.two.example.com"]) - found = elimination_problems(["one", "two"], Registry([one, two])) - assert len(found) == 1, found - - def test_two_declared_token_formats_are_reported_whatever_their_issuers() -> None: """A JWT that names no issuer is taken by either, so a token from one authorization passes as the other's even when their services differ.""" @@ -632,8 +623,8 @@ def test_prefixes_next_to_a_declared_token_format_are_reported() -> None: def test_a_connection_that_accepts_google_issued_jwts_is_reported() -> None: """Naming Google's authorization server as one's own host would let ID - tokens through the issuer match a declared format makes; the checks must - catch the declaration.""" + tokens through the issuer match that a declared format makes; the checks + must catch the declaration.""" entry = connection(hosts=["accounts.google.com"], **JWT_TOKENS) assert any( "Google-issued" in problem diff --git a/tests/test_connections_command.py b/tests/test_connections_command.py index ebf5607..8e899bc 100644 --- a/tests/test_connections_command.py +++ b/tests/test_connections_command.py @@ -226,7 +226,7 @@ def test_a_declared_token_format_reads_as_the_format_it_declares() -> None: connection has to see which of the two it is.""" entry = prefixless(tokens={"format": "jwt"}) rows = connections_table(Registry([entry])) - assert rows[0]["token_prefixes"] == "(none: jwt naming no issuer but this service)" + assert rows[0]["token_prefixes"] == "(none: jwt naming this service or no issuer)" assert "jwt" in format_connection(entry) diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 154182c..c569cee 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -1,7 +1,5 @@ """The resolved declaration: one file that carries everything the runtime needs.""" -import base64 -import json from pathlib import Path import yaml @@ -11,15 +9,9 @@ from gete.connection import Registry from gete.declaration import load_project, load_resolved, resolve - -def jwt_issued_by(issuer: str) -> str: - """A JWT-shaped token naming this issuer. The signature is never checked.""" - payload = ( - base64.urlsafe_b64encode(json.dumps({"iss": issuer}).encode()) - .rstrip(b"=") - .decode() - ) - return f"eyJhbGciOiJSUzI1NiJ9.{payload}.sig" +# Claims: {"exp": 0}, as Zendesk's JWTs carry with token expiry turned on. +# The signature is never checked. +ISSUERLESS_JWT = "eyJhbGciOiJSUzI1NiJ9.eyJleHAiOjB9.sig" def write_project_with_policy(project: ProjectBuilder) -> None: @@ -101,7 +93,7 @@ def test_a_declared_token_format_travels_to_the_runtime( loaded = load_project(project.root / "gete.yaml") connections = resolve(loaded, loaded.agents[0])["resolved"]["connections"] zendesk = Registry.from_documents(connections).get("zendesk") - assert zendesk.accepts_token(jwt_issued_by("https://acme.zendesk.com")) + assert zendesk.accepts_token(ISSUERLESS_JWT) assert not zendesk.accepts_token("a1b2c3d4e5f60718293a4b5c6d7e8f90") diff --git a/tests/test_validate.py b/tests/test_validate.py index c90ebca..33a7445 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -386,7 +386,7 @@ def test_a_declared_token_format_may_be_held_beside_a_prefixless_connection( project: ProjectBuilder, ) -> None: """An agent that reads one service and writes to another needs both, and - a service whose tokens name their issuer is not the anonymous one.""" + a connection held to JWTs takes nothing the anonymous one does.""" project.write_project( { "version": 1, From 2d86873c6663acf98e89c246287e670dc890c62d Mon Sep 17 00:00:00 2001 From: haruotsu <65439874+haruotsu@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:58:15 +0900 Subject: [PATCH 5/6] Take issuerless JWTs only where tokens.issuer: optional is declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 61 +++++--- src/gete/catalog/connections/zendesk.yaml | 35 +++-- src/gete/connection/checks.py | 57 +++++-- src/gete/connection/registry.py | 62 ++++---- src/gete/connection/runtime.py | 51 ++++--- src/gete/connections_listing.py | 4 +- src/gete/schema/connection.json | 11 +- tests/conformance/test_catalog.py | 34 +++-- tests/test_caller_token.py | 54 +++++-- tests/test_connection_registry.py | 175 ++++++++++++++++++---- tests/test_connections_command.py | 10 +- tests/test_resolve.py | 3 +- tests/test_schema.py | 14 ++ tests/test_validate.py | 56 +++++-- 14 files changed, 468 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index 90e796f..0e8cb9e 100644 --- a/README.md +++ b/README.md @@ -331,9 +331,10 @@ the flow. An authorization server that requires PKCE refuses the code exchange without one, and there is no other way to ask for it from a declaration. `token_prefixes: []` says the service does not announce itself: a token is -taken as its own once no other connection's prefix matches it and it is not -a JWT, the shape `tokens.format: jwt` below declares. Two such connections -cannot be told apart, so an agent may hold only one of them. +taken as its own once no other connection's prefix matches it. A JWT is taken +only if its `iss` claim names the service's own host; one that names no +issuer is left to a connection declaring `issuer: optional` below. Two such +connections cannot be told apart, so an agent may hold only one of them. Declaring a second one in `gete.yaml` is fine; naming both under one agent's `connections` is what `gete validate` refuses. @@ -362,34 +363,56 @@ elimination refuses every token that carries a prefix declared anywhere in the project or the catalog, whichever agent holds that connection. Some services announce themselves without a prefix: their access tokens are -JWTs. `tokens.format: jwt` says so, and gete holds the connection to it — a -token that is not a JWT is refused, however little else claims it, and so is -a JWT whose `iss` claim names any host but the service's own. A JWT that -names no issuer at all is taken: Zendesk's carry only `exp`. A connection -that declares neither prefixes nor a format takes no JWT, so the two part by -shape and an agent may hold one of each: +JWTs whose `iss` claim names the service's own host. `tokens.format: jwt` says +so, and gete holds the connection to it — a token that is not such a JWT is +refused, however little else claims it. In return the connection is no longer +accepted by elimination, and an agent may hold it next to one that is: + +```yaml +connections: + internal-billing: + display_name: Internal Billing + hosts: [billing.internal.example.com] + tokens: + format: jwt # tokens are JWTs whose iss names this service + oauth: + authorization_url: https://auth.billing.example.com/authorize + token_url: https://auth.billing.example.com/token + scopes: {} +``` + +An agent may then write to `internal-billing` and read `internal-api` above, +which keeps the one place a token is taken by elimination. What the two may +not share is an issuer: one authorization server in front of both services +puts its host in either connection's tokens, and `gete validate` refuses that +pairing for the same reason it refuses two anonymous connections. + +Some providers issue JWTs that name no issuer at all — Zendesk's carry only +`exp`. Such a token says nothing about whose it is, so neither elimination nor +the format alone takes it. `issuer: optional` says the connection's tokens may +name none: ```yaml connections: zendesk: base_url: https://acme.zendesk.com tokens: - format: jwt # tokens are JWTs; any issuer they name is acme.zendesk.com + format: jwt + issuer: optional # Zendesk's JWTs name no issuer ``` -An agent may then read Zendesk and write to `internal-api` above, which keeps -the one place a token is taken by elimination — even when one authorization -server stands in front of both, since `internal-api` turns every JWT away. -What an agent may not hold is two connections that declare the format: a JWT -naming no issuer would pass as either's, and `gete validate` refuses that -pairing for the same reason it refuses two anonymous connections. +A JWT that does name an issuer is still held to the connection's own host. +An agent may hold Zendesk beside `internal-api` and beside `internal-billing`, +since neither takes a JWT naming no issuer, but not beside a second connection +declaring `issuer: optional`: nothing would tell their tokens apart. Whether a provider issues such tokens can be a setting rather than a promise — Zendesk does so only with token expiry turned on — so the declaration belongs in `gete.yaml`, not in a catalog entry that would promise it for every -installation. Without it, every JWT the connection is handed is refused, and -the log names the declaration to add. It cuts both ways: turn that setting off -and a token that is not a JWT is refused until the declaration goes with it. +installation. Without it, every such token the connection is handed is +refused, and the log names the declaration to add. It cuts both ways: turn +that setting off and a token that is not a JWT is refused until the +declaration goes with it. A service whose root moves with the installation — the tenant in a subdomain, or a deployment you host — writes its URLs around `{base_url}`, and the diff --git a/src/gete/catalog/connections/zendesk.yaml b/src/gete/catalog/connections/zendesk.yaml index 26f989f..e8898b1 100644 --- a/src/gete/catalog/connections/zendesk.yaml +++ b/src/gete/catalog/connections/zendesk.yaml @@ -9,10 +9,11 @@ docs: https://developer.zendesk.com/api-reference/ # Zendesk announces no prefix, and what its access tokens look like is the # installation's own setting rather than the service's promise. With token # expiry turned on they arrive as JWTs whose claims carry only exp and name no -# issuer. With it off, their shape has not been confirmed. A connection that -# accepts tokens by elimination takes no JWT, so an installation that has -# expiry on declares connections.zendesk.tokens.format: jwt in its gete.yaml; -# the catalog cannot promise that for every installation. +# issuer. With it off, their shape has not been confirmed. Neither elimination +# nor the jwt format alone takes a JWT that names no issuer, so an +# installation that has expiry on declares +# connections.zendesk.tokens: {format: jwt, issuer: optional} in its +# gete.yaml; the catalog cannot promise that for every installation. token_prefixes: [] oauth: @@ -44,15 +45,23 @@ setup: | fails afterwards. Zendesk can be set to issue access tokens that expire. Those tokens are - JWTs whose claims carry only exp: they name no issuer. A connection that - accepts tokens by elimination takes no JWT, so an installation that has - the setting on declares connections.zendesk.tokens.format: jwt in its - gete.yaml. Until it does, every token is refused and the log says so. The - declaration also frees an agent to hold Zendesk beside another connection - whose tokens announce themselves no way at all. It is held to: turn the - setting off again and a token that is not a JWT is refused until the - declaration is removed. What arrives with the setting off has not been - confirmed. + JWTs whose claims carry only exp: they name no issuer. Such a token is + taken only by a connection that says its tokens may name none, so an + installation that has the setting on declares, in its gete.yaml: + + connections: + zendesk: + tokens: + format: jwt + issuer: optional + + Until it does, every token is refused and the log says what to declare. + The declaration also frees an agent to hold Zendesk beside a connection + whose tokens announce themselves no way at all, or beside one whose JWTs + name their own issuer. An agent may hold only one connection that + declares issuer: optional. The declaration is held to: turn the setting + off again and a token that is not a JWT is refused until the declaration + is removed. What arrives with the setting off has not been confirmed. # No accepted example: without the declaration only a token that is not a JWT # could be accepted, and what Zendesk issues with expiry off has not been diff --git a/src/gete/connection/checks.py b/src/gete/connection/checks.py index d673b59..a87e84d 100644 --- a/src/gete/connection/checks.py +++ b/src/gete/connection/checks.py @@ -5,6 +5,7 @@ from gete.connection.registry import ( GOOGLE_ACCESS_TOKEN_PREFIX, + JWT_FORMAT, Connection, OAuth, Registry, @@ -62,11 +63,17 @@ def elimination_problems( gete ships as well, and declaring a service of your own must not depend on which of those announce themselves. - A declared format is the other half of that split: a connection held to - tokens.format: jwt takes only JWTs, and one accepting by elimination - takes none, so an agent may hold one of each. Two declaring ones are - the anonymous pair again: a JWT whose claims name no issuer, as - Zendesk's do, is taken by either, so no issuer tells them apart. + A declared format announces the service in every token, so it does not + take that one place - unless one issuer stands behind two of the + connections judging tokens by issuer, which is the same confusion by + another route. An anonymous connection is one of those: it takes a JWT + naming its own authorization server as readily as a declaring one does, + so a shared issuer confuses the two whichever of them declared. + + A JWT that names no issuer, as Zendesk's do, is taken only by a + connection declaring tokens.issuer: optional. Nothing tells two of those + apart, so an agent may hold one, the way it may hold one anonymous + connection; neither of the others takes such a token. Prefixes that overlap are that confusion among the connections that do announce themselves: a token carrying the shared prefix passes as @@ -92,15 +99,38 @@ def elimination_problems( "connections may accept tokens by elimination, or a token from one " "of them would be accepted as another's" ) - declared = [ - connection for connection in connections if connection.token_format is not None + issuerless = [ + connection for connection in connections if _takes_issuerless(connection) ] - if len(declared) >= 2: + if len(issuerless) >= 2: problems.append( - f"{', '.join(connection.id for connection in declared)} declare " - "tokens.format; only one of an agent's connections may, because a " - "JWT that names no issuer would be accepted as any of theirs" + f"{', '.join(connection.id for connection in issuerless)} declare " + "tokens.issuer: optional; only one of an agent's connections may " + "accept JWTs that name no issuer, or a token from one of them would " + "be accepted as another's" ) + # A prefix decides on its own and before any issuer does, so a connection + # held to one judges no token by who issued it, and shares an issuer with + # nothing. + by_issuer = [ + connection + for connection in connections + if connection.token_format is not None or not connection.token_prefixes + ] + for index, connection in enumerate(by_issuer): + for other in by_issuer[index + 1 :]: + if connection.token_format is None and other.token_format is None: + # Already refused above, by everything the pair fails to say + # about itself rather than by the one issuer they happen to + # share; naming it here would say it twice. + continue + shared = connection.issuer_hosts & other.issuer_hosts + if shared: + problems.append( + f"{connection.id}, {other.id} both accept tokens issued by " + f"{', '.join(sorted(shared))}; a token from one of them " + "would be accepted as the other's" + ) # A declared format decides before any prefix is read, so a connection # held to one takes no token for its prefix. by_prefix = [ @@ -118,6 +148,11 @@ def elimination_problems( return problems +def _takes_issuerless(connection: Connection) -> bool: + """Whether the connection accepts a JWT that names no issuer.""" + return connection.token_format == JWT_FORMAT and connection.issuer_optional + + def _overlapping_prefixes(connection: Connection, other: Connection) -> list[str]: """The prefixes a token can carry and be accepted by both connections. diff --git a/src/gete/connection/registry.py b/src/gete/connection/registry.py index 3fa36f7..7202d9e 100644 --- a/src/gete/connection/registry.py +++ b/src/gete/connection/registry.py @@ -21,8 +21,7 @@ # A JWT has three dot-separated segments (two dots) and its base64url header # starts with "eyJ". Google ID tokens and service account tokens are JWTs, # but so are other services' access tokens, so the shape convicts nothing -# on its own: a connection that declares the shape judges what a JWT names -# as its issuer, and one accepting by elimination takes no JWT at all. +# on its own: what a JWT names as its issuer is what is judged. _JWT_DOTS = 2 _JWT_HEADER_PREFIX = "eyJ" @@ -35,6 +34,11 @@ # names itself in every token, which is what a prefix would otherwise do. JWT_FORMAT = "jwt" +# What tokens.issuer says of a JWT format whose tokens name no issuer at all, +# as Zendesk's do. Anything else - the default, or a value this gete cannot +# read - holds every JWT to naming the connection's own service. +ISSUER_OPTIONAL = "optional" + # Stands for the root of a service that differs per installation, wherever the # connection's URLs are written around it. A definition meant to be shared # cannot spell a tenant's host, and a stand-in host would be a name a stranger @@ -66,8 +70,8 @@ def missing_base_url(connection_id: str) -> str: def looks_like_jwt(token: str) -> bool: - """True for anything shaped like a JWT, whether its claims can be read - or not: the shape is what decides which connection may judge it.""" + """True for anything shaped like a JWT. Shape alone convicts nothing: + what the claims name as their issuer is what accepts_token judges.""" return token.count(".") >= _JWT_DOTS or token.startswith(_JWT_HEADER_PREFIX) @@ -217,10 +221,14 @@ class Connection: redirect_hosts: frozenset[str] = frozenset() token_prefixes: tuple[str, ...] = () # The format the service commits to issuing, JWT_FORMAT or None. A - # connection that declares one takes nothing else, and one accepting by - # elimination takes nothing of that shape, so the two never share a - # token; one this gete cannot judge takes nothing at all. + # connection that declares one takes nothing else, so it announces itself + # as surely as a prefix does and is not accepted by elimination; one this + # gete cannot judge takes nothing at all. token_format: str | None = None + # Whether a JWT that names no issuer keeps the format's promise. Such a + # token says nothing about whose it is, so only the connection that + # declares it takes one, and validate lets an agent hold one such. + issuer_optional: bool = False # Prefixes declared by every other connection in the registry, filled in by # Registry. A connection without prefixes of its own accepts a token only # if none of these match, so a bare from_mapping() connection judges more @@ -268,6 +276,7 @@ def from_mapping(cls, data: Mapping[str, Any]) -> "Connection": redirect_hosts=frozenset(data.get("redirect_hosts", ())), token_prefixes=tuple(data.get("token_prefixes", ())), token_format=data.get("tokens", {}).get("format"), + issuer_optional=data.get("tokens", {}).get("issuer") == ISSUER_OPTIONAL, base_url=base_url, docs=data.get("docs"), oauth_client=data.get("oauth_client"), @@ -328,10 +337,12 @@ def accepts_token(self, token: str) -> bool: however little else claims it. A declared prefix decides next: Google issues ya29.c. access tokens that carry two dots, and a shape heuristic must not overrule a prefix the catalog vouches for. With - neither, the connection takes what announces itself no way at all, - and a JWT is not that: it is the shape tokens.format: jwt declares, - so the two kinds of connection part over it and one agent can hold - one of each. + neither, a JWT is judged by the issuer its claims name - Google ID + tokens and service account tokens always name Google's - and one that + names none is refused: it says nothing about whose it is, and only a + connection declaring tokens.issuer: optional takes it, so the two + never share a token. One that cannot be read is refused rather than + guessed about. """ if not token: return False @@ -339,26 +350,27 @@ def accepts_token(self, token: str) -> bool: # A resolved declaration outlives the gete that wrote it, and a # format this one cannot judge is not one it may fall back from: # elimination would accept what the declaration meant to narrow. - return self.token_format == JWT_FORMAT and self._issued_here_jwt(token) + return self.token_format == JWT_FORMAT and self._issued_here_jwt( + token, issuerless=self.issuer_optional + ) if self.token_prefixes: return token.startswith(self.token_prefixes) - if token.startswith(GOOGLE_ACCESS_TOKEN_PREFIX) or looks_like_jwt(token): + if token.startswith(GOOGLE_ACCESS_TOKEN_PREFIX): return False + if looks_like_jwt(token): + return self._issued_here_jwt(token, issuerless=False) # The token does not announce itself. All that can be said is that # it is not some other service's. return not any(token.startswith(prefix) for prefix in self.foreign_prefixes) - def _issued_here_jwt(self, token: str) -> bool: - """Whether the token keeps the promise tokens.format: jwt makes. - - A JWT whose claims can be read, and which names no issuer but this - connection's own service. Claims that name none are kept: Zendesk's - carry only exp. Google's ID tokens and service account tokens always - name theirs, which is never the connection's own, so they still fall - out without a list of Google's issuers. Google's ya29.c. - access tokens carry two dots as well, and are refused for the prefix - they announce rather than for a middle segment that happens not to - decode. + def _issued_here_jwt(self, token: str, *, issuerless: bool) -> bool: + """Whether the token is a JWT this connection's own service issued. + + Its claims have to be readable and name this service as the issuer, + or - where issuerless is allowed - name no issuer at all. Google's + ya29.c. access tokens carry two dots as well, and are + refused for the prefix they announce rather than for a middle + segment that happens not to decode. """ if token.startswith(GOOGLE_ACCESS_TOKEN_PREFIX) or not looks_like_jwt(token): return False @@ -366,7 +378,7 @@ def _issued_here_jwt(self, token: str) -> bool: if claims is None: return False if "iss" not in claims: - return True + return issuerless issuer = claims["iss"] return isinstance(issuer, str) and self.issued_here(issuer) diff --git a/src/gete/connection/runtime.py b/src/gete/connection/runtime.py index 26aa558..70634c9 100644 --- a/src/gete/connection/runtime.py +++ b/src/gete/connection/runtime.py @@ -102,20 +102,19 @@ def describe_token(connection: Connection, token: str) -> str: """Which declared shape the token has, without repeating any of it. A connection without prefixes declares no shapes, so a refused JWT is - described by what refused it, or the shape message would leave the - refusal looking like a mystery. One accepting by elimination takes no - JWT at all, and its operator is told the declaration that would take - it: a provider that starts issuing JWTs has every token refused at once, - and nothing else in the log would say why. The declaration is not named - where it would refuse the same token: a Google access token carries two - dots as a JWT does, and claims that cannot be read are taken by nothing. - A connection that declares a token format is refusing against a - promise, and the half it missed is named. Read in the order + described by what refused it - claims that cannot be read, an issuer + that does not name this service, or no issuer at all - or the shape + message would leave the refusal looking like a mystery. A JWT naming no + issuer is taken only where tokens.issuer: optional is declared, and the + message names that declaration: a provider whose JWTs never name one has + every token refused at once, and nothing else in the log would say why. + A Google access token carries two dots as a JWT does, and is described + by the shapes it misses rather than as a JWT. Read in the order accepts_token decides in, so the message names the rule that actually refused the token. """ if connection.token_format is not None: - return _unmet_format(connection.token_format, token) + return _unmet_format(connection, token) for prefix in connection.token_prefixes: if token.startswith(prefix): return f"starts with {prefix}" @@ -125,16 +124,16 @@ def describe_token(connection: Connection, token: str) -> str: or not looks_like_jwt(token) ): return NO_DECLARED_SHAPE - if jwt_claims(token) is None: - return "a JWT whose claims cannot be read" - return ( - f"a JWT, which {connection.id} does not take: it declares neither " - "token_prefixes nor tokens.format; declare " - f"connections.{connection.id}.tokens.format: jwt if its tokens are JWTs" + return _refused_jwt( + token, + issuerless=( + f"declare connections.{connection.id}.tokens.format: jwt with " + "issuer: optional if its tokens never name one" + ), ) -def _unmet_format(token_format: str, token: str) -> str: +def _unmet_format(connection: Connection, token: str) -> str: """Which part of the declared format the token missed. A format this gete cannot judge refuses every token alike, so it is the @@ -142,12 +141,28 @@ def _unmet_format(token_format: str, token: str) -> str: issuer was wrong would go looking at a token that may be exactly right, when what refused it is a declaration newer than the gete reading it. """ + token_format = connection.token_format if token_format != JWT_FORMAT: return f"declared as {token_format}, which this gete cannot judge" if token.startswith(GOOGLE_ACCESS_TOKEN_PREFIX) or not looks_like_jwt(token): return f"not the {token_format} this connection declares" - if jwt_claims(token) is None: + return _refused_jwt( + token, + issuerless=( + f"declare connections.{connection.id}.tokens.issuer: optional if " + "its tokens never name one" + ), + ) + + +def _refused_jwt(token: str, *, issuerless: str) -> str: + """Why a JWT-shaped token was refused; issuerless is the declaration + that would take one naming no issuer.""" + claims = jwt_claims(token) + if claims is None: return "a JWT whose claims cannot be read" + if "iss" not in claims: + return f"a JWT that names no issuer; {issuerless}" return "a JWT whose issuer does not name this service" diff --git a/src/gete/connections_listing.py b/src/gete/connections_listing.py index 2071f41..6a55488 100644 --- a/src/gete/connections_listing.py +++ b/src/gete/connections_listing.py @@ -20,8 +20,10 @@ def token_shapes(connection: Connection) -> str: """ if connection.token_prefixes: return ", ".join(connection.token_prefixes) - if connection.token_format is not None: + if connection.token_format is not None and connection.issuer_optional: return f"(none: {connection.token_format} naming this service or no issuer)" + if connection.token_format is not None: + return f"(none: {connection.token_format} naming this service)" return BY_ELIMINATION diff --git a/src/gete/schema/connection.json b/src/gete/schema/connection.json index 459834c..018b016 100644 --- a/src/gete/schema/connection.json +++ b/src/gete/schema/connection.json @@ -61,7 +61,7 @@ } }, "token_prefixes": { - "description": "Prefixes of issued tokens. Empty means the service does not announce itself and tokens are accepted by elimination, unless tokens.format says what they look like; a connection accepting by elimination takes no JWT, and an agent may declare only one connection that announces itself neither way. Connections may share a prefix, as two installations of one service do; an agent may not declare two whose prefixes overlap.", + "description": "Prefixes of issued tokens. Empty means the service does not announce itself and tokens are accepted by elimination, unless tokens.format says what they look like; a connection accepting by elimination takes no JWT that names no issuer, and an agent may declare only one connection that announces itself neither way. Connections may share a prefix, as two installations of one service do; an agent may not declare two whose prefixes overlap.", "type": "array", "uniqueItems": true, "items": { @@ -78,10 +78,17 @@ ], "properties": { "format": { - "description": "jwt: the tokens are JWTs, and an iss claim, where the claims carry one, names the connection's own service. A JWT that names no issuer is accepted. An agent may declare only one connection with this format.", + "description": "jwt: the tokens are JWTs whose iss claim names the connection's own service. Nothing else is a format that says whose token it is.", "enum": [ "jwt" ] + }, + "issuer": { + "description": "required (the default): every JWT names the connection's own service as its issuer. optional: a JWT that names no issuer at all is accepted as well, as Zendesk's are; one that names an issuer is still held to it. Such a token says nothing about whose it is, so an agent may hold only one connection that declares optional.", + "enum": [ + "required", + "optional" + ] } } }, diff --git a/tests/conformance/test_catalog.py b/tests/conformance/test_catalog.py index 1f492df..d0f65fc 100644 --- a/tests/conformance/test_catalog.py +++ b/tests/conformance/test_catalog.py @@ -1,6 +1,7 @@ """Every bundled connection must be sound on its own and against the others.""" from importlib.resources import files +from typing import Any import pytest @@ -355,7 +356,9 @@ def test_zendesk_promises_no_token_format_for_every_installation() -> None: """What arrives depends on whether the installation turned token expiry on, so the catalog must not promise a shape; setup says what to declare.""" assert "tokens" not in CATALOG["zendesk"] - assert "tokens.format: jwt" in CATALOG["zendesk"]["setup"] + setup = " ".join(CATALOG["zendesk"]["setup"].split()) + assert "format: jwt" in setup + assert "issuer: optional" in setup def test_zendesk_setup_does_not_say_its_jwts_name_the_subdomain() -> None: @@ -378,23 +381,34 @@ def test_zendesk_records_no_accepted_shape() -> None: assert CATALOG["zendesk"]["examples"]["rejects"] -def test_zendesk_refuses_an_expiring_token_until_the_format_is_declared() -> None: - """Accepting by elimination takes no JWT, so the catalog entry as it - stands turns the token away and the log says what to declare.""" - zendesk = Registry.from_catalog( - {"zendesk": {"base_url": "https://acme.zendesk.com"}} - ).get("zendesk") +@pytest.mark.parametrize( + "tokens", + [ + None, # accepted by elimination, which takes no JWT naming no issuer + {"format": "jwt"}, # the format alone requires an issuer + ], +) +def test_zendesk_refuses_an_expiring_token_until_its_issuer_is_optional( + tokens: dict[str, Any] | None, +) -> None: + """The catalog entry as it stands turns the token away, and so does the + format without the optional issuer; the log says what to declare.""" + override: dict[str, Any] = {"base_url": "https://acme.zendesk.com"} + if tokens is not None: + override["tokens"] = tokens + zendesk = Registry.from_catalog({"zendesk": override}).get("zendesk") assert not zendesk.accepts_token(ZENDESK_EXPIRING_TOKEN) def test_an_installation_may_declare_that_zendesk_issues_jwts() -> None: - """With the format declared Zendesk takes its expiring tokens, and sits - beside a connection whose tokens announce themselves no way at all.""" + """With the format and the optional issuer declared Zendesk takes its + expiring tokens, and sits beside a connection whose tokens announce + themselves no way at all.""" registry = Registry.from_catalog( { "zendesk": { "base_url": "https://acme.zendesk.com", - "tokens": {"format": "jwt"}, + "tokens": {"format": "jwt", "issuer": "optional"}, } } ) diff --git a/tests/test_caller_token.py b/tests/test_caller_token.py index 4d154a3..f04a098 100644 --- a/tests/test_caller_token.py +++ b/tests/test_caller_token.py @@ -30,8 +30,8 @@ GOOGLE_ACCESS_TOKEN_WITH_DOTS = "ya29.c.b0Aaekm1K" -# Claims: {"exp": 0}, as Zendesk's JWTs carry. The shape tokens.format: jwt -# declares, whatever little the claims say. +# Claims: {"exp": 0}, as Zendesk's JWTs carry. Taken only where +# tokens.issuer: optional is declared. JWT_WITHOUT_ISSUER = "eyJhbGciOiJFZERTQSJ9.eyJleHAiOjB9.sig" # A connection whose tokens are JWTs of its own making, as an installation # declares one whose provider issues them. @@ -55,6 +55,21 @@ # Claims: {"iss": "https://api.example.com"}: a token DECLARED's own service # issued, and the only kind it takes. OWN_ISSUER_JWT = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FwaS5leGFtcGxlLmNvbSJ9.sig" +# DECLARED as an installation declares it whose provider's JWTs name no +# issuer at all. +ISSUERLESS = Connection.from_mapping( + { + "id": "issuerless", + "display_name": "Issuerless", + "hosts": ["api.example.com"], + "tokens": {"format": "jwt", "issuer": "optional"}, + "oauth": { + "authorization_url": "https://api.example.com/authorize", + "token_url": "https://api.example.com/token", + "scopes": {}, + }, + } +) # A format no gete of this version knows, as a resolved declaration written # by a later one carries it. UNJUDGEABLE = Connection.from_mapping( @@ -148,16 +163,13 @@ def test_describe_token_names_the_prefix_not_the_value() -> None: assert GITHUB_TOKEN not in describe_token(GITHUB, GITHUB_TOKEN) -def test_describe_token_says_a_jwt_belongs_to_a_declaring_connection() -> None: - """A connection accepting by elimination takes no JWT, so the operator - reading the log has to be told the fix: declare the format on it.""" - expected = ( - "a JWT, which freee does not take: it declares neither token_prefixes " - "nor tokens.format; declare connections.freee.tokens.format: jwt if " - "its tokens are JWTs" +def test_describe_token_says_why_a_jwt_was_refused_without_repeating_it() -> None: + """A prefixless connection declares no shapes, so the shape message would + leave an issuer refusal looking like a mystery.""" + assert ( + describe_token(FREEE, GOOGLE_ISSUED_JWT) + == "a JWT whose issuer does not name this service" ) - assert describe_token(FREEE, JWT_WITHOUT_ISSUER) == expected - assert describe_token(FREEE, GOOGLE_ISSUED_JWT) == expected # A declared prefix is the whole judgment; the JWT is described no further. assert ( describe_token(GITHUB, GOOGLE_ISSUED_JWT) @@ -165,6 +177,21 @@ def test_describe_token_says_a_jwt_belongs_to_a_declaring_connection() -> None: ) +def test_describe_token_names_the_declaration_a_jwt_naming_no_issuer_needs() -> None: + """Only a connection declaring tokens.issuer: optional takes such a token, + so a provider whose JWTs name no issuer has every token refused until it + is declared, and the log has to say what to declare.""" + assert describe_token(FREEE, JWT_WITHOUT_ISSUER) == ( + "a JWT that names no issuer; declare connections.freee.tokens.format: " + "jwt with issuer: optional if its tokens never name one" + ) + assert describe_token(DECLARED, JWT_WITHOUT_ISSUER) == ( + "a JWT that names no issuer; declare connections.declared.tokens.issuer: " + "optional if its tokens never name one" + ) + assert ISSUERLESS.accepts_token(JWT_WITHOUT_ISSUER) + + def test_describe_token_does_not_name_the_declaration_where_it_would_not_help() -> None: """A Google access token with two dots looks like a JWT, and so does a token whose claims cannot be read; a declaring connection refuses both @@ -181,9 +208,8 @@ def test_describe_token_does_not_name_the_declaration_where_it_would_not_help() def test_describe_token_says_a_declared_format_was_not_met() -> None: - """The connection promised JWTs, and an issuer they name has to be its - own. The message says which half of the promise the token missed.""" - assert DECLARED.accepts_token(JWT_WITHOUT_ISSUER) + """The connection promised JWTs its own service issued. The message says + which part of the promise the token missed.""" assert ( describe_token(DECLARED, FREEE_TOKEN) == "not the jwt this connection declares" ) diff --git a/tests/test_connection_registry.py b/tests/test_connection_registry.py index c091f15..65c1f9c 100644 --- a/tests/test_connection_registry.py +++ b/tests/test_connection_registry.py @@ -99,22 +99,44 @@ def test_google_issued_jwts_are_never_accepted(catalog: Registry) -> None: assert not entry.accepts_token(jwt_with({"iss": issuer})), entry.id +def test_a_jwt_naming_no_issuer_is_not_accepted_by_elimination() -> None: + """It says nothing about whose it is. A connection whose tokens are such + JWTs says so with tokens.issuer: optional, and elimination leaves them to + it, so the two can be held by one agent without sharing a token.""" + alone = Registry([connection()]).get("example") + assert not alone.accepts_token(jwt_with({"exp": 0})) + assert alone.accepts_token("a1b2c3d4e5f60718293a4b5c6d7e8f90") + + +def test_a_jwt_naming_the_connections_own_service_is_accepted() -> None: + """The issuer of a service's tokens is its authorization server, which + may live beside the API rather than on it; both count as the service.""" + alone = Registry([connection()]).get("example") + assert alone.accepts_token(jwt_with({"iss": "https://api.example.com"})) + assert alone.accepts_token(jwt_with({"iss": "api.example.com"})) + assert alone.accepts_token(jwt_with({"iss": "https://auth.example.com"})) + + +def test_a_jwt_naming_the_installation_root_is_accepted() -> None: + entry = Connection.from_mapping({**ROOTED, "base_url": "https://acme.example.com"}) + assert entry.accepts_token(jwt_with({"iss": "https://acme.example.com"})) + + @pytest.mark.parametrize( - "claims", + "issuer", [ - {"exp": 0}, # Zendesk's, with token expiry turned on - {"iss": "https://api.example.com"}, # naming the connection's own service - {"iss": "https://auth.example.com"}, - {"iss": "https://idp.example.org"}, + "https://accounts.google.com", # Google ID token + "agent@project.iam.gserviceaccount.com", # service account token + "https://idp.example.org", # some other service's authorization server + "https://api.example.com.evil.example", # suffix must not pass as a match + 5, # RFC 7519 wants a string; anything else names nothing + None, # present but null is just as far from a string + "https://[", # names no host; must be refused, not raise ], ) -def test_a_connection_accepting_by_elimination_takes_no_jwt(claims: Any) -> None: - """A JWT is the shape tokens.format: jwt declares. Elimination takes what - announces itself no way at all, so the two shapes part between the - connections and an agent can hold one of each.""" +def test_a_jwt_naming_any_other_issuer_is_refused(issuer: Any) -> None: alone = Registry([connection()]).get("example") - assert not alone.accepts_token(jwt_with(claims)) - assert alone.accepts_token("a1b2c3d4e5f60718293a4b5c6d7e8f90") + assert not alone.accepts_token(jwt_with({"iss": issuer})) def test_prefixless_connection_alone_still_refuses_google_access_tokens() -> None: @@ -137,18 +159,47 @@ def test_a_declared_jwt_format_accepts_a_token_the_service_issued() -> None: assert entry.accepts_token(jwt_with({"iss": "api.example.com"})) -def test_a_declared_jwt_format_accepts_a_jwt_that_names_no_issuer() -> None: - """Zendesk's JWTs carry only exp. The declaration promises the shape, and - claims that name nobody break no part of it.""" +ISSUERLESS_JWT_TOKENS: dict[str, Any] = { + "tokens": {"format": "jwt", "issuer": "optional"} +} + + +def test_a_declared_jwt_format_refuses_a_jwt_that_names_no_issuer() -> None: + """The format promises tokens of this service's own making, and an + issuer is what says so; claims naming nobody keep no part of it.""" entry = Registry([connection(**JWT_TOKENS)]).get("example") + assert not entry.accepts_token(jwt_with({"exp": 0})) + + +def test_an_optional_issuer_accepts_a_jwt_that_names_none() -> None: + """Zendesk's JWTs carry only exp. An installation that knows its + provider's tokens never name an issuer says so, and only then are they + taken; one that does name an issuer is still held to it.""" + entry = Registry([connection(**ISSUERLESS_JWT_TOKENS)]).get("example") assert entry.accepts_token(jwt_with({"exp": 0})) + assert entry.accepts_token(jwt_with({"iss": "https://auth.example.com"})) + assert not entry.accepts_token(jwt_with({"iss": "https://idp.example.org"})) + assert not entry.accepts_token(jwt_with({"iss": "https://accounts.google.com"})) + assert not entry.accepts_token("a1b2c3d4e5f60718293a4b5c6d7e8f90") + assert not entry.accepts_token("eyJhbGciOiJSUzI1NiJ9.x.sig") + + +def test_an_issuer_requirement_this_gete_cannot_read_is_held_as_required() -> None: + """A resolved declaration outlives the gete that wrote it. Reading an + unknown value as optional would take tokens the declaration may have + meant to refuse; as required, it takes no more than the format alone.""" + entry = Registry([connection(tokens={"format": "jwt", "issuer": "pinned"})]).get( + "example" + ) + assert not entry.accepts_token(jwt_with({"exp": 0})) + assert entry.accepts_token(jwt_with({"iss": "https://auth.example.com"})) def test_a_declared_jwt_format_refuses_a_google_access_token_by_its_prefix() -> None: """Google's ya29.c. access tokens carry two dots. One is refused for the prefix it announces, not for a middle segment that happens not to decode, so a payload that does decode changes nothing.""" - entry = Registry([connection(**JWT_TOKENS)]).get("example") + entry = Registry([connection(**ISSUERLESS_JWT_TOKENS)]).get("example") assert not entry.accepts_token("ya29.c.b0Aaekm1K") readable = "ya29." + jwt_with({"exp": 0}).split(".", 1)[1] assert not entry.accepts_token(readable) @@ -167,6 +218,7 @@ def test_a_declared_jwt_format_refuses_a_token_that_is_not_a_jwt() -> None: @pytest.mark.parametrize( "claims", [ + {"exp": 0}, # names no issuer, and none is declared optional {"iss": "https://idp.example.org"}, {"iss": "https://accounts.google.com"}, {"iss": None}, # present but null names nothing a host could match @@ -579,21 +631,33 @@ def test_a_declared_token_format_is_not_an_acceptance_by_elimination() -> None: assert elimination_problems(["zendesk", "internal"], registry) == [] -def test_an_issuer_shared_with_an_anonymous_connection_confuses_nothing() -> None: - """The anonymous connection takes no JWT at all, so whatever issuer the - claims name, the token can only be the declaring connection's.""" +def test_a_shared_issuer_confuses_a_declared_format_and_an_anonymous_one() -> None: + """The anonymous connection takes a JWT naming its own authorization + server too, so one issuer serving both is the same confusion as two + declaring connections sharing one - the declaration says nothing the + other's token does not say as well.""" declared = connection(id="declared", **JWT_TOKENS) anonymous = connection(id="anonymous", hosts=["api.other.example.com"]) registry = Registry([declared, anonymous]) token = jwt_with({"iss": "https://auth.example.com"}) assert registry.get("declared").accepts_token(token) - assert not registry.get("anonymous").accepts_token(token) - assert elimination_problems(["declared", "anonymous"], registry) == [] + assert registry.get("anonymous").accepts_token(token) + found = elimination_problems(["declared", "anonymous"], registry) + assert len(found) == 1, found + assert "auth.example.com" in found[0], found -def test_two_declared_token_formats_are_reported_whatever_their_issuers() -> None: - """A JWT that names no issuer is taken by either, so a token from one - authorization passes as the other's even when their services differ.""" +def test_an_anonymous_pair_sharing_an_issuer_is_reported_once() -> None: + """Neither can be told from the other by anything at all; naming the + issuer they share on top of that would say it twice.""" + one = connection(id="one") + two = connection(id="two", hosts=["api.two.example.com"]) + found = elimination_problems(["one", "two"], Registry([one, two])) + assert len(found) == 1, found + + +def test_two_declared_token_formats_from_different_services_never_collide() -> None: + """Each takes only what its own issuer named; neither reaches the other's.""" one = connection(id="one", **JWT_TOKENS) two = connection( id="two", @@ -605,13 +669,68 @@ def test_two_declared_token_formats_are_reported_whatever_their_issuers() -> Non }, **JWT_TOKENS, ) - registry = Registry([one, two]) + assert elimination_problems(["one", "two"], Registry([one, two])) == [] + + +def test_two_declared_token_formats_issued_by_one_host_are_reported() -> None: + """Both would accept a JWT that names the shared issuer, so a token from + either authorization passes as the other's.""" + api = connection(id="api", **JWT_TOKENS) + mcp = connection(id="mcp", hosts=["mcp.example.com"], **JWT_TOKENS) + found = elimination_problems(["api", "mcp"], Registry([api, mcp])) + assert [p for p in found if "api" in p and "mcp" in p] == found + assert len(found) == 1 + + +def test_an_optional_issuer_sits_beside_any_connection_that_requires_one() -> None: + """A JWT naming no issuer is taken by it alone: an anonymous connection + and one requiring an issuer both refuse it, and its own issuer host is + named by neither of theirs.""" + optional = connection(id="optional", **ISSUERLESS_JWT_TOKENS) + anonymous = connection( + id="anonymous", + hosts=["api.anonymous.example.com"], + oauth={ + "authorization_url": "https://auth.anonymous.example.com/authorize", + "token_url": "https://auth.anonymous.example.com/token", + "scopes": {"read": "Read data"}, + }, + ) + required = connection( + id="required", + hosts=["api.two.example.com"], + oauth={ + "authorization_url": "https://auth.two.example.com/authorize", + "token_url": "https://auth.two.example.com/token", + "scopes": {"read": "Read data"}, + }, + **JWT_TOKENS, + ) + registry = Registry([optional, anonymous, required]) issuerless = jwt_with({"exp": 0}) - assert registry.get("one").accepts_token(issuerless) - assert registry.get("two").accepts_token(issuerless) - found = elimination_problems(["one", "two"], registry) + assert registry.get("optional").accepts_token(issuerless) + assert not registry.get("anonymous").accepts_token(issuerless) + assert not registry.get("required").accepts_token(issuerless) + assert elimination_problems(["optional", "anonymous", "required"], registry) == [] + + +def test_two_optional_issuers_are_reported_whatever_their_services() -> None: + """A JWT that names no issuer is taken by either, so a token from one + authorization passes as the other's even when their services differ.""" + one = connection(id="one", **ISSUERLESS_JWT_TOKENS) + two = connection( + id="two", + hosts=["api.two.example.com"], + oauth={ + "authorization_url": "https://auth.two.example.com/authorize", + "token_url": "https://auth.two.example.com/token", + "scopes": {"read": "Read data"}, + }, + **ISSUERLESS_JWT_TOKENS, + ) + found = elimination_problems(["one", "two"], Registry([one, two])) assert len(found) == 1, found - assert "one, two" in found[0] and "tokens.format" in found[0], found + assert "one, two" in found[0] and "tokens.issuer" in found[0], found def test_prefixes_next_to_a_declared_token_format_are_reported() -> None: diff --git a/tests/test_connections_command.py b/tests/test_connections_command.py index 8e899bc..18e07d5 100644 --- a/tests/test_connections_command.py +++ b/tests/test_connections_command.py @@ -226,10 +226,18 @@ def test_a_declared_token_format_reads_as_the_format_it_declares() -> None: connection has to see which of the two it is.""" entry = prefixless(tokens={"format": "jwt"}) rows = connections_table(Registry([entry])) - assert rows[0]["token_prefixes"] == "(none: jwt naming this service or no issuer)" + assert rows[0]["token_prefixes"] == "(none: jwt naming this service)" assert "jwt" in format_connection(entry) +def test_an_optional_issuer_reads_as_taking_jwts_that_name_none() -> None: + """An agent may hold only one such connection, so the person preparing + it has to see that it is one.""" + entry = prefixless(tokens={"format": "jwt", "issuer": "optional"}) + rows = connections_table(Registry([entry])) + assert rows[0]["token_prefixes"] == "(none: jwt naming this service or no issuer)" + + def test_an_app_connection_is_described_by_its_app_not_an_oauth_client() -> None: """Nobody registers an OAuth client for it; what a person prepares is the App, its key, and the limit every token is narrowed to.""" diff --git a/tests/test_resolve.py b/tests/test_resolve.py index c569cee..1bfce55 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -84,7 +84,7 @@ def test_a_declared_token_format_travels_to_the_runtime( "connections": { "zendesk": { "base_url": "https://acme.zendesk.com", - "tokens": {"format": "jwt"}, + "tokens": {"format": "jwt", "issuer": "optional"}, } }, } @@ -93,6 +93,7 @@ def test_a_declared_token_format_travels_to_the_runtime( loaded = load_project(project.root / "gete.yaml") connections = resolve(loaded, loaded.agents[0])["resolved"]["connections"] zendesk = Registry.from_documents(connections).get("zendesk") + # Without the optional issuer travelling with it, this would be refused. assert zendesk.accepts_token(ISSUERLESS_JWT) assert not zendesk.accepts_token("a1b2c3d4e5f60718293a4b5c6d7e8f90") diff --git a/tests/test_schema.py b/tests/test_schema.py index 88eb54f..4d14f88 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -654,13 +654,27 @@ def test_connection_may_declare_that_its_tokens_are_jwts() -> None: ) +@pytest.mark.parametrize("issuer", ["required", "optional"]) +def test_connection_may_say_whether_its_jwts_name_an_issuer(issuer: str) -> None: + """Zendesk's JWTs name none; an installation says so, or holds every + token to naming its own service, which is the default.""" + validate_document( + "connection", + {**CONNECTION, "tokens": {"format": "jwt", "issuer": issuer}}, + source="c.yaml", + ) + + @pytest.mark.parametrize( "tokens", [ {"format": "opaque"}, # the only format that promises anything is jwt {"format": "JWT"}, {}, # a tokens block that declares nothing + # The issuer is the connection's hosts; a second list would be a + # second ceiling to keep in step with the first. {"format": "jwt", "issuer": "https://auth.example.com"}, + {"issuer": "optional"}, # whether a JWT names an issuer needs a JWT ], ) def test_connection_token_format_is_one_declared_shape(tokens: dict[str, Any]) -> None: diff --git a/tests/test_validate.py b/tests/test_validate.py index 33a7445..c432662 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -405,11 +405,12 @@ def test_a_declared_token_format_may_be_held_beside_a_prefixless_connection( assert problems(project) == [] -def test_a_declared_format_may_share_an_issuer_with_a_prefixless_connection( +def test_a_declared_format_beside_a_connection_of_the_same_issuer_is_refused( project: ProjectBuilder, ) -> None: - """The prefixless connection takes no JWT, so one authorization server in - front of both services cannot put a token in the wrong one's hands.""" + """One authorization server standing in front of both services puts its + host in either connection's tokens, and the declaration then says nothing + the other's token does not say as well.""" project.write_project( { "version": 1, @@ -429,10 +430,38 @@ def test_a_declared_format_may_share_an_issuer_with_a_prefixless_connection( project.write_agent( "mail-triage", {"connections": ["internal-api", "internal-billing"]} ) + found = problems(project) + assert any("auth.internal.example.com" in problem for problem in found), found + + +ZENDESK_ISSUERLESS: dict[str, Any] = { + "base_url": "https://acme.zendesk.com", + "tokens": {"format": "jwt", "issuer": "optional"}, +} + + +def test_zendesk_may_sit_beside_a_service_whose_jwts_name_their_issuer( + project: ProjectBuilder, +) -> None: + """Only Zendesk takes a JWT naming no issuer, and only the other takes one + naming its own authorization server, so neither token passes as the + other's.""" + project.write_project( + { + "version": 1, + "project": "example-project", + "location": "us-central1", + "connections": { + "internal-api": {**INTERNAL_API, "tokens": {"format": "jwt"}}, + "zendesk": ZENDESK_ISSUERLESS, + }, + } + ) + project.write_agent("mail-triage", {"connections": ["zendesk", "internal-api"]}) assert problems(project) == [] -def test_two_declared_formats_on_one_agent_are_reported( +def test_two_optional_issuers_on_one_agent_are_reported( project: ProjectBuilder, ) -> None: """A JWT that names no issuer is taken by either, whichever service each is.""" @@ -442,22 +471,22 @@ def test_two_declared_formats_on_one_agent_are_reported( "project": "example-project", "location": "us-central1", "connections": { - "internal-api": {**INTERNAL_API, "tokens": {"format": "jwt"}}, - "zendesk": { - "base_url": "https://acme.zendesk.com", - "tokens": {"format": "jwt"}, + "internal-api": { + **INTERNAL_API, + "tokens": {"format": "jwt", "issuer": "optional"}, }, + "zendesk": ZENDESK_ISSUERLESS, }, } ) project.write_agent("mail-triage", {"connections": ["zendesk", "internal-api"]}) found = problems(project) assert any( - "tokens.format" in p and "internal-api" in p and "zendesk" in p for p in found + "tokens.issuer" in p and "internal-api" in p and "zendesk" in p for p in found ), found -def test_zendesk_declared_as_jwt_may_be_held_beside_notion_mcp( +def test_zendesk_declared_issuerless_may_be_held_beside_notion_mcp( project: ProjectBuilder, ) -> None: """Zendesk's JWTs and Notion MCP's opaque tokens part by shape, so one @@ -467,12 +496,7 @@ def test_zendesk_declared_as_jwt_may_be_held_beside_notion_mcp( "version": 1, "project": "example-project", "location": "us-central1", - "connections": { - "zendesk": { - "base_url": "https://acme.zendesk.com", - "tokens": {"format": "jwt"}, - }, - }, + "connections": {"zendesk": ZENDESK_ISSUERLESS}, } ) project.write_agent("mail-triage", {"connections": ["zendesk", "notion-mcp"]}) From 5d0d6f355e6a662c1fd1d9f99984c4e3a27f0067 Mon Sep 17 00:00:00 2001 From: haruotsu <65439874+haruotsu@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:10:13 +0900 Subject: [PATCH 6/6] Name the declaration that parts two anonymous connections in validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/gete/connection/checks.py | 4 +++- tests/test_connection_registry.py | 23 +++++++++++++++++++---- tests/test_validate.py | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/gete/connection/checks.py b/src/gete/connection/checks.py index a87e84d..18e11e7 100644 --- a/src/gete/connection/checks.py +++ b/src/gete/connection/checks.py @@ -97,7 +97,9 @@ def elimination_problems( f"{', '.join(connection.id for connection in anonymous)} declare " "neither token_prefixes nor tokens.format; only one of an agent's " "connections may accept tokens by elimination, or a token from one " - "of them would be accepted as another's" + "of them would be accepted as another's. Declare tokens.format: jwt " + "on one whose tokens are JWTs, with issuer: optional if they never " + "name an issuer" ) issuerless = [ connection for connection in connections if _takes_issuerless(connection) diff --git a/tests/test_connection_registry.py b/tests/test_connection_registry.py index 65c1f9c..392f618 100644 --- a/tests/test_connection_registry.py +++ b/tests/test_connection_registry.py @@ -599,6 +599,18 @@ def test_two_prefixless_connections_are_reported_as_one_pairing() -> None: assert len(found) == 1 +def test_two_prefixless_connections_are_told_what_declaration_parts_them() -> None: + """A provider whose tokens are JWTs leaves elimination by declaring the + format, and one whose JWTs name no issuer needs the optional issuer + beside it; the report names both, or an operator adding the format alone + would see every token refused at runtime before learning of the second.""" + freee = connection(id="freee", token_prefixes=[]) + internal = connection(id="internal", token_prefixes=[]) + found = elimination_problems(["internal", "freee"], Registry([freee, internal])) + assert "tokens.format: jwt" in found[0], found + assert "issuer: optional" in found[0], found + + def test_a_prefixless_connection_is_no_problem_on_its_own() -> None: """The registry holds all of them; only what one agent holds can collide.""" freee = connection(id="freee", token_prefixes=[]) @@ -740,11 +752,14 @@ def test_prefixes_next_to_a_declared_token_format_are_reported() -> None: assert any("tokens" in problem for problem in problems), problems -def test_a_connection_that_accepts_google_issued_jwts_is_reported() -> None: +@pytest.mark.parametrize("tokens", [{}, JWT_TOKENS]) +def test_a_connection_that_accepts_google_issued_jwts_is_reported( + tokens: dict[str, Any], +) -> None: """Naming Google's authorization server as one's own host would let ID - tokens through the issuer match that a declared format makes; the checks - must catch the declaration.""" - entry = connection(hosts=["accounts.google.com"], **JWT_TOKENS) + tokens through the issuer match, which elimination and a declared format + both make; the checks must catch the declaration either way.""" + entry = connection(hosts=["accounts.google.com"], **tokens) assert any( "Google-issued" in problem for problem in connection_problems(entry, Registry([entry])) diff --git a/tests/test_validate.py b/tests/test_validate.py index c432662..5eb0d05 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -386,7 +386,7 @@ def test_a_declared_token_format_may_be_held_beside_a_prefixless_connection( project: ProjectBuilder, ) -> None: """An agent that reads one service and writes to another needs both, and - a connection held to JWTs takes nothing the anonymous one does.""" + a service whose tokens name their issuer is not the anonymous one.""" project.write_project( { "version": 1,