mirror of https://github.com/coder/coder.git
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:
parent
99151183bc
commit
0a16bda786
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue