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
2 changes: 1 addition & 1 deletion agent-gateway/cmd/agent-gateway/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ func executeServe(command *cobra.Command, dataDir, authority string, allowedHost
}
runtime, err := newComposition(composition.Options{
Ownership: ownership, TrafficBudget: budget,
Store: store, InstallationID: identity.InstallationID, CallbackURL: "http://" + httpboundary.LocalAuthority(authority) + "/oauth/callback",
Store: store, InstallationID: identity.InstallationID, CallbackURL: origin + "/oauth/callback",
Clock: dependencies.clock, Entropy: dependencies.entropy, Invalidate: eventHub.Publish, Ready: ready.Load,
Diagnostics: dependencies.diagnostics,
DiagnosticProcessID: dependencies.diagnostics.ProcessID(),
Expand Down
6 changes: 4 additions & 2 deletions agent-gateway/docs/design/downstream-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ The registered-resource origin and explicitly trusted origins may resolve to res

A static registration is local and binds the exact graph issuer/resource, configured client ID, exact callback URI, and an explicitly metadata-supported token endpoint authentication method. Dynamic registration requires the advertised role endpoint and no reusable exact unexpired authority.

It sends one unauthenticated bounded RFC 7591 native-client JSON request with the exact callback, code response, code/refresh grants, and one method selected Basic → Post → None; it never probes or retries. Publication accepts only bounded extensible JSON at `201` whose client ID, sole callback, response/grant support, returned method, and secret/expiry shape match.
It sends one unauthenticated bounded RFC 7591 JSON request with application type `web` for the configured external HTTPS callback or `native` for local HTTP loopback, the exact callback, code response, code/refresh grants, and one method selected Basic → Post → None; it never probes or retries. Provider-specific registration acceptance remains outside this protocol support. Publication accepts only bounded extensible JSON at `201` whose client ID, sole callback, response/grant support, returned method, and secret/expiry shape match.

Confidential results require a nonempty secret; an omitted expiry or expiry `0` means non-expiring, while any nonzero expiry must be strictly future. They atomically publish the registration row, `oauth_client` credential revision/handle, keyring authority, and verified candidate generation in one marker-armed transaction.

Expand All @@ -200,7 +200,9 @@ Stale desired/registration/client/flow or drain results are orphan cleanup only;

Foreground flow creation first commits one safe `preparing` record under the global-16/per-server-one bound, then performs discovery/registration outside SQLite admission. Independent 32-byte state and verifier material, S256 challenge, exact graph/registration/revision binding, endpoint snapshot, and sorted requested scopes remain in one process-local registry.

Optional `callback_uri` selects one exact HTTP `localhost` or canonical numeric-loopback URI with an explicit nonzero port and canonical path. Query, fragment, userinfo, wildcard/remote/alias hosts, encoded paths, and ambiguous spellings are rejected. `localhost` binds numeric `127.0.0.1`, never a resolved or wildcard address. Omission retains the main numeric-loopback `/oauth/callback`; that exact URI reuses the existing listener. Registration, authorization, and exchange retain identical redirect bytes.
With a configured external HTTPS origin, omission of `callback_uri` selects that exact origin plus `/oauth/callback`. Explicit remote overrides must equal those bytes, including scheme, authority, port and path; they reuse the main Gateway listener and never acquire a public listener. The main HTTP boundary admits only the configured external Host for callbacks, rejects encoded paths and bodies, and rejects forwarding headers rather than deriving authority from them. API create/transport replacement validates selection against the configured origin; flow preparation rechecks before discovery/registration so persisted configuration cannot bypass a changed serving origin. Registration, authorization and token exchange retain the same exact redirect bytes.

Without external origin, optional `callback_uri` selects one exact HTTP `localhost` or canonical numeric-loopback URI with an explicit nonzero port and canonical path. Query, fragment, userinfo, wildcard/remote/alias hosts, encoded paths, and ambiguous spellings are rejected. `localhost` binds numeric `127.0.0.1`, never a resolved or wildcard address. Omission retains the main numeric-loopback `/oauth/callback`; that exact URI reuses the existing listener. Registration, authorization, and exchange retain identical redirect bytes.

Composition owns temporary callback-only listener acquisition before URL publication. A conflicting port fails without fallback. Each listener admits only its exact method, path, and authority, rejects forwarding headers and request bodies, and delegates to the shared one-time state/PKCE/issuer/fence/token owner. State presented on the wrong callback or another flow's listener is not consumed. The existing global flow bound limits outstanding listener owners and accepted connections, while the independent callback-work bound limits exchanges. Header/body/write deadlines and shutdown are finite. No MCP, health, administration, assets, redirects, main allowed-host, or alternate browser-origin authority is added. Terminal, cancellation, expiry, supersession, shutdown, and late preparation paths release owned listeners; drain waits for their connections outside unrelated locks.

Expand Down
10 changes: 9 additions & 1 deletion agent-gateway/docs/operators/remote-serving.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,17 @@ Replace the example with your canonical public HTTPS origin: lowercase DNS hostn

This contract needs no vendor-specific headers or trusted-proxy CIDR discovery. The configured origin automatically permits its exact public Host and is the only trusted browser Origin. Session cookies are Secure, HttpOnly, host-only and SameSite=Strict; CSRF and separate administrator/agent credentials remain mandatory. Repeatable `--allowed-host internal.example` permits authenticated direct MCP/API access at internal aliases without granting browser Origin trust. Forwarding headers cannot add aliases.

## Remote browser OAuth

With `--external-origin https://gateway.example`, upstream OAuth defaults to the exact redirect `https://gateway.example/oauth/callback`. Route that path through the same proxy and main Gateway listener; no additional public callback listener or SSH tunnel is needed. Registration, browser authorization and token exchange use identical redirect bytes. An explicit per-server `callback_uri` in remote mode must equal this configured origin plus `/oauth/callback`, including a nondefault port; different hosts, ports, paths and local-loopback overrides are rejected. Forwarding headers never select callback authority.

Register that exact URI with your provider when using a configured client. Dynamic registration advertises a `web` application for HTTPS callbacks (`native` remains the local-loopback behavior); provider support for registration, redirect policy and token authentication still varies. Public HTTPS callback support is not universal provider compatibility. Start authorization from the Servers UI, follow the provider page, then return to Gateway to inspect the flow. A canceled, expired, completed or interrupted flow cannot be replayed; after restart start a new flow rather than revisiting the old page. See [upstream OAuth configuration](upstream-servers.md#oauth-compatibility-settings).

Keep proxy callback URLs/query strings out of access logs and error pages: they carry one-time authorization material. Disable callback caching and retries. Gateway's callback responses are fixed and nonreflecting; its HTTPS issuer/token endpoint and upstream address policies remain unchanged. Source fixtures use an isolated HTTPS provider and reverse proxy, not a real provider, installed proxy or container qualification.

## Local access and protocol separation

For wildcard binds, local health probes use `http://127.0.0.1:8210/livez` or `/readyz`, and a local CLI (including one invoked through `docker exec` in an operator-managed container) uses the same loopback endpoint and protected administrator bearer file. Use `--address http://127.0.0.1:<port>` for a custom port. This narrow internal access accepts no Origin or Cookie header and cannot sign in or serve the browser. A specific non-loopback bind does not also listen on loopback. Remote HTTPS CLI and external OAuth callbacks are not delivered by this configuration.
For wildcard binds, local health probes use `http://127.0.0.1:8210/livez` or `/readyz`, and a local CLI (including one invoked through `docker exec` in an operator-managed container) uses the same loopback endpoint and protected administrator bearer file. Use `--address http://127.0.0.1:<port>` for a custom port. This narrow internal access accepts no Origin or Cookie header and cannot sign in or serve the browser. A specific non-loopback bind does not also listen on loopback. Remote HTTPS CLI is not delivered by this configuration; local CLI through `docker exec` remains sufficient.

HTTP/Git forwarding is a **separate direct listener**, not a website route. Enable it explicitly with e.g. `--http-proxy-listen 0.0.0.0:8212` instead of the opt-out above, after [client/CA setup](http-proxy.md); protect the unencrypted client-to-proxy hop. Wildcard listeners conservatively reserve their ports against outgoing proxy requests. Container image/runtime qualification and live Debian/proxy adoption require separate evidence; source tests do not qualify them.

Expand Down
8 changes: 5 additions & 3 deletions agent-gateway/docs/operators/upstream-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ These headers apply only to this server's MCP requests (all protocol modes, init

Server create files, complete transport replacements in server update files, and the web form's **Advanced OAuth settings** accept three optional fields under `transport.authentication`:

- `callback_uri`: the exact HTTP loopback redirect registered with the provider, including explicit port and canonical path. `localhost`, canonical IPv4 loopback, and `[::1]` are accepted; remote names, wildcard addresses, credentials, query, fragment, encoded paths, and ambiguous spellings are rejected. Omission uses the main numeric-loopback `/oauth/callback` listener.
- `callback_uri`: omit for Gateway's default `/oauth/callback`. With `--external-origin`, the default uses that exact HTTPS origin, and an explicit override must equal that origin plus `/oauth/callback` byte-for-byte. No other host, port, path or loopback override is accepted in remote mode. Without external origin, supported overrides remain exact HTTP loopback redirects with an explicit port and canonical path: `localhost`, canonical IPv4 loopback, and `[::1]`. Credentials, query, fragment, encoded paths, wildcard addresses and ambiguous spellings are rejected. Configuration saves check the current serving origin, and flow creation rechecks it before provider work (including after a serving-origin change).
- `auth_server_metadata_url`: an exact canonical HTTPS metadata location with a path and optional bounded query. This changes the fetch location for authorization and refresh, never issuer identity or protected-resource authority. Invalid metadata fails without fallback. Existing TLS, address, trusted-origin, no-redirect, size, and deadline controls still apply.
- `scopes`: initial scope tokens replacing resource-metadata defaults. Tokens are deduplicated and sorted, bounded to 64 entries, 256 bytes each, and 8192 bytes including separators. `[]` explicitly sends an empty scope value; omission uses metadata defaults. `request_offline_access` separately adds `offline_access` only when advertised. Token responses may narrow, never expand the requested set; later expansion requires a new foreground authorization, without tool-call replay.

Expand Down Expand Up @@ -149,7 +149,9 @@ This disabled synthetic example illustrates all three settings together; its met

Save as a JSON file and use `agent-gateway mcp server create --file PATH`. For updates, put the complete `transport` object under `transport` in the patch file and use `agent-gateway mcp server update SERVER_ID --file PATH --yes`.

Registration, browser authorization, and code exchange use identical redirect bytes. Gateway acquires a separate numeric-loopback, callback-only listener before exposing the authorization URL. A port collision—including another Broker or flow—fails without random-port fallback: stop the conflicting listener and start a new flow. The default callback URI reuses the main callback route. Additional listeners never expose MCP, administration, health, or assets, and main `--allowed-host` configuration neither registers nor admits them. Listeners close on terminal state, cancellation, expiry, supersession, shutdown, and rejected late preparation. Configuration changes fence stale flows and incompatible token authority. Restart never resumes listeners or authorization pages.
Registration, browser authorization, and code exchange use identical redirect bytes. HTTPS callbacks use the existing main listener through the [remote-serving proxy contract](remote-serving.md#remote-browser-oauth), never a new public listener. Dynamic registration sends application type `web` for HTTPS and `native` for HTTP loopback; a provider may still require manual client registration or impose additional redirect/client-authentication restrictions. This support does not qualify any particular provider.

For a nondefault local callback, Gateway acquires a separate numeric-loopback, callback-only listener before exposing the authorization URL. A port collision—including another Broker or flow—fails without random-port fallback: stop the conflicting listener and start a new flow. The default callback URI reuses the main callback route. Additional listeners never expose MCP, administration, health, or assets, and main `--allowed-host` configuration neither registers nor admits them. Listeners close on terminal state, cancellation, expiry, supersession, shutdown, and rejected late preparation. Configuration changes fence stale flows and incompatible token authority. Restart never resumes listeners or authorization pages.

Deterministic fixtures are not live Slack qualification. Use actual provider configuration and credentials only under separately authorized live testing.

Expand Down Expand Up @@ -214,7 +216,7 @@ agent-gateway mcp server auth-flow start SERVER_ID --etag ETAG --open

Flow start requires a prepared controlling terminal in human and JSON modes. It publishes the one-time authorization URL only to that sink; safe metadata goes to ordinary output. `--open` opens the validated URL explicitly without a referrer. Gateway does not retain or reconstruct a lost URL and never retries flow creation automatically.

The OAuth callback remains Gateway-owned, on its main origin by default or the configured callback-only loopback listener, never the frontend development origin. Flow state is process-local where sensitive and bounded where durable; restart interrupts nonterminal work. To cancel an eligible flow after reading its current state:
The OAuth callback remains Gateway-owned, on its configured external HTTPS origin or local main origin by default, or the configured callback-only loopback listener in local mode, never the frontend development origin. Flow state is process-local where sensitive and bounded where durable; restart interrupts nonterminal work. To cancel an eligible flow after reading its current state:

```bash
agent-gateway mcp server auth-flow cancel SERVER_ID FLOW_ID
Expand Down
32 changes: 32 additions & 0 deletions agent-gateway/internal/api/remote_callback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package api

import (
"testing"

"github.com/averycrespi/agent-tools/agent-gateway/internal/contract"
"github.com/stretchr/testify/require"
)

func TestConfiguredCallbackSelection(t *testing.T) {
for _, test := range []struct {
origin, callback string
valid bool
}{
{"https://gateway.example:8443", "https://gateway.example:8443/oauth/callback", true},
{"https://gateway.example:8443", "https://gateway.example/oauth/callback", false},
{"https://gateway.example:8443", "https://other.example:8443/oauth/callback", false},
{"https://gateway.example:8443", "https://gateway.example:8443/other", false},
{"https://gateway.example:8443", "http://localhost:3118/callback", false},
{"http://127.0.0.1:8210", "https://gateway.example:8443/oauth/callback", false},
{"http://127.0.0.1:8210", "http://localhost:3118/callback", true},
} {
handler := New(Options{Origin: test.origin})
transport := contract.StreamableHTTPTransport{Authentication: contract.OAuthAuthentication{CallbackURI: &test.callback}}
err := handler.validateCallbackSelection(transport)
if test.valid {
require.NoError(t, err)
} else {
require.Error(t, err)
}
}
}
20 changes: 20 additions & 0 deletions agent-gateway/internal/api/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ type ServerService interface {
ListOperations(context.Context, string, *serverdomain.SnapshotCursor, int) (serverdomain.OperationPage, error)
}

func (handler *Handler) validateCallbackSelection(transport contract.Transport) error {
httpTransport, ok := transport.(contract.StreamableHTTPTransport)
if !ok {
return nil
}
authentication, ok := httpTransport.Authentication.(contract.OAuthAuthentication)
if ok && authentication.CallbackURI != nil && !contract.ValidOAuthCallbackSelection(handler.origin+"/oauth/callback", *authentication.CallbackURI) {
return serverdomain.NewConfigurationError(contract.ServerConfigurationFieldCallbackURI, contract.ServerConfigurationRuleCanonicalURL)
}
return nil
}

type OperationStateProvider func(context.Context, string) serverdomain.OperationTriggerState

type rawServerCreate struct {
Expand Down Expand Up @@ -100,6 +112,10 @@ func (handler *Handler) createServer(writer http.ResponseWriter, request *http.R
writeServerConfigurationError(writer, err)
return
}
if err := handler.validateCallbackSelection(transport); err != nil {
writeServerConfigurationError(writer, err)
return
}
key, ok := idempotencyKey(writer, request)
if !ok {
return
Expand Down Expand Up @@ -178,6 +194,10 @@ func (handler *Handler) patchServer(writer http.ResponseWriter, request *http.Re
writeServerConfigurationError(writer, err)
return
}
if err := handler.validateCallbackSelection(transport); err != nil {
writeServerConfigurationError(writer, err)
return
}
patch.Transport = transport
}
result, err := handler.servers.Patch(request.Context(), serverID, revision, patch)
Expand Down
2 changes: 1 addition & 1 deletion agent-gateway/internal/api/static/app.js

Large diffs are not rendered by default.

Loading
Loading