diff --git a/.vscode/settings.json b/.vscode/settings.json index 74308369e644f..81a974f40a0a5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -42,6 +42,7 @@ "mattn", "mitchellh", "moby", + "namesgenerator", "nfpms", "nhooyr", "nolint", diff --git a/cli/server.go b/cli/server.go index 41e5905b0f976..83ba9c33bd91e 100644 --- a/cli/server.go +++ b/cli/server.go @@ -23,6 +23,7 @@ import ( "sync" "time" + "github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-systemd/daemon" embeddedpostgres "github.com/fergusstrange/embedded-postgres" "github.com/google/go-github/v43/github" @@ -84,6 +85,12 @@ func server() *cobra.Command { oauth2GithubAllowedOrganizations []string oauth2GithubAllowedTeams []string oauth2GithubAllowSignups bool + oidcAllowSignups bool + oidcClientID string + oidcClientSecret string + oidcEmailDomain string + oidcIssuerURL string + oidcScopes []string telemetryEnable bool telemetryURL string tlsCertFile string @@ -282,6 +289,38 @@ func server() *cobra.Command { } } + if oidcClientSecret != "" { + if oidcClientID == "" { + return xerrors.Errorf("OIDC client ID be set!") + } + if oidcIssuerURL == "" { + return xerrors.Errorf("OIDC issuer URL must be set!") + } + + oidcProvider, err := oidc.NewProvider(ctx, oidcIssuerURL) + if err != nil { + return xerrors.Errorf("configure oidc provider: %w", err) + } + redirectURL, err := accessURLParsed.Parse("/api/v2/users/oidc/callback") + if err != nil { + return xerrors.Errorf("parse oidc oauth callback url: %w", err) + } + options.OIDCConfig = &coderd.OIDCConfig{ + OAuth2Config: &oauth2.Config{ + ClientID: oidcClientID, + ClientSecret: oidcClientSecret, + RedirectURL: redirectURL.String(), + Endpoint: oidcProvider.Endpoint(), + Scopes: oidcScopes, + }, + Verifier: oidcProvider.Verifier(&oidc.Config{ + ClientID: oidcClientID, + }), + EmailDomain: oidcEmailDomain, + AllowSignups: oidcAllowSignups, + } + } + if inMemoryDatabase { options.Database = databasefake.New() options.Pubsub = database.NewPubsubInMemory() @@ -340,6 +379,8 @@ func server() *cobra.Command { Logger: logger.Named("telemetry"), URL: telemetryURL, GitHubOAuth: oauth2GithubClientID != "", + OIDCAuth: oidcClientID != "", + OIDCIssuerURL: oidcIssuerURL, Prometheus: promEnabled, STUN: len(stunServers) != 0, Tunnel: tunnel, @@ -636,6 +677,18 @@ func server() *cobra.Command { "Specifies teams inside organizations the user must be a member of to authenticate with GitHub. Formatted as: /.") cliflag.BoolVarP(root.Flags(), &oauth2GithubAllowSignups, "oauth2-github-allow-signups", "", "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS", false, "Specifies whether new users can sign up with GitHub.") + cliflag.BoolVarP(root.Flags(), &oidcAllowSignups, "oidc-allow-signups", "", "CODER_OIDC_ALLOW_SIGNUPS", true, + "Specifies whether new users can sign up with OIDC.") + cliflag.StringVarP(root.Flags(), &oidcClientID, "oidc-client-id", "", "CODER_OIDC_CLIENT_ID", "", + "Specifies a client ID to use for OIDC.") + cliflag.StringVarP(root.Flags(), &oidcClientSecret, "oidc-client-secret", "", "CODER_OIDC_CLIENT_SECRET", "", + "Specifies a client secret to use for OIDC.") + cliflag.StringVarP(root.Flags(), &oidcEmailDomain, "oidc-email-domain", "", "CODER_OIDC_EMAIL_DOMAIN", "", + "Specifies an email domain that clients authenticating with OIDC must match.") + cliflag.StringVarP(root.Flags(), &oidcIssuerURL, "oidc-issuer-url", "", "CODER_OIDC_ISSUER_URL", "", + "Specifies an issuer URL to use for OIDC.") + cliflag.StringArrayVarP(root.Flags(), &oidcScopes, "oidc-scopes", "", "CODER_OIDC_SCOPES", []string{oidc.ScopeOpenID, "profile", "email"}, + "Specifies scopes to grant when authenticating with OIDC.") enableTelemetryByDefault := !isTest() cliflag.BoolVarP(root.Flags(), &telemetryEnable, "telemetry", "", "CODER_TELEMETRY", enableTelemetryByDefault, "Specifies whether telemetry is enabled or not. Coder collects anonymized usage data to help improve our product.") cliflag.StringVarP(root.Flags(), &telemetryURL, "telemetry-url", "", "CODER_TELEMETRY_URL", "https://telemetry.coder.com", "Specifies a URL to send telemetry to.") diff --git a/coderd/coderd.go b/coderd/coderd.go index 4572beed5057f..fdf0ee86a682d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -57,6 +57,7 @@ type Options struct { AzureCertificates x509.VerifyOptions GoogleTokenValidator *idtoken.Validator GithubOAuth2Config *GithubOAuth2Config + OIDCConfig *OIDCConfig ICEServers []webrtc.ICEServer SecureAuthCookie bool SSHKeygenAlgorithm gitsshkey.Algorithm @@ -105,6 +106,7 @@ func New(options *Options) *API { api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgent, 0) oauthConfigs := &httpmw.OAuth2Configs{ Github: options.GithubOAuth2Config, + OIDC: options.OIDCConfig, } apiKeyMiddleware := httpmw.ExtractAPIKey(options.Database, oauthConfigs, false) @@ -259,6 +261,10 @@ func New(options *Options) *API { r.Get("/callback", api.userOAuth2Github) }) }) + r.Route("/oidc/callback", func(r chi.Router) { + r.Use(httpmw.ExtractOAuth2(options.OIDCConfig)) + r.Get("/", api.userOIDC) + }) r.Group(func(r chi.Router) { r.Use( apiKeyMiddleware, diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index 7eb9167caa05a..c457f729f8676 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -248,6 +248,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { // Has it's own auth "GET:/api/v2/users/oauth2/github/callback": {NoAuthorize: true}, + "GET:/api/v2/users/oidc/callback": {NoAuthorize: true}, // All workspaceagents endpoints do not use rbac "POST:/api/v2/workspaceagents/aws-instance-identity": {NoAuthorize: true}, diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index b392aafffd5b4..c16f6f860dd11 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -63,6 +63,7 @@ type Options struct { Authorizer rbac.Authorizer AzureCertificates x509.VerifyOptions GithubOAuth2Config *coderd.GithubOAuth2Config + OIDCConfig *coderd.OIDCConfig GoogleTokenValidator *idtoken.Validator SSHKeygenAlgorithm gitsshkey.Algorithm APIRateLimit int @@ -189,6 +190,7 @@ func newWithCloser(t *testing.T, options *Options) (*codersdk.Client, io.Closer) AWSCertificates: options.AWSCertificates, AzureCertificates: options.AzureCertificates, GithubOAuth2Config: options.GithubOAuth2Config, + OIDCConfig: options.OIDCConfig, GoogleTokenValidator: options.GoogleTokenValidator, SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, TURNServer: turnServer, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 3479bb1bdc1e7..b288894c6751e 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -27,7 +27,8 @@ CREATE TYPE log_source AS ENUM ( CREATE TYPE login_type AS ENUM ( 'password', - 'github' + 'github', + 'oidc' ); CREATE TYPE parameter_destination_scheme AS ENUM ( diff --git a/coderd/database/dump/main.go b/coderd/database/dump/main.go index 802fcedc38b2e..20c4ac0c2e30a 100644 --- a/coderd/database/dump/main.go +++ b/coderd/database/dump/main.go @@ -31,6 +31,11 @@ func main() { } cmd := exec.Command( + "docker", + "run", + "--rm", + "--network=host", + "postgres:13", "pg_dump", "--schema-only", connection, diff --git a/coderd/database/migrations/000032_oidc.down.sql b/coderd/database/migrations/000032_oidc.down.sql new file mode 100644 index 0000000000000..285044163baa0 --- /dev/null +++ b/coderd/database/migrations/000032_oidc.down.sql @@ -0,0 +1,7 @@ +CREATE TYPE old_login_type AS ENUM ( + 'password', + 'github' +); +ALTER TABLE api_keys ALTER COLUMN login_type TYPE old_login_type USING (login_type::text::old_login_type); +DROP TYPE login_type; +ALTER TYPE old_login_type RENAME TO login_type; diff --git a/coderd/database/migrations/000032_oidc.up.sql b/coderd/database/migrations/000032_oidc.up.sql new file mode 100644 index 0000000000000..29d1022f56ef5 --- /dev/null +++ b/coderd/database/migrations/000032_oidc.up.sql @@ -0,0 +1,8 @@ +CREATE TYPE new_login_type AS ENUM ( + 'password', + 'github', + 'oidc' +); +ALTER TABLE api_keys ALTER COLUMN login_type TYPE new_login_type USING (login_type::text::new_login_type); +DROP TYPE login_type; +ALTER TYPE new_login_type RENAME TO login_type; diff --git a/coderd/database/models.go b/coderd/database/models.go index a7b793f33e7cf..9ea7faf7293fa 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -101,6 +101,7 @@ type LoginType string const ( LoginTypePassword LoginType = "password" LoginTypeGithub LoginType = "github" + LoginTypeOIDC LoginType = "oidc" ) func (e *LoginType) Scan(src interface{}) error { diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index 2bfe281c2c760..03a876c7b6374 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "reflect" - "regexp" "strings" "github.com/go-playground/validator/v10" @@ -16,8 +15,7 @@ import ( ) var ( - validate *validator.Validate - usernameRegex = regexp.MustCompile("^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$") + validate *validator.Validate ) // This init is used to create a validator and register validation-specific @@ -39,13 +37,7 @@ func init() { if !ok { return false } - if len(str) > 32 { - return false - } - if len(str) < 1 { - return false - } - return usernameRegex.MatchString(str) + return UsernameValid(str) }) if err != nil { panic(err) diff --git a/coderd/httpapi/httpapi_test.go b/coderd/httpapi/httpapi_test.go index 75f5878b5cbf8..35ed403ba48da 100644 --- a/coderd/httpapi/httpapi_test.go +++ b/coderd/httpapi/httpapi_test.go @@ -81,71 +81,6 @@ func TestRead(t *testing.T) { }) } -func TestReadUsername(t *testing.T) { - t.Parallel() - // Tests whether usernames are valid or not. - testCases := []struct { - Username string - Valid bool - }{ - {"1", true}, - {"12", true}, - {"123", true}, - {"12345678901234567890", true}, - {"123456789012345678901", true}, - {"a", true}, - {"a1", true}, - {"a1b2", true}, - {"a1b2c3d4e5f6g7h8i9j0", true}, - {"a1b2c3d4e5f6g7h8i9j0k", true}, - {"aa", true}, - {"abc", true}, - {"abcdefghijklmnopqrst", true}, - {"abcdefghijklmnopqrstu", true}, - {"wow-test", true}, - - {"", false}, - {" ", false}, - {" a", false}, - {" a ", false}, - {" 1", false}, - {"1 ", false}, - {" aa", false}, - {"aa ", false}, - {" 12", false}, - {"12 ", false}, - {" a1", false}, - {"a1 ", false}, - {" abcdefghijklmnopqrstu", false}, - {"abcdefghijklmnopqrstu ", false}, - {" 123456789012345678901", false}, - {" a1b2c3d4e5f6g7h8i9j0k", false}, - {"a1b2c3d4e5f6g7h8i9j0k ", false}, - {"bananas_wow", false}, - {"test--now", false}, - - {"123456789012345678901234567890123", false}, - {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false}, - {"123456789012345678901234567890123123456789012345678901234567890123", false}, - } - type toValidate struct { - Username string `json:"username" validate:"username"` - } - for _, testCase := range testCases { - testCase := testCase - t.Run(testCase.Username, func(t *testing.T) { - t.Parallel() - rw := httptest.NewRecorder() - data, err := json.Marshal(toValidate{testCase.Username}) - require.NoError(t, err) - r := httptest.NewRequest("POST", "/", bytes.NewBuffer(data)) - - var validate toValidate - require.Equal(t, testCase.Valid, httpapi.Read(rw, r, &validate)) - }) - } -} - func WebsocketCloseMsg(t *testing.T) { t.Parallel() diff --git a/coderd/httpapi/username.go b/coderd/httpapi/username.go new file mode 100644 index 0000000000000..f6e5fcc28dca3 --- /dev/null +++ b/coderd/httpapi/username.go @@ -0,0 +1,45 @@ +package httpapi + +import ( + "regexp" + "strings" + + "github.com/moby/moby/pkg/namesgenerator" +) + +var ( + usernameValid = regexp.MustCompile("^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$") + usernameReplace = regexp.MustCompile("[^a-zA-Z0-9-]*") +) + +// UsernameValid returns whether the input string is a valid username. +func UsernameValid(str string) bool { + if len(str) > 32 { + return false + } + if len(str) < 1 { + return false + } + return usernameValid.MatchString(str) +} + +// UsernameFrom returns a best-effort username from the provided string. +// +// It first attempts to validate the incoming string, which will +// be returned if it is valid. It then will attempt to extract +// the username from an email address. If no success happens during +// these steps, a random username will be returned. +func UsernameFrom(str string) string { + if UsernameValid(str) { + return str + } + emailAt := strings.LastIndex(str, "@") + if emailAt >= 0 { + str = str[:emailAt] + } + str = usernameReplace.ReplaceAllString(str, "") + if UsernameValid(str) { + return str + } + return strings.ReplaceAll(namesgenerator.GetRandomName(1), "_", "-") +} diff --git a/coderd/httpapi/username_test.go b/coderd/httpapi/username_test.go new file mode 100644 index 0000000000000..fa6c0e1434233 --- /dev/null +++ b/coderd/httpapi/username_test.go @@ -0,0 +1,102 @@ +package httpapi_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/httpapi" +) + +func TestValid(t *testing.T) { + t.Parallel() + // Tests whether usernames are valid or not. + testCases := []struct { + Username string + Valid bool + }{ + {"1", true}, + {"12", true}, + {"123", true}, + {"12345678901234567890", true}, + {"123456789012345678901", true}, + {"a", true}, + {"a1", true}, + {"a1b2", true}, + {"a1b2c3d4e5f6g7h8i9j0", true}, + {"a1b2c3d4e5f6g7h8i9j0k", true}, + {"aa", true}, + {"abc", true}, + {"abcdefghijklmnopqrst", true}, + {"abcdefghijklmnopqrstu", true}, + {"wow-test", true}, + + {"", false}, + {" ", false}, + {" a", false}, + {" a ", false}, + {" 1", false}, + {"1 ", false}, + {" aa", false}, + {"aa ", false}, + {" 12", false}, + {"12 ", false}, + {" a1", false}, + {"a1 ", false}, + {" abcdefghijklmnopqrstu", false}, + {"abcdefghijklmnopqrstu ", false}, + {" 123456789012345678901", false}, + {" a1b2c3d4e5f6g7h8i9j0k", false}, + {"a1b2c3d4e5f6g7h8i9j0k ", false}, + {"bananas_wow", false}, + {"test--now", false}, + + {"123456789012345678901234567890123", false}, + {"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false}, + {"123456789012345678901234567890123123456789012345678901234567890123", false}, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.Username, func(t *testing.T) { + t.Parallel() + require.Equal(t, testCase.Valid, httpapi.UsernameValid(testCase.Username)) + }) + } +} + +func TestFrom(t *testing.T) { + t.Parallel() + testCases := []struct { + From string + Match string + }{ + {"1", "1"}, + {"kyle@kwc.io", "kyle"}, + {"kyle+wow@kwc.io", "kylewow"}, + {"kyle+testing", "kyletesting"}, + {"kyle-testing", "kyle-testing"}, + {"much.”more unusual”@example.com", "muchmoreunusual"}, + + // Cases where an invalid string is provided, and the result is a random name. + {"123456789012345678901234567890123", ""}, + {"very.unusual.”@”.unusual.com@example.com", ""}, + {"___@ok.com", ""}, + {" something with spaces ", ""}, + {"--test--", ""}, + {"", ""}, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.From, func(t *testing.T) { + t.Parallel() + converted := httpapi.UsernameFrom(testCase.From) + t.Log(converted) + require.True(t, httpapi.UsernameValid(converted)) + if testCase.Match == "" { + require.NotEqual(t, testCase.From, converted) + } else { + require.Equal(t, testCase.Match, converted) + } + }) + } +} diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 9ababe6cd45ef..80586bc976f49 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -49,6 +49,7 @@ func AuthorizationUserRoles(r *http.Request) database.GetAuthorizationUserRolesR // This should be extended to support other authentication types in the future. type OAuth2Configs struct { Github OAuth2Config + OIDC OAuth2Config } const ( @@ -155,6 +156,8 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool switch key.LoginType { case database.LoginTypeGithub: oauthConfig = oauth.Github + case database.LoginTypeOIDC: + oauthConfig = oauth.OIDC default: write(http.StatusInternalServerError, codersdk.Response{ Message: internalErrorMessage, diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 9a5e7a494bbfd..c52bbec33f3c1 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -41,6 +41,8 @@ type Options struct { BuiltinPostgres bool DeploymentID string GitHubOAuth bool + OIDCAuth bool + OIDCIssuerURL string Prometheus bool STUN bool SnapshotFrequency time.Duration @@ -229,6 +231,8 @@ func (r *remoteReporter) deployment() error { BuiltinPostgres: r.options.BuiltinPostgres, Containerized: containerized, GitHubOAuth: r.options.GitHubOAuth, + OIDCAuth: r.options.OIDCAuth, + OIDCIssuerURL: r.options.OIDCIssuerURL, Prometheus: r.options.Prometheus, STUN: r.options.STUN, Tunnel: r.options.Tunnel, @@ -601,6 +605,8 @@ type Deployment struct { Containerized bool `json:"containerized"` Tunnel bool `json:"tunnel"` GitHubOAuth bool `json:"github_oauth"` + OIDCAuth bool `json:"oidc_auth"` + OIDCIssuerURL string `json:"oidc_issuer_url"` Prometheus bool `json:"prometheus"` STUN bool `json:"stun"` OSType string `json:"os_type"` diff --git a/coderd/userauth.go b/coderd/userauth.go index 50ec4e8a90dd0..0ddb3f34d7a21 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "net/http" + "strings" + "github.com/coreos/go-oidc/v3/oidc" "github.com/google/go-github/v43/github" "github.com/google/uuid" "golang.org/x/oauth2" @@ -40,6 +42,7 @@ func (api *API) userAuthMethods(rw http.ResponseWriter, _ *http.Request) { httpapi.Write(rw, http.StatusOK, codersdk.AuthMethods{ Password: true, Github: api.GithubOAuth2Config != nil, + OIDC: api.OIDCConfig != nil, }) } @@ -205,3 +208,137 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) { } http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) } + +type OIDCConfig struct { + httpmw.OAuth2Config + + Verifier *oidc.IDTokenVerifier + // EmailDomain is the domain to enforce when a user authenticates. + EmailDomain string + AllowSignups bool +} + +func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) { + state := httpmw.OAuth2(r) + + // See the example here: https://github.com/coreos/go-oidc + rawIDToken, ok := state.Token.Extra("id_token").(string) + if !ok { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "id_token not found in response payload. Ensure your OIDC callback is configured correctly!", + }) + return + } + + idToken, err := api.OIDCConfig.Verifier.Verify(r.Context(), rawIDToken) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to verify OIDC token.", + Detail: err.Error(), + }) + return + } + + var claims struct { + Email string `json:"email"` + Verified bool `json:"email_verified"` + Username string `json:"preferred_username"` + } + err = idToken.Claims(&claims) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to extract OIDC claims.", + Detail: err.Error(), + }) + return + } + if claims.Email == "" { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "No email found in OIDC payload!", + }) + return + } + if !claims.Verified { + httpapi.Write(rw, http.StatusForbidden, codersdk.Response{ + Message: fmt.Sprintf("Verify the %q email address on your OIDC provider to authenticate!", claims.Email), + }) + return + } + // The username is a required property in Coder. We make a best-effort + // attempt at using what the claims provide, but if that fails we will + // generate a random username. + if !httpapi.UsernameValid(claims.Username) { + // If no username is provided, we can default to use the email address. + // This will be converted in the from function below, so it's safe + // to keep the domain. + if claims.Username == "" { + claims.Username = claims.Email + } + claims.Username = httpapi.UsernameFrom(claims.Username) + } + if api.OIDCConfig.EmailDomain != "" { + if !strings.HasSuffix(claims.Email, api.OIDCConfig.EmailDomain) { + httpapi.Write(rw, http.StatusForbidden, codersdk.Response{ + Message: fmt.Sprintf("Your email %q is not a part of the %q domain!", claims.Email, api.OIDCConfig.EmailDomain), + }) + return + } + } + + var user database.User + user, err = api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ + Email: claims.Email, + }) + if errors.Is(err, sql.ErrNoRows) { + if !api.OIDCConfig.AllowSignups { + httpapi.Write(rw, http.StatusForbidden, codersdk.Response{ + Message: "Signups are disabled for OIDC authentication!", + }) + return + } + var organizationID uuid.UUID + organizations, _ := api.Database.GetOrganizations(r.Context()) + if len(organizations) > 0 { + // Add the user to the first organization. Once multi-organization + // support is added, we should enable a configuration map of user + // email to organization. + organizationID = organizations[0].ID + } + user, _, err = api.createUser(r.Context(), codersdk.CreateUserRequest{ + Email: claims.Email, + Username: claims.Username, + OrganizationID: organizationID, + }) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error creating user.", + Detail: err.Error(), + }) + return + } + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get user by email.", + Detail: err.Error(), + }) + return + } + + _, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + OAuthAccessToken: state.Token.AccessToken, + OAuthRefreshToken: state.Token.RefreshToken, + OAuthExpiry: state.Token.Expiry, + }) + if !created { + return + } + + redirect := state.Redirect + if redirect == "" { + redirect = "/" + } + http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect) +} diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index 358846f047691..a07ed2bd18038 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -2,11 +2,19 @@ package coderd_test import ( "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "io" "net/http" "net/url" "testing" + "time" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/golang-jwt/jwt" "github.com/google/go-github/v43/github" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" "golang.org/x/xerrors" @@ -16,13 +24,18 @@ import ( "github.com/coder/coder/codersdk" ) -type oauth2Config struct{} +type oauth2Config struct { + token *oauth2.Token +} func (*oauth2Config) AuthCodeURL(state string, _ ...oauth2.AuthCodeOption) string { return "/?state=" + url.QueryEscape(state) } -func (*oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) { +func (o *oauth2Config) Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + if o.token != nil { + return o.token, nil + } return &oauth2.Token{ AccessToken: "token", }, nil @@ -249,6 +262,169 @@ func TestUserOAuth2Github(t *testing.T) { }) } +func TestUserOIDC(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + Name string + Claims jwt.MapClaims + AllowSignups bool + EmailDomain string + Username string + StatusCode int + }{{ + Name: "EmailNotVerified", + Claims: jwt.MapClaims{ + "email": "kyle@kwc.io", + }, + AllowSignups: true, + StatusCode: http.StatusForbidden, + }, { + Name: "NotInRequiredEmailDomain", + Claims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + }, + AllowSignups: true, + EmailDomain: "coder.com", + StatusCode: http.StatusForbidden, + }, { + Name: "EmptyClaims", + Claims: jwt.MapClaims{}, + AllowSignups: true, + StatusCode: http.StatusBadRequest, + }, { + Name: "NoSignups", + Claims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + }, + StatusCode: http.StatusForbidden, + }, { + Name: "UsernameFromEmail", + Claims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + }, + Username: "kyle", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }, { + Name: "UsernameFromClaims", + Claims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + "preferred_username": "hotdog", + }, + Username: "hotdog", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }, { + // Services like Okta return the email as the username: + // https://developer.okta.com/docs/reference/api/oidc/#base-claims-always-present + Name: "UsernameAsEmail", + Claims: jwt.MapClaims{ + "email": "kyle@kwc.io", + "email_verified": true, + "preferred_username": "kyle@kwc.io", + }, + Username: "kyle", + AllowSignups: true, + StatusCode: http.StatusTemporaryRedirect, + }} { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + config := createOIDCConfig(t, tc.Claims) + config.AllowSignups = tc.AllowSignups + config.EmailDomain = tc.EmailDomain + client := coderdtest.New(t, &coderdtest.Options{ + OIDCConfig: config, + }) + resp := oidcCallback(t, client) + assert.Equal(t, tc.StatusCode, resp.StatusCode) + + if tc.Username != "" { + client.SessionToken = resp.Cookies()[0].Value + user, err := client.User(context.Background(), "me") + require.NoError(t, err) + require.Equal(t, tc.Username, user.Username) + } + }) + } + + t.Run("Disabled", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + resp := oidcCallback(t, client) + require.Equal(t, http.StatusPreconditionRequired, resp.StatusCode) + }) + + t.Run("NoIDToken", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + OIDCConfig: &coderd.OIDCConfig{ + OAuth2Config: &oauth2Config{}, + }, + }) + resp := oidcCallback(t, client) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("BadVerify", func(t *testing.T) { + t.Parallel() + verifier := oidc.NewVerifier("", &oidc.StaticKeySet{ + PublicKeys: []crypto.PublicKey{}, + }, &oidc.Config{}) + + client := coderdtest.New(t, &coderdtest.Options{ + OIDCConfig: &coderd.OIDCConfig{ + OAuth2Config: &oauth2Config{ + token: (&oauth2.Token{ + AccessToken: "token", + }).WithExtra(map[string]interface{}{ + "id_token": "invalid", + }), + }, + Verifier: verifier, + }, + }) + resp := oidcCallback(t, client) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) +} + +// createOIDCConfig generates a new OIDCConfig that returns a static token +// with the claims provided. +func createOIDCConfig(t *testing.T, claims jwt.MapClaims) *coderd.OIDCConfig { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + // https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 + claims["exp"] = time.Now().Add(time.Hour).UnixMilli() + + signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(key) + require.NoError(t, err) + + verifier := oidc.NewVerifier("", &oidc.StaticKeySet{ + PublicKeys: []crypto.PublicKey{key.Public()}, + }, &oidc.Config{ + SkipClientIDCheck: true, + }) + + return &coderd.OIDCConfig{ + OAuth2Config: &oauth2Config{ + token: (&oauth2.Token{ + AccessToken: "token", + }).WithExtra(map[string]interface{}{ + "id_token": signed, + }), + }, + Verifier: verifier, + } +} + func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response { client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse @@ -269,3 +445,26 @@ func oauth2Callback(t *testing.T, client *codersdk.Client) *http.Response { }) return res } + +func oidcCallback(t *testing.T, client *codersdk.Client) *http.Response { + t.Helper() + client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + state := "somestate" + oauthURL, err := client.URL.Parse("/api/v2/users/oidc/callback?code=asd&state=" + state) + require.NoError(t, err) + req, err := http.NewRequest("GET", oauthURL.String(), nil) + require.NoError(t, err) + req.AddCookie(&http.Cookie{ + Name: "oauth_state", + Value: state, + }) + res, err := client.HTTPClient.Do(req) + require.NoError(t, err) + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + require.NoError(t, err) + t.Log(string(data)) + return res +} diff --git a/codersdk/users.go b/codersdk/users.go index 7396cd9a97d3f..17252c20405c3 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -174,6 +174,7 @@ type CreateOrganizationRequest struct { type AuthMethods struct { Password bool `json:"password"` Github bool `json:"github"` + OIDC bool `json:"oidc"` } // HasFirstUser returns whether the first user has been created. diff --git a/docs/install/auth.md b/docs/install/auth.md new file mode 100644 index 0000000000000..76ed35b830b4a --- /dev/null +++ b/docs/install/auth.md @@ -0,0 +1,74 @@ +# Authentication + +By default, Coder is accessible via password authentication. + +The following steps explain how to set up GitHub OAuth or OpenID Connect. + +## GitHub + +### Step 1: Configure the OAuth application in GitHub + +First, [register a GitHub OAuth app](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). GitHub will ask you for the following Coder parameters: + +- **Homepage URL**: Set to your Coder domain (e.g. `https://coder.domain.com`) +- **User Authorization Callback URL**: Set to `https://coder.domain.com/api/v2/users/oauth2/github/callback` + +Note the Client ID and Client Secret generated by GitHub. You will use these +values in the next step. + +### Step 2: Configure Coder with the OAuth credentials + +Navigate to your Coder host and run the following command to start up the Coder +server: + +```console +coder server --oauth2-github-allow-signups=true --oauth2-github-allowed-orgs="your-org" --oauth2-github-client-id="8d1...e05" --oauth2-github-client-secret="57ebc9...02c24c" +``` + +Alternatively, if you are running Coder as a system service, you can achieve the +same result as the command above by adding the following environment variables +to the `/etc/coder.d/coder.env` file: + +```console +CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=true +CODER_OAUTH2_GITHUB_ALLOWED_ORGS="your-org" +CODER_OAUTH2_GITHUB_CLIENT_ID="8d1...e05" +CODER_OAUTH2_GITHUB_CLIENT_SECRET="57ebc9...02c24c" +``` + +Once complete, run `sudo service coder restart` to reboot Coder. + +## OpenID Connect with Google + +> We describe how to set up the most popular OIDC provider, Google, but any (Okta, Azure Active Directory, GitLab, Auth0, etc.) may be used. + +### Step 1: Configure the OAuth application on Google Cloud + +First, [register a Google OAuth app](https://support.google.com/cloud/answer/6158849?hl=en). Google will ask you for the following Coder parameters: + +- **Authorized JavaScript origins**: Set to your Coder domain (e.g. `https://coder.domain.com`) +- **Redirect URIs**: Set to `https://coder.domain.com/api/v2/users/oidc/callback` + +### Step 2: Configure Coder with the OpenID Connect credentials + +Navigate to your Coder host and run the following command to start up the Coder +server: + +```console +coder server --oidc-issuer-url="https://accounts.google.com" --oidc-email-domain="your-domain" --oidc-client-id="533...ent.com" --oidc-client-secret="G0CSP...7qSM" +``` + +Alternatively, if you are running Coder as a system service, you can achieve the +same result as the command above by adding the following environment variables +to the `/etc/coder.d/coder.env` file: + +```console +CODER_OIDC_ISSUER_URL="https://accounts.google.com" +CODER_OIDC_EMAIL_DOMAIN="your-domain" +CODER_OIDC_CLIENT_ID="533...ent.com" +CODER_OIDC_CLIENT_SECRET="G0CSP...7qSM" +``` + +Once complete, run `sudo service coder restart` to reboot Coder. + +> When a new user is created, the `preferred_username` claim becomes 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`). diff --git a/docs/install/oauth.md b/docs/install/oauth.md deleted file mode 100644 index c632dc88224e2..0000000000000 --- a/docs/install/oauth.md +++ /dev/null @@ -1,37 +0,0 @@ -# GitHub OAuth - -By default, Coder is accessible via built-in authentication. Alternatively, you -can configure Coder to enable logging in through GitHub OAuth. See below for -configuration steps. - -## Step 1: Configure the OAuth application in GitHub - -First, [register a GitHub OAuth app](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/). GitHub will ask you for the following Coder parameters: - -- **Homepage URL**: Set to your Coder domain (e.g. `https://coder.domain.com`) -- **User Authorization Callback URL**: Set to `https://coder.domain.com/api/v2/users/oauth2/github/callback` - -Note the Client ID and Client Secret generated by GitHub. You will use these -values in the next step. - -## Step 2: Configure Coder with the OAuth credentials - -Navigate to your Coder host and run the following command to start up the Coder -server: - -```console -coder server --oauth2-github-allow-signups=true --oauth2-github-allowed-orgs="your-org" --oauth2-github-client-id="8d1...e05" --oauth2-github-client-secret="57ebc9...02c24c" -``` - -Alternatively, if you are running Coder as a system service, you can achieve the -same result as the command above by adding the following environment variables -to the `/etc/coder.d/coder.env` file: - -```console -CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=true -CODER_OAUTH2_GITHUB_ALLOWED_ORGS="your-org" -CODER_OAUTH2_GITHUB_CLIENT_ID="8d1...e05" -CODER_OAUTH2_GITHUB_CLIENT_SECRET="57ebc9...02c24c" -``` - -Once complete, run `sudo service coder restart` to reboot Coder. diff --git a/docs/manifest.json b/docs/manifest.json index 0bebc1b8d58b4..5abffd3cb4e02 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -26,9 +26,9 @@ "path": "./install.md", "children": [ { - "title": "GitHub OAuth", - "description": "Learn how to set up OAuth using your GitHub organization.", - "path": "./install/oauth.md" + "title": "Authentication", + "description": "Learn how to set up authentication using GitHub or OpenID Connect.", + "path": "./install/auth.md" } ] }, diff --git a/go.mod b/go.mod index 15fe49fe0bb64..21fd753d45fec 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( github.com/charmbracelet/lipgloss v0.5.0 github.com/cli/safeexec v1.0.0 github.com/coder/retry v1.3.0 + github.com/coreos/go-oidc/v3 v3.2.0 github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf github.com/creack/pty v1.1.18 github.com/elastic/go-sysinfo v1.8.1 @@ -135,7 +136,10 @@ require ( tailscale.com v1.26.2 ) -require github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect +require ( + github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect + gopkg.in/square/go-jose.v2 v2.6.0 // indirect +) require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect diff --git a/go.sum b/go.sum index 8503b349c4679..5e70b7e3bde88 100644 --- a/go.sum +++ b/go.sum @@ -464,6 +464,8 @@ github.com/coreos/go-iptables v0.5.0/go.mod h1:/mVI274lEDI2ns62jHCDnCyBF9Iwsmeka github.com/coreos/go-iptables v0.6.0 h1:is9qnZMPYjLd8LYqmm/qlE+wwEgJIkTYdhV3rfZo4jk= github.com/coreos/go-iptables v0.6.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-oidc/v3 v3.2.0 h1:2eR2MGR7thBXSQ2YbODlF0fcmgtliLCfr9iX6RW11fc= +github.com/coreos/go-oidc/v3 v3.2.0/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20161114122254-48702e0da86b/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -2115,6 +2117,7 @@ golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= @@ -2778,6 +2781,8 @@ gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= +gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9dfe44998ebea..8517250504297 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -28,6 +28,7 @@ export interface AgentGitSSHKey { export interface AuthMethods { readonly password: boolean readonly github: boolean + readonly oidc: boolean } // From codersdk/workspaceagents.go diff --git a/site/src/components/SignInForm/SignInForm.stories.tsx b/site/src/components/SignInForm/SignInForm.stories.tsx index 88378c4acd23a..15a951180b259 100644 --- a/site/src/components/SignInForm/SignInForm.stories.tsx +++ b/site/src/components/SignInForm/SignInForm.stories.tsx @@ -99,5 +99,26 @@ WithGithub.args = { authMethods: { password: true, github: true, + oidc: false, + }, +} + +export const WithOIDC = Template.bind({}) +WithOIDC.args = { + ...SignedOut.args, + authMethods: { + password: true, + github: false, + oidc: true, + }, +} + +export const WithGithubAndOIDC = Template.bind({}) +WithGithubAndOIDC.args = { + ...SignedOut.args, + authMethods: { + password: true, + github: true, + oidc: true, }, } diff --git a/site/src/components/SignInForm/SignInForm.tsx b/site/src/components/SignInForm/SignInForm.tsx index 2860f6f3cedd2..4ad7c2c3d8d6b 100644 --- a/site/src/components/SignInForm/SignInForm.tsx +++ b/site/src/components/SignInForm/SignInForm.tsx @@ -1,8 +1,10 @@ +import Box from "@material-ui/core/Box" import Button from "@material-ui/core/Button" import Link from "@material-ui/core/Link" import { makeStyles } from "@material-ui/core/styles" import TextField from "@material-ui/core/TextField" import GitHubIcon from "@material-ui/icons/GitHub" +import KeyIcon from "@material-ui/icons/VpnKey" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" import { Stack } from "components/Stack/Stack" import { FormikContextType, FormikTouched, useFormik } from "formik" @@ -43,6 +45,7 @@ export const Language = { }, passwordSignIn: "Sign In", githubSignIn: "GitHub", + oidcSignIn: "OpenID Connect", } const validationSchema = Yup.object({ @@ -155,7 +158,7 @@ export const SignInForm: FC = ({ - {authMethods?.github && ( + {(authMethods?.github || authMethods?.oidc) && ( <>
@@ -163,24 +166,43 @@ export const SignInForm: FC = ({
-
- - - -
+ + + )} + + {authMethods.oidc && ( + + + + )} + )} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index efe26681ca63b..457ea4eb10cb3 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -304,6 +304,7 @@ export const MockUserAgent: Types.UserAgent = { export const MockAuthMethods: TypesGen.AuthMethods = { password: true, github: false, + oidc: false, } export const MockGitSSHKey: TypesGen.GitSSHKey = {