From fcf431c1d7b24eaf66e97718f9f8955ec0cbd84e Mon Sep 17 00:00:00 2001 From: cryptoluks <9020527+cryptoluks@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:18:49 +0100 Subject: [PATCH] fix(coderd/workspaceapps): prefer app session cookie over Authorization (#22041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes a workspace app authentication bug where requests that include an `Authorization` header (intended for the upstream app) can cause Coder to ignore the workspace app session cookie (`coder_subdomain_app_session_token_*` / `coder_path_app_session_token`). When that happens, Coder fails to mint or renew `coder_signed_app_token` and redirects to `/api/v2/applications/auth-redirect` instead of proxying the request to the workspace. This commonly shows up when users run a frontend and backend in the same workspace and the backend requires `Authorization` (for example, `curl -H "Authorization: bearer ..."` or browser `fetch()` calls). Related issues / context: * Primary bug report and repro: [https://github.com/coder/coder/issues/21467](https://github.com/coder/coder/issues/21467) * Related symptoms reported as CORS / redirect failures for workspace apps: * [https://github.com/coder/coder/issues/20667](https://github.com/coder/coder/issues/20667) * [https://github.com/coder/coder/issues/19728](https://github.com/coder/coder/issues/19728) ## Root Cause In `coderd/workspaceapps/cookies.go`, `AppCookies.TokenFromRequest` checked `httpmw.APITokenFromRequest(r)` first. That helper returns a token from several places, including `Authorization: Bearer ...`. As a result, when a request included an upstream `Authorization` header, that header value was returned as the “session token” for the app proxy, and `coder_subdomain_app_session_token_*` was never read. Authentication then failed and the request was treated as signed out. ## Fix Change the precedence in `AppCookies.TokenFromRequest`: 1. First check the access-method-specific cookie: * subdomain apps: `coder_subdomain_app_session_token_{hash}` * path apps: `coder_path_app_session_token` 2. If not present, fall back to `httpmw.APITokenFromRequest(r)` (so non-browser clients can still authenticate via query, header, or bearer tokens if they really want to). This ensures that: * Backend requests that require `Authorization` still reach the workspace. * `coder_signed_app_token` can be renewed from the app session cookie even when `Authorization` is present. * `Authorization` is still forwarded to the upstream app (the reverse proxy code does not strip it). Initially, I attempted workarounds ([https://github.com/coder/coder/issues/20667#issuecomment-3868578388](https://github.com/coder/coder/issues/20667#issuecomment-3868578388), [https://github.com/coder/coder/issues/19728#issuecomment-3868578093](https://github.com/coder/coder/issues/19728#issuecomment-3868578093)), but adding `/auth-redirect` to the permissive CORS paths and extending the validity of workspace app auth tokens from 1 minute to 1 hour only partially masked the issue. After workspace restarts and token expiry, I no longer saw CORS errors, but the tokens were still not renewed. After patching my local Nix-based setup on Coder v1.30.0 with this change, I can no longer observe this behavior. --- coderd/workspaceapps/cookies.go | 31 +++++++++++++++------------- coderd/workspaceapps/cookies_test.go | 18 ++++++++++++++++ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/coderd/workspaceapps/cookies.go b/coderd/workspaceapps/cookies.go index 28169fe18c..716f510185 100644 --- a/coderd/workspaceapps/cookies.go +++ b/coderd/workspaceapps/cookies.go @@ -68,27 +68,30 @@ func SubdomainAppSessionTokenCookie(hostname string) string { // the wrong value. // // We use different cookie names for: -// - path apps on primary access URL: coder_session_token -// - path apps on proxies: coder_path_app_session_token +// - path apps: coder_path_app_session_token // - subdomain apps: coder_subdomain_app_session_token_{unique_hash} // -// First we try the default function to get a token from request, which supports -// query parameters, the Coder-Session-Token header and the coder_session_token -// cookie. -// -// Then we try the specific cookie name for the access method. +// We prefer the access-method-specific cookie first, then fall back to standard +// Coder token extraction (query parameters, Coder-Session-Token header, etc.). func (c AppCookies) TokenFromRequest(r *http.Request, accessMethod AccessMethod) string { - // Try the default function first. - token := httpmw.APITokenFromRequest(r) - if token != "" { - return token - } - - // Then try the specific cookie name for the access method. + // Prefer the access-method-specific cookie first. + // + // Workspace app requests commonly include an `Authorization` header intended + // for the upstream app (e.g. API calls). `httpmw.APITokenFromRequest` supports + // RFC 6750 bearer tokens, so if we consult it first we'd incorrectly treat + // that upstream header as a Coder session token and ignore the app session + // cookie, breaking token renewal for subdomain apps. cookie, err := r.Cookie(c.CookieNameForAccessMethod(accessMethod)) if err == nil && cookie.Value != "" { return cookie.Value } + // Fall back to standard Coder token extraction (session cookie, query param, + // Coder-Session-Token header, and then Authorization: Bearer). + token := httpmw.APITokenFromRequest(r) + if token != "" { + return token + } + return "" } diff --git a/coderd/workspaceapps/cookies_test.go b/coderd/workspaceapps/cookies_test.go index 898c35c995..053d28e694 100644 --- a/coderd/workspaceapps/cookies_test.go +++ b/coderd/workspaceapps/cookies_test.go @@ -1,6 +1,8 @@ package workspaceapps_test import ( + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/require" @@ -32,3 +34,19 @@ func TestAppCookies(t *testing.T) { newCookies := workspaceapps.NewAppCookies("different.com") require.NotEqual(t, cookies.SubdomainAppSessionToken, newCookies.SubdomainAppSessionToken) } + +func TestAppCookies_TokenFromRequest_PrefersAppCookieOverAuthorizationBearer(t *testing.T) { + t.Parallel() + + cookies := workspaceapps.NewAppCookies("apps.example.com") + + req := httptest.NewRequest("GET", "https://8081--agent--workspace--user.apps.example.com/", nil) + req.Header.Set("Authorization", "Bearer whatever") + req.AddCookie(&http.Cookie{ + Name: cookies.CookieNameForAccessMethod(workspaceapps.AccessMethodSubdomain), + Value: "subdomain-session-token", + }) + + got := cookies.TokenFromRequest(req, workspaceapps.AccessMethodSubdomain) + require.Equal(t, "subdomain-session-token", got) +}