Skip to content

feat: allow configuring OIDC email claim and OIDC auth url parameters #6867

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
EmailDomain: cfg.OIDC.EmailDomain,
AllowSignups: cfg.OIDC.AllowSignups.Value(),
UsernameField: cfg.OIDC.UsernameField.String(),
EmailField: cfg.OIDC.EmailField.String(),
AuthURLParams: cfg.OIDC.AuthURLParams.Value,
GroupField: cfg.OIDC.GroupField.String(),
GroupMapping: cfg.OIDC.GroupMapping.Value,
SignInText: cfg.OIDC.SignInText.String(),
Expand Down
161 changes: 161 additions & 0 deletions cli/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/coder/coder/coderd/database/postgres"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil"
)
Expand Down Expand Up @@ -1016,6 +1017,166 @@ func TestServer(t *testing.T) {
require.True(t, strings.HasPrefix(fakeURL.String(), fakeRedirect), fakeURL.String())
})

t.Run("OIDC", func(t *testing.T) {
t.Parallel()

t.Run("Defaults", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
defer cancel()

// Startup a fake server that just responds to .well-known/openid-configuration
// This is just needed to get Coder to start up.
oidcServer := httptest.NewServer(nil)
fakeWellKnownHandler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
payload := fmt.Sprintf("{\"issuer\": %q}", oidcServer.URL)
_, _ = w.Write([]byte(payload))
}
oidcServer.Config.Handler = http.HandlerFunc(fakeWellKnownHandler)
t.Cleanup(oidcServer.Close)

inv, cfg := clitest.New(t,
"server",
"--in-memory",
"--http-address", ":0",
"--access-url", "http://example.com",
"--oidc-client-id", "fake",
"--oidc-client-secret", "fake",
"--oidc-issuer-url", oidcServer.URL,
// Leaving the rest of the flags as defaults.
)

// Ensure that the server starts up without error.
clitest.Start(t, inv)
accessURL := waitAccessURL(t, cfg)
client := codersdk.New(accessURL)

randPassword, err := cryptorand.String(24)
require.NoError(t, err)

_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
Email: "admin@coder.com",
Password: randPassword,
Username: "admin",
Trial: true,
})
require.NoError(t, err)

loginResp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: "admin@coder.com",
Password: randPassword,
})
require.NoError(t, err)
client.SetSessionToken(loginResp.SessionToken)

deploymentConfig, err := client.DeploymentConfig(ctx)
require.NoError(t, err)

// Ensure that the OIDC provider is configured correctly.
require.Equal(t, "fake", deploymentConfig.Values.OIDC.ClientID.Value())
// The client secret is not returned from the API.
require.Empty(t, deploymentConfig.Values.OIDC.ClientSecret.Value())
require.Equal(t, oidcServer.URL, deploymentConfig.Values.OIDC.IssuerURL.Value())
// These are the default values returned from the API. See codersdk/deployment.go for the default values.
require.True(t, deploymentConfig.Values.OIDC.AllowSignups.Value())
require.Empty(t, deploymentConfig.Values.OIDC.EmailDomain.Value())
require.Equal(t, []string{"openid", "profile", "email"}, deploymentConfig.Values.OIDC.Scopes.Value())
require.False(t, deploymentConfig.Values.OIDC.IgnoreEmailVerified.Value())
require.Equal(t, "preferred_username", deploymentConfig.Values.OIDC.UsernameField.Value())
require.Equal(t, "email", deploymentConfig.Values.OIDC.EmailField.Value())
require.Equal(t, map[string]string{"access_type": "offline"}, deploymentConfig.Values.OIDC.AuthURLParams.Value)
require.Empty(t, deploymentConfig.Values.OIDC.GroupField.Value())
require.Empty(t, deploymentConfig.Values.OIDC.GroupMapping.Value)
require.Equal(t, "OpenID Connect", deploymentConfig.Values.OIDC.SignInText.Value())
require.Empty(t, deploymentConfig.Values.OIDC.IconURL.Value())
})

t.Run("Overrides", func(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
defer cancel()

// Startup a fake server that just responds to .well-known/openid-configuration
// This is just needed to get Coder to start up.
oidcServer := httptest.NewServer(nil)
fakeWellKnownHandler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
payload := fmt.Sprintf("{\"issuer\": %q}", oidcServer.URL)
_, _ = w.Write([]byte(payload))
}
oidcServer.Config.Handler = http.HandlerFunc(fakeWellKnownHandler)
t.Cleanup(oidcServer.Close)

inv, cfg := clitest.New(t,
"server",
"--in-memory",
"--http-address", ":0",
"--access-url", "http://example.com",
"--oidc-client-id", "fake",
"--oidc-client-secret", "fake",
"--oidc-issuer-url", oidcServer.URL,
// The following values have defaults that we want to override.
"--oidc-allow-signups=false",
"--oidc-email-domain", "example.com",
"--oidc-scopes", "360noscope",
"--oidc-ignore-email-verified",
"--oidc-username-field", "not_preferred_username",
"--oidc-email-field", "not_email",
"--oidc-auth-url-params", `{"prompt":"consent"}`,
"--oidc-group-field", "serious_business_unit",
"--oidc-group-mapping", `{"serious_business_unit": "serious_business_unit"}`,
"--oidc-sign-in-text", "Sign In With Coder",
"--oidc-icon-url", "https://example.com/icon.png",
)

// Ensure that the server starts up without error.
clitest.Start(t, inv)
accessURL := waitAccessURL(t, cfg)
client := codersdk.New(accessURL)

randPassword, err := cryptorand.String(24)
require.NoError(t, err)

_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
Email: "admin@coder.com",
Password: randPassword,
Username: "admin",
Trial: true,
})
require.NoError(t, err)

loginResp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
Email: "admin@coder.com",
Password: randPassword,
})
require.NoError(t, err)
client.SetSessionToken(loginResp.SessionToken)

deploymentConfig, err := client.DeploymentConfig(ctx)
require.NoError(t, err)

// Ensure that the OIDC provider is configured correctly.
require.Equal(t, "fake", deploymentConfig.Values.OIDC.ClientID.Value())
// The client secret is not returned from the API.
require.Empty(t, deploymentConfig.Values.OIDC.ClientSecret.Value())
require.Equal(t, oidcServer.URL, deploymentConfig.Values.OIDC.IssuerURL.Value())
// These are values that we want to make sure were overridden.
require.False(t, deploymentConfig.Values.OIDC.AllowSignups.Value())
require.Equal(t, []string{"example.com"}, deploymentConfig.Values.OIDC.EmailDomain.Value())
require.Equal(t, []string{"360noscope"}, deploymentConfig.Values.OIDC.Scopes.Value())
require.True(t, deploymentConfig.Values.OIDC.IgnoreEmailVerified.Value())
require.Equal(t, "not_preferred_username", deploymentConfig.Values.OIDC.UsernameField.Value())
require.Equal(t, "not_email", deploymentConfig.Values.OIDC.EmailField.Value())
require.Equal(t, map[string]string{"access_type": "offline", "prompt": "consent"}, deploymentConfig.Values.OIDC.AuthURLParams.Value)
require.Equal(t, "serious_business_unit", deploymentConfig.Values.OIDC.GroupField.Value())
require.Equal(t, map[string]string{"serious_business_unit": "serious_business_unit"}, deploymentConfig.Values.OIDC.GroupMapping.Value)
require.Equal(t, "Sign In With Coder", deploymentConfig.Values.OIDC.SignInText.Value())
require.Equal(t, "https://example.com/icon.png", deploymentConfig.Values.OIDC.IconURL.Value().String())
})
})

t.Run("RateLimit", func(t *testing.T) {
t.Parallel()

Expand Down
6 changes: 6 additions & 0 deletions cli/testdata/coder_server_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ can safely ignore these settings.
--oidc-allow-signups bool, $CODER_OIDC_ALLOW_SIGNUPS (default: true)
Whether new users can sign up with OIDC.

--oidc-auth-url-params struct[map[string]string], $CODER_OIDC_AUTH_URL_PARAMS (default: {"access_type": "offline"})
OIDC auth URL parameters to pass to the upstream provider.

--oidc-client-id string, $CODER_OIDC_CLIENT_ID
Client ID to use for Login with OIDC.

Expand All @@ -270,6 +273,9 @@ can safely ignore these settings.
--oidc-email-domain string-array, $CODER_OIDC_EMAIL_DOMAIN
Email domains that clients logging in with OIDC must match.

--oidc-email-field string, $CODER_OIDC_EMAIL_FIELD (default: email)
OIDC claim field to use as the email.

--oidc-group-field string, $CODER_OIDC_GROUP_FIELD
Change the OIDC default 'groups' claim field. By default, will be
'groups' if present in the oidc scopes argument.
Expand Down
6 changes: 6 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,12 @@ func New(options *Options) *API {
*options.UpdateCheckOptions,
)
}

var oidcAuthURLParams map[string]string
if options.OIDCConfig != nil {
oidcAuthURLParams = options.OIDCConfig.AuthURLParams
}

api.Auditor.Store(&options.Auditor)
api.TemplateScheduleStore.Store(&options.TemplateScheduleStore)
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0)
Expand Down Expand Up @@ -387,7 +393,7 @@ func New(options *Options) *API {
for _, gitAuthConfig := range options.GitAuthConfigs {
r.Route(fmt.Sprintf("/%s", gitAuthConfig.ID), func(r chi.Router) {
r.Use(
httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient),
httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient, nil),
apiKeyMiddleware,
)
r.Get("/callback", api.gitAuthCallback(gitAuthConfig))
Expand Down Expand Up @@ -531,12 +537,12 @@ func New(options *Options) *API {
r.Post("/login", api.postLogin)
r.Route("/oauth2", func(r chi.Router) {
r.Route("/github", func(r chi.Router) {
r.Use(httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient))
r.Use(httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, nil))
r.Get("/callback", api.userOAuth2Github)
})
})
r.Route("/oidc/callback", func(r chi.Router) {
r.Use(httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient))
r.Use(httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient, oidcAuthURLParams))
r.Get("/", api.userOIDC)
})
})
Expand Down
2 changes: 2 additions & 0 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,8 @@ func (o *OIDCConfig) OIDCConfig(t *testing.T, userInfoClaims jwt.MapClaims, opts
}),
Provider: provider,
UsernameField: "preferred_username",
EmailField: "email",
AuthURLParams: map[string]string{"access_type": "offline"},
GroupField: "groups",
}
for _, opt := range opts {
Expand Down
6 changes: 6 additions & 0 deletions coderd/deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ func TestDeploymentValues(t *testing.T) {
// values should not be returned
cfg.OAuth2.Github.ClientSecret.Set(hi)
cfg.OIDC.ClientSecret.Set(hi)
cfg.OIDC.AuthURLParams.Set(`{"foo":"bar"}`)
cfg.OIDC.EmailField.Set("some_random_field_you_never_expected")
cfg.PostgresURL.Set(hi)
cfg.SCIMAPIKey.Set(hi)

Expand All @@ -32,6 +34,10 @@ func TestDeploymentValues(t *testing.T) {
require.NoError(t, err)
// ensure normal values pass through
require.EqualValues(t, true, scrubbed.Values.BrowserOnly.Value())
require.NotEmpty(t, cfg.OIDC.AuthURLParams)
require.EqualValues(t, cfg.OIDC.AuthURLParams, scrubbed.Values.OIDC.AuthURLParams)
require.NotEmpty(t, cfg.OIDC.EmailField)
require.EqualValues(t, cfg.OIDC.EmailField, scrubbed.Values.OIDC.EmailField)
// ensure secrets are removed
require.Empty(t, scrubbed.Values.OAuth2.Github.ClientSecret.Value())
require.Empty(t, scrubbed.Values.OIDC.ClientSecret.Value())
Expand Down
12 changes: 10 additions & 2 deletions coderd/httpmw/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,15 @@ func OAuth2(r *http.Request) OAuth2State {
// ExtractOAuth2 is a middleware for automatically redirecting to OAuth
// URLs, and handling the exchange inbound. Any route that does not have
// a "code" URL parameter will be redirected.
func ExtractOAuth2(config OAuth2Config, client *http.Client) func(http.Handler) http.Handler {
// AuthURLOpts are passed to the AuthCodeURL function. If this is nil,
// the default option oauth2.AccessTypeOffline will be used.
func ExtractOAuth2(config OAuth2Config, client *http.Client, 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 {
opts = append(opts, oauth2.SetAuthURLParam(k, v))
}

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
Expand Down Expand Up @@ -109,7 +117,7 @@ func ExtractOAuth2(config OAuth2Config, client *http.Client) func(http.Handler)
SameSite: http.SameSiteLaxMode,
})

http.Redirect(rw, r, config.AuthCodeURL(state, oauth2.AccessTypeOffline), http.StatusTemporaryRedirect)
http.Redirect(rw, r, config.AuthCodeURL(state, opts...), http.StatusTemporaryRedirect)
return
}

Expand Down
Loading