Description
When the Hub runs behind Identity-Aware Proxy with transport auth (SCION_TRANSPORT_MODE=iap), an agent's port-forward tunnel never connects. The tunnel websocket is dialed with only the agent token and no transport token, so IAP rejects the upgrade before the request reaches the Hub. The agent then retries every 5 seconds for its whole lifetime and logs an error on every attempt.
The agent's other Hub traffic works in the same setup: heartbeat and status updates send the transport token and succeed, and terminal attach (scion attach) works too. Only the tunnel is affected, so port forwarding and web previews of agent-served ports do not work behind IAP.
Steps to Reproduce
- Deploy a hosted Hub behind IAP with
server.auth.transport mode iap (for example on Cloud Run), with the embedded runtime broker dispatching agents to the kubernetes runtime.
- Create any agent. It receives
SCION_TRANSPORT_MODE=iap and SCION_TRANSPORT_TOKEN, and IAP accepts the token's audience: heartbeats and status updates reach the Hub.
- Read the agent's log.
Expected Behavior
The port-forward tunnel (/api/v1/agents/{id}/ports/tunnel) connects through IAP like the agent's other Hub calls, and exposed ports can be opened through the Hub.
Actual Behavior
The agent log repeats about every 5 seconds, indefinitely:
[sciontool] ERROR: Port-forward tunnel disconnected: websocket: bad handshake
The Hub never logs the tunnel request, because IAP refuses the upgrade first. Heartbeat, status and terminal attach keep working.
Root Cause
pkg/sciontool/portforward/tunnel.go, Manager.runOnce (lines 82-84 at v0.3.0-preview.3; unchanged on main):
header := http.Header{}
header.Set("X-Scion-Agent-Token", m.client.AuthToken())
conn, _, err := websocket.DefaultDialer.DialContext(ctx, endpoint, header)
The dial carries only the agent token. The sciontool Hub client adds the transport token to its HTTP calls through the round-tripper that configureOIDCTransport installs in pkg/sciontool/hub/oidc.go (transportauth.Wrap(..., transportauth.HeaderAuthorization), lines 55 and 75). A websocket dial with websocket.DefaultDialer does not go through that round-tripper, so the token is never attached.
The other websocket dialers already handle this with transportauth.ApplyHeaders:
Suggested Fix
- Let
pkg/sciontool/hub.Client apply its transport token to a header. For example, add an ApplyTransportHeaders(h http.Header) error method that calls transportauth.ApplyHeaders with the client's oidcSource and header mode, and does nothing when oidcSource is nil.
- Call it in
runOnce before the dial:
header := http.Header{}
header.Set("X-Scion-Agent-Token", m.client.AuthToken())
if err := m.client.ApplyTransportHeaders(header); err != nil {
return err
}
conn, _, err := websocket.DefaultDialer.DialContext(ctx, endpoint, header)
The client already refreshes injected transport tokens (adjustRefreshForTransportTokens), so each reconnect picks up a valid token.
Test idea: a tunnel test against a Hub stub that requires Authorization: Bearer <transport token> on the upgrade and returns 401 without it. Today that test reproduces the failure.
Nice to have: back off for longer after repeated 401/403 handshake failures (reconnectDelay is a fixed 5 s today), so that a misconfigured deployment does not log an error every 5 seconds forever.
Related
Environment
- Scion version: v0.3.0-preview.3; the code above is unchanged on
main as of 2026-09-25
- OS/Platform: Linux (amd64); Hub on Cloud Run behind IAP, agents on GKE
- Runtime: Kubernetes
- Harness: Claude
Description
When the Hub runs behind Identity-Aware Proxy with transport auth (
SCION_TRANSPORT_MODE=iap), an agent's port-forward tunnel never connects. The tunnel websocket is dialed with only the agent token and no transport token, so IAP rejects the upgrade before the request reaches the Hub. The agent then retries every 5 seconds for its whole lifetime and logs an error on every attempt.The agent's other Hub traffic works in the same setup: heartbeat and status updates send the transport token and succeed, and terminal attach (
scion attach) works too. Only the tunnel is affected, so port forwarding and web previews of agent-served ports do not work behind IAP.Steps to Reproduce
server.auth.transportmodeiap(for example on Cloud Run), with the embedded runtime broker dispatching agents to the kubernetes runtime.SCION_TRANSPORT_MODE=iapandSCION_TRANSPORT_TOKEN, and IAP accepts the token's audience: heartbeats and status updates reach the Hub.Expected Behavior
The port-forward tunnel (
/api/v1/agents/{id}/ports/tunnel) connects through IAP like the agent's other Hub calls, and exposed ports can be opened through the Hub.Actual Behavior
The agent log repeats about every 5 seconds, indefinitely:
The Hub never logs the tunnel request, because IAP refuses the upgrade first. Heartbeat, status and terminal attach keep working.
Root Cause
pkg/sciontool/portforward/tunnel.go,Manager.runOnce(lines 82-84 at v0.3.0-preview.3; unchanged onmain):The dial carries only the agent token. The sciontool Hub client adds the transport token to its HTTP calls through the round-tripper that
configureOIDCTransportinstalls inpkg/sciontool/hub/oidc.go(transportauth.Wrap(..., transportauth.HeaderAuthorization), lines 55 and 75). A websocket dial withwebsocket.DefaultDialerdoes not go through that round-tripper, so the token is never attached.The other websocket dialers already handle this with
transportauth.ApplyHeaders:pkg/runtimebroker/controlchannel.go(lines 331 and 366), added in feat: broker OIDC transport auth for IAP-protected hubs (Phase 3) #791pkg/wsclient/pty.go(line 103)Suggested Fix
pkg/sciontool/hub.Clientapply its transport token to a header. For example, add anApplyTransportHeaders(h http.Header) errormethod that callstransportauth.ApplyHeaderswith the client'soidcSourceand header mode, and does nothing whenoidcSourceis nil.runOncebefore the dial:The client already refreshes injected transport tokens (
adjustRefreshForTransportTokens), so each reconnect picks up a valid token.Test idea: a tunnel test against a Hub stub that requires
Authorization: Bearer <transport token>on the upgrade and returns 401 without it. Today that test reproduces the failure.Nice to have: back off for longer after repeated 401/403 handshake failures (
reconnectDelayis a fixed 5 s today), so that a misconfigured deployment does not log an error every 5 seconds forever.Related
scion attachunder proxy-auth (IAP) mode. A related IAP/websocket path, already fixed.Environment
mainas of 2026-09-25