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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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,
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(),

View File

@ -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()

View File

@ -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.

6
coderd/apidoc/docs.go generated
View File

@ -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"
},

View File

@ -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"
},

View File

@ -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)
})
})

View File

@ -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 {

View File

@ -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())

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -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

View File

@ -255,6 +255,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"`
@ -845,10 +847,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",
@ -860,6 +861,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.",

View File

@ -134,8 +134,32 @@ helm upgrade <release-name> coder-v2/coder -n <namespace> -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.
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
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).

View File

@ -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": {

View File

@ -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 | | |

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.
### --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
| | |
@ -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 | <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
| | |

View File

@ -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