Skip to content

feat: Add OIDC authentication #3314

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Aug 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"mattn",
"mitchellh",
"moby",
"namesgenerator",
"nfpms",
"nhooyr",
"nolint",
Expand Down
53 changes: 53 additions & 0 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: <organization-name>/<team-slug>.")
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.")
Expand Down
6 changes: 6 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions coderd/coderd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
2 changes: 2 additions & 0 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion coderd/database/dump.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions coderd/database/dump/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ func main() {
}

cmd := exec.Command(
"docker",
"run",
"--rm",
"--network=host",
"postgres:13",
Comment on lines +34 to +38
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bit out of scope, but makes everything use Docker, which makes changing our versions simpler!

"pg_dump",
"--schema-only",
connection,
Expand Down
7 changes: 7 additions & 0 deletions coderd/database/migrations/000032_oidc.down.sql
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions coderd/database/migrations/000032_oidc.up.sql
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions coderd/database/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 2 additions & 10 deletions coderd/httpapi/httpapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"fmt"
"net/http"
"reflect"
"regexp"
"strings"

"github.com/go-playground/validator/v10"
Expand All @@ -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
Expand All @@ -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)
Expand Down
65 changes: 0 additions & 65 deletions coderd/httpapi/httpapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,71 +81,6 @@ func TestRead(t *testing.T) {
})
}

func TestReadUsername(t *testing.T) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These have been moved to the username package!

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

Expand Down
45 changes: 45 additions & 0 deletions coderd/httpapi/username.go
Original file line number Diff line number Diff line change
@@ -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), "_", "-")
}
Loading