fix(coderd/workspaceapps): prefer app session cookie over Authorization (#22041)

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.
This commit is contained in:
cryptoluks
2026-02-11 13:18:49 +01:00
committed by GitHub
parent 0938981ebf
commit fcf431c1d7
2 changed files with 35 additions and 14 deletions
+17 -14
View File
@@ -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 ""
}
+18
View File
@@ -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)
}