mirror of
https://github.com/coder/coder.git
synced 2026-06-03 13:08:25 +00:00
fcf431c1d7
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.
98 lines
3.6 KiB
Go
98 lines
3.6 KiB
Go
package workspaceapps
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/http"
|
|
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
type AppCookies struct {
|
|
PathAppSessionToken string
|
|
SubdomainAppSessionToken string
|
|
SignedAppToken string
|
|
}
|
|
|
|
// NewAppCookies returns the cookie names for the app session token for the
|
|
// given hostname. The subdomain cookie is unique per workspace proxy and is
|
|
// based on a hash of the workspace proxy subdomain hostname. See
|
|
// SubdomainAppSessionTokenCookie for more details.
|
|
func NewAppCookies(hostname string) AppCookies {
|
|
return AppCookies{
|
|
PathAppSessionToken: codersdk.PathAppSessionTokenCookie,
|
|
SubdomainAppSessionToken: SubdomainAppSessionTokenCookie(hostname),
|
|
SignedAppToken: codersdk.SignedAppTokenCookie,
|
|
}
|
|
}
|
|
|
|
// CookieNameForAccessMethod returns the cookie name for the long-lived session
|
|
// token for the given access method.
|
|
func (c AppCookies) CookieNameForAccessMethod(accessMethod AccessMethod) string {
|
|
if accessMethod == AccessMethodSubdomain {
|
|
return c.SubdomainAppSessionToken
|
|
}
|
|
// Path-based and terminal apps are on the same domain:
|
|
return c.PathAppSessionToken
|
|
}
|
|
|
|
// SubdomainAppSessionTokenCookie returns the cookie name for the subdomain app
|
|
// session token. This is unique per workspace proxy and is based on a hash of
|
|
// the workspace proxy subdomain hostname.
|
|
//
|
|
// The reason the cookie needs to be unique per workspace proxy is to avoid
|
|
// cookies from one proxy (e.g. the primary) being sent on requests to a
|
|
// different proxy underneath the wildcard.
|
|
//
|
|
// E.g. `*.dev.coder.com` and `*.sydney.dev.coder.com`
|
|
//
|
|
// If you have an expired cookie on the primary proxy (valid for
|
|
// `*.dev.coder.com`), your browser will send it on all requests to the Sydney
|
|
// proxy as it's underneath the wildcard.
|
|
//
|
|
// By using a unique cookie name per workspace proxy, we can avoid this issue.
|
|
func SubdomainAppSessionTokenCookie(hostname string) string {
|
|
hash := sha256.Sum256([]byte(hostname))
|
|
// 16 bytes of uniqueness is probably enough.
|
|
str := hex.EncodeToString(hash[:16])
|
|
return codersdk.SubdomainAppSessionTokenCookie + "_" + str
|
|
}
|
|
|
|
// AppConnectSessionTokenFromRequest returns the session token from the request
|
|
// if it exists. The access method is used to determine which cookie name to
|
|
// use.
|
|
//
|
|
// We use different cookie names for path apps and for subdomain apps to avoid
|
|
// both being set and sent to the server at the same time and the server using
|
|
// the wrong value.
|
|
//
|
|
// We use different cookie names for:
|
|
// - path apps: coder_path_app_session_token
|
|
// - subdomain apps: coder_subdomain_app_session_token_{unique_hash}
|
|
//
|
|
// 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 {
|
|
// 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 ""
|
|
}
|