mirror of
https://github.com/coder/coder.git
synced 2025-03-16 23:40:29 +00:00
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:
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -42,6 +42,7 @@
|
||||
"mattn",
|
||||
"mitchellh",
|
||||
"moby",
|
||||
"namesgenerator",
|
||||
"nfpms",
|
||||
"nhooyr",
|
||||
"nolint",
|
||||
|
@ -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.")
|
||||
|
@ -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,
|
||||
|
@ -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},
|
||||
|
@ -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,
|
||||
|
3
coderd/database/dump.sql
generated
3
coderd/database/dump.sql
generated
@ -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 (
|
||||
|
@ -31,6 +31,11 @@ func main() {
|
||||
}
|
||||
|
||||
cmd := exec.Command(
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--network=host",
|
||||
"postgres:13",
|
||||
"pg_dump",
|
||||
"--schema-only",
|
||||
connection,
|
||||
|
7
coderd/database/migrations/000032_oidc.down.sql
Normal file
7
coderd/database/migrations/000032_oidc.down.sql
Normal 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;
|
8
coderd/database/migrations/000032_oidc.up.sql
Normal file
8
coderd/database/migrations/000032_oidc.up.sql
Normal 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;
|
@ -101,6 +101,7 @@ type LoginType string
|
||||
const (
|
||||
LoginTypePassword LoginType = "password"
|
||||
LoginTypeGithub LoginType = "github"
|
||||
LoginTypeOIDC LoginType = "oidc"
|
||||
)
|
||||
|
||||
func (e *LoginType) Scan(src interface{}) error {
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
||||
|
45
coderd/httpapi/username.go
Normal file
45
coderd/httpapi/username.go
Normal 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), "_", "-")
|
||||
}
|
102
coderd/httpapi/username_test.go
Normal file
102
coderd/httpapi/username_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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"`
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
74
docs/install/auth.md
Normal 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`).
|
@ -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.
|
@ -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
6
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
|
||||
|
5
go.sum
5
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=
|
||||
|
@ -28,6 +28,7 @@ export interface AgentGitSSHKey {
|
||||
export interface AuthMethods {
|
||||
readonly password: boolean
|
||||
readonly github: boolean
|
||||
readonly oidc: boolean
|
||||
}
|
||||
|
||||
// From codersdk/workspaceagents.go
|
||||
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
@ -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 = {
|
||||
|
Reference in New Issue
Block a user