Skip to content

DevTunnelWeb private connect sends GitHub token to relay, causing 401 with empty body on /data/get #1293

Description

@JoshuaRowePhantom

Summary

When the app starts with dataAccess.mode = "devTunnelWeb", devTunnel.tunnelName = "phantom-workspaces-playspace", devTunnel.accessMode = "private", and no webEndpoint, the first data-access call fails with:

Failed to connect: Web data access call to '/data/get' failed with 401:

(note the trailing 401: — the response body is empty.)

The 401 is emitted by the Microsoft dev tunnels relay (*.devtunnels.ms), not by the Workspaces server. The client is sending a GitHub OAuth identity token as X-Tunnel-Authorization: tunnel <github-token>, which the relay does not accept. The relay only honors a Microsoft-issued Connect-scope tunnel access token.

A separate issue tracks that this specific failure isn't logged before the app dies; this issue is about the 401 root cause.

Root Cause

For DataAccessMode.DevTunnelWeb with a tunnelName set and no webEndpoint, WorkspacesConfiguration.ToRepositorySource() (Phantom.Workspaces/Configuration/WorkspacesConfiguration.cs, ~lines 224-231) returns a DevTunnelNameRepositorySourcenot a WebRepositorySource. UseGitHubAuthToken = true is set only on the WebRepositorySource branch (~line 225), so on the DevTunnelName path UseGitHubAuthToken == false (a red herring — this flag never gates the buggy behavior).

EntityRepository.CreateDevTunnelNameDataAccessLayerAsync (Phantom.Workspaces/EntityRepository.cs, ~lines 83-91 dispatch, ~lines 119-154 impl) resolves an endpoint via DevTunnelEndpointResolver.ResolveAsync, then constructs the request authorization via DevTunnelClientAuthorization.Resolve (~lines 140-148):

var authorization = Services.DevTunnel.DevTunnelClientAuthorization.Resolve(
    resolution,
    repositorySource.AccessMode,
    () => Phantom.Workspaces.Llm.GitHubAuthTokenResolver.Resolve());
return new WebClientDataAccessLayer(
    resolution.BaseUri.ToString(),
    authorization.Token,
    authorization.RefreshResolver);

The bug site is Phantom.Workspaces/Services/DevTunnel/DevTunnelClientAuthorization.cs (~lines 15-38). The logic is:

  1. If resolution.TunnelAuthToken (a Microsoft Connect-scope token from the Management API) is present → use it. ✅
  2. Else if accessMode == Anonymous → send no header. ✅
  3. Else (Private + no Connect token) → line ~37 falls back to sending the raw GitHub identity token verbatim as X-Tunnel-Authorization: tunnel <github-oauth-token>. ❌

The dev tunnels relay does not accept GitHub OAuth tokens on X-Tunnel-Authorization; it only accepts a Microsoft tunnel Connect/Host access token. The relay responds 401 Unauthorized with an empty body — the /data/get ASP.NET endpoint has no auth middleware and cannot itself emit 401 (confirmed in Phantom.Workspaces.Data.Web.Server/WebDataAccessEndpointRouteBuilderExtensions.cs, which only returns 200/400).

Why the Connect token is missing

DevTunnelEndpointResolver (Phantom.Workspaces/Services/DevTunnel/DevTunnelEndpointResolver.cs, ~lines 23-60, esp. 47-58) reads lookup.ConnectToken for Private access. DevTunnelManagementClientWrapper.LookupByNameAsync / DiscoverSingleAsync (Phantom.Workspaces/Services/DevTunnel/DevTunnelManagementClientWrapper.cs, ~lines 230-284) request TokenScopes = [TunnelAccessScopes.Connect] (see CreateConnectRequestOptions ~lines 317-324), list tunnels with ownedTunnels = true, and read tunnel.AccessTokens[TunnelAccessScopes.Connect] into ConnectToken (see ToLookupResult ~lines 272-284).

So a valid Connect token is only minted when:

  • (a) the caller's GitHub identity owns the tunnel, and
  • (b) the tunnel carries both the Workspaces marker label and the exact phantom-workspaces-playspace name label (see HasLabel filter ~line 236).

If any of these fail (or the Management API returns no AccessTokens[Connect]), resolution.TunnelAuthToken is null and the bad GitHub-identity fallback in DevTunnelClientAuthorization.cs:~37 kicks in. Note also that DevTunnelServiceFactory (Phantom.Workspaces/Services/DevTunnel/DevTunnelServiceFactory.cs, ~lines 42-56) authenticates the Management API with TunnelAuthenticationSchemes.GitHub — valid for tunnels.api.visualstudio.com (management) but not for the *.devtunnels.ms relay. Reusing the same token for both is exactly what the fallback does, and it doesn't work.

Where the 401 surfaces

Phantom.Workspaces.Data.Web.Client/WebClientDataAccessLayer.cs:

  • ~lines 42-46: adds X-Tunnel-Authorization: tunnel <token>.
  • ~lines 104-112: retry-once-on-401 via RefreshResolver.
  • ~lines 136-141: on final failure, throws WebDataAccessRequestException with the message ... failed with {status}: {body}. Empty body ⇒ the trailing 401: observed.

Related background

A separate known issue tracks that tunnelName is stored as a label on the tunnel (not Tunnel.Name), and that existing tunnels' labels must not be relabeled. Label / ownership mismatch is a plausible reason no Connect token is minted here. The fix for this bug should coordinate with that issue but must not prescribe relabeling existing tunnels.

Affected Files

File Lines Role
Phantom.Workspaces/Services/DevTunnel/DevTunnelClientAuthorization.cs ~15-38 (esp. ~37) Bug site. Falls back to sending the GitHub identity token as tunnel authorization.
Phantom.Workspaces/EntityRepository.cs ~83-91, ~119-154 Dispatches DevTunnelNameRepositorySource and wires the authorization into WebClientDataAccessLayer.
Phantom.Workspaces/Configuration/WorkspacesConfiguration.cs ~27-42, ~224-231 Projects config → DevTunnelNameRepositorySource; DevTunnelAccessMode enum.
Phantom.Workspaces/RepositorySource.cs ~33-35 DevTunnelNameRepositorySource record.
Phantom.Workspaces/Services/DevTunnel/DevTunnelEndpointResolver.cs ~23-60 (esp. 47-58) Produces TunnelAuthToken from lookup.ConnectToken.
Phantom.Workspaces/Services/DevTunnel/DevTunnelManagementClientWrapper.cs ~230-284, ~317-324 Requests TokenScopes=[Connect], reads tunnel.AccessTokens[Connect].
Phantom.Workspaces/Services/DevTunnel/DevTunnelServiceFactory.cs ~42-56 Authenticates Management API with TunnelAuthenticationSchemes.GitHub.
Phantom.Workspaces.Data.Web.Client/WebClientDataAccessLayer.cs ~42-46, ~104-112, ~136-141 Applies X-Tunnel-Authorization, does retry-on-401, surfaces the empty-body error.
Phantom.Workspaces.Data.Web.Server/WebDataAccessEndpointRouteBuilderExtensions.cs (all) Confirms server never emits 401 — so the 401 is from the relay.

Design / Fix

Present as options; the maintainer will choose.

Option A (preferred): remove the GitHub-token fallback for the relay.

  • For Private access, require a valid Microsoft Connect-scope token from the Management API before attempting the data-access call.
  • If none can be minted (tunnel not found / not owned / label mismatch), fail fast with a clear, actionable error naming the tunnel and label and how to fix it. Do not fall back to sending a GitHub token to the relay.
  • Concretely, remove or gate the identity-token fallback at DevTunnelClientAuthorization.cs:~37.

Option B: fix Connect-token retrieval.

  • Investigate whether Connect-token retrieval is failing due to label / ownership mismatch. If the lookup should have found the tunnel but didn't mint a Connect token, fix the token request so AccessTokens[Connect] is populated — without relabeling existing tunnels (coordinate with the related label/ownership issue).

Cross-cutting:

  • Ensure the retry-on-401 refresh path (WebClientDataAccessLayer.cs:~104-112) re-resolves a real Connect token, not the GitHub token.
  • Improve the surfaced error: a 401 with empty body from a *.devtunnels.ms host should be translated to something like:

    Dev tunnel rejected the connection (401). No valid connect token was obtained for private tunnel '<name>'.

Expected Tests

Naming convention in existing dev-tunnel tests is Method_Scenario_ExpectedOutcome (PascalCase). See DevTunnelClientAuthorizationTests.cs (Resolve_PrivateMode_WithExplicitConnectToken_UsesTokenVerbatim_NoIdentityFallback, etc.) and WebClientDataAccessLayerTests.cs (GetAsync_On401_WithTokenResolver_RefreshesTokenAndRetries, etc.).

Test Name Class What It Verifies
Resolve_PrivateMode_WithNoConnectToken_DoesNotSendGitHubIdentityToken DevTunnelClientAuthorizationTests Private + no TunnelAuthToken must not fall back to emitting the GitHub OAuth token as X-Tunnel-Authorization.
Resolve_PrivateMode_WithConnectTokenPresent_UsesConnectTokenVerbatim DevTunnelClientAuthorizationTests When resolution.TunnelAuthToken is non-null, it is used verbatim as the tunnel authorization.
Resolve_PrivateMode_WithNoConnectToken_ThrowsActionableError DevTunnelClientAuthorizationTests Per Option A, missing Connect token for a Private tunnel produces a clear error naming the tunnel and label, not a silent GitHub-token fallback.
Resolve_AnonymousMode_WithNoConnectToken_SendsNoAuthorization DevTunnelClientAuthorizationTests Regression guard for the existing Anonymous branch.
CreateDevTunnelNameDataAccessLayerAsync_WhenConnectTokenUnavailable_FailsFastWithoutRelayCall EntityRepositoryTests (or existing DevTunnel dispatch tests) The dispatch path does not attempt to hit *.devtunnels.ms when no Connect token can be minted.
GetAsync_WhenRelayReturns401WithEmptyBody_SurfacesActionableMessage WebClientDataAccessLayerTests An empty-body 401 from a *.devtunnels.ms host produces a descriptive error mentioning the dev tunnel and missing connect token, not a bare ... failed with 401:.
WorkspacesConfiguration_ToRepositorySource_DevTunnelWebWithTunnelNameAndNoWebEndpoint_ReturnsDevTunnelNameRepositorySource DevTunnelConfigurationTests Regression guard clarifying that UseGitHubAuthToken is not set on this path (documents the red herring).

Metadata

Metadata

Labels

bugSomething isn't workingdiagnosedRoot cause identifiedneeds-slow-testsRequires full test suite including slow Git tests at checkinnext-upverified-locallyImplementation has been verified locally

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions