coderd/httpmw/oauth2_test.go
Rama main · réplica de lectura del repositorio original.
package httpmw_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
type testOAuth2Provider struct {
t testing.TB
authOpts []oauth2.AuthCodeOption
}
func (p *testOAuth2Provider) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
assert.EqualValues(p.t, p.authOpts, opts)
return "?state=" + url.QueryEscape(state)
}
func (*testOAuth2Provider) Exchange(_ context.Context, _ string, _ ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
return &oauth2.Token{
AccessToken: "hello",
}, nil
}
func (*testOAuth2Provider) TokenSource(_ context.Context, _ *oauth2.Token) oauth2.TokenSource {
return nil
}
func newTestOAuth2Provider(t testing.TB, opts ...oauth2.AuthCodeOption) *testOAuth2Provider {
return &testOAuth2Provider{
t: t,
authOpts: opts,
}
}
// nolint:bodyclose
func TestOAuth2(t *testing.T) {
t.Parallel()
t.Run("NotSetup", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", "/", nil)
res := httptest.NewRecorder()
httpmw.ExtractOAuth2(nil, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
})
t.Run("RedirectWithoutCode", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape("/dashboard"), nil)
res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req)
location := res.Header().Get("Location")
if !assert.NotEmpty(t, location) {
return
}
require.Len(t, res.Result().Cookies(), 2)
cookie := res.Result().Cookies()[1]
require.Equal(t, "/dashboard", cookie.Value)
})
t.Run("OnlyPathBaseRedirect", func(t *testing.T) {
t.Parallel()
// Construct a URI to a potentially malicious
// site and assert that we omit the host
// when redirecting the request.
uri := &url.URL{
Scheme: "https",
Host: "some.bad.domain.com",
Path: "/sadf/asdfasdf",
RawQuery: "foo=hello&bar=world",
}
expectedValue := uri.Path + "?" + uri.RawQuery
req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape(uri.String()), nil)
res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req)
location := res.Header().Get("Location")
if !assert.NotEmpty(t, location) {
return
}
require.Len(t, res.Result().Cookies(), 2)
cookie := res.Result().Cookies()[1]
require.Equal(t, expectedValue, cookie.Value)
})
t.Run("NoState", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", "/?code=something", nil)
res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
})
t.Run("NoStateCookie", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", "/?code=something&state=test", nil)
res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode)
})
t.Run("MismatchedState", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", "/?code=something&state=test", nil)
req.AddCookie(&http.Cookie{
Name: codersdk.OAuth2StateCookie,
Value: "mismatch",
})
res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode)
})
t.Run("ExchangeCodeAndState", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", "/?code=test&state=something", nil)
req.AddCookie(&http.Cookie{
Name: codersdk.OAuth2StateCookie,
Value: "something",
})
req.AddCookie(&http.Cookie{
Name: "oauth_redirect",
Value: "/dashboard",
})
res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
state := httpmw.OAuth2(r)
require.Equal(t, "/dashboard", state.Redirect)
})).ServeHTTP(res, req)
})
t.Run("CustomAuthCodeOptions", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape("/dashboard"), nil)
res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("foo", "bar"))
authOpts := map[string]string{"foo": "bar"}
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, authOpts, nil, nil, "")(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.
// testOAuth2Provider does this job for us.
require.NotEmpty(t, location)
})
t.Run("PresetConvertState", func(t *testing.T) {
t.Parallel()
customState := testutil.GetRandomName(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, codersdk.HTTPCookieConfig{
Secure: true,
SameSite: "none",
}, nil, nil, 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
}
}
require.True(t, found, "expected state cookie")
})
}
// nolint:bodyclose
func TestOAuth2DynamicRedirect(t *testing.T) {
t.Parallel()
const callbackPath = "/api/v2/users/oidc/callback"
const primaryHost = "coder.test.netflix.net"
const altHost = "dev-workspaces.test.netflix.net"
const wantPrimaryURI = "https://" + primaryHost + callbackPath
const wantAltURI = "https://" + altHost + callbackPath
t.Run("InitOnAllowedHostSetsCookieAndOverridesRedirect", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", callbackPath+"?redirect="+url.QueryEscape("/dashboard"), nil)
req.Host = altHost
res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t,
oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("redirect_uri", wantAltURI),
)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil,
[]string{primaryHost, altHost}, "https")(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusTemporaryRedirect, res.Result().StatusCode)
var redirectCookie *http.Cookie
for _, c := range res.Result().Cookies() {
if c.Name == codersdk.OAuth2RedirectURICookie {
redirectCookie = c
break
}
}
require.NotNil(t, redirectCookie, "expected %s cookie", codersdk.OAuth2RedirectURICookie)
require.Equal(t, wantAltURI, redirectCookie.Value)
})
t.Run("InitOnDisallowedHostReturnsBadRequest", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", callbackPath, nil)
req.Host = "evil.example.com"
res := httptest.NewRecorder()
// authOpts must not be asserted: the request should be rejected
// before AuthCodeURL is called.
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil,
[]string{primaryHost, altHost}, "")(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
for _, c := range res.Result().Cookies() {
require.NotEqual(t, codersdk.OAuth2RedirectURICookie, c.Name, "must not set redirect_uri cookie when host is rejected")
}
})
t.Run("AllowlistHostMatchIsCaseInsensitiveAndIgnoresPort", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", callbackPath, nil)
req.Host = "DEV-WORKSPACES.test.netflix.net:8443"
res := httptest.NewRecorder()
// Host is preserved verbatim in the constructed redirect_uri so the
// IdP sees exactly what the user typed (case is preserved but the
// allowlist match is insensitive). The scheme comes from the caller-
// supplied defaultScheme; real callers populate this from AccessURL.
expectedURI := "https://DEV-WORKSPACES.test.netflix.net:8443" + callbackPath
tp := newTestOAuth2Provider(t,
oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("redirect_uri", expectedURI),
)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil,
[]string{altHost}, "https")(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusTemporaryRedirect, res.Result().StatusCode)
})
t.Run("ExchangeReusesRedirectURIFromCookie", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", callbackPath+"?code=test&state=something", nil)
req.Host = altHost
req.AddCookie(&http.Cookie{Name: codersdk.OAuth2StateCookie, Value: "something"})
req.AddCookie(&http.Cookie{Name: codersdk.OAuth2RedirectCookie, Value: "/dashboard"})
req.AddCookie(&http.Cookie{Name: codersdk.OAuth2RedirectURICookie, Value: wantAltURI})
res := httptest.NewRecorder()
exchangeCalled := false
tp := &exchangeAssertingProvider{
t: t,
onExchange: func(opts []oauth2.AuthCodeOption) {
exchangeCalled = true
require.Contains(t, opts, oauth2.SetAuthURLParam("redirect_uri", wantAltURI))
},
}
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil,
[]string{primaryHost, altHost}, "https")(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
state := httpmw.OAuth2(r)
require.Equal(t, "/dashboard", state.Redirect)
})).ServeHTTP(res, req)
require.True(t, exchangeCalled, "expected Exchange to be invoked")
})
t.Run("CallbackWithMissingRedirectURICookieReturnsBadRequest", func(t *testing.T) {
t.Parallel()
// Same shape as ExchangeReusesRedirectURIFromCookie but without the
// redirect_uri cookie. Must fail loudly rather than silently sending
// the static config redirect_uri (which would mismatch what was used
// in the original authorization request).
req := httptest.NewRequest("GET", callbackPath+"?code=test&state=something", nil)
req.Host = altHost
req.AddCookie(&http.Cookie{Name: codersdk.OAuth2StateCookie, Value: "something"})
req.AddCookie(&http.Cookie{Name: codersdk.OAuth2RedirectCookie, Value: "/dashboard"})
// Intentionally NO OAuth2RedirectURICookie.
res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil,
[]string{primaryHost, altHost}, "https")(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
})
t.Run("CallbackWithMismatchedRedirectURICookieReturnsBadRequest", func(t *testing.T) {
t.Parallel()
// Cookie was set when the user initiated on altHost, but the callback
// is somehow arriving from primaryHost (or the cookie was tampered).
// Defense in depth: reject the exchange instead of forwarding a
// stale/mismatched value to the IdP.
req := httptest.NewRequest("GET", callbackPath+"?code=test&state=something", nil)
req.Host = primaryHost
req.AddCookie(&http.Cookie{Name: codersdk.OAuth2StateCookie, Value: "something"})
req.AddCookie(&http.Cookie{Name: codersdk.OAuth2RedirectURICookie, Value: wantAltURI})
res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil,
[]string{primaryHost, altHost}, "https")(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
})
t.Run("CallbackOnDisallowedHostReturnsBadRequest", func(t *testing.T) {
t.Parallel()
// Even with a valid state cookie and code, the host must be allowed.
req := httptest.NewRequest("GET", callbackPath+"?code=test&state=something", nil)
req.Host = "evil.example.com"
req.AddCookie(&http.Cookie{Name: codersdk.OAuth2StateCookie, Value: "something"})
req.AddCookie(&http.Cookie{Name: codersdk.OAuth2RedirectURICookie, Value: wantPrimaryURI})
res := httptest.NewRecorder()
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil,
[]string{primaryHost}, "")(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
})
t.Run("AllowlistDisabledLeavesBehaviorUnchanged", func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest("GET", callbackPath+"?redirect="+url.QueryEscape("/dashboard"), nil)
req.Host = "anything.example.com"
res := httptest.NewRecorder()
// With no allowlist, AuthCodeURL must be invoked with only the base
// AccessTypeOffline option; no redirect_uri override should be added.
tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline)
httpmw.ExtractOAuth2(tp, nil, codersdk.HTTPCookieConfig{}, nil, nil, nil, "")(nil).ServeHTTP(res, req)
require.Equal(t, http.StatusTemporaryRedirect, res.Result().StatusCode)
for _, c := range res.Result().Cookies() {
require.NotEqual(t, codersdk.OAuth2RedirectURICookie, c.Name)
}
})
}
// exchangeAssertingProvider is a test OAuth2 provider that captures the
// options passed to Exchange so the test can assert on them.
type exchangeAssertingProvider struct {
t testing.TB
onExchange func(opts []oauth2.AuthCodeOption)
}
func (*exchangeAssertingProvider) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string {
return "?state=" + url.QueryEscape(state)
}
func (p *exchangeAssertingProvider) Exchange(_ context.Context, _ string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
if p.onExchange != nil {
p.onExchange(opts)
}
return &oauth2.Token{AccessToken: "hello"}, nil
}
func (*exchangeAssertingProvider) TokenSource(_ context.Context, _ *oauth2.Token) oauth2.TokenSource {
return nil
}
// TestExtractOAuth2ProviderAppBodyTooLarge covers the pre-authentication body
// read on POST /oauth2/tokens and POST /oauth2/revoke. Those routes authenticate
// the client from the request body, so they carry no API key middleware and the
// form parse below runs before any authorization decision.
//
// net/http would otherwise cap the body at its own 10 MiB maxFormSize, which is
// larger than the ceiling every other endpoint carries. The rejection is
// asserted in the RFC 6749 error shape because a client that parses that shape
// cannot read a codersdk.Response, so the status alone is not sufficient.
func TestExtractOAuth2ProviderAppBodyTooLarge(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
// Padding a valid form past the limit leaves size as the only reason to
// reject the request. client_id is present in the body, so a fallthrough to
// the missing-client_id path would be observable as a 400.
oversized := "client_id=" + uuid.NewString() + "&pad=" +
strings.Repeat("a", httpapi.DefaultMaxRequestBodyBytes)
require.Greater(t, len(oversized), httpapi.DefaultMaxRequestBodyBytes)
var nextCalled bool
handler := httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(db)(
http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
nextCalled = true
}),
)
r := httptest.NewRequest(http.MethodPost, "/oauth2/tokens", strings.NewReader(oversized))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rw := httptest.NewRecorder()
handler.ServeHTTP(rw, r)
require.Equal(t, http.StatusRequestEntityTooLarge, rw.Code)
require.False(t, nextCalled, "handler ran on an oversized body")
var errResp codersdk.OAuth2Error
require.NoError(t, json.Unmarshal(rw.Body.Bytes(), &errResp))
require.Equal(t, codersdk.OAuth2ErrorCodeInvalidRequest, errResp.Error)
require.Contains(t, errResp.ErrorDescription, strconv.Itoa(httpapi.DefaultMaxRequestBodyBytes))
}