diff --git a/cli/server.go b/cli/server.go index da84b8ded0bc7..06f10d13006e3 100644 --- a/cli/server.go +++ b/cli/server.go @@ -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(), diff --git a/cli/server_test.go b/cli/server_test.go index 757d38fd91457..3512601a1c31e 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -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" ) @@ -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() diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index eefe266d7ca3e..01e15c14231b3 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -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. @@ -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. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9435f1bec2a37..1ece708702624 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7294,6 +7294,9 @@ const docTemplate = `{ "allow_signups": { "type": "boolean" }, + "auth_url_params": { + "type": "object" + }, "client_id": { "type": "string" }, @@ -7306,6 +7309,9 @@ const docTemplate = `{ "type": "string" } }, + "email_field": { + "type": "string" + }, "group_mapping": { "type": "object" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7a0f6e16d341f..c342722a24484 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6532,6 +6532,9 @@ "allow_signups": { "type": "boolean" }, + "auth_url_params": { + "type": "object" + }, "client_id": { "type": "string" }, @@ -6544,6 +6547,9 @@ "type": "string" } }, + "email_field": { + "type": "string" + }, "group_mapping": { "type": "object" }, diff --git a/coderd/coderd.go b/coderd/coderd.go index e17ce255b34f4..6a94083cce19a 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) @@ -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)) @@ -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) }) }) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index ff9bf82addc98..4dde514cc469f 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -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 { diff --git a/coderd/deployment_test.go b/coderd/deployment_test.go index 22e64ff318499..978464ad6b5ff 100644 --- a/coderd/deployment_test.go +++ b/coderd/deployment_test.go @@ -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) @@ -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()) diff --git a/coderd/httpmw/oauth2.go b/coderd/httpmw/oauth2.go index 26c4ff63d71ea..be2648f474512 100644 --- a/coderd/httpmw/oauth2.go +++ b/coderd/httpmw/oauth2.go @@ -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() @@ -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 } diff --git a/coderd/httpmw/oauth2_test.go b/coderd/httpmw/oauth2_test.go index 37d5f15fb1a49..b7a14b61353f1 100644 --- a/coderd/httpmw/oauth2_test.go +++ b/coderd/httpmw/oauth2_test.go @@ -15,9 +15,13 @@ import ( "github.com/coder/coder/codersdk" ) -type testOAuth2Provider struct{} +type testOAuth2Provider struct { + t testing.TB + authOpts []oauth2.AuthCodeOption +} -func (*testOAuth2Provider) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string { +func (p *testOAuth2Provider) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + assert.EqualValues(p.t, p.authOpts, opts) return "?state=" + url.QueryEscape(state) } @@ -31,6 +35,13 @@ func (*testOAuth2Provider) TokenSource(_ context.Context, _ *oauth2.Token) oauth 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() @@ -38,14 +49,15 @@ func TestOAuth2(t *testing.T) { t.Parallel() req := httptest.NewRequest("GET", "/", nil) res := httptest.NewRecorder() - httpmw.ExtractOAuth2(nil, nil)(nil).ServeHTTP(res, req) + httpmw.ExtractOAuth2(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() - httpmw.ExtractOAuth2(&testOAuth2Provider{}, nil)(nil).ServeHTTP(res, req) + tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) + httpmw.ExtractOAuth2(tp, nil, nil)(nil).ServeHTTP(res, req) location := res.Header().Get("Location") if !assert.NotEmpty(t, location) { return @@ -58,14 +70,16 @@ func TestOAuth2(t *testing.T) { t.Parallel() req := httptest.NewRequest("GET", "/?code=something", nil) res := httptest.NewRecorder() - httpmw.ExtractOAuth2(&testOAuth2Provider{}, nil)(nil).ServeHTTP(res, req) + tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) + httpmw.ExtractOAuth2(tp, 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() - httpmw.ExtractOAuth2(&testOAuth2Provider{}, nil)(nil).ServeHTTP(res, req) + tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) + httpmw.ExtractOAuth2(tp, nil, nil)(nil).ServeHTTP(res, req) require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode) }) t.Run("MismatchedState", func(t *testing.T) { @@ -76,7 +90,8 @@ func TestOAuth2(t *testing.T) { Value: "mismatch", }) res := httptest.NewRecorder() - httpmw.ExtractOAuth2(&testOAuth2Provider{}, nil)(nil).ServeHTTP(res, req) + tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) + httpmw.ExtractOAuth2(tp, nil, nil)(nil).ServeHTTP(res, req) require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode) }) t.Run("ExchangeCodeAndState", func(t *testing.T) { @@ -91,9 +106,23 @@ func TestOAuth2(t *testing.T) { Value: "/dashboard", }) res := httptest.NewRecorder() - httpmw.ExtractOAuth2(&testOAuth2Provider{}, nil)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + tp := newTestOAuth2Provider(t, oauth2.AccessTypeOffline) + httpmw.ExtractOAuth2(tp, nil, nil)(http.HandlerFunc(func(rw 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, 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. + // testOAuth2Provider does this job for us. + require.NotEmpty(t, location) + }) } diff --git a/coderd/userauth.go b/coderd/userauth.go index 7e779734755cd..558a44aa32c74 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -477,6 +477,12 @@ type OIDCConfig struct { // UsernameField selects the claim field to be used as the created user's // username. UsernameField string + // EmailField selects the claim field to be used as the created user's + // email. + EmailField string + // AuthURLParams are additional parameters to be passed to the OIDC provider + // when requesting an access token. + AuthURLParams map[string]string // GroupField selects the claim field to be used as the created user's // groups. If the group field is the empty string, then no group updates // will ever come from the OIDC provider. @@ -593,7 +599,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { username, _ = usernameRaw.(string) } - emailRaw, ok := claims["email"] + emailRaw, ok := claims[api.OIDCConfig.EmailField] if !ok { // Email is an optional claim in OIDC and // instead the email is frequently sent in diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 9548afec47c6f..b6256520c165c 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -256,6 +256,8 @@ type OIDCConfig struct { Scopes clibase.StringArray `json:"scopes" typescript:",notnull"` IgnoreEmailVerified clibase.Bool `json:"ignore_email_verified" typescript:",notnull"` UsernameField clibase.String `json:"username_field" typescript:",notnull"` + EmailField clibase.String `json:"email_field" typescript:",notnull"` + AuthURLParams clibase.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"` GroupField clibase.String `json:"groups_field" typescript:",notnull"` GroupMapping clibase.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"` SignInText clibase.String `json:"sign_in_text" typescript:",notnull"` @@ -846,10 +848,9 @@ when required by your organization's security policy.`, Description: "Ignore the email_verified claim from the upstream provider.", Flag: "oidc-ignore-email-verified", Env: "CODER_OIDC_IGNORE_EMAIL_VERIFIED", - - Value: &c.OIDC.IgnoreEmailVerified, - Group: &deploymentGroupOIDC, - YAML: "ignoreEmailVerified", + Value: &c.OIDC.IgnoreEmailVerified, + Group: &deploymentGroupOIDC, + YAML: "ignoreEmailVerified", }, { Name: "OIDC Username Field", @@ -861,6 +862,26 @@ when required by your organization's security policy.`, Group: &deploymentGroupOIDC, YAML: "usernameField", }, + { + Name: "OIDC Email Field", + Description: "OIDC claim field to use as the email.", + Flag: "oidc-email-field", + Env: "CODER_OIDC_EMAIL_FIELD", + Default: "email", + Value: &c.OIDC.EmailField, + Group: &deploymentGroupOIDC, + YAML: "emailField", + }, + { + Name: "OIDC Auth URL Parameters", + Description: "OIDC auth URL parameters to pass to the upstream provider.", + Flag: "oidc-auth-url-params", + Env: "CODER_OIDC_AUTH_URL_PARAMS", + Default: `{"access_type": "offline"}`, + Value: &c.OIDC.AuthURLParams, + Group: &deploymentGroupOIDC, + YAML: "authURLParams", + }, { Name: "OIDC Group Field", Description: "Change the OIDC default 'groups' claim field. By default, will be 'groups' if present in the oidc scopes argument.", diff --git a/docs/admin/auth.md b/docs/admin/auth.md index 3bb7becc57e78..141420cca0ab7 100644 --- a/docs/admin/auth.md +++ b/docs/admin/auth.md @@ -134,8 +134,32 @@ helm upgrade coder-v2/coder -n -f values.yaml ## OIDC Claims -Coder requires all OIDC email addresses to be verified by default. If the -`email_verified` claim is present in the token response from the identity +When a user logs in for the first time via OIDC, Coder will merge both +the claims from the ID token and the claims obtained from hitting the +upstream provider's `userinfo` endpoint, and use the resulting data +as a basis for creating a new user or looking up an existing user. + +To troubleshoot claims, set `CODER_VERBOSE=true` and follow the logs +while signing in via OIDC as a new user. Coder will log the claim fields +returned by the upstream identity provider in a message containing the +string `got oidc claims`, as well as the user info returned. + +### Email Addresses + +By default, Coder will look for the OIDC claim named `email` and use that +value for the newly created user's email address. + +If your upstream identity provider users a different claim, you can set +`CODER_OIDC_EMAIL_FIELD` to the desired claim. + +> **Note:** If this field is not present, Coder will attempt to use the +> claim field configured for `username` as an email address. If this field +> is not a valid email address, OIDC logins will fail. + +### Email Address Verification + +Coder requires all OIDC email addresses to be verified by default. If +the `email_verified` claim is present in the token response from the identity provider, Coder will validate that its value is `true`. If needed, you can disable this behavior with the following setting: @@ -144,12 +168,25 @@ CODER_OIDC_IGNORE_EMAIL_VERIFIED=true ``` > **Note:** This will cause Coder to implicitly treat all OIDC emails as -> "verified". +> "verified", regardless of what the upstream identity provider says. + +### Usernames -When a new user is created, the `preferred_username` claim becomes the username. +When a new user logs in via OIDC, Coder will by default use the value +of the claim field named `preferred_username` as the the username. If this claim is empty, the email address will be stripped of the domain, and become the username (e.g. `example@coder.com` becomes `example`). +If your upstream identity provider uses a different claim, you can +set `CODER_OIDC_USERNAME_FIELD` to the desired claim. + +> **Note:** If this claim is empty, the email address will be stripped of +> the domain, and become the username (e.g. `example@coder.com` becomes `example`). +> To avoid conflicts, Coder may also append a random word to the resulting +> username. + +## OIDC Login Customization + If you'd like to change the OpenID Connect button text and/or icon, you can configure them like so: @@ -214,3 +251,30 @@ OIDC provider will be added to the `myCoderGroupName` group in Coder. > **Note:** Groups are only updated on login. [azure-gids]: https://github.com/MicrosoftDocs/azure-docs/issues/59766#issuecomment-664387195 + +## Provider-Specific Guides + +Below are some details specific to individual OIDC providers. + +### Active Directory Federation Services (ADFS) + +> **Note:** Tested on ADFS 4.0, Windows Server 2019 + +1. In your Federation Server, create a new application group for Coder. Follow the + steps as described [here.](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/development/msal/adfs-msal-web-app-web-api#app-registration-in-ad-fs) + - **Server Application**: Note the Client ID. + - **Configure Application Credentials**: Note the Client Secret. + - **Configure Web API**: Ensure the Client ID is set as the relying party identifier. + - **Application Permissions**: Allow access to the claims `openid`, `email`, and `profile`. +1. Visit your ADFS server's `/.well-known/openid-configuration` URL and note + the value for `issuer`. + > **Note:** This is usually of the form `https://adfs.corp/adfs/.well-known/openid-configuration` +1. In Coder's configuration file (or Helm values as appropriate), set the following + environment variables or their corresponding CLI arguments: + - `CODER_OIDC_ISSUER_URL`: the `issuer` value from the previous step. + - `CODER_OIDC_CLIENT_ID`: the Client ID from step 1. + - `CODER_OIDC_CLIENT_SECRET`: the Client Secret from step 1. + - `CODER_OIDC_AUTH_URL_PARAMS`: set to `{"resource":"urn:microsoft:userinfo"}` ([see here](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/overview/ad-fs-openid-connect-oauth-flows-scenarios#:~:text=scope%E2%80%AFopenid.-,resource,-optional)). OIDC logins will fail if this is not set. +1. Ensure that Coder has the required OIDC claims by performing either of the below: + - Configure your federation server to reuturn both the `email` and `preferred_username` fields by [creating a custom claim rule](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-rule-to-send-ldap-attributes-as-claims), or + - Set `CODER_OIDC_EMAIL_FIELD="upn"`. This will use the User Principal Name as the user email, which is [guaranteed to be unique in an Active Directory Forest](https://learn.microsoft.com/en-us/windows/win32/ad/naming-properties#upn-format). diff --git a/docs/api/general.md b/docs/api/general.md index 33b377a059c1b..43dafb41e5d51 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -231,9 +231,11 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ }, "oidc": { "allow_signups": true, + "auth_url_params": {}, "client_id": "string", "client_secret": "string", "email_domain": ["string"], + "email_field": "string", "group_mapping": {}, "groups_field": "string", "icon_url": { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 9a92f20f1ca3b..06f1066680317 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1798,9 +1798,11 @@ CreateParameterRequest is a structure used to create a new parameter value for a }, "oidc": { "allow_signups": true, + "auth_url_params": {}, "client_id": "string", "client_secret": "string", "email_domain": ["string"], + "email_field": "string", "group_mapping": {}, "groups_field": "string", "icon_url": { @@ -2144,9 +2146,11 @@ CreateParameterRequest is a structure used to create a new parameter value for a }, "oidc": { "allow_signups": true, + "auth_url_params": {}, "client_id": "string", "client_secret": "string", "email_domain": ["string"], + "email_field": "string", "group_mapping": {}, "groups_field": "string", "icon_url": { @@ -2808,9 +2812,11 @@ CreateParameterRequest is a structure used to create a new parameter value for a ```json { "allow_signups": true, + "auth_url_params": {}, "client_id": "string", "client_secret": "string", "email_domain": ["string"], + "email_field": "string", "group_mapping": {}, "groups_field": "string", "icon_url": { @@ -2839,9 +2845,11 @@ CreateParameterRequest is a structure used to create a new parameter value for a | Name | Type | Required | Restrictions | Description | | ----------------------- | -------------------------- | -------- | ------------ | ----------- | | `allow_signups` | boolean | false | | | +| `auth_url_params` | object | false | | | | `client_id` | string | false | | | | `client_secret` | string | false | | | | `email_domain` | array of string | false | | | +| `email_field` | string | false | | | | `group_mapping` | object | false | | | | `groups_field` | string | false | | | | `icon_url` | [clibase.URL](#clibaseurl) | false | | | diff --git a/docs/cli/server.md b/docs/cli/server.md index 60df9f642ad23..32aa32ae070cc 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -310,6 +310,16 @@ Base URL of a GitHub Enterprise deployment to use for Login with GitHub. Whether new users can sign up with OIDC. +### --oidc-auth-url-params + +| | | +| ----------- | ---------------------------------------- | +| Type | struct[map[string]string] | +| Environment | $CODER_OIDC_AUTH_URL_PARAMS | +| Default | {"access_type": "offline"} | + +OIDC auth URL parameters to pass to the upstream provider. + ### --oidc-client-id | | | @@ -337,6 +347,16 @@ Client secret to use for Login with OIDC. Email domains that clients logging in with OIDC must match. +### --oidc-email-field + +| | | +| ----------- | ------------------------------------ | +| Type | string | +| Environment | $CODER_OIDC_EMAIL_FIELD | +| Default | email | + +OIDC claim field to use as the email. + ### --oidc-group-field | | | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5a17c399aafde..a1e96934b4308 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -516,6 +516,10 @@ export interface OIDCConfig { readonly scopes: string[] readonly ignore_email_verified: boolean readonly username_field: string + readonly email_field: string + // Named type "github.com/coder/coder/cli/clibase.Struct[map[string]string]" unknown, using "any" + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type + readonly auth_url_params: any readonly groups_field: string // Named type "github.com/coder/coder/cli/clibase.Struct[map[string]string]" unknown, using "any" // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type