Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +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
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.
Expand Down Expand Up @@ -366,25 +368,51 @@ 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 issued by acme.zendesk.com, nothing else
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. 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.
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. 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 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
Expand Down
49 changes: 30 additions & 19 deletions src/gete/catalog/connections/zendesk.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ 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. 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:
Expand Down Expand Up @@ -46,18 +45,30 @@ 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. 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:

# 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.
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
# 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
Expand Down
35 changes: 32 additions & 3 deletions src/gete/connection/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from gete.connection.registry import (
GOOGLE_ACCESS_TOKEN_PREFIX,
JWT_FORMAT,
Connection,
OAuth,
Registry,
Expand Down Expand Up @@ -41,7 +42,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(
Expand All @@ -65,6 +70,11 @@ def elimination_problems(
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
either's. A service that is run in more than one place - hosted by its
Expand All @@ -87,7 +97,19 @@ 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)
]
if len(issuerless) >= 2:
problems.append(
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
Expand Down Expand Up @@ -128,6 +150,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.

Expand Down Expand Up @@ -202,7 +229,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.
Expand Down
47 changes: 29 additions & 18 deletions src/gete/connection/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,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
Expand Down Expand Up @@ -220,6 +225,10 @@ class Connection:
# 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
Expand Down Expand Up @@ -267,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"),
Expand Down Expand Up @@ -328,47 +338,48 @@ def accepts_token(self, token: str) -> bool:
issues ya29.c.<payload> 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.
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
if self.token_format is not None:
# 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):
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)
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:
def _issued_here_jwt(self, token: str, *, issuerless: bool) -> bool:
"""Whether the token is a JWT this connection's own service issued.

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.
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.<payload> 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:
return False
issuer = claims.get("iss")
if "iss" not in claims:
return issuerless
issuer = claims["iss"]
return isinstance(issuer, str) and self.issued_here(issuer)

@property
Expand Down
Loading
Loading