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

This commit:

- Allows configuring the OIDC claim Coder uses for email addresses (by default, this is still email)
- Allows customising the parameters sent to the upstream identity provider when requesting a token. This is still access_type=offline by default.
- Updates documentation related to the above.
This commit is contained in:
Cian Johnston
2023-03-30 09:36:57 +01:00
committed by GitHub
parent 6981f89cd8
commit 563c3ade06
17 changed files with 379 additions and 22 deletions

View File

@@ -726,6 +726,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
EmailDomain: cfg.OIDC.EmailDomain, EmailDomain: cfg.OIDC.EmailDomain,
AllowSignups: cfg.OIDC.AllowSignups.Value(), AllowSignups: cfg.OIDC.AllowSignups.Value(),
UsernameField: cfg.OIDC.UsernameField.String(), UsernameField: cfg.OIDC.UsernameField.String(),
EmailField: cfg.OIDC.EmailField.String(),
AuthURLParams: cfg.OIDC.AuthURLParams.Value,
GroupField: cfg.OIDC.GroupField.String(), GroupField: cfg.OIDC.GroupField.String(),
GroupMapping: cfg.OIDC.GroupMapping.Value, GroupMapping: cfg.OIDC.GroupMapping.Value,
SignInText: cfg.OIDC.SignInText.String(), SignInText: cfg.OIDC.SignInText.String(),

View File

@@ -37,6 +37,7 @@ import (
"github.com/coder/coder/coderd/database/postgres" "github.com/coder/coder/coderd/database/postgres"
"github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/pty/ptytest" "github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil" "github.com/coder/coder/testutil"
) )
@@ -1016,6 +1017,166 @@ func TestServer(t *testing.T) {
require.True(t, strings.HasPrefix(fakeURL.String(), fakeRedirect), fakeURL.String()) 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.Run("RateLimit", func(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -261,6 +261,9 @@ can safely ignore these settings.
--oidc-allow-signups bool, $CODER_OIDC_ALLOW_SIGNUPS (default: true) --oidc-allow-signups bool, $CODER_OIDC_ALLOW_SIGNUPS (default: true)
Whether new users can sign up with OIDC. 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 --oidc-client-id string, $CODER_OIDC_CLIENT_ID
Client ID to use for Login with OIDC. 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 --oidc-email-domain string-array, $CODER_OIDC_EMAIL_DOMAIN
Email domains that clients logging in with OIDC must match. 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 --oidc-group-field string, $CODER_OIDC_GROUP_FIELD
Change the OIDC default 'groups' claim field. By default, will be Change the OIDC default 'groups' claim field. By default, will be
'groups' if present in the oidc scopes argument. 'groups' if present in the oidc scopes argument.

6
coderd/apidoc/docs.go generated
View File

@@ -7294,6 +7294,9 @@ const docTemplate = `{
"allow_signups": { "allow_signups": {
"type": "boolean" "type": "boolean"
}, },
"auth_url_params": {
"type": "object"
},
"client_id": { "client_id": {
"type": "string" "type": "string"
}, },
@@ -7306,6 +7309,9 @@ const docTemplate = `{
"type": "string" "type": "string"
} }
}, },
"email_field": {
"type": "string"
},
"group_mapping": { "group_mapping": {
"type": "object" "type": "object"
}, },

View File

@@ -6532,6 +6532,9 @@
"allow_signups": { "allow_signups": {
"type": "boolean" "type": "boolean"
}, },
"auth_url_params": {
"type": "object"
},
"client_id": { "client_id": {
"type": "string" "type": "string"
}, },
@@ -6544,6 +6547,9 @@
"type": "string" "type": "string"
} }
}, },
"email_field": {
"type": "string"
},
"group_mapping": { "group_mapping": {
"type": "object" "type": "object"
}, },

View File

@@ -301,6 +301,12 @@ func New(options *Options) *API {
*options.UpdateCheckOptions, *options.UpdateCheckOptions,
) )
} }
var oidcAuthURLParams map[string]string
if options.OIDCConfig != nil {
oidcAuthURLParams = options.OIDCConfig.AuthURLParams
}
api.Auditor.Store(&options.Auditor) api.Auditor.Store(&options.Auditor)
api.TemplateScheduleStore.Store(&options.TemplateScheduleStore) api.TemplateScheduleStore.Store(&options.TemplateScheduleStore)
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0) api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0)
@@ -387,7 +393,7 @@ func New(options *Options) *API {
for _, gitAuthConfig := range options.GitAuthConfigs { for _, gitAuthConfig := range options.GitAuthConfigs {
r.Route(fmt.Sprintf("/%s", gitAuthConfig.ID), func(r chi.Router) { r.Route(fmt.Sprintf("/%s", gitAuthConfig.ID), func(r chi.Router) {
r.Use( r.Use(
httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient), httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient, nil),
apiKeyMiddleware, apiKeyMiddleware,
) )
r.Get("/callback", api.gitAuthCallback(gitAuthConfig)) r.Get("/callback", api.gitAuthCallback(gitAuthConfig))
@@ -531,12 +537,12 @@ func New(options *Options) *API {
r.Post("/login", api.postLogin) r.Post("/login", api.postLogin)
r.Route("/oauth2", func(r chi.Router) { r.Route("/oauth2", func(r chi.Router) {
r.Route("/github", 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.Get("/callback", api.userOAuth2Github)
}) })
}) })
r.Route("/oidc/callback", func(r chi.Router) { 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) r.Get("/", api.userOIDC)
}) })
}) })

View File

@@ -967,6 +967,8 @@ func (o *OIDCConfig) OIDCConfig(t *testing.T, userInfoClaims jwt.MapClaims, opts
}), }),
Provider: provider, Provider: provider,
UsernameField: "preferred_username", UsernameField: "preferred_username",
EmailField: "email",
AuthURLParams: map[string]string{"access_type": "offline"},
GroupField: "groups", GroupField: "groups",
} }
for _, opt := range opts { for _, opt := range opts {

View File

@@ -21,6 +21,8 @@ func TestDeploymentValues(t *testing.T) {
// values should not be returned // values should not be returned
cfg.OAuth2.Github.ClientSecret.Set(hi) cfg.OAuth2.Github.ClientSecret.Set(hi)
cfg.OIDC.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.PostgresURL.Set(hi)
cfg.SCIMAPIKey.Set(hi) cfg.SCIMAPIKey.Set(hi)
@@ -32,6 +34,10 @@ func TestDeploymentValues(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// ensure normal values pass through // ensure normal values pass through
require.EqualValues(t, true, scrubbed.Values.BrowserOnly.Value()) 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 // ensure secrets are removed
require.Empty(t, scrubbed.Values.OAuth2.Github.ClientSecret.Value()) require.Empty(t, scrubbed.Values.OAuth2.Github.ClientSecret.Value())
require.Empty(t, scrubbed.Values.OIDC.ClientSecret.Value()) require.Empty(t, scrubbed.Values.OIDC.ClientSecret.Value())

View File

@@ -40,7 +40,15 @@ func OAuth2(r *http.Request) OAuth2State {
// ExtractOAuth2 is a middleware for automatically redirecting to OAuth // ExtractOAuth2 is a middleware for automatically redirecting to OAuth
// URLs, and handling the exchange inbound. Any route that does not have // URLs, and handling the exchange inbound. Any route that does not have
// a "code" URL parameter will be redirected. // 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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
@@ -109,7 +117,7 @@ func ExtractOAuth2(config OAuth2Config, client *http.Client) func(http.Handler)
SameSite: http.SameSiteLaxMode, 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 return
} }

View File

@@ -15,9 +15,13 @@ import (
"github.com/coder/coder/codersdk" "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) return "?state=" + url.QueryEscape(state)
} }
@@ -31,6 +35,13 @@ func (*testOAuth2Provider) TokenSource(_ context.Context, _ *oauth2.Token) oauth
return nil return nil
} }
func newTestOAuth2Provider(t testing.TB, opts ...oauth2.AuthCodeOption) *testOAuth2Provider {
return &testOAuth2Provider{
t: t,
authOpts: opts,
}
}
// nolint:bodyclose // nolint:bodyclose
func TestOAuth2(t *testing.T) { func TestOAuth2(t *testing.T) {
t.Parallel() t.Parallel()
@@ -38,14 +49,15 @@ func TestOAuth2(t *testing.T) {
t.Parallel() t.Parallel()
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
res := httptest.NewRecorder() 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) require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
}) })
t.Run("RedirectWithoutCode", func(t *testing.T) { t.Run("RedirectWithoutCode", func(t *testing.T) {
t.Parallel() t.Parallel()
req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape("/dashboard"), nil) req := httptest.NewRequest("GET", "/?redirect="+url.QueryEscape("/dashboard"), nil)
res := httptest.NewRecorder() 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") location := res.Header().Get("Location")
if !assert.NotEmpty(t, location) { if !assert.NotEmpty(t, location) {
return return
@@ -58,14 +70,16 @@ func TestOAuth2(t *testing.T) {
t.Parallel() t.Parallel()
req := httptest.NewRequest("GET", "/?code=something", nil) req := httptest.NewRequest("GET", "/?code=something", nil)
res := httptest.NewRecorder() 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) require.Equal(t, http.StatusBadRequest, res.Result().StatusCode)
}) })
t.Run("NoStateCookie", func(t *testing.T) { t.Run("NoStateCookie", func(t *testing.T) {
t.Parallel() t.Parallel()
req := httptest.NewRequest("GET", "/?code=something&state=test", nil) req := httptest.NewRequest("GET", "/?code=something&state=test", nil)
res := httptest.NewRecorder() 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) require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode)
}) })
t.Run("MismatchedState", func(t *testing.T) { t.Run("MismatchedState", func(t *testing.T) {
@@ -76,7 +90,8 @@ func TestOAuth2(t *testing.T) {
Value: "mismatch", Value: "mismatch",
}) })
res := httptest.NewRecorder() 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) require.Equal(t, http.StatusUnauthorized, res.Result().StatusCode)
}) })
t.Run("ExchangeCodeAndState", func(t *testing.T) { t.Run("ExchangeCodeAndState", func(t *testing.T) {
@@ -91,9 +106,23 @@ func TestOAuth2(t *testing.T) {
Value: "/dashboard", Value: "/dashboard",
}) })
res := httptest.NewRecorder() 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) state := httpmw.OAuth2(r)
require.Equal(t, "/dashboard", state.Redirect) require.Equal(t, "/dashboard", state.Redirect)
})).ServeHTTP(res, req) })).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)
})
} }

View File

@@ -477,6 +477,12 @@ type OIDCConfig struct {
// UsernameField selects the claim field to be used as the created user's // UsernameField selects the claim field to be used as the created user's
// username. // username.
UsernameField string 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 // 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 // groups. If the group field is the empty string, then no group updates
// will ever come from the OIDC provider. // will ever come from the OIDC provider.
@@ -593,7 +599,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
username, _ = usernameRaw.(string) username, _ = usernameRaw.(string)
} }
emailRaw, ok := claims["email"] emailRaw, ok := claims[api.OIDCConfig.EmailField]
if !ok { if !ok {
// Email is an optional claim in OIDC and // Email is an optional claim in OIDC and
// instead the email is frequently sent in // instead the email is frequently sent in

View File

@@ -255,6 +255,8 @@ type OIDCConfig struct {
Scopes clibase.StringArray `json:"scopes" typescript:",notnull"` Scopes clibase.StringArray `json:"scopes" typescript:",notnull"`
IgnoreEmailVerified clibase.Bool `json:"ignore_email_verified" typescript:",notnull"` IgnoreEmailVerified clibase.Bool `json:"ignore_email_verified" typescript:",notnull"`
UsernameField clibase.String `json:"username_field" 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"` GroupField clibase.String `json:"groups_field" typescript:",notnull"`
GroupMapping clibase.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"` GroupMapping clibase.Struct[map[string]string] `json:"group_mapping" typescript:",notnull"`
SignInText clibase.String `json:"sign_in_text" typescript:",notnull"` SignInText clibase.String `json:"sign_in_text" typescript:",notnull"`
@@ -845,10 +847,9 @@ when required by your organization's security policy.`,
Description: "Ignore the email_verified claim from the upstream provider.", Description: "Ignore the email_verified claim from the upstream provider.",
Flag: "oidc-ignore-email-verified", Flag: "oidc-ignore-email-verified",
Env: "CODER_OIDC_IGNORE_EMAIL_VERIFIED", Env: "CODER_OIDC_IGNORE_EMAIL_VERIFIED",
Value: &c.OIDC.IgnoreEmailVerified,
Value: &c.OIDC.IgnoreEmailVerified, Group: &deploymentGroupOIDC,
Group: &deploymentGroupOIDC, YAML: "ignoreEmailVerified",
YAML: "ignoreEmailVerified",
}, },
{ {
Name: "OIDC Username Field", Name: "OIDC Username Field",
@@ -860,6 +861,26 @@ when required by your organization's security policy.`,
Group: &deploymentGroupOIDC, Group: &deploymentGroupOIDC,
YAML: "usernameField", 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", Name: "OIDC Group Field",
Description: "Change the OIDC default 'groups' claim field. By default, will be 'groups' if present in the oidc scopes argument.", Description: "Change the OIDC default 'groups' claim field. By default, will be 'groups' if present in the oidc scopes argument.",

View File

@@ -134,8 +134,32 @@ helm upgrade <release-name> coder-v2/coder -n <namespace> -f values.yaml
## OIDC Claims ## OIDC Claims
Coder requires all OIDC email addresses to be verified by default. If the When a user logs in for the first time via OIDC, Coder will merge both
`email_verified` claim is present in the token response from the identity 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 provider, Coder will validate that its value is `true`. If needed, you can
disable this behavior with the following setting: 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 > **Note:** This will cause Coder to implicitly treat all OIDC emails as
> "verified". > "verified", regardless of what the upstream identity provider says.
When a new user is created, the `preferred_username` claim becomes the username. ### Usernames
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 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`). 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 If you'd like to change the OpenID Connect button text and/or icon, you can
configure them like so: 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. > **Note:** Groups are only updated on login.
[azure-gids]: https://github.com/MicrosoftDocs/azure-docs/issues/59766#issuecomment-664387195 [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).

View File

@@ -231,9 +231,11 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
}, },
"oidc": { "oidc": {
"allow_signups": true, "allow_signups": true,
"auth_url_params": {},
"client_id": "string", "client_id": "string",
"client_secret": "string", "client_secret": "string",
"email_domain": ["string"], "email_domain": ["string"],
"email_field": "string",
"group_mapping": {}, "group_mapping": {},
"groups_field": "string", "groups_field": "string",
"icon_url": { "icon_url": {

View File

@@ -1798,9 +1798,11 @@ CreateParameterRequest is a structure used to create a new parameter value for a
}, },
"oidc": { "oidc": {
"allow_signups": true, "allow_signups": true,
"auth_url_params": {},
"client_id": "string", "client_id": "string",
"client_secret": "string", "client_secret": "string",
"email_domain": ["string"], "email_domain": ["string"],
"email_field": "string",
"group_mapping": {}, "group_mapping": {},
"groups_field": "string", "groups_field": "string",
"icon_url": { "icon_url": {
@@ -2144,9 +2146,11 @@ CreateParameterRequest is a structure used to create a new parameter value for a
}, },
"oidc": { "oidc": {
"allow_signups": true, "allow_signups": true,
"auth_url_params": {},
"client_id": "string", "client_id": "string",
"client_secret": "string", "client_secret": "string",
"email_domain": ["string"], "email_domain": ["string"],
"email_field": "string",
"group_mapping": {}, "group_mapping": {},
"groups_field": "string", "groups_field": "string",
"icon_url": { "icon_url": {
@@ -2808,9 +2812,11 @@ CreateParameterRequest is a structure used to create a new parameter value for a
```json ```json
{ {
"allow_signups": true, "allow_signups": true,
"auth_url_params": {},
"client_id": "string", "client_id": "string",
"client_secret": "string", "client_secret": "string",
"email_domain": ["string"], "email_domain": ["string"],
"email_field": "string",
"group_mapping": {}, "group_mapping": {},
"groups_field": "string", "groups_field": "string",
"icon_url": { "icon_url": {
@@ -2839,9 +2845,11 @@ CreateParameterRequest is a structure used to create a new parameter value for a
| Name | Type | Required | Restrictions | Description | | Name | Type | Required | Restrictions | Description |
| ----------------------- | -------------------------- | -------- | ------------ | ----------- | | ----------------------- | -------------------------- | -------- | ------------ | ----------- |
| `allow_signups` | boolean | false | | | | `allow_signups` | boolean | false | | |
| `auth_url_params` | object | false | | |
| `client_id` | string | false | | | | `client_id` | string | false | | |
| `client_secret` | string | false | | | | `client_secret` | string | false | | |
| `email_domain` | array of string | false | | | | `email_domain` | array of string | false | | |
| `email_field` | string | false | | |
| `group_mapping` | object | false | | | | `group_mapping` | object | false | | |
| `groups_field` | string | false | | | | `groups_field` | string | false | | |
| `icon_url` | [clibase.URL](#clibaseurl) | false | | | | `icon_url` | [clibase.URL](#clibaseurl) | false | | |

View File

@@ -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. Whether new users can sign up with OIDC.
### --oidc-auth-url-params
| | |
| ----------- | ---------------------------------------- |
| Type | <code>struct[map[string]string]</code> |
| Environment | <code>$CODER_OIDC_AUTH_URL_PARAMS</code> |
| Default | <code>{"access_type": "offline"}</code> |
OIDC auth URL parameters to pass to the upstream provider.
### --oidc-client-id ### --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. Email domains that clients logging in with OIDC must match.
### --oidc-email-field
| | |
| ----------- | ------------------------------------ |
| Type | <code>string</code> |
| Environment | <code>$CODER_OIDC_EMAIL_FIELD</code> |
| Default | <code>email</code> |
OIDC claim field to use as the email.
### --oidc-group-field ### --oidc-group-field
| | | | | |

View File

@@ -516,6 +516,10 @@ export interface OIDCConfig {
readonly scopes: string[] readonly scopes: string[]
readonly ignore_email_verified: boolean readonly ignore_email_verified: boolean
readonly username_field: string 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 readonly groups_field: string
// Named type "github.com/coder/coder/cli/clibase.Struct[map[string]string]" unknown, using "any" // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- External type