chore: add external auth providers to `oidctest` (#10958)

* implement external auth in oidctest
* Refactor more external tests to new oidctest
This commit is contained in:
Steven Masley 2023-11-30 14:05:15 -06:00 committed by GitHub
parent 99151183bc
commit 0a16bda786
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 234 additions and 71 deletions

View File

@ -19,8 +19,6 @@ import (
"testing"
"time"
"github.com/coder/coder/v2/coderd/util/syncmap"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-chi/chi/v5"
"github.com/go-jose/go-jose/v3"
@ -34,22 +32,32 @@ import (
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/util/syncmap"
"github.com/coder/coder/v2/codersdk"
)
// FakeIDP is a functional OIDC provider.
// It only supports 1 OIDC client.
type FakeIDP struct {
issuer string
key *rsa.PrivateKey
provider ProviderJSON
handler http.Handler
cfg *oauth2.Config
issuer string
issuerURL *url.URL
key *rsa.PrivateKey
provider ProviderJSON
handler http.Handler
cfg *oauth2.Config
// clientID to be used by coderd
clientID string
clientSecret string
logger slog.Logger
// externalProviderID is optional to match the provider in coderd for
// redirectURLs.
externalProviderID string
logger slog.Logger
// externalAuthValidate will be called when the user tries to validate their
// external auth. The fake IDP will reject any invalid tokens, so this just
// controls the response payload after a successfully authed token.
externalAuthValidate func(email string, rw http.ResponseWriter, r *http.Request)
// These maps are used to control the state of the IDP.
// That is the various access tokens, refresh tokens, states, etc.
@ -222,6 +230,7 @@ func (f *FakeIDP) updateIssuerURL(t testing.TB, issuer string) {
require.NoError(t, err, "invalid issuer URL")
f.issuer = issuer
f.issuerURL = u
// ProviderJSON is the JSON representation of the OpenID Connect provider
// These are all the urls that the IDP will respond to.
f.provider = ProviderJSON{
@ -347,6 +356,47 @@ func (f *FakeIDP) LoginWithClient(t testing.TB, client *codersdk.Client, idToken
return user, res
}
// ExternalLogin does the oauth2 flow for external auth providers. This requires
// an authenticated coder client.
func (f *FakeIDP) ExternalLogin(t testing.TB, client *codersdk.Client, opts ...func(r *http.Request)) {
coderOauthURL, err := client.URL.Parse(fmt.Sprintf("/external-auth/%s/callback", f.externalProviderID))
require.NoError(t, err)
f.SetRedirect(t, coderOauthURL.String())
cli := f.HTTPClient(client.HTTPClient)
cli.CheckRedirect = func(req *http.Request, via []*http.Request) error {
// Store the idTokenClaims to the specific state request. This ties
// the claims 1:1 with a given authentication flow.
state := req.URL.Query().Get("state")
f.stateToIDTokenClaims.Store(state, jwt.MapClaims{})
return nil
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
req, err := http.NewRequestWithContext(ctx, "GET", coderOauthURL.String(), nil)
require.NoError(t, err)
// External auth flow requires the user be authenticated.
headerName := client.SessionTokenHeader
if headerName == "" {
headerName = codersdk.SessionTokenHeader
}
req.Header.Set(headerName, client.SessionToken())
if cli.Jar == nil {
cli.Jar, err = cookiejar.New(nil)
require.NoError(t, err, "failed to create cookie jar")
}
for _, opt := range opts {
opt(req)
}
res, err := cli.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode, "client failed to login")
_ = res.Body.Close()
}
// OIDCCallback will emulate the IDP redirecting back to the Coder callback.
// This is helpful if no Coderd exists because the IDP needs to redirect to
// something.
@ -640,7 +690,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
_ = json.NewEncoder(rw).Encode(token)
}))
mux.Handle(userInfoPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
validateMW := func(rw http.ResponseWriter, r *http.Request) (email string, ok bool) {
token, err := f.authenticateBearerTokenRequest(t, r)
f.logger.Info(r.Context(), "http call idp user info",
slog.Error(err),
@ -648,15 +698,23 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
)
if err != nil {
http.Error(rw, fmt.Sprintf("invalid user info request: %s", err.Error()), http.StatusBadRequest)
return
return "", false
}
email, ok := f.accessTokens.Load(token)
email, ok = f.accessTokens.Load(token)
if !ok {
t.Errorf("access token user for user_info has no email to indicate which user")
http.Error(rw, "invalid access token, missing user info", http.StatusBadRequest)
return "", false
}
return email, true
}
mux.Handle(userInfoPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
email, ok := validateMW(rw, r)
if !ok {
return
}
claims, err := f.hookUserInfo(email)
if err != nil {
http.Error(rw, fmt.Sprintf("user info hook returned error: %s", err.Error()), httpErrorCode(http.StatusBadRequest, err))
@ -665,6 +723,24 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
_ = json.NewEncoder(rw).Encode(claims)
}))
// There is almost no difference between this and /userinfo.
// The main tweak is that this route is "mounted" vs "handle" because "/userinfo"
// should be strict, and this one needs to handle sub routes.
mux.Mount("/external-auth-validate/", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
email, ok := validateMW(rw, r)
if !ok {
return
}
if f.externalAuthValidate == nil {
t.Errorf("missing external auth validate handler")
http.Error(rw, "missing external auth validate handler", http.StatusBadRequest)
return
}
f.externalAuthValidate(email, rw, r)
}))
mux.Handle(keysPath, http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
f.logger.Info(r.Context(), "http call idp /keys")
set := jose.JSONWebKeySet{
@ -767,6 +843,80 @@ func (f *FakeIDP) SetCoderdCallbackHandler(handler http.HandlerFunc) {
})
}
// ExternalAuthConfigOptions exists to provide additional functionality ontop
// of the standard "validate" url. Some providers like github we actually parse
// the response from the validate URL to gain additional information.
type ExternalAuthConfigOptions struct {
// ValidatePayload is the payload that is used when the user calls the
// equivalent of "userinfo" for oauth2. This is not standardized, so is
// different for each provider type.
ValidatePayload func(email string) interface{}
// routes is more advanced usage. This allows the caller to
// completely customize the response. It captures all routes under the /external-auth-validate/*
// so the caller can do whatever they want and even add routes.
routes map[string]func(email string, rw http.ResponseWriter, r *http.Request)
}
func (o *ExternalAuthConfigOptions) AddRoute(route string, handle func(email string, rw http.ResponseWriter, r *http.Request)) *ExternalAuthConfigOptions {
if route == "/" || route == "" || route == "/user" {
panic("cannot override the /user route. Use ValidatePayload instead")
}
if !strings.HasPrefix(route, "/") {
route = "/" + route
}
if o.routes == nil {
o.routes = make(map[string]func(email string, rw http.ResponseWriter, r *http.Request))
}
o.routes[route] = handle
return o
}
// ExternalAuthConfig is the config for external auth providers.
func (f *FakeIDP) ExternalAuthConfig(t testing.TB, id string, custom *ExternalAuthConfigOptions, opts ...func(cfg *externalauth.Config)) *externalauth.Config {
if custom == nil {
custom = &ExternalAuthConfigOptions{}
}
f.externalProviderID = id
f.externalAuthValidate = func(email string, rw http.ResponseWriter, r *http.Request) {
newPath := strings.TrimPrefix(r.URL.Path, fmt.Sprintf("/external-auth-validate/%s", id))
switch newPath {
// /user is ALWAYS supported under the `/` path too.
case "/user", "/", "":
var payload interface{} = "OK"
if custom.ValidatePayload != nil {
payload = custom.ValidatePayload(email)
}
_ = json.NewEncoder(rw).Encode(payload)
default:
if custom.routes == nil {
custom.routes = make(map[string]func(email string, rw http.ResponseWriter, r *http.Request))
}
handle, ok := custom.routes[newPath]
if !ok {
t.Errorf("missing route handler for %s", newPath)
http.Error(rw, fmt.Sprintf("missing route handler for %s", newPath), http.StatusBadRequest)
return
}
handle(email, rw, r)
}
}
cfg := &externalauth.Config{
OAuth2Config: f.OIDCConfig(t, nil),
ID: id,
// No defaults for these fields by omitting the type
Type: "",
DisplayIcon: f.WellknownConfig().UserInfoURL,
// Omit the /user for the validate so we can easily append to it when modifying
// the cfg for advanced tests.
ValidateURL: f.issuerURL.ResolveReference(&url.URL{Path: fmt.Sprintf("/external-auth-validate/%s", id)}).String(),
}
for _, opt := range opts {
opt(cfg)
}
return cfg
}
// OIDCConfig returns the OIDC config to use for Coderd.
func (f *FakeIDP) OIDCConfig(t testing.TB, scopes []string, opts ...func(cfg *coderd.OIDCConfig)) *coderd.OIDCConfig {
t.Helper()

View File

@ -17,6 +17,7 @@ import (
"golang.org/x/oauth2"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/externalauth"
"github.com/coder/coder/v2/coderd/httpapi"
@ -30,15 +31,18 @@ func TestExternalAuthByID(t *testing.T) {
t.Parallel()
t.Run("Unauthenticated", func(t *testing.T) {
t.Parallel()
const providerID = "fake-github"
fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
client := coderdtest.New(t, &coderdtest.Options{
ExternalAuthConfigs: []*externalauth.Config{{
ID: "test",
OAuth2Config: &testutil.OAuth2Config{},
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
ExternalAuthConfigs: []*externalauth.Config{
fake.ExternalAuthConfig(t, providerID, nil, func(cfg *externalauth.Config) {
cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
}),
},
})
coderdtest.CreateFirstUser(t, client)
auth, err := client.ExternalAuthByID(context.Background(), "test")
auth, err := client.ExternalAuthByID(context.Background(), providerID)
require.NoError(t, err)
require.False(t, auth.Authenticated)
})
@ -46,42 +50,49 @@ func TestExternalAuthByID(t *testing.T) {
// Ensures that a provider that can't obtain a user can
// still return that the provider is authenticated.
t.Parallel()
const providerID = "fake-azure"
fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
client := coderdtest.New(t, &coderdtest.Options{
ExternalAuthConfigs: []*externalauth.Config{{
ID: "test",
OAuth2Config: &testutil.OAuth2Config{},
ExternalAuthConfigs: []*externalauth.Config{
// AzureDevops doesn't have a user endpoint!
Type: codersdk.EnhancedExternalAuthProviderAzureDevops.String(),
}},
fake.ExternalAuthConfig(t, providerID, nil, func(cfg *externalauth.Config) {
cfg.Type = codersdk.EnhancedExternalAuthProviderAzureDevops.String()
}),
},
})
coderdtest.CreateFirstUser(t, client)
resp := coderdtest.RequestExternalAuthCallback(t, "test", client)
_ = resp.Body.Close()
auth, err := client.ExternalAuthByID(context.Background(), "test")
fake.ExternalLogin(t, client)
auth, err := client.ExternalAuthByID(context.Background(), providerID)
require.NoError(t, err)
require.True(t, auth.Authenticated)
})
t.Run("AuthenticatedWithUser", func(t *testing.T) {
t.Parallel()
validateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), w, http.StatusOK, github.User{
Login: github.String("kyle"),
AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"),
})
}))
defer validateSrv.Close()
const providerID = "fake-github"
fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
client := coderdtest.New(t, &coderdtest.Options{
ExternalAuthConfigs: []*externalauth.Config{{
ID: "test",
ValidateURL: validateSrv.URL,
OAuth2Config: &testutil.OAuth2Config{},
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
ExternalAuthConfigs: []*externalauth.Config{
fake.ExternalAuthConfig(t, providerID, &oidctest.ExternalAuthConfigOptions{
ValidatePayload: func(_ string) interface{} {
return github.User{
Login: github.String("kyle"),
AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"),
}
},
}, func(cfg *externalauth.Config) {
cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
}),
},
})
coderdtest.CreateFirstUser(t, client)
resp := coderdtest.RequestExternalAuthCallback(t, "test", client)
_ = resp.Body.Close()
auth, err := client.ExternalAuthByID(context.Background(), "test")
// Login to external auth provider
fake.ExternalLogin(t, client)
auth, err := client.ExternalAuthByID(context.Background(), providerID)
require.NoError(t, err)
require.True(t, auth.Authenticated)
require.NotNil(t, auth.User)
@ -89,40 +100,42 @@ func TestExternalAuthByID(t *testing.T) {
})
t.Run("AuthenticatedWithInstalls", func(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/user":
httpapi.Write(r.Context(), w, http.StatusOK, github.User{
const providerID = "fake-github"
fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
// routes includes a route for /install that returns a list of installations
routes := (&oidctest.ExternalAuthConfigOptions{
ValidatePayload: func(_ string) interface{} {
return github.User{
Login: github.String("kyle"),
AvatarURL: github.String("https://avatars.githubusercontent.com/u/12345678?v=4"),
})
case "/installs":
httpapi.Write(r.Context(), w, http.StatusOK, struct {
Installations []github.Installation `json:"installations"`
}{
Installations: []github.Installation{{
ID: github.Int64(12345678),
Account: &github.User{
Login: github.String("coder"),
},
}},
})
}
}))
defer srv.Close()
client := coderdtest.New(t, &coderdtest.Options{
ExternalAuthConfigs: []*externalauth.Config{{
ID: "test",
ValidateURL: srv.URL + "/user",
AppInstallationsURL: srv.URL + "/installs",
OAuth2Config: &testutil.OAuth2Config{},
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
}},
}
},
}).AddRoute("/installs", func(_ string, rw http.ResponseWriter, r *http.Request) {
httpapi.Write(r.Context(), rw, http.StatusOK, struct {
Installations []github.Installation `json:"installations"`
}{
Installations: []github.Installation{{
ID: github.Int64(12345678),
Account: &github.User{
Login: github.String("coder"),
},
}},
})
})
client := coderdtest.New(t, &coderdtest.Options{
ExternalAuthConfigs: []*externalauth.Config{
fake.ExternalAuthConfig(t, providerID, routes, func(cfg *externalauth.Config) {
cfg.AppInstallationsURL = cfg.ValidateURL + "/installs"
cfg.Type = codersdk.EnhancedExternalAuthProviderGitHub.String()
}),
},
})
coderdtest.CreateFirstUser(t, client)
resp := coderdtest.RequestExternalAuthCallback(t, "test", client)
_ = resp.Body.Close()
auth, err := client.ExternalAuthByID(context.Background(), "test")
fake.ExternalLogin(t, client)
auth, err := client.ExternalAuthByID(context.Background(), providerID)
require.NoError(t, err)
require.True(t, auth.Authenticated)
require.NotNil(t, auth.User)