Skip to content

Commit 563c3ad

Browse files
authored
feat: allow configuring OIDC email claim and OIDC auth url parameters (coder#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.
1 parent 6981f89 commit 563c3ad

17 files changed

+379
-22
lines changed

cli/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
726726
EmailDomain: cfg.OIDC.EmailDomain,
727727
AllowSignups: cfg.OIDC.AllowSignups.Value(),
728728
UsernameField: cfg.OIDC.UsernameField.String(),
729+
EmailField: cfg.OIDC.EmailField.String(),
730+
AuthURLParams: cfg.OIDC.AuthURLParams.Value,
729731
GroupField: cfg.OIDC.GroupField.String(),
730732
GroupMapping: cfg.OIDC.GroupMapping.Value,
731733
SignInText: cfg.OIDC.SignInText.String(),

cli/server_test.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"github.com/coder/coder/coderd/database/postgres"
3838
"github.com/coder/coder/coderd/telemetry"
3939
"github.com/coder/coder/codersdk"
40+
"github.com/coder/coder/cryptorand"
4041
"github.com/coder/coder/pty/ptytest"
4142
"github.com/coder/coder/testutil"
4243
)
@@ -1016,6 +1017,166 @@ func TestServer(t *testing.T) {
10161017
require.True(t, strings.HasPrefix(fakeURL.String(), fakeRedirect), fakeURL.String())
10171018
})
10181019

1020+
t.Run("OIDC", func(t *testing.T) {
1021+
t.Parallel()
1022+
1023+
t.Run("Defaults", func(t *testing.T) {
1024+
t.Parallel()
1025+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
1026+
defer cancel()
1027+
1028+
// Startup a fake server that just responds to .well-known/openid-configuration
1029+
// This is just needed to get Coder to start up.
1030+
oidcServer := httptest.NewServer(nil)
1031+
fakeWellKnownHandler := func(w http.ResponseWriter, r *http.Request) {
1032+
w.Header().Set("Content-Type", "application/json")
1033+
payload := fmt.Sprintf("{\"issuer\": %q}", oidcServer.URL)
1034+
_, _ = w.Write([]byte(payload))
1035+
}
1036+
oidcServer.Config.Handler = http.HandlerFunc(fakeWellKnownHandler)
1037+
t.Cleanup(oidcServer.Close)
1038+
1039+
inv, cfg := clitest.New(t,
1040+
"server",
1041+
"--in-memory",
1042+
"--http-address", ":0",
1043+
"--access-url", "http://example.com",
1044+
"--oidc-client-id", "fake",
1045+
"--oidc-client-secret", "fake",
1046+
"--oidc-issuer-url", oidcServer.URL,
1047+
// Leaving the rest of the flags as defaults.
1048+
)
1049+
1050+
// Ensure that the server starts up without error.
1051+
clitest.Start(t, inv)
1052+
accessURL := waitAccessURL(t, cfg)
1053+
client := codersdk.New(accessURL)
1054+
1055+
randPassword, err := cryptorand.String(24)
1056+
require.NoError(t, err)
1057+
1058+
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
1059+
Email: "admin@coder.com",
1060+
Password: randPassword,
1061+
Username: "admin",
1062+
Trial: true,
1063+
})
1064+
require.NoError(t, err)
1065+
1066+
loginResp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
1067+
Email: "admin@coder.com",
1068+
Password: randPassword,
1069+
})
1070+
require.NoError(t, err)
1071+
client.SetSessionToken(loginResp.SessionToken)
1072+
1073+
deploymentConfig, err := client.DeploymentConfig(ctx)
1074+
require.NoError(t, err)
1075+
1076+
// Ensure that the OIDC provider is configured correctly.
1077+
require.Equal(t, "fake", deploymentConfig.Values.OIDC.ClientID.Value())
1078+
// The client secret is not returned from the API.
1079+
require.Empty(t, deploymentConfig.Values.OIDC.ClientSecret.Value())
1080+
require.Equal(t, oidcServer.URL, deploymentConfig.Values.OIDC.IssuerURL.Value())
1081+
// These are the default values returned from the API. See codersdk/deployment.go for the default values.
1082+
require.True(t, deploymentConfig.Values.OIDC.AllowSignups.Value())
1083+
require.Empty(t, deploymentConfig.Values.OIDC.EmailDomain.Value())
1084+
require.Equal(t, []string{"openid", "profile", "email"}, deploymentConfig.Values.OIDC.Scopes.Value())
1085+
require.False(t, deploymentConfig.Values.OIDC.IgnoreEmailVerified.Value())
1086+
require.Equal(t, "preferred_username", deploymentConfig.Values.OIDC.UsernameField.Value())
1087+
require.Equal(t, "email", deploymentConfig.Values.OIDC.EmailField.Value())
1088+
require.Equal(t, map[string]string{"access_type": "offline"}, deploymentConfig.Values.OIDC.AuthURLParams.Value)
1089+
require.Empty(t, deploymentConfig.Values.OIDC.GroupField.Value())
1090+
require.Empty(t, deploymentConfig.Values.OIDC.GroupMapping.Value)
1091+
require.Equal(t, "OpenID Connect", deploymentConfig.Values.OIDC.SignInText.Value())
1092+
require.Empty(t, deploymentConfig.Values.OIDC.IconURL.Value())
1093+
})
1094+
1095+
t.Run("Overrides", func(t *testing.T) {
1096+
t.Parallel()
1097+
1098+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
1099+
defer cancel()
1100+
1101+
// Startup a fake server that just responds to .well-known/openid-configuration
1102+
// This is just needed to get Coder to start up.
1103+
oidcServer := httptest.NewServer(nil)
1104+
fakeWellKnownHandler := func(w http.ResponseWriter, r *http.Request) {
1105+
w.Header().Set("Content-Type", "application/json")
1106+
payload := fmt.Sprintf("{\"issuer\": %q}", oidcServer.URL)
1107+
_, _ = w.Write([]byte(payload))
1108+
}
1109+
oidcServer.Config.Handler = http.HandlerFunc(fakeWellKnownHandler)
1110+
t.Cleanup(oidcServer.Close)
1111+
1112+
inv, cfg := clitest.New(t,
1113+
"server",
1114+
"--in-memory",
1115+
"--http-address", ":0",
1116+
"--access-url", "http://example.com",
1117+
"--oidc-client-id", "fake",
1118+
"--oidc-client-secret", "fake",
1119+
"--oidc-issuer-url", oidcServer.URL,
1120+
// The following values have defaults that we want to override.
1121+
"--oidc-allow-signups=false",
1122+
"--oidc-email-domain", "example.com",
1123+
"--oidc-scopes", "360noscope",
1124+
"--oidc-ignore-email-verified",
1125+
"--oidc-username-field", "not_preferred_username",
1126+
"--oidc-email-field", "not_email",
1127+
"--oidc-auth-url-params", `{"prompt":"consent"}`,
1128+
"--oidc-group-field", "serious_business_unit",
1129+
"--oidc-group-mapping", `{"serious_business_unit": "serious_business_unit"}`,
1130+
"--oidc-sign-in-text", "Sign In With Coder",
1131+
"--oidc-icon-url", "https://example.com/icon.png",
1132+
)
1133+
1134+
// Ensure that the server starts up without error.
1135+
clitest.Start(t, inv)
1136+
accessURL := waitAccessURL(t, cfg)
1137+
client := codersdk.New(accessURL)
1138+
1139+
randPassword, err := cryptorand.String(24)
1140+
require.NoError(t, err)
1141+
1142+
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
1143+
Email: "admin@coder.com",
1144+
Password: randPassword,
1145+
Username: "admin",
1146+
Trial: true,
1147+
})
1148+
require.NoError(t, err)
1149+
1150+
loginResp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
1151+
Email: "admin@coder.com",
1152+
Password: randPassword,
1153+
})
1154+
require.NoError(t, err)
1155+
client.SetSessionToken(loginResp.SessionToken)
1156+
1157+
deploymentConfig, err := client.DeploymentConfig(ctx)
1158+
require.NoError(t, err)
1159+
1160+
// Ensure that the OIDC provider is configured correctly.
1161+
require.Equal(t, "fake", deploymentConfig.Values.OIDC.ClientID.Value())
1162+
// The client secret is not returned from the API.
1163+
require.Empty(t, deploymentConfig.Values.OIDC.ClientSecret.Value())
1164+
require.Equal(t, oidcServer.URL, deploymentConfig.Values.OIDC.IssuerURL.Value())
1165+
// These are values that we want to make sure were overridden.
1166+
require.False(t, deploymentConfig.Values.OIDC.AllowSignups.Value())
1167+
require.Equal(t, []string{"example.com"}, deploymentConfig.Values.OIDC.EmailDomain.Value())
1168+
require.Equal(t, []string{"360noscope"}, deploymentConfig.Values.OIDC.Scopes.Value())
1169+
require.True(t, deploymentConfig.Values.OIDC.IgnoreEmailVerified.Value())
1170+
require.Equal(t, "not_preferred_username", deploymentConfig.Values.OIDC.UsernameField.Value())
1171+
require.Equal(t, "not_email", deploymentConfig.Values.OIDC.EmailField.Value())
1172+
require.Equal(t, map[string]string{"access_type": "offline", "prompt": "consent"}, deploymentConfig.Values.OIDC.AuthURLParams.Value)
1173+
require.Equal(t, "serious_business_unit", deploymentConfig.Values.OIDC.GroupField.Value())
1174+
require.Equal(t, map[string]string{"serious_business_unit": "serious_business_unit"}, deploymentConfig.Values.OIDC.GroupMapping.Value)
1175+
require.Equal(t, "Sign In With Coder", deploymentConfig.Values.OIDC.SignInText.Value())
1176+
require.Equal(t, "https://example.com/icon.png", deploymentConfig.Values.OIDC.IconURL.Value().String())
1177+
})
1178+
})
1179+
10191180
t.Run("RateLimit", func(t *testing.T) {
10201181
t.Parallel()
10211182

cli/testdata/coder_server_--help.golden

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,9 @@ can safely ignore these settings.
261261
--oidc-allow-signups bool, $CODER_OIDC_ALLOW_SIGNUPS (default: true)
262262
Whether new users can sign up with OIDC.
263263

264+
--oidc-auth-url-params struct[map[string]string], $CODER_OIDC_AUTH_URL_PARAMS (default: {"access_type": "offline"})
265+
OIDC auth URL parameters to pass to the upstream provider.
266+
264267
--oidc-client-id string, $CODER_OIDC_CLIENT_ID
265268
Client ID to use for Login with OIDC.
266269

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

276+
--oidc-email-field string, $CODER_OIDC_EMAIL_FIELD (default: email)
277+
OIDC claim field to use as the email.
278+
273279
--oidc-group-field string, $CODER_OIDC_GROUP_FIELD
274280
Change the OIDC default 'groups' claim field. By default, will be
275281
'groups' if present in the oidc scopes argument.

coderd/apidoc/docs.go

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,12 @@ func New(options *Options) *API {
301301
*options.UpdateCheckOptions,
302302
)
303303
}
304+
305+
var oidcAuthURLParams map[string]string
306+
if options.OIDCConfig != nil {
307+
oidcAuthURLParams = options.OIDCConfig.AuthURLParams
308+
}
309+
304310
api.Auditor.Store(&options.Auditor)
305311
api.TemplateScheduleStore.Store(&options.TemplateScheduleStore)
306312
api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0)
@@ -387,7 +393,7 @@ func New(options *Options) *API {
387393
for _, gitAuthConfig := range options.GitAuthConfigs {
388394
r.Route(fmt.Sprintf("/%s", gitAuthConfig.ID), func(r chi.Router) {
389395
r.Use(
390-
httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient),
396+
httpmw.ExtractOAuth2(gitAuthConfig, options.HTTPClient, nil),
391397
apiKeyMiddleware,
392398
)
393399
r.Get("/callback", api.gitAuthCallback(gitAuthConfig))
@@ -531,12 +537,12 @@ func New(options *Options) *API {
531537
r.Post("/login", api.postLogin)
532538
r.Route("/oauth2", func(r chi.Router) {
533539
r.Route("/github", func(r chi.Router) {
534-
r.Use(httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient))
540+
r.Use(httpmw.ExtractOAuth2(options.GithubOAuth2Config, options.HTTPClient, nil))
535541
r.Get("/callback", api.userOAuth2Github)
536542
})
537543
})
538544
r.Route("/oidc/callback", func(r chi.Router) {
539-
r.Use(httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient))
545+
r.Use(httpmw.ExtractOAuth2(options.OIDCConfig, options.HTTPClient, oidcAuthURLParams))
540546
r.Get("/", api.userOIDC)
541547
})
542548
})

coderd/coderdtest/coderdtest.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,8 @@ func (o *OIDCConfig) OIDCConfig(t *testing.T, userInfoClaims jwt.MapClaims, opts
967967
}),
968968
Provider: provider,
969969
UsernameField: "preferred_username",
970+
EmailField: "email",
971+
AuthURLParams: map[string]string{"access_type": "offline"},
970972
GroupField: "groups",
971973
}
972974
for _, opt := range opts {

coderd/deployment_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ func TestDeploymentValues(t *testing.T) {
2121
// values should not be returned
2222
cfg.OAuth2.Github.ClientSecret.Set(hi)
2323
cfg.OIDC.ClientSecret.Set(hi)
24+
cfg.OIDC.AuthURLParams.Set(`{"foo":"bar"}`)
25+
cfg.OIDC.EmailField.Set("some_random_field_you_never_expected")
2426
cfg.PostgresURL.Set(hi)
2527
cfg.SCIMAPIKey.Set(hi)
2628

@@ -32,6 +34,10 @@ func TestDeploymentValues(t *testing.T) {
3234
require.NoError(t, err)
3335
// ensure normal values pass through
3436
require.EqualValues(t, true, scrubbed.Values.BrowserOnly.Value())
37+
require.NotEmpty(t, cfg.OIDC.AuthURLParams)
38+
require.EqualValues(t, cfg.OIDC.AuthURLParams, scrubbed.Values.OIDC.AuthURLParams)
39+
require.NotEmpty(t, cfg.OIDC.EmailField)
40+
require.EqualValues(t, cfg.OIDC.EmailField, scrubbed.Values.OIDC.EmailField)
3541
// ensure secrets are removed
3642
require.Empty(t, scrubbed.Values.OAuth2.Github.ClientSecret.Value())
3743
require.Empty(t, scrubbed.Values.OIDC.ClientSecret.Value())

coderd/httpmw/oauth2.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,15 @@ func OAuth2(r *http.Request) OAuth2State {
4040
// ExtractOAuth2 is a middleware for automatically redirecting to OAuth
4141
// URLs, and handling the exchange inbound. Any route that does not have
4242
// a "code" URL parameter will be redirected.
43-
func ExtractOAuth2(config OAuth2Config, client *http.Client) func(http.Handler) http.Handler {
43+
// AuthURLOpts are passed to the AuthCodeURL function. If this is nil,
44+
// the default option oauth2.AccessTypeOffline will be used.
45+
func ExtractOAuth2(config OAuth2Config, client *http.Client, authURLOpts map[string]string) func(http.Handler) http.Handler {
46+
opts := make([]oauth2.AuthCodeOption, 0, len(authURLOpts)+1)
47+
opts = append(opts, oauth2.AccessTypeOffline)
48+
for k, v := range authURLOpts {
49+
opts = append(opts, oauth2.SetAuthURLParam(k, v))
50+
}
51+
4452
return func(next http.Handler) http.Handler {
4553
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
4654
ctx := r.Context()
@@ -109,7 +117,7 @@ func ExtractOAuth2(config OAuth2Config, client *http.Client) func(http.Handler)
109117
SameSite: http.SameSiteLaxMode,
110118
})
111119

112-
http.Redirect(rw, r, config.AuthCodeURL(state, oauth2.AccessTypeOffline), http.StatusTemporaryRedirect)
120+
http.Redirect(rw, r, config.AuthCodeURL(state, opts...), http.StatusTemporaryRedirect)
113121
return
114122
}
115123

0 commit comments

Comments
 (0)