feat: Add OIDC authentication (#3314)

* feat: Add OIDC authentication

* Extract username into a separate package and add OIDC tests

* Add test case for invalid tokens

* Add test case for username as email

* Add OIDC to the frontend

* Improve comments from self-review

* Add authentication docs

* Add telemetry

* Update docs/install/auth.md

Co-authored-by: Ammar Bandukwala <ammar@ammar.io>

* Update docs/install/auth.md

Co-authored-by: Ammar Bandukwala <ammar@ammar.io>

* Remove username package

Co-authored-by: Ammar Bandukwala <ammar@ammar.io>
This commit is contained in:
Kyle Carberry
2022-07-31 23:05:35 -05:00
committed by GitHub
parent 8b17bf98ea
commit 3d0febdd90
28 changed files with 733 additions and 137 deletions

View File

@ -42,6 +42,7 @@
"mattn",
"mitchellh",
"moby",
"namesgenerator",
"nfpms",
"nhooyr",
"nolint",

View File

@ -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
@ -283,6 +290,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()
@ -341,6 +380,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,
@ -637,6 +678,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.")

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,11 @@ func main() {
}
cmd := exec.Command(
"docker",
"run",
"--rm",
"--network=host",
"postgres:13",
"pg_dump",
"--schema-only",
connection,

View File

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

View File

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

View File

@ -101,6 +101,7 @@ type LoginType string
const (
LoginTypePassword LoginType = "password"
LoginTypeGithub LoginType = "github"
LoginTypeOIDC LoginType = "oidc"
)
func (e *LoginType) Scan(src interface{}) error {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

74
docs/install/auth.md Normal file
View File

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

View File

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

View File

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

6
go.mod
View File

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

5
go.sum
View File

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

View File

@ -28,6 +28,7 @@ export interface AgentGitSSHKey {
export interface AuthMethods {
readonly password: boolean
readonly github: boolean
readonly oidc: boolean
}
// From codersdk/workspaceagents.go

View File

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

View File

@ -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<SignInFormProps> = ({
</div>
</Stack>
</form>
{authMethods?.github && (
{(authMethods?.github || authMethods?.oidc) && (
<>
<div className={styles.divider}>
<div className={styles.dividerLine} />
@ -163,24 +166,43 @@ export const SignInForm: FC<SignInFormProps> = ({
<div className={styles.dividerLine} />
</div>
<div>
<Link
underline="none"
href={`/api/v2/users/oauth2/github/callback?redirect=${encodeURIComponent(
redirectTo,
)}`}
>
<Button
startIcon={<GitHubIcon className={styles.buttonIcon} />}
disabled={isLoading}
fullWidth
type="submit"
variant="contained"
<Box display="grid" gridGap="16px">
{authMethods.github && (
<Link
underline="none"
href={`/api/v2/users/oauth2/github/callback?redirect=${encodeURIComponent(
redirectTo,
)}`}
>
{Language.githubSignIn}
</Button>
</Link>
</div>
<Button
startIcon={<GitHubIcon className={styles.buttonIcon} />}
disabled={isLoading}
fullWidth
type="submit"
variant="contained"
>
{Language.githubSignIn}
</Button>
</Link>
)}
{authMethods.oidc && (
<Link
underline="none"
href={`/api/v2/users/oidc/callback?redirect=${encodeURIComponent(redirectTo)}`}
>
<Button
startIcon={<KeyIcon className={styles.buttonIcon} />}
disabled={isLoading}
fullWidth
type="submit"
variant="contained"
>
{Language.oidcSignIn}
</Button>
</Link>
)}
</Box>
</>
)}
</>

View File

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