mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
chore: add custom samesite options to auth cookies (#16885)
Allows controlling `samesite` cookie settings from the deployment config
This commit is contained in:
@@ -641,7 +641,6 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
|
||||
GoogleTokenValidator: googleTokenValidator,
|
||||
ExternalAuthConfigs: externalAuthConfigs,
|
||||
RealIPConfig: realIPConfig,
|
||||
SecureAuthCookie: vals.SecureAuthCookie.Value(),
|
||||
SSHKeygenAlgorithm: sshKeygenAlgorithm,
|
||||
TracerProvider: tracerProvider,
|
||||
Telemetry: telemetry.NewNoop(),
|
||||
|
||||
+3
@@ -251,6 +251,9 @@ NETWORKING OPTIONS:
|
||||
Specifies whether to redirect requests that do not match the access
|
||||
URL host.
|
||||
|
||||
--samesite-auth-cookie lax|none, $CODER_SAMESITE_AUTH_COOKIE (default: lax)
|
||||
Controls the 'SameSite' property is set on browser session cookies.
|
||||
|
||||
--secure-auth-cookie bool, $CODER_SECURE_AUTH_COOKIE
|
||||
Controls if the 'Secure' property is set on browser session cookies.
|
||||
|
||||
|
||||
+3
@@ -174,6 +174,9 @@ networking:
|
||||
# Controls if the 'Secure' property is set on browser session cookies.
|
||||
# (default: <unset>, type: bool)
|
||||
secureAuthCookie: false
|
||||
# Controls the 'SameSite' property is set on browser session cookies.
|
||||
# (default: lax, type: enum[lax\|none])
|
||||
sameSiteAuthCookie: lax
|
||||
# Whether Coder only allows connections to workspaces via the browser.
|
||||
# (default: <unset>, type: bool)
|
||||
browserOnly: false
|
||||
|
||||
Generated
+14
-3
@@ -11902,6 +11902,9 @@ const docTemplate = `{
|
||||
"description": "HTTPAddress is a string because it may be set to zero to disable.",
|
||||
"type": "string"
|
||||
},
|
||||
"http_cookies": {
|
||||
"$ref": "#/definitions/codersdk.HTTPCookieConfig"
|
||||
},
|
||||
"in_memory_database": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -11962,9 +11965,6 @@ const docTemplate = `{
|
||||
"scim_api_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"secure_auth_cookie": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"session_lifetime": {
|
||||
"$ref": "#/definitions/codersdk.SessionLifetime"
|
||||
},
|
||||
@@ -12484,6 +12484,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.HTTPCookieConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"same_site": {
|
||||
"type": "string"
|
||||
},
|
||||
"secure_auth_cookie": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Healthcheck": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+14
-3
@@ -10642,6 +10642,9 @@
|
||||
"description": "HTTPAddress is a string because it may be set to zero to disable.",
|
||||
"type": "string"
|
||||
},
|
||||
"http_cookies": {
|
||||
"$ref": "#/definitions/codersdk.HTTPCookieConfig"
|
||||
},
|
||||
"in_memory_database": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -10702,9 +10705,6 @@
|
||||
"scim_api_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"secure_auth_cookie": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"session_lifetime": {
|
||||
"$ref": "#/definitions/codersdk.SessionLifetime"
|
||||
},
|
||||
@@ -11214,6 +11214,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.HTTPCookieConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"same_site": {
|
||||
"type": "string"
|
||||
},
|
||||
"secure_auth_cookie": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Healthcheck": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+2
-4
@@ -382,12 +382,10 @@ func (api *API) createAPIKey(ctx context.Context, params apikey.CreateParams) (*
|
||||
APIKeys: []telemetry.APIKey{telemetry.ConvertAPIKey(newkey)},
|
||||
})
|
||||
|
||||
return &http.Cookie{
|
||||
return api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
|
||||
Name: codersdk.SessionTokenCookie,
|
||||
Value: sessionToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: api.SecureAuthCookie,
|
||||
}, &newkey, nil
|
||||
}), &newkey, nil
|
||||
}
|
||||
|
||||
+5
-6
@@ -155,7 +155,6 @@ type Options struct {
|
||||
GithubOAuth2Config *GithubOAuth2Config
|
||||
OIDCConfig *OIDCConfig
|
||||
PrometheusRegistry *prometheus.Registry
|
||||
SecureAuthCookie bool
|
||||
StrictTransportSecurityCfg httpmw.HSTSConfig
|
||||
SSHKeygenAlgorithm gitsshkey.Algorithm
|
||||
Telemetry telemetry.Reporter
|
||||
@@ -740,7 +739,7 @@ func New(options *Options) *API {
|
||||
StatsCollector: workspaceapps.NewStatsCollector(options.WorkspaceAppsStatsCollectorOptions),
|
||||
|
||||
DisablePathApps: options.DeploymentValues.DisablePathApps.Value(),
|
||||
SecureAuthCookie: options.DeploymentValues.SecureAuthCookie.Value(),
|
||||
Cookies: options.DeploymentValues.HTTPCookies,
|
||||
APIKeyEncryptionKeycache: options.AppEncryptionKeyCache,
|
||||
}
|
||||
|
||||
@@ -828,7 +827,7 @@ func New(options *Options) *API {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
},
|
||||
httpmw.CSRF(options.SecureAuthCookie),
|
||||
httpmw.CSRF(options.DeploymentValues.HTTPCookies),
|
||||
)
|
||||
|
||||
// This incurs a performance hit from the middleware, but is required to make sure
|
||||
@@ -868,7 +867,7 @@ func New(options *Options) *API {
|
||||
r.Route(fmt.Sprintf("/%s/callback", externalAuthConfig.ID), func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddlewareRedirect,
|
||||
httpmw.ExtractOAuth2(externalAuthConfig, options.HTTPClient, nil),
|
||||
httpmw.ExtractOAuth2(externalAuthConfig, options.HTTPClient, options.DeploymentValues.HTTPCookies, nil),
|
||||
)
|
||||
r.Get("/", api.externalAuthCallback(externalAuthConfig))
|
||||
})
|
||||
@@ -1123,14 +1122,14 @@ func New(options *Options) *API {
|
||||
r.Get("/github/device", api.userOAuth2GithubDevice)
|
||||
r.Route("/github", func(r chi.Router) {
|
||||
r.Use(
|
||||
httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, nil),
|
||||
httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, options.DeploymentValues.HTTPCookies, nil),
|
||||
)
|
||||
r.Get("/callback", api.userOAuth2Github)
|
||||
})
|
||||
})
|
||||
r.Route("/oidc/callback", func(r chi.Router) {
|
||||
r.Use(
|
||||
httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient, oidcAuthURLParams),
|
||||
httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient, options.DeploymentValues.HTTPCookies, oidcAuthURLParams),
|
||||
)
|
||||
r.Get("/", api.userOIDC)
|
||||
})
|
||||
|
||||
@@ -1320,7 +1320,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
|
||||
// requests will fail.
|
||||
func (f *FakeIDP) HTTPClient(rest *http.Client) *http.Client {
|
||||
if f.serve {
|
||||
if rest == nil || rest.Transport == nil {
|
||||
if rest == nil {
|
||||
return &http.Client{}
|
||||
}
|
||||
return rest
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package testjar
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func New() *Jar {
|
||||
return &Jar{}
|
||||
}
|
||||
|
||||
// Jar exists because 'cookiejar.New()' strips many of the http.Cookie fields
|
||||
// that are needed to assert. Such as 'Secure' and 'SameSite'.
|
||||
type Jar struct {
|
||||
m sync.Mutex
|
||||
perURL map[string][]*http.Cookie
|
||||
}
|
||||
|
||||
func (j *Jar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||
j.m.Lock()
|
||||
defer j.m.Unlock()
|
||||
if j.perURL == nil {
|
||||
j.perURL = make(map[string][]*http.Cookie)
|
||||
}
|
||||
j.perURL[u.Host] = append(j.perURL[u.Host], cookies...)
|
||||
}
|
||||
|
||||
func (j *Jar) Cookies(u *url.URL) []*http.Cookie {
|
||||
j.m.Lock()
|
||||
defer j.m.Unlock()
|
||||
return j.perURL[u.Host]
|
||||
}
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
// for non-GET requests.
|
||||
// If enforce is false, then CSRF enforcement is disabled. We still want
|
||||
// to include the CSRF middleware because it will set the CSRF cookie.
|
||||
func CSRF(secureCookie bool) func(next http.Handler) http.Handler {
|
||||
func CSRF(cookieCfg codersdk.HTTPCookieConfig) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
mw := nosurf.New(next)
|
||||
mw.SetBaseCookie(http.Cookie{Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: secureCookie})
|
||||
mw.SetBaseCookie(*cookieCfg.Apply(&http.Cookie{Path: "/", HttpOnly: true}))
|
||||
mw.SetFailureHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sessCookie, err := r.Cookie(codersdk.SessionTokenCookie)
|
||||
if err == nil &&
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestCSRFExemptList(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
mw := httpmw.CSRF(false)
|
||||
mw := httpmw.CSRF(codersdk.HTTPCookieConfig{})
|
||||
csrfmw := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).(*nosurf.CSRFHandler)
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -87,7 +87,7 @@ func TestCSRFError(t *testing.T) {
|
||||
var handler http.Handler = http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
})
|
||||
handler = httpmw.CSRF(false)(handler)
|
||||
handler = httpmw.CSRF(codersdk.HTTPCookieConfig{})(handler)
|
||||
|
||||
// Not testing the error case, just providing the example of things working
|
||||
// to base the failure tests off of.
|
||||
|
||||
@@ -40,7 +40,7 @@ func OAuth2(r *http.Request) OAuth2State {
|
||||
// a "code" URL parameter will be redirected.
|
||||
// AuthURLOpts are passed to the AuthCodeURL function. If this is nil,
|
||||
// the default option oauth2.AccessTypeOffline will be used.
|
||||
func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, authURLOpts map[string]string) func(http.Handler) http.Handler {
|
||||
func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, cookieCfg codersdk.HTTPCookieConfig, authURLOpts map[string]string) func(http.Handler) http.Handler {
|
||||
opts := make([]oauth2.AuthCodeOption, 0, len(authURLOpts)+1)
|
||||
opts = append(opts, oauth2.AccessTypeOffline)
|
||||
for k, v := range authURLOpts {
|
||||
@@ -118,22 +118,20 @@ func ExtractOAuth2(config promoauth.OAuth2Config, client *http.Client, authURLOp
|
||||
}
|
||||
}
|
||||
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
http.SetCookie(rw, cookieCfg.Apply(&http.Cookie{
|
||||
Name: codersdk.OAuth2StateCookie,
|
||||
Value: state,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}))
|
||||
// Redirect must always be specified, otherwise
|
||||
// an old redirect could apply!
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
http.SetCookie(rw, cookieCfg.Apply(&http.Cookie{
|
||||
Name: codersdk.OAuth2RedirectCookie,
|
||||
Value: redirect,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}))
|
||||
|
||||
http.Redirect(rw, r, config.AuthCodeURL(state, opts...), http.StatusTemporaryRedirect)
|
||||
return
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestOAuth2(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
res := httptest.NewRecorder()
|
||||
httpmw.ExtractOAuth2(nil, nil, nil)(nil).ServeHTTP(res, req)
|
||||
httpmw.ExtractOAuth2(nil, nil, codersdk.HTTPCookieConfig{}, nil)(nil).ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
})
|
||||
t.Run("RedirectWithoutCode", func(t *testing.T) {
|
||||
@@ -58,7 +58,7 @@ func TestOAuth2(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape("/dashboard"), nil)
|
||||
res := httptest.NewRecorder()
|
||||
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
|
||||
httpmw.ExtractOAuth2(tp, nil, nil)(nil).ServeHTTP(res, req)
|
||||
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil)(nil).ServeHTTP(res, req)
|
||||
location := res.Header().Get("Location")
|
||||
if !assert.NotEmpty(t, location) {
|
||||
return
|
||||
@@ -82,7 +82,7 @@ func TestOAuth2(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape(uri.String()), nil)
|
||||
res := httptest.NewRecorder()
|
||||
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
|
||||
httpmw.ExtractOAuth2(tp, nil, nil)(nil).ServeHTTP(res, req)
|
||||
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil)(nil).ServeHTTP(res, req)
|
||||
location := res.Header().Get("Location")
|
||||
if !assert.NotEmpty(t, location) {
|
||||
return
|
||||
@@ -97,7 +97,7 @@ func TestOAuth2(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/?code=something", nil)
|
||||
res := httptest.NewRecorder()
|
||||
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
|
||||
httpmw.ExtractOAuth2(tp, nil, nil)(nil).ServeHTTP(res, req)
|
||||
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil)(nil).ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
|
||||
})
|
||||
t.Run("NoStateCookie", func(t *testing.T) {
|
||||
@@ -105,7 +105,7 @@ func TestOAuth2(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/?code=something&state=test", nil)
|
||||
res := httptest.NewRecorder()
|
||||
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
|
||||
httpmw.ExtractOAuth2(tp, nil, nil)(nil).ServeHTTP(res, req)
|
||||
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil)(nil).ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode)
|
||||
})
|
||||
t.Run("MismatchedState", func(t *testing.T) {
|
||||
@@ -117,7 +117,7 @@ func TestOAuth2(t *testing.T) {
|
||||
})
|
||||
res := httptest.NewRecorder()
|
||||
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
|
||||
httpmw.ExtractOAuth2(tp, nil, nil)(nil).ServeHTTP(res, req)
|
||||
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil)(nil).ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode)
|
||||
})
|
||||
t.Run("ExchangeCodeAndState", func(t *testing.T) {
|
||||
@@ -133,7 +133,7 @@ func TestOAuth2(t *testing.T) {
|
||||
})
|
||||
res := httptest.NewRecorder()
|
||||
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
|
||||
httpmw.ExtractOAuth2(tp, nil, nil)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
state := httpmw.OAuth2(r)
|
||||
require.Equal(t, "/dashboard", state.Redirect)
|
||||
})).ServeHTTP(res, req)
|
||||
@@ -144,7 +144,7 @@ func TestOAuth2(t *testing.T) {
|
||||
res := httptest.NewRecorder()
|
||||
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("foo", "bar"))
|
||||
authOpts := map[string]string{"foo": "bar"}
|
||||
httpmw.ExtractOAuth2(tp, nil, authOpts)(nil).ServeHTTP(res, req)
|
||||
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, authOpts)(nil).ServeHTTP(res, req)
|
||||
location := res.Header().Get("Location")
|
||||
// Ideally we would also assert that the location contains the query params
|
||||
// we set in the auth URL but this would essentially be testing the oauth2 package.
|
||||
@@ -157,12 +157,17 @@ func TestOAuth2(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/?oidc_merge_state="+customState+"&redirect="+url.QueryEscape("/dashboard"), nil)
|
||||
res := httptest.NewRecorder()
|
||||
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
|
||||
httpmw.ExtractOAuth2(tp, nil, nil)(nil).ServeHTTP(res, req)
|
||||
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{
|
||||
Secure: true,
|
||||
SameSite: "none",
|
||||
}, nil)(nil).ServeHTTP(res, req)
|
||||
|
||||
found := false
|
||||
for _, cookie := range res.Result().Cookies() {
|
||||
if cookie.Name == codersdk.OAuth2StateCookie {
|
||||
require.Equal(t, cookie.Value, customState, "expected state")
|
||||
require.Equal(t, true, cookie.Secure, "cookie set to secure")
|
||||
require.Equal(t, http.SameSiteNoneMode, cookie.SameSite, "same-site = none")
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
+3
-4
@@ -204,7 +204,7 @@ func (api *API) postConvertLoginType(rw http.ResponseWriter, r *http.Request) {
|
||||
Path: "/",
|
||||
Value: token,
|
||||
Expires: claims.Expiry.Time(),
|
||||
Secure: api.SecureAuthCookie,
|
||||
Secure: api.DeploymentValues.HTTPCookies.Secure.Value(),
|
||||
HttpOnly: true,
|
||||
// Must be SameSite to work on the redirected auth flow from the
|
||||
// oauth provider.
|
||||
@@ -1913,13 +1913,12 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
|
||||
slog.F("user_id", user.ID),
|
||||
)
|
||||
}
|
||||
cookies = append(cookies, &http.Cookie{
|
||||
cookies = append(cookies, api.DeploymentValues.HTTPCookies.Apply(&http.Cookie{
|
||||
Name: codersdk.SessionTokenCookie,
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
Secure: api.SecureAuthCookie,
|
||||
HttpOnly: true,
|
||||
})
|
||||
}))
|
||||
// This is intentional setting the key to the deleted old key,
|
||||
// as the user needs to be forced to log back in.
|
||||
key = *oldKey
|
||||
|
||||
+35
-4
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -33,6 +34,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest/testjar"
|
||||
"github.com/coder/coder/v2/coderd/cryptokeys"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
@@ -66,8 +68,16 @@ func TestOIDCOauthLoginWithExisting(t *testing.T) {
|
||||
cfg.SecondaryClaims = coderd.MergedClaimsSourceNone
|
||||
})
|
||||
|
||||
certificates := []tls.Certificate{testutil.GenerateTLSCertificate(t, "localhost")}
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
OIDCConfig: cfg,
|
||||
OIDCConfig: cfg,
|
||||
TLSCertificates: certificates,
|
||||
DeploymentValues: coderdtest.DeploymentValues(t, func(values *codersdk.DeploymentValues) {
|
||||
values.HTTPCookies = codersdk.HTTPCookieConfig{
|
||||
Secure: true,
|
||||
SameSite: "none",
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const username = "alice"
|
||||
@@ -78,15 +88,36 @@ func TestOIDCOauthLoginWithExisting(t *testing.T) {
|
||||
"sub": uuid.NewString(),
|
||||
}
|
||||
|
||||
helper := oidctest.NewLoginHelper(client, fake)
|
||||
// Signup alice
|
||||
userClient, _ := helper.Login(t, claims)
|
||||
freshClient := func() *codersdk.Client {
|
||||
cli := codersdk.New(client.URL)
|
||||
cli.HTTPClient.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
}
|
||||
cli.HTTPClient.Jar = testjar.New()
|
||||
return cli
|
||||
}
|
||||
|
||||
unauthenticated := freshClient()
|
||||
userClient, _ := fake.Login(t, unauthenticated, claims)
|
||||
|
||||
cookies := unauthenticated.HTTPClient.Jar.Cookies(client.URL)
|
||||
require.True(t, len(cookies) > 0)
|
||||
for _, c := range cookies {
|
||||
require.Truef(t, c.Secure, "cookie %q", c.Name)
|
||||
require.Equalf(t, http.SameSiteNoneMode, c.SameSite, "cookie %q", c.Name)
|
||||
}
|
||||
|
||||
// Expire the link. This will force the client to refresh the token.
|
||||
helper := oidctest.NewLoginHelper(userClient, fake)
|
||||
helper.ExpireOauthToken(t, api.Database, userClient)
|
||||
|
||||
// Instead of refreshing, just log in again.
|
||||
helper.Login(t, claims)
|
||||
unauthenticated = freshClient()
|
||||
fake.Login(t, unauthenticated, claims)
|
||||
}
|
||||
|
||||
func TestUserLogin(t *testing.T) {
|
||||
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
type ResolveRequestOptions struct {
|
||||
Logger slog.Logger
|
||||
SignedTokenProvider SignedTokenProvider
|
||||
CookieCfg codersdk.HTTPCookieConfig
|
||||
|
||||
DashboardURL *url.URL
|
||||
PathAppBaseURL *url.URL
|
||||
@@ -75,12 +76,12 @@ func ResolveRequest(rw http.ResponseWriter, r *http.Request, opts ResolveRequest
|
||||
//
|
||||
// For subdomain apps, this applies to the entire subdomain, e.g.
|
||||
// app--agent--workspace--user.apps.example.com
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
http.SetCookie(rw, opts.CookieCfg.Apply(&http.Cookie{
|
||||
Name: codersdk.SignedAppTokenCookie,
|
||||
Value: tokenStr,
|
||||
Path: appReq.BasePath,
|
||||
Expires: token.Expiry.Time(),
|
||||
})
|
||||
}))
|
||||
|
||||
return token, true
|
||||
}
|
||||
|
||||
@@ -110,8 +110,8 @@ type Server struct {
|
||||
//
|
||||
// Subdomain apps are safer with their cookies scoped to the subdomain, and XSS
|
||||
// calls to the dashboard are not possible due to CORs.
|
||||
DisablePathApps bool
|
||||
SecureAuthCookie bool
|
||||
DisablePathApps bool
|
||||
Cookies codersdk.HTTPCookieConfig
|
||||
|
||||
AgentProvider AgentProvider
|
||||
StatsCollector *StatsCollector
|
||||
@@ -230,16 +230,14 @@ func (s *Server) handleAPIKeySmuggling(rw http.ResponseWriter, r *http.Request,
|
||||
// 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.
|
||||
http.SetCookie(rw, &http.Cookie{
|
||||
http.SetCookie(rw, s.Cookies.Apply(&http.Cookie{
|
||||
Name: AppConnectSessionTokenCookieName(accessMethod),
|
||||
Value: payload.APIKey,
|
||||
Domain: domain,
|
||||
Path: "/",
|
||||
MaxAge: 0,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.SecureAuthCookie,
|
||||
})
|
||||
}))
|
||||
|
||||
// Strip the query parameter.
|
||||
path := r.URL.Path
|
||||
@@ -300,6 +298,7 @@ func (s *Server) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
|
||||
// permissions to connect to a workspace.
|
||||
token, ok := ResolveRequest(rw, r, ResolveRequestOptions{
|
||||
Logger: s.Logger,
|
||||
CookieCfg: s.Cookies,
|
||||
SignedTokenProvider: s.SignedTokenProvider,
|
||||
DashboardURL: s.DashboardURL,
|
||||
PathAppBaseURL: s.AccessURL,
|
||||
@@ -405,6 +404,7 @@ func (s *Server) HandleSubdomain(middlewares ...func(http.Handler) http.Handler)
|
||||
|
||||
token, ok := ResolveRequest(rw, r, ResolveRequestOptions{
|
||||
Logger: s.Logger,
|
||||
CookieCfg: s.Cookies,
|
||||
SignedTokenProvider: s.SignedTokenProvider,
|
||||
DashboardURL: s.DashboardURL,
|
||||
PathAppBaseURL: s.AccessURL,
|
||||
@@ -630,6 +630,7 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
appToken, ok := ResolveRequest(rw, r, ResolveRequestOptions{
|
||||
Logger: s.Logger,
|
||||
CookieCfg: s.Cookies,
|
||||
SignedTokenProvider: s.SignedTokenProvider,
|
||||
DashboardURL: s.DashboardURL,
|
||||
PathAppBaseURL: s.AccessURL,
|
||||
|
||||
+38
-2
@@ -358,7 +358,7 @@ type DeploymentValues struct {
|
||||
Telemetry TelemetryConfig `json:"telemetry,omitempty" typescript:",notnull"`
|
||||
TLS TLSConfig `json:"tls,omitempty" typescript:",notnull"`
|
||||
Trace TraceConfig `json:"trace,omitempty" typescript:",notnull"`
|
||||
SecureAuthCookie serpent.Bool `json:"secure_auth_cookie,omitempty" typescript:",notnull"`
|
||||
HTTPCookies HTTPCookieConfig `json:"http_cookies,omitempty" typescript:",notnull"`
|
||||
StrictTransportSecurity serpent.Int64 `json:"strict_transport_security,omitempty" typescript:",notnull"`
|
||||
StrictTransportSecurityOptions serpent.StringArray `json:"strict_transport_security_options,omitempty" typescript:",notnull"`
|
||||
SSHKeygenAlgorithm serpent.String `json:"ssh_keygen_algorithm,omitempty" typescript:",notnull"`
|
||||
@@ -586,6 +586,30 @@ type TraceConfig struct {
|
||||
DataDog serpent.Bool `json:"data_dog" typescript:",notnull"`
|
||||
}
|
||||
|
||||
type HTTPCookieConfig struct {
|
||||
Secure serpent.Bool `json:"secure_auth_cookie,omitempty" typescript:",notnull"`
|
||||
SameSite string `json:"same_site,omitempty" typescript:",notnull"`
|
||||
}
|
||||
|
||||
func (cfg *HTTPCookieConfig) Apply(c *http.Cookie) *http.Cookie {
|
||||
c.Secure = cfg.Secure.Value()
|
||||
c.SameSite = cfg.HTTPSameSite()
|
||||
return c
|
||||
}
|
||||
|
||||
func (cfg HTTPCookieConfig) HTTPSameSite() http.SameSite {
|
||||
switch strings.ToLower(cfg.SameSite) {
|
||||
case "lax":
|
||||
return http.SameSiteLaxMode
|
||||
case "strict":
|
||||
return http.SameSiteStrictMode
|
||||
case "none":
|
||||
return http.SameSiteNoneMode
|
||||
default:
|
||||
return http.SameSiteDefaultMode
|
||||
}
|
||||
}
|
||||
|
||||
type ExternalAuthConfig struct {
|
||||
// Type is the type of external auth config.
|
||||
Type string `json:"type" yaml:"type"`
|
||||
@@ -2376,11 +2400,23 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
Description: "Controls if the 'Secure' property is set on browser session cookies.",
|
||||
Flag: "secure-auth-cookie",
|
||||
Env: "CODER_SECURE_AUTH_COOKIE",
|
||||
Value: &c.SecureAuthCookie,
|
||||
Value: &c.HTTPCookies.Secure,
|
||||
Group: &deploymentGroupNetworking,
|
||||
YAML: "secureAuthCookie",
|
||||
Annotations: serpent.Annotations{}.Mark(annotationExternalProxies, "true"),
|
||||
},
|
||||
{
|
||||
Name: "SameSite Auth Cookie",
|
||||
Description: "Controls the 'SameSite' property is set on browser session cookies.",
|
||||
Flag: "samesite-auth-cookie",
|
||||
Env: "CODER_SAMESITE_AUTH_COOKIE",
|
||||
// Do not allow "strict" same-site cookies. That would potentially break workspace apps.
|
||||
Value: serpent.EnumOf(&c.HTTPCookies.SameSite, "lax", "none"),
|
||||
Default: "lax",
|
||||
Group: &deploymentGroupNetworking,
|
||||
YAML: "sameSiteAuthCookie",
|
||||
Annotations: serpent.Annotations{}.Mark(annotationExternalProxies, "true"),
|
||||
},
|
||||
{
|
||||
Name: "Terms of Service URL",
|
||||
Description: "A URL to an external Terms of Service that must be accepted by users when logging in.",
|
||||
|
||||
Generated
+4
-1
@@ -260,6 +260,10 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
|
||||
"threshold_database": 0
|
||||
},
|
||||
"http_address": "string",
|
||||
"http_cookies": {
|
||||
"same_site": "string",
|
||||
"secure_auth_cookie": true
|
||||
},
|
||||
"in_memory_database": true,
|
||||
"job_hang_detector_interval": 0,
|
||||
"logging": {
|
||||
@@ -433,7 +437,6 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
|
||||
},
|
||||
"redirect_to_access_url": true,
|
||||
"scim_api_key": "string",
|
||||
"secure_auth_cookie": true,
|
||||
"session_lifetime": {
|
||||
"default_duration": 0,
|
||||
"default_token_lifetime": 0,
|
||||
|
||||
Generated
+25
-3
@@ -1945,6 +1945,10 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"threshold_database": 0
|
||||
},
|
||||
"http_address": "string",
|
||||
"http_cookies": {
|
||||
"same_site": "string",
|
||||
"secure_auth_cookie": true
|
||||
},
|
||||
"in_memory_database": true,
|
||||
"job_hang_detector_interval": 0,
|
||||
"logging": {
|
||||
@@ -2118,7 +2122,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
},
|
||||
"redirect_to_access_url": true,
|
||||
"scim_api_key": "string",
|
||||
"secure_auth_cookie": true,
|
||||
"session_lifetime": {
|
||||
"default_duration": 0,
|
||||
"default_token_lifetime": 0,
|
||||
@@ -2422,6 +2425,10 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"threshold_database": 0
|
||||
},
|
||||
"http_address": "string",
|
||||
"http_cookies": {
|
||||
"same_site": "string",
|
||||
"secure_auth_cookie": true
|
||||
},
|
||||
"in_memory_database": true,
|
||||
"job_hang_detector_interval": 0,
|
||||
"logging": {
|
||||
@@ -2595,7 +2602,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
},
|
||||
"redirect_to_access_url": true,
|
||||
"scim_api_key": "string",
|
||||
"secure_auth_cookie": true,
|
||||
"session_lifetime": {
|
||||
"default_duration": 0,
|
||||
"default_token_lifetime": 0,
|
||||
@@ -2711,6 +2717,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
| `external_token_encryption_keys` | array of string | false | | |
|
||||
| `healthcheck` | [codersdk.HealthcheckConfig](#codersdkhealthcheckconfig) | false | | |
|
||||
| `http_address` | string | false | | Http address is a string because it may be set to zero to disable. |
|
||||
| `http_cookies` | [codersdk.HTTPCookieConfig](#codersdkhttpcookieconfig) | false | | |
|
||||
| `in_memory_database` | boolean | false | | |
|
||||
| `job_hang_detector_interval` | integer | false | | |
|
||||
| `logging` | [codersdk.LoggingConfig](#codersdkloggingconfig) | false | | |
|
||||
@@ -2729,7 +2736,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
| `rate_limit` | [codersdk.RateLimitConfig](#codersdkratelimitconfig) | false | | |
|
||||
| `redirect_to_access_url` | boolean | false | | |
|
||||
| `scim_api_key` | string | false | | |
|
||||
| `secure_auth_cookie` | boolean | false | | |
|
||||
| `session_lifetime` | [codersdk.SessionLifetime](#codersdksessionlifetime) | false | | |
|
||||
| `ssh_keygen_algorithm` | string | false | | |
|
||||
| `strict_transport_security` | integer | false | | |
|
||||
@@ -3298,6 +3304,22 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
|
||||
| » `[any property]` | array of string | false | | |
|
||||
| `regex_filter` | [regexp.Regexp](#regexpregexp) | false | | Regex filter is a regular expression that filters the groups returned by the OIDC provider. Any group not matched by this regex will be ignored. If the group filter is nil, then no group filtering will occur. |
|
||||
|
||||
## codersdk.HTTPCookieConfig
|
||||
|
||||
```json
|
||||
{
|
||||
"same_site": "string",
|
||||
"secure_auth_cookie": true
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|----------------------|---------|----------|--------------|-------------|
|
||||
| `same_site` | string | false | | |
|
||||
| `secure_auth_cookie` | boolean | false | | |
|
||||
|
||||
## codersdk.Healthcheck
|
||||
|
||||
```json
|
||||
|
||||
Generated
+11
@@ -992,6 +992,17 @@ Type of auth to use when connecting to postgres. For AWS RDS, using IAM authenti
|
||||
|
||||
Controls if the 'Secure' property is set on browser session cookies.
|
||||
|
||||
### --samesite-auth-cookie
|
||||
|
||||
| | |
|
||||
|-------------|--------------------------------------------|
|
||||
| Type | <code>lax\|none</code> |
|
||||
| Environment | <code>$CODER_SAMESITE_AUTH_COOKIE</code> |
|
||||
| YAML | <code>networking.sameSiteAuthCookie</code> |
|
||||
| Default | <code>lax</code> |
|
||||
|
||||
Controls the 'SameSite' property is set on browser session cookies.
|
||||
|
||||
### --terms-of-service-url
|
||||
|
||||
| | |
|
||||
|
||||
@@ -264,7 +264,7 @@ func (r *RootCmd) proxyServer() *serpent.Command {
|
||||
Tracing: tracer,
|
||||
PrometheusRegistry: prometheusRegistry,
|
||||
APIRateLimit: int(cfg.RateLimit.API.Value()),
|
||||
SecureAuthCookie: cfg.SecureAuthCookie.Value(),
|
||||
CookieConfig: cfg.HTTPCookies,
|
||||
DisablePathApps: cfg.DisablePathApps.Value(),
|
||||
ProxySessionToken: proxySessionToken.Value(),
|
||||
AllowAllCors: cfg.Dangerous.AllowAllCors.Value(),
|
||||
|
||||
@@ -252,6 +252,9 @@ NETWORKING OPTIONS:
|
||||
Specifies whether to redirect requests that do not match the access
|
||||
URL host.
|
||||
|
||||
--samesite-auth-cookie lax|none, $CODER_SAMESITE_AUTH_COOKIE (default: lax)
|
||||
Controls the 'SameSite' property is set on browser session cookies.
|
||||
|
||||
--secure-auth-cookie bool, $CODER_SECURE_AUTH_COOKIE
|
||||
Controls if the 'Secure' property is set on browser session cookies.
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ func NewWorkspaceProxyReplica(t *testing.T, coderdAPI *coderd.API, owner *coders
|
||||
RealIPConfig: coderdAPI.RealIPConfig,
|
||||
Tracing: coderdAPI.TracerProvider,
|
||||
APIRateLimit: coderdAPI.APIRateLimit,
|
||||
SecureAuthCookie: coderdAPI.SecureAuthCookie,
|
||||
CookieConfig: coderdAPI.DeploymentValues.HTTPCookies,
|
||||
ProxySessionToken: token,
|
||||
DisablePathApps: options.DisablePathApps,
|
||||
// We need a new registry to not conflict with the coderd internal
|
||||
|
||||
@@ -70,7 +70,7 @@ type Options struct {
|
||||
TLSCertificates []tls.Certificate
|
||||
|
||||
APIRateLimit int
|
||||
SecureAuthCookie bool
|
||||
CookieConfig codersdk.HTTPCookieConfig
|
||||
DisablePathApps bool
|
||||
DERPEnabled bool
|
||||
DERPServerRelayAddress string
|
||||
@@ -310,8 +310,8 @@ func New(ctx context.Context, opts *Options) (*Server, error) {
|
||||
Logger: s.Logger.Named("proxy_token_provider"),
|
||||
},
|
||||
|
||||
DisablePathApps: opts.DisablePathApps,
|
||||
SecureAuthCookie: opts.SecureAuthCookie,
|
||||
DisablePathApps: opts.DisablePathApps,
|
||||
Cookies: opts.CookieConfig,
|
||||
|
||||
AgentProvider: agentProvider,
|
||||
StatsCollector: workspaceapps.NewStatsCollector(opts.StatsCollectorOptions),
|
||||
@@ -362,7 +362,7 @@ func New(ctx context.Context, opts *Options) (*Server, error) {
|
||||
},
|
||||
// CSRF is required here because we need to set the CSRF cookies on
|
||||
// responses.
|
||||
httpmw.CSRF(s.Options.SecureAuthCookie),
|
||||
httpmw.CSRF(s.Options.CookieConfig),
|
||||
)
|
||||
|
||||
// Attach workspace apps routes.
|
||||
|
||||
Generated
+7
-1
@@ -649,7 +649,7 @@ export interface DeploymentValues {
|
||||
readonly telemetry?: TelemetryConfig;
|
||||
readonly tls?: TLSConfig;
|
||||
readonly trace?: TraceConfig;
|
||||
readonly secure_auth_cookie?: boolean;
|
||||
readonly http_cookies?: HTTPCookieConfig;
|
||||
readonly strict_transport_security?: number;
|
||||
readonly strict_transport_security_options?: string;
|
||||
readonly ssh_keygen_algorithm?: string;
|
||||
@@ -976,6 +976,12 @@ export interface GroupSyncSettings {
|
||||
readonly legacy_group_name_mapping?: Record<string, string>;
|
||||
}
|
||||
|
||||
// From codersdk/deployment.go
|
||||
export interface HTTPCookieConfig {
|
||||
readonly secure_auth_cookie?: boolean;
|
||||
readonly same_site?: string;
|
||||
}
|
||||
|
||||
// From health/model.go
|
||||
export type HealthCode =
|
||||
| "EACS03"
|
||||
|
||||
Reference in New Issue
Block a user